omarsol commited on
Commit
d5ed5c9
·
1 Parent(s): 3f613d2

Update project structure

Browse files

- Renamed the example environment file to reflect the FastAPI backend usage.
- Removed the Gradio UI and associated files, consolidating the project to a single Next.js frontend served by FastAPI.
- Added a pre-commit configuration for local linting and formatting.
- Updated AGENTS.md and README.md to clarify the architecture and deployment process, emphasizing the new structure and removing references to the Gradio UI.
- Introduced CI workflows for automated linting, testing, and deployment to Hugging Face Spaces.

.env.example CHANGED
@@ -1,4 +1,4 @@
1
- # To run AI Tutor Gradio UI
2
  OPENAI_API_KEY=...
3
  ANTHROPIC_API_KEY=...
4
  GEMINI_API_KEY=...
 
1
+ # To run the AI Tutor app (FastAPI backend)
2
  OPENAI_API_KEY=...
3
  ANTHROPIC_API_KEY=...
4
  GEMINI_API_KEY=...
.github/workflows/ci.yml ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ branches: [main]
6
+ push:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ jobs:
14
+ qa:
15
+ name: Lint & format
16
+ runs-on: ubuntu-latest
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - name: Install uv
21
+ uses: astral-sh/setup-uv@v4
22
+ with:
23
+ enable-cache: true
24
+
25
+ - name: Set up Python
26
+ uses: actions/setup-python@v5
27
+ with:
28
+ python-version-file: ".python-version"
29
+
30
+ - name: Install dev tooling
31
+ run: uv sync --only-dev --frozen
32
+
33
+ - name: Format check
34
+ run: uv run --no-sync ruff format --check .
35
+
36
+ - name: Lint check
37
+ run: uv run --no-sync ruff check .
38
+
39
+ tests:
40
+ name: Tests
41
+ runs-on: ubuntu-latest
42
+ steps:
43
+ - uses: actions/checkout@v4
44
+
45
+ # run_kb_command sandbox tests shell out to ripgrep; without it they skip.
46
+ - name: Install ripgrep
47
+ run: sudo apt-get update && sudo apt-get install -y ripgrep
48
+
49
+ - name: Install uv
50
+ uses: astral-sh/setup-uv@v4
51
+ with:
52
+ enable-cache: true
53
+
54
+ - name: Set up Python
55
+ uses: actions/setup-python@v5
56
+ with:
57
+ python-version-file: ".python-version"
58
+
59
+ - name: Install project
60
+ run: uv sync --frozen
61
+
62
+ # Live model-backed tests stay skipped here (no RUN_LIVE_* / no API keys);
63
+ # they are run manually/locally. CI guards the offline suite only.
64
+ - name: Run tests
65
+ run: uv run --no-sync pytest
.github/workflows/deploy-prod-to-hf.yml ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy prod to Hugging Face (ai-tutor-chatbot)
2
+
3
+ # Manual-only promotion to the public prod Space. Run it from the Actions tab
4
+ # after verifying the dev Space (ai-tutor) looks good. Deploys the ref you
5
+ # pick in the "Run workflow" dropdown (defaults to main).
6
+ on:
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ deploy-prod:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v3
14
+ with:
15
+ fetch-depth: 0
16
+ lfs: true
17
+
18
+ - name: Push to HuggingFace Space (ai-tutor-chatbot)
19
+ env:
20
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
21
+ HF_USERNAME: ${{ secrets.HF_USERNAME }}
22
+ run: git push --force "https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot" HEAD:main
.github/workflows/sync-to-hf.yml CHANGED
@@ -1,4 +1,7 @@
1
- name: Sync to Hugging Face Spaces
 
 
 
2
  on:
3
  push:
4
  branches: [main]
@@ -7,39 +10,21 @@ on:
7
  - 'README.md'
8
  - 'docs/**'
9
  - '**.md'
10
- # Add any other paths you want to exclude
11
 
12
  # to run this workflow manually from the Actions tab
13
  workflow_dispatch:
14
 
15
  jobs:
16
- sync-to-hub:
17
  runs-on: ubuntu-latest
18
- strategy:
19
- fail-fast: false
20
- matrix:
21
- include:
22
- - space: ai-tutor
23
- dockerfile: Dockerfile
24
- - space: ai-tutor-chatbot
25
- dockerfile: Dockerfile.gradio
26
  steps:
27
  - uses: actions/checkout@v3
28
  with:
29
  fetch-depth: 0
30
  lfs: true
31
 
32
- - name: Swap in target Dockerfile
33
- if: matrix.dockerfile != 'Dockerfile'
34
- run: |
35
- git config user.email "actions@github.com"
36
- git config user.name "GitHub Actions"
37
- cp "${{ matrix.dockerfile }}" Dockerfile
38
- git add Dockerfile
39
- git commit -m "Use ${{ matrix.dockerfile }} for ${{ matrix.space }} space"
40
-
41
- - name: Push to HuggingFace Space (${{ matrix.space }})
42
  env:
43
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
44
  HF_USERNAME: ${{ secrets.HF_USERNAME }}
45
- run: git push --force "https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/towardsai-tutors/${{ matrix.space }}" main:main
 
1
+ name: Deploy dev to Hugging Face (ai-tutor)
2
+
3
+ # Continuous deployment to the private dev Space. Test there, then promote
4
+ # the same commit to prod with the "Deploy prod" workflow (manual trigger).
5
  on:
6
  push:
7
  branches: [main]
 
10
  - 'README.md'
11
  - 'docs/**'
12
  - '**.md'
 
13
 
14
  # to run this workflow manually from the Actions tab
15
  workflow_dispatch:
16
 
17
  jobs:
18
+ deploy-dev:
19
  runs-on: ubuntu-latest
 
 
 
 
 
 
 
 
20
  steps:
21
  - uses: actions/checkout@v3
22
  with:
23
  fetch-depth: 0
24
  lfs: true
25
 
26
+ - name: Push to HuggingFace Space (ai-tutor)
 
 
 
 
 
 
 
 
 
27
  env:
28
  HF_TOKEN: ${{ secrets.HF_TOKEN }}
29
  HF_USERNAME: ${{ secrets.HF_USERNAME }}
30
+ run: git push --force "https://$HF_USERNAME:$HF_TOKEN@huggingface.co/spaces/towardsai-tutors/ai-tutor" HEAD:main
.pre-commit-config.yaml ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Local fast-feedback hooks. One-time setup per clone: `uv run pre-commit install`.
2
+ # CI (.github/workflows/ci.yml) is the real gate; these just fix lint/format
3
+ # before a commit so you don't get a red CI run for formatting.
4
+ repos:
5
+ - repo: https://github.com/astral-sh/ruff-pre-commit
6
+ rev: v0.15.15 # keep in sync with the ruff version in pyproject dev deps
7
+ hooks:
8
+ - id: ruff-check # lint
9
+ args: [--fix]
10
+ - id: ruff-format # format
AGENTS.md CHANGED
@@ -4,17 +4,14 @@ This is the **canonical, tool-agnostic** instruction file for the repo. `CLAUDE.
4
 
5
  ## Project Overview
6
 
7
- AI tutor for applied AI, LLMs, RAG, and Python. **Agentic RAG**: a LangChain/LangGraph agent grounds answers in a curated corpus of course + library docs, can browse a local file-based knowledge base, and (optionally) search the live web. Two frontends share one agent core:
8
-
9
- - **Gradio** UI — `scripts/main.py` (the original chatbot).
10
- - **Next.js** UI — `frontend/`, served by a **FastAPI** backend (`scripts/api.py`) that streams in the Vercel AI SDK UI-message protocol.
11
 
12
  ChromaDB for vectors; Cohere for embeddings/rerank; chat model is provider-configurable (Gemini default, Anthropic, OpenAI). Python ≥3.13, managed with `uv`.
13
 
14
  ## Key URLs
15
 
16
  - [GitHub repo](https://github.com/towardsai/ai-tutor-app)
17
- - [Live demo (Gradio)](https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot)
18
  - [Vector DB + KB bundle](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-vector-db) · [Private raw JSONL data](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data)
19
 
20
  ## Where things live
@@ -26,7 +23,6 @@ ChromaDB for vectors; Cohere for embeddings/rerank; chat model is provider-confi
26
  | Hybrid retrieval | `scripts/chroma_rag.py` |
27
  | KB browsing sandbox + citation resolution | `scripts/kb_shell.py`, `scripts/kb_manifest.py` |
28
  | FastAPI server (`/api/chat`, `/api/tools`, `/healthz`) | `scripts/api.py` |
29
- | Gradio app + renderer | `scripts/main.py`, `scripts/gradio_presenter.py` |
30
  | Paths, models, startup downloads | `scripts/setup.py` |
31
  | **Sources — single source of truth** | `data/scraping_scripts/source_registry.py` |
32
  | Agent tracing (LangSmith) + server logging (stdlib `logging` → stdout) | `scripts/agent_tracing.py`, `scripts/setup.py` |
@@ -35,7 +31,7 @@ ChromaDB for vectors; Cohere for embeddings/rerank; chat model is provider-confi
35
 
36
  ## Architecture in brief
37
 
38
- The agent is built with `langchain.agents.create_agent()` (LangGraph), an `InMemorySaver` checkpointer keyed by `thread_id`, and middlewares for context-editing, summarization, and source preference. `stream_chat()` is the single entry point both frontends call; it yields typed `ChatEvent`s. It always exposes two custom tools, plus provider-native web tools when enabled:
39
 
40
  - **`retrieve_tutor_context(query)`** — hybrid RAG over the corpus, scoped to the user's selected sources.
41
  - **`run_kb_command(...)`** — read-only KB file browsing (see below).
@@ -63,13 +59,12 @@ Runtime guidance the agent follows is in `data/kb/AGENTS.md` (injected into the
63
 
64
  ## Sources & config
65
 
66
- `data/scraping_scripts/source_registry.py` is the **single source of truth** for sources (`SOURCE_CONFIGS`, key groupings, UI labels, defaults); `scripts/setup.py` re-exports them and both frontends derive the picker from it. Docs sources ingest via the GitHub API or `llms.txt`; course sources are Notion exports. To add a source: add it to the registry (+ the relevant grouping tuples), then run the matching workflow — no separate UI edit needed. Models live in `setup.AVAILABLE_MODELS` (default `google-genai:gemini-3.5-flash`; also Claude Haiku 4.5; OpenAI supported in code).
67
 
68
  ## Running locally
69
 
70
  ```bash
71
  uv sync && cp .env.example .env # then fill in keys
72
- uv run -m scripts.main # Gradio UI (:7860)
73
  uv run -m scripts.api # FastAPI backend (:8000; override AI_TUTOR_API_PORT/PORT)
74
  # Next.js frontend (needs the API running):
75
  cd frontend && npm install && cp .env.example .env.local && npm run dev # :3000
@@ -77,7 +72,7 @@ cd frontend && npm install && cp .env.example .env.local && npm run dev # :300
77
 
78
  First start downloads the vector-db/KB bundle from HF if missing (`HF_TOKEN`). The frontend is a static export (`output: 'export'`); `npm run build` emits `frontend/out`, which `scripts/api.py` mounts at `/`.
79
 
80
- Test & lint: `uv run pytest` · `uv run ruff check .`
81
 
82
  ## Data update workflows
83
 
