File size: 12,719 Bytes
7131bbe
 
 
 
 
cbb6866
7131bbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7dedc8d
7131bbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
cbb6866
 
 
 
 
 
 
 
 
 
 
413c5fe
 
 
 
 
 
 
 
 
 
 
 
 
7131bbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
74ee21a
347a4d6
7131bbe
 
 
7dedc8d
 
 
 
cbb6866
 
413c5fe
 
 
 
 
 
 
 
 
cbb6866
2608b33
 
 
 
 
 
 
 
50a1c46
 
 
 
 
 
 
 
72e4ba4
 
 
 
 
 
 
 
5b93402
 
 
 
 
 
 
 
40cb975
 
 
 
 
 
 
 
bd6cbae
 
 
 
 
 
 
 
6609140
 
 
 
 
 
 
 
7131bbe
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""FastAPI app for ProofFrame."""

from __future__ import annotations

import os
import json
from io import BytesIO
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile

import uvicorn
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, Response
from fastapi.staticfiles import StaticFiles

from . import __version__
from .config import ConfigurationError, Settings
from .models import Asset, AssetStatus, AssetStatusUpdate, Campaign, CampaignCreate, CampaignManifest
from .providers import create_media_provider
from .repository import MemoryRepository
from .storage import create_storage_backend, manifest_to_plain_json
from .submission_gate import build_submission_gate


def frontend_index_path() -> Path | None:
    explicit_root = os.environ.get("PROOFFRAME_WEB_ROOT", "").strip()
    candidates: list[Path] = []
    if explicit_root:
        candidates.append(Path(explicit_root) / "index.html")
    candidates.extend(
        [
            Path(__file__).resolve().parents[2] / "apps" / "web" / "index.html",
            Path.cwd() / "apps" / "web" / "index.html",
        ]
    )
    for candidate in candidates:
        if candidate.exists():
            return candidate
    return None


def public_artifact_path(filename: str) -> Path | None:
    candidates = [
        Path(__file__).resolve().parents[2] / "docs" / "assets" / filename,
        Path.cwd() / "docs" / "assets" / filename,
    ]
    for candidate in candidates:
        if candidate.exists():
            return candidate
    return None


def load_public_artifact(filename: str, expected_schema: str, label: str) -> dict[str, object]:
    path = public_artifact_path(filename)
    if path is None:
        raise HTTPException(status_code=404, detail=f"{label} artifact not found")
    try:
        payload = json.loads(path.read_text(encoding="utf-8"))
    except json.JSONDecodeError as exc:
        raise HTTPException(status_code=503, detail=f"{label} artifact is invalid") from exc
    if payload.get("schema") != expected_schema:
        raise HTTPException(status_code=503, detail=f"{label} schema mismatch")
    return payload


def create_app(storage_root: Path | str | None = None, settings: Settings | None = None) -> FastAPI:
    settings = settings or Settings.from_env()
    if storage_root is not None:
        settings = Settings(
            **{
                **settings.__dict__,
                "storage_root": str(storage_root),
            }
        )
    app = FastAPI(title="ProofFrame", version=__version__)
    repo = MemoryRepository()
    generator = create_media_provider(settings)
    storage = create_storage_backend(settings)

    app.state.repo = repo
    app.state.generator = generator
    app.state.storage = storage
    app.state.settings = settings

    def persist_generated_assets(campaign: Campaign, count: int) -> list[Asset]:
        generated = generator.generate(campaign, count=max(1, min(count, 6)))
        assets: list[Asset] = []
        for media in generated:
            stored = storage.put_media(campaign.id, media)
            asset = Asset(
                campaign_id=campaign.id,
                prompt=media.prompt,
                provider=media.provider,
                model=media.model,
                storage_backend=stored.storage_backend,
                storage_key=stored.storage_key,
                public_url=stored.public_url,
                sha256=stored.sha256,
                bytes_size=stored.bytes_size,
                generation_metadata=media.generation_metadata,
            )
            assets.append(repo.add_asset(asset))
        return assets

    @app.get("/")
    def index() -> FileResponse:
        index_path = frontend_index_path()
        if index_path is None:
            raise HTTPException(status_code=404, detail="Frontend not built")
        return FileResponse(index_path)

    @app.get("/api/health")
    def health() -> dict[str, object]:
        return {
            "app": "ProofFrame",
            "version": __version__,
            "generation_backend": generator.provider,
            "storage_backend": storage.name,
            "b2_configured": bool(settings.b2_bucket and settings.b2_endpoint_url),
            "genblaze_configured": settings.genblaze_configured(),
            "genblaze_provider": settings.genblaze_provider,
            "ready": True,
        }

    @app.get("/api/submission/gate")
    def submission_gate() -> dict[str, object]:
        return build_submission_gate()

    @app.get("/api/judge/brief")
    def judge_brief() -> dict[str, object]:
        return load_public_artifact("judge-brief.json", "proofframe.judge_brief.v1", "Judge brief")

    @app.get("/api/judge/crosswalk")
    def judge_crosswalk() -> dict[str, object]:
        return load_public_artifact(
            "judge-crosswalk.json",
            "proofframe.judge_crosswalk.v1",
            "Judge crosswalk",
        )

    @app.get("/api/judge/decision-brief")
    def judge_decision_brief() -> dict[str, object]:
        return load_public_artifact(
            "judge-decision-brief.json",
            "proofframe.judge_decision_brief.v1",
            "Judge decision brief",
        )

    @app.get("/api/judge/evidence-index")
    def judge_evidence_index() -> dict[str, object]:
        return load_public_artifact(
            "judge-evidence-index.json",
            "proofframe.judge_evidence_index.v1",
            "Judge evidence index",
        )

    @app.get("/api/judge/final-closeout")
    def judge_final_closeout() -> dict[str, object]:
        return load_public_artifact(
            "final-closeout-status.json",
            "proofframe.final_closeout_status.v1",
            "Final closeout status",
        )

    @app.get("/api/judge/video-publish-kit")
    def judge_video_publish_kit() -> dict[str, object]:
        return load_public_artifact(
            "final-video-publish-kit.json",
            "proofframe.final_video_publish_kit.v1",
            "Final video publish kit",
        )

    @app.get("/api/judge/recording")
    def judge_recording() -> dict[str, object]:
        return load_public_artifact(
            "recording-assets.json",
            "proofframe.recording_assets.v1",
            "Recording assets",
        )

    @app.get("/api/judge/devpost")
    def judge_devpost() -> dict[str, object]:
        return load_public_artifact(
            "devpost-form-kit.json",
            "proofframe.devpost_form_kit.v1",
            "Devpost form kit",
        )

    @app.get("/api/judge/submission-checklist")
    def judge_submission_checklist() -> dict[str, object]:
        return load_public_artifact(
            "devpost-submission-checklist.json",
            "proofframe.devpost_submission_checklist.v1",
            "Devpost submission checklist",
        )

    @app.post("/api/campaigns", response_model=Campaign)
    def create_campaign(payload: CampaignCreate) -> Campaign:
        return repo.add_campaign(Campaign(**payload.model_dump()))

    @app.get("/api/campaigns", response_model=list[Campaign])
    def list_campaigns() -> list[Campaign]:
        return repo.list_campaigns()

    @app.post("/api/campaigns/{campaign_id}/generate", response_model=list[Asset])
    def generate_assets(campaign_id: str, count: int = 3) -> list[Asset]:
        campaign = repo.get_campaign(campaign_id)
        if campaign is None:
            raise HTTPException(status_code=404, detail="Campaign not found")
        return persist_generated_assets(campaign, count)

    @app.post("/api/demo/judge-packet")
    def create_judge_packet() -> dict[str, object]:
        campaign = repo.add_campaign(
            Campaign(
                title="Judge Ready Provenance Packet",
                audience="hackathon judges, creative leads, and storage reviewers",
                tone="credible, warm, production-minded",
                brief=(
                    "Create a public-safe launch visual packet for an arts nonprofit. "
                    "The packet should prove prompt, provider, model, storage key, checksum, "
                    "and approval state without exposing secrets."
                ),
            )
        )
        assets = persist_generated_assets(campaign, count=3)
        if assets:
            assets[0].status = AssetStatus.approved
            assets[0].risk_note = "Approved demo asset: provenance, checksum, and storage route verified."
        manifest = CampaignManifest(campaign=campaign, assets=repo.list_assets(campaign.id))
        return {
            "campaign": campaign.model_dump(mode="json"),
            "assets": [asset.model_dump(mode="json") for asset in repo.list_assets(campaign.id)],
            "manifest": manifest_to_plain_json(manifest),
        }

    @app.post("/api/assets/{asset_id}/status", response_model=Asset)
    def update_asset_status(asset_id: str, payload: AssetStatusUpdate) -> Asset:
        asset = repo.get_asset(asset_id)
        if asset is None:
            raise HTTPException(status_code=404, detail="Asset not found")
        asset.status = payload.status
        if payload.risk_note is not None:
            asset.risk_note = payload.risk_note
        return asset

    @app.get("/api/campaigns/{campaign_id}/manifest", response_model=CampaignManifest)
    def campaign_manifest(campaign_id: str) -> CampaignManifest:
        campaign = repo.get_campaign(campaign_id)
        if campaign is None:
            raise HTTPException(status_code=404, detail="Campaign not found")
        return CampaignManifest(campaign=campaign, assets=repo.list_assets(campaign_id))

    @app.post("/api/campaigns/{campaign_id}/export")
    def export_campaign(campaign_id: str) -> dict[str, object]:
        campaign = repo.get_campaign(campaign_id)
        if campaign is None:
            raise HTTPException(status_code=404, detail="Campaign not found")
        manifest = CampaignManifest(campaign=campaign, assets=repo.list_assets(campaign_id))
        stored = storage.put_manifest(campaign_id, manifest)
        return {
            "manifest": manifest_to_plain_json(manifest),
            "stored_manifest": stored.model_dump(mode="json"),
        }

    @app.get("/api/campaigns/{campaign_id}/packet.zip")
    def download_packet(campaign_id: str) -> Response:
        campaign = repo.get_campaign(campaign_id)
        if campaign is None:
            raise HTTPException(status_code=404, detail="Campaign not found")
        assets = repo.list_assets(campaign_id)
        manifest = CampaignManifest(campaign=campaign, assets=assets)
        manifest_json = manifest.model_dump_json(indent=2)
        read_bytes = getattr(storage, "read_bytes", None)

        buffer = BytesIO()
        with ZipFile(buffer, "w", ZIP_DEFLATED) as packet:
            packet.writestr("manifest.json", manifest_json)
            packet.writestr(
                "README.txt",
                "\n".join(
                    [
                        "ProofFrame Evidence Packet",
                        f"Campaign: {campaign.title}",
                        f"Campaign ID: {campaign.id}",
                        f"Storage backend: {storage.name}",
                        "",
                        "manifest.json contains prompts, providers, models, storage keys, checksums, and approval states.",
                        "Local media files are included when available. Remote B2 objects are referenced in the manifest only.",
                    ]
                ),
            )
            if callable(read_bytes):
                for asset in assets:
                    try:
                        data = read_bytes(asset.storage_key)
                    except OSError:
                        continue
                    filename = Path(asset.storage_key).name
                    packet.writestr(f"media/{filename}", data)

        return Response(
            content=buffer.getvalue(),
            media_type="application/zip",
            headers={
                "Content-Disposition": f'attachment; filename="{campaign_id}-proofframe-packet.zip"'
            },
        )

    if storage.name == "local":
        storage_path = Path(settings.storage_root)
        storage_path.mkdir(parents=True, exist_ok=True)
        app.mount("/storage", StaticFiles(directory=storage_path), name="storage")

    return app


try:
    app = create_app()
except ConfigurationError as exc:
    # Uvicorn should fail clearly when an explicitly requested integration is misconfigured.
    raise RuntimeError(str(exc)) from exc


def main() -> None:
    uvicorn.run("proofframe.app:app", host="127.0.0.1", port=8088, reload=True)


if __name__ == "__main__":
    main()