jasondo OpenAI Codex commited on
Commit
f24826e
·
1 Parent(s): 55428b1

Split vision prompt roles

Browse files

Co-authored-by: OpenAI Codex <codex@openai.com>

Files changed (3) hide show
  1. AGENTS.md +18 -0
  2. modal_app.py +67 -33
  3. snap2sim/prompts.py +73 -59
AGENTS.md CHANGED
@@ -311,6 +311,24 @@ technical cutaway animation.
311
  real Modal analysis for `piston mechanism` at `0.85` confidence with 3 parts,
312
  `/generate_scene` returned `renderer: three`, `render_mode: three`, and no
313
  HTML field, and a high confidence threshold returned `render_mode: annotate`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
 
315
  ## Next Work
316
 
 
311
  real Modal analysis for `piston mechanism` at `0.85` confidence with 3 parts,
312
  `/generate_scene` returned `renderer: three`, `render_mode: three`, and no
313
  HTML field, and a high confidence threshold returned `render_mode: annotate`.
314
+ - Implemented the `FEATURE4.md` prompt-role split on June 15, 2026: moved the
315
+ invariant analysis contract into `VISION_SYSTEM_PROMPT`, reduced
316
+ `build_vision_prompt()` to a backward-compatible per-image user-prompt alias,
317
+ added `build_vision_messages()`, and wired Modal llama.cpp analysis calls to
318
+ pass `-sys` with a fallback that prepends the system text only if the deployed
319
+ binary rejects the system-prompt flag.
320
+ - Verification after the `FEATURE4.md` pass: prompt-helper checks,
321
+ schema/parser/coercion checks, FastAPI `TestClient` checks for `/`,
322
+ `/analyze_image`, and `/generate_scene`, and a `modal_app.py` import/helper
323
+ check all passed locally. Modal dev raw analysis parsed strict JSON in
324
+ `28.59s` with `coerced_render_mode: three`, and the endpoint-style Modal
325
+ diagnostic returned a validated two-part mechanism payload. Stable Modal
326
+ deploy completed on June 15, 2026, and the stable unauthenticated
327
+ `runtime_probe` request returned `401 Unauthorized`. Authenticated stable
328
+ Modal and private-Space verification could not be run from the local shell
329
+ because `SNAP2SIM_API_TOKEN` / Hugging Face auth tokens were not set locally.
330
+ GitHub push/sync and authenticated private-Space verification still need to
331
+ run before public demo claims rely on the prompt-role split.
332
 
333
  ## Next Work
334
 
modal_app.py CHANGED
@@ -17,7 +17,7 @@ import modal
17
  from fastapi import Header, HTTPException
18
 
19
  from snap2sim.model_io import coerce_analysis_response, parse_analysis_response
20
- from snap2sim.prompts import build_vision_prompt
21
  from snap2sim.schema import EXAMPLE_ANALYSIS, select_render_mode, validate_analysis
22
 
23
 
