StockAnalysis / external_api.py
igardner's picture
feat: add external analysis adapter and local JSON parser
ed8fc9e
Raw History Blame Contribute Delete
5.49 kB
"""Adapter between the Gradio app and the external stock-analysis project.
The external project is not hard-coded. Set ``EXTERNAL_API_URL`` to its HTTP
endpoint when it is ready. The app sends a small JSON request, keeps the
upstream body, and passes JSON through unchanged. Non-JSON bodies are wrapped
so the Gradio JSON output always has a stable shape.
This module is the only place to change when the external request or response
contract changes.
"""
from __future__ import annotations
import os
from dataclasses import dataclass
from typing import Any
import requests
DEFAULT_TIMEOUT_SECONDS = 120.0
_settings: dict[str, Any] = {
"url": "",
"timeout": DEFAULT_TIMEOUT_SECONDS,
"model_id": "",
}
@dataclass(frozen=True)
class ExternalResult:
"""Normalized result returned by :func:`query_external`."""
payload: Any
raw: str
http_status: int | None = None
content_type: str | None = None
is_json: bool = False
error: str | None = None
def configure(
*,
url: str | None = None,
timeout: float | None = None,
model_id: str | None = None,
) -> dict[str, Any]:
"""Apply settings from ``app.py``. All values are optional."""
if url is not None:
_settings["url"] = str(url).strip()
if timeout is not None:
_settings["timeout"] = float(timeout)
if model_id is not None:
_settings["model_id"] = str(model_id).strip()
return settings()
def settings() -> dict[str, Any]:
"""Return a copy of the current settings, without any credentials."""
return {
"configured": is_configured(),
"timeout": float(_settings["timeout"]),
"model_id": _resolved_model_id(),
}
def is_configured() -> bool:
return bool(_resolved_url())
def model_id() -> str:
return _resolved_model_id()
def _resolved_url() -> str:
return str(_settings["url"] or os.getenv("EXTERNAL_API_URL", "")).strip()
def _resolved_model_id() -> str:
return str(_settings["model_id"] or os.getenv("STOCK_MODEL_ID", "")).strip()
def _token() -> str | None:
return os.getenv("HF_TOKEN") or None
def _redact(text: str, token: str | None) -> str:
if token:
text = text.replace(token, "hf_***")
return text
def _headers(token: str | None) -> dict[str, str]:
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "StockAnalysis-Space/1.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
return headers
def _not_configured(ticker: str) -> ExternalResult:
message = "Set EXTERNAL_API_URL to the stock-analysis project endpoint."
return ExternalResult(
payload={
"status": "not_configured",
"ticker": ticker,
"message": message,
},
raw="",
error=message,
)
def query_external(
ticker: str,
horizon: str,
risk_profile: str,
question: str,
) -> ExternalResult:
"""Send one analysis request to the external project.
The request body is intentionally small and easy to map onto another
service. If ``STOCK_MODEL_ID`` is set, it is included as ``model`` so the
external project can select the uploaded custom model.
"""
url = _resolved_url()
if not url:
return _not_configured(ticker)
token = _token()
payload: dict[str, Any] = {
"ticker": ticker,
"horizon": horizon,
"risk_profile": risk_profile,
"question": question,
}
resolved_model = _resolved_model_id()
if resolved_model:
payload["model"] = resolved_model
try:
response = requests.post(
url,
json=payload,
headers=_headers(token),
timeout=float(_settings["timeout"]),
)
except requests.RequestException as exc:
message = _redact(f"{type(exc).__name__}: {exc}", token)
return ExternalResult(
payload={
"status": "error",
"ticker": ticker,
"error": message,
},
raw="",
error=message,
)
raw = response.text or ""
content_type = response.headers.get("content-type", "").split(";")[0].strip() or None
try:
parsed = response.json()
except ValueError:
parsed = None
if not response.ok:
message = _redact(f"HTTP {response.status_code}: {raw[:500]}", token)
return ExternalResult(
payload={
"status": "error",
"ticker": ticker,
"http_status": response.status_code,
"content_type": content_type,
"error": message,
"raw": raw,
},
raw=raw,
http_status=response.status_code,
content_type=content_type,
error=message,
)
if parsed is not None:
return ExternalResult(
payload=parsed,
raw=raw,
http_status=response.status_code,
content_type=content_type,
is_json=True,
)
return ExternalResult(
payload={
"status": "ok",
"ticker": ticker,
"http_status": response.status_code,
"content_type": content_type,
"raw": raw,
},
raw=raw,
http_status=response.status_code,
content_type=content_type,
is_json=False,
)