File size: 23,876 Bytes
51982d6
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
import os
import asyncio
import base64
import uuid
import logging
import tempfile
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Optional
import httpx
from fastapi import FastAPI, BackgroundTasks
from fastapi.responses import JSONResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel, Field
from pydantic_ai import Agent, RunContext
from pydantic_ai.usage import UsageLimits
from orchestrator import discover_checks, run_all_checks

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("ifcore")

# In-memory job store — CF Worker polls this
_jobs: dict = {}

# ---------------------------------------------------------------------------
# Regulation knowledge base — Spanish / Catalan building bye-laws
# Each entry contains the official regulation, article/section reference,
# PDF link, content reference, compliance threshold, and required action.
# ---------------------------------------------------------------------------
REGULATIONS_KB: dict[str, dict] = {
    "walls": {
        "regulation": "CTE DB SE-F — Seguridad Estructural: Cimientos",
        "reference": "CTE DB SE-F, Section 4.1 (Muros); EHE-08, Art. 23",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SE/DBSEF.pdf",
        "page_ref": "Section 4.1, p. 14 — Minimum wall thickness 100 mm",
        "threshold": "Minimum wall thickness: ≥ 100 mm",
        "action": (
            "Increase wall thickness to ≥ 100 mm. For load-bearing walls, a qualified "
            "structural engineer must verify revised stability calculations under CTE DB SE. "
            "Update architectural and structural drawings accordingly."
        ),
    },
    "beams": {
        "regulation": "EHE-08 — Instrucción de Hormigón Estructural",
        "reference": "EHE-08, Art. 23 (Vigas) and Art. 42.3 (Dimensiones mínimas)",
        "pdf": "https://www.mitma.gob.es/recursos_mfom/0820200.pdf",
        "page_ref": "Art. 23.1, p. 62 — Minimum depth 200 mm; Art. 23.2 — Minimum width 150 mm",
        "threshold": "Minimum beam depth: ≥ 200 mm; minimum beam width: ≥ 150 mm",
        "action": (
            "Redesign beam cross-section to achieve depth ≥ 200 mm and width ≥ 150 mm. "
            "Recheck load and deflection calculations. Have a licensed structural engineer "
            "verify and sign off the revised design. Update structural drawings."
        ),
    },
    "columns": {
        "regulation": "EHE-08 — Instrucción de Hormigón Estructural",
        "reference": "EHE-08, Art. 24 (Pilares) and Art. 42.3",
        "pdf": "https://www.mitma.gob.es/recursos_mfom/0820200.pdf",
        "page_ref": "Art. 24.1, p. 65 — Minimum column dimension 250 mm",
        "threshold": "Minimum column dimension: ≥ 250 mm",
        "action": (
            "Increase the smaller column dimension to ≥ 250 mm. Re-evaluate reinforcement "
            "ratios and load capacity. Update column schedule and structural calculations. "
            "Coordinate changes with the foundation design."
        ),
    },
    "foundations": {
        "regulation": "CTE DB SE-C — Seguridad Estructural: Cimientos; EHE-08",
        "reference": "EHE-08, Art. 69 (Cimentaciones); CTE DB SE-C, Section 4.1",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SE/DBSEC.pdf",
        "page_ref": "Art. 69.1 — Minimum foundation element depth 200 mm; DB SE-C Section 4.1, p. 18",
        "threshold": "Minimum foundation depth: ≥ 200 mm",
        "action": (
            "Deepen or redesign foundation elements to ≥ 200 mm. If a geotechnical study "
            "has not been done, commission one. Submit revised foundation drawings to the "
            "project certifier. Ensure compliance with DB SE-C soil bearing capacity requirements."
        ),
    },
    "slabs": {
        "regulation": "CTE DB HE — Ahorro de Energía; EHE-08",
        "reference": "CTE DB HE1, Table 2.3 (Transmitancias límite); EHE-08, Art. 22",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/HE/DBHE.pdf",
        "page_ref": "HE1 Table 2.3, p. 11 — Slab thickness 150–200 mm; Art. 22 structural dimensions",
        "threshold": "Slab thickness: 150–200 mm",
        "action": (
            "Adjust slab thickness to the 150–200 mm range. Verify structural load capacity "
            "for the revised thickness. If thermal performance is affected, recalculate U-values "
            "for the slab assembly using HULC or equivalent CTE tool."
        ),
    },
    "doors": {
        "regulation": "CTE DB SUA — Seguridad de Utilización y Accesibilidad",
        "reference": "CTE DB SUA, SUA-9 (Accesibilidad), Section 1.1.1 and Table 2.1",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SUA/DBSUA.pdf",
        "page_ref": "SUA-9 Section 1.1.1, p. 47 — Minimum door clear width 800 mm; Table 2.1, p. 49",
        "threshold": "Minimum door clear width: ≥ 800 mm",
        "action": (
            "Replace or widen door frames to achieve ≥ 800 mm clear passage width. "
            "For full wheelchair access, 900 mm is recommended. Update the door schedule "
            "in architectural drawings. In Catalan projects, also verify Decreto 141/2012."
        ),
    },
    "windows": {
        "regulation": "CTE DB SUA — Seguridad de Utilización y Accesibilidad",
        "reference": "CTE DB SUA, SUA-1, Section 2.1 (Protección frente al riesgo de caída)",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SUA/DBSUA.pdf",
        "page_ref": "SUA-1 Section 2.1, p. 6 — Minimum window sill height 1200 mm above finished floor",
        "threshold": "Minimum window sill height: ≥ 1200 mm, or protective barrier required",
        "action": (
            "Raise window sill to ≥ 1200 mm above finished floor level, or install a "
            "compliant protective barrier (parapet or railing) at the required height. "
            "Verify glazing impact resistance under CTE DB SUA-2."
        ),
    },
    "corridors": {
        "regulation": "CTE DB SUA — Accesibilidad; Decreto 141/2012 (Catalonia)",
        "reference": "CTE DB SUA, SUA-9, Table 2.1; Decreto 141/2012, Art. 18",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SUA/DBSUA.pdf",
        "page_ref": "SUA-9 Table 2.1, p. 49 — Min. corridor width ≥ 1200 mm (public); ≥ 1100 mm (housing); Decreto 141/2012 Art. 18",
        "threshold": "Minimum corridor width: ≥ 1100 mm in dwellings; ≥ 1200 mm in public routes",
        "action": (
            "Widen corridor to the applicable minimum. Revise floor-plan layout if needed. "
            "For Catalan housing projects, additionally verify Decreto 141/2012 Art. 18 "
            "(PDF: https://portaldogc.gencat.cat/utilsEADOP/PDF/6138/1223437.pdf)."
        ),
    },
    "ceiling": {
        "regulation": "CTE DB SUA — Accesibilidad; Decreto 141/2012 (Catalonia)",
        "reference": "CTE DB SUA, SUA-9, Section 1.1; Decreto 141/2012, Art. 15",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SUA/DBSUA.pdf",
        "page_ref": "SUA-9 Section 1.1, p. 47 — Minimum clear ceiling height 2200 mm; Decreto 141/2012 Art. 15",
        "threshold": "Minimum clear ceiling height: ≥ 2200 mm (≥ 2500 mm in Catalan living spaces)",
        "action": (
            "Increase floor-to-ceiling clear height to ≥ 2200 mm. Review structural floor "
            "depth and finish build-up. For Catalan housing, Decreto 141/2012 Art. 15 requires "
            "≥ 2500 mm in habitable rooms — verify and revise section drawings."
        ),
    },
    "stairs": {
        "regulation": "CTE DB SUA — Seguridad de Utilización y Accesibilidad",
        "reference": "CTE DB SUA, SUA-1, Section 4.2.1 (Escaleras de uso general)",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SUA/DBSUA.pdf",
        "page_ref": "SUA-1 Section 4.2.1, p. 12 — Riser 130–185 mm; Tread ≥ 280 mm; formula: 2R + H = 620–640 mm",
        "threshold": "Stair riser: 130–185 mm; stair tread: ≥ 280 mm",
        "action": (
            "Redesign stair geometry so riser falls within 130–185 mm and tread is ≥ 280 mm. "
            "Apply the ergonomic formula: 2×riser + tread = 620–640 mm. "
            "Update stair detail drawings and structural calculations."
        ),
    },
    "railings": {
        "regulation": "CTE DB SUA — Seguridad de Utilización y Accesibilidad",
        "reference": "CTE DB SUA, SUA-1, Section 3.2.1 (Protección en los bordes de los forjados)",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SUA/DBSUA.pdf",
        "page_ref": "SUA-1 Section 3.2.1, p. 9 — Min. height 900 mm; ≥ 1100 mm where drop > 6 m",
        "threshold": "Minimum railing height: ≥ 900 mm; ≥ 1100 mm where floor-to-ground > 6 m",
        "action": (
            "Raise railing/balustrade to ≥ 900 mm (or ≥ 1100 mm where applicable). "
            "Ensure baluster spacing ≤ 100 mm to prevent climbing. "
            "Verify structural fixing adequacy under CTE DB SE."
        ),
    },
    "energy": {
        "regulation": "CTE DB HE — Ahorro de Energía",
        "reference": "CTE DB HE, HE1, Section 2.2 (Transmitancia térmica máxima de cerramientos)",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/HE/DBHE.pdf",
        "page_ref": "HE1 Table 2.3, p. 11 — Maximum wall U-value 0.80 W/m²K (Climate Zone B)",
        "threshold": "Maximum wall U-value: ≤ 0.80 W/m²K (Spain Climate Zone B)",
        "action": (
            "Add or upgrade thermal insulation in the wall assembly to bring U-value below "
            "0.80 W/m²K. Use HULC or CYPETHERM software to recalculate. Specify insulation "
            "type, thickness, and λ-value on building specifications."
        ),
    },
    "fire": {
        "regulation": "CTE DB SI — Seguridad en caso de Incendio",
        "reference": "CTE DB SI, SI-2 (Propagación interior); SI-6 (Resistencia al fuego)",
        "pdf": "https://www.codigotecnico.org/pdf/Documentos/SI/DBSI.pdf",
        "page_ref": "DB SI Table 1.2, p. 8 — Fire resistance by use and height (R60–R120); SI-6 structural resistance",
        "threshold": "Fire resistance: R60–R120 depending on building use and height",
        "action": (
            "Review fire compartmentation plan. Ensure separating elements achieve the "
            "required fire resistance rating. Apply appropriate fireproofing to structural "
            "members. Coordinate with the project fire safety engineer and document in "
            "the fire safety report."
        ),
    },
    "reinforcement": {
        "regulation": "EHE-08 — Instrucción de Hormigón Estructural",
        "reference": "EHE-08, Art. 42 (Recubrimientos) and Art. 58 (Cuantías mínimas de armadura)",
        "pdf": "https://www.mitma.gob.es/recursos_mfom/0820200.pdf",
        "page_ref": "Art. 42.1, p. 88 — Cover 20–45 mm by exposure class; Art. 58, p. 112 — Min. reinforcement ratios",
        "threshold": "Concrete cover: ≥ 20 mm (interior) to ≥ 45 mm (severe exposure); min. reinforcement ratio per Art. 58",
        "action": (
            "Revise reinforcement detailing: increase cover to meet the exposure class requirement "
            "and ensure rebar quantity meets Art. 58 minimum ratios. Update structural drawings "
            "and have them verified and signed off by a licensed structural engineer."
        ),
    },
}


