File size: 16,072 Bytes
9e637cd
 
 
 
 
 
 
 
 
 
 
 
 
9936912
 
 
48ee375
9936912
1476b6c
48ee375
9936912
 
4de16e3
9936912
48ee375
306a458
9936912
 
 
 
 
9e637cd
9936912
 
 
 
 
 
 
9e637cd
9936912
 
48ee375
 
9e637cd
 
 
 
 
 
 
7498488
 
74544ce
7498488
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
 
4de16e3
 
9e637cd
 
 
1476b6c
 
 
 
 
 
 
 
 
31e0df0
74544ce
 
 
 
31e0df0
 
 
1476b6c
 
 
31e0df0
 
 
 
 
 
 
74544ce
31e0df0
1476b6c
 
 
9e637cd
 
 
1476b6c
9e637cd
 
4de16e3
9e637cd
7498488
 
 
 
9e637cd
48ee375
9936912
306a458
 
9e637cd
 
 
 
306a458
 
 
9e637cd
9936912
 
 
 
 
 
 
 
9e637cd
f950fd2
 
9936912
 
 
 
 
 
 
 
 
 
 
 
88bdfd7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
 
 
 
 
 
 
 
88bdfd7
9e637cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9936912
 
 
9e637cd
 
 
 
 
 
9936912
 
 
9e637cd
 
 
 
9936912
9e637cd
 
9936912
9e637cd
 
 
 
 
 
 
 
9936912
 
 
 
 
9e637cd
 
 
9936912
 
 
9e637cd
9936912
 
 
 
 
9e637cd
9936912
9e637cd
9936912
 
9e637cd
 
 
9936912
9e637cd
 
 
48ee375
9936912
9e637cd
 
 
 
 
 
 
9936912
 
9e637cd
9936912
 
9e637cd
9936912
 
 
9e637cd
 
 
 
 
9936912
 
 
9e637cd
 
 
9936912
 
7498488
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9e637cd
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7498488
9936912
 
9e637cd
9936912
9e637cd
9936912
 
 
 
9e637cd
 
 
9936912
 
9e637cd
9936912
9e637cd
 
88bdfd7
9936912
9e637cd
88bdfd7
9936912
9e637cd
9936912
9e637cd
 
9936912
 
9e637cd
88bdfd7
 
 
9e637cd
 
9a21cc0
b24b1e1
9936912
9e637cd
9936912
 
9e637cd
 
 
 
9936912
 
 
 
 
9e637cd
 
 
 
9936912
 
 
 
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
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
"""ControlAI web server.

Local-only by design. The previous version carried a Hugging Face Spaces
deployment inside it -- Gradio blocks that existed purely to satisfy a ZeroGPU
SDK check, `@spaces.GPU` decorators, a CUDA/GGUF branch, and a threading split
whose two halves each existed to work around the other environment. None of it
ran on this machine, and all of it had to be reasoned about on every change.
What is left is a FastAPI app serving one MLX-backed agent.

MLX keeps its compute stream in thread-local state: the model must be used from
the same OS thread that loaded it, or it raises "There is no Stream(gpu, 0) in
current thread." Every call therefore goes through one dedicated worker thread.
"""

from __future__ import annotations

import asyncio
import json
import os
import queue
import shutil
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any

from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel

PROJECT_ROOT = Path(__file__).resolve().parent
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from controlai_agent.agent import ControlAgent
from controlai_rag.chunker import chunk_document
from controlai_rag.document_loader import load_single_file
from controlai_rag.index import get_shared_index

STATIC_DIR = PROJECT_ROOT / "web"
PLOTS_DIR = PROJECT_ROOT / "outputs" / "plots"
UPLOADS_DIR = PROJECT_ROOT / "data" / "user_docs" / "user_uploaded"
for directory in (STATIC_DIR, PLOTS_DIR, UPLOADS_DIR):
    directory.mkdir(parents=True, exist_ok=True)

# One thread, for the lifetime of the process: see the module docstring.
def _uses_mlx() -> bool:
    return os.environ.get("CONTROLAI_BACKEND", "mlx").strip().lower() not in (
        "torch", "pytorch", "cuda", "api", "remote", "hosted",
    )


