File size: 19,700 Bytes
dc1b199
 
 
970bad4
3f046da
dc1b199
 
faa8fb3
f06fd2b
 
dc1b199
 
32c4506
f06fd2b
3c31a2a
dc1b199
b76f199
 
 
 
 
 
 
 
 
 
 
 
3c31a2a
dc1b199
 
837762d
dc1b199
 
5618b02
dc1b199
 
5618b02
 
 
 
dc1b199
 
 
31cee57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
970bad4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31cee57
 
 
 
970bad4
 
 
31cee57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
 
31cee57
dc1b199
 
 
 
5618b02
 
 
3f6fdc5
 
 
3f046da
b76f199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
3f046da
 
 
faa8fb3
 
3f046da
 
faa8fb3
 
3f046da
 
faa8fb3
3f046da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
faa8fb3
3f046da
 
 
 
 
 
 
 
 
 
 
 
 
 
 
b76f199
 
7fa723a
b76f199
 
 
 
 
 
 
 
 
7fa723a
 
 
 
 
 
 
 
 
 
 
b76f199
 
 
377c1bc
 
 
 
 
 
 
 
 
 
 
732b14f
 
 
 
 
 
 
 
 
 
 
dc1b199
 
732b14f
 
 
 
dc1b199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3f046da
 
 
 
 
 
 
 
 
 
 
 
dc1b199
 
3f6fdc5
 
dc1b199
 
 
 
 
 
b76f199
 
0908f70
569bceb
dc1b199
b76f199
 
 
dc1b199
3c31a2a
dc1b199
f06fd2b
32c4506
f06fd2b
 
 
32c4506
 
 
 
 
 
 
3c31a2a
 
d611f45
3c31a2a
 
837762d
 
3c31a2a
b76f199
faa8fb3
 
3c31a2a
 
 
 
d611f45
3c31a2a
d611f45
3c31a2a
 
d611f45
3c31a2a
 
 
 
d611f45
3c31a2a
 
 
 
 
d611f45
3c31a2a
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
be9fd4a
7fa723a
 
 
 
732b14f
 
 
 
be9fd4a
 
 
 
 
 
732b14f
 
 
 
 
 
 
7fa723a
be9fd4a
 
732b14f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3c31a2a
 
 
d611f45
 
 
faa8fb3
 
732b14f
 
 
 
 
 
 
 
 
 
 
b76f199
7fa723a
be9fd4a
7fa723a
be9fd4a
732b14f
 
be9fd4a
 
 
 
 
 
 
 
 
 
 
 
 
d611f45
3c31a2a
dc1b199
f06fd2b
 
 
 
dc1b199
 
 
 
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
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
"""FastAPI application factory and entry point."""

import logging
import os
import uuid
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from datetime import UTC
from pathlib import Path

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
from sqlalchemy import text

from app.api import (
    agentic,
    canonical_rollout,
    catalog,
    content_similarity,
    generate,
    photos,
    rag_runtime,
    status,
    survey_level,
    upload,
)
from app.api import export as export_api
from app.api.middleware import TenantAuthMiddleware
from app.config import settings
from app.db.database import get_session_factory, init_db

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(name)s — %(message)s",
)
logging.getLogger("aiosqlite").setLevel(logging.WARNING)
logging.getLogger("sqlalchemy.engine").setLevel(
    logging.INFO if settings.dev_mode else logging.WARNING
)
logger = logging.getLogger(__name__)


# Sentinel values for `tenant_secret_key` that must never be allowed to ship
# to a real tenant. We surface a loud WARNING (not a hard fail) because the
# setting is currently inert — declared in `Settings` but not consumed by any
# signing/verification path. The day someone wires it into tenant-token
# signing, this guard becomes a fail-fast and they get the right behaviour
# for free. Until then, a noisy warning is the correct severity.
_DEFAULT_TENANT_SECRETS: frozenset[str] = frozenset(
    {
        "",
        "dev-secret-change-me",
        "change-me-in-production",
    }
)


