// UQO Éval — laboratoire pédagogique d'évaluation immobilière /** * 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, 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; while (attempts < 2 && !output) { attempts++; const stream = this.client.messages.stream({ model: this.model, max_tokens: 24000, system, messages, tools, tool_choice: { type: "tool", name: TOOL_NAME }, 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); lastIssues = issues; if (!hard.length) { output = value as ModelOutput; break; } } // relance unique avec les erreurs de validation 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; messages.push({ role: "user", content: toolId ? [{ type: "tool_result", tool_use_id: toolId, is_error: true, content: `Schema validation failed:\n${lastIssues.slice(0, 40).map((i) => `${i.path}: ${i.message}`).join("\n")}\nCall ${TOOL_NAME} again with a fully valid input.` }] : `Schema validation failed. Call ${TOOL_NAME} again with a fully valid input.` }); } 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 }, }; } }