File size: 7,606 Bytes
ae74af5
3b71d95
ae74af5
 
 
3b71d95
ae74af5
 
3b71d95
ae74af5
 
df6bf75
ae74af5
df6bf75
3b71d95
4f52b3e
 
ae74af5
 
 
 
 
 
 
 
 
 
 
 
ea03df5
 
7946db4
ea03df5
df6bf75
4f52b3e
 
 
 
 
7946db4
ae74af5
 
 
 
 
 
 
 
73b5679
3b71d95
 
 
 
 
 
 
 
 
 
 
ae74af5
 
df6bf75
ae74af5
 
4f52b3e
 
 
 
d41238e
 
 
 
3b71d95
 
 
ae74af5
 
 
3b71d95
 
 
 
 
 
 
ae74af5
 
 
 
 
 
 
 
 
 
 
 
3b71d95
 
ae74af5
 
 
 
 
 
 
 
 
 
 
df6bf75
 
3b71d95
df6bf75
85f7c19
df6bf75
85f7c19
 
 
 
 
df6bf75
 
bc642b5
df6bf75
bc642b5
df6bf75
bc642b5
 
 
 
 
 
57ba197
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ae74af5
 
 
 
 
 
 
 
73b5679
ae74af5
 
 
 
 
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
import asyncio
import os
import pathlib
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi import Depends
from app.database import Database
from app.worker import worker_loop
from app.eo_products import registry
from app.api.jobs import router as jobs_router, init_router as init_jobs
from app.api.products_api import router as products_router
from app.api.auth import router as auth_router, get_current_user
from app.advisor import get_aoi_advice
from app.models import AoiAdviceRequest

# Resolve paths relative to this file so they work regardless of cwd
_HERE = pathlib.Path(__file__).resolve().parent
_FRONTEND_DIR = _HERE.parent / "frontend"
_INDEX_HTML = _FRONTEND_DIR / "index.html"