# MLX keeps its compute stream in thread-local state, so every call must land on
# one consistent OS thread for the process lifetime.
#
# **This must never be used for the CUDA/ZeroGPU path.** `spaces` only intercepts
# CUDA inside the context it manages; a manually-created thread is outside it, and
# a @spaces.GPU call made from one fails in its own worker with
# "RuntimeError: No CUDA GPUs are available" even when the Space genuinely has a
# GPU attached. This was found once before and fixed the same way (4de16e3); the
# MLX rewrite reintroduced the unconditional executor and reintroduced the bug.
USE_INFERENCE_THREAD = _uses_mlx()
inference_executor = (
    ThreadPoolExecutor(max_workers=1, thread_name_prefix="controlai")
    if USE_INFERENCE_THREAD
    else None
)
# The agent holds a single KV cache that every turn mutates, so turns must not
# interleave even though they all land on the same thread.
inference_lock = threading.Lock()

_agent: ControlAgent | None = None


def _make_engine():
    """The engine for this process. MLX unless the Space asked for CUDA.

    This is the *only* backend branch left in the serving path, and it exists
    for one reason: the public demo Space runs on Linux/NVIDIA, where MLX does
    not exist. `TorchEngine` is imported lazily so a normal Apple Silicon run
    never needs torch installed. Everything downstream -- the agent loop, the
    registry, every tool -- is identical either way.
    """
    backend = os.environ.get("CONTROLAI_BACKEND", "mlx").strip().lower()
    if backend in ("api", "remote", "hosted"):
        from controlai_agent.engine_api import RemoteEngine

        return RemoteEngine()
    # "pytorch" is what the old orchestrator called this and it survives as a
    # variable on the deployed Space; "cuda" is the obvious other guess.
    if backend in ("torch", "pytorch", "cuda"):
        from controlai_agent.engine_torch import TorchEngine

        return TorchEngine()
    if backend not in ("mlx", ""):
        # Never fall through to MLX because a variable was misspelt. That is
        # exactly what happened on the Space: CONTROLAI_BACKEND=pytorch did not
        # match a check for "torch", so it built LocalEngine on a box with no
        # MLX and died in an import several frames deeper than the real cause.
        raise ValueError(
            f"CONTROLAI_BACKEND={backend!r} is not a known backend "
            f"(expected one of: mlx, torch/pytorch/cuda, api/remote/hosted)"
        )
    return None  # ControlAgent's own default, LocalEngine


def get_agent() -> ControlAgent:
    global _agent
    if _agent is None:
        _agent = ControlAgent(engine=_make_engine())
    return _agent


async def _on_inference_thread(fn, *args):
    if not USE_INFERENCE_THREAD:
        # Deliberately blocking: on ZeroGPU the call has to stay in the context
        # `spaces` manages, and a demo serving one turn at a time is fine.
        return fn(*args)
    return await asyncio.get_running_loop().run_in_executor(inference_executor, fn, *args)


@asynccontextmanager
async def lifespan(app: FastAPI):
    started = time.time()
    print("Loading ControlAI…")
    await _on_inference_thread(get_agent)
    print(f"ControlAI ready in {time.time() - started:.1f}s -> http://127.0.0.1:8000")
    yield


app = FastAPI(title="ControlAI", version="2.0.0", lifespan=lifespan)
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)
app.mount("/plots", StaticFiles(directory=str(PLOTS_DIR)), name="plots")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")


class ChatRequest(BaseModel):
    message: str
    history: list[dict[str, str]] = []


class ChatResponse(BaseModel):
    response: str
    tool_traces: list[dict[str, Any]] = []
    plots: list[str] = []
    elapsed_seconds: float


# Set by app_space.py to a @spaces.GPU-decorated generator. ZeroGPU attaches
# real hardware only for the duration of such a call, so on the Space the whole
# turn -- every tool step, every KV-cache mutation -- has to happen inside one.
# Left None locally, where MLX needs no such thing.
stream_hook = None


def _stream_events(message: str, history: list[dict[str, str]]):
    """The agent's raw events, through the GPU hook when one is installed."""
    if stream_hook is not None:
        yield from stream_hook(message, history)
    else:
        yield from get_agent().stream(message, history)


