Spaces:
Running on Zero
fix: Make the Space a Gradio app, which is what ZeroGPU requires
Browse filesZeroGPU assumes the Gradio app *is* the Space: it schedules GPU workers by
forking the server process, and its startup validation looks for a @spaces.GPU
function wired to a Gradio event handler. Keeping FastAPI on the public port
with Gradio as a hidden side-car was the seventh and last incompatibility -- the
GPU was scheduled and acquired and the forked worker still died in torch.init(),
while the platform probed the public port for /api/predict and got 404. A no-op
@spaces.GPU function failed the same way, so it was never the model.
So Gradio owns the port and ControlAgent runs inside @spaces.GPU. The web/
console is lost on the Space only; the agent, the 29 solvers, the verifier and
the 80,370-chunk retriever are unchanged, and app.py keeps the console locally.
The turn is the unit wrapped by @spaces.GPU, not each generation: a turn is
several generations sharing one KV cache. Tool activity is streamed as it
happens so a multi-step turn does not look like a hang, and plots and sources
are appended when it finishes.
The other six constraints are all still respected -- import-scope build, no
device_map, agent via module global, embedder warmed in the same window, no
upper bounds in requirements, and no pinning torch below CUDA 12.8 (ZeroGPU is
Blackwell, sm_120). All seven are written up in CLAUDE.md.
engine_api.py stays, unused: it works and only needs a token permission, and it
is the fallback for a demo with no GPU at all.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- CLAUDE.md +38 -44
- app_space.py +124 -34
- requirements-space.txt +15 -22
|
@@ -179,53 +179,47 @@ formats correctly. `CONTROLAI_ADAPTER=<path>` loads one anyway for A/B work.
|
|
| 179 |
discovery → extraction → dataset generation → training → evaluation). That pipeline is independent
|
| 180 |
of the serving path and uses `requirements-training.txt`/`requirements-corpus.txt`.
|
| 181 |
|
| 182 |
-
### Deployment (`app_space.py`, `
|
| 183 |
-
The demo Space
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
`
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
**ZeroGPU was tried at length and abandoned.** `engine_torch.py` is kept and works on an ordinary
|
| 203 |
-
CUDA box; it is ZeroGPU specifically that does not fit. Seven distinct problems, recorded so the
|
| 204 |
-
attempt is not repeated blind:
|
| 205 |
-
1. **Dependency resolution.** The platform appends its own `gradio[oauth,mcp]`, `spaces`, `uvicorn`
|
| 206 |
-
and a `torch` ceiling to `requirements.txt`. Any upper bound of your own can make the resolve
|
| 207 |
-
impossible — `transformers<4.56` did, because gradio 6 needs `huggingface-hub>=1.16` and every
|
| 208 |
-
`transformers<4.56` needs `<1.0`.
|
| 209 |
-
2. **Import-window loading.** ZeroGPU patches torch during the entry module's import and only
|
| 210 |
-
intercepts CUDA inside that window. Building the model in FastAPI's `lifespan`, on a worker
|
| 211 |
-
thread, reaches real CUDA init and raises.
|
| 212 |
-
3. **`device_map` and bitsandbytes.** `device_map` routes transformers through
|
| 213 |
`caching_allocator_warmup`'s direct `torch.empty(..., device="cuda")`, which trips the same
|
| 214 |
-
guard; bitsandbytes requires `device_map`, so 4-bit is unavailable.
|
| 215 |
4. **Inference must be inside `@spaces.GPU`.** Otherwise the packed tensors are never materialised
|
| 216 |
and forward passes return fluent multilingual noise at ~15 min/turn — up, responsive, and
|
| 217 |
-
confidently wrong.
|
| 218 |
-
5. **
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
6. **The
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
7. **
|
| 225 |
-
forked worker still
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
### Benchmark (`benchmarks/`)
|
| 231 |
`controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
|
|
|
|
| 179 |
discovery → extraction → dataset generation → training → evaluation). That pipeline is independent
|
| 180 |
of the serving path and uses `requirements-training.txt`/`requirements-corpus.txt`.
|
| 181 |
|
| 182 |
+
### Deployment (`app_space.py`, `engine_torch.py`, `requirements-space.txt`)
|
| 183 |
+
The demo Space is a **Gradio app on ZeroGPU**, not the FastAPI console. That is not a preference:
|
| 184 |
+
**ZeroGPU assumes the Gradio app is the Space.** It schedules GPU workers by forking the server
|
| 185 |
+
process, and its startup validation looks for a `@spaces.GPU` function wired to a Gradio event
|
| 186 |
+
handler. Keeping FastAPI on the public port with Gradio as a hidden side-car was tried at length and
|
| 187 |
+
does not work — see the list below. `app_space.py` therefore gives Gradio the port and drives
|
| 188 |
+
`ControlAgent` from inside `@spaces.GPU`. The `web/` console is lost on the Space only; the agent,
|
| 189 |
+
the 29 solvers, the verifier and the 80,370-chunk retriever are all the same.
|
| 190 |
+
|
| 191 |
+
Seven ZeroGPU constraints, each found the hard way. Read these before changing anything here:
|
| 192 |
+
1. **No upper bounds in `requirements-space.txt`.** The platform appends its own
|
| 193 |
+
`gradio[oauth,mcp]`, `spaces`, `uvicorn` and a `torch` ceiling; one extra constraint can make the
|
| 194 |
+
resolve impossible. `transformers<4.56` did, because gradio 6 needs `huggingface-hub>=1.16` and
|
| 195 |
+
every `transformers<4.56` needs `<1.0`. **And do not pin torch down to CUDA 12** — ZeroGPU is
|
| 196 |
+
backed by Blackwell (sm_120), which needs CUDA 12.8+; an older wheel has no kernels for it.
|
| 197 |
+
2. **Build the model at import scope.** ZeroGPU patches torch during the entry module's import and
|
| 198 |
+
only intercepts CUDA inside that window. Building it in a lifespan or a request reaches real CUDA
|
| 199 |
+
init and raises `Low-level CUDA init (torch._C._cuda_init) reached`.
|
| 200 |
+
3. **No `device_map`, no bitsandbytes.** `device_map` routes transformers through
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
`caching_allocator_warmup`'s direct `torch.empty(..., device="cuda")`, which trips the same
|
| 202 |
+
guard; bitsandbytes requires `device_map`, so 4-bit is unavailable and the model must fit in bf16.
|
| 203 |
4. **Inference must be inside `@spaces.GPU`.** Otherwise the packed tensors are never materialised
|
| 204 |
and forward passes return fluent multilingual noise at ~15 min/turn — up, responsive, and
|
| 205 |
+
confidently wrong. Wrap the whole *turn*: a turn is several generations sharing one KV cache.
|
| 206 |
+
5. **Never pass the agent as an argument** to a `@spaces.GPU` function; reach it through a module
|
| 207 |
+
global. ZeroGPU marshals arguments across a process boundary and tries to share CUDA tensors,
|
| 208 |
+
hanging with no output.
|
| 209 |
+
6. **The embedder is a second model** with the same import-window problem. It loads lazily on the
|
| 210 |
+
first query, so `_build()` embeds one throwaway string to force it in. Without that the Space
|
| 211 |
+
starts fine and every answer carries `[agent] retrieval failed`.
|
| 212 |
+
7. **Gradio must own the public port.** With all of the above fixed but FastAPI on the port, the GPU
|
| 213 |
+
was scheduled and acquired and ZeroGPU's own forked worker still died in `torch.init()`, while
|
| 214 |
+
the platform probed the public port for `/api/predict` and got 404. A no-op `@spaces.GPU`
|
| 215 |
+
function failed identically, so it was not the model or the payload.
|
| 216 |
+
|
| 217 |
+
`HF_TOKEN` must be a Space secret with read access to the private index dataset repo, or retrieval
|
| 218 |
+
is silently disabled.
|
| 219 |
+
|
| 220 |
+
`engine_api.py` is an unused-but-working alternative: `CONTROLAI_BACKEND=api` runs generation over
|
| 221 |
+
Inference Providers with everything else local. It needs a token carrying *Make calls to Inference
|
| 222 |
+
Providers*. Kept for anyone who wants a demo without a GPU at all.
|
| 223 |
|
| 224 |
### Benchmark (`benchmarks/`)
|
| 225 |
`controlbench_v1.jsonl` is the eval set; `SCOPE.md` defines the taxonomy and `README.md` the
|
|
@@ -1,43 +1,50 @@
|
|
| 1 |
-
"""Hugging Face Space entry point:
|
| 2 |
-
|
| 3 |
-
`./run.sh` runs `app.py`
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
the
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
import os
|
|
|
|
|
|
|
| 27 |
|
| 28 |
-
# Must precede the `
|
| 29 |
-
|
| 30 |
-
# else -- CONTROLAI_BACKEND=pytorch, left from the old orchestrator, once did.
|
| 31 |
-
os.environ["CONTROLAI_BACKEND"] = "api"
|
| 32 |
|
| 33 |
-
import
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
|
|
|
| 37 |
|
| 38 |
|
| 39 |
def _fetch_index() -> None:
|
| 40 |
-
"""Pull the retrieval index in before the agent starts."""
|
| 41 |
from controlai_rag.fetch_index import fetch
|
| 42 |
|
| 43 |
if not os.environ.get("HF_TOKEN"):
|
|
@@ -50,11 +57,94 @@ def _fetch_index() -> None:
|
|
| 50 |
print(f"[space] could not fetch the index ({exc}); retrieval disabled")
|
| 51 |
|
| 52 |
|
| 53 |
-
def
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
if __name__ == "__main__":
|
| 60 |
-
|
|
|
|
|
|
| 1 |
+
"""Hugging Face Space entry point: a Gradio app on ZeroGPU.
|
| 2 |
+
|
| 3 |
+
`./run.sh` runs `app.py` -- FastAPI plus the `web/` console -- and that is the
|
| 4 |
+
real product. This file is a *different front end over the same agent*, and it
|
| 5 |
+
exists because of one hard platform constraint:
|
| 6 |
+
|
| 7 |
+
**ZeroGPU assumes the Gradio app is the Space.** It schedules GPU workers by
|
| 8 |
+
forking the server process, and its startup validation looks for a `@spaces.GPU`
|
| 9 |
+
function wired to a Gradio event handler. An earlier version of this file kept
|
| 10 |
+
FastAPI on the public port and ran Gradio as a hidden side-car; the GPU was
|
| 11 |
+
scheduled and acquired and the forked worker still died in `torch.init()`, while
|
| 12 |
+
the platform probed the public port for `/api/predict` and got 404. Six other
|
| 13 |
+
incompatibilities were fixed before that one; all seven are in CLAUDE.md.
|
| 14 |
+
|
| 15 |
+
So here Gradio owns the port and the agent runs inside `@spaces.GPU`. What is
|
| 16 |
+
lost is the `web/` console, on the Space only. What is kept is everything that
|
| 17 |
+
matters: the same `ControlAgent`, the same 29 deterministic solvers, the same
|
| 18 |
+
verifier, the same 80,370-chunk hybrid retriever, the same model.
|
| 19 |
+
|
| 20 |
+
Two ordering rules, both learned the hard way:
|
| 21 |
+
* The model is built at **import scope**. ZeroGPU patches torch during the
|
| 22 |
+
entry module's import and only intercepts CUDA inside that window; building
|
| 23 |
+
it later reaches real CUDA init and raises.
|
| 24 |
+
* The agent is reached from inside the GPU function through a **module
|
| 25 |
+
global**, never passed as an argument. ZeroGPU marshals arguments across a
|
| 26 |
+
process boundary and would try to share the model's CUDA tensors, hanging
|
| 27 |
+
with no output.
|
| 28 |
"""
|
| 29 |
|
| 30 |
from __future__ import annotations
|
| 31 |
|
| 32 |
import os
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
from typing import Iterator
|
| 35 |
|
| 36 |
+
# Must precede the `controlai_agent` imports below.
|
| 37 |
+
os.environ["CONTROLAI_BACKEND"] = "torch"
|
|
|
|
|
|
|
| 38 |
|
| 39 |
+
import gradio as gr
|
| 40 |
+
import spaces
|
| 41 |
|
| 42 |
+
PLOTS_DIR = Path("outputs/plots")
|
| 43 |
+
|
| 44 |
+
AGENT = None
|
| 45 |
|
| 46 |
|
| 47 |
def _fetch_index() -> None:
|
|
|
|
| 48 |
from controlai_rag.fetch_index import fetch
|
| 49 |
|
| 50 |
if not os.environ.get("HF_TOKEN"):
|
|
|
|
| 57 |
print(f"[space] could not fetch the index ({exc}); retrieval disabled")
|
| 58 |
|
| 59 |
|
| 60 |
+
def _build() -> None:
|
| 61 |
+
"""Build the agent while ZeroGPU is still watching for CUDA calls."""
|
| 62 |
+
global AGENT
|
| 63 |
+
from controlai_agent.agent import ControlAgent
|
| 64 |
+
from controlai_agent.engine_torch import TorchEngine
|
| 65 |
+
from controlai_rag.embeddings import get_embedder
|
| 66 |
+
|
| 67 |
+
print("[space] building agent at import scope (ZeroGPU CUDA window)")
|
| 68 |
+
AGENT = ControlAgent(engine=TorchEngine())
|
| 69 |
+
# The retrieval embedder is a second model and loads lazily on first query --
|
| 70 |
+
# a request, outside the window. Embedding one string forces it in here too.
|
| 71 |
+
get_embedder().encode_query("warmup")
|
| 72 |
+
print("[space] agent and embedder ready")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@spaces.GPU(duration=300)
|
| 76 |
+
def respond(message: str, history: list) -> Iterator[str]:
|
| 77 |
+
"""One agent turn, streamed. Runs with real hardware attached.
|
| 78 |
+
|
| 79 |
+
Wraps the whole turn rather than each generation: a turn is several
|
| 80 |
+
generations sharing one KV cache, and splitting them across separate
|
| 81 |
+
@spaces.GPU calls would put that shared state across a process boundary on
|
| 82 |
+
every tool step.
|
| 83 |
+
"""
|
| 84 |
+
if AGENT is None: # pragma: no cover - import always builds it
|
| 85 |
+
yield "Agent failed to start; check the Space logs."
|
| 86 |
+
return
|
| 87 |
|
| 88 |
+
turns = [
|
| 89 |
+
{"role": m["role"], "content": m["content"]}
|
| 90 |
+
for m in (history or [])
|
| 91 |
+
if isinstance(m, dict) and m.get("role") in ("user", "assistant") and m.get("content")
|
| 92 |
+
]
|
| 93 |
+
|
| 94 |
+
answer, tools, sources, plots = "", [], [], []
|
| 95 |
+
for event in AGENT.stream(message, turns):
|
| 96 |
+
kind = event["type"]
|
| 97 |
+
if kind == "text":
|
| 98 |
+
answer += event["text"]
|
| 99 |
+
yield answer
|
| 100 |
+
elif kind == "tool_end":
|
| 101 |
+
tools.append(event.get("tool"))
|
| 102 |
+
# Show tool activity while the model is still thinking, so a
|
| 103 |
+
# multi-step turn does not look like a hang.
|
| 104 |
+
yield answer + f"\n\n*running `{event.get('tool')}`…*"
|
| 105 |
+
elif kind == "done":
|
| 106 |
+
answer = event["answer"]
|
| 107 |
+
sources = event.get("sources") or []
|
| 108 |
+
plots = event.get("plots") or []
|
| 109 |
+
|
| 110 |
+
footer = ""
|
| 111 |
+
for plot in plots:
|
| 112 |
+
name = Path(plot).name
|
| 113 |
+
footer += f"\n\n"
|
| 114 |
+
if tools:
|
| 115 |
+
footer += "\n\n---\n*Computed with: " + ", ".join(f"`{t}`" for t in dict.fromkeys(tools)) + "*"
|
| 116 |
+
if sources:
|
| 117 |
+
footer += "\n\n*Sources: " + "; ".join(str(s) for s in sources[:4]) + "*"
|
| 118 |
+
yield answer + footer
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
with gr.Blocks(title="ControlAI", fill_height=True) as demo:
|
| 122 |
+
gr.Markdown(
|
| 123 |
+
"# ControlAI\n"
|
| 124 |
+
"Control-systems assistant. Every number in an answer comes from a deterministic "
|
| 125 |
+
"solver — SciPy/LAPACK/CVXPY behind a validated tool registry — never from the "
|
| 126 |
+
"model's own arithmetic, and conceptual answers are grounded in a local "
|
| 127 |
+
"control-theory corpus.\n\n"
|
| 128 |
+
"*This hosted demo runs on Hugging Face's hardware, so the offline guarantee of a "
|
| 129 |
+
"local install does not apply here — don't enter anything confidential. The full "
|
| 130 |
+
"app, with its own console, runs on Apple Silicon: see the repository.*"
|
| 131 |
+
)
|
| 132 |
+
gr.ChatInterface(
|
| 133 |
+
fn=respond,
|
| 134 |
+
type="messages",
|
| 135 |
+
examples=[
|
| 136 |
+
"Design an LQR for A=[[0,1],[-2,-3]], B=[[0],[1]], Q=eye(2), R=1.",
|
| 137 |
+
"What is the phase margin of G(s) = 10/(s(s+1)(s+5))?",
|
| 138 |
+
"Explain the Bode sensitivity integral and what it implies for loop shaping.",
|
| 139 |
+
"Place the poles of A=[[0,1],[0,0]], B=[[0],[1]] at -2 and -3.",
|
| 140 |
+
],
|
| 141 |
+
cache_examples=False,
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
_fetch_index()
|
| 146 |
+
_build()
|
| 147 |
|
| 148 |
if __name__ == "__main__":
|
| 149 |
+
PLOTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 150 |
+
demo.launch(allowed_paths=[str(PLOTS_DIR)])
|
|
@@ -1,25 +1,24 @@
|
|
| 1 |
# Hugging Face Space dependencies -- see app_space.py.
|
| 2 |
#
|
| 3 |
-
# The Space
|
| 4 |
-
#
|
| 5 |
-
#
|
| 6 |
-
# hundred milliseconds, which is fine.
|
| 7 |
-
#
|
| 8 |
-
# Do NOT install this locally: requirements.txt is the Apple Silicon MLX runtime.
|
| 9 |
|
| 10 |
-
#
|
| 11 |
-
#
|
| 12 |
-
--
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
torch>=2.4.0
|
| 14 |
-
|
| 15 |
-
# No upper bound: the platform appends its own gradio/spaces/uvicorn on top of
|
| 16 |
-
# this file, and one extra constraint can make the resolve impossible. Pinning
|
| 17 |
-
# transformers<4.56 did exactly that once -- gradio 6 needs huggingface-hub>=1.16
|
| 18 |
-
# and every transformers<4.56 needs <1.0.
|
| 19 |
transformers>=4.51.0
|
| 20 |
-
|
| 21 |
|
| 22 |
-
#
|
|
|
|
|
|
|
|
|
|
| 23 |
numpy>=1.24.0
|
| 24 |
scipy>=1.11.0
|
| 25 |
control>=0.9.4
|
|
@@ -31,9 +30,3 @@ jsonschema>=4.20.0
|
|
| 31 |
rank-bm25>=0.2.2
|
| 32 |
pypdf>=3.17.0
|
| 33 |
pymupdf>=1.23.0
|
| 34 |
-
|
| 35 |
-
# Web console.
|
| 36 |
-
fastapi>=0.110.0
|
| 37 |
-
uvicorn>=0.28.0
|
| 38 |
-
python-multipart>=0.0.9
|
| 39 |
-
pydantic>=2.0.0
|
|
|
|
| 1 |
# Hugging Face Space dependencies -- see app_space.py.
|
| 2 |
#
|
| 3 |
+
# The Space is a Gradio app on ZeroGPU (Linux/NVIDIA), where MLX does not exist,
|
| 4 |
+
# so inference goes through controlai_agent/engine_torch.py. Do NOT install this
|
| 5 |
+
# locally: requirements.txt on `main` is the Apple Silicon MLX runtime.
|
|
|
|
|
|
|
|
|
|
| 6 |
|
| 7 |
+
# Unconstrained on purpose. The platform appends its own gradio, spaces, uvicorn
|
| 8 |
+
# and a `torch<=2.11.0` ceiling to this file, and one extra bound of ours can
|
| 9 |
+
# make the resolve impossible -- `transformers<4.56` did exactly that, because
|
| 10 |
+
# gradio 6 needs huggingface-hub>=1.16 and every transformers<4.56 needs <1.0.
|
| 11 |
+
#
|
| 12 |
+
# Do not pin torch down to CUDA 12 either: ZeroGPU is backed by Blackwell
|
| 13 |
+
# (sm_120), which needs CUDA 12.8+, so an older wheel has no kernels for it.
|
| 14 |
torch>=2.4.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
transformers>=4.51.0
|
| 16 |
+
accelerate>=0.30.0
|
| 17 |
|
| 18 |
+
# gradio, spaces and huggingface-hub are injected by the platform at build time;
|
| 19 |
+
# listing them here only adds constraints to conflict with it.
|
| 20 |
+
|
| 21 |
+
# Deterministic numerics -- every number in an answer comes from these.
|
| 22 |
numpy>=1.24.0
|
| 23 |
scipy>=1.11.0
|
| 24 |
control>=0.9.4
|
|
|
|
| 30 |
rank-bm25>=0.2.2
|
| 31 |
pypdf>=3.17.0
|
| 32 |
pymupdf>=1.23.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|