File size: 11,647 Bytes
d861091
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Every model the Den uses, called through Hugging Face Inference Providers.

    review(pitch, panel)   the panel's written review         GLM-5.3-Flash, with DeepSeek-V4.1-Flash as backup
    transcribe(samples)    16 kHz mono float32 in, text out   Whisper large-v3-turbo, with Qwen3-ASR-1.7B as backup
    speak(turns)           (judge, line) pairs in, audio out  Kokoro-82M, with when each line starts

All of it is billed to the Space owner's credits through the HF_TOKEN secret, so visitors need no
account, and a spending guard stops the Den before those credits run out.
"""
from __future__ import annotations

import io
import json
import threading
import time
from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from typing import Callable, Sequence, TypeVar

import numpy as np
import soundfile as sf
from huggingface_hub import InferenceClient

import artifacts
import config
import contract
from contract import Review
from judges import JUDGES, system_prompt

T = TypeVar("T")


def log(stage: str, started: float, **extra) -> None:
    detail = " ".join(f"{k}={v}" for k, v in extra.items())
    print(f"[den] {stage} {time.perf_counter() - started:.2f}s {detail}".rstrip(), flush=True)


def _client(provider: str, timeout: float = config.TIMEOUT) -> InferenceClient:
    return InferenceClient(provider=provider, api_key=config.HF_TOKEN, timeout=timeout)


def _first(attempts: Sequence[Callable[[], T]], head_start: float) -> T:
    """Run attempts in order of preference and return the first to succeed.

    Each attempt gets `head_start` seconds to itself before the next one starts alongside it, and a
    failure starts the next one at once. Providers stall now and then; this way one slow provider costs a
    visitor a few seconds rather than a timeout, and the preferred one still wins whenever it is healthy.
    """
    pool = ThreadPoolExecutor(max_workers=len(attempts))
    waiting, running, error = list(attempts), set(), None
    try:
        while waiting or running:
            while waiting and (not running or head_start <= 0):   # No head start: all start together.
                running.add(pool.submit(waiting.pop(0)))
            done, running = wait(running, timeout=head_start if waiting else None, return_when=FIRST_COMPLETED)
            for future in done:
                if future.exception() is None:
                    return future.result()
                error = future.exception()
            if waiting and (not done or error):
                running.add(pool.submit(waiting.pop(0)))
        raise error
    finally:
        pool.shutdown(wait=False, cancel_futures=True)   # A slower attempt finishes in the background.


# ---------------------------------------------------------------- spending


OUT_OF_BUDGET = ValueError("The panel has used up its budget, so it's taking a break. Sorry!")


class Budget:
    """Dollars spent this calendar month, kept on disk so a restart doesn't forget them."""

    def __init__(self, path: Path, limit: float):
        self._path, self._limit, self._lock = path, limit, threading.Lock()

    def _read(self) -> float:
        try:
            data = json.loads(self._path.read_text())
            return float(data["usd"]) if data.get("month") == _month() else 0.0
        except (OSError, ValueError, KeyError, TypeError):
            return 0.0

    def check(self) -> None:
        """Before any call: refuse if the Space has no token, or this month's budget is spent."""
        if not config.HF_TOKEN:
            raise ValueError("The panel isn't connected yet. Your pitch is safe - please try again shortly.")
        if self._read() >= self._limit:
            raise OUT_OF_BUDGET

    def add(self, usd: float) -> float:
        with self._lock:
            total = self._read() + usd
            self._path.parent.mkdir(parents=True, exist_ok=True)
            self._path.write_text(json.dumps({"month": _month(), "usd": round(total, 6)}))
            return total


def _month() -> str:
    return datetime.now(timezone.utc).strftime("%Y-%m")


BUDGET = Budget(Path(config.SPEND_FILE), config.BUDGET_USD)


def _status(error: Exception) -> int | None:
    """The HTTP status, if any. Hugging Face refuses every provider with 402 once the account's credits are spent."""
    return getattr(getattr(error, "response", None), "status_code", None)


def _give_up(stage: str, error: Exception, message: str) -> ValueError:
    """Every provider failed. Logs only the error's type and status, never its text, which can echo a pitch."""
    print(f"[den] {stage} failed everywhere: {type(error).__name__} {_status(error)}", flush=True)
    return OUT_OF_BUDGET if _status(error) == 402 else ValueError(message)


# ---------------------------------------------------------------- the review


# What Hugging Face actually bills, read from this account's usage: audio models by the second of request
# time (a stalled request costs as much as it waits, so timeouts are short), Together a flat cent per request,
# the other chat providers a fraction of that. Dollars.
PER_SECOND = {"deepinfra": 0.0013, "fal-ai": 0.0013, "hf-inference": 0.00025}


def _billed(provider: str, call: Callable[[], T]) -> T:
    """Run one audio request and count what it costs, whether or not it succeeds."""
    started = time.perf_counter()
    try:
        return call()
    finally:
        BUDGET.add((time.perf_counter() - started) * PER_SECOND[provider])


@dataclass(frozen=True)
class Route:
    """One chat model at one provider, and roughly what one review costs there."""
    provider: str
    model: str
    cost: float
    options: dict = field(default_factory=dict)
    schema_in_prompt: bool = False   # For a provider with JSON mode but no JSON-schema mode.