def _collect(message: str, history: list[dict[str, str]]):
    """`ControlAgent.run()` over `_stream_events`, so /api/chat honours the hook.

    ControlAgent.run consumes self.stream directly, which would bypass the hook
    and run inference outside the GPU window.
    """
    answer, plots, sources, stats = "", [], [], {}
    traces: list[Any] = []
    for event in _stream_events(message, history):
        # The `done` event already carries the full traces, arguments included;
        # accumulating tool_end events separately would only lose the arguments.
        if event["type"] == "done":
            answer = event["answer"]
            traces = event["traces"]
            plots = event["plots"]
            sources = event["sources"]
            stats = event["stats"]
    return answer, traces, plots, sources, stats


def _to_wire_events(message: str, history: list[dict[str, str]]):
    """Translate agent events into the shape the browser client consumes.

    The client speaks `thought`/`token`/`tool_end`/`plot`/`done`; the agent
    speaks `thinking`/`text`/... Doing the mapping here keeps the wire format
    stable for the existing UI while the agent's own vocabulary stays clean.
    """
    thoughts: list[str] = []
    for event in _stream_events(message, history):
        kind = event["type"]
        if kind == "text":
            yield {"type": "token", "content": event["text"]}
        elif kind == "thinking":
            yield {"type": "thought", "content": event["text"]}
        elif kind == "tool_start":
            note = f"Calling {event['tool']} with {json.dumps(event['arguments'], ensure_ascii=False, default=str)[:300]}"
            thoughts.append(note)
            yield {"type": "thought", "content": note}
            yield {"type": "tool_start", "tool": event["tool"], "args": event["arguments"]}
        elif kind == "tool_end":
            trace = {"tool": event["tool"], "status": event["status"]}
            note = f"{event['tool']} -> {event['status']}"
            thoughts.append(note)
            yield {"type": "thought", "content": note}
            yield {"type": "tool_end", "trace": trace}
        elif kind == "plot":
            yield {"type": "plot", "url": event["url"]}
        elif kind == "done":
            yield {
                "type": "done",
                "response": event["answer"],
                "traces": event["traces"],
                "plots": event["plots"],
                "sources": event["sources"],
                "thoughts": thoughts,
                "stats": event["stats"],
            }


@app.get("/", response_class=HTMLResponse)
async def serve_index() -> HTMLResponse:
    index_file = STATIC_DIR / "index.html"
    if not index_file.exists():
        return HTMLResponse("<h1>ControlAI: web/index.html is missing</h1>", status_code=500)
    return HTMLResponse(
        index_file.read_text(encoding="utf-8"),
        headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
    )


@app.get("/api/status")
async def status() -> dict[str, Any]:
    index = get_shared_index()
    agent = _agent
    payload: dict[str, Any] = {
        "system": "ControlAI",
        "status": "ready" if agent else "loading",
        "indexed_chunks": len(index.chunks),
    }
    if agent:
        payload.update(
            model=agent.engine.model_id,
            adapter=agent.engine.adapter_path,
            thinking=agent.thinking,
            dense_retrieval=bool(agent.retriever and agent.retriever.has_dense),
        )
    return payload


@app.get("/api/documents")
async def list_documents() -> dict[str, Any]:
    categories: dict[str, list[str]] = {}
    root = UPLOADS_DIR.parent
    if root.exists():
        for item in sorted(root.iterdir()):
            if item.is_dir() and not item.name.startswith("."):
                files = [f.name for f in item.rglob("*.pdf") if not f.name.startswith(".")]
                if files:
                    categories[item.name.replace("_", " ").title()] = files[:10]
    return {"categories": categories}