def _validate_production_settings() -> None:
    """Refuse to boot a production deployment with sentinel/empty secrets.

    Runs only when ``DEV_MODE=false`` (the Dockerfile's default). In dev mode
    the conftest sets ``DEV_MODE=true`` and ``OPENAI_API_KEY=""`` on purpose,
    so this guard is a no-op for the test suite.

    Raises:
        RuntimeError: if a hard requirement is missing in production. The
            error message is explicit about which env var to set, so the
            operator sees a fixable cause in the deploy logs rather than a
            silent degradation hours later.
    """
    if settings.dev_mode:
        return

    api_key = (settings.openai_api_key or "").strip()
    if not api_key and os.environ.get("SPACE_ID"):
        api_key = (os.environ.get("OPENAI_API_KEY") or "").strip()
        if api_key:
            settings.openai_api_key = api_key

    if not api_key:
        hf_hint = (
            " Hugging Face Space: Settings → Variables and secrets → Secrets → "
            "New secret, name exactly OPENAI_API_KEY, value sk-…, Save, then "
            "Restart Space (secrets load on boot only)."
        )
        on_hf = bool(os.environ.get("SPACE_ID"))
        if on_hf or os.environ.get("ALLOW_MISSING_OPENAI_API_KEY", "").lower() in (
            "1",
            "true",
            "yes",
        ):
            logger.error(
                "OPENAI_API_KEY is not set — the Space will start but AI generation, "
                "inspector, and vision are disabled until you add the secret and restart.%s",
                hf_hint if on_hf else "",
            )
            return
        raise RuntimeError(
            "OPENAI_API_KEY is required when DEV_MODE=false. "
            "Without it the agentic inspector silently falls back to keyword "
            "heuristics and reports become low quality. "
            "Set OPENAI_API_KEY in your hosting platform's secrets"
            f"{hf_hint if on_hf else ''} "
            "or set DEV_MODE=true to suppress this check (development only)."
        )

    if settings.tenant_secret_key.strip() in _DEFAULT_TENANT_SECRETS:
        logger.warning(
            "TENANT_SECRET_KEY is unset or using a default sentinel value. "
            "It is currently inert (no signing path reads it) but you should "
            "set a non-default value before any signing logic is added."
        )

    if not settings.knowledge_base_enabled:
        logger.warning(
            "KNOWLEDGE_BASE_ENABLED=false in production: the agentic inspector "
            "loop's KB-grounding tool returns nothing, so generated reports "
            "rely solely on tenant uploads. This is the expected default for "
            "the HF Spaces free-tier deploy (KB source PDFs are gitignored)."
        )