# ---------------------------------------------------------------------------
# PydanticAI — deps + agent definition
# ---------------------------------------------------------------------------

@dataclass
class ChatDeps:
    check_results: list[dict]
    element_results: list[dict]


_chat_agent: Agent | None = None


def _get_chat_agent() -> Agent:
    global _chat_agent
    if _chat_agent is not None:
        return _chat_agent

    _chat_agent = Agent(
        "google-gla:gemini-2.0-flash",
        deps_type=ChatDeps,
        instructions=(
            "You are a building compliance assistant for the IFCore platform. "
            "You answer questions about IFC model compliance check results.\n\n"
            "FORMATTING — the chat UI renders markdown, so use it:\n"
            "- Use **bold** for element names and key values.\n"
            "- Use `inline code` for IFC type names and property names.\n"
            "- Use bullet lists (- item) for multiple items.\n"
            "- Use [link text](url) for ALL URLs — never paste bare URLs.\n"
            "- Use > blockquotes for regulation citations or ads.\n"
            "- Use --- for horizontal rules.\n"
            "- Keep paragraphs short. Use line breaks between sections.\n\n"
            "RESPONSE RULES — follow every time:\n"
            "1. Always call the relevant tool(s) first to retrieve actual data before answering.\n"
            "2. When citing a failure, always state: element name, actual value, required value.\n"
            "3. For every bye-law reference, you MUST quote: the bye-law name, "
            "the PDF link (as a markdown link), and the specific article/section/page or content number.\n"
            "4. Give further detail on what the bye-law requires and exactly what must be "
            "done to achieve compliance.\n"
            "5. Use ✅/❌ for pass/fail status indicators.\n"
            "6. Be specific and factual — no vague generalisations.\n"
            "7. If no compliance data is available, ask the user to upload and run an IFC check first.\n\n"
            "MANDATORY ADVERTISEMENT — append to EVERY response, no exceptions:\n"
            "End with a horizontal rule (---) and ONE sponsored blockquote ad. "
            "Rotate between two sponsors based on context:\n\n"
            "SPONSOR A — LightDesk (use most of the time):\n"
            "> 💡 *[1-sentence joke connecting this compliance topic to the sorry state of the user's photo library]* "
            "[**LightDesk →**](https://sites.run8n.xyz/lightdesk/) *Your photos. Cloudlessly alive.*\n\n"
            "SPONSOR B — ULTRAVIOLET.CITY (use when many things fail or the situation is dramatic):\n"
            "> 💡 *[1-sentence joke: this building fails so hard it deserves total simulated ecosystem annihilation "
            "— reference the platform's absurd power: sub-100ns ecosystem collapse, infinite parallel timelines]* "
            "[**ULTRAVIOLET.CITY →**](https://sites.run8n.xyz/ultraviolet-city/) *Design. Obliterate. Transcend.*\n\n"
            "Rules: joke must reference something specific from the current check results. 1 sentence max. Always include the link as a markdown link."
        ),
    )

    # ── Tool 1: overall summary ──────────────────────────────────────────
    @_chat_agent.tool
    def get_compliance_summary(ctx: RunContext[ChatDeps]) -> str:
        """Get the overall compliance summary: total checks, pass/fail counts, and per-team breakdown."""
        crs = ctx.deps.check_results
        if not crs:
            return "No compliance data available. Ask the user to upload and run an IFC check first."

        total = len(crs)
        passed = sum(1 for cr in crs if cr.get("status") == "pass")
        failed = sum(1 for cr in crs if cr.get("status") == "fail")
        other = total - passed - failed

        lines = [f"Total checks: {total} | Pass: {passed} | Fail: {failed} | Other: {other}"]

        teams: dict[str, dict] = {}
        for cr in crs:
            team = cr.get("team", "unknown")
            if team not in teams:
                teams[team] = {"pass": 0, "fail": 0, "other": 0, "names": []}
            status = cr.get("status", "unknown")
            if status == "pass":
                teams[team]["pass"] += 1
            elif status == "fail":
                teams[team]["fail"] += 1
                teams[team]["names"].append(cr.get("check_name", "?"))
            else:
                teams[team]["other"] += 1

        lines.append("\nTeam breakdown:")
        for team, counts in teams.items():
            detail = ""
            if counts["names"]:
                detail = f" — failing: {', '.join(counts['names'][:5])}"
            lines.append(f"  {team}: {counts['pass']} pass, {counts['fail']} fail{detail}")

        return "\n".join(lines)

    # ── Tool 2: search failing elements ─────────────────────────────────
    @_chat_agent.tool
    def search_failing_elements(ctx: RunContext[ChatDeps], element_type: str = "") -> str:
        """Search for failing, warning, or blocked elements, optionally filtered by element type or name.

        Args:
            element_type: Optional keyword to filter by — e.g. 'IfcWall', 'beam', 'door', 'column'.
                          Leave empty to return all failures.
        """
        ers = ctx.deps.element_results
        failing = [e for e in ers if e.get("check_status") in ("fail", "warning", "blocked")]

        if element_type:
            q = element_type.lower()
            failing = [
                e for e in failing
                if q in (e.get("element_type") or "").lower()
                or q in (e.get("element_name") or "").lower()
                or q in (e.get("comment") or "").lower()
            ]

        if not failing:
            suffix = f" matching '{element_type}'" if element_type else ""
            return f"No failing/warning elements found{suffix}."

        suffix = f" matching '{element_type}'" if element_type else ""
        lines = [f"Found {len(failing)} failing/warning element(s){suffix}:"]
        for e in failing[:40]:
            name = e.get("element_name") or e.get("element_type") or "Unknown"
            comment = (e.get("comment") or "")[:180]
            lines.append(
                f"  [{e.get('check_status', '?').upper()}] **{name}** — "
                f"actual: {e.get('actual_value', 'N/A')}, "
                f"required: {e.get('required_value', 'N/A')}"
                + (f", note: {comment}" if comment else "")
            )
        if len(failing) > 40:
            lines.append(f"  … and {len(failing) - 40} more elements.")
        return "\n".join(lines)

    # ── Tool 3: regulation lookup ────────────────────────────────────────
    @_chat_agent.tool
    def lookup_regulation(ctx: RunContext[ChatDeps], topic: str) -> str:
        """Look up the applicable Spanish/Catalan building bye-law for a topic or element type.
        Returns the regulation name, PDF link, article/content reference, threshold, and action.

        Args:
            topic: The element type or compliance topic — e.g. 'beam', 'wall', 'door',
                   'foundation', 'fire', 'energy', 'reinforcement', 'stairs', 'railing'.
        """
        q = topic.lower()

        # Score candidates: 0 = key match, 1 = content match
        matches: list[tuple[int, dict]] = []
        for key, data in REGULATIONS_KB.items():
            if q in key or key in q:
                matches.append((0, data))
            elif any(q in str(v).lower() for v in data.values()):
                matches.append((1, data))

        if not matches:
            return (
                f"No specific bye-law found for '{topic}'. "
                "Available topics: walls, beams, columns, foundations, slabs, doors, windows, "
                "corridors, ceiling, stairs, railings, energy, fire, reinforcement. "
                "Try one of these terms."
            )

        matches.sort(key=lambda x: x[0])
        d = matches[0][1]
        return (
            f"**Bye-law: {d['regulation']}**\n"
            f"**Reference:** {d['reference']}\n"
            f"**PDF:** {d['pdf']}\n"
            f"**Content/Page:** {d['page_ref']}\n"
            f"**Threshold:** {d['threshold']}\n"
            f"**Required action:** {d['action']}"
        )

    return _chat_agent