@@ -97,12 +92,17 @@ Chat runtime: `COHERE_API_KEY` (retrieval), one chat-model provider key (`GEMINI
97
 
98
  ## Deployment
99
 
100
- `.github/workflows/sync-to-hf.yml` force-pushes to two HF Spaces on every push to `main`: **`ai-tutor`** (`Dockerfile`: FastAPI + Next.js export) and **`ai-tutor-chatbot`** (`Dockerfile.gradio`). Both install `ripgrep` for `run_kb_command` and run on :7860.
 
 
 
 
 
101
 
102
  ## Conventions
103
 
104
- - **No em-dashes in frontend user-facing text.** Use a comma, parentheses, a colon, or two sentences instead. This covers every string the UI renders (Next.js components and Gradio): tool descriptions, popovers, labels, `title` tooltips, placeholders, empty states. This file and other docs are exempt.
105
- - **Decide frontend vs. backend ownership before changing behavior, and fix it on the side that owns the data.** The backend is the single source of truth for data shape and meaning; the frontend renders what it receives instead of reshaping it. If a field is empty, that should be because the server wrote it empty, not because the UI stripped it. For example, a source with no library version must get `version: null` from `capture_source_versions.py`; it should never be filtered out client-side. Reshaping data in the frontend causes drift between the two frontends and ambiguity about why a value is missing (did the server omit it, or did the client hide it?).
106
 
107
  ## Gotchas
108
 
 
4
 
5
  ## Project Overview
6
 
7
+ AI tutor for applied AI, LLMs, RAG, and Python. **Agentic RAG**: a LangChain/LangGraph agent grounds answers in a curated corpus of course + library docs, can browse a local file-based knowledge base, and (optionally) search the live web. One frontend: a **Next.js** UI (`frontend/`), served by a **FastAPI** backend (`scripts/api.py`) that streams in the Vercel AI SDK UI-message protocol. (A Gradio UI existed historically; it was removed to keep one rendering path.)
 
 
 
8
 
9
  ChromaDB for vectors; Cohere for embeddings/rerank; chat model is provider-configurable (Gemini default, Anthropic, OpenAI). Python ≥3.13, managed with `uv`.
10
 
11
  ## Key URLs
12
 
13
  - [GitHub repo](https://github.com/towardsai/ai-tutor-app)
14
+ - [Live demo — prod Space](https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot) · [Dev Space (private)](https://huggingface.co/spaces/towardsai-tutors/ai-tutor)
15
  - [Vector DB + KB bundle](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-vector-db) · [Private raw JSONL data](https://huggingface.co/datasets/towardsai-tutors/ai-tutor-data)
16
 
17
  ## Where things live
 
23
  | Hybrid retrieval | `scripts/chroma_rag.py` |
24
  | KB browsing sandbox + citation resolution | `scripts/kb_shell.py`, `scripts/kb_manifest.py` |
25
  | FastAPI server (`/api/chat`, `/api/tools`, `/healthz`) | `scripts/api.py` |
 
26
  | Paths, models, startup downloads | `scripts/setup.py` |
27
  | **Sources — single source of truth** | `data/scraping_scripts/source_registry.py` |
28
  | Agent tracing (LangSmith) + server logging (stdlib `logging` → stdout) | `scripts/agent_tracing.py`, `scripts/setup.py` |
 
31
 
32
  ## Architecture in brief
33
 
34
+ The agent is built with `langchain.agents.create_agent()` (LangGraph), an `InMemorySaver` checkpointer keyed by `thread_id`, and middlewares for context-editing, summarization, and source preference. `stream_chat()` is the single entry point the API calls; it yields typed `ChatEvent`s that `scripts/api.py` encodes into the AI SDK UI-message stream. It always exposes two custom tools, plus provider-native web tools when enabled:
35
 
36
  - **`retrieve_tutor_context(query)`** — hybrid RAG over the corpus, scoped to the user's selected sources.
37
  - **`run_kb_command(...)`** — read-only KB file browsing (see below).
 
59
 
60
  ## Sources & config
61
 
62
+ `data/scraping_scripts/source_registry.py` is the **single source of truth** for sources (`SOURCE_CONFIGS`, key groupings, UI labels, defaults); `scripts/setup.py` re-exports them and the frontend derives the picker from it (via `/api/tools`). Docs sources ingest via the GitHub API or `llms.txt`; course sources are Notion exports. To add a source: add it to the registry (+ the relevant grouping tuples), then run the matching workflow — no separate UI edit needed. Models live in `setup.AVAILABLE_MODELS` (default `google-genai:gemini-3.5-flash`; also Claude Haiku 4.5; OpenAI supported in code).
63
 
64
  ## Running locally
65
 
66
  ```bash
67
  uv sync && cp .env.example .env # then fill in keys
 
68
  uv run -m scripts.api # FastAPI backend (:8000; override AI_TUTOR_API_PORT/PORT)
69
  # Next.js frontend (needs the API running):
70
  cd frontend && npm install && cp .env.example .env.local && npm run dev # :3000
 
72
 
73
  First start downloads the vector-db/KB bundle from HF if missing (`HF_TOKEN`). The frontend is a static export (`output: 'export'`); `npm run build` emits `frontend/out`, which `scripts/api.py` mounts at `/`.
74
 
75
+ Test, lint & format: `uv run pytest` · `uv run ruff check .` · `uv run ruff format .`. CI (`.github/workflows/ci.yml`) enforces all three on PRs/pushes to `main`; for local auto-fix on commit, run `uv run pre-commit install` once.
76
 
77
  ## Data update workflows
78
 
 
92
 
93
  ## Deployment
94
 
95
+ Both HF Spaces run the same image (`Dockerfile`: FastAPI + Next.js static export, `ripgrep` installed for `run_kb_command`, port :7860), in a dev → prod flow:
96
+
97
+ - **Dev — `ai-tutor`** (private): `.github/workflows/sync-to-hf.yml` force-pushes on every push to `main`. Verify changes here first.
98
+ - **Prod — `ai-tutor-chatbot`** (public): `.github/workflows/deploy-prod-to-hf.yml`, **manual trigger only** (Actions tab → "Deploy prod to Hugging Face" → Run workflow).
99
+
100
+ Both Spaces need the same runtime secrets (`COHERE_API_KEY`, model provider key, `HF_TOKEN`, optional `LANGSMITH_*`/`MONGODB_URI`) configured in their HF settings.
101
 
102
  ## Conventions
103
 
104
+ - **No em-dashes in frontend user-facing text.** Use a comma, parentheses, a colon, or two sentences instead. This covers every string the UI renders (Next.js components): tool descriptions, popovers, labels, `title` tooltips, placeholders, empty states. This file and other docs are exempt.
105
+ - **Decide frontend vs. backend ownership before changing behavior, and fix it on the side that owns the data.** The backend is the single source of truth for data shape and meaning; the frontend renders what it receives instead of reshaping it. If a field is empty, that should be because the server wrote it empty, not because the UI stripped it. For example, a source with no library version must get `version: null` from `capture_source_versions.py`; it should never be filtered out client-side. Reshaping data in the frontend causes ambiguity about why a value is missing (did the server omit it, or did the client hide it?).
106
 
107
  ## Gotchas
108
 
Dockerfile.gradio DELETED
@@ -1,27 +0,0 @@
1
- FROM python:3.13
2
-
3
- # ripgrep backs run_kb_command's `rg`; without it the model falls back to find+cat.
4
- RUN apt-get update \
5
- && apt-get install -y --no-install-recommends ripgrep \
6
- && rm -rf /var/lib/apt/lists/*
7
-
8
- # Install uv into /usr/local/bin (on PATH for every user, including HF's uid 1000).
9
- RUN curl -LsSf https://astral.sh/uv/install.sh | sh \
10
- && mv /root/.local/bin/uv /root/.local/bin/uvx /usr/local/bin/ \
11
- && rm -rf /root/.local /root/.cache
12
-
13
- RUN useradd -m -u 1000 user
14
- WORKDIR /app
15
-
16
- COPY pyproject.toml uv.lock ./
17
- RUN uv sync --locked
18
-
19
- COPY . .
20
-
21
- ENV HOME=/home/user \
22
- PORT=7860
23
- RUN chown -R user:user /app
24
- USER user
25
-
26
- EXPOSE 7860
27
- CMD ["uv", "run", "-m", "scripts.main"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
README.md CHANGED
@@ -8,15 +8,15 @@ app_port: 7860
8
  pinned: false
9
  ---
10
 
11
- ### Gradio UI Chatbot
12
 
13
- A Gradio UI for the chatbot is available in [scripts/main.py](./scripts/main.py).
14
 
15
- The Gradio demo is deployed on Hugging Face Spaces at: [AI Tutor Chatbot on Hugging Face](https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot).
16
 
17
- **Note:** A GitHub Action automatically deploys the Gradio demo when changes are pushed to the main branch (excluding documentation and scripts in the `data/scraping_scripts` directory).
18
 
19
- ### Gradio UI — Quick Start
20
 
21
  1. Install dependencies (requires [uv](https://docs.astral.sh/uv/getting-started/installation/#installation-methods)):
22
 
@@ -30,17 +30,9 @@ The Gradio demo is deployed on Hugging Face Spaces at: [AI Tutor Chatbot on Hugg
30
  cp .env.example .env # then edit values
31
  ```
32
 
33
- The chat model is provider-agnostic. Use the UI field in `provider:model` format, for example `openai:gpt-5.4-mini`. Optional provider keys include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `GOOGLE_API_KEY`. Anthropic support is wired through the `anthropic` SDK, and Gemini support is wired through Google’s `google-genai` SDK.
34
  To trace requests in LangSmith, set `LANGSMITH_API_KEY`. The app enables tracing automatically when that key is present unless `LANGSMITH_TRACING=false` is set.
35
 
36
- 3. Run:
37
-
38
- ```bash
39
- uv run -m scripts.main
40
- ```
41
-
42
- Starts the Gradio AI Tutor interface.
43
-
44
  ### LangSmith Agent Tracing
45
 
46
  The chatbot is built with `langchain.agents.create_agent()`, so LangSmith can trace the LangGraph/LangChain run tree without extra dependencies. Add these values to `.env`:
@@ -57,7 +49,7 @@ Tracing sends prompts, retrieved snippets, tool inputs, tool outputs, and model
57
 
58
  ### Next.js Frontend — Quick Start
59
 
60
- The repo now also includes a separate Next.js frontend in [frontend](./frontend) that talks to the FastAPI backend instead of the Gradio transport.
61
 
62
  1. Start the Python API:
63
 
@@ -102,79 +94,11 @@ This frontend consumes:
102
 
103
  and renders sources, tool activity, and reasoning as separate UI elements rather than a single markdown block.
104
 
105
- ### Gradio API
106
-
107
- The chat endpoint is exposed as `chat`, so the API flow is:
108
-
109
- ```bash
110
- POST /gradio_api/call/chat
111
- GET /gradio_api/call/chat/{event_id}
112
- ```
113
-
114
- The `POST` body must send `data` in this exact order:
115
-
116
- 1. User message: string
117
- 2. History: array
118
- 3. Sources: array of source labels exactly as shown in the UI
119
- 4. Model: `provider:model` string
120
- 5. Show Gemini thoughts: boolean
121
- 6. Thread ID: string, empty for a new conversation
122
-
123
- Example first turn:
124
-
125
- ```bash
126
- curl -s http://127.0.0.1:7860/gradio_api/call/chat \
127
- -H 'Content-Type: application/json' \
128
- -d '{
129
- "data": [
130
- "What is LoRA?",
131
- [],
132
- ["PEFT Docs", "Transformers Docs"],
133
- "openai:gpt-4o-mini",
134
- false,
135
- ""
136
- ]
137
- }'
138
- ```
139
-
140
- That returns an `event_id`. Open the server-sent event stream with:
141
-
142
- ```bash
143
- curl -N http://127.0.0.1:7860/gradio_api/call/chat/<event_id>
144
- ```
145
-
146
- The streamed payloads look like this:
147
-
148
- ```json
149
- ["Partial assistant text", null, "thread-id"]
150
- ```
151
-
152
- - `data[0]`: current streamed assistant text
153
- - `data[1]`: Gradio's hidden state placeholder, ignore this
154
- - `data[2]`: `thread_id` to reuse on the next turn
155
-
156
- Example follow-up turn on the same conversation:
157
-
158
- ```bash
159
- curl -s http://127.0.0.1:7860/gradio_api/call/chat \
160
- -H 'Content-Type: application/json' \
161
- -d '{
162
- "data": [
163
- "How is it different from adapters?",
164
- [],
165
- ["PEFT Docs", "Transformers Docs"],
166
- "openai:gpt-4o-mini",
167
- false,
168
- "thread-id-from-previous-response"
169
- ]
170
- }'
171
- ```
172
-
173
- Notes:
174
 
175
- - API clients should usually send `[]` for history and continue the conversation with `thread_id`.
176
- - The source filter is request-scoped, so you can keep the same `thread_id` while changing sources between turns.
177
- - Sending an empty `thread_id` starts a new backend conversation.
178
 
179
  ### Knowledge Base (file-based)
180
 
 
8
  pinned: false
9
  ---
10
 
11
+ ### AI Tutor Chatbot
12
 
13
+ An agentic RAG tutor for applied AI, LLMs, RAG, and Python: a Next.js frontend served by a FastAPI backend, with a LangChain/LangGraph agent core. See [AGENTS.md](./AGENTS.md) for the architecture map.
14
 
15
+ The live app is deployed on Hugging Face Spaces at: [AI Tutor Chatbot on Hugging Face](https://huggingface.co/spaces/towardsai-tutors/ai-tutor-chatbot) (prod).
16
 
17
+ **Deployment flow:** every push to `main` auto-deploys to the private dev Space ([ai-tutor](https://huggingface.co/spaces/towardsai-tutors/ai-tutor)) for verification; the prod Space is promoted manually via the "Deploy prod to Hugging Face" workflow in the Actions tab.
18
 
19
+ ### Backend — Quick Start
20
 
21
  1. Install dependencies (requires [uv](https://docs.astral.sh/uv/getting-started/installation/#installation-methods)):
22
 
 
30
  cp .env.example .env # then edit values
31
  ```
32
 
33
+ The chat model is provider-agnostic, configured in `provider:model` format, for example `google-genai:gemini-3.5-flash`. Optional provider keys include `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, and `GOOGLE_API_KEY`. Anthropic support is wired through the `anthropic` SDK, and Gemini support is wired through Google’s `google-genai` SDK.
34
  To trace requests in LangSmith, set `LANGSMITH_API_KEY`. The app enables tracing automatically when that key is present unless `LANGSMITH_TRACING=false` is set.
35
 
 
 
 
 
 
 
 
 
36
  ### LangSmith Agent Tracing
37
 
38
  The chatbot is built with `langchain.agents.create_agent()`, so LangSmith can trace the LangGraph/LangChain run tree without extra dependencies. Add these values to `.env`:
 
49
 
50
  ### Next.js Frontend — Quick Start
51
 
52
+ The Next.js frontend in [frontend](./frontend) talks to the FastAPI backend.
53
 
54
  1. Start the Python API:
55
 
 
94
 
95
  and renders sources, tool activity, and reasoning as separate UI elements rather than a single markdown block.
96
 
97
+ API notes:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
 
99
+ - API clients should usually send only the new user message and continue the conversation with `threadId` (the `data-thread` part of the stream carries it; send it back on the next request).
100
+ - The source filter is request-scoped, so you can keep the same `threadId` while changing sources between turns.
101
+ - Sending an empty `threadId` starts a new backend conversation.
102
 
103
  ### Knowledge Base (file-based)
104
 
data/scraping_scripts/README.md CHANGED
@@ -125,7 +125,7 @@ uv run -m data.scraping_scripts.add_course_workflow --courses master_ai_for_work
125
  Run the chatbot locally to test if the course has been added correctly.
126
 
127
  ```bash
128
- uv run -m scripts.main
129
  ```
130
 
131
  ----
@@ -284,11 +284,11 @@ leaving the source configured as active for a future rebuild.
284
 
285
  3. By default, only new content will have context added to save time and resources. Use `--process-all-context` only if you need to regenerate context for all documents. Use `--skip-data-upload` if you don't want to upload data files to the private HuggingFace repo (they're uploaded by default).
286
 
287
- 4. When adding a new course, verify that it appears in the Gradio UI:
288
  - Add the source label and default-selection metadata in `source_registry.py`
289
- - Check that the new source appears in the dropdown menu in the UI
290
  - Make sure it's properly included in the default selected sources if desired
291
- - Restart the Gradio app to see the changes
292
 
293
  5. First time setup or missing files:
294
  - Both workflows automatically check for and download required data files:
 
125
  Run the chatbot locally to test if the course has been added correctly.
126
 
127
  ```bash
128
+ uv run -m scripts.api
129
  ```
130
 
131
  ----
 
284
 
285
  3. By default, only new content will have context added to save time and resources. Use `--process-all-context` only if you need to regenerate context for all documents. Use `--skip-data-upload` if you don't want to upload data files to the private HuggingFace repo (they're uploaded by default).
286
 
287
+ 4. When adding a new course, verify that it appears in the UI:
288
  - Add the source label and default-selection metadata in `source_registry.py`
289
+ - Check that the new source appears in the source picker in the UI
290
  - Make sure it's properly included in the default selected sources if desired
291
+ - Restart the API server to see the changes
292
 
293
  5. First time setup or missing files:
294
  - Both workflows automatically check for and download required data files:
pyproject.toml CHANGED
@@ -10,7 +10,6 @@ dependencies = [
10
  "cohere",
11
  "fastapi",
12
  "google-genai",
13
- "gradio",
14
  "hf-xet",
15
  "huggingface-hub",
16
  "instructor",
@@ -34,9 +33,14 @@ dependencies = [
34
  [dependency-groups]
35
  dev = [
36
  "httpx",
 
37
  "pytest>=9.0.3",
38
  "ruff>=0.15.10",
39
  ]
40
 
41
  [tool.pytest.ini_options]
42
  pythonpath = ["."]
 
 
 
 
 
10
  "cohere",
11
  "fastapi",
12
  "google-genai",
 
13
  "hf-xet",
14
  "huggingface-hub",
15
  "instructor",
 
33
  [dependency-groups]
34
  dev = [
35
  "httpx",
36
+ "pre-commit",
37
  "pytest>=9.0.3",
38
  "ruff>=0.15.10",
39
  ]
40
 
41
  [tool.pytest.ini_options]
42
  pythonpath = ["."]
43
+
44
+ [tool.ruff]
45
+ line-length = 88
46
+ target-version = "py313"
scripts/chat_service.py CHANGED
@@ -57,19 +57,8 @@ from .setup import (
57
 
58
  logger = logging.getLogger(__name__)
59
 
60
- SOURCES_HEADER = "📝 Here are the sources I used to answer your question:"
61
- ACTIVITY_BLOCK_START = "<!-- MODEL_ACTIVITY_START -->"
62
- ACTIVITY_BLOCK_END = "<!-- MODEL_ACTIVITY_END -->"
63
- THOUGHTS_BLOCK_START = "<!-- GEMINI_THOUGHTS_START -->"
64
- THOUGHTS_BLOCK_END = "<!-- GEMINI_THOUGHTS_END -->"
65
- ANSWER_HEADER = "**Answer**"
66
- LEGACY_THOUGHTS_DETAILS_OPEN = "<details><summary>Gemini thoughts</summary>"
67
- LEGACY_THOUGHTS_DETAILS_OPEN_EXPANDED = (
68
- "<details open><summary>Gemini thoughts</summary>"
69
- )
70
  CHECKPOINTER = InMemorySaver()
71
  _RETRIEVER_INIT_LOCK = Lock()
72
- KB_TOOL_NAMES = ("run_kb_command",)
73
  DEFAULT_KB_COMMAND_LIMIT = 20
74
  _KB_COMMAND_COUNTS: dict[str, int] = {}
75
  _KB_COMMAND_COUNT_LOCK = Lock()
@@ -246,79 +235,15 @@ def message_content_to_text(content: Any) -> str:
246
  return str(content)
247
 
248
 
249
- def strip_sources_block(text: str) -> str:
250
- separator = f"\n\n{SOURCES_HEADER}"
251
- body, marker, _ = text.partition(separator)
252
- if marker:
253
- return body.strip()
254
- if text.startswith(SOURCES_HEADER):
255
- return ""
256
- return text.strip()
257
-
258
-
259
- def strip_hidden_block(text: str, start_marker: str, end_marker: str) -> str:
260
- stripped = text
261
- while True:
262
- start = stripped.find(start_marker)
263
- if start == -1:
264
- return stripped
265
- end = stripped.find(end_marker, start)
266
- if end == -1:
267
- return stripped[:start]
268
- stripped = stripped[:start] + stripped[end + len(end_marker) :]
269
-
270
-
271
- def strip_activity_block(text: str) -> str:
272
- stripped = strip_hidden_block(text, ACTIVITY_BLOCK_START, ACTIVITY_BLOCK_END)
273
- stripped = strip_hidden_block(stripped, THOUGHTS_BLOCK_START, THOUGHTS_BLOCK_END)
274
-
275
- for marker in (
276
- LEGACY_THOUGHTS_DETAILS_OPEN_EXPANDED,
277
- LEGACY_THOUGHTS_DETAILS_OPEN,
278
- ):
279
- separator = f"\n\n{marker}"
280
- body, found, _ = stripped.partition(separator)
281
- if found:
282
- return body.strip()
283
- if stripped.startswith(marker):
284
- return ""
285
- return stripped.strip()
286
-
287
-
288
- def strip_answer_header(text: str) -> str:
289
- stripped = text.strip()
290
- answer_prefix = f"{ANSWER_HEADER}\n\n"
291
- if stripped.startswith(answer_prefix):
292
- return stripped[len(answer_prefix) :].strip()
293
- if stripped == ANSWER_HEADER:
294
- return ""
295
- return stripped
296
-
297
-
298
- def strip_display_blocks(text: str) -> str:
299
- return strip_answer_header(strip_activity_block(strip_sources_block(text)))
300
-
301
-
302
- def normalize_history(
303
- history: list[dict[str, Any]] | tuple[ChatTurn, ...] | list[ChatTurn],
304
- ) -> tuple[ChatTurn, ...]:
305
- if not history:
306
- return ()
307
-
308
  normalized: list[ChatTurn] = []
309
- for message in history:
310
- if isinstance(message, ChatTurn):
311
- role = message.role
312
- content = message.content
313
- else:
314
- role = str(message.get("role", ""))
315
- content = message_content_to_text(message.get("content"))
316
-
317
- if role not in {"user", "assistant"}:
318
  continue
319
- if role == "assistant":
320
- content = strip_display_blocks(content)
321
- normalized.append(ChatTurn(role=role, content=content))
322
  return tuple(normalized)
323
 
324
 
@@ -333,7 +258,7 @@ def checkpoint_messages_to_history(messages: list[BaseMessage]) -> tuple[ChatTur
333
  history.append(
334
  ChatTurn(
335
  "assistant",
336
- strip_display_blocks(message_content_to_text(message.content)),
337
  )
338
  )
339
  return tuple(history)
@@ -814,7 +739,7 @@ def effective_tool_names(
814
  model_name: str,
815
  enabled_tools: tuple[str, ...],
816
  ) -> tuple[str, ...]:
817
- names = ["retrieve_tutor_context", *KB_TOOL_NAMES]
818
  enabled = set(enabled_tools)
819
  if is_google_genai_model(model_name):
820
  if "web_search" in enabled:
@@ -928,6 +853,76 @@ def extract_grounding_source_matches(
928
 
929
 
930
  GOOGLE_SEARCH_TOOL_NAME = "google_search"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
931
  ANTHROPIC_SERVER_TOOL_NAMES = frozenset({"web_search", "web_fetch"})
932
  ANTHROPIC_RESULT_BLOCK_TYPES = {
933
  "web_search_tool_result": ("web_search", "Web"),
@@ -1057,9 +1052,7 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
1057
  include_reasoning = bool(request.include_reasoning) and is_google_genai_model(
1058
  request.model_name
1059
  )
1060
- google_search_call_id = ""
1061
- google_search_queries: list[str] = []
1062
- google_search_match_count = 0
1063
 
1064
  logger.info("Running query: %s", request.query)
1065
  agent = build_agent(
@@ -1138,33 +1131,11 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
1138
  },
1139
  )
1140
 
1141
- token_metadata = getattr(token, "response_metadata", None)
1142
- new_queries = [
1143
- q
1144
- for q in extract_web_search_queries(token_metadata)
1145
- if q not in google_search_queries
1146
- ]
1147
- new_grounding = extract_grounding_source_matches(
1148
- token_metadata,
1149
- web_evidence,
1150
  )
1151
- if (new_queries or new_grounding) and not google_search_call_id:
1152
- google_search_call_id = uuid4().hex
1153
- yield ChatEvent(
1154
- "tool_call_started",
1155
- {
1156
- "message_id": message_id,
1157
- "call_id": google_search_call_id,
1158
- "tool_name": GOOGLE_SEARCH_TOOL_NAME,
1159
- "args": {
1160
- "query": "; ".join(new_queries) if new_queries else ""
1161
- },
1162
- "args_text": "; ".join(new_queries),
1163
- },
1164
- )
1165
- if new_queries:
1166
- google_search_queries.extend(new_queries)
1167
- google_search_match_count += len(new_grounding)
1168
  continue
1169
 
1170
  if chunk["type"] != "updates":
@@ -1224,33 +1195,11 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
1224
  if step != "model" or getattr(message, "type", None) != "ai":
1225
  continue
1226
 
1227
- message_metadata = getattr(message, "response_metadata", None)
1228
- new_queries = [
1229
- q
1230
- for q in extract_web_search_queries(message_metadata)
1231
- if q not in google_search_queries
1232
- ]
1233
- new_grounding = extract_grounding_source_matches(
1234
- message_metadata,
1235
- web_evidence,
1236
  )
1237
- if (new_queries or new_grounding) and not google_search_call_id:
1238
- google_search_call_id = uuid4().hex
1239
- yield ChatEvent(
1240
- "tool_call_started",
1241
- {
1242
- "message_id": message_id,
1243
- "call_id": google_search_call_id,
1244
- "tool_name": GOOGLE_SEARCH_TOOL_NAME,
1245
- "args": {
1246
- "query": "; ".join(new_queries) if new_queries else ""
1247
- },
1248
- "args_text": "; ".join(new_queries),
1249
- },
1250
- )
1251
- if new_queries:
1252
- google_search_queries.extend(new_queries)
1253
- google_search_match_count += len(new_grounding)
1254
 
1255
  if is_anthropic_model(request.model_name):
1256
  anthropic_updates, anthropic_tool_uses = (
@@ -1301,24 +1250,9 @@ async def stream_chat(request: ChatRequest) -> AsyncIterator[ChatEvent]:
1301
  finally:
1302
  _clear_kb_command_budget(message_id)
1303
 
1304
- if google_search_call_id:
1305
- joined_queries = "; ".join(google_search_queries)
1306
- if google_search_match_count == 0:
1307
- output_text = "Google search ran but returned no grounding results."
1308
- else:
1309
- plural = "" if google_search_match_count == 1 else "s"
1310
- output_text = f"Google search returned {google_search_match_count} web result{plural}."
1311
- yield ChatEvent(
1312
- "tool_call_completed",
1313
- {
1314
- "message_id": message_id,
1315
- "call_id": google_search_call_id,
1316
- "tool_name": GOOGLE_SEARCH_TOOL_NAME,
1317
- "args": {"query": joined_queries},
1318
- "args_text": joined_queries,
1319
- "output_text": output_text,
1320
- },
1321
- )
1322
 
1323
  answer = "".join(answer_chunks).strip() or completed_answer.strip()
1324
  matched_sources = list(
 
57
 
58
  logger = logging.getLogger(__name__)
59
 
 
 
 
 
 
 
 
 
 
 
60
  CHECKPOINTER = InMemorySaver()
61
  _RETRIEVER_INIT_LOCK = Lock()
 
62
  DEFAULT_KB_COMMAND_LIMIT = 20
63
  _KB_COMMAND_COUNTS: dict[str, int] = {}
64
  _KB_COMMAND_COUNT_LOCK = Lock()
 
235
  return str(content)
236
 
237
 
238
+ def normalize_history(history: tuple[ChatTurn, ...]) -> tuple[ChatTurn, ...]:
239
+ """Keep user/assistant turns, trimming assistant whitespace so incoming
240
+ history compares equal to checkpoint-derived history."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  normalized: list[ChatTurn] = []
242
+ for turn in history:
243
+ if turn.role not in {"user", "assistant"}:
 
 
 
 
 
 
 
244
  continue
245
+ content = turn.content.strip() if turn.role == "assistant" else turn.content
246
+ normalized.append(ChatTurn(role=turn.role, content=content))
 
247
  return tuple(normalized)
248
 
249
 
 
258
  history.append(
259
  ChatTurn(
260
  "assistant",
261
+ message_content_to_text(message.content).strip(),
262
  )
263
  )
264
  return tuple(history)
 
739
  model_name: str,
740
  enabled_tools: tuple[str, ...],
741
  ) -> tuple[str, ...]:
742
+ names = ["retrieve_tutor_context", "run_kb_command"]
743
  enabled = set(enabled_tools)
744
  if is_google_genai_model(model_name):
745
  if "web_search" in enabled:
 
853
 
854
 
855
  GOOGLE_SEARCH_TOOL_NAME = "google_search"
856
+
857
+
858
+ class GoogleSearchActivity:
859
+ """Surface Gemini's server-side google_search activity as tool events.
860
+
861
+ Gemini reports search grounding via response metadata instead of tool
862
+ messages, so queries and grounding results are accumulated from every
863
+ metadata payload and exposed as a single synthetic tool call per turn.
864
+ """
865
+
866
+ def __init__(self, message_id: str, web_evidence: dict[str, SourceMatch]) -> None:
867
+ self._message_id = message_id
868
+ self._web_evidence = web_evidence
869
+ self._call_id = ""
870
+ self._queries: list[str] = []
871
+ self._match_count = 0
872
+
873
+ def observe(self, response_metadata: Any) -> ChatEvent | None:
874
+ """Record metadata; return a tool_call_started event on first activity."""
875
+ new_queries = [
876
+ q
877
+ for q in extract_web_search_queries(response_metadata)
878
+ if q not in self._queries
879
+ ]
880
+ new_grounding = extract_grounding_source_matches(
881
+ response_metadata,
882
+ self._web_evidence,
883
+ )
884
+ started: ChatEvent | None = None
885
+ if (new_queries or new_grounding) and not self._call_id:
886
+ self._call_id = uuid4().hex
887
+ joined = "; ".join(new_queries)
888
+ started = ChatEvent(
889
+ "tool_call_started",
890
+ {
891
+ "message_id": self._message_id,
892
+ "call_id": self._call_id,
893
+ "tool_name": GOOGLE_SEARCH_TOOL_NAME,
894
+ "args": {"query": joined},
895
+ "args_text": joined,
896
+ },
897
+ )
898
+ self._queries.extend(new_queries)
899
+ self._match_count += len(new_grounding)
900
+ return started
901
+
902
+ def completed_event(self) -> ChatEvent | None:
903
+ if not self._call_id:
904
+ return None
905
+ joined = "; ".join(self._queries)
906
+ if self._match_count == 0:
907
+ output_text = "Google search ran but returned no grounding results."
908
+ else:
909
+ plural = "" if self._match_count == 1 else "s"
910
+ output_text = (
911
+ f"Google search returned {self._match_count} web result{plural}."
912
+ )
913
+ return ChatEvent(
914
+ "tool_call_completed",
915
+ {
916
+ "message_id": self._message_id,
917
+ "call_id": self._call_id,
918
+ "tool_name": GOOGLE_SEARCH_TOOL_NAME,
919
+ "args": {"query": joined},
920
+ "args_text": joined,
921
+ "output_text": output_text,
922
+ },
923
+ )
924
+
925
+
926
  ANTHROPIC_SERVER_TOOL_NAMES = frozenset({"web_search", "web_fetch"})
927
  ANTHROPIC_RESULT_BLOCK_TYPES = {
928
  "web_search_tool_result": ("web_search", "Web"),
 
1052
  include_reasoning = bool(request.include_reasoning) and is_google_genai_model(
1053
  request.model_name
1054
  )
1055
+ google_search = GoogleSearchActivity(message_id, web_evidence)
 
 
1056
 
1057
  logger.info("Running query: %s", request.query)
1058
  agent = build_agent(
 
1131
  },
1132
  )
1133
 
1134
+ search_started = google_search.observe(
1135
+ getattr(token, "response_metadata", None)
 
 
 
 
 
 
 
1136
  )
1137
+ if search_started:
1138
+ yield search_started
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1139
  continue
1140
 
1141
  if chunk["type"] != "updates":
 
1195
  if step != "model" or getattr(message, "type", None) != "ai":
1196
  continue
1197
 
1198
+ search_started = google_search.observe(
1199
+ getattr(message, "response_metadata", None)
 
 
 
 
 
 
 
1200
  )
1201
+ if search_started:
1202
+ yield search_started
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1203
 
1204
  if is_anthropic_model(request.model_name):
1205
  anthropic_updates, anthropic_tool_uses = (
 
1250
  finally:
1251
  _clear_kb_command_budget(message_id)
1252
 
1253
+ search_completed = google_search.completed_event()
1254
+ if search_completed:
1255
+ yield search_completed
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1256
 
1257
  answer = "".join(answer_chunks).strip() or completed_answer.strip()
1258
  matched_sources = list(
scripts/gradio_presenter.py DELETED
@@ -1,375 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import json
4
- from dataclasses import dataclass, field
5
- from typing import Any
6
-
7
- from .chat_types import ChatEvent
8
-
9
- SOURCES_HEADER = "📝 Here are the sources I used to answer your question:"
10
- ACTIVITY_BLOCK_START = "<!-- MODEL_ACTIVITY_START -->"
11
- ACTIVITY_BLOCK_END = "<!-- MODEL_ACTIVITY_END -->"
12
- THOUGHTS_HEADER = "**Thinking**"
13
- THOUGHTS_HINT = "_Reasoning summary from Gemini. This is not the final answer._"
14
- TOOL_HEADER = "**Tool**"
15
- TOOL_PENDING_HINT = "_Searching the selected sources..._"
16
- ANSWER_HEADER = "**Answer**"
17
-
18
-
19
- @dataclass
20
- class ActivityEvent:
21
- key: str
22
- kind: str
23
- body: str
24
-
25
-
26
- def as_blockquote(text: str) -> str:
27
- lines = text.splitlines()
28
- if not lines:
29
- return ""
30
- return "\n".join("> " + line if line else ">" for line in lines)
31
-
32
-
33
- def merge_stream_text(existing: str, incoming: str) -> str:
34
- current = existing.strip()
35
- update = incoming.strip()
36
- if not update:
37
- return current
38
- if not current:
39
- return update
40
- if update == current or update in current:
41
- return current
42
- if current in update:
43
- return update
44
- return f"{current}\n\n{update}"
45
-
46
-
47
- def upsert_activity_event(
48
- events: list[ActivityEvent],
49
- *,
50
- key: str,
51
- kind: str,
52
- body: str,
53
- replace: bool = False,
54
- ) -> None:
55
- if not body.strip():
56
- return
57
-
58
- for index, event in enumerate(events):
59
- if event.key != key:
60
- continue
61
- events[index] = ActivityEvent(
62
- key=key,
63
- kind=kind,
64
- body=body.strip() if replace else merge_stream_text(event.body, body),
65
- )
66
- return
67
-
68
- events.append(ActivityEvent(key=key, kind=kind, body=body.strip()))
69
-
70
-
71
- def format_tool_args(args: Any, args_text: str = "") -> str:
72
- query = ""
73
- url = ""
74
- command = ""
75
- if isinstance(args, dict):
76
- query = str(args.get("query", "")).strip()
77
- url = str(args.get("url", "")).strip()
78
- command = str(args.get("command", "")).strip()
79
-
80
- if command:
81
- trimmed = command[:157] + "..." if len(command) > 160 else command
82
- return f"Command: `{trimmed}`"
83
-
84
- if not query and args_text.strip():
85
- query = args_text.strip()
86
-
87
- if query:
88
- trimmed = query[:157] + "..." if len(query) > 160 else query
89
- return f'Query: "{trimmed}"'
90
-
91
- if url:
92
- trimmed = url[:157] + "..." if len(url) > 160 else url
93
- return f"URL: `{trimmed}`"
94
-
95
- if isinstance(args, dict) and args:
96
- serialized = json.dumps(args, ensure_ascii=False, sort_keys=True)
97
- trimmed = serialized[:197] + "..." if len(serialized) > 200 else serialized
98
- return f"Args: `{trimmed}`"
99
-
100
- if args is None:
101
- return ""
102
- serialized = str(args).strip()
103
- if not serialized:
104
- return ""
105
- trimmed = serialized[:197] + "..." if len(serialized) > 200 else serialized
106
- return f"Args: `{trimmed}`"
107
-
108
-
109
- def summarize_activity_sources(sources: list[str], *, max_items: int = 3) -> str:
110
- if not sources:
111
- return ""
112
- if len(sources) <= max_items:
113
- return ", ".join(sources)
114
- remaining = len(sources) - max_items
115
- return f"{', '.join(sources[:max_items])}, and {remaining} more"
116
-
117
-
118
- def summarize_tool_result(event: ChatEvent) -> str:
119
- tool_name = str(event.data.get("tool_name", ""))
120
- matches = event.data.get("matches", [])
121
- match_count = len(matches)
122
-
123
- if tool_name == "retrieve_tutor_context":
124
- if not matches:
125
- try:
126
- payload = json.loads(str(event.data.get("output_text", "")))
127
- matches = (
128
- payload.get("matches", []) if isinstance(payload, dict) else []
129
- )
130
- match_count = len(matches)
131
- except json.JSONDecodeError:
132
- matches = []
133
- match_count = 0
134
- if not matches:
135
- return "_No matching sources found in the selected sources._"
136
- ordered_sources: list[str] = []
137
- seen_sources: set[str] = set()
138
- for match in matches:
139
- source_label = str(
140
- match.get("source_label")
141
- or match.get("source_key")
142
- or match.get("source")
143
- or "unknown"
144
- )
145
- if source_label in seen_sources:
146
- continue
147
- seen_sources.add(source_label)
148
- ordered_sources.append(source_label)
149
- source_summary = summarize_activity_sources(ordered_sources)
150
- match_label = "match" if match_count == 1 else "matches"
151
- return f"_Found {match_count} {match_label} from {source_summary}._"
152
-
153
- if tool_name == "run_kb_command":
154
- output_text = str(event.data.get("output_text", "")).strip()
155
- command = ""
156
- exit_code = ""
157
- for line in output_text.splitlines():
158
- if line.startswith("$ "):
159
- command = line[2:].strip()
160
- elif line.startswith("exit_code:"):
161
- exit_code = line.partition(":")[2].strip()
162
- if "error:" in output_text:
163
- return f"_KB command failed: {output_text.split('error:', 1)[1].strip()[:160]}_"
164
- if command and exit_code:
165
- return f"_Ran `{command}` with exit code {exit_code}._"
166
- if command:
167
- return f"_Ran `{command}`._"
168
- return "_Ran KB command._"
169
-
170
- output_text = str(event.data.get("output_text", "")).strip()
171
- if output_text:
172
- return f"_{output_text}_"
173
-
174
- if tool_name in ("google_search", "web_search"):
175
- if match_count == 0:
176
- return "_Search ran but returned no results._"
177
- label = "result" if match_count == 1 else "results"
178
- return f"_Searched the web — {match_count} {label} used as sources._"
179
-
180
- if tool_name in ("url_context", "web_fetch"):
181
- if match_count == 0:
182
- return "_URL fetched._"
183
- label = "page" if match_count == 1 else "pages"
184
- return f"_Fetched {match_count} {label}._"
185
-
186
- return "_Tool completed._"
187
-
188
-
189
- def format_tool_event(
190
- tool_name: str,
191
- args: Any,
192
- status_line: str,
193
- *,
194
- args_text: str = "",
195
- ) -> str:
196
- lines = [f"Using `{tool_name}`"]
197
- args_line = format_tool_args(args, args_text=args_text)
198
- if args_line:
199
- lines.append(args_line)
200
- lines.append(status_line)
201
- return "\n".join(lines)
202
-
203
-
204
- def render_activity_block(events: list[ActivityEvent]) -> str:
205
- sections: list[str] = []
206
- for event in events:
207
- if event.kind == "thinking":
208
- sections.append(
209
- "\n".join(
210
- [
211
- THOUGHTS_HEADER,
212
- THOUGHTS_HINT,
213
- "",
214
- as_blockquote(event.body),
215
- ]
216
- ).strip()
217
- )
218
- continue
219
- if event.kind == "tool":
220
- sections.append(f"{TOOL_HEADER}\n{event.body}".strip())
221
-
222
- if not sections:
223
- return ""
224
-
225
- rendered_sections = "\n\n".join(section for section in sections if section)
226
- return f"{ACTIVITY_BLOCK_START}\n\n{rendered_sections}\n\n{ACTIVITY_BLOCK_END}"
227
-
228
-
229
- def format_sources(matches_by_doc_id: dict[str, dict[str, Any]]) -> str:
230
- if not matches_by_doc_id:
231
- return ""
232
-
233
- lines = [SOURCES_HEADER]
234
- sorted_matches = sorted(
235
- matches_by_doc_id.values(),
236
- key=lambda item: item["score"],
237
- reverse=True,
238
- )
239
- for match in sorted_matches:
240
- lines.append(
241
- f"- [🔗 {match['source_label']}: {match['title']}]({match['url']}), relevance: {match['score']:.2f}"
242
- )
243
- return "\n".join(lines)
244
-
245
-
246
- def render_output(
247
- answer: str,
248
- activity_block: str = "",
249
- sources_block: str = "",
250
- ) -> str:
251
- visible_answer = answer.strip()
252
- visible_activity = activity_block.strip()
253
- visible_sources = sources_block.strip()
254
-
255
- output_parts = [visible_activity]
256
- if visible_answer:
257
- if visible_activity:
258
- output_parts.append(f"{ANSWER_HEADER}\n\n{visible_answer}")
259
- else:
260
- output_parts.append(visible_answer)
261
- output_parts.append(visible_sources)
262
- return "\n\n".join(part for part in output_parts if part)
263
-
264
-
265
- @dataclass
266
- class GradioPresenterState:
267
- show_activity: bool = False
268
- thread_id: str = ""
269
- message_completed: bool = False
270
- matches_by_doc_id: dict[str, dict[str, Any]] = field(default_factory=dict)
271
- activity_events: list[ActivityEvent] = field(default_factory=list)
272
- answer_chunks: list[str] = field(default_factory=list)
273
- completed_answer: str = ""
274
- tool_matches_by_call_id: dict[str, list[dict[str, Any]]] = field(
275
- default_factory=dict
276
- )
277
-
278
- def apply(self, event: ChatEvent) -> None:
279
- if event.type == "thread_started":
280
- self.thread_id = str(event.data.get("thread_id", self.thread_id))
281
- return
282
-
283
- if event.type == "text_delta":
284
- text = str(event.data.get("text", ""))
285
- if text:
286
- self.answer_chunks.append(text)
287
- return
288
-
289
- if event.type == "reasoning_delta":
290
- if not self.show_activity:
291
- return
292
- step = str(event.data.get("step", ""))
293
- body = str(event.data.get("text", ""))
294
- upsert_activity_event(
295
- self.activity_events,
296
- key=f"thinking:{step}",
297
- kind="thinking",
298
- body=body,
299
- )
300
- return
301
-
302
- if event.type == "tool_call_started":
303
- if not self.show_activity:
304
- return
305
- call_id = str(event.data.get("call_id", ""))
306
- upsert_activity_event(
307
- self.activity_events,
308
- key=f"tool:{call_id}",
309
- kind="tool",
310
- body=format_tool_event(
311
- str(event.data.get("tool_name", "tool")),
312
- event.data.get("args"),
313
- TOOL_PENDING_HINT,
314
- args_text=str(event.data.get("args_text", "")),
315
- ),
316
- replace=True,
317
- )
318
- return
319
-
320
- if event.type == "source_match":
321
- doc_id = str(event.data.get("doc_id", ""))
322
- existing = self.matches_by_doc_id.get(doc_id)
323
- incoming_score = float(event.data.get("score", 0.0))
324
- if existing and float(existing["score"]) >= incoming_score:
325
- return
326
- source_data = {
327
- "title": str(event.data.get("title", "")),
328
- "url": str(event.data.get("url", "")),
329
- "source_label": str(event.data.get("source_label", "")),
330
- "score": incoming_score,
331
- }
332
- self.matches_by_doc_id[doc_id] = source_data
333
-
334
- call_id = str(event.data.get("call_id", ""))
335
- if call_id:
336
- self.tool_matches_by_call_id.setdefault(call_id, []).append(source_data)
337
- return
338
-
339
- if event.type == "tool_call_completed":
340
- if not self.show_activity:
341
- return
342
- call_id = str(event.data.get("call_id", ""))
343
- matches = self.tool_matches_by_call_id.get(call_id, [])
344
- event_with_matches = ChatEvent(
345
- type=event.type,
346
- data={**event.data, "matches": matches},
347
- )
348
- upsert_activity_event(
349
- self.activity_events,
350
- key=f"tool:{call_id}",
351
- kind="tool",
352
- body=format_tool_event(
353
- str(event.data.get("tool_name", "tool")),
354
- event.data.get("args"),
355
- summarize_tool_result(event_with_matches),
356
- args_text=str(event.data.get("args_text", "")),
357
- ),
358
- replace=True,
359
- )
360
- return
361
-
362
- if event.type == "message_completed":
363
- self.message_completed = True
364
- self.thread_id = str(event.data.get("thread_id", self.thread_id))
365
- self.completed_answer = str(event.data.get("answer", "")).strip()
366
-
367
- def render(self) -> str:
368
- answer = "".join(self.answer_chunks).strip() or self.completed_answer
369
- activity_block = (
370
- render_activity_block(self.activity_events) if self.show_activity else ""
371
- )
372
- sources_block = (
373
- format_sources(self.matches_by_doc_id) if self.message_completed else ""
374
- )
375
- return render_output(answer, activity_block, sources_block)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
scripts/main.py DELETED
@@ -1,151 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import gradio as gr
4
-
5
- from .chat_service import (
6
- ChatRequest,
7
- normalize_history,
8
- stream_chat,
9
- warm_up_retriever,
10
- )
11
- from .gradio_presenter import GradioPresenterState
12
- from .setup import (
13
- AVAILABLE_SOURCES_UI,
14
- DEFAULT_MODEL_NAME,
15
- DEFAULT_SELECTED_SOURCES_UI,
16
- SOURCE_UI_TO_KEY,
17
- )
18
-
19
-
20
- async def generate_completion(
21
- query: str,
22
- history,
23
- sources,
24
- model,
25
- thread_id,
26
- enable_web_search,
27
- enable_url_read,
28
- ):
29
- enabled_tools: list[str] = []
30
- if enable_web_search:
31
- enabled_tools.append("web_search")
32
- if enable_url_read:
33
- enabled_tools.extend(["url_context", "web_fetch"])
34
- request = ChatRequest(
35
- query=query,
36
- history=normalize_history(history),
37
- source_keys=tuple(
38
- SOURCE_UI_TO_KEY[source] for source in sources if source in SOURCE_UI_TO_KEY
39
- ),
40
- model_name=model,
41
- include_reasoning=True,
42
- thread_id=thread_id,
43
- enabled_tools=tuple(enabled_tools),
44
- )
45
- presenter = GradioPresenterState(show_activity=True)
46
- last_emitted = ""
47
-
48
- async for event in stream_chat(request):
49
- presenter.apply(event)
50
- current_output = presenter.render()
51
- if current_output and current_output != last_emitted:
52
- last_emitted = current_output
53
- yield current_output, presenter.thread_id
54
-
55
- final_output = presenter.render()
56
- if final_output and final_output != last_emitted:
57
- yield final_output, presenter.thread_id
58
-
59
-
60
- accordion = gr.Accordion(label="Customize Sources (Click to expand)", open=False)
61
- sources = gr.CheckboxGroup(
62
- AVAILABLE_SOURCES_UI,
63
- label="Sources",
64
- value=DEFAULT_SELECTED_SOURCES_UI,
65
- interactive=True,
66
- )
67
- model = gr.Textbox(
68
- label="Model (provider:model)",
69
- value=DEFAULT_MODEL_NAME,
70
- interactive=False,
71
- placeholder="openai:gpt-5.4-mini | anthropic:claude-opus-4-6 | google-genai:gemini-3.5-flash",
72
- )
73
- enable_web_search = gr.Checkbox(
74
- label="Web search",
75
- value=True,
76
- info="Let the model use its built-in web search (Gemini google_search / Claude web_search).",
77
- )
78
- enable_url_read = gr.Checkbox(
79
- label="URL read",
80
- value=True,
81
- info="Let the model fetch URLs (Gemini url_context / Claude web_fetch).",
82
- )
83
- thread_id = gr.Textbox(
84
- label="Thread ID",
85
- value="",
86
- visible=False,
87
- container=False,
88
- )
89
-
90
- with gr.Blocks(
91
- title="Towards AI 🤖",
92
- analytics_enabled=True,
93
- fill_height=True,
94
- ) as demo:
95
-
96
- def reset_thread_id():
97
- return ""
98
-
99
- chatbot = gr.Chatbot(
100
- scale=20,
101
- placeholder="<strong>Towards AI 🤖: A Question-Answering Bot for anything AI-related</strong><br>",
102
- show_label=False,
103
- buttons=["copy"],
104
- )
105
- chatbot.clear(
106
- reset_thread_id,
107
- None,
108
- [thread_id],
109
- api_visibility="undocumented",
110
- queue=False,
111
- )
112
- chatbot.undo(
113
- reset_thread_id,
114
- None,
115
- [thread_id],
116
- api_visibility="undocumented",
117
- queue=False,
118
- )
119
- chatbot.retry(
120
- reset_thread_id,
121
- None,
122
- [thread_id],
123
- api_visibility="undocumented",
124
- queue=False,
125
- )
126
- chatbot.edit(
127
- reset_thread_id,
128
- None,
129
- [thread_id],
130
- api_visibility="undocumented",
131
- queue=False,
132
- )
133
- gr.ChatInterface(
134
- fn=generate_completion,
135
- chatbot=chatbot,
136
- additional_inputs=[
137
- sources,
138
- model,
139
- thread_id,
140
- enable_web_search,
141
- enable_url_read,
142
- ],
143
- additional_outputs=[thread_id],
144
- additional_inputs_accordion=accordion,
145
- api_name="chat",
146
- )
147
-
148
-
149
- if __name__ == "__main__":
150
- warm_up_retriever()
151
- demo.launch(server_name="0.0.0.0", server_port=7860, debug=False, share=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/manual_e2e_langsmith.md CHANGED
@@ -51,13 +51,6 @@ uv run dotenv -f .env run -- env RUN_LIVE_API_E2E=1 \
51
  pytest tests/test_api.py::test_live_api_stream_exposes_frontend_parts -q
52
  ```
53
 
54
- Gradio curl path:
55
-
56
- ```bash
57
- uv run dotenv -f .env run -- env RUN_LIVE_GRADIO_CURL_E2E=1 \
58
- pytest tests/test_gradio_kb_e2e.py::test_live_gradio_curl_can_use_retrieval_and_shell -q
59
- ```
60
-
61
  ## Manual API Test
62
 
63
  Start the FastAPI app in one terminal:
@@ -168,59 +161,6 @@ Expected result:
168
  - The answer has inline citations, not only a final sources list.
169
  - `data-source` parts include the source cards the frontend will render.
170
 
171
- ## Manual Gradio Test
172
-
173
- Start the Gradio app in one terminal:
174
-
175
- ```bash
176
- uv run dotenv -f .env run -- python -m scripts.main
177
- ```
178
-
179
- Submit a Gradio job:
180
-
181
- ```bash
182
- cat >/tmp/ai_tutor_gradio_payload.json <<'JSON'
183
- {
184
- "data": [
185
- "Use run_kb_command to answer: can you tell me about codex?",
186
- [],
187
- [
188
- "Agentic AI Engineering",
189
- "LangChain Docs",
190
- "LangGraph Docs",
191
- "PEFT Docs",
192
- "Transformers Docs"
193
- ],
194
- "google-genai:gemini-3.5-flash",
195
- "",
196
- false,
197
- false
198
- ]
199
- }
200
- JSON
201
-
202
- curl -s http://127.0.0.1:7860/gradio_api/call/chat \
203
- -H "Content-Type: application/json" \
204
- -d @/tmp/ai_tutor_gradio_payload.json \
205
- -o /tmp/ai_tutor_gradio_post.json
206
- ```
207
-
208
- Stream the result:
209
-
210
- ```bash
211
- EVENT_ID="$(jq -r '.event_id' /tmp/ai_tutor_gradio_post.json)"
212
-
213
- curl -N --max-time 240 \
214
- "http://127.0.0.1:7860/gradio_api/call/chat/${EVENT_ID}" \
215
- -o /tmp/ai_tutor_gradio_stream.sse
216
- ```
217
-
218
- Expected result:
219
-
220
- - The rendered text contains `Using \`retrieve_tutor_context\``.
221
- - The rendered text contains `Using \`run_kb_command\``.
222
- - The rendered text contains inline citations and source cards.
223
-
224
  ## Find The LangSmith Trace
225
 
226
  List recent traces in the project:
 
51
  pytest tests/test_api.py::test_live_api_stream_exposes_frontend_parts -q
52
  ```
53
 
 
 
 
 
 
 
 
54
  ## Manual API Test
55
 
56
  Start the FastAPI app in one terminal:
 
161
  - The answer has inline citations, not only a final sources list.
162
  - `data-source` parts include the source cards the frontend will render.
163
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  ## Find The LangSmith Trace
165
 
166
  List recent traces in the project:
tests/test_gradio_kb_e2e.py DELETED
@@ -1,209 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import socket
4
- import os
5
-
6
- import pytest
7
- from gradio_client import Client
8
-
9
- from scripts.chat_types import ChatEvent
10
-
11
-
12
- def free_port() -> int:
13
- with socket.socket() as sock:
14
- sock.bind(("127.0.0.1", 0))
15
- return int(sock.getsockname()[1])
16
-
17
-
18
- def test_gradio_chat_api_surfaces_shell_and_retrieval_activity(monkeypatch) -> None:
19
- import scripts.main as main
20
-
21
- async def fake_stream_chat(_request):
22
- yield ChatEvent("thread_started", {"thread_id": "thread_kb"})
23
- yield ChatEvent("message_started", {"message_id": "message_kb"})
24
- yield ChatEvent(
25
- "tool_call_started",
26
- {
27
- "message_id": "message_kb",
28
- "call_id": "call_search",
29
- "tool_name": "retrieve_tutor_context",
30
- "args": {"query": "generate_next_queries_tool"},
31
- "args_text": "generate_next_queries_tool",
32
- },
33
- )
34
- yield ChatEvent(
35
- "tool_call_completed",
36
- {
37
- "message_id": "message_kb",
38
- "call_id": "call_search",
39
- "tool_name": "retrieve_tutor_context",
40
- "args": {"query": "generate_next_queries_tool"},
41
- "args_text": "generate_next_queries_tool",
42
- "output_text": (
43
- '{"matches": [{"doc_id": "agentic_ai_engineering:lesson-18", '
44
- '"title": "Lesson 18: Research Loop", '
45
- '"url": "https://academy.towardsai.net/lesson-18", '
46
- '"source": "agentic_ai_engineering", "score": 10.0}]}'
47
- ),
48
- },
49
- )
50
- yield ChatEvent(
51
- "tool_call_started",
52
- {
53
- "message_id": "message_kb",
54
- "call_id": "call_rg",
55
- "tool_name": "run_kb_command",
56
- "args": {"command": "rg generate_next_queries_tool raw"},
57
- "args_text": "rg generate_next_queries_tool raw",
58
- },
59
- )
60
- yield ChatEvent(
61
- "tool_call_completed",
62
- {
63
- "message_id": "message_kb",
64
- "call_id": "call_rg",
65
- "tool_name": "run_kb_command",
66
- "args": {"command": "rg generate_next_queries_tool raw"},
67
- "args_text": "rg generate_next_queries_tool raw",
68
- "output_text": "$ rg generate_next_queries_tool raw\ncwd: data/kb\nexit_code: 0\nstdout:\nraw/courses/agentic_ai_engineering/lesson-18.md:4:Call `generate_next_queries_tool`.",
69
- },
70
- )
71
- yield ChatEvent(
72
- "text_delta",
73
- {
74
- "message_id": "message_kb",
75
- "text": "The symbol appears in Lesson 18: Research Loop.",
76
- },
77
- )
78
- yield ChatEvent(
79
- "source_match",
80
- {
81
- "message_id": "message_kb",
82
- "doc_id": "agentic_ai_engineering:lesson-18",
83
- "title": "Lesson 18: Research Loop",
84
- "url": "https://academy.towardsai.net/lesson-18",
85
- "source_key": "agentic_ai_engineering",
86
- "source_label": "Agentic AI Engineering",
87
- "score": 10.0,
88
- "group": "courses",
89
- },
90
- )
91
- yield ChatEvent(
92
- "message_completed",
93
- {
94
- "message_id": "message_kb",
95
- "thread_id": "thread_kb",
96
- "answer": "The symbol appears in Lesson 18: Research Loop.",
97
- },
98
- )
99
-
100
- monkeypatch.setattr(main, "stream_chat", fake_stream_chat)
101
- port = free_port()
102
- main.demo.launch(
103
- server_name="127.0.0.1",
104
- server_port=port,
105
- prevent_thread_lock=True,
106
- quiet=True,
107
- )
108
- try:
109
- client = Client(f"http://127.0.0.1:{port}")
110
- result = client.predict(
111
- "Where is generate_next_queries_tool discussed?",
112
- [],
113
- ["Agentic AI Engineering"],
114
- "google-genai:gemini-3.5-flash",
115
- "",
116
- False,
117
- False,
118
- api_name="/chat",
119
- )
120
- finally:
121
- main.demo.close()
122
-
123
- rendered = result[0] if isinstance(result, tuple) else str(result)
124
- assert "Using `retrieve_tutor_context`" in rendered
125
- assert "Using `run_kb_command`" in rendered
126
- assert "rg generate_next_queries_tool raw" in rendered
127
- assert "Lesson 18: Research Loop" in rendered
128
- assert "Agentic AI Engineering" in rendered
129
-
130
-
131
- @pytest.mark.skipif(
132
- os.getenv("RUN_LIVE_GRADIO_CURL_E2E") != "1",
133
- reason="Set RUN_LIVE_GRADIO_CURL_E2E=1 to run the live model-backed Gradio curl smoke test.",
134
- )
135
- def test_live_gradio_curl_can_use_retrieval_and_shell() -> None:
136
- if os.getenv("RUN_LIVE_GRADIO_CURL_E2E") != "1":
137
- pytest.skip("Set RUN_LIVE_GRADIO_CURL_E2E=1 to run this test.")
138
- import scripts.main as main
139
-
140
- import json
141
- import shutil
142
- import subprocess
143
-
144
- if shutil.which("curl") is None:
145
- pytest.skip("curl is required for the live Gradio curl smoke test.")
146
- if not os.getenv("COHERE_API_KEY"):
147
- pytest.skip("COHERE_API_KEY is required for live retrieval fallback.")
148
- if not (os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")):
149
- pytest.skip("GEMINI_API_KEY or GOOGLE_API_KEY is required for the live model.")
150
- if not os.path.exists("data/kb/wiki/index.md"):
151
- pytest.skip("data/kb artifacts must exist before running the live smoke test.")
152
-
153
- port = free_port()
154
- main.demo.launch(
155
- server_name="127.0.0.1",
156
- server_port=port,
157
- prevent_thread_lock=True,
158
- quiet=True,
159
- )
160
- try:
161
- post = subprocess.run(
162
- [
163
- "curl",
164
- "-s",
165
- f"http://127.0.0.1:{port}/gradio_api/call/chat",
166
- "-H",
167
- "Content-Type: application/json",
168
- "-d",
169
- json.dumps(
170
- {
171
- "data": [
172
- "Use both retrieve_tutor_context and run_kb_command to answer: how does PEFT configure LoRA with LoraConfig?",
173
- [],
174
- ["PEFT Docs", "Transformers Docs"],
175
- os.getenv(
176
- "LIVE_GRADIO_E2E_MODEL", "google-genai:gemini-3.5-flash"
177
- ),
178
- "",
179
- False,
180
- False,
181
- ]
182
- }
183
- ),
184
- ],
185
- check=True,
186
- capture_output=True,
187
- text=True,
188
- timeout=30,
189
- )
190
- event_id = json.loads(post.stdout)["event_id"]
191
- stream = subprocess.run(
192
- [
193
- "curl",
194
- "-N",
195
- f"http://127.0.0.1:{port}/gradio_api/call/chat/{event_id}",
196
- ],
197
- check=True,
198
- capture_output=True,
199
- text=True,
200
- timeout=180,
201
- )
202
- finally:
203
- main.demo.close()
204
-
205
- rendered = stream.stdout
206
- assert "Using `retrieve_tutor_context`" in rendered
207
- assert "Using `run_kb_command`" in rendered
208
- assert "LoRA" in rendered
209
- assert "PEFT" in rendered or "Transformers" in rendered
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
tests/test_gradio_presenter.py DELETED
@@ -1,91 +0,0 @@
1
- from __future__ import annotations
2
-
3
- import unittest
4
-
5
- from scripts.chat_types import ChatEvent
6
- from scripts.gradio_presenter import (
7
- GradioPresenterState,
8
- SOURCES_HEADER,
9
- summarize_tool_result,
10
- )
11
-
12
-
13
- class GradioPresenterTestCase(unittest.TestCase):
14
- def test_sources_render_only_after_message_completion(self) -> None:
15
- presenter = GradioPresenterState(show_activity=True)
16
-
17
- presenter.apply(ChatEvent("thread_started", {"thread_id": "thread_1"}))
18
- presenter.apply(ChatEvent("text_delta", {"message_id": "m1", "text": "Answer"}))
19
- presenter.apply(
20
- ChatEvent(
21
- "source_match",
22
- {
23
- "message_id": "m1",
24
- "call_id": "c1",
25
- "doc_id": "doc_1",
26
- "title": "RAG overview",
27
- "url": "https://example.com/rag",
28
- "source_label": "LangChain Docs",
29
- "score": 0.92,
30
- },
31
- )
32
- )
33
-
34
- in_progress = presenter.render()
35
- self.assertIn("Answer", in_progress)
36
- self.assertNotIn(SOURCES_HEADER, in_progress)
37
-
38
- presenter.apply(
39
- ChatEvent(
40
- "message_completed",
41
- {
42
- "message_id": "m1",
43
- "thread_id": "thread_1",
44
- "answer": "Answer",
45
- },
46
- )
47
- )
48
-
49
- completed = presenter.render()
50
- self.assertIn(SOURCES_HEADER, completed)
51
- self.assertIn("RAG overview", completed)
52
-
53
- def test_retrieval_summary_uses_tool_payload_matches(self) -> None:
54
- event = ChatEvent(
55
- "tool_call_completed",
56
- {
57
- "tool_name": "retrieve_tutor_context",
58
- "output_text": (
59
- '{"matches": [{"title": "LoRA", "source_label": "PEFT Docs"}]}'
60
- ),
61
- },
62
- )
63
-
64
- self.assertEqual(
65
- summarize_tool_result(event),
66
- "_Found 1 match from PEFT Docs._",
67
- )
68
-
69
- def test_run_kb_command_summary_confirms_command_execution(self) -> None:
70
- event = ChatEvent(
71
- "tool_call_completed",
72
- {
73
- "tool_name": "run_kb_command",
74
- "output_text": (
75
- "$ rg LoraConfig raw\n"
76
- "cwd: data/kb\n"
77
- "exit_code: 0\n"
78
- "stdout:\n"
79
- "raw/docs/peft/lora.md:3:LoraConfig"
80
- ),
81
- },
82
- )
83
-
84
- self.assertEqual(
85
- summarize_tool_result(event),
86
- "_Ran `rg LoraConfig raw` with exit code 0._",
87
- )
88
-
89
-
90
- if __name__ == "__main__":
91
- unittest.main()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
uv.lock CHANGED
@@ -20,7 +20,6 @@ dependencies = [
20
  { name = "cohere" },
21
  { name = "fastapi" },
22
  { name = "google-genai" },
23
- { name = "gradio" },
24
  { name = "hf-xet" },
25
  { name = "huggingface-hub" },
26
  { name = "instructor" },
@@ -44,6 +43,7 @@ dependencies = [
44
  [package.dev-dependencies]
45
  dev = [
46
  { name = "httpx" },
 
47
  { name = "pytest" },
48
  { name = "ruff" },
49
  ]
@@ -55,7 +55,6 @@ requires-dist = [
55
  { name = "cohere" },
56
  { name = "fastapi" },
57
  { name = "google-genai" },
58
- { name = "gradio" },
59
  { name = "hf-xet" },
60
  { name = "huggingface-hub" },
61
  { name = "instructor" },
@@ -79,6 +78,7 @@ requires-dist = [
79
  [package.metadata.requires-dev]
80
  dev = [
81
  { name = "httpx" },
 
82
  { name = "pytest", specifier = ">=9.0.3" },
83
  { name = "ruff", specifier = ">=0.15.10" },
84
  ]
@@ -270,62 +270,6 @@ wheels = [
270
  { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
271
  ]
272
 
273
- [[package]]
274
- name = "audioop-lts"
275
- version = "0.2.2"
276
- source = { registry = "https://pypi.org/simple" }
277
- sdist = { url = "https://files.pythonhosted.org/packages/38/53/946db57842a50b2da2e0c1e34bd37f36f5aadba1a929a3971c5d7841dbca/audioop_lts-0.2.2.tar.gz", hash = "sha256:64d0c62d88e67b98a1a5e71987b7aa7b5bcffc7dcee65b635823dbdd0a8dbbd0", size = 30686, upload-time = "2025-08-05T16:43:17.409Z" }
278
- wheels = [
279
- { url = "https://files.pythonhosted.org/packages/de/d4/94d277ca941de5a507b07f0b592f199c22454eeaec8f008a286b3fbbacd6/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_universal2.whl", hash = "sha256:fd3d4602dc64914d462924a08c1a9816435a2155d74f325853c1f1ac3b2d9800", size = 46523, upload-time = "2025-08-05T16:42:20.836Z" },
280
- { url = "https://files.pythonhosted.org/packages/f8/5a/656d1c2da4b555920ce4177167bfeb8623d98765594af59702c8873f60ec/audioop_lts-0.2.2-cp313-abi3-macosx_10_13_x86_64.whl", hash = "sha256:550c114a8df0aafe9a05442a1162dfc8fec37e9af1d625ae6060fed6e756f303", size = 27455, upload-time = "2025-08-05T16:42:22.283Z" },
281
- { url = "https://files.pythonhosted.org/packages/1b/83/ea581e364ce7b0d41456fb79d6ee0ad482beda61faf0cab20cbd4c63a541/audioop_lts-0.2.2-cp313-abi3-macosx_11_0_arm64.whl", hash = "sha256:9a13dc409f2564de15dd68be65b462ba0dde01b19663720c68c1140c782d1d75", size = 26997, upload-time = "2025-08-05T16:42:23.849Z" },
282
- { url = "https://files.pythonhosted.org/packages/b8/3b/e8964210b5e216e5041593b7d33e97ee65967f17c282e8510d19c666dab4/audioop_lts-0.2.2-cp313-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:51c916108c56aa6e426ce611946f901badac950ee2ddaf302b7ed35d9958970d", size = 85844, upload-time = "2025-08-05T16:42:25.208Z" },
283
- { url = "https://files.pythonhosted.org/packages/c7/2e/0a1c52faf10d51def20531a59ce4c706cb7952323b11709e10de324d6493/audioop_lts-0.2.2-cp313-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47eba38322370347b1c47024defbd36374a211e8dd5b0dcbce7b34fdb6f8847b", size = 85056, upload-time = "2025-08-05T16:42:26.559Z" },
284
- { url = "https://files.pythonhosted.org/packages/75/e8/cd95eef479656cb75ab05dfece8c1f8c395d17a7c651d88f8e6e291a63ab/audioop_lts-0.2.2-cp313-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba7c3a7e5f23e215cb271516197030c32aef2e754252c4c70a50aaff7031a2c8", size = 93892, upload-time = "2025-08-05T16:42:27.902Z" },
285
- { url = "https://files.pythonhosted.org/packages/5c/1e/a0c42570b74f83efa5cca34905b3eef03f7ab09fe5637015df538a7f3345/audioop_lts-0.2.2-cp313-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def246fe9e180626731b26e89816e79aae2276f825420a07b4a647abaa84becc", size = 96660, upload-time = "2025-08-05T16:42:28.9Z" },
286
- { url = "https://files.pythonhosted.org/packages/50/d5/8a0ae607ca07dbb34027bac8db805498ee7bfecc05fd2c148cc1ed7646e7/audioop_lts-0.2.2-cp313-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e160bf9df356d841bb6c180eeeea1834085464626dc1b68fa4e1d59070affdc3", size = 79143, upload-time = "2025-08-05T16:42:29.929Z" },
287
- { url = "https://files.pythonhosted.org/packages/12/17/0d28c46179e7910bfb0bb62760ccb33edb5de973052cb2230b662c14ca2e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:4b4cd51a57b698b2d06cb9993b7ac8dfe89a3b2878e96bc7948e9f19ff51dba6", size = 84313, upload-time = "2025-08-05T16:42:30.949Z" },
288
- { url = "https://files.pythonhosted.org/packages/84/ba/bd5d3806641564f2024e97ca98ea8f8811d4e01d9b9f9831474bc9e14f9e/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:4a53aa7c16a60a6857e6b0b165261436396ef7293f8b5c9c828a3a203147ed4a", size = 93044, upload-time = "2025-08-05T16:42:31.959Z" },
289
- { url = "https://files.pythonhosted.org/packages/f9/5e/435ce8d5642f1f7679540d1e73c1c42d933331c0976eb397d1717d7f01a3/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3fc38008969796f0f689f1453722a0f463da1b8a6fbee11987830bfbb664f623", size = 78766, upload-time = "2025-08-05T16:42:33.302Z" },
290
- { url = "https://files.pythonhosted.org/packages/ae/3b/b909e76b606cbfd53875693ec8c156e93e15a1366a012f0b7e4fb52d3c34/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_s390x.whl", hash = "sha256:15ab25dd3e620790f40e9ead897f91e79c0d3ce65fe193c8ed6c26cffdd24be7", size = 87640, upload-time = "2025-08-05T16:42:34.854Z" },
291
- { url = "https://files.pythonhosted.org/packages/30/e7/8f1603b4572d79b775f2140d7952f200f5e6c62904585d08a01f0a70393a/audioop_lts-0.2.2-cp313-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:03f061a1915538fd96272bac9551841859dbb2e3bf73ebe4a23ef043766f5449", size = 86052, upload-time = "2025-08-05T16:42:35.839Z" },
292
- { url = "https://files.pythonhosted.org/packages/b5/96/c37846df657ccdda62ba1ae2b6534fa90e2e1b1742ca8dcf8ebd38c53801/audioop_lts-0.2.2-cp313-abi3-win32.whl", hash = "sha256:3bcddaaf6cc5935a300a8387c99f7a7fbbe212a11568ec6cf6e4bc458c048636", size = 26185, upload-time = "2025-08-05T16:42:37.04Z" },
293
- { url = "https://files.pythonhosted.org/packages/34/a5/9d78fdb5b844a83da8a71226c7bdae7cc638861085fff7a1d707cb4823fa/audioop_lts-0.2.2-cp313-abi3-win_amd64.whl", hash = "sha256:a2c2a947fae7d1062ef08c4e369e0ba2086049a5e598fda41122535557012e9e", size = 30503, upload-time = "2025-08-05T16:42:38.427Z" },
294
- { url = "https://files.pythonhosted.org/packages/34/25/20d8fde083123e90c61b51afb547bb0ea7e77bab50d98c0ab243d02a0e43/audioop_lts-0.2.2-cp313-abi3-win_arm64.whl", hash = "sha256:5f93a5db13927a37d2d09637ccca4b2b6b48c19cd9eda7b17a2e9f77edee6a6f", size = 24173, upload-time = "2025-08-05T16:42:39.704Z" },
295
- { url = "https://files.pythonhosted.org/packages/58/a7/0a764f77b5c4ac58dc13c01a580f5d32ae8c74c92020b961556a43e26d02/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:73f80bf4cd5d2ca7814da30a120de1f9408ee0619cc75da87d0641273d202a09", size = 47096, upload-time = "2025-08-05T16:42:40.684Z" },
296
- { url = "https://files.pythonhosted.org/packages/aa/ed/ebebedde1a18848b085ad0fa54b66ceb95f1f94a3fc04f1cd1b5ccb0ed42/audioop_lts-0.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:106753a83a25ee4d6f473f2be6b0966fc1c9af7e0017192f5531a3e7463dce58", size = 27748, upload-time = "2025-08-05T16:42:41.992Z" },
297
- { url = "https://files.pythonhosted.org/packages/cb/6e/11ca8c21af79f15dbb1c7f8017952ee8c810c438ce4e2b25638dfef2b02c/audioop_lts-0.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:fbdd522624141e40948ab3e8cdae6e04c748d78710e9f0f8d4dae2750831de19", size = 27329, upload-time = "2025-08-05T16:42:42.987Z" },
298
- { url = "https://files.pythonhosted.org/packages/84/52/0022f93d56d85eec5da6b9da6a958a1ef09e80c39f2cc0a590c6af81dcbb/audioop_lts-0.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:143fad0311e8209ece30a8dbddab3b65ab419cbe8c0dde6e8828da25999be911", size = 92407, upload-time = "2025-08-05T16:42:44.336Z" },
299
- { url = "https://files.pythonhosted.org/packages/87/1d/48a889855e67be8718adbc7a01f3c01d5743c325453a5e81cf3717664aad/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfbbc74ec68a0fd08cfec1f4b5e8cca3d3cd7de5501b01c4b5d209995033cde9", size = 91811, upload-time = "2025-08-05T16:42:45.325Z" },
300
- { url = "https://files.pythonhosted.org/packages/98/a6/94b7213190e8077547ffae75e13ed05edc488653c85aa5c41472c297d295/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cfcac6aa6f42397471e4943e0feb2244549db5c5d01efcd02725b96af417f3fe", size = 100470, upload-time = "2025-08-05T16:42:46.468Z" },
301
- { url = "https://files.pythonhosted.org/packages/e9/e9/78450d7cb921ede0cfc33426d3a8023a3bda755883c95c868ee36db8d48d/audioop_lts-0.2.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:752d76472d9804ac60f0078c79cdae8b956f293177acd2316cd1e15149aee132", size = 103878, upload-time = "2025-08-05T16:42:47.576Z" },
302
- { url = "https://files.pythonhosted.org/packages/4f/e2/cd5439aad4f3e34ae1ee852025dc6aa8f67a82b97641e390bf7bd9891d3e/audioop_lts-0.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:83c381767e2cc10e93e40281a04852facc4cd9334550e0f392f72d1c0a9c5753", size = 84867, upload-time = "2025-08-05T16:42:49.003Z" },
303
- { url = "https://files.pythonhosted.org/packages/68/4b/9d853e9076c43ebba0d411e8d2aa19061083349ac695a7d082540bad64d0/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c0022283e9556e0f3643b7c3c03f05063ca72b3063291834cca43234f20c60bb", size = 90001, upload-time = "2025-08-05T16:42:50.038Z" },
304
- { url = "https://files.pythonhosted.org/packages/58/26/4bae7f9d2f116ed5593989d0e521d679b0d583973d203384679323d8fa85/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a2d4f1513d63c795e82948e1305f31a6d530626e5f9f2605408b300ae6095093", size = 99046, upload-time = "2025-08-05T16:42:51.111Z" },
305
- { url = "https://files.pythonhosted.org/packages/b2/67/a9f4fb3e250dda9e9046f8866e9fa7d52664f8985e445c6b4ad6dfb55641/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c9c8e68d8b4a56fda8c025e538e639f8c5953f5073886b596c93ec9b620055e7", size = 84788, upload-time = "2025-08-05T16:42:52.198Z" },
306
- { url = "https://files.pythonhosted.org/packages/70/f7/3de86562db0121956148bcb0fe5b506615e3bcf6e63c4357a612b910765a/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:96f19de485a2925314f5020e85911fb447ff5fbef56e8c7c6927851b95533a1c", size = 94472, upload-time = "2025-08-05T16:42:53.59Z" },
307
- { url = "https://files.pythonhosted.org/packages/f1/32/fd772bf9078ae1001207d2df1eef3da05bea611a87dd0e8217989b2848fa/audioop_lts-0.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e541c3ef484852ef36545f66209444c48b28661e864ccadb29daddb6a4b8e5f5", size = 92279, upload-time = "2025-08-05T16:42:54.632Z" },
308
- { url = "https://files.pythonhosted.org/packages/4f/41/affea7181592ab0ab560044632571a38edaf9130b84928177823fbf3176a/audioop_lts-0.2.2-cp313-cp313t-win32.whl", hash = "sha256:d5e73fa573e273e4f2e5ff96f9043858a5e9311e94ffefd88a3186a910c70917", size = 26568, upload-time = "2025-08-05T16:42:55.627Z" },
309
- { url = "https://files.pythonhosted.org/packages/28/2b/0372842877016641db8fc54d5c88596b542eec2f8f6c20a36fb6612bf9ee/audioop_lts-0.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9191d68659eda01e448188f60364c7763a7ca6653ed3f87ebb165822153a8547", size = 30942, upload-time = "2025-08-05T16:42:56.674Z" },
310
- { url = "https://files.pythonhosted.org/packages/ee/ca/baf2b9cc7e96c179bb4a54f30fcd83e6ecb340031bde68f486403f943768/audioop_lts-0.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c174e322bb5783c099aaf87faeb240c8d210686b04bd61dfd05a8e5a83d88969", size = 24603, upload-time = "2025-08-05T16:42:57.571Z" },
311
- { url = "https://files.pythonhosted.org/packages/5c/73/413b5a2804091e2c7d5def1d618e4837f1cb82464e230f827226278556b7/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:f9ee9b52f5f857fbaf9d605a360884f034c92c1c23021fb90b2e39b8e64bede6", size = 47104, upload-time = "2025-08-05T16:42:58.518Z" },
312
- { url = "https://files.pythonhosted.org/packages/ae/8c/daa3308dc6593944410c2c68306a5e217f5c05b70a12e70228e7dd42dc5c/audioop_lts-0.2.2-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:49ee1a41738a23e98d98b937a0638357a2477bc99e61b0f768a8f654f45d9b7a", size = 27754, upload-time = "2025-08-05T16:43:00.132Z" },
313
- { url = "https://files.pythonhosted.org/packages/4e/86/c2e0f627168fcf61781a8f72cab06b228fe1da4b9fa4ab39cfb791b5836b/audioop_lts-0.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b00be98ccd0fc123dcfad31d50030d25fcf31488cde9e61692029cd7394733b", size = 27332, upload-time = "2025-08-05T16:43:01.666Z" },
314
- { url = "https://files.pythonhosted.org/packages/c7/bd/35dce665255434f54e5307de39e31912a6f902d4572da7c37582809de14f/audioop_lts-0.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a6d2e0f9f7a69403e388894d4ca5ada5c47230716a03f2847cfc7bd1ecb589d6", size = 92396, upload-time = "2025-08-05T16:43:02.991Z" },
315
- { url = "https://files.pythonhosted.org/packages/2d/d2/deeb9f51def1437b3afa35aeb729d577c04bcd89394cb56f9239a9f50b6f/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9b0b8a03ef474f56d1a842af1a2e01398b8f7654009823c6d9e0ecff4d5cfbf", size = 91811, upload-time = "2025-08-05T16:43:04.096Z" },
316
- { url = "https://files.pythonhosted.org/packages/76/3b/09f8b35b227cee28cc8231e296a82759ed80c1a08e349811d69773c48426/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2b267b70747d82125f1a021506565bdc5609a2b24bcb4773c16d79d2bb260bbd", size = 100483, upload-time = "2025-08-05T16:43:05.085Z" },
317
- { url = "https://files.pythonhosted.org/packages/0b/15/05b48a935cf3b130c248bfdbdea71ce6437f5394ee8533e0edd7cfd93d5e/audioop_lts-0.2.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0337d658f9b81f4cd0fdb1f47635070cc084871a3d4646d9de74fdf4e7c3d24a", size = 103885, upload-time = "2025-08-05T16:43:06.197Z" },
318
- { url = "https://files.pythonhosted.org/packages/83/80/186b7fce6d35b68d3d739f228dc31d60b3412105854edb975aa155a58339/audioop_lts-0.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:167d3b62586faef8b6b2275c3218796b12621a60e43f7e9d5845d627b9c9b80e", size = 84899, upload-time = "2025-08-05T16:43:07.291Z" },
319
- { url = "https://files.pythonhosted.org/packages/49/89/c78cc5ac6cb5828f17514fb12966e299c850bc885e80f8ad94e38d450886/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0d9385e96f9f6da847f4d571ce3cb15b5091140edf3db97276872647ce37efd7", size = 89998, upload-time = "2025-08-05T16:43:08.335Z" },
320
- { url = "https://files.pythonhosted.org/packages/4c/4b/6401888d0c010e586c2ca50fce4c903d70a6bb55928b16cfbdfd957a13da/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:48159d96962674eccdca9a3df280e864e8ac75e40a577cc97c5c42667ffabfc5", size = 99046, upload-time = "2025-08-05T16:43:09.367Z" },
321
- { url = "https://files.pythonhosted.org/packages/de/f8/c874ca9bb447dae0e2ef2e231f6c4c2b0c39e31ae684d2420b0f9e97ee68/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8fefe5868cd082db1186f2837d64cfbfa78b548ea0d0543e9b28935ccce81ce9", size = 84843, upload-time = "2025-08-05T16:43:10.749Z" },
322
- { url = "https://files.pythonhosted.org/packages/3e/c0/0323e66f3daebc13fd46b36b30c3be47e3fc4257eae44f1e77eb828c703f/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:58cf54380c3884fb49fdd37dfb7a772632b6701d28edd3e2904743c5e1773602", size = 94490, upload-time = "2025-08-05T16:43:12.131Z" },
323
- { url = "https://files.pythonhosted.org/packages/98/6b/acc7734ac02d95ab791c10c3f17ffa3584ccb9ac5c18fd771c638ed6d1f5/audioop_lts-0.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:088327f00488cdeed296edd9215ca159f3a5a5034741465789cad403fcf4bec0", size = 92297, upload-time = "2025-08-05T16:43:13.139Z" },
324
- { url = "https://files.pythonhosted.org/packages/13/c3/c3dc3f564ce6877ecd2a05f8d751b9b27a8c320c2533a98b0c86349778d0/audioop_lts-0.2.2-cp314-cp314t-win32.whl", hash = "sha256:068aa17a38b4e0e7de771c62c60bbca2455924b67a8814f3b0dee92b5820c0b3", size = 27331, upload-time = "2025-08-05T16:43:14.19Z" },
325
- { url = "https://files.pythonhosted.org/packages/72/bb/b4608537e9ffcb86449091939d52d24a055216a36a8bf66b936af8c3e7ac/audioop_lts-0.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a5bf613e96f49712073de86f20dbdd4014ca18efd4d34ed18c75bd808337851b", size = 31697, upload-time = "2025-08-05T16:43:15.193Z" },
326
- { url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" },
327
- ]
328
-
329
  [[package]]
330
  name = "banks"
331
  version = "2.4.2"
@@ -439,34 +383,6 @@ css = [
439
  { name = "tinycss2" },
440
  ]
441
 
442
- [[package]]
443
- name = "brotli"
444
- version = "1.2.0"
445
- source = { registry = "https://pypi.org/simple" }
446
- sdist = { url = "https://files.pythonhosted.org/packages/f7/16/c92ca344d646e71a43b8bb353f0a6490d7f6e06210f8554c8f874e454285/brotli-1.2.0.tar.gz", hash = "sha256:e310f77e41941c13340a95976fe66a8a95b01e783d430eeaf7a2f87e0a57dd0a", size = 7388632, upload-time = "2025-11-05T18:39:42.86Z" }
447
- wheels = [
448
- { url = "https://files.pythonhosted.org/packages/6c/d4/4ad5432ac98c73096159d9ce7ffeb82d151c2ac84adcc6168e476bb54674/brotli-1.2.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:9e5825ba2c9998375530504578fd4d5d1059d09621a02065d1b6bfc41a8e05ab", size = 861523, upload-time = "2025-11-05T18:38:34.67Z" },
449
- { url = "https://files.pythonhosted.org/packages/91/9f/9cc5bd03ee68a85dc4bc89114f7067c056a3c14b3d95f171918c088bf88d/brotli-1.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0cf8c3b8ba93d496b2fae778039e2f5ecc7cff99df84df337ca31d8f2252896c", size = 444289, upload-time = "2025-11-05T18:38:35.6Z" },
450
- { url = "https://files.pythonhosted.org/packages/2e/b6/fe84227c56a865d16a6614e2c4722864b380cb14b13f3e6bef441e73a85a/brotli-1.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8565e3cdc1808b1a34714b553b262c5de5fbda202285782173ec137fd13709f", size = 1528076, upload-time = "2025-11-05T18:38:36.639Z" },
451
- { url = "https://files.pythonhosted.org/packages/55/de/de4ae0aaca06c790371cf6e7ee93a024f6b4bb0568727da8c3de112e726c/brotli-1.2.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:26e8d3ecb0ee458a9804f47f21b74845cc823fd1bb19f02272be70774f56e2a6", size = 1626880, upload-time = "2025-11-05T18:38:37.623Z" },
452
- { url = "https://files.pythonhosted.org/packages/5f/16/a1b22cbea436642e071adcaf8d4b350a2ad02f5e0ad0da879a1be16188a0/brotli-1.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:67a91c5187e1eec76a61625c77a6c8c785650f5b576ca732bd33ef58b0dff49c", size = 1419737, upload-time = "2025-11-05T18:38:38.729Z" },
453
- { url = "https://files.pythonhosted.org/packages/46/63/c968a97cbb3bdbf7f974ef5a6ab467a2879b82afbc5ffb65b8acbb744f95/brotli-1.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4ecdb3b6dc36e6d6e14d3a1bdc6c1057c8cbf80db04031d566eb6080ce283a48", size = 1484440, upload-time = "2025-11-05T18:38:39.916Z" },
454
- { url = "https://files.pythonhosted.org/packages/06/9d/102c67ea5c9fc171f423e8399e585dabea29b5bc79b05572891e70013cdd/brotli-1.2.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3e1b35d56856f3ed326b140d3c6d9db91740f22e14b06e840fe4bb1923439a18", size = 1593313, upload-time = "2025-11-05T18:38:41.24Z" },
455
- { url = "https://files.pythonhosted.org/packages/9e/4a/9526d14fa6b87bc827ba1755a8440e214ff90de03095cacd78a64abe2b7d/brotli-1.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54a50a9dad16b32136b2241ddea9e4df159b41247b2ce6aac0b3276a66a8f1e5", size = 1487945, upload-time = "2025-11-05T18:38:42.277Z" },
456
- { url = "https://files.pythonhosted.org/packages/5b/e8/3fe1ffed70cbef83c5236166acaed7bb9c766509b157854c80e2f766b38c/brotli-1.2.0-cp313-cp313-win32.whl", hash = "sha256:1b1d6a4efedd53671c793be6dd760fcf2107da3a52331ad9ea429edf0902f27a", size = 334368, upload-time = "2025-11-05T18:38:43.345Z" },
457
- { url = "https://files.pythonhosted.org/packages/ff/91/e739587be970a113b37b821eae8097aac5a48e5f0eca438c22e4c7dd8648/brotli-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:b63daa43d82f0cdabf98dee215b375b4058cce72871fd07934f179885aad16e8", size = 369116, upload-time = "2025-11-05T18:38:44.609Z" },
458
- { url = "https://files.pythonhosted.org/packages/17/e1/298c2ddf786bb7347a1cd71d63a347a79e5712a7c0cba9e3c3458ebd976f/brotli-1.2.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:6c12dad5cd04530323e723787ff762bac749a7b256a5bece32b2243dd5c27b21", size = 863080, upload-time = "2025-11-05T18:38:45.503Z" },
459
- { url = "https://files.pythonhosted.org/packages/84/0c/aac98e286ba66868b2b3b50338ffbd85a35c7122e9531a73a37a29763d38/brotli-1.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3219bd9e69868e57183316ee19c84e03e8f8b5a1d1f2667e1aa8c2f91cb061ac", size = 445453, upload-time = "2025-11-05T18:38:46.433Z" },
460
- { url = "https://files.pythonhosted.org/packages/ec/f1/0ca1f3f99ae300372635ab3fe2f7a79fa335fee3d874fa7f9e68575e0e62/brotli-1.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:963a08f3bebd8b75ac57661045402da15991468a621f014be54e50f53a58d19e", size = 1528168, upload-time = "2025-11-05T18:38:47.371Z" },
461
- { url = "https://files.pythonhosted.org/packages/d6/a6/2ebfc8f766d46df8d3e65b880a2e220732395e6d7dc312c1e1244b0f074a/brotli-1.2.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9322b9f8656782414b37e6af884146869d46ab85158201d82bab9abbcb971dc7", size = 1627098, upload-time = "2025-11-05T18:38:48.385Z" },
462
- { url = "https://files.pythonhosted.org/packages/f3/2f/0976d5b097ff8a22163b10617f76b2557f15f0f39d6a0fe1f02b1a53e92b/brotli-1.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cf9cba6f5b78a2071ec6fb1e7bd39acf35071d90a81231d67e92d637776a6a63", size = 1419861, upload-time = "2025-11-05T18:38:49.372Z" },
463
- { url = "https://files.pythonhosted.org/packages/9c/97/d76df7176a2ce7616ff94c1fb72d307c9a30d2189fe877f3dd99af00ea5a/brotli-1.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7547369c4392b47d30a3467fe8c3330b4f2e0f7730e45e3103d7d636678a808b", size = 1484594, upload-time = "2025-11-05T18:38:50.655Z" },
464
- { url = "https://files.pythonhosted.org/packages/d3/93/14cf0b1216f43df5609f5b272050b0abd219e0b54ea80b47cef9867b45e7/brotli-1.2.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:fc1530af5c3c275b8524f2e24841cbe2599d74462455e9bae5109e9ff42e9361", size = 1593455, upload-time = "2025-11-05T18:38:51.624Z" },
465
- { url = "https://files.pythonhosted.org/packages/b3/73/3183c9e41ca755713bdf2cc1d0810df742c09484e2e1ddd693bee53877c1/brotli-1.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d2d085ded05278d1c7f65560aae97b3160aeb2ea2c0b3e26204856beccb60888", size = 1488164, upload-time = "2025-11-05T18:38:53.079Z" },
466
- { url = "https://files.pythonhosted.org/packages/64/6a/0c78d8f3a582859236482fd9fa86a65a60328a00983006bcf6d83b7b2253/brotli-1.2.0-cp314-cp314-win32.whl", hash = "sha256:832c115a020e463c2f67664560449a7bea26b0c1fdd690352addad6d0a08714d", size = 339280, upload-time = "2025-11-05T18:38:54.02Z" },
467
- { url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
468
- ]
469
-
470
  [[package]]
471
  name = "build"
472
  version = "1.5.0"
@@ -535,6 +451,15 @@ wheels = [
535
  { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
536
  ]
537
 
 
 
 
 
 
 
 
 
 
538
  [[package]]
539
  name = "charset-normalizer"
540
  version = "3.4.7"
@@ -805,6 +730,15 @@ wheels = [
805
  { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197, upload-time = "2022-11-28T23:32:31.219Z" },
806
  ]
807
 
 
 
 
 
 
 
 
 
 
808
  [[package]]
809
  name = "distro"
810
  version = "1.9.0"
@@ -1066,61 +1000,6 @@ wheels = [
1066
  { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
1067
  ]
1068
 
1069
- [[package]]
1070
- name = "gradio"
1071
- version = "6.16.0"
1072
- source = { registry = "https://pypi.org/simple" }
1073
- dependencies = [
1074
- { name = "anyio" },
1075
- { name = "audioop-lts" },
1076
- { name = "brotli" },
1077
- { name = "fastapi" },
1078
- { name = "gradio-client" },
1079
- { name = "groovy" },
1080
- { name = "hf-gradio" },
1081
- { name = "httpx" },
1082
- { name = "huggingface-hub" },
1083
- { name = "jinja2" },
1084
- { name = "markupsafe" },
1085
- { name = "numpy" },
1086
- { name = "orjson" },
1087
- { name = "packaging" },
1088
- { name = "pandas" },
1089
- { name = "pillow" },
1090
- { name = "pydantic" },
1091
- { name = "pydub" },
1092
- { name = "python-multipart" },
1093
- { name = "pytz" },
1094
- { name = "pyyaml" },
1095
- { name = "safehttpx" },
1096
- { name = "semantic-version" },
1097
- { name = "starlette" },
1098
- { name = "tomlkit" },
1099
- { name = "typer" },
1100
- { name = "typing-extensions" },
1101
- { name = "uvicorn" },
1102
- ]
1103
- sdist = { url = "https://files.pythonhosted.org/packages/a3/26/44044687cdd16d0d9d9137c8f9d445eb68391b14583c82a6c2d528da952c/gradio-6.16.0.tar.gz", hash = "sha256:a48d03ffb436644265e4386388ad8a50ec183c8e1fdd3bae0548f7f229c474da", size = 36441812, upload-time = "2026-06-03T19:07:12.973Z" }
1104
- wheels = [
1105
- { url = "https://files.pythonhosted.org/packages/2a/12/ac98683fa45d84c2c96c14acdb040aa746209089ea045c766f380549ecec/gradio-6.16.0-py3-none-any.whl", hash = "sha256:a5de01bf17336bf421d52af4b5dbd9fdbd530762c485067748456ccb797700bb", size = 20097732, upload-time = "2026-06-03T19:07:09.904Z" },
1106
- ]
1107
-
1108
- [[package]]
1109
- name = "gradio-client"
1110
- version = "2.5.0"
1111
- source = { registry = "https://pypi.org/simple" }
1112
- dependencies = [
1113
- { name = "fsspec" },
1114
- { name = "httpx" },
1115
- { name = "huggingface-hub" },
1116
- { name = "packaging" },
1117
- { name = "typing-extensions" },
1118
- ]
1119
- sdist = { url = "https://files.pythonhosted.org/packages/e8/e6/6b6029f5fe2ad7f1211105d530e34d991014c2cae463f9223033031cfc4f/gradio_client-2.5.0.tar.gz", hash = "sha256:4cde99bad62149595c30c90876ca2e405e3a13687ecf895474f3412cb476673d", size = 59013, upload-time = "2026-04-20T23:16:21.518Z" }
1120
- wheels = [
1121
- { url = "https://files.pythonhosted.org/packages/78/81/0a861b8e1ff42960139c6cd4c7dd591292fa09ea1ae2d87677441cba4c00/gradio_client-2.5.0-py3-none-any.whl", hash = "sha256:d43e2179c29076292a76485ad7ed2e6eaa19d14ac58283bd7f5beabfe4ca958c", size = 59952, upload-time = "2026-04-20T23:16:20.186Z" },
1122
- ]
1123
-
1124
  [[package]]
1125
  name = "greenlet"
1126
  version = "3.5.1"
@@ -1213,15 +1092,6 @@ wheels = [
1213
  { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
1214
  ]
1215
 
1216
- [[package]]
1217
- name = "groovy"
1218
- version = "0.1.2"
1219
- source = { registry = "https://pypi.org/simple" }
1220
- sdist = { url = "https://files.pythonhosted.org/packages/52/36/bbdede67400277bef33d3ec0e6a31750da972c469f75966b4930c753218f/groovy-0.1.2.tar.gz", hash = "sha256:25c1dc09b3f9d7e292458aa762c6beb96ea037071bf5e917fc81fb78d2231083", size = 17325, upload-time = "2025-02-28T20:24:56.068Z" }
1221
- wheels = [
1222
- { url = "https://files.pythonhosted.org/packages/28/27/3d6dcadc8a3214d8522c1e7f6a19554e33659be44546d44a2f7572ac7d2a/groovy-0.1.2-py3-none-any.whl", hash = "sha256:7f7975bab18c729a257a8b1ae9dcd70b7cafb1720481beae47719af57c35fa64", size = 14090, upload-time = "2025-02-28T20:24:55.152Z" },
1223
- ]
1224
-
1225
  [[package]]
1226
  name = "grpcio"
1227
  version = "1.81.0"
@@ -1262,19 +1132,6 @@ wheels = [
1262
  { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
1263
  ]
1264
 
1265
- [[package]]
1266
- name = "hf-gradio"
1267
- version = "0.4.1"
1268
- source = { registry = "https://pypi.org/simple" }
1269
- dependencies = [
1270
- { name = "gradio-client" },
1271
- { name = "typer" },
1272
- ]
1273
- sdist = { url = "https://files.pythonhosted.org/packages/ce/86/c9694b7cfada5780e75769e60dc161a161f4dd7fc91b61db5e3a3338bef9/hf_gradio-0.4.1.tar.gz", hash = "sha256:a017d942618f0d495a58ee4563047fa04bef614c00e0cb789a9a6d0633cffa7b", size = 6560, upload-time = "2026-04-22T14:01:32.334Z" }
1274
- wheels = [
1275
- { url = "https://files.pythonhosted.org/packages/30/2d/afff2ee87e75d8eb85c92bb8cf0e15b05c23c2ebd8fd8dec781d8601ed7f/hf_gradio-0.4.1-py3-none-any.whl", hash = "sha256:76b8cb8be6abe62d74c1ad2d35b42f0629db89aa9e1a8d033cecfe7c856eeab3", size = 4482, upload-time = "2026-04-17T19:53:31.827Z" },
1276
- ]
1277
-
1278
  [[package]]
1279
  name = "hf-xet"
1280
  version = "1.5.0"
@@ -1385,6 +1242,15 @@ wheels = [
1385
  { url = "https://files.pythonhosted.org/packages/02/28/d7cef5e477b855c25d415b8f57e5bc7347c7a90cad3acf1725d0c92ca294/huggingface_hub-1.17.0-py3-none-any.whl", hash = "sha256:3b8156d23118e87f6a587648bfbc04f04a12a757ccb4ed298b35c4ae638bf24c", size = 671546, upload-time = "2026-05-28T15:12:11.441Z" },
1386
  ]
1387
 
 
 
 
 
 
 
 
 
 
1388
  [[package]]
1389
  name = "idna"
1390
  version = "3.18"
@@ -2309,6 +2175,15 @@ wheels = [
2309
  { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" },
2310
  ]
2311
 
 
 
 
 
 
 
 
 
 
2312
  [[package]]
2313
  name = "numpy"
2314
  version = "2.4.6"
@@ -2581,50 +2456,6 @@ wheels = [
2581
  { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
2582
  ]
2583
 
2584
- [[package]]
2585
- name = "pandas"
2586
- version = "3.0.3"
2587
- source = { registry = "https://pypi.org/simple" }
2588
- dependencies = [
2589
- { name = "numpy" },
2590
- { name = "python-dateutil" },
2591
- { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
2592
- ]
2593
- sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414, upload-time = "2026-05-11T18:54:29.21Z" }
2594
- wheels = [
2595
- { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071, upload-time = "2026-05-11T18:52:58.838Z" },
2596
- { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690, upload-time = "2026-05-11T18:53:01.431Z" },
2597
- { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634, upload-time = "2026-05-11T18:53:04.393Z" },
2598
- { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243, upload-time = "2026-05-11T18:53:07.643Z" },
2599
- { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659, upload-time = "2026-05-11T18:53:10.634Z" },
2600
- { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880, upload-time = "2026-05-11T18:53:13.536Z" },
2601
- { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091, upload-time = "2026-05-11T18:53:16.306Z" },
2602
- { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282, upload-time = "2026-05-11T18:53:18.768Z" },
2603
- { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016, upload-time = "2026-05-11T18:53:21.227Z" },
2604
- { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210, upload-time = "2026-05-11T18:53:23.982Z" },
2605
- { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126, upload-time = "2026-05-11T18:53:26.731Z" },
2606
- { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051, upload-time = "2026-05-11T18:53:29.125Z" },
2607
- { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796, upload-time = "2026-05-11T18:53:32.056Z" },
2608
- { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741, upload-time = "2026-05-11T18:53:34.985Z" },
2609
- { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958, upload-time = "2026-05-11T18:53:37.857Z" },
2610
- { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065, upload-time = "2026-05-11T18:53:41.099Z" },
2611
- { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101, upload-time = "2026-05-11T18:53:43.515Z" },
2612
- { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553, upload-time = "2026-05-11T18:53:46.394Z" },
2613
- { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065, upload-time = "2026-05-11T18:53:49.134Z" },
2614
- { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188, upload-time = "2026-05-11T18:53:52.527Z" },
2615
- { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966, upload-time = "2026-05-11T18:53:55.043Z" },
2616
- { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755, upload-time = "2026-05-11T18:53:58.067Z" },
2617
- { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658, upload-time = "2026-05-11T18:54:00.733Z" },
2618
- { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242, upload-time = "2026-05-11T18:54:03.564Z" },
2619
- { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369, upload-time = "2026-05-11T18:54:06.311Z" },
2620
- { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306, upload-time = "2026-05-11T18:54:09.085Z" },
2621
- { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394, upload-time = "2026-05-11T18:54:11.956Z" },
2622
- { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717, upload-time = "2026-05-11T18:54:14.539Z" },
2623
- { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897, upload-time = "2026-05-11T18:54:17.146Z" },
2624
- { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855, upload-time = "2026-05-11T18:54:19.72Z" },
2625
- { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464, upload-time = "2026-05-11T18:54:22.754Z" },
2626
- ]
2627
-
2628
  [[package]]
2629
  name = "pandocfilters"
2630
  version = "1.5.1"
@@ -2731,6 +2562,22 @@ wheels = [
2731
  { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
2732
  ]
2733
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2734
  [[package]]
2735
  name = "prompt-toolkit"
2736
  version = "3.0.52"
@@ -3090,15 +2937,6 @@ wheels = [
3090
  { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
3091
  ]
3092
 
3093
- [[package]]
3094
- name = "pydub"
3095
- version = "0.25.1"
3096
- source = { registry = "https://pypi.org/simple" }
3097
- sdist = { url = "https://files.pythonhosted.org/packages/fe/9a/e6bca0eed82db26562c73b5076539a4a08d3cffd19c3cc5913a3e61145fd/pydub-0.25.1.tar.gz", hash = "sha256:980a33ce9949cab2a569606b65674d748ecbca4f0796887fd6f46173a7b0d30f", size = 38326, upload-time = "2021-03-10T02:09:54.659Z" }
3098
- wheels = [
3099
- { url = "https://files.pythonhosted.org/packages/a6/53/d78dc063216e62fc55f6b2eebb447f6a4b0a59f55c8406376f76bf959b08/pydub-0.25.1-py2.py3-none-any.whl", hash = "sha256:65617e33033874b59d87db603aa1ed450633288aefead953b30bded59cb599a6", size = 32327, upload-time = "2021-03-10T02:09:53.503Z" },
3100
- ]
3101
-
3102
  [[package]]
3103
  name = "pygments"
3104
  version = "2.20.0"
@@ -3196,30 +3034,25 @@ wheels = [
3196
  ]
3197
 
3198
  [[package]]
3199
- name = "python-dotenv"
3200
- version = "1.2.2"
3201
  source = { registry = "https://pypi.org/simple" }
3202
- sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
3203
- wheels = [
3204
- { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
3205
  ]
3206
-
3207
- [[package]]
3208
- name = "python-multipart"
3209
- version = "0.0.31"
3210
- source = { registry = "https://pypi.org/simple" }
3211
- sdist = { url = "https://files.pythonhosted.org/packages/64/7e/9b35ad8f3d9ca680f7c87a88f19612fdd8da9796c4d3b46e560ac79dcc4a/python_multipart-0.0.31.tar.gz", hash = "sha256:fc631183bb13e56db3158a4909908dfb2e23565286744e798241e63750e5d680", size = 46689, upload-time = "2026-06-04T08:27:49.014Z" }
3212
  wheels = [
3213
- { url = "https://files.pythonhosted.org/packages/5e/1e/7f7f299527a5a8ad90acd5f2f78dfa6c8495c6301a3205106ea68a84de96/python_multipart-0.0.31-py3-none-any.whl", hash = "sha256:8408153d68a9773291fc1da39a8b85a50044bddbabd2dd72e9229776b7b15e28", size = 29996, upload-time = "2026-06-04T08:27:47.804Z" },
3214
  ]
3215
 
3216
  [[package]]
3217
- name = "pytz"
3218
- version = "2026.2"
3219
  source = { registry = "https://pypi.org/simple" }
3220
- sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" }
3221
  wheels = [
3222
- { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" },
3223
  ]
3224
 
3225
  [[package]]
@@ -3559,27 +3392,6 @@ wheels = [
3559
  { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
3560
  ]
3561
 
3562
- [[package]]
3563
- name = "safehttpx"
3564
- version = "0.1.7"
3565
- source = { registry = "https://pypi.org/simple" }
3566
- dependencies = [
3567
- { name = "httpx" },
3568
- ]
3569
- sdist = { url = "https://files.pythonhosted.org/packages/89/d1/4282284d9cf1ee873607a46442da977fc3c985059315ab23610be31d5885/safehttpx-0.1.7.tar.gz", hash = "sha256:db201c0978c41eddb8bb480f3eee59dd67304fdd91646035e9d9a720049a9d23", size = 10385, upload-time = "2025-10-24T18:30:09.783Z" }
3570
- wheels = [
3571
- { url = "https://files.pythonhosted.org/packages/2e/a3/0f0b7d78e2f1eb9e8e1afbff1d2bff8d60144aee17aca51c065b516743dd/safehttpx-0.1.7-py3-none-any.whl", hash = "sha256:c4f4a162db6993464d7ca3d7cc4af0ffc6515a606dfd220b9f82c6945d869cde", size = 8959, upload-time = "2025-10-24T18:30:08.733Z" },
3572
- ]
3573
-
3574
- [[package]]
3575
- name = "semantic-version"
3576
- version = "2.10.0"
3577
- source = { registry = "https://pypi.org/simple" }
3578
- sdist = { url = "https://files.pythonhosted.org/packages/7d/31/f2289ce78b9b473d582568c234e104d2a342fd658cc288a7553d83bb8595/semantic_version-2.10.0.tar.gz", hash = "sha256:bdabb6d336998cbb378d4b9db3a4b56a1e3235701dc05ea2690d9a997ed5041c", size = 52289, upload-time = "2022-05-26T13:35:23.454Z" }
3579
- wheels = [
3580
- { url = "https://files.pythonhosted.org/packages/6a/23/8146aad7d88f4fcb3a6218f41a60f6c2d4e3a72de72da1825dc7c8f7877c/semantic_version-2.10.0-py2.py3-none-any.whl", hash = "sha256:de78a3b8e0feda74cabc54aab2da702113e33ac9d9eb9d2389bcf1f58b7d9177", size = 15552, upload-time = "2022-05-26T13:35:21.206Z" },
3581
- ]
3582
-
3583
  [[package]]
3584
  name = "setuptools"
3585
  version = "82.0.1"
@@ -3787,15 +3599,6 @@ wheels = [
3787
  { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" },
3788
  ]
3789
 
3790
- [[package]]
3791
- name = "tomlkit"
3792
- version = "0.14.0"
3793
- source = { registry = "https://pypi.org/simple" }
3794
- sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
3795
- wheels = [
3796
- { url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
3797
- ]
3798
-
3799
  [[package]]
3800
  name = "tornado"
3801
  version = "6.5.6"
@@ -3895,15 +3698,6 @@ wheels = [
3895
  { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
3896
  ]
3897
 
3898
- [[package]]
3899
- name = "tzdata"
3900
- version = "2026.2"
3901
- source = { registry = "https://pypi.org/simple" }
3902
- sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" }
3903
- wheels = [
3904
- { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" },
3905
- ]
3906
-
3907
  [[package]]
3908
  name = "urllib3"
3909
  version = "2.7.0"
@@ -4027,6 +3821,21 @@ wheels = [
4027
  { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
4028
  ]
4029
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4030
  [[package]]
4031
  name = "watchfiles"
4032
  version = "1.2.0"
 
20
  { name = "cohere" },
21
  { name = "fastapi" },
22
  { name = "google-genai" },
 
23
  { name = "hf-xet" },
24
  { name = "huggingface-hub" },
25
  { name = "instructor" },
 
43
  [package.dev-dependencies]
44
  dev = [
45
  { name = "httpx" },
46
+ { name = "pre-commit" },
47
  { name = "pytest" },
48
  { name = "ruff" },
49
  ]
 
55
  { name = "cohere" },
56
  { name = "fastapi" },
57
  { name = "google-genai" },
 
58
  { name = "hf-xet" },
59
  { name = "huggingface-hub" },
60
  { name = "instructor" },
 
78
  [package.metadata.requires-dev]
79
  dev = [
80
  { name = "httpx" },
81
+ { name = "pre-commit" },
82
  { name = "pytest", specifier = ">=9.0.3" },
83
  { name = "ruff", specifier = ">=0.15.10" },
84
  ]
 
270
  { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
271
  ]
272
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
273
  [[package]]
274
  name = "banks"
275
  version = "2.4.2"
 
383
  { name = "tinycss2" },
384
  ]
385
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
386
  [[package]]
387
  name = "build"
388
  version = "1.5.0"
 
451
  { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
452
  ]
453
 
454
+ [[package]]
455
+ name = "cfgv"
456
+ version = "3.5.0"
457
+ source = { registry = "https://pypi.org/simple" }
458
+ sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
459
+ wheels = [
460
+ { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
461
+ ]
462
+
463
  [[package]]
464
  name = "charset-normalizer"
465
  version = "3.4.7"
 
730
  { url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197, upload-time = "2022-11-28T23:32:31.219Z" },
731
  ]
732
 
733
+ [[package]]
734
+ name = "distlib"
735
+ version = "0.4.2"
736
+ source = { registry = "https://pypi.org/simple" }
737
+ sdist = { url = "https://files.pythonhosted.org/packages/46/8d/873e9252ea2c0e0c857884e0a2899ec43ade132345df1925ef24cbe64f18/distlib-0.4.2.tar.gz", hash = "sha256:baeb401c90f27acd15c4861ae0847d1e731c27ac3dbf4210643ba61fa1e813db", size = 614914, upload-time = "2026-06-08T16:24:15.439Z" }
738
+ wheels = [
739
+ { url = "https://files.pythonhosted.org/packages/c1/60/aa891c893821d4d127292ed66c6940d1d715894bd5a0ce048056bc641773/distlib-0.4.2-py2.py3-none-any.whl", hash = "sha256:ca4cb11e5d746b5ec13c199cbf19ae27a241f89702b54e153a74332955446067", size = 470510, upload-time = "2026-06-08T16:24:13.208Z" },
740
+ ]
741
+
742
  [[package]]
743
  name = "distro"
744
  version = "1.9.0"
 
1000
  { url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
1001
  ]
1002
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1003
  [[package]]
1004
  name = "greenlet"
1005
  version = "3.5.1"
 
1092
  { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
1093
  ]
1094
 
 
 
 
 
 
 
 
 
 
1095
  [[package]]
1096
  name = "grpcio"
1097
  version = "1.81.0"
 
1132
  { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
1133
  ]
1134
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1135
  [[package]]
1136
  name = "hf-xet"
1137
  version = "1.5.0"
 
1242
  { url = "https://files.pythonhosted.org/packages/02/28/d7cef5e477b855c25d415b8f57e5bc7347c7a90cad3acf1725d0c92ca294/huggingface_hub-1.17.0-py3-none-any.whl", hash = "sha256:3b8156d23118e87f6a587648bfbc04f04a12a757ccb4ed298b35c4ae638bf24c", size = 671546, upload-time = "2026-05-28T15:12:11.441Z" },
1243
  ]
1244
 
1245
+ [[package]]
1246
+ name = "identify"
1247
+ version = "2.6.19"
1248
+ source = { registry = "https://pypi.org/simple" }
1249
+ sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
1250
+ wheels = [
1251
+ { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
1252
+ ]
1253
+
1254
  [[package]]
1255
  name = "idna"
1256
  version = "3.18"
 
2175
  { url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" },
2176
  ]
2177
 
2178
+ [[package]]
2179
+ name = "nodeenv"
2180
+ version = "1.10.0"
2181
+ source = { registry = "https://pypi.org/simple" }
2182
+ sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
2183
+ wheels = [
2184
+ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
2185
+ ]
2186
+
2187
  [[package]]
2188
  name = "numpy"
2189
  version = "2.4.6"
 
2456
  { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
2457
  ]
2458
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2459
  [[package]]
2460
  name = "pandocfilters"
2461
  version = "1.5.1"
 
2562
  { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
2563
  ]
2564
 
2565
+ [[package]]
2566
+ name = "pre-commit"
2567
+ version = "4.6.0"
2568
+ source = { registry = "https://pypi.org/simple" }
2569
+ dependencies = [
2570
+ { name = "cfgv" },
2571
+ { name = "identify" },
2572
+ { name = "nodeenv" },
2573
+ { name = "pyyaml" },
2574
+ { name = "virtualenv" },
2575
+ ]
2576
+ sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
2577
+ wheels = [
2578
+ { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
2579
+ ]
2580
+
2581
  [[package]]
2582
  name = "prompt-toolkit"
2583
  version = "3.0.52"
 
2937
  { url = "https://files.pythonhosted.org/packages/ae/8d/f1af3832f5e6eb13ba94ee809e72b8ecb5eef226d27ee0bef7d963d943c7/pydantic_settings-2.14.1-py3-none-any.whl", hash = "sha256:6e3c7edfd8277687cdc598f56e5cff0e9bfff0910a3749deaa8d4401c3a2b9de", size = 60964, upload-time = "2026-05-08T13:40:04.958Z" },
2938
  ]
2939
 
 
 
 
 
 
 
 
 
 
2940
  [[package]]
2941
  name = "pygments"
2942
  version = "2.20.0"
 
3034
  ]
3035
 
3036
  [[package]]
3037
+ name = "python-discovery"
3038
+ version = "1.4.0"
3039
  source = { registry = "https://pypi.org/simple" }
3040
+ dependencies = [
3041
+ { name = "filelock" },
3042
+ { name = "platformdirs" },
3043
  ]
3044
+ sdist = { url = "https://files.pythonhosted.org/packages/a6/12/38c1a0b1e64806780c9563e3fc9f6e472251839662587cfbe9bfaf2ae10a/python_discovery-1.4.0.tar.gz", hash = "sha256:eb8bc7daad3c226c147e45bb4e970a1feb1bf4048ee178e6db59e197b8010ce3", size = 68455, upload-time = "2026-05-28T01:15:37.639Z" }
 
 
 
 
 
3045
  wheels = [
3046
+ { url = "https://files.pythonhosted.org/packages/c8/8d/3d316429f65029532bb1e28ff77b797d86b5ac3915bb44ca4e19aa283d43/python_discovery-1.4.0-py3-none-any.whl", hash = "sha256:26ed78d703e234879a66244c7d4114563fb13ec5cd30a2d1357e5fb4850782da", size = 33217, upload-time = "2026-05-28T01:15:36.573Z" },
3047
  ]
3048
 
3049
  [[package]]
3050
+ name = "python-dotenv"
3051
+ version = "1.2.2"
3052
  source = { registry = "https://pypi.org/simple" }
3053
+ sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
3054
  wheels = [
3055
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
3056
  ]
3057
 
3058
  [[package]]
 
3392
  { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
3393
  ]
3394
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3395
  [[package]]
3396
  name = "setuptools"
3397
  version = "82.0.1"
 
3599
  { url = "https://files.pythonhosted.org/packages/cd/2b/2be299bab55fc595e3d38567edb1a87f86e594842968fa9515a07bdcf422/tokenizers-0.23.1-cp310-abi3-win_arm64.whl", hash = "sha256:a26197957d8e4425dfba746315f3c425ea00cfa8367c5fbc4ec73447893dcea9", size = 2664127, upload-time = "2026-04-27T14:43:26.949Z" },
3600
  ]
3601
 
 
 
 
 
 
 
 
 
 
3602
  [[package]]
3603
  name = "tornado"
3604
  version = "6.5.6"
 
3698
  { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
3699
  ]
3700
 
 
 
 
 
 
 
 
 
 
3701
  [[package]]
3702
  name = "urllib3"
3703
  version = "2.7.0"
 
3821
  { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
3822
  ]
3823
 
3824
+ [[package]]
3825
+ name = "virtualenv"
3826
+ version = "21.4.2"
3827
+ source = { registry = "https://pypi.org/simple" }
3828
+ dependencies = [
3829
+ { name = "distlib" },
3830
+ { name = "filelock" },
3831
+ { name = "platformdirs" },
3832
+ { name = "python-discovery" },
3833
+ ]
3834
+ sdist = { url = "https://files.pythonhosted.org/packages/e1/0d/4e93c8e6d1001a75763f87d8f5ecda8ebc7f4aa2153dddfaf4ae8892821a/virtualenv-21.4.2.tar.gz", hash = "sha256:38e6ee0a555615c0ea9da2ac7e9998fe8dc3b911dd33ad8eaad2020957653b0c", size = 7613326, upload-time = "2026-05-31T17:01:22.827Z" }
3835
+ wheels = [
3836
+ { url = "https://files.pythonhosted.org/packages/bf/c4/557dc082be035381b85fdb2b74e21d3d21b57750b74f2b47a32f3a639ff9/virtualenv-21.4.2-py3-none-any.whl", hash = "sha256:854210ca524a1a4d0d744734f4acbc721c3ffe163b85bbf5d56d14d5ae2f0fae", size = 7594079, upload-time = "2026-05-31T17:01:20.735Z" },
3837
+ ]
3838
+
3839
  [[package]]
3840
  name = "watchfiles"
3841
  version = "1.2.0"