// Auteur : Simon-Pierre Boucher — contact@spboucher.ai /** * Abstraction du modèle de vision (§181) + fournisseur Claude Sonnet 5 (§94, * 134-136, 180-182). Sortie structurée via un outil imposé par `tool_choice` * (schéma JSON complet ; `strict` optionnel — voir note dans analyze), streaming * + `finalMessage()`, prompt système versionné et mis en cache, * validation/réparation maison + 1 relance. * Aucune chaîne de pensée n'est conservée. */ import Anthropic from "@anthropic-ai/sdk"; import fs from "fs"; import path from "path"; import { buildModelSchema, MAX_MISSING_SECTIONS, missingTopLevelSections, PROMPT_VERSION, schemaForApi, validateAndRepair, type ModelOutput, type ValidationIssue } from "./schema"; import type { ImageMediaType } from "./images"; export const DEFAULT_MODEL = "claude-sonnet-5"; export const TOOL_NAME = "submit_property_analysis"; /** Tarif Sonnet 5 (USD / M jetons) — pour l'estimation de coût API (admin). */ export const PRICING_USD = { input: 2.0, output: 10.0, cacheRead: 0.2, cacheWrite: 2.5 }; export interface AnalyzeImage { id: string; mediaType: ImageMediaType; data: Buffer; roomHint: string; position: number } export interface AnalyzeInput { listingContext: string; // texte déterministe : champs structurés + description + MAMH images: AnalyzeImage[]; assemblyCodes: string[]; assemblyCatalogueText: string; // codes + libellés + unités } export interface AnalyzeUsage { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number; estimatedCostUsd: number; latencyMs: number; attempts: number } export interface AnalyzeResult { output: ModelOutput; usage: AnalyzeUsage; model: string; promptVersion: string; issues: ValidationIssue[] } export interface PropertyVisionModel { readonly model: string; analyze(input: AnalyzeInput): Promise; } let promptCache: string | null = null; export function loadSystemPrompt(): string { if (promptCache) return promptCache; const p = path.join(process.cwd(), "prompts", `property-cost-analysis-${PROMPT_VERSION}.md`); promptCache = fs.readFileSync(p, "utf8"); return promptCache; } export function estimateCostUsd(u: { input: number; output: number; cacheRead: number; cacheWrite: number }): number { return (u.input * PRICING_USD.input + u.output * PRICING_USD.output + u.cacheRead * PRICING_USD.cacheRead + u.cacheWrite * PRICING_USD.cacheWrite) / 1_000_000; } export class ClaudeSonnet5Provider implements PropertyVisionModel { readonly model: string; private client: Anthropic; constructor(model = process.env.PROPERTY_ANALYSIS_MODEL ?? DEFAULT_MODEL, client?: Anthropic) { this.model = model; this.client = client ?? new Anthropic(); } async analyze(input: AnalyzeInput): Promise { const schema = buildModelSchema(input.assemblyCodes); const system: Anthropic.TextBlockParam[] = [ { type: "text", text: loadSystemPrompt(), cache_control: { type: "ephemeral" } }, { type: "text", text: `## Assembly catalogue (only these codes are valid)\n${input.assemblyCatalogueText}`, cache_control: { type: "ephemeral" } }, ]; const manifest = input.images.map((i) => `${i.id}: position ${i.position}, room_hint=${i.roomHint}`).join("\n"); const content: Anthropic.ContentBlockParam[] = []; for (const img of input.images) { content.push({ type: "text", text: `[${img.id}]` }); content.push({ type: "image", source: { type: "base64", media_type: img.mediaType, data: img.data.toString("base64") } }); } 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.` }); // Mode strict de l'API : limité à 16 paramètres nullables/union — notre schéma en compte ~150 (chaque // inférence est nullable par conception, §94). On force donc l'outil (tool_choice) sans `strict`, et la // conformité est garantie par le validateur/réparateur local + une relance. STRICT=1 réactive le mode strict. const strict = process.env.PROPERTY_ANALYSIS_STRICT === "1"; 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 }]; const messages: Anthropic.MessageParam[] = [{ role: "user", content }]; const t0 = Date.now(); let inTok = 0, outTok = 0, cacheRead = 0, cacheWrite = 0; let attempts = 0; let lastIssues: ValidationIssue[] = []; let output: ModelOutput | null = null; // tool_choice : `auto` au 1er essai — un outil FORCÉ (`type:"tool"`) sans réflexion fait parfois // répondre Sonnet 5 par un placeholder ({"property_analysis":{"placeholder":true}}, 44 jetons) sur ce // schéma de ~150 champs (constaté 2026-09-08 sur une annonce Facebook Marketplace ; la même requête en // `auto` produit 21 Ko d'analyse). `any` en relance : le modèle doit appeler un outil, avec le retour d'erreur. const MAX_ATTEMPTS = 3; while (attempts < MAX_ATTEMPTS && !output) { attempts++; const stream = this.client.messages.stream({ model: this.model, max_tokens: 24000, system, messages, tools, tool_choice: attempts === 1 ? { type: "auto" } : { type: "any" }, thinking: { type: "disabled" }, }); const msg = await stream.finalMessage(); inTok += msg.usage.input_tokens; outTok += msg.usage.output_tokens; cacheRead += msg.usage.cache_read_input_tokens ?? 0; cacheWrite += msg.usage.cache_creation_input_tokens ?? 0; if (msg.stop_reason === "refusal") throw new Error("Le modèle a refusé l'analyse (stop_reason=refusal)."); const tu = msg.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use" && b.name === TOOL_NAME); if (!tu) { lastIssues = [{ path: "$", message: `aucun appel d'outil (stop_reason=${msg.stop_reason})`, repaired: false }]; } else { const { value, issues } = validateAndRepair(tu.input, schema); const hard = issues.filter((i) => !i.repaired); const missing = missingTopLevelSections(issues); lastIssues = missing.length > MAX_MISSING_SECTIONS ? [{ path: "$", message: `sortie vide ou placeholder — ${missing.length} sections absentes (${missing.slice(0, 6).join(", ")}…)`, repaired: false }, ...issues] : issues; if (!hard.length && missing.length <= MAX_MISSING_SECTIONS) { output = value as ModelOutput; break; } } if (attempts >= MAX_ATTEMPTS) break; // relance avec les erreurs de validation (la sortie fautive reste dans l'historique du tour) 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) }); const toolId = msg.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")?.id; 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.`; messages.push({ role: "user", content: toolId ? [{ type: "tool_result", tool_use_id: toolId, is_error: true, content: feedback }] : feedback }); } 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(" ; ")}`); return { output, model: this.model, promptVersion: PROMPT_VERSION, issues: lastIssues, usage: { inputTokens: inTok, outputTokens: outTok, cacheReadTokens: cacheRead, cacheWriteTokens: cacheWrite, estimatedCostUsd: estimateCostUsd({ input: inTok, output: outTok, cacheRead, cacheWrite }), latencyMs: Date.now() - t0, attempts }, }; } }