@@ -291,6 +291,7 @@ def smoke_test_llamacpp_image() -> dict[str, Any]:
291
  def run_llamacpp_prompt(
292
  prompt: str,
293
  image_path: Path | None = None,
 
294
  max_tokens: int = 3072,
295
  timeout_seconds: int = 300,
296
  ctx_size: int = 8192,
@@ -299,41 +300,72 @@ def run_llamacpp_prompt(
299
  import subprocess
300
 
301
  model_path, mmproj_path = ensure_runtime_assets()
302
- cmd = [
303
- "/opt/llama.cpp/build/bin/llama-mtmd-cli",
304
- "-m",
305
- str(model_path),
306
- "--mmproj",
307
- str(mmproj_path),
308
- "-p",
309
- prompt,
310
- "-n",
311
- str(max_tokens),
312
- "-c",
313
- str(ctx_size),
314
- "--temp",
315
- "0.2",
316
- ]
317
- if image_path is not None:
318
- cmd.extend(["--image", str(image_path)])
319
 
320
- try:
321
- proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_seconds)
322
- except subprocess.TimeoutExpired as exc:
323
- partial_output = "\n".join(
324
- part.decode("utf-8", errors="replace") if isinstance(part, bytes) else part
325
- for part in [exc.stdout, exc.stderr]
326
- if part
327
- ).strip()
328
- raise TimeoutError(
329
- f"llama.cpp timed out after {timeout_seconds}s: {partial_output[-2000:]}"
330
- ) from exc
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  output = "\n".join(part for part in [proc.stdout, proc.stderr] if part).strip()
 
 
 
 
332
  if proc.returncode != 0:
333
  raise RuntimeError(f"llama.cpp exited with {proc.returncode}: {output[-2000:]}")
334
  return output
335
 
336
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
337
  def write_payload_image(payload: dict[str, Any]) -> Path:
338
  """Decode an image_base64 payload into a temporary RGB JPEG."""
339
  import base64
@@ -385,9 +417,11 @@ def write_payload_image(payload: dict[str, Any]) -> Path:
385
  def analyze_image_llamacpp_payload(payload: dict[str, Any]) -> dict[str, Any]:
386
  image_path = write_payload_image(payload)
387
  try:
 
388
  response = run_llamacpp_prompt(
389
- build_vision_prompt(),
390
  image_path=image_path,
 
391
  max_tokens=4096,
392
  timeout_seconds=300,
393
  ctx_size=8192,
@@ -485,8 +519,6 @@ def runtime_probe(authorization: str = Header(default="")) -> dict[str, Any]:
485
  @modal.fastapi_endpoint(method="POST")
486
  def analyze_image(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, Any]:
487
  require_authorization(authorization)
488
- _ = payload.get("image_base64", "")
489
- _prompt = build_vision_prompt()
490
  if runtime_config()["runtime_mode"] != "placeholder":
491
  raise NotImplementedError(
492
  "Use the analyze_image_llamacpp endpoint for the verified Nemotron "
@@ -509,9 +541,11 @@ def analyze_image_llamacpp_raw_task(payload: dict[str, Any]) -> dict[str, Any]:
509
  image_path = write_payload_image(payload)
510
  start = time.monotonic()
511
  try:
 
512
  response = run_llamacpp_prompt(
513
- build_vision_prompt(),
514
  image_path=image_path,
 
515
  max_tokens=4096,
516
  timeout_seconds=300,
517
  ctx_size=8192,
 
17
  from fastapi import Header, HTTPException
18
 
19
  from snap2sim.model_io import coerce_analysis_response, parse_analysis_response
20
+ from snap2sim.prompts import build_vision_messages
21
  from snap2sim.schema import EXAMPLE_ANALYSIS, select_render_mode, validate_analysis
22
 
23
 
 
291
  def run_llamacpp_prompt(
292
  prompt: str,
293
  image_path: Path | None = None,
294
+ system_prompt: str | None = None,
295
  max_tokens: int = 3072,
296
  timeout_seconds: int = 300,
297
  ctx_size: int = 8192,
 
300
  import subprocess
301
 
302
  model_path, mmproj_path = ensure_runtime_assets()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
303
 
304
+ def build_cmd(user_prompt: str, system: str | None) -> list[str]:
305
+ cmd = [
306
+ "/opt/llama.cpp/build/bin/llama-mtmd-cli",
307
+ "-m",
308
+ str(model_path),
309
+ "--mmproj",
310
+ str(mmproj_path),
311
+ ]
312
+ if system:
313
+ cmd.extend(["-sys", system])
314
+ if image_path is not None:
315
+ cmd.extend(["--image", str(image_path)])
316
+ cmd.extend(
317
+ [
318
+ "-p",
319
+ user_prompt,
320
+ "-n",
321
+ str(max_tokens),
322
+ "-c",
323
+ str(ctx_size),
324
+ "--temp",
325
+ "0.2",
326
+ ]
327
+ )
328
+ return cmd
329
+
330
+ def run_cmd(cmd: list[str]) -> subprocess.CompletedProcess[str]:
331
+ try:
332
+ return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_seconds)
333
+ except subprocess.TimeoutExpired as exc:
334
+ partial_output = "\n".join(
335
+ part.decode("utf-8", errors="replace") if isinstance(part, bytes) else part
336
+ for part in [exc.stdout, exc.stderr]
337
+ if part
338
+ ).strip()
339
+ raise TimeoutError(
340
+ f"llama.cpp timed out after {timeout_seconds}s: {partial_output[-2000:]}"
341
+ ) from exc
342
+
343
+ proc = run_cmd(build_cmd(prompt, system_prompt))
344
  output = "\n".join(part for part in [proc.stdout, proc.stderr] if part).strip()
345
+ if proc.returncode != 0 and system_prompt and _system_prompt_flag_unsupported(output):
346
+ combined_prompt = f"{system_prompt.strip()}\n\nUser request:\n{prompt}"
347
+ proc = run_cmd(build_cmd(combined_prompt, None))
348
+ output = "\n".join(part for part in [proc.stdout, proc.stderr] if part).strip()
349
  if proc.returncode != 0:
350
  raise RuntimeError(f"llama.cpp exited with {proc.returncode}: {output[-2000:]}")
351
  return output
352
 
353
 
354
+ def _system_prompt_flag_unsupported(output: str) -> bool:
355
+ lowered = output.lower()
356
+ return any(
357
+ marker in lowered
358
+ for marker in [
359
+ "unknown argument: -sys",
360
+ "unknown argument '-sys'",
361
+ "unknown option: -sys",
362
+ "unrecognized option '-sys'",
363
+ "invalid option -- 'sys'",
364
+ "error: unknown argument",
365
+ ]
366
+ )
367
+
368
+
369
  def write_payload_image(payload: dict[str, Any]) -> Path:
370
  """Decode an image_base64 payload into a temporary RGB JPEG."""
371
  import base64
 
417
  def analyze_image_llamacpp_payload(payload: dict[str, Any]) -> dict[str, Any]:
418
  image_path = write_payload_image(payload)
419
  try:
420
+ system_prompt, user_prompt = build_vision_messages()
421
  response = run_llamacpp_prompt(
422
+ user_prompt,
423
  image_path=image_path,
424
+ system_prompt=system_prompt,
425
  max_tokens=4096,
426
  timeout_seconds=300,
427
  ctx_size=8192,
 
519
  @modal.fastapi_endpoint(method="POST")
520
  def analyze_image(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, Any]:
521
  require_authorization(authorization)
 
 
522
  if runtime_config()["runtime_mode"] != "placeholder":
523
  raise NotImplementedError(
524
  "Use the analyze_image_llamacpp endpoint for the verified Nemotron "
 
541
  image_path = write_payload_image(payload)
542
  start = time.monotonic()
543
  try:
544
+ system_prompt, user_prompt = build_vision_messages()
545
  response = run_llamacpp_prompt(
546
+ user_prompt,
547
  image_path=image_path,
548
+ system_prompt=system_prompt,
549
  max_tokens=4096,
550
  timeout_seconds=300,
551
  ctx_size=8192,
snap2sim/prompts.py CHANGED
@@ -4,67 +4,81 @@ from __future__ import annotations
4
 
5
  VISION_SYSTEM_PROMPT = """You are a mechanical teardown analyst for an annotated
6
  technical cutaway demo. Reason briefly if useful, then emit exactly one JSON
7
- object matching the schema. Prefer the simplest truthful primitive mechanism.
8
  If the photo is ambiguous, lower confidence and use visible photo annotations
9
- instead of forcing speculative 3D geometry."""
10
 
 
 
 
 
 
 
11
 
12
- def build_vision_prompt() -> str:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  return (
14
- "Analyze the uploaded hardware component as a cutaway mechanism. "
15
- "Final answer must be one JSON object with no markdown.\n\n"
16
- "Required top-level keys: component, confidence, summary, trigger, "
17
- "motion_sequence, parts. Optional top-level render_mode is three, "
18
- "annotate, or unavailable.\n"
19
- "Each part requires: id, name, role, and either geometry plus motion, "
20
- "or annotation when the visible component can be located but 3D "
21
- "geometry is uncertain.\n"
22
- "Shapes, with size always [x, y, z] extents:\n"
23
- "- box: plates, housings, blocks, levers, selectors\n"
24
- "- cylinder: shafts, sleeves, bushings, drums, pins\n"
25
- "- cone: valve cones, tips, nozzles, tapers\n"
26
- "- capsule: pistons, rollers, dowel pins, plungers, bearings\n"
27
- "- sphere: balls, detents, ball bearings, nodes\n"
28
- "- rod: links, tie rods, thin axles, connecting rods\n"
29
- "- gear: toothed wheels, ratchets, cogs; set teeth when useful\n"
30
- "- torus: o-rings, snap rings, seals, washers, single coils\n"
31
- "- spring: helical springs and coils; set coils when useful\n\n"
32
- "Motions, with axis as a numeric vector like [0, 1, 0]:\n"
33
- "- static: fixed structure or housing\n"
34
- "- rotate: continuous spin; use speed\n"
35
- "- oscillate: sinusoidal twist; use amplitude and speed\n"
36
- "- translate: slide along axis; use range [min, max]\n"
37
- "- screw: spin plus advance along axis; use pitch for helical action\n"
38
- "- orbit: revolve around pivot [x, y, z]\n"
39
- "- pulse: scale breathing for diaphragms, springs, valves, pumps\n\n"
40
- "Every geometry must use size: [x, y, z] and position: [x, y, z]. "
41
- "Do not use radius, height, length, width, or depth fields. Every "
42
- "motion axis must be a numeric vector such as [0, 1, 0], never a string "
43
- "like x, y, or z.\n\n"
44
- "Use 2 to 6 parts; prefer the fewest that explain the mechanism. "
45
- "Keep names and descriptions short. When possible, "
46
- "include annotation.point in normalized image coordinates [x, y] with "
47
- "origin at top-left, plus a short annotation.note. Optional "
48
- "annotation.box is [x, y, width, height], also normalized from 0 to 1.\n\n"
49
- "Use this compact shape:\n"
50
- "{\n"
51
- ' "component": "short component name",\n'
52
- ' "confidence": 0.7,\n'
53
- ' "summary": "one or two sentences",\n'
54
- ' "trigger": "what starts the mechanism",\n'
55
- ' "motion_sequence": ["step one", "step two"],\n'
56
- ' "parts": [\n'
57
- " {\n"
58
- ' "id": "part_id",\n'
59
- ' "name": "part name",\n'
60
- ' "role": "mechanical role",\n'
61
- ' "geometry": {"shape": "spring", "size": [0.4, 1.2, 0.4], "position": [0, 0, 0], "coils": 6},\n'
62
- ' "motion": {"type": "pulse", "speed": 2, "amplitude": 0.08},\n'
63
- ' "annotation": {"point": [0.5, 0.5], "label": "visible label", "note": "short visible clue"}\n'
64
- " }\n"
65
- " ]\n"
66
- "}\n\n"
67
- "Optional geometry fields: rotation, teeth, coils, wire, color. Optional "
68
- "motion fields: axis, speed, amplitude, phase, range, pitch, pivot. "
69
- "Include optional fields only when useful."
70
  )
 
 
 
 
 
 
 
 
 
 
4
 
5
  VISION_SYSTEM_PROMPT = """You are a mechanical teardown analyst for an annotated
6
  technical cutaway demo. Reason briefly if useful, then emit exactly one JSON
7
+ object with no markdown. Prefer the simplest truthful primitive mechanism.
8
  If the photo is ambiguous, lower confidence and use visible photo annotations
9
+ instead of forcing speculative 3D geometry.
10
 
11
+ Required top-level keys: component, confidence, summary, trigger,
12
+ motion_sequence, parts. Optional top-level render_mode is three, annotate, or
13
+ unavailable.
14
+ Each part requires: id, name, role, and either geometry plus motion, or
15
+ annotation when the visible component can be located but 3D geometry is
16
+ uncertain.
17
 
18
+ Shapes, with size always [x, y, z] extents:
19
+ - box: plates, housings, blocks, levers, selectors
20
+ - cylinder: shafts, sleeves, bushings, drums, pins
21
+ - cone: valve cones, tips, nozzles, tapers
22
+ - capsule: pistons, rollers, dowel pins, plungers, bearings
23
+ - sphere: balls, detents, ball bearings, nodes
24
+ - rod: links, tie rods, thin axles, connecting rods
25
+ - gear: toothed wheels, ratchets, cogs; set teeth when useful
26
+ - torus: o-rings, snap rings, seals, washers, single coils
27
+ - spring: helical springs and coils; set coils when useful
28
+
29
+ Motions, with axis as a numeric vector like [0, 1, 0]:
30
+ - static: fixed structure or housing
31
+ - rotate: continuous spin; use speed
32
+ - oscillate: sinusoidal twist; use amplitude and speed
33
+ - translate: slide along axis; use range [min, max]
34
+ - screw: spin plus advance along axis; use pitch for helical action
35
+ - orbit: revolve around pivot [x, y, z]
36
+ - pulse: scale breathing for diaphragms, springs, valves, pumps
37
+
38
+ Every geometry must use size: [x, y, z] and position: [x, y, z]. Do not use
39
+ radius, height, length, width, or depth fields. Every motion axis must be a
40
+ numeric vector such as [0, 1, 0], never a string like x, y, or z.
41
+
42
+ Use 2 to 6 parts; prefer the fewest that explain the mechanism. Keep names and
43
+ descriptions short. When possible, include annotation.point in normalized image
44
+ coordinates [x, y] with origin at top-left, plus a short annotation.note.
45
+ Optional annotation.box is [x, y, width, height], also normalized from 0 to 1.
46
+
47
+ Use this compact shape:
48
+ {
49
+ "component": "short component name",
50
+ "confidence": 0.7,
51
+ "summary": "one or two sentences",
52
+ "trigger": "what starts the mechanism",
53
+ "motion_sequence": ["step one", "step two"],
54
+ "parts": [
55
+ {
56
+ "id": "part_id",
57
+ "name": "part name",
58
+ "role": "mechanical role",
59
+ "geometry": {"shape": "spring", "size": [0.4, 1.2, 0.4], "position": [0, 0, 0], "coils": 6},
60
+ "motion": {"type": "pulse", "speed": 2, "amplitude": 0.08},
61
+ "annotation": {"point": [0.5, 0.5], "label": "visible label", "note": "short visible clue"}
62
+ }
63
+ ]
64
+ }
65
+
66
+ Optional geometry fields: rotation, teeth, coils, wire, color. Optional motion
67
+ fields: axis, speed, amplitude, phase, range, pitch, pivot. Include optional
68
+ fields only when useful."""
69
+
70
+
71
+ def build_vision_user_prompt() -> str:
72
  return (
73
+ "Analyze the hardware component in this photo as a cutaway mechanism. "
74
+ "Return the analysis JSON for the visible component."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  )
76
+
77
+
78
+ def build_vision_prompt() -> str:
79
+ """Backward-compatible alias for the per-image user prompt."""
80
+ return build_vision_user_prompt()
81
+
82
+
83
+ def build_vision_messages() -> tuple[str, str]:
84
+ return VISION_SYSTEM_PROMPT, build_vision_user_prompt()