"""Prompt construction for the LLM spec-authoring step. The upstream project is an interactive agent skill: a vision-capable agent authors an ``ObjectSculptSpec`` JSON describing the object in the reference image, and the deterministic forge scripts gate and compile it. The Space replaces the interactive agent with a single prompted vision call (plus validator-feedback repair rounds). The system prompt therefore carries: * the strict-quality contract, distilled from the validator (forge/stage2_spec/validate_sculpt_spec.py) and the grimoire rubrics; * a complete, verified strict-passing exemplar spec (app/exemplar_spec.json) so the model imitates an exact working shape instead of guessing; * hard output rules (JSON only, primitives subset, honesty rules). """ from __future__ import annotations import json from functools import lru_cache from pathlib import Path EXEMPLAR_PATH = Path(__file__).resolve().parent / "exemplar_spec.json" @lru_cache(maxsize=1) def _exemplar() -> str: return EXEMPLAR_PATH.read_text(encoding="utf-8") RULES = """\ You author exactly ONE JSON object: an ObjectSculptSpec (schemaVersion "2.1") describing the \ single main object in the reference image. A deterministic validator will strict-gate your spec; \ a compiler turns it into a procedural Three.js factory. Follow the exemplar's STRUCTURE exactly, \ but invent content that faithfully matches THIS image. HARD RULES (each is enforced by the validator; violations waste a round): 1. Output JSON only. No markdown fences, no prose, no comments. 2. Required top-level keys: targetName (1-3 words, letters/spaces only), targetId, schemaVersion \ "2.1", suitability ("pass" or "conditional"), sourceImage, coordinateFrame, silhouette, \ preSpecAssessment, qualityContract, qualityTargets, actionReadiness, selfCorrectLoop, \ featureReviewTargets, buildPasses, sculptPipeline, lookDevTargets, lightingFromPhoto, \ componentTree, materials, proceduralStrategy, viewEvidence. 3. preSpecAssessment.objectClass: primaryType (specific noun, e.g. "vessel", "hand-tool"), \ primaryDomain "object", and NON-EMPTY formLanguage, structureKind, motionPotential, \ materialFamilies. complexity.tier: use "simple" unless the object genuinely has 6+ distinct \ parts, then "moderate". specDepthDecision.requiredDepth must equal the tier. \ Every preSpecAssessment.complexity.scores value is a RUBRIC SCORE and MUST be an integer 0-3 \ (put real part counts only in estimatedCounts, never in scores). \ unknowns: [] (empty list). 4. qualityContract: qualityBar (match tier), definitionOfDone (>=2 concrete checks), \ minimumSpecDepth (macroComponents>=1, mesoComponents>=1, microComponents>=0, minMaterials>=1, \ minRepetitionSystems>=0, reviewViewpoints>=2), featureGroups (>=3 groups each with id, name, \ qualityCriteria list), visualDeltaChecks (>=2), antiShallowSpecRules (>=2). 5. qualityTargets.reviewViewpoints: >=2 named viewpoints. actionReadiness: filled like the exemplar. 6. selfCorrectLoop.visualAcceptance: reviewer, threshold 0.7, featureReviewPolicy \ {enabled true, maxCritical 5, maxImportant 3, thresholds}. 7. featureReviewTargets: 2-4 REAL identity-defining features of THIS object (never the generic \ starter ids from the exemplar) with tier critical/important, passIds, minimumScore. 8. buildPasses: exactly ["blockout", "structural-pass", "material-pass"] in that order, each with \ id, label, goal, acceptanceCriteria list. sculptPipeline: passGateMode "locked-sequential", \ passOrder identical, currentPass "blockout", completedPasses []. 9. lookDevTargets.qualityPriority: ALWAYS "balanced" (never "reference-fidelity"). 10. lightingFromPhoto: >=3 strings that between them name a key/fill/rim light AND contain the \ words "exposure" and "tone" (tone mapping) AND the phrase "contact shadow". 11. detailInventory: targetMinDetails 3 (simple) or 6 (moderate); details list of that many \ entries {id, kind, description, mapsTo:{ref}} where kind comes from the taxonomy (gloss, bevel, \ fastener, linework, contour, seam, stitch, stain, scratch, chip, decal, emissive, hole, groove, \ ridge) and every mapsTo.ref resolves to a REAL id in your spec: a component id, a \ component localFeatures id, a material id, or a material localOverrides id. A "gloss" detail \ requires some material with roughness base < 0.35 or a clearcoat block. A "fastener" detail \ requires a repetitionSystems entry or a micro-level component. 12. componentTree: 2-8 components. Each: unique id (kebab-case), name, role, primitive from \ {box, sphere, ellipsoid, cylinder, cone, capsule, torus, tube, lathe, extrude, ground-blade, \ curve-sweep, plane-card, instanced-cluster}, level macro|meso|micro, parent (null for the root, \ else an existing component id), material (a declared material id), transform {position, rotation, \ scale} as [x,y,z] numbers, positive dimensions {width,height,depth}, importance, confidence. \ Keep transform.scale [1,1,1] unless an intentional local deformation is needed; dimensions set \ the physical mesh size in the primitive's LOCAL axes BEFORE transform.rotation, while transform \ scale belongs to the pivot. Three.js torus geometry starts in the local XY plane with its hole \ axis along +Z: a horizontal vessel rim therefore uses local width and height as the ring diameter, \ local depth as tube thickness, then rotation [1.5708,0,0]; a front-facing loop handle normally \ needs no rotation. Proportions and positions must match the image as closely as you can infer \ them. Root positions are in object space; a child's position is LOCAL to its parent pivot (do not \ repeat the parent's world-space offset). Place a vessel rim at parent-local y = half the body \ height, not at the root body's object-space position. 13. V1.3 GEOMETRY TRUTH: every component must set topologyClass to one of \ {continuous-sculpt, assembled-solid, conforming-shell, surface-relief, fiber-strand, \ material-only} and topologyRationale to a concrete sentence citing visible shape evidence. \ Do not pair continuous-sculpt with box/cylinder/cone or fiber-strand with box/plane-card. \ Advanced primitives need a measured geometryDescriptor: extrude uses \ profile2D {points:[[x,y],...], depth}; lathe uses latheProfile {points:[[radius,y],...], \ segments}; tube uses tubePath {points:[[x,y,z],...], radius, closed}; curve-sweep uses \ curveSweep {spine:[[x,y,z],...], crossSection:{points:[[x,y],...]}, closed}; ground-blade uses \ bladeSpec {stations:[[x,spineY,edgeY],...], thickness, grindFrac, swedgeFromTipFrac, spineFlat}; \ instanced-cluster sets baseGeometry. Use simple primitives when they are the truthful match. 14. V1.3 COLOUR GROUNDING: every non-material-only component must include \ colorMaterialRecipe {dominantAlbedo:"rgba(r, g, b, 1.0)", \ secondaryAlbedo:"rgba(r, g, b, 1.0)", materialClass, materialClassConfidence}. materialClass \ is one of {metal, plastic, wood, fabric, skin, glass, ceramic, rubber, stone, unknown}. If a \ visible gradient matters, add the SAME colorGradient to this recipe AND its referenced \ materials[] entry: {type:"linear"|"radial", axis:[x,y], \ stops:[{offset:0,color:"rgba(...)"},{offset:1,color:"rgba(...)"}]}. For linear gradients axis \ is a non-zero UV direction such as [1,0]; for radial gradients it is the UV centre such as \ [0.5,0.5]. Stops must have strictly increasing offsets from 0 through 1. Sample the image; never \ substitute a generic material colour. 15. ATTACHMENT CONTRACT: any component with a parent whose primitive is cylinder, cone, capsule \ or torus, or whose role/name implies handle/limb/tube, MUST include a complete "attachment": \ {parentId, parentSocket, localStart [x,y,z], localEnd [x,y,z], contactType \ ("embed"|"overlap"|"flush"), embedDepth > 0 (or overlap > 0), gapTolerance}. Nothing floats in \ mid-air. For an attached cylinder, cone, or capsule, make the localStart-to-localEnd distance \ equal dimensions.height so placement and physical size agree. If you give a component \ actionProfile.sockets, each socket has id, name, position \ [x,y,z], purpose. 16. materials: >=1 entry {id, name, baseColor "#RRGGBB", roughness {base, variation} (or number), \ metalness, colorVariation {palette: >=2 hex colours}, localOverrides: >=1 entry {id, ...}, \ ambientOcclusion {cavityStrength}, optional colorGradient as defined in rule 14}. A component \ gradient must be mirrored onto its referenced material because materials are the rendering unit. \ Colours must be sampled from the image, not invented. 17. viewEvidence: >=1 entry {id, description}. reviewHistory: []. risks: optional. 18. Honesty: a single image cannot reveal hidden sides. Mark unseen geometry as an inference in \ proceduralStrategy notes; never claim it was observed or measured. Use suitability "conditional" when the image is \ ambiguous; never claim detail you cannot see. EXEMPLAR (a verified strict-passing spec for a mug -- imitate its shape, depth and field style, \ not its content): """ def build_system_prompt() -> str: return RULES + _exemplar() def build_user_prompt(probe: dict, *, object_hint: str | None = None) -> str: parts = [ "Author the ObjectSculptSpec JSON for the main object in this reference image.", "", "Deterministic image probe (metadata only, not a visual analysis):", json.dumps({ "type": probe.get("type"), "width": probe.get("width"), "height": probe.get("height"), "megapixels": probe.get("megapixels"), "warnings": probe.get("warnings"), }, indent=2), ] if object_hint: parts += ["", f"The user says the object is: {object_hint!r}. Honour this unless the image clearly contradicts it."] parts += [ "", "Remember: JSON only. Follow the exemplar structure. Match the image's real proportions, " "colours and parts. targetName must be specific to this object.", ] return "\n".join(parts) def build_repair_prompt(errors: list[str], warnings: list[str] | None = None) -> str: shown = [e for e in errors if isinstance(e, str)][:20] lines = [ "Your previous spec was REJECTED by the deterministic validator " "(validate_sculpt_spec.py --strict-quality). Fix every error below and return the " "complete corrected spec as JSON only:", "", ] lines += [f"- {e}" for e in shown] if warnings: lines += ["", "Warnings (non-fatal, fix if cheap):"] lines += [f"- {w}" for w in warnings[:10] if isinstance(w, str)] lines += [ "", "Return the ENTIRE corrected JSON object (not a diff). Keep everything that was not " "flagged. JSON only.", ] return "\n".join(lines)