def create_app(db_path: str = "aperture.db", run_worker: bool = False) -> FastAPI:
    db = Database(db_path)

    @asynccontextmanager
    async def lifespan(app: FastAPI):
        from app.config import OPENEO_CLIENT_ID, OPENEO_CLIENT_SECRET
        if OPENEO_CLIENT_ID and OPENEO_CLIENT_SECRET:
            print(f"[Aperture] CDSE credentials configured (client_id={OPENEO_CLIENT_ID[:8]}...)")
        else:
            print("[Aperture] WARNING: CDSE credentials NOT configured β€” EO products will fail")
        from app.config import ANTHROPIC_API_KEY
        if ANTHROPIC_API_KEY:
            print("[Aperture] Anthropic API key configured (AOI advisor enabled)")
        else:
            print("[Aperture] Anthropic API key not set β€” AOI advisor disabled")
        print(f"[Aperture] SPACE_ID={os.environ.get('SPACE_ID', '<not set>')}")
        await db.init()
        worker_task = None
        if run_worker:
            worker_task = asyncio.create_task(worker_loop(db, registry))
        yield
        if worker_task is not None:
            worker_task.cancel()

    app = FastAPI(title="MERLx Aperture", lifespan=lifespan)

    # CORS β€” restrict to same-origin by default, configurable via env
    allowed_origins = os.environ.get("APERTURE_CORS_ORIGINS", "").split(",")
    allowed_origins = [o.strip() for o in allowed_origins if o.strip()]
    app.add_middleware(
        CORSMiddleware,
        allow_origins=allowed_origins or ["*"] if os.environ.get("APERTURE_DEMO", "true").lower() == "true" else [],
        allow_methods=["GET", "POST"],
        allow_headers=["Authorization", "Content-Type"],
    )

    init_jobs(db)
    app.include_router(jobs_router)
    app.include_router(products_router)
    app.include_router(auth_router)

    @app.post("/api/aoi-advice")
    async def aoi_advice(request: AoiAdviceRequest, email: str = Depends(get_current_user)):
        return await get_aoi_advice(request.bbox)

    @app.get("/health")
    async def health():
        return {"status": "ok"}

    # ── Download endpoints (auth-gated) ─────────────────────────────
    async def _verify_job_access(job_id: str, email: str):
        """Check job exists, is complete, and belongs to the user."""
        job = await db.get_job(job_id)
        if job is None:
            raise HTTPException(status_code=404, detail="Job not found")
        if job.request.email != email:
            raise HTTPException(status_code=403, detail="Not your job")
        return job

    @app.get("/api/jobs/{job_id}/report")
    async def download_report(job_id: str, email: str = Depends(get_current_user)):
        job = await _verify_job_access(job_id, email)
        if job.status.value != "complete":
            raise HTTPException(status_code=404, detail="Report not available yet")
        pdf_path = _HERE.parent / "results" / job_id / "report.pdf"
        if not pdf_path.exists():
            raise HTTPException(status_code=404, detail="Report file not found")
        return FileResponse(
            path=str(pdf_path),
            media_type="application/pdf",
            filename=f"aperture_report_{job_id}.pdf",
        )

    @app.get("/api/jobs/{job_id}/package")
    async def download_package(job_id: str, email: str = Depends(get_current_user)):
        job = await _verify_job_access(job_id, email)
        if job.status.value != "complete":
            raise HTTPException(status_code=404, detail="Package not available yet")
        zip_path = _HERE.parent / "results" / job_id / "package.zip"
        if not zip_path.exists():
            raise HTTPException(status_code=404, detail="Package file not found")
        return FileResponse(
            path=str(zip_path),
            media_type="application/zip",
            filename=f"aperture_package_{job_id}.zip",
        )

    @app.get("/api/jobs/{job_id}/maps/{product_id}")
    async def get_product_map(job_id: str, product_id: str, email: str = Depends(get_current_user)):
        await _verify_job_access(job_id, email)
        map_path = _HERE.parent / "results" / job_id / f"{product_id}_map.png"
        if not map_path.exists():
            raise HTTPException(status_code=404, detail="Map not available for this EO product")
        return FileResponse(
            path=str(map_path),
            media_type="image/png",
        )

    @app.get("/api/jobs/{job_id}/spatial/{product_id}")
    async def get_product_spatial(job_id: str, product_id: str, email: str = Depends(get_current_user)):
        await _verify_job_access(job_id, email)
        spatial_path = _HERE.parent / "results" / job_id / f"{product_id}_spatial.json"
        if not spatial_path.exists():
            raise HTTPException(status_code=404, detail="Spatial data not available for this EO product")
        import json as _json
        with open(spatial_path) as f:
            data = _json.load(f)
        from fastapi.responses import JSONResponse
        return JSONResponse(content=data)

    @app.get("/api/jobs/{job_id}/overview")
    async def get_job_overview(job_id: str, email: str = Depends(get_current_user)):
        """Return composite score + compound signals for the results dashboard.

        Reads the same files the PDF report renderer consumes so the web
        dashboard can show the same findings without re-running any analysis.
        """
        await _verify_job_access(job_id, email)
        import json as _json
        from fastapi.responses import JSONResponse

        results_dir = _HERE.parent / "results" / job_id
        overview_path = results_dir / "overview_score.json"
        signals_path = results_dir / "compound_signals.json"

        overview: dict = {}
        if overview_path.exists():
            with open(overview_path) as f:
                overview = _json.load(f)

        signals: list = []
        if signals_path.exists():
            with open(signals_path) as f:
                signals = _json.load(f)

        return JSONResponse(content={"overview": overview, "compound_signals": signals})

    # ── Static files + SPA root ───────────────────────────────────────
    if _FRONTEND_DIR.exists():
        app.mount("/static", StaticFiles(directory=str(_FRONTEND_DIR)), name="static")

        @app.get("/", response_class=HTMLResponse)
        async def serve_index():
            if _INDEX_HTML.exists():
                return HTMLResponse(content=_INDEX_HTML.read_text(encoding="utf-8"))
            return HTMLResponse(content="<h1>MERLx Aperture</h1><p>Frontend not found.</p>")

    return app


app = create_app(run_worker=True)