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 * Abstraction du modèle de vision (§181) + fournisseur Claude Sonnet 5 (§94,4 * 134-136, 180-182). Sortie structurée via un outil imposé par `tool_choice`5 * (schéma JSON complet ; `strict` optionnel — voir note dans analyze), streaming6 * + `finalMessage()`, prompt système versionné et mis en cache,7 * validation/réparation maison + 1 relance.8 * Aucune chaîne de pensée n'est conservée.9 */10import Anthropic from "@anthropic-ai/sdk";11import fs from "fs";12import path from "path";13import { buildModelSchema, MAX_MISSING_SECTIONS, missingTopLevelSections, PROMPT_VERSION, schemaForApi, validateAndRepair, type ModelOutput, type ValidationIssue } from "./schema";14import type { ImageMediaType } from "./images";1516export const DEFAULT_MODEL = "claude-sonnet-5";17export const TOOL_NAME = "submit_property_analysis";18/** Tarif Sonnet 5 (USD / M jetons) — pour l'estimation de coût API (admin). */19export const PRICING_USD = { input: 2.0, output: 10.0, cacheRead: 0.2, cacheWrite: 2.5 };2021export interface AnalyzeImage { id: string; mediaType: ImageMediaType; data: Buffer; roomHint: string; position: number }22export interface AnalyzeInput {23 listingContext: string; // texte déterministe : champs structurés + description + MAMH24 images: AnalyzeImage[];25 assemblyCodes: string[];26 assemblyCatalogueText: string; // codes + libellés + unités27}28export interface AnalyzeUsage { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; estimatedCostUsd: number; latencyMs: number; attempts: number }29export interface AnalyzeResult { output: ModelOutput; usage: AnalyzeUsage; model: string; promptVersion: string; issues: ValidationIssue[] }3031export interface PropertyVisionModel {32 readonly model: string;33 analyze(input: AnalyzeInput): Promise<AnalyzeResult>;34}3536let promptCache: string | null = null;37export function loadSystemPrompt(): string {38 if (promptCache) return promptCache;39 const p = path.join(process.cwd(), "prompts", `property-cost-analysis-${PROMPT_VERSION}.md`);40 promptCache = fs.readFileSync(p, "utf8");41 return promptCache;42}4344export function estimateCostUsd(u: { input: number; output: number; cacheRead: number; cacheWrite: number }): number {45 return (u.input * PRICING_USD.input + u.output * PRICING_USD.output + u.cacheRead * PRICING_USD.cacheRead + u.cacheWrite * PRICING_USD.cacheWrite) / 1_000_000;46}4748export class ClaudeSonnet5Provider implements PropertyVisionModel {49 readonly model: string;50 private client: Anthropic;51 constructor(model = process.env.PROPERTY_ANALYSIS_MODEL ?? DEFAULT_MODEL, client?: Anthropic) {52 this.model = model;53 this.client = client ?? new Anthropic();54 }5556 async analyze(input: AnalyzeInput): Promise<AnalyzeResult> {57 const schema = buildModelSchema(input.assemblyCodes);58 const system: Anthropic.TextBlockParam[] = [59 { type: "text", text: loadSystemPrompt(), cache_control: { type: "ephemeral" } },60 { type: "text", text: `## Assembly catalogue (only these codes are valid)\n${input.assemblyCatalogueText}`, cache_control: { type: "ephemeral" } },61 ];62 const manifest = input.images.map((i) => `${i.id}: position ${i.position}, room_hint=${i.roomHint}`).join("\n");63 const content: Anthropic.ContentBlockParam[] = [];64 for (const img of input.images) {65 content.push({ type: "text", text: `[${img.id}]` });66 content.push({ type: "image", source: { type: "base64", media_type: img.mediaType, data: img.data.toString("base64") } });67 }68 content.push({ type: "text", text: `## Image manifest\n${manifest}\n\n## Listing context\n${input.listingContext}\n\nExtract the building profile now by calling the ${TOOL_NAME} tool. Fill every field; use null when not supported by evidence.` });69 // Mode strict de l'API : limité à 16 paramètres nullables/union — notre schéma en compte ~150 (chaque70 // inférence est nullable par conception, §94). On force donc l'outil (tool_choice) sans `strict`, et la71 // conformité est garantie par le validateur/réparateur local + une relance. STRICT=1 réactive le mode strict.72 const strict = process.env.PROPERTY_ANALYSIS_STRICT === "1";73 const tools: Anthropic.Tool[] = [{ name: TOOL_NAME, description: "Structured technical profile of the building for the cost engine (no monetary values). Every field is required; use null when evidence is missing.", ...(strict ? { strict: true } : {}), input_schema: schemaForApi(schema) as Anthropic.Tool.InputSchema }];74 const messages: Anthropic.MessageParam[] = [{ role: "user", content }];7576 const t0 = Date.now();77 let inTok = 0, outTok = 0, cacheRead = 0, cacheWrite = 0;78 let attempts = 0;79 let lastIssues: ValidationIssue[] = [];80 let output: ModelOutput | null = null;81 // tool_choice : `auto` au 1er essai — un outil FORCÉ (`type:"tool"`) sans réflexion fait parfois82 // répondre Sonnet 5 par un placeholder ({"property_analysis":{"placeholder":true}}, 44 jetons) sur ce83 // schéma de ~150 champs (constaté 2026-09-08 sur une annonce Facebook Marketplace ; la même requête en84 // `auto` produit 21 Ko d'analyse). `any` en relance : le modèle doit appeler un outil, avec le retour d'erreur.85 const MAX_ATTEMPTS = 3;86 while (attempts < MAX_ATTEMPTS && !output) {87 attempts++;88 const stream = this.client.messages.stream({89 model: this.model, max_tokens: 24000, system, messages, tools, tool_choice: attempts === 1 ? { type: "auto" } : { type: "any" }, thinking: { type: "disabled" },90 });91 const msg = await stream.finalMessage();92 inTok += msg.usage.input_tokens; outTok += msg.usage.output_tokens;93 cacheRead += msg.usage.cache_read_input_tokens ?? 0; cacheWrite += msg.usage.cache_creation_input_tokens ?? 0;94 if (msg.stop_reason === "refusal") throw new Error("Le modèle a refusé l'analyse (stop_reason=refusal).");95 const tu = msg.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use" && b.name === TOOL_NAME);96 if (!tu) {97 lastIssues = [{ path: "$", message: `aucun appel d'outil (stop_reason=${msg.stop_reason})`, repaired: false }];98 } else {99 const { value, issues } = validateAndRepair(tu.input, schema);100 const hard = issues.filter((i) => !i.repaired);101 const missing = missingTopLevelSections(issues);102 lastIssues = missing.length > MAX_MISSING_SECTIONS103 ? [{ path: "$", message: `sortie vide ou placeholder — ${missing.length} sections absentes (${missing.slice(0, 6).join(", ")}…)`, repaired: false }, ...issues]104 : issues;105 if (!hard.length && missing.length <= MAX_MISSING_SECTIONS) { output = value as ModelOutput; break; }106 }107 if (attempts >= MAX_ATTEMPTS) break;108 // relance avec les erreurs de validation (la sortie fautive reste dans l'historique du tour)109 messages.push({ role: "assistant", content: msg.content.map((b) => (b.type === "tool_use" ? { type: "tool_use" as const, id: b.id, name: b.name, input: b.input } : b.type === "text" ? { type: "text" as const, text: b.text } : { type: "text" as const, text: "" })).filter((b) => b.type !== "text" || b.text) });110 const toolId = msg.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")?.id;111 const feedback = `Schema validation failed:\n${lastIssues.slice(0, 40).map((i) => `${i.path}: ${i.message}`).join("\n")}\nThis was not an analysis. Look at every photo and the listing context, then call ${TOOL_NAME} again with the COMPLETE input: all top-level sections (property, geometry, construction, exterior, interior, kitchens, bathrooms, mechanical, electrical, plumbing, basement, garage, exterior_improvements, quality, condition, estimated_effective_age, renovations, estimated_quantities, assemblies, uncertainties, privacy) filled, null only where evidence is missing. Never return a placeholder.`;112 messages.push({ role: "user", content: toolId ? [{ type: "tool_result", tool_use_id: toolId, is_error: true, content: feedback }] : feedback });113 }114 if (!output) throw new Error(`Sortie du modèle invalide après ${attempts} tentative(s) : ${lastIssues.slice(0, 5).map((i) => `${i.path} ${i.message}`).join(" ; ")}`);115 return {116 output, model: this.model, promptVersion: PROMPT_VERSION, issues: lastIssues,117 usage: { inputTokens: inTok, outputTokens: outTok, cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite, estimatedCostUsd: estimateCostUsd({ input: inTok, output: outTok, cacheRead, cacheWrite }), latencyMs: Date.now() - t0, attempts },118 };119 }120}121