@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
    """Initialise resources on startup; release them on shutdown."""
    _validate_production_settings()
    logger.info("Initialising database…")
    await init_db()
    settings.upload_dir.mkdir(parents=True, exist_ok=True)
    settings.cache_dir.mkdir(parents=True, exist_ok=True)
    logger.info("Pre-warming vector store…")
    from app.vectorstore.factory import get_vectorstore
    get_vectorstore()
    logger.info("Pre-warming embedding model…")
    from app.embeddings.factory import get_embedding_client
    get_embedding_client()

    # Optional: ingest local standards/exemplar corpus (Behrang + RAW Context) into a reserved tenant.
    # Run this in the background so the API becomes reachable immediately.
    try:
        import anyio

        from app.services.knowledge_base import upsert_knowledge_base

        async def _kb_job() -> None:
            try:
                await anyio.to_thread.run_sync(upsert_knowledge_base)
            except Exception:  # noqa: BLE001
                logger.exception("Knowledge base ingest failed")

        if settings.knowledge_base_enabled:
            anyio.create_task_group  # keep import used for type checkers
            # Fire-and-forget
            import asyncio

            asyncio.create_task(_kb_job())
    except Exception:
        logger.exception("Knowledge base ingest scheduling failed")

    import asyncio

    from app.services.generation_stale import generation_stale_sweeper_loop

    if int(getattr(settings, "generation_stale_sweep_seconds", 120)) > 0:
        asyncio.create_task(generation_stale_sweeper_loop())

    # Resume any pending/processing ingests after restarts so the UI doesn't
    # hang indefinitely on "pending" rows created by earlier batch uploads.
    try:
        from datetime import datetime

        from sqlalchemy import select

        from app.db.models import Document as DBDocument
        from app.db.models import IngestStatus
        from app.ingest.schedule import schedule_ingest

        now = datetime.now(UTC)
        stale_s = int(settings.ingest_timeout_seconds)

        async with get_session_factory()() as db:
            result = await db.execute(
                select(DBDocument).where(DBDocument.status.in_([IngestStatus.pending, IngestStatus.processing]))
            )
            rows = result.scalars().all()
            resumed = 0
            marked_failed = 0
            for d in rows:
                if d.status == IngestStatus.pending:
                    schedule_ingest(doc_id=d.id, file_path=Path(d.file_path))
                    resumed += 1
                elif d.status == IngestStatus.processing:
                    updated = d.updated_at
                    if updated is not None and updated.tzinfo is None:
                        updated = updated.replace(tzinfo=UTC)
                    age_s = (now - updated).total_seconds() if updated else 0
                    if age_s > stale_s:
                        d.status = IngestStatus.failed
                        d.error_message = (
                            f"Ingestion timed out after {stale_s}s during processing. "
                            "Restarted server detected a stale ingest."
                        )
                        marked_failed += 1
            if marked_failed:
                await db.commit()
        if resumed or marked_failed:
            logger.info("Startup ingest resume: queued=%d stale_failed=%d", resumed, marked_failed)
    except Exception:
        logger.exception("Startup ingest resume failed")

    try:
        from app.agentic.runtime_status import inspector_public_status
        from app.optimization.ai_readiness import ai_features_status, collect_ai_feature_warnings

        ins = inspector_public_status()
        logger.info(
            "RICS inspector runtime: mode=%s key=%s flag=%s — %s",
            ins["effective_mode"],
            ins["openai_api_key_configured"],
            ins["inspector_tool_agent"],
            ins["summary"],
        )
        ai = ai_features_status()
        logger.info(
            "AI features: inspector_live=%s post_generate_inspector=%s vision_live=%s "
            "rag_sanitise_llm=%s",
            ai["inspector_live"],
            ai["post_generate_uses_inspector"],
            ai["section_photo_vision_live"],
            ai["enable_rag_upload_sanitisation"] and ai["rag_sanitisation_use_llm"],
        )
        for msg in collect_ai_feature_warnings():
            logger.warning("AI readiness: %s", msg)
    except Exception:  # noqa: BLE001
        logger.exception("Could not log RICS inspector status")

    # Optional: build reference style profile from the KB corpus.
    # Non-blocking: if the KB hasn't been ingested yet (or no key is set), this is a no-op.
    try:
        import asyncio

        from app.services.reference_style import seed_reference_style_profile

        asyncio.create_task(seed_reference_style_profile())
    except Exception:  # noqa: BLE001
        logger.exception("Reference style seeding failed")

    from app.redis_client import redis_configured

    if redis_configured():
        try:
            from app.redis_client import get_redis

            await get_redis()
            logger.info("Redis available for rate limits / job queue")
        except Exception as exc:  # noqa: BLE001
            logger.warning("Redis configured but not reachable at startup: %s", exc)

    logger.info("Startup complete.")
    yield
    from app.redis_client import close_redis, redis_configured as _redis_cfg

    if _redis_cfg():
        await close_redis()
    logger.info("Shutdown complete.")


