| 10 | import Anthropic from "@anthropic-ai/sdk"; |
10 | import Anthropic from "@anthropic-ai/sdk"; |
| 11 | import fs from "fs"; |
11 | import fs from "fs"; |
| 12 | import path from "path"; |
12 | import path from "path"; |
| 13 | −import { buildModelSchema, PROMPT_VERSION, schemaForApi, validateAndRepair, type ModelOutput, type ValidationIssue } from "./schema"; |
13 | +import { buildModelSchema, MAX_MISSING_SECTIONS, missingTopLevelSections, PROMPT_VERSION, schemaForApi, validateAndRepair, type ModelOutput, type ValidationIssue } from "./schema"; |
| 14 | import type { ImageMediaType } from "./images"; |
14 | import type { ImageMediaType } from "./images"; |
| 15 | |
15 | |
| 16 | export const DEFAULT_MODEL = "claude-sonnet-5"; |
16 | export const DEFAULT_MODEL = "claude-sonnet-5"; |
| 78 | let attempts = 0; |
78 | let attempts = 0; |
| 79 | let lastIssues: ValidationIssue[] = []; |
79 | let lastIssues: ValidationIssue[] = []; |
| 80 | let output: ModelOutput | null = null; |
80 | let output: ModelOutput | null = null; |
| 81 | − while (attempts < 2 && !output) { |
81 | + // tool_choice : `auto` au 1er essai — un outil FORCÉ (`type:"tool"`) sans réflexion fait parfois |
| |
82 | + // répondre Sonnet 5 par un placeholder ({"property_analysis":{"placeholder":true}}, 44 jetons) sur ce |
| |
83 | + // schéma de ~150 champs (constaté 2026-09-08 sur une annonce Facebook Marketplace ; la même requête en |
| |
84 | + // `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) { |
| 82 | attempts++; |
87 | attempts++; |
| 83 | const stream = this.client.messages.stream({ |
88 | const stream = this.client.messages.stream({ |
| 84 | − model: this.model, max_tokens: 24000, system, messages, tools, tool_choice: { type: "tool", name: TOOL_NAME }, thinking: { type: "disabled" }, |
89 | + model: this.model, max_tokens: 24000, system, messages, tools, tool_choice: attempts === 1 ? { type: "auto" } : { type: "any" }, thinking: { type: "disabled" }, |
| 85 | }); |
90 | }); |
| 86 | const msg = await stream.finalMessage(); |
91 | const msg = await stream.finalMessage(); |
| 87 | inTok += msg.usage.input_tokens; outTok += msg.usage.output_tokens; |
92 | inTok += msg.usage.input_tokens; outTok += msg.usage.output_tokens; |
| 93 | } else { |
98 | } else { |
| 94 | const { value, issues } = validateAndRepair(tu.input, schema); |
99 | const { value, issues } = validateAndRepair(tu.input, schema); |
| 95 | const hard = issues.filter((i) => !i.repaired); |
100 | const hard = issues.filter((i) => !i.repaired); |
| 96 | − lastIssues = issues; |
101 | + const missing = missingTopLevelSections(issues); |
| 97 | − if (!hard.length) { output = value as ModelOutput; break; } |
102 | + lastIssues = missing.length > MAX_MISSING_SECTIONS |
| |
103 | + ? [{ 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; } |
| 98 | } |
106 | } |
| 99 | − // relance unique avec les erreurs de validation |
107 | + if (attempts >= MAX_ATTEMPTS) break; |
| |
108 | + // relance avec les erreurs de validation (la sortie fautive reste dans l'historique du tour) |
| 100 | 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) }); |
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) }); |
| 101 | const toolId = msg.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")?.id; |
110 | const toolId = msg.content.find((b): b is Anthropic.ToolUseBlock => b.type === "tool_use")?.id; |
| 102 | − 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.` }); |
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 }); |
| 103 | } |
113 | } |
| 104 | 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(" ; ")}`); |
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(" ; ")}`); |
| 105 | return { |
115 | return { |