File size: 6,473 Bytes
2fc729c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
#!/usr/bin/env python3
"""Read-only REST API for forecaster-agent (Phase 3b).

Shares handlers with MCP (`services/mcp_handlers.py`). OpenAPI at /docs.

  uvicorn api_server:app --host 127.0.0.1 --port 8765
  # or: python api_server.py

Auth: set FORECASTER_API_KEY to require X-API-Key header (optional in local dev).
"""
from __future__ import annotations

import sys
from pathlib import Path
from typing import Annotated, Any, Optional

_ROOT = Path(__file__).resolve().parent
if str(_ROOT) not in sys.path:
    sys.path.insert(0, str(_ROOT))
sys.path = [p for p in sys.path if p != "/home/sean"]

from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse

from services.api_auth import RateLimiter, configured_api_key, verify_api_key
from services.api_schemas import ContributionSubmitRequest, JobSearchRequest, OodRequest
from services.config_loader import load_config
from services.crowd_service import (
    get_blind_contribution_target,
    get_crowd_result,
    submit_contribution,
)
from services.mcp_handlers import (
    INDUSTRIES,
    handle_get_calibration_scoreboard,
    handle_get_ood_assessment,
    handle_list_open_predictions,
    handle_search_jobs,
)

_cfg = load_config()
_api_cfg = _cfg.get("api", {})
_limiter = RateLimiter(int(_api_cfg.get("rate_limit_per_minute", 60)))

app = FastAPI(
    title="forecaster-agent API",
    version="0.6.0",
    description=(
        "Read-only calibration, OOD, job radar, and open predictions; plus crowd "
        "contributions (anti-anchoring submit flow). Speculative outputs — not "
        "financial or career advice. BUSL-1.1; commercial use requires a license."
    ),
    docs_url="/docs",
    redoc_url="/redoc",
    openapi_url="/openapi.json",
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=_api_cfg.get("cors_origins", ["http://localhost:8501", "http://127.0.0.1:8501"]),
    allow_methods=["GET", "POST"],
    allow_headers=["*"],
)


@app.exception_handler(ValueError)
async def value_error_handler(_request: Request, exc: ValueError) -> JSONResponse:
    return JSONResponse(status_code=400, content={"detail": str(exc)})


@app.exception_handler(PermissionError)
async def permission_error_handler(_request: Request, exc: PermissionError) -> JSONResponse:
    return JSONResponse(status_code=403, content={"detail": str(exc)})


async def _rate_limit(request: Request) -> None:
    _limiter.check(request)


AuthDep = Annotated[None, Depends(verify_api_key)]
LimitDep = Annotated[None, Depends(_rate_limit)]


@app.get("/health")
def health(_auth: AuthDep, _limit: LimitDep) -> dict[str, str]:
    return {"status": "ok", "auth": "required" if configured_api_key() else "open"}


@app.get("/v1/scoreboard")
def get_scoreboard(_auth: AuthDep, _limit: LimitDep) -> dict[str, Any]:
    return handle_get_calibration_scoreboard()


@app.get("/v1/ood")
def get_ood(
    _auth: AuthDep,
    _limit: LimitDep,
    scenario_json: Optional[str] = Query(
        None,
        description="JSON object merging onto evolution.CURRENT_AI_SCENARIO",
    ),
    n_bootstrap: Optional[int] = Query(None, ge=1, le=200),
) -> dict[str, Any]:
    return handle_get_ood_assessment(scenario_json=scenario_json, n_bootstrap=n_bootstrap)


@app.post("/v1/ood")
def post_ood(body: OodRequest, _auth: AuthDep, _limit: LimitDep) -> dict[str, Any]:
    scenario = body.scenario.to_dict() if body.scenario else None
    return handle_get_ood_assessment(
        n_bootstrap=body.n_bootstrap,
        scenario=scenario,
    )


@app.get("/v1/jobs/search")
def search_jobs_get(
    _auth: AuthDep,
    _limit: LimitDep,
    query: str = "",
    industry: str = Query("All", description=f"One of: {', '.join(INDUSTRIES)}"),
    limit: int = Query(10, ge=1, le=50),
    scenario_json: Optional[str] = None,
) -> dict[str, Any]:
    try:
        return handle_search_jobs(
            query=query, industry=industry, limit=limit, scenario_json=scenario_json
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e


@app.post("/v1/jobs/search")
def search_jobs_post(body: JobSearchRequest, _auth: AuthDep, _limit: LimitDep) -> dict[str, Any]:
    try:
        body.validated_industry()
        scenario = body.scenario.to_dict() if body.scenario else None
        return handle_search_jobs(
            query=body.query,
            industry=body.industry,
            limit=body.limit,
            scenario=scenario,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e


@app.get("/v1/predictions/open")
def list_predictions(
    _auth: AuthDep,
    _limit: LimitDep,
    limit: int = Query(20, ge=1, le=100),
) -> dict[str, Any]:
    return handle_list_open_predictions(limit=limit)


@app.get("/v1/predictions/{target_id}/contribute")
def get_contribution_target(
    target_id: str,
    _auth: AuthDep,
    _limit: LimitDep,
) -> dict[str, Any]:
    try:
        return get_blind_contribution_target(target_id)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e)) from e


@app.post("/v1/predictions/{target_id}/contributions")
def post_contribution(
    target_id: str,
    body: ContributionSubmitRequest,
    _auth: AuthDep,
    _limit: LimitDep,
) -> dict[str, Any]:
    try:
        return submit_contribution(
            target_id=target_id,
            contributor_id=body.contributor_id,
            probability=body.probability,
            argument=body.argument,
            evidence_urls=body.evidence_urls,
            cfg=_cfg,
        )
    except ValueError as e:
        raise HTTPException(status_code=400, detail=str(e)) from e


@app.get("/v1/predictions/{target_id}/crowd")
def get_prediction_crowd(
    target_id: str,
    _auth: AuthDep,
    _limit: LimitDep,
    contributor_id: str = Query(..., min_length=1),
) -> dict[str, Any]:
    try:
        return get_crowd_result(target_id, contributor_id)
    except PermissionError as e:
        raise HTTPException(status_code=403, detail=str(e)) from e
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e)) from e


def main() -> None:
    import uvicorn

    host = _api_cfg.get("host", "127.0.0.1")
    port = int(_api_cfg.get("port", 8765))
    uvicorn.run("api_server:app", host=host, port=port, reload=False)


if __name__ == "__main__":
    main()