# GLM-5.3-Flash writes the liveliest judges. It always thinks unless told otherwise, and each provider has its
# own switch: Together and Z.ai honour a low reasoning effort (Together answered 20 of 20 reviews in 3-5s with
# ten visitors at once; Z.ai 18 of 20 in 6-9s). DeepSeek on Baseten is the last resort, in case GLM is down.
GLM_QUICK = {"reasoning_effort": "low", "response_format": {"type": "json_object"}}
ROUTES = (
    Route("together", "zai-org/GLM-5.3-Flash", 0.01, GLM_QUICK, schema_in_prompt=True),
    Route("zai-org", "zai-org/GLM-5.3-Flash", 0.001, GLM_QUICK, schema_in_prompt=True),
    Route("baseten", "deepseek-ai/DeepSeek-V4.1-Flash", 0.002,
          {"chat_template_kwargs": {"enable_thinking": False},
           "response_format": {"type": "json_schema",
                               "json_schema": {"name": "review", "strict": True, "schema": contract.review_schema()}}}),
)


def _ask(route: Route, pitch: str, panel: Sequence[str]) -> Review:
    started = time.perf_counter()
    system = system_prompt(panel)
    if route.schema_in_prompt:
        system += "\n\nReply with only a JSON object matching this schema:\n" + json.dumps(contract.review_schema())
    user = json.dumps({"pitch": pitch, "excerpts": dict(enumerate(contract.excerpts(pitch)))}, ensure_ascii=False)
    reply = _client(route.provider).chat_completion(
        [{"role": "system", "content": system}, {"role": "user", "content": user}], model=route.model,
        max_tokens=config.MAX_TOKENS, temperature=config.TEMPERATURE, top_p=config.TOP_P,
        presence_penalty=config.PRESENCE_PENALTY, extra_body=route.options)
    BUDGET.add(route.cost)   # A reply arrived, so it was billed, even if it turns out to be unreadable.
    review = contract.read(reply.choices[0].message.content or "", panel, pitch)
    log("review", started, route=f"{route.provider}/{route.model}")
    return review


def review(pitch: str, panel: Sequence[str]) -> Review:
    BUDGET.check()
    try:
        return _first([lambda route=route: _ask(route, pitch, panel) for route in ROUTES], config.REVIEW_HEAD_START)
    except Exception as error:   # Busy, slow, refused or unreadable, everywhere.
        raise _give_up("review", error, "The panel is swamped right now. Your pitch is safe - give it a moment and "
                                        "try again.") from None


# ---------------------------------------------------------------- speech


# Hugging Face's own Whisper server is steady at about 3s per minute of audio and cheap. DeepInfra's Qwen3-ASR
# is usually quicker but sometimes stalls, so it only joins in when Whisper is slow or fails.
ASR = (("hf-inference", "openai/whisper-large-v3-turbo"), ("deepinfra", "Qwen/Qwen3-ASR-1.7B"))
# DeepInfra has Kokoro's British voices. fal, with American voices only, is used only if DeepInfra fails: never
# alongside it, because running both bills both.
TTS = (("deepinfra", "voice"), ("fal-ai", "backup_voice"))
TTS_MODEL = "hexgrad/Kokoro-82M"


def transcribe(samples: np.ndarray) -> str:
    BUDGET.check()
    started = time.perf_counter()
    path = artifacts.save_flac(samples, config.ASR_SAMPLE_RATE)   # hf-inference needs a file to know the format

    def attempt(provider: str, model: str) -> str:
        return _billed(provider, lambda: _client(provider, config.ASR_TIMEOUT).automatic_speech_recognition(
            path, model=model).text)

    minutes = len(samples) / config.ASR_SAMPLE_RATE / 60
    try:   # Whisper's head start grows with the recording, so a long pitch isn't paid for twice.
        text = _first([lambda p=p, m=m: attempt(p, m) for p, m in ASR],
                      config.ASR_HEAD_START + config.ASR_SECONDS_PER_MINUTE * minutes)
    except Exception as error:
        raise _give_up("transcribe", error, "We couldn't transcribe that just now. Try again, or type your pitch.") from None
    log("transcribe", started, audio_seconds=round(minutes * 60, 1))
    return text.strip()


def _voice_with(provider: str, voice_field: str, turns: Sequence[tuple[str, str]]) -> tuple[int, np.ndarray, list[float]]:
    """One whole clip from one provider, every line at once, so accents never switch mid-argument."""
    started = time.perf_counter()

    def line(turn: tuple[str, str]) -> tuple[np.ndarray, int]:
        judge, text = turn
        audio = _billed(provider, lambda: _client(provider, config.LINE_TIMEOUT).text_to_speech(
            text, model=TTS_MODEL, extra_body={"voice": getattr(JUDGES[judge], voice_field)}))
        return sf.read(io.BytesIO(audio), dtype="float32")

    with ThreadPoolExecutor(max_workers=len(turns)) as pool:
        lines = list(pool.map(line, turns))
    rate = lines[0][1]
    pause = np.zeros(int(config.GAP_SECONDS * rate), dtype="float32")
    audio = np.concatenate([part for samples, _ in lines for part in (samples, pause)])
    starts = list(np.cumsum([0] + [len(samples) + len(pause) for samples, _ in lines[:-1]]) / rate)
    log("speak", started, provider=provider, speech_seconds=round(len(audio) / rate, 1))
    return rate, audio, [round(float(s), 2) for s in starts]


def speak(turns: Sequence[tuple[str, str]]) -> tuple[int, np.ndarray, list[float]]:
    """The panel's lines as one clip. Returns (sample rate, samples, the second each line starts)."""
    BUDGET.check()
    for provider, voice_field in TTS:
        started = time.perf_counter()
        try:
            return _voice_with(provider, voice_field, turns)
        except Exception as error:
            log("speak", started, provider=provider, failed=type(error).__name__)
            if _status(error) == 402:
                raise OUT_OF_BUDGET from None
    raise ValueError("The panel lost their voices just now. Your review is below.")