@app.post("/api/upload")
async def upload_document(file: UploadFile = File(...)) -> dict[str, Any]:
    if not file.filename:
        raise HTTPException(status_code=400, detail="Invalid filename")
    if Path(file.filename).suffix.lower() not in (".pdf", ".txt", ".md"):
        raise HTTPException(status_code=400, detail="Only PDF, TXT, and Markdown files are supported.")

    target = UPLOADS_DIR / Path(file.filename).name
    with target.open("wb") as handle:
        shutil.copyfileobj(file.file, handle)

    def _ingest() -> dict[str, Any]:
        pages = load_single_file(target)
        new_chunks = [c for page in pages for c in chunk_document(page)]
        index = get_shared_index()
        index.add_chunks(new_chunks)
        # Embedding happens on the inference thread because it uses MLX too.
        embedded = 0
        agent = get_agent()
        if agent.retriever is not None:
            embedded = agent.retriever.add_chunks(
                [c for c in index.chunks if c["chunk_id"] in {n["chunk_id"] for n in new_chunks}]
            )
        return {
            "status": "success",
            "filename": target.name,
            "pages_parsed": len(pages),
            "chunks_added": len(new_chunks),
            "chunks_embedded": embedded,
            "total_indexed_chunks": len(index.chunks),
        }

    try:
        with inference_lock:
            return await _on_inference_thread(_ingest)
    except Exception as exc:
        raise HTTPException(status_code=500, detail=f"Indexing failed: {exc}") from exc


@app.post("/api/chat/stream")
async def chat_stream(req: ChatRequest) -> StreamingResponse:
    message = req.message.strip()
    if not message:
        raise HTTPException(status_code=400, detail="Message cannot be empty")

    def _sse(event: dict) -> str:
        return f"data: {json.dumps(event, ensure_ascii=False, default=str)}\n\n"

    if not USE_INFERENCE_THREAD:
        # ZeroGPU: hand Starlette a plain sync generator and let it iterate on
        # its own threadpool. The queue-and-custom-executor relay below would
        # put the @spaces.GPU call on a thread `spaces` does not manage.
        def sync_relay():
            try:
                with inference_lock:
                    for event in _to_wire_events(message, req.history):
                        yield _sse(event)
            except Exception as exc:
                print(f"[chat] {type(exc).__name__}: {exc}")
                yield _sse({"type": "error", "error": f"{type(exc).__name__}: {exc}"})

        return StreamingResponse(
            sync_relay(),
            media_type="text/event-stream",
            headers={"Cache-Control": "no-cache", "Connection": "keep-alive",
                     "X-Accel-Buffering": "no"},
        )

    events: queue.Queue = queue.Queue()
    sentinel = object()

    def produce() -> None:
        try:
            with inference_lock:
                for event in _to_wire_events(message, req.history):
                    events.put(event)
        except Exception as exc:  # surface the failure in the chat, don't hang
            print(f"[chat] {type(exc).__name__}: {exc}")
            events.put({"type": "error", "error": f"{type(exc).__name__}: {exc}"})
        finally:
            events.put(sentinel)

    async def relay():
        loop = asyncio.get_running_loop()
        loop.run_in_executor(inference_executor, produce)
        while True:
            # Draining the queue never touches MLX, so the default threadpool
            # is fine here and keeps the single inference thread free.
            event = await loop.run_in_executor(None, events.get)
            if event is sentinel:
                break
            yield _sse(event)

    return StreamingResponse(
        relay(),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no"},
    )


@app.post("/api/chat", response_model=ChatResponse)
async def chat(req: ChatRequest) -> ChatResponse:
    message = req.message.strip()
    if not message:
        raise HTTPException(status_code=400, detail="Message cannot be empty")

    started = time.time()

    def _run():
        with inference_lock:
            return _collect(message, req.history)

    try:
        answer, traces, plots, _sources, _stats = await _on_inference_thread(_run)
    except Exception as exc:
        print(f"[chat] {type(exc).__name__}: {exc}")
        return ChatResponse(
            response=f"Inference failed: {exc}",
            elapsed_seconds=round(time.time() - started, 2),
        )

    return ChatResponse(
        response=answer,
        tool_traces=[{"tool": t.get("tool"), "status": t.get("status")} for t in traces],
        plots=plots,
        elapsed_seconds=round(time.time() - started, 2),
    )


def main() -> None:
    import threading as _threading
    import webbrowser

    import uvicorn

    def open_browser() -> None:
        time.sleep(1.5)
        try:
            webbrowser.open("http://127.0.0.1:8000")
        except Exception:
            pass

    _threading.Thread(target=open_browser, daemon=True).start()
    # No reload: the model load is far too expensive to repeat on every file
    # save, and reload would fork a second copy of it.
    uvicorn.run(app, host="127.0.0.1", port=8000, log_level="info")


if __name__ == "__main__":
    main()