File size: 5,060 Bytes
ce11d27
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""TraceScope web-demo explanation API for Hugging Face Spaces.

The important bit: this service reuses the real TraceScope Python code paths.
It loads an AnalysisResult cache, converts browser probe coordinates with
tracescope.visualization.probe.probe_point(), and asks
SemanticExplainer.explain_probe_multi() to build/call the same path prompt used
by the native renderer.
"""

from __future__ import annotations

import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel

from tracescope.analysis.explainer import SemanticExplainer
from tracescope.models.analysis import AnalysisResult
from tracescope.providers.llm import OpenAILLM
from tracescope.storage.cache import LLMResponseCache
from tracescope.visualization.probe import probe_point


ALLOWED_ORIGINS = [
    "https://pixedar.github.io",
    "http://localhost:3000",
    "http://127.0.0.1:3000",
]
CACHE_DIR = Path(os.environ.get("TRACESCOPE_CACHE_DIR", "/tmp/tracescope-api-cache"))
CACHE_DIR.mkdir(parents=True, exist_ok=True)
RESULT_CACHE = os.environ.get("TRACESCOPE_RESULT_CACHE", "/app/cache/latest")

app = FastAPI(title="TraceScope Explain API")
app.add_middleware(
    CORSMiddleware,
    allow_origins=ALLOWED_ORIGINS,
    allow_credentials=False,
    allow_methods=["GET", "POST", "OPTIONS"],
    allow_headers=["*"],
)


class ExplainRequest(BaseModel):
    trajectory: list[list[float]]
    scoreContext: list[dict[str, float]] | None = None


_RESULT: AnalysisResult | None = None
_EXPLAINER: SemanticExplainer | None = None


def _load_result() -> AnalysisResult:
    global _RESULT
    if _RESULT is None:
        _RESULT = AnalysisResult.load_result(RESULT_CACHE)
    return _RESULT


def _load_explainer() -> SemanticExplainer:
    global _EXPLAINER
    key = os.environ.get("OPENAI_API_KEY")
    if not key:
        raise HTTPException(status_code=429, detail={"error": "budget_exhausted"})
    if _EXPLAINER is None:
        llm = OpenAILLM(
            api_key=key,
            model=os.environ.get("TRACESCOPE_OPENAI_MODEL", "gpt-5"),
        )
        cache = LLMResponseCache(str(CACHE_DIR / "llm_cache.db"), enabled=True)
        _EXPLAINER = SemanticExplainer(llm, cache)
    return _EXPLAINER


def _cache_key(payload: dict[str, Any]) -> Path:
    digest = hashlib.sha256(json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()
    return CACHE_DIR / f"{digest}.json"


def _budget_available() -> bool:
    """Simple daily request cap, useful for free demo deployment."""
    limit = int(os.environ.get("TRACESCOPE_DAILY_REQUEST_LIMIT", "60"))
    today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
    counter_path = CACHE_DIR / f"counter-{today}.txt"
    try:
        current = int(counter_path.read_text()) if counter_path.exists() else 0
    except Exception:
        current = 0
    if current >= limit:
        return False
    counter_path.write_text(str(current + 1))
    return True


@app.get("/health")
def health() -> dict[str, Any]:
    npz = Path(RESULT_CACHE + ".npz")
    js = Path(RESULT_CACHE + ".json")
    return {
        "ok": True,
        "hasKey": bool(os.environ.get("OPENAI_API_KEY")),
        "resultCache": RESULT_CACHE,
        "hasResultCache": npz.exists() and js.exists(),
    }


@app.post("/explain")
async def explain(req: ExplainRequest) -> dict[str, str]:
    payload = req.model_dump()
    path = _cache_key(payload)
    if path.exists():
        return json.loads(path.read_text(encoding="utf-8"))

    if not _budget_available():
        raise HTTPException(status_code=429, detail={"error": "budget_exhausted"})

    result = _load_result()
    explainer = _load_explainer()
    axis_labels = result.axis_info.labels

    control_points = []
    for point in req.trajectory:
        if len(point) != 3:
            continue
        info = probe_point(result, float(point[0]), float(point[1]), float(point[2]))
        control_points.append(
            {
                "axis_pcts": [int(v) for v in info["axis_percentages"].values()],
                "cluster_distances": [
                    (name, int(round(float(pct))))
                    for name, pct in info["cluster_distances"].items()
                ],
            }
        )

    if not control_points:
        raise HTTPException(status_code=400, detail={"error": "empty_trajectory"})

    if len(control_points) == 1:
        text = explainer.explain_probe_single(
            axis_labels=axis_labels,
            slider_pcts=control_points[0]["axis_pcts"],
            cluster_distances=control_points[0]["cluster_distances"],
        )
    else:
        text = explainer.explain_probe_multi(
            axis_labels=axis_labels,
            control_points=control_points,
            score_context=req.scoreContext,
        )

    result = {"text": text}
    path.write_text(json.dumps(result, ensure_ascii=False), encoding="utf-8")
    return result