File size: 7,401 Bytes
2d86174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
201
202
203
204
205
206
207
208
209
210
211
212
213
"""
Smoke test: does Gemma 4 vision work on OpenRouter free tier?

If this passes we can build a Street View flood-indicator agent.
If it fails (or returns garbage), we either need paid OpenRouter or
a Google AI Studio direct-call code path.

Run:
    cd backend && set -a && source .env && set +a && \\
    PYTHONPATH=. .venv/bin/python scripts/smoke_test_vision.py
"""
import asyncio
import base64
import json
import os
import sys

import httpx

API_KEY = os.environ.get("OPENROUTER_API_KEY", "").strip()
BASE = "https://openrouter.ai/api/v1/chat/completions"
PRIMARY = "google/gemma-4-31b-it:free"
FALLBACK = "google/gemma-4-26b-a4b-it:free"

HEADERS = {
    "Authorization": f"Bearer {API_KEY}",
    "Content-Type": "application/json",
    "HTTP-Referer": "https://flutiq.pages.dev",
    "X-Title": "FlutIQ vision smoke test",
}

# Hotlink-friendly source. Picsum gives us a real photograph (random
# subject) at our requested size β€” fine for "does vision work at all".
# We test flood-indicator extraction separately with a known building
# image.
TEST_IMAGE_URL = "https://picsum.photos/seed/flutiq-vision-test/640/480"


def section(title: str) -> None:
    print("\n" + "=" * 70)
    print(title)
    print("=" * 70)


async def fetch_image_as_data_url(url: str) -> str:
    """Download an image and return it as a data: URL (base64-encoded).

    Mirrors the production pattern: we'll fetch Google Street View
    images server-side, then send them inline to Gemma 4. This is
    more reliable than passing a public URL (which the upstream
    fetcher may not be able to reach due to User-Agent rules).
    """
    # Realistic UA β€” Wikimedia in particular rejects clients without one.
    headers = {"User-Agent": "FlutIQ/1.0 (smoke test)"}
    async with httpx.AsyncClient(timeout=30, headers=headers, follow_redirects=True) as client:
        resp = await client.get(url)
    resp.raise_for_status()
    ct = resp.headers.get("content-type", "image/jpeg").split(";")[0].strip()
    b64 = base64.b64encode(resp.content).decode("ascii")
    return f"data:{ct};base64,{b64}"


async def call(model: str, prompt: str, image_url: str) -> dict:
    payload = {
        "model": model,
        "messages": [{
            "role": "user",
            "content": [
                {"type": "text", "text": prompt},
                {"type": "image_url", "image_url": {"url": image_url}},
            ],
        }],
        "max_tokens": 1024,
        "temperature": 0.2,
    }
    async with httpx.AsyncClient(timeout=120) as client:
        resp = await client.post(BASE, headers=HEADERS, json=payload)
    print(f"  HTTP {resp.status_code}")
    if resp.status_code != 200:
        print(f"  body (first 800 chars): {resp.text[:800]}")
        return {}
    return resp.json()


async def test_describe(model: str, image_url: str) -> bool:
    section(f"TEST 1 β€” basic image describe ({model})")
    data = await call(
        model,
        "What kind of building is shown in this photo? Answer in one sentence.",
        image_url,
    )
    if not data:
        return False
    msg = data.get("choices", [{}])[0].get("message", {})
    text = msg.get("content", "") or ""
    print(f"  response: {text[:400]!r}")
    print(f"  usage: {data.get('usage', {})}")
    return bool(text and len(text.strip()) > 5)


async def test_flood_indicators(model: str, image_url: str) -> bool:
    section(f"TEST 2 β€” flood-risk indicator extraction ({model})")
    prompt = """You are a flood risk surveyor analyzing a street-level photo of a residential building.

Identify visible flood risk indicators. For each one you find, note its
location in the image and what it implies about flood vulnerability.

Look specifically for:
- Basement-level windows (vulnerable to surface flooding)
- Ground-floor HVAC units, water heaters, electrical panels
- Below-grade entries or stairwells
- Visible drainage infrastructure (downspouts, storm drains, swales)
- Evidence of prior water damage (staining, repairs)
- Property elevation relative to street grade
- Proximity to obvious water features

Return ONLY a JSON object with this shape:
{
  "indicators": [
    {"feature": "<short name>", "location": "<where in image>", "risk_implication": "<1 sentence>"}
  ],
  "overall_visual_risk": "low" | "moderate" | "high",
  "confidence": "low" | "medium" | "high",
  "summary": "<1 sentence>"
}"""
    data = await call(model, prompt, image_url)
    if not data:
        return False
    msg = data.get("choices", [{}])[0].get("message", {})
    text = msg.get("content", "") or ""
    print(f"  raw response (first 800 chars): {text[:800]}")

    # Try to parse JSON
    clean = text.strip()
    if clean.startswith("```"):
        clean = clean.split("\n", 1)[1] if "\n" in clean else clean[3:]
        if clean.endswith("```"):
            clean = clean.rsplit("```", 1)[0]
        clean = clean.strip()
        if clean.startswith("json"):
            clean = clean[4:].strip()
    try:
        parsed = json.loads(clean)
        print()
        print("  PARSED JSON:")
        print(json.dumps(parsed, indent=4))
        return True
    except json.JSONDecodeError as e:
        print(f"  JSON parse failed: {e}")
        return False


async def main() -> int:
    if not API_KEY:
        print("ERROR: OPENROUTER_API_KEY not set", file=sys.stderr)
        return 2

    print(f"Fetching test image: {TEST_IMAGE_URL}")
    try:
        image = await fetch_image_as_data_url(TEST_IMAGE_URL)
        decoded_bytes = (len(image) - len(image.split(",")[0]) - 1) * 3 // 4
        print(f"  β†’ got {len(image)} chars of data URL ({decoded_bytes} bytes raw)")
    except Exception as e:
        print(f"  failed to fetch test image: {e}", file=sys.stderr)
        return 2

    results = {}
    for model in (PRIMARY, FALLBACK):
        try:
            results[(model, "describe")] = await test_describe(model, image)
        except Exception as e:
            print(f"  EXCEPTION: {type(e).__name__}: {e}")
            results[(model, "describe")] = False

        await asyncio.sleep(2)  # be polite to free tier

        try:
            results[(model, "flood_indicators")] = await test_flood_indicators(model, image)
        except Exception as e:
            print(f"  EXCEPTION: {type(e).__name__}: {e}")
            results[(model, "flood_indicators")] = False

        await asyncio.sleep(2)

    section("SUMMARY")
    for (model, name), ok in results.items():
        mark = "PASS" if ok else "FAIL"
        print(f"  [{mark}] {model:42s} {name}")

    any_describe_passed = any(
        results.get((m, "describe"), False) for m in (PRIMARY, FALLBACK)
    )
    any_flood_passed = any(
        results.get((m, "flood_indicators"), False) for m in (PRIMARY, FALLBACK)
    )

    print()
    if any_describe_passed and any_flood_passed:
        print("VERDICT: Vision works on free tier. Street View agent is buildable.")
        return 0
    elif any_describe_passed:
        print("VERDICT: Vision works for description but JSON-mode flood-indicator")
        print("         extraction is unreliable. Buildable but with retry logic.")
        return 0
    else:
        print("VERDICT: Vision does NOT work on this free-tier route.")
        print("         Need either paid OpenRouter or Google AI Studio direct.")
        return 1


if __name__ == "__main__":
    sys.exit(asyncio.run(main()))