Spaces:
Sleeping
Sleeping
File size: 7,848 Bytes
a1e2ff8 732b14f a1e2ff8 732b14f a1e2ff8 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 | """GPT-4o Vision analyser for RICS section photos.
Extracts professional survey observations from uploaded images.
Has a hard timeout and retry logic to handle transient API failures gracefully.
"""
from __future__ import annotations
import base64
import json
import logging
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
pass
logger = logging.getLogger(__name__)
_VISION_SYSTEM_PROMPT = """You are a UK Chartered Building Surveyor (MRICS) examining a photograph taken during a residential property inspection.
Analyse the image and extract factual, professional observations suitable for a RICS survey report. Focus on:
- Visible condition (good, fair, poor, defective)
- Materials and construction type
- Defects (cracks, damp stains, decay, displacement)
- Maintenance items or recommendations
Do NOT invent or assume defects that are not clearly visible. State only what can be observed.
Output a JSON object with a single key "observations" containing a list of concise bullet strings (max 8 items)."""
_VISION_USER_TEMPLATE = "This photo is from report section: {section_label}. Describe the relevant observations."
_VISION_TIMEOUT_SECONDS: float = 45.0
_VISION_MAX_ATTEMPTS: int = 2
class VisionAnalysisResult:
"""Outcome of a vision-analysis attempt.
Attributes:
observations: Extracted bullets (may be empty).
ok: ``True`` if the call succeeded.
error: Short, human-readable failure reason (``""`` if ok).
"""
__slots__ = ("observations", "ok", "error")
def __init__(self, observations: list[str], ok: bool, error: str = "") -> None:
self.observations = observations
self.ok = ok
self.error = error
def analyze_section_photo(
image_path: str | Path,
mime_type: str,
section_label: str,
openai_api_key: str,
vision_model: str = "gpt-4o",
) -> VisionAnalysisResult:
"""Analyse a section photo and return observation bullets.
Has a hard timeout of ~45 seconds and retries once on transient errors
(timeout / connection / 5xx). The return type is a small structured object
so the caller can surface a useful error message to the UI when vision
fails — avoiding silent skips on a paid API call.
Args:
image_path: Absolute path to the stored image file.
mime_type: MIME type of the image (``image/jpeg``, ``image/png``, etc.).
section_label: Human-readable section name (e.g. "Main Roof").
openai_api_key: OpenAI API key.
vision_model: Model that supports vision (default ``gpt-4o``).
Returns:
VisionAnalysisResult with observations list, ok flag, and error message.
"""
if not openai_api_key:
logger.debug("No OpenAI key — skipping vision analysis")
return VisionAnalysisResult([], ok=False, error="OpenAI API key not configured.")
try:
image_bytes = Path(image_path).read_bytes()
except OSError as exc:
logger.warning("Could not read photo %s: %s", image_path, exc)
return VisionAnalysisResult([], ok=False, error=f"Could not read image file: {exc}")
b64 = base64.standard_b64encode(image_bytes).decode("ascii")
data_url = f"data:{mime_type};base64,{b64}"
from app.config import settings as app_settings
from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI
last_error: str = ""
for attempt in range(_VISION_MAX_ATTEMPTS):
try:
messages = [
{"role": "system", "content": _VISION_SYSTEM_PROMPT},
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": data_url, "detail": "high"},
},
{
"type": "text",
"text": _VISION_USER_TEMPLATE.format(section_label=section_label),
},
],
},
]
from app.llm.openai_chat import chat_completions_create_raw
async def _vision_call() -> str:
return await chat_completions_create_raw(
messages=messages,
model=vision_model,
max_tokens=400,
temperature=0.0,
response_format={"type": "json_object"},
phase="section_photo_vision",
section_id=section_label,
api_key=openai_api_key,
)
import asyncio
import concurrent.futures
def _run_in_fresh_loop() -> str:
return asyncio.run(_vision_call())
try:
asyncio.get_running_loop()
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
raw = pool.submit(_run_in_fresh_loop).result(
timeout=_VISION_TIMEOUT_SECONDS + 10
)
except RuntimeError:
raw = _run_in_fresh_loop()
data = json.loads(raw)
# Distinguish three cases that the previous .get(key, []) collapsed:
# 1. key absent → contract violation, surface as ok=False
# 2. key present, !list → contract violation, surface as ok=False
# 3. key present, list → genuine result (empty list is "model saw nothing", ok=True)
if "observations" not in data:
logger.warning(
"Vision response missing 'observations' key: %s",
raw[:200],
)
return VisionAnalysisResult(
[],
ok=False,
error="Vision response missing 'observations' key.",
)
obs = data["observations"]
if not isinstance(obs, list):
logger.warning(
"Vision response 'observations' is not a list (got %s): %s",
type(obs).__name__,
raw[:200],
)
return VisionAnalysisResult(
[],
ok=False,
error=f"Invalid 'observations' field type: {type(obs).__name__}.",
)
clean = [str(o).strip() for o in obs if isinstance(o, str) and o.strip()][:8]
logger.info("Vision analysis OK: %d observations from %s", len(clean), image_path)
return VisionAnalysisResult(clean, ok=True, error="")
except (APITimeoutError, APIConnectionError) as exc:
last_error = f"Network error: {exc}"
logger.warning("Vision API transient error (attempt %d/%d): %s", attempt + 1, _VISION_MAX_ATTEMPTS, exc)
continue
except APIStatusError as exc:
if exc.status_code >= 500:
last_error = f"Server error ({exc.status_code})"
logger.warning("Vision API 5xx (attempt %d/%d): %s", attempt + 1, _VISION_MAX_ATTEMPTS, exc)
continue
else:
last_error = f"API error ({exc.status_code}): {exc.message}"
logger.warning("Vision API client error: %s", exc)
break
except json.JSONDecodeError as exc:
last_error = f"Invalid JSON response: {exc}"
logger.warning("Vision response not valid JSON: %s", exc)
break
except Exception as exc:
last_error = f"Unexpected error: {exc}"
logger.exception("Vision analysis unexpected failure")
break
return VisionAnalysisResult([], ok=False, error=last_error or "Vision analysis failed after retries.")
|