File size: 6,571 Bytes
024efc3
 
 
 
 
 
 
 
 
 
 
 
fcdba1d
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
620acd3
 
 
 
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcdba1d
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
fcdba1d
 
024efc3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Bridge the synchronous pipeline to an async SSE stream.

`pipeline.answer()` is ordinary blocking Python β€” it makes network calls, and
`rerank.rerank` can sleep for up to 90 s backing off a Cohere 429. Running that on the
event loop would freeze every other connection, so it runs in a worker thread and pushes
trace events back through an asyncio queue.

Concurrency is deliberately capped at one run at a time. Two independent reasons:

  * Cohere trial keys allow 10 rerank calls/minute and a single complex-route run makes up
    to 3. Serialising keeps a burst of visitors from tripping the provider's limit β€” which
    would show up as a 90 s stall, not a clean error.
  * The free HF Spaces CPU is 2 vCPU. The 16576x1536 dense matmul plus BM25 scoring is
    comfortable serially and thrashes in parallel.
"""

from __future__ import annotations

import asyncio
import json
import sys
import time
from typing import AsyncIterator

from starlette.concurrency import run_in_threadpool

from src import pipeline, trace

from . import limits

# One live pipeline run at a time; a few may wait, the rest are told to come back.
_semaphore = asyncio.Semaphore(1)
_waiting = 0
MAX_WAITING = 3

HEARTBEAT_SECONDS = 15
RUN_TIMEOUT_SECONDS = 120

VALID_MODES = ("dense", "hybrid", "hybrid_rerank")
VALID_ROUTES = ("auto", "simple", "complex")


def sse(kind: str, payload: dict) -> str:
    """Format one Server-Sent Event frame."""
    return f"event: {kind}\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n"


def count_api_calls(events: list[dict]) -> dict:
    """Derive the paid-API call counts for a finished run from its own event stream.

    Every paid call announces itself: `llm_call` per generation, `retrieval_start` per query
    embedding, `shortlist` per Cohere rerank (only emitted on the hybrid_rerank path). So
    the accounting is exact rather than estimated, and it stays correct automatically if the
    pipeline's shape ever changes.
    """
    counts = {"llm": 0, "embed": 0, "rerank": 0}
    for event in events:
        limits.tally(counts, event)
    return counts


def try_admit() -> bool:
    """Claim a slot in the run queue, or return False if it is already full.

    Admission is synchronous and separate from `stream()` because an async generator does
    not execute a single line until it is first iterated β€” which happens after the endpoint
    has already returned its response. Deciding here lets the endpoint answer a busy demo
    with a clean 503 instead of an error buried mid-stream.
    """
    global _waiting
    if _waiting >= MAX_WAITING:
        return False
    _waiting += 1
    return True


async def stream(question: str, mode: str, route: str) -> AsyncIterator[str]:
    """Run the pipeline and yield SSE frames as its trace events arrive.

    Yields the raw event stream first (so the UI animates in real time), then an `answer`
    and a `done` frame. Call `try_admit()` first β€” this releases the slot it claimed.
    """
    global _waiting
    try:
        async with _semaphore:
            async for frame in _run(question, mode, route):
                yield frame
    finally:
        _waiting -= 1


async def _run(question: str, mode: str, route: str) -> AsyncIterator[str]:
    loop = asyncio.get_running_loop()
    queue: asyncio.Queue = asyncio.Queue()
    captured: list[dict] = []
    started = time.perf_counter()

    def emit(ev: dict) -> None:
        # Called from the worker thread β€” hop back onto the loop to touch the queue.
        captured.append(ev)
        loop.call_soon_threadsafe(queue.put_nowait, ev)

    def work() -> dict:
        # The sink is bound *inside* the worker thread, so the ContextVar is set on the
        # thread that actually runs the pipeline. No context propagation to reason about.
        try:
            with trace.collect(emit):
                return pipeline.answer(
                    question, mode=mode, route=None if route == "auto" else route
                )
        finally:
            # Billed here, in the worker thread, rather than beside the streaming loop:
            # the paid calls happen whether or not the visitor is still connected, and a
            # client that disconnects mid-run closes the generator without draining the
            # queue. Recording at the source is the only place the count is complete.
            counts = count_api_calls(captured)
            if any(counts.values()):
                limits.record(**counts)

    task = asyncio.ensure_future(run_in_threadpool(work))
    deadline = loop.time() + RUN_TIMEOUT_SECONDS

    yield sse("accepted", {"question": question, "mode": mode, "route": route})

    try:
        while not (task.done() and queue.empty()):
            if loop.time() > deadline:
                yield sse("error", {"message": "That took too long, so it was stopped."})
                return
            try:
                event = await asyncio.wait_for(queue.get(), timeout=HEARTBEAT_SECONDS)
            except asyncio.TimeoutError:
                # Keeps intermediaries from dropping a connection that is legitimately
                # waiting on a slow model call.
                yield ": heartbeat\n\n"
                continue
            yield sse(event["kind"], event)

        result = await task
    except Exception as exc:  # noqa: BLE001 - the stream must report, never crash
        # The detail goes to the server log, not the visitor: provider exceptions can carry
        # request context, and a public demo has no reason to hand that out.
        print(f"[demo] run failed: {type(exc).__name__}: {exc}", file=sys.stderr)
        yield sse("error", {"message": "That run did not finish. Try again, or pick "
                                       "one of the saved runs."})
        return
    finally:
        # A worker thread cannot be cancelled β€” on timeout or client disconnect it keeps
        # running to completion. Waiting for it here means the caller's semaphore is not
        # released while that thread is still calling Cohere, which is what makes the
        # one-run-at-a-time limit actually hold.
        if not task.done():
            await asyncio.gather(task, return_exceptions=True)

    yield sse("answer", {"text": result["answer"], "route": result["route"]})
    yield sse(
        "done",
        {
            "route": result["route"],
            "excerpts": len(result["results"]),
            "ms": round((time.perf_counter() - started) * 1000),
            "api_calls": count_api_calls(captured),
        },
    )