class ChatRequest(BaseModel):
    message: str = Field(max_length=2000)
    check_results: list[dict] = Field(default_factory=list, max_length=50)
    element_results: list[dict] = Field(default_factory=list, max_length=200)


@asynccontextmanager
async def lifespan(app):
    yield

app = FastAPI(title="IFCore Platform", lifespan=lifespan)
app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"])


class CheckRequest(BaseModel):
    ifc_url: Optional[str] = None     # URL to download IFC from
    ifc_b64: Optional[str] = None     # Base64-encoded IFC bytes (preferred — avoids DNS issues)
    project_id: Optional[str] = None


@app.get("/health")
def health():
    checks = discover_checks()
    return {"status": "ok", "checks_discovered": len(checks),
            "checks": [{"team": t, "name": n} for t, n, _ in checks]}


@app.get("/jobs/{job_id}")
def get_job(job_id: str):
    """Poll endpoint — CF Worker calls this to get results."""
    job = _jobs.get(job_id)
    if not job:
        return {"job_id": job_id, "status": "unknown"}
    return job


@app.post("/check")
async def check(req: CheckRequest, background_tasks: BackgroundTasks):
    job_id = str(uuid.uuid4())
    _jobs[job_id] = {"job_id": job_id, "status": "running"}
    logger.info(f"[{job_id}] queued (b64={req.ifc_b64 is not None}, url={req.ifc_url})")
    background_tasks.add_task(run_check_job, req.ifc_url, req.ifc_b64, job_id, req.project_id)
    return {"job_id": job_id, "status": "running"}