def create_app() -> FastAPI:
    """Build and return the configured FastAPI application.

    Returns:
        A fully configured FastAPI instance with routers and middleware.

    Example::

        app = create_app()
    """
    app = FastAPI(
        title="Report Genius AI",
        description="RICS-style RAG report generation API",
        version="0.1.0",
        lifespan=lifespan,
    )

    @app.exception_handler(Exception)
    async def _unhandled_exception_handler(request, exc):  # type: ignore[no-untyped-def]
        error_id = str(uuid.uuid4())
        logger.exception("Unhandled error_id=%s path=%s", error_id, getattr(request, "url", "unknown"))
        return JSONResponse(
            status_code=500,
            content={
                "detail": "Internal Server Error",
                "error_id": error_id,
            },
        )

    app.add_middleware(
        CORSMiddleware,
        allow_origins=settings.allowed_origins,
        allow_credentials=False,
        allow_methods=["*"],
        allow_headers=["*"],
    )
    app.add_middleware(TenantAuthMiddleware)

    app.include_router(upload.router, tags=["upload"])
    app.include_router(survey_level.router, tags=["upload"])
    app.include_router(catalog.router, tags=["templates"])
    app.include_router(content_similarity.router, tags=["content"])
    app.include_router(canonical_rollout.router, tags=["content"])
    app.include_router(generate.router, tags=["generate"])
    app.include_router(agentic.router, tags=["agentic"])
    app.include_router(photos.router, tags=["photos"])
    app.include_router(rag_runtime.router, tags=["rag"])
    app.include_router(status.router, tags=["status"])
    app.include_router(export_api.router, tags=["export"])

    _frontend = Path(__file__).parent.parent / "frontend"
    _favicon = _frontend / "favicon.ico"
    if _frontend.exists():
        app.mount("/static", StaticFiles(directory=str(_frontend)), name="static")

    @app.get("/favicon.ico", include_in_schema=False, response_model=None)
    async def favicon() -> FileResponse | Response:
        """Serve site icon at root path (browsers request /favicon.ico by default)."""
        if _favicon.is_file():
            return FileResponse(str(_favicon), media_type="image/x-icon")
        return Response(status_code=204)

    @app.get("/health", include_in_schema=True, summary="Deep health check")
    async def health_check() -> JSONResponse:
        """Return system health status with component-level detail.

        Returns HTTP 200 when all components are healthy, HTTP 503 when any
        component is degraded. Safe to call without authentication.
        Component errors are logged server-side but are not exposed in detail.
        """
        from app.agentic.runtime_status import inspector_public_status
        from app.vectorstore.factory import VECTORSTORE_BACKEND_LABEL

        components: dict[str, str] = {}
        overall_ok = True

        try:
            async with get_session_factory()() as db:
                await db.execute(text("SELECT 1"))
            components["database"] = "ok"
        except Exception as exc:  # noqa: BLE001
            logger.warning("Health check: database unreachable — %s", exc)
            components["database"] = "error"
            overall_ok = False

        try:
            from app.vectorstore.factory import get_vectorstore

            vs = get_vectorstore()
            vs.count(tenant_id="__healthcheck__")
            components["vectorstore"] = "ok"
        except Exception as exc:  # noqa: BLE001
            logger.warning("Health check: vectorstore unreachable — %s", exc)
            components["vectorstore"] = "error"
            overall_ok = False

        if settings.enable_temporal_workflow:
            try:
                from temporalio.client import Client

                client = await Client.connect(
                    settings.temporal_host,
                    namespace=settings.temporal_namespace,
                )
                await client.service_client.check_health()
                components["temporal"] = "ok"
            except Exception as exc:  # noqa: BLE001
                logger.warning("Health check: Temporal unreachable — %s", exc)
                components["temporal"] = "error"
                overall_ok = False
        else:
            components["temporal"] = "disabled"

        backend = (settings.vectorstore_backend or "faiss").strip().lower()
        if backend == "qdrant":
            try:
                from qdrant_client import QdrantClient

                qclient = QdrantClient(
                    url=settings.qdrant_url,
                    api_key=settings.qdrant_api_key or None,
                )
                qclient.get_collections()
                components["qdrant"] = "ok"
            except Exception as exc:  # noqa: BLE001
                logger.warning("Health check: Qdrant unreachable — %s", exc)
                components["qdrant"] = "error"
                overall_ok = False

        from app.api.rate_limit import rate_limit_backend_label
        from app.optimization.ai_phases import collect_ai_phase_warnings, collect_ai_phases
        from app.optimization.ai_readiness import (
            ai_features_status,
            collect_ai_feature_warnings,
        )
        from app.optimization.health_warnings import (
            collect_optimization_hints,
            collect_optimization_warnings,
        )
        from app.optimization.scale_status import (
            collect_scale_feature_warnings,
            phase2_status,
            phase3_status,
            scale_optimization_active,
        )
        from app.redis_client import (
            generation_queue_depth,
            job_queue_enabled,
            redis_configured,
            redis_health_ok,
        )

        ai_warnings = collect_ai_feature_warnings()
        ai_phase_warnings = collect_ai_phase_warnings()
        opt_warnings = collect_optimization_warnings() + collect_scale_feature_warnings()
        opt_hints = collect_optimization_hints()
        queue_depth: int | None = None
        if redis_configured():
            components["redis"] = "ok" if await redis_health_ok() else "error"
            if components["redis"] == "error":
                overall_ok = False
            queue_depth = await generation_queue_depth()
            if (
                job_queue_enabled()
                and queue_depth is not None
                and queue_depth > 0
            ):
                opt_warnings.append(
                    f"Generation job queue has {queue_depth} pending job(s); "
                    "ensure jobs_worker is running."
                )
        else:
            components["redis"] = "disabled"
        components["job_queue"] = "active" if job_queue_enabled() else "disabled"
        components["rate_limit_backend"] = rate_limit_backend_label()
        status_code = 200 if overall_ok else 503
        return JSONResponse(
            status_code=status_code,
            content={
                "status": "ok" if overall_ok else "degraded",
                "components": components,
                "vectorstore_backend": VECTORSTORE_BACKEND_LABEL,
                "faiss_index_path": str(settings.faiss_index_path),
                "enable_async_pipeline": bool(settings.enable_async_pipeline),
                "enable_speculative_executor": bool(settings.enable_speculative_executor),
                "enable_prompt_caching": bool(settings.enable_prompt_caching),
                "enable_temporal_workflow": bool(settings.enable_temporal_workflow),
                "semantic_cache_enabled": bool(settings.semantic_cache_enabled),
                "enable_hybrid_retrieval": bool(settings.enable_hybrid_retrieval),
                "notes_only_generation": bool(settings.notes_only_generation),
                "agentic_inspector_when_notes_only": bool(
                    settings.agentic_inspector_when_notes_only
                ),
                "qdrant_url": settings.qdrant_url if backend == "qdrant" else None,
                "rics_inspector": inspector_public_status(),
                "ai_features": ai_features_status(),
                "ai_phases": collect_ai_phases(),
                "ai_warnings": ai_warnings,
                "ai_phase_warnings": ai_phase_warnings,
                "optimization_warnings": opt_warnings,
                "optimization_hints": opt_hints,
                "infrastructure": {
                    "scale_optimization_profile": scale_optimization_active(),
                    "redis_url_configured": redis_configured(),
                    "enable_job_queue": bool(settings.enable_job_queue),
                    "generation_queue_depth": queue_depth,
                    "job_queue": components.get("job_queue"),
                    "redis": components.get("redis"),
                    "rate_limit_backend": components.get("rate_limit_backend"),
                    "backend_scale": {
                        "phase2_legacy_async_flags": phase2_status(),
                        "phase3_redis_queue": phase3_status(),
                    },
                },
            },
        )

    @app.get("/", include_in_schema=False)
    async def serve_ui() -> FileResponse:
        return FileResponse(str(_frontend / "index.html"))

    return app


app = create_app()