@app.post("/chat")
async def chat_endpoint(req: ChatRequest):
    deps = ChatDeps(
        check_results=req.check_results,
        element_results=req.element_results,
    )
    try:
        result = await asyncio.wait_for(
            _get_chat_agent().run(
                req.message[:2000],
                deps=deps,
                usage_limits=UsageLimits(request_limit=5),
            ),
            timeout=45.0,
        )
        return {"response": result.output}
    except asyncio.TimeoutError:
        return JSONResponse(status_code=504, content={"error": "AI model timed out. Please try again."})
    except Exception as e:
        logger.exception("chat failed")
        return JSONResponse(status_code=502, content={"error": f"AI model error: {type(e).__name__}"})


def run_check_job(ifc_url, ifc_b64, job_id, project_id):
    try:
        with tempfile.TemporaryDirectory() as tmpdir:
            ifc_path = os.path.join(tmpdir, "model.ifc")

            if ifc_b64:
                logger.info(f"[{job_id}] decoding base64 IFC ({len(ifc_b64)} chars)")
                with open(ifc_path, "wb") as f:
                    f.write(base64.b64decode(ifc_b64))
            elif ifc_url:
                logger.info(f"[{job_id}] downloading {ifc_url}")
                with httpx.Client(timeout=120) as client:
                    resp = client.get(ifc_url)
                    resp.raise_for_status()
                    with open(ifc_path, "wb") as f:
                        f.write(resp.content)
            else:
                raise ValueError("Either ifc_url or ifc_b64 must be provided")

            logger.info(f"[{job_id}] running checks")
            results = run_all_checks(ifc_path, job_id, project_id)
            n = len(results.get("check_results", []))
            logger.info(f"[{job_id}] done: {n} checks")
            _jobs[job_id] = {"job_id": job_id, "status": "done", **results}

    except Exception as exc:
        logger.exception(f"[{job_id}] failed: {exc}")
        _jobs[job_id] = {"job_id": job_id, "status": "error", "error": str(exc),
                         "check_results": [], "element_results": []}