Spaces:
Paused
feat: 2026 model refresh + robustness, CPU-efficiency & UI overhaul
Browse filesModels (verified live against the HF API):
- Fix 2 dead 404 repos that crashed on selection (LFM-2.6B-Transcript,
granite-4.0-Tiny-7B) -> repointed to live LFM2-2.6B and granite-4.0-h-tiny.
- Add verified 2026 models: Qwen3.5 0.8B/4B, Granite 4.0 H-1B/H-Tiny,
LFM2.5 1.2B, SmolLM3 3B (+ extraction/synthesis options).
- Group the picker by CPU speed tier (fast <1B / balanced 1-3B / experimental).
Robustness:
- Friendly model-load errors + global-state reset on failure.
- Advanced pipeline: clamp window budget (was going negative -> hang),
pre-split oversized lines, empty/noise-input guard, per-stage synthesis
degrade-to-bulleted-list, transient-timeout retry.
- OpenCC lazy-init (fixes custom-model zh-TW crash), max-tokens truncation
notice, copy-not-mutate synthesis config (was corrupting the global registry).
CPU efficiency (2 vCPU / 16GB):
- Remove silently-ignored v_type/k_type kwargs (KV cache was f16, not q8_0);
n_batch 2048->512; drop blocking time.sleep(0.5) on unload (+ llm.close()).
- Throttle O(n^2) per-token parse loops; cache window token counts; batch
embeddings; bound reasoning headroom + fix double-counted context buffer.
UI redesign:
- Wire up custom_css + Soft theme (the stylesheet was never applied);
remove perpetual background animation; Summary becomes the hero output with
reasoning/model-details collapsed; inference + hardware controls in accordions;
reasoning defaults OFF (opt-in); accurate per-model reasoning help;
queue() + Generate-button disable with serialized concurrency;
fix custom-GGUF being silently ignored on submit; dynamic header/instructions.
Runtime:
- Bump llama-cpp-python 0.3.22 -> 0.3.30 (official prebuilt CPU wheel) for
newer model-architecture support.
Validated: py_compile, full create_interface() build under real Gradio 5.50.0
(GET / and /config both 200), and a live HF re-verification (31/31 repos OK).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q8tnLHhcX2vbixoFHSkspt
- CLAUDE.md +154 -266
- Dockerfile +19 -4
- app.py +651 -250
- meeting_summarizer/extraction.py +53 -27
- summarize_transcript.py +2 -2
|
@@ -4,341 +4,229 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
|
| 4 |
|
| 5 |
## Project Overview
|
| 6 |
|
| 7 |
-
Tiny Scribe is a transcript summarization tool
|
| 8 |
-
|
| 9 |
-
|
| 10 |
|
| 11 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
|
| 17 |
-
|
| 18 |
-
# Basic usage (default model: Qwen3-0.6B Q4_0)
|
| 19 |
-
python summarize_transcript.py -i ./transcripts/short.txt
|
| 20 |
|
| 21 |
-
#
|
| 22 |
-
python summarize_transcript.py -m unsloth/Qwen3-1.7B-GGUF:Q2_K_L
|
| 23 |
|
| 24 |
-
|
| 25 |
-
python summarize_transcript.py -
|
|
|
|
|
|
|
|
|
|
| 26 |
```
|
| 27 |
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
```bash
|
| 31 |
-
# Local development
|
| 32 |
pip install -r requirements.txt
|
| 33 |
-
python app.py
|
| 34 |
-
# Opens at http://localhost:7860
|
| 35 |
```
|
| 36 |
|
| 37 |
-
###
|
| 38 |
|
| 39 |
-
|
|
|
|
| 40 |
|
| 41 |
```bash
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
|
| 46 |
-
#
|
| 47 |
-
pytest
|
| 48 |
```
|
| 49 |
|
| 50 |
-
|
| 51 |
|
| 52 |
```bash
|
| 53 |
-
|
| 54 |
-
docker build -t tiny-scribe .
|
| 55 |
-
|
| 56 |
-
# Run
|
| 57 |
-
docker run -p 7860:7860 tiny-scribe
|
| 58 |
```
|
| 59 |
|
| 60 |
-
##
|
| 61 |
-
|
| 62 |
-
### Two Execution Paths
|
| 63 |
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
↓
|
| 68 |
-
Stream tokens → OpenCC (s2twp) → stdout
|
| 69 |
-
↓
|
| 70 |
-
parse_thinking_blocks() → thinking.txt + summary.txt
|
| 71 |
```
|
| 72 |
|
| 73 |
-
|
| 74 |
-
```
|
| 75 |
-
User upload → Gradio File → app.py:summarize_streaming()
|
| 76 |
-
↓
|
| 77 |
-
Llama.create_chat_completion(stream=True)
|
| 78 |
-
↓
|
| 79 |
-
Token-by-token yield → OpenCC → Two textboxes:
|
| 80 |
-
↓ - Thinking (raw stream)
|
| 81 |
-
parse_thinking_blocks() - Summary (parsed output)
|
| 82 |
-
```
|
| 83 |
|
| 84 |
-
###
|
| 85 |
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
| Model loading | On-demand per run | Global singleton (cached) |
|
| 89 |
-
| Model selection | CLI argument `repo_id:quant` | Dropdown with 10 models |
|
| 90 |
-
| Thinking tags | Supports both formats | Supports both formats + streaming |
|
| 91 |
-
| Reasoning toggle | Not supported | Qwen3: /think or /no_think |
|
| 92 |
-
| Inference settings | Hardcoded per run | Model-specific, dynamic UI |
|
| 93 |
-
| Output | Print to stdout + save files | Yield tuples for dual textboxes |
|
| 94 |
-
| GPU support | Configurable via `--cpu` flag | Hardcoded `n_gpu_layers=0` |
|
| 95 |
-
| Context window | 32K tokens | Per-model (32K-262K, capped at 32K) |
|
| 96 |
|
| 97 |
-
|
|
|
|
|
|
|
|
|
|
| 98 |
|
| 99 |
-
|
|
|
|
| 100 |
|
| 101 |
-
```
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
verbose=False, # Reduce log noise
|
| 109 |
-
)
|
| 110 |
```
|
| 111 |
|
| 112 |
-
**
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
-
###
|
| 115 |
|
| 116 |
-
The
|
|
|
|
| 117 |
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
| 120 |
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
content = chunk['choices'][0]['delta'].get('content', '')
|
| 126 |
-
full_response += content
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
|
|
|
| 130 |
|
| 131 |
-
|
| 132 |
-
thinking_blocks, summary = parse_thinking_blocks(full_response)
|
| 133 |
-
current_summary = summary
|
| 134 |
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
```
|
| 138 |
|
| 139 |
-
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
|
| 142 |
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
- `<thinking>reasoning</thinking>` (Claude-style)
|
| 146 |
|
| 147 |
-
Regex pattern:
|
| 148 |
```python
|
| 149 |
-
# Matches both <think> and <thinking> tags
|
| 150 |
pattern = r'<think(?:ing)?>(.*?)</think(?:ing)?>'
|
| 151 |
-
matches = re.findall(pattern, content, re.DOTALL)
|
| 152 |
-
thinking = '\n\n'.join(match.strip() for match in matches)
|
| 153 |
-
summary = re.sub(pattern, '', content, flags=re.DOTALL).strip()
|
| 154 |
```
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
### Qwen3 Thinking Mode
|
| 159 |
|
| 160 |
-
Qwen3 models
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
-
|
| 163 |
-
- Add `/think` to system prompt or user message to enable thinking mode
|
| 164 |
-
- Add `/no_think` to disable thinking mode (faster, direct output)
|
| 165 |
-
- Most recent instruction takes precedence in multi-turn conversations
|
| 166 |
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|---------|------------------|---------------|
|
| 171 |
-
| Temperature | 0.7 | 0.6 |
|
| 172 |
-
| Top_P | 0.8 | 0.95 |
|
| 173 |
-
| Top_K | 20 | 20 |
|
| 174 |
-
| Min_P | 0.0 | 0.0 |
|
| 175 |
|
| 176 |
-
|
| 177 |
-
-
|
| 178 |
-
|
| 179 |
-
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
|
| 181 |
-
|
| 182 |
-
The Gradio app (`app.py`) implements this via:
|
| 183 |
-
- `enable_reasoning` checkbox (models with `supports_toggle: true`)
|
| 184 |
-
- Dynamic system prompt: `你是一個有助的助手,負責總結轉錄內容。{reasoning_mode}`
|
| 185 |
-
- Where `reasoning_mode = "/think"` or `/no_think"` based on toggle
|
| 186 |
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
All outputs are converted from Simplified to Traditional Chinese (Taiwan standard):
|
| 190 |
-
|
| 191 |
-
```python
|
| 192 |
-
from opencc import OpenCC
|
| 193 |
-
converter = OpenCC('s2twp') # s2twp = Simplified → Traditional (Taiwan + phrases)
|
| 194 |
-
traditional = converter.convert(simplified)
|
| 195 |
-
```
|
| 196 |
|
| 197 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 198 |
|
| 199 |
-
##
|
| 200 |
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
- **Context limits**: Per-model context windows (32K to 262K tokens), capped at 32K for CPU performance
|
| 206 |
|
| 207 |
-
|
| 208 |
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
| 210 |
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
```bash
|
| 214 |
-
./deploy.sh "Add new model: Gemma-3 270M"
|
| 215 |
-
```
|
| 216 |
-
|
| 217 |
-
The script:
|
| 218 |
-
1. Checks for uncommitted changes
|
| 219 |
-
2. Prompts for commit message if not provided
|
| 220 |
-
3. Warns about generic/short messages
|
| 221 |
-
4. Shows commits to be pushed
|
| 222 |
-
5. Confirms before pushing
|
| 223 |
-
6. Verifies commit message was preserved on remote
|
| 224 |
-
|
| 225 |
-
### Docker Optimization
|
| 226 |
-
|
| 227 |
-
The Dockerfile avoids building llama-cpp-python from source by using a prebuilt wheel:
|
| 228 |
-
|
| 229 |
-
```dockerfile
|
| 230 |
-
RUN pip install --no-cache-dir \
|
| 231 |
-
https://huggingface.co/Luigi/llama-cpp-python-wheels-hf-spaces-free-cpu/resolve/main/llama_cpp_python-0.3.22-cp310-cp310-linux_x86_64.whl
|
| 232 |
-
```
|
| 233 |
|
| 234 |
-
|
|
|
|
|
|
|
|
|
|
| 235 |
|
| 236 |
-
## Git
|
| 237 |
|
| 238 |
-
|
| 239 |
|
| 240 |
```bash
|
| 241 |
-
# Initialize after clone
|
| 242 |
git submodule update --init --recursive
|
| 243 |
-
|
| 244 |
-
# Update to latest
|
| 245 |
-
cd llama-cpp-python
|
| 246 |
-
git pull origin main
|
| 247 |
-
cd ..
|
| 248 |
-
git add llama-cpp-python
|
| 249 |
-
git commit -m "Update llama-cpp-python submodule"
|
| 250 |
```
|
| 251 |
|
| 252 |
-
##
|
| 253 |
-
|
| 254 |
-
CLI model argument format: `repo_id:quantization`
|
| 255 |
-
|
| 256 |
-
Examples:
|
| 257 |
-
- `unsloth/Qwen3-0.6B-GGUF:Q4_0` → Searches for `*Q4_0.gguf`
|
| 258 |
-
- `unsloth/Qwen3-1.7B-GGUF:Q2_K_L` → Searches for `*Q2_K_L.gguf`
|
| 259 |
-
|
| 260 |
-
The `:` separator is parsed in `summarize_transcript.py:128-130`.
|
| 261 |
-
|
| 262 |
-
## Error Handling Notes
|
| 263 |
-
|
| 264 |
-
When modifying streaming logic:
|
| 265 |
-
- **Always** handle `'choices'` key presence in chunks
|
| 266 |
-
- **Always** check for `'delta'` in choice before accessing `'content'`
|
| 267 |
-
- Gradio error handling: Yield error messages in the summary field, keep thinking field intact
|
| 268 |
-
- File upload: Validate file existence and encoding before reading
|
| 269 |
-
|
| 270 |
-
## Model Registry
|
| 271 |
-
|
| 272 |
-
The Gradio app (`app.py:32-155`) includes a model registry (`AVAILABLE_MODELS`) with:
|
| 273 |
-
|
| 274 |
-
1. **Model metadata** (repo_id, filename, max context)
|
| 275 |
-
2. **Model-specific inference settings** (temperature, top_p, top_k, repeat_penalty)
|
| 276 |
-
3. **Feature flags** (e.g., `supports_toggle` for Qwen3 reasoning mode)
|
| 277 |
-
|
| 278 |
-
Each model has optimized defaults. The UI updates inference controls when model selection changes.
|
| 279 |
-
|
| 280 |
-
### Available Models
|
| 281 |
-
|
| 282 |
-
| Key | Model | Params | Max Context | Quant |
|
| 283 |
-
|-----|-------|--------|-------------|-------|
|
| 284 |
-
| `falcon_h1_100m` | Falcon-H1 100M | 100M | 32K | Q8_0 |
|
| 285 |
-
| `gemma3_270m` | Gemma-3 270M | 270M | 32K | Q8_0 |
|
| 286 |
-
| `ernie_300m` | ERNIE-4.5 0.3B | 300M | 131K | Q8_0 |
|
| 287 |
-
| `granite_350m` | Granite-4.0 350M | 350M | 32K | Q8_0 |
|
| 288 |
-
| `lfm2_350m` | LFM2 350M | 350M | 32K | Q8_0 |
|
| 289 |
-
| `bitcpm4_500m` | BitCPM4 0.5B | 500M | 128K | q4_0 |
|
| 290 |
-
| `hunyuan_500m` | Hunyuan 0.5B | 500M | 256K | Q8_0 |
|
| 291 |
-
| `qwen3_600m_q4` | Qwen3 0.6B | 600M | 32K | Q4_K_M |
|
| 292 |
-
| `falcon_h1_1.5b_q4` | Falcon-H1 1.5B | 1.5B | 32K | Q4_K_M |
|
| 293 |
-
| `qwen3_1.7b_q4` | Qwen3 1.7B | 1.7B | 32K | Q4_K_M |
|
| 294 |
-
|
| 295 |
-
### Adding a New Model
|
| 296 |
-
|
| 297 |
-
1. Add entry to `AVAILABLE_MODELS` in `app.py`:
|
| 298 |
-
```python
|
| 299 |
-
"model_key": {
|
| 300 |
-
"name": "Human-Readable Name",
|
| 301 |
-
"repo_id": "org/model-name-GGUF",
|
| 302 |
-
"filename": "*Quantization.gguf",
|
| 303 |
-
"max_context": 32768,
|
| 304 |
-
"supports_toggle": False, # For Qwen3 /think mode
|
| 305 |
-
"inference_settings": {
|
| 306 |
-
"temperature": 0.6,
|
| 307 |
-
"top_p": 0.95,
|
| 308 |
-
"top_k": 20,
|
| 309 |
-
"repeat_penalty": 1.05,
|
| 310 |
-
},
|
| 311 |
-
},
|
| 312 |
-
```
|
| 313 |
-
|
| 314 |
-
2. Set `DEFAULT_MODEL_KEY` to the new key if it should be default
|
| 315 |
-
|
| 316 |
-
## Common Modifications
|
| 317 |
-
|
| 318 |
-
### Changing the Default Model
|
| 319 |
-
|
| 320 |
-
**CLI:** Use `-m` argument at runtime
|
| 321 |
-
|
| 322 |
-
**Gradio app:** Change `DEFAULT_MODEL_KEY` in `app.py:157`
|
| 323 |
-
|
| 324 |
-
### Adjusting Context Window
|
| 325 |
-
|
| 326 |
-
**CLI:** Change `n_ctx` in `summarize_transcript.py:23`
|
| 327 |
-
|
| 328 |
-
**Gradio app:** The app dynamically calculates `n_ctx` based on input size and model limits. To change the global cap, modify `MAX_USABLE_CTX` in `app.py:29`.
|
| 329 |
-
|
| 330 |
-
Values:
|
| 331 |
-
- 32768 (current) = handles ~24KB text input
|
| 332 |
-
- 8192 = faster, lower memory, ~6KB text
|
| 333 |
-
- 131072 = very slow on CPU, ~100KB text
|
| 334 |
-
|
| 335 |
-
### GPU Acceleration
|
| 336 |
-
|
| 337 |
-
**CLI:** Remove `-c` flag (defaults to SYCL/CUDA if available)
|
| 338 |
-
|
| 339 |
-
**Gradio app:** Change `app.py:206`:
|
| 340 |
-
```python
|
| 341 |
-
n_gpu_layers=-1, # Use all GPU layers
|
| 342 |
-
```
|
| 343 |
|
| 344 |
-
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
## Project Overview
|
| 6 |
|
| 7 |
+
Tiny Scribe is a transcript/meeting summarization tool built on llama-cpp-python and GGUF
|
| 8 |
+
quantized models, optimized for the HuggingFace Spaces Free CPU tier (2 vCPUs). It has two
|
| 9 |
+
entry points:
|
| 10 |
|
| 11 |
+
1. **CLI tool** (`summarize_transcript.py`) — standalone single-shot summarizer with
|
| 12 |
+
optional SYCL/CUDA GPU acceleration.
|
| 13 |
+
2. **Gradio web app** (`app.py`) — the primary, much larger interface (~3400 lines). Offers
|
| 14 |
+
two summarization modes (Standard and Advanced), ~25 selectable models, custom HF GGUF
|
| 15 |
+
loading, live token streaming, and a debug trace download.
|
| 16 |
|
| 17 |
+
Output is **English by default**; Traditional Chinese (zh-TW) is opt-in and applied via
|
| 18 |
+
OpenCC (`s2twp`) only when `output_language == "zh-TW"`. (Note: an older version always
|
| 19 |
+
converted to zh-TW — that is no longer true.)
|
| 20 |
|
| 21 |
+
The bulk of the real logic lives in `app.py` and the `meeting_summarizer/` package. The
|
| 22 |
+
many `summary_*.json`, `*.txt`, `*.md`, and benchmark files in the repo root are generated
|
| 23 |
+
artifacts/reports, not source.
|
| 24 |
|
| 25 |
+
## Development Commands
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
### CLI
|
|
|
|
| 28 |
|
| 29 |
+
```bash
|
| 30 |
+
python summarize_transcript.py -i ./transcripts/short.txt # default English
|
| 31 |
+
python summarize_transcript.py -i ./transcripts/short.txt -l zh-TW # Traditional Chinese
|
| 32 |
+
python summarize_transcript.py -m unsloth/Qwen3-1.7B-GGUF:Q2_K_L # model = repo_id:quant
|
| 33 |
+
python summarize_transcript.py -c # force CPU (default uses GPU)
|
| 34 |
```
|
| 35 |
|
| 36 |
+
Note: CLI GPU default is `n_gpu_layers=-1` (GPU if available); `-c`/`--cpu` forces CPU.
|
| 37 |
+
This is the opposite default from the Gradio app, which is CPU-first.
|
| 38 |
+
|
| 39 |
+
### Gradio app
|
| 40 |
|
| 41 |
```bash
|
|
|
|
| 42 |
pip install -r requirements.txt
|
| 43 |
+
python app.py # serves on 0.0.0.0:7860, share=False
|
|
|
|
| 44 |
```
|
| 45 |
|
| 46 |
+
### Tests
|
| 47 |
|
| 48 |
+
Root-level tests are plain scripts (no pytest config / fixtures); they download models and
|
| 49 |
+
hit the real pipeline, so they are slow and network-dependent:
|
| 50 |
|
| 51 |
```bash
|
| 52 |
+
python test_e2e.py # end-to-end Standard-mode summarization
|
| 53 |
+
python test_advanced_mode.py # 3-stage Advanced pipeline
|
| 54 |
+
python test_lfm2_extract.py # LFM2 extraction sanity check
|
| 55 |
|
| 56 |
+
# pytest also works if installed:
|
| 57 |
+
pytest test_e2e.py -v
|
| 58 |
```
|
| 59 |
|
| 60 |
+
llama-cpp-python submodule tests:
|
| 61 |
|
| 62 |
```bash
|
| 63 |
+
cd llama-cpp-python && pip install ".[test]" && pytest tests/test_llama.py -v
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
```
|
| 65 |
|
| 66 |
+
### Docker / Deploy
|
|
|
|
|
|
|
| 67 |
|
| 68 |
+
```bash
|
| 69 |
+
docker build -t tiny-scribe . && docker run -p 7860:7860 tiny-scribe
|
| 70 |
+
./deploy.sh "Meaningful commit message" # commits + pushes to HF Spaces with message checks
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
```
|
| 72 |
|
| 73 |
+
## Architecture
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
| 75 |
+
### Two summarization modes (Gradio app)
|
| 76 |
|
| 77 |
+
Selected by the `mode_radio` control ("Standard Mode" vs "Advanced Mode (3-Model Pipeline)").
|
| 78 |
+
Both stream to the same dual output (Thinking textbox + Summary markdown).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
**Standard Mode** — `summarize_streaming()` (`app.py`):
|
| 81 |
+
single model, one `create_chat_completion(stream=True)` call. Tokens are streamed to the
|
| 82 |
+
Thinking field; `parse_thinking_blocks()` separates `<think>`/`<thinking>` content from the
|
| 83 |
+
final summary. OpenCC conversion happens token-by-token when language is zh-TW.
|
| 84 |
|
| 85 |
+
**Advanced Mode** — `summarize_advanced()` (`app.py`), a generator driving a 3-stage pipeline
|
| 86 |
+
implemented in `meeting_summarizer/extraction.py`:
|
| 87 |
|
| 88 |
+
```
|
| 89 |
+
preprocess_transcript() # strip CSV, collapse repeats, drop ASR-hallucination noise
|
| 90 |
+
→ windowing (token-budgeted, with overlap_turns)
|
| 91 |
+
→ Stage 1 Extraction: stream_extract_from_window() per window → structured JSON
|
| 92 |
+
{action_items, decisions, key_points, open_questions}
|
| 93 |
+
→ Stage 2 Deduplication: deduplicate_items() — embeds items, drops cosine-similar dupes
|
| 94 |
+
→ Stage 3 Synthesis: stream_synthesize_executive_summary() → executive summary
|
|
|
|
|
|
|
| 95 |
```
|
| 96 |
|
| 97 |
+
Each stage loads its own model, then **unloads it** (`unload_model()` / `gc.collect()`)
|
| 98 |
+
before the next stage to stay within 2-vCPU / memory limits. A `Tracer`
|
| 99 |
+
(`meeting_summarizer/trace.py`) records every stage; when logging is enabled the app embeds
|
| 100 |
+
this trace into the downloadable JSON.
|
| 101 |
|
| 102 |
+
### Three independent model registries (in `app.py`)
|
| 103 |
|
| 104 |
+
The same model key (e.g. `qwen3_1.7b_q4`) appears in multiple registries with **role-specific
|
| 105 |
+
inference settings** — do not assume one shared config.
|
| 106 |
|
| 107 |
+
| Registry | Used by | Tuning |
|
| 108 |
+
|----------|---------|--------|
|
| 109 |
+
| `AVAILABLE_MODELS` | Standard Mode dropdown (+ `custom_hf` entry) | general |
|
| 110 |
+
| `EXTRACTION_MODELS` | Advanced Stage 1 | low temp (0.1–0.3), deterministic JSON |
|
| 111 |
+
| `SYNTHESIS_MODELS` | Advanced Stage 3 | higher temp (0.7–0.9), creative |
|
| 112 |
|
| 113 |
+
Embedding models for Stage 2 live in `EMBEDDING_MODELS` (`meeting_summarizer/extraction.py`).
|
| 114 |
+
|
| 115 |
+
Defaults: `DEFAULT_MODEL_KEY = "qwen3_600m_q4"`, `DEFAULT_EXTRACTION_MODEL = "qwen2.5_1.5b"`,
|
| 116 |
+
`DEFAULT_SYNTHESIS_MODEL = "qwen3_1.7b_q4"`.
|
|
|
|
|
|
|
| 117 |
|
| 118 |
+
`get_model_config(model_key, role)` and `load_model_for_role(...)` resolve a key against the
|
| 119 |
+
extraction/synthesis registry; Standard mode uses `load_model()` (a cached global singleton
|
| 120 |
+
that reloads only when the key changes).
|
| 121 |
|
| 122 |
+
### Model entry shape
|
|
|
|
|
|
|
| 123 |
|
| 124 |
+
```python
|
| 125 |
+
"model_key": {
|
| 126 |
+
"name": "Human-Readable Name",
|
| 127 |
+
"repo_id": "org/model-name-GGUF", # None for the custom_hf placeholder
|
| 128 |
+
"filename": "*Q4_K_M.gguf", # wildcard matched by Llama.from_pretrained
|
| 129 |
+
"max_context": 32768,
|
| 130 |
+
"supports_reasoning": True/False, # has a thinking mode at all
|
| 131 |
+
"supports_toggle": True/False, # hybrid (/think /no_think) vs thinking-only
|
| 132 |
+
"inference_settings": {"temperature": ..., "top_p": ..., "top_k": ..., "repeat_penalty": ...},
|
| 133 |
+
}
|
| 134 |
```
|
| 135 |
|
| 136 |
+
Reasoning UI is driven by `update_reasoning_visibility()`: three model types —
|
| 137 |
+
**non-reasoning** (checkbox hidden), **thinking-only** (`supports_reasoning` + not
|
| 138 |
+
`supports_toggle`: checkbox shown, checked, locked), **hybrid** (both true: toggleable).
|
| 139 |
|
| 140 |
+
### Thinking-block parsing
|
| 141 |
|
| 142 |
+
Both interfaces strip reasoning with the same regex, handling both tag styles and streaming
|
| 143 |
+
(unclosed) tags:
|
|
|
|
| 144 |
|
|
|
|
| 145 |
```python
|
|
|
|
| 146 |
pattern = r'<think(?:ing)?>(.*?)</think(?:ing)?>'
|
|
|
|
|
|
|
|
|
|
| 147 |
```
|
| 148 |
|
| 149 |
+
### Qwen3 thinking mode
|
|
|
|
|
|
|
| 150 |
|
| 151 |
+
Hybrid Qwen3 models switch via `/think` or `/no_think` appended to the system prompt
|
| 152 |
+
(`build_system_prompt()`). Use the Unsloth-recommended sampling — thinking: temp 0.6 / top_p
|
| 153 |
+
0.95 / top_k 20; non-thinking: temp 0.7 / top_p 0.8 / top_k 20. **Never greedy-decode in
|
| 154 |
+
thinking mode** (causes repetition loops). Exception: extraction forces temp 0.0 for Qwen3
|
| 155 |
+
to avoid empty-JSON output on large windows (see `stream_extract_from_window`).
|
| 156 |
|
| 157 |
+
### Custom HF GGUF loading
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
+
The `custom_hf` entry (`🔧 Custom HF GGUF...`) lets users load any repo. Supporting code:
|
| 160 |
+
`list_repo_gguf_files()`, `parse_quantization()`, `format_file_choice()`, and
|
| 161 |
+
`load_custom_model_from_hf()` (conservative defaults: n_ctx 8192, CPU-only).
|
| 162 |
|
| 163 |
+
### Model loading & resources
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
+
- Standard/CLI: `Llama.from_pretrained(repo_id, filename, n_ctx, n_gpu_layers, seed=1337, ...)`.
|
| 166 |
+
- `n_ctx = min(model["max_context"], MAX_USABLE_CTX)` where `MAX_USABLE_CTX = 32768` caps
|
| 167 |
+
memory on CPU even for 128K–256K models. `n_batch = min(512, n_ctx)` (small on 2 cores).
|
| 168 |
+
- **KV cache is f16** (the default). Earlier code passed `v_type=2/k_type=2` to quantize it,
|
| 169 |
+
but those are not real `Llama` kwargs (the correct names are `type_k`/`type_v`) so they were
|
| 170 |
+
silently dropped — they've been removed. Quantizing the V cache needs flash-attention, which
|
| 171 |
+
is unverified on the OpenBLAS CPU wheel, so don't enable it blind.
|
| 172 |
+
- After a Standard completion, call `llm.reset()` to clear KV cache. In Advanced mode, models
|
| 173 |
+
are fully unloaded between stages via `unload_model()` (which calls `llm.close()` + `gc`).
|
| 174 |
+
- **Model registry is tiered** by CPU speed via `MODEL_TIERS` / `build_preset_choices()`:
|
| 175 |
+
⚡ fast (<1B) · ✅ balanced (1–3B) · 🐢 experimental (>3B / giant MoE at TQ1_0, slow on CPU).
|
| 176 |
+
Every `repo_id`+`filename` glob is verified live against the HF API (June 2026); the two
|
| 177 |
+
former dead 404 repos are repointed to live ones (`LiquidAI/LFM2-2.6B-GGUF`,
|
| 178 |
+
`unsloth/granite-4.0-h-tiny-GGUF`). 2026 additions: Qwen3.5, Granite 4.0 (h-1b/h-tiny),
|
| 179 |
+
LFM2.5, SmolLM3. Loading the newest archs needs llama-cpp-python ≥ ~0.3.30 (Dockerfile bumped).
|
| 180 |
+
- **Reasoning is opt-in** (the Standard checkbox defaults OFF) because thinking mode multiplies
|
| 181 |
+
CPU generation time. `update_reasoning_visibility()` hides it for non-reasoning models,
|
| 182 |
+
locks it on for thinking-only models, and leaves it toggleable (default off) for hybrids.
|
| 183 |
+
Note: Qwen3.5 toggles thinking via `chat_template_kwargs(enable_thinking=…)`, NOT `/think`,
|
| 184 |
+
so it's registered as plain instruct; SmolLM3 *does* use `/think`/`/no_think`.
|
| 185 |
|
| 186 |
+
## Configuration (environment variables)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 187 |
|
| 188 |
+
The Gradio app is CPU-first but GPU-aware:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 189 |
|
| 190 |
+
| Var | Effect |
|
| 191 |
+
|-----|--------|
|
| 192 |
+
| `N_GPU_LAYERS` | 0 = CPU (default), -1 = all layers on GPU. App probes `llama_supports_gpu_offload()` and falls back to CPU if unavailable. |
|
| 193 |
+
| `DEFAULT_N_THREADS` | Default CPU thread count (1–32). Otherwise thread presets: "free"=2, "upgrade"=8, "custom"=N. |
|
| 194 |
+
| `HF_HUB_DOWNLOAD_TIMEOUT` | Set to 300s in `app.py` for slow connections. |
|
| 195 |
|
| 196 |
+
## Streaming contract
|
| 197 |
|
| 198 |
+
Standard-mode generator yields 5-tuples
|
| 199 |
+
`(thinking_text, summary_text, info_text, metrics_dict, system_prompt)`.
|
| 200 |
+
Advanced-mode generator yields dicts keyed by
|
| 201 |
+
`stage | ticker | thinking | summary | error | trace_stats`.
|
| 202 |
|
| 203 |
+
When touching streaming code: always guard `'choices'` presence and `delta.get('content', '')`;
|
| 204 |
+
surface errors in the summary field while leaving the thinking field intact.
|
|
|
|
| 205 |
|
| 206 |
+
## Adding a model
|
| 207 |
|
| 208 |
+
1. Add an entry to the relevant registry (`AVAILABLE_MODELS` for Standard mode;
|
| 209 |
+
`EXTRACTION_MODELS` / `SYNTHESIS_MODELS` for Advanced stages) using the shape above.
|
| 210 |
+
2. To change the Standard default, set `DEFAULT_MODEL_KEY`.
|
| 211 |
+
3. Keep files under ~4GB and prefer ≤32K usable context for free-tier performance.
|
| 212 |
|
| 213 |
+
## Docker note
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
|
| 215 |
+
The Dockerfile installs a prebuilt CPU wheel
|
| 216 |
+
(`Luigi/llama-cpp-python-wheels-hf-spaces-free-cpu`) instead of compiling from source,
|
| 217 |
+
cutting build time from ~10 min to ~2 min. It copies only `app.py` and `meeting_summarizer/`
|
| 218 |
+
— if you add a new runtime module, add it to the Dockerfile too (this has bitten deploys before).
|
| 219 |
|
| 220 |
+
## Git submodule
|
| 221 |
|
| 222 |
+
`llama-cpp-python/` is a submodule tracking upstream:
|
| 223 |
|
| 224 |
```bash
|
|
|
|
| 225 |
git submodule update --init --recursive
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 226 |
```
|
| 227 |
|
| 228 |
+
## Related docs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
|
| 230 |
+
`AGENTS.md` (code-style conventions), `README.md`, `DEPLOY.md` (HF Spaces deployment),
|
| 231 |
+
`UI_UX_IMPLEMENTATION_PLAN.md` and `docs/` (design notes). `GEMINI.md` mirrors `AGENTS.md`
|
| 232 |
+
for another assistant.
|
|
@@ -9,9 +9,24 @@ RUN apt-get update && apt-get install -y \
|
|
| 9 |
libgomp1 \
|
| 10 |
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
-
# Install llama-cpp-python from prebuilt wheel (FAST - no
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
# Copy and install other requirements
|
| 17 |
COPY requirements.txt .
|
|
@@ -25,6 +40,6 @@ COPY meeting_summarizer/ meeting_summarizer/
|
|
| 25 |
# RUN python -c "from huggingface_hub import hf_hub_download; hf_hub_download(repo_id='unsloth/Qwen3-0.6B-GGUF', filename='Qwen3-0.6B-Q4_K_M.gguf', local_dir='./models')"
|
| 26 |
|
| 27 |
EXPOSE 7860
|
| 28 |
-
# Cache bust: 2026-
|
| 29 |
|
| 30 |
CMD ["python", "app.py"]
|
|
|
|
| 9 |
libgomp1 \
|
| 10 |
&& rm -rf /var/lib/apt/lists/*
|
| 11 |
|
| 12 |
+
# Install llama-cpp-python from a prebuilt wheel (FAST - no source build).
|
| 13 |
+
#
|
| 14 |
+
# Bumped 0.3.22 -> 0.3.30 to pick up newer llama.cpp model-architecture support
|
| 15 |
+
# (the 2026 model families added to app.py — Qwen3.5, Granite 4.0 hybrid-Mamba,
|
| 16 |
+
# SmolLM3, LFM2.5 — need a recent llama.cpp to load).
|
| 17 |
+
#
|
| 18 |
+
# This uses the OFFICIAL prebuilt CPU wheel index (manylinux2014 x86_64, no
|
| 19 |
+
# compile). Trade-off vs the previous custom Luigi/...-free-cpu wheel: that one
|
| 20 |
+
# was built with OpenBLAS tuned for the free tier, whereas the official CPU wheel
|
| 21 |
+
# is a generic build — prompt-processing throughput may differ. For best perf,
|
| 22 |
+
# rebuild a 0.3.30 OpenBLAS wheel into that repo and pin it here instead.
|
| 23 |
+
#
|
| 24 |
+
# NOTE: this must be verified with a Space rebuild — confirm the new models load.
|
| 25 |
+
RUN pip install --no-cache-dir "llama-cpp-python==0.3.30" \
|
| 26 |
+
--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu
|
| 27 |
+
# Fallback (previous pinned wheel, supports fewer 2026 architectures):
|
| 28 |
+
# RUN pip install --no-cache-dir \
|
| 29 |
+
# https://huggingface.co/Luigi/llama-cpp-python-wheels-hf-spaces-free-cpu/resolve/main/llama_cpp_python-0.3.22-cp310-cp310-linux_x86_64.whl
|
| 30 |
|
| 31 |
# Copy and install other requirements
|
| 32 |
COPY requirements.txt .
|
|
|
|
| 40 |
# RUN python -c "from huggingface_hub import hf_hub_download; hf_hub_download(repo_id='unsloth/Qwen3-0.6B-GGUF', filename='Qwen3-0.6B-Q4_K_M.gguf', local_dir='./models')"
|
| 41 |
|
| 42 |
EXPOSE 7860
|
| 43 |
+
# Cache bust: 2026-06-19-v2 (llama-cpp-python 0.3.30 + 2026 model registry)
|
| 44 |
|
| 45 |
CMD ["python", "app.py"]
|
|
@@ -484,18 +484,21 @@ AVAILABLE_MODELS = {
|
|
| 484 |
},
|
| 485 |
},
|
| 486 |
"lfm2_2_6b_transcript": {
|
| 487 |
-
|
| 488 |
-
|
|
|
|
|
|
|
| 489 |
"filename": "*Q4_0.gguf",
|
| 490 |
"max_context": 32768,
|
| 491 |
-
"default_temperature": 0.
|
| 492 |
"supports_reasoning": False,
|
| 493 |
"supports_toggle": False,
|
| 494 |
"inference_settings": {
|
| 495 |
-
"temperature": 0.
|
| 496 |
-
"top_p":
|
| 497 |
-
"top_k":
|
| 498 |
-
"
|
|
|
|
| 499 |
},
|
| 500 |
},
|
| 501 |
"breeze_3b_q4": {
|
|
@@ -544,9 +547,12 @@ AVAILABLE_MODELS = {
|
|
| 544 |
},
|
| 545 |
},
|
| 546 |
"granite4_tiny_q3": {
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
|
|
|
|
|
|
|
|
|
| 550 |
"max_context": 131072,
|
| 551 |
"default_temperature": 0.7,
|
| 552 |
"supports_reasoning": False,
|
|
@@ -648,6 +654,87 @@ AVAILABLE_MODELS = {
|
|
| 648 |
"repeat_penalty": 1.0,
|
| 649 |
},
|
| 650 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 651 |
"custom_hf": {
|
| 652 |
"name": "🔧 Custom HF GGUF...",
|
| 653 |
"repo_id": None,
|
|
@@ -667,6 +754,62 @@ AVAILABLE_MODELS = {
|
|
| 667 |
|
| 668 |
DEFAULT_MODEL_KEY = "qwen3_600m_q4"
|
| 669 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 670 |
|
| 671 |
# ===== ADVANCED MODE: EXTRACTION MODELS REGISTRY (13 models, ≤1.7B) =====
|
| 672 |
# Used exclusively for Stage 1: Extraction (transcript windows → structured JSON)
|
|
@@ -689,6 +832,39 @@ EXTRACTION_MODELS = {
|
|
| 689 |
"repeat_penalty": 1.0,
|
| 690 |
},
|
| 691 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 692 |
}
|
| 693 |
|
| 694 |
DEFAULT_EXTRACTION_MODEL = "qwen2.5_1.5b"
|
|
@@ -771,8 +947,9 @@ SYNTHESIS_MODELS = {
|
|
| 771 |
},
|
| 772 |
},
|
| 773 |
"lfm2_2_6b_transcript": {
|
| 774 |
-
|
| 775 |
-
"
|
|
|
|
| 776 |
"filename": "*Q4_0.gguf",
|
| 777 |
"max_context": 32768,
|
| 778 |
"supports_reasoning": False,
|
|
@@ -827,9 +1004,10 @@ SYNTHESIS_MODELS = {
|
|
| 827 |
},
|
| 828 |
},
|
| 829 |
"granite4_tiny_q3": {
|
| 830 |
-
|
| 831 |
-
"
|
| 832 |
-
"
|
|
|
|
| 833 |
"max_context": 131072,
|
| 834 |
"supports_reasoning": False,
|
| 835 |
"supports_toggle": False,
|
|
@@ -924,6 +1102,49 @@ SYNTHESIS_MODELS = {
|
|
| 924 |
"repeat_penalty": 1.0,
|
| 925 |
},
|
| 926 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 927 |
}
|
| 928 |
|
| 929 |
DEFAULT_SYNTHESIS_MODEL = "qwen3_1.7b_q4"
|
|
@@ -989,24 +1210,45 @@ def load_model(model_key: str = None, n_threads: int = 2) -> Tuple[Llama, str]:
|
|
| 989 |
repo_id=model["repo_id"],
|
| 990 |
filename=model["filename"],
|
| 991 |
n_ctx=n_ctx,
|
| 992 |
-
|
|
|
|
|
|
|
| 993 |
n_threads=n_threads, # Configurable thread count
|
| 994 |
n_threads_batch=n_threads, # Parallel batch processing
|
| 995 |
n_gpu_layers=n_gpu_layers, # 0=CPU only, -1=all GPU layers (if available)
|
| 996 |
verbose=False,
|
| 997 |
seed=1337,
|
| 998 |
-
v_type=2
|
| 999 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1000 |
)
|
| 1001 |
-
|
| 1002 |
current_model_key = model_key
|
| 1003 |
info_msg = f"Loaded: {model['name']} ({n_ctx:,} context)"
|
| 1004 |
logger.info(info_msg)
|
| 1005 |
return llm, info_msg
|
| 1006 |
-
|
| 1007 |
except Exception as e:
|
| 1008 |
-
|
| 1009 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1010 |
|
| 1011 |
|
| 1012 |
def update_reasoning_visibility(model_key):
|
|
@@ -1026,13 +1268,20 @@ def update_reasoning_visibility(model_key):
|
|
| 1026 |
|
| 1027 |
if not supports_reasoning:
|
| 1028 |
# Non-reasoning model: hide checkbox
|
| 1029 |
-
return gr.update(visible=False, value=False, interactive=False,
|
|
|
|
|
|
|
| 1030 |
elif supports_reasoning and not supports_toggle:
|
| 1031 |
-
# Thinking-only model: show, check, lock
|
| 1032 |
-
return gr.update(visible=True, value=True, interactive=False,
|
|
|
|
|
|
|
| 1033 |
else:
|
| 1034 |
-
# Hybrid model: show, toggleable
|
| 1035 |
-
|
|
|
|
|
|
|
|
|
|
| 1036 |
|
| 1037 |
|
| 1038 |
# ===== ADVANCED MODE: HELPER FUNCTIONS =====
|
|
@@ -1125,35 +1374,47 @@ def load_model_for_role(
|
|
| 1125 |
logger.warning(f"Could not detect GPU: {e}. Using CPU.")
|
| 1126 |
n_gpu_layers = 0
|
| 1127 |
|
| 1128 |
-
# Load model
|
|
|
|
|
|
|
|
|
|
| 1129 |
logger.info(f"Loading {config['name']} for {model_role} role (n_ctx={n_ctx:,})")
|
| 1130 |
-
|
| 1131 |
-
|
| 1132 |
-
|
| 1133 |
-
|
| 1134 |
-
|
| 1135 |
-
|
| 1136 |
-
|
| 1137 |
-
|
| 1138 |
-
|
| 1139 |
-
|
| 1140 |
-
|
| 1141 |
-
|
| 1142 |
-
|
| 1143 |
-
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
|
| 1147 |
-
|
| 1148 |
-
|
| 1149 |
-
|
| 1150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1151 |
except Exception as e:
|
| 1152 |
-
# Graceful failure - let user select different model
|
| 1153 |
-
|
| 1154 |
-
|
| 1155 |
-
f"Please select a different model and try again."
|
| 1156 |
-
)
|
| 1157 |
logger.error(error_msg, exc_info=True)
|
| 1158 |
raise Exception(error_msg)
|
| 1159 |
|
|
@@ -1162,9 +1423,16 @@ def unload_model(llm: Optional[Llama], model_name: str = "model") -> None:
|
|
| 1162 |
"""Explicitly unload model and trigger garbage collection."""
|
| 1163 |
if llm:
|
| 1164 |
logger.info(f"Unloading {model_name}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1165 |
del llm
|
| 1166 |
gc.collect()
|
| 1167 |
-
time.sleep(0.5) # Allow OS to reclaim memory
|
| 1168 |
|
| 1169 |
|
| 1170 |
def get_extraction_model_info(model_key: str) -> str:
|
|
@@ -1316,19 +1584,51 @@ def summarize_advanced(
|
|
| 1316 |
|
| 1317 |
# Create windows from preprocessed transcript
|
| 1318 |
lines = [l.strip() for l in transcript.split('\n') if l.strip()]
|
| 1319 |
-
|
| 1320 |
-
# Reserve tokens for system prompt (~
|
| 1321 |
-
|
| 1322 |
-
|
| 1323 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1324 |
windows = []
|
| 1325 |
current_window = []
|
|
|
|
| 1326 |
current_tokens = 0
|
| 1327 |
window_id = 1
|
| 1328 |
-
|
| 1329 |
-
for line_num, line in enumerate(
|
| 1330 |
-
line_tokens = count_tokens(line)
|
| 1331 |
-
|
| 1332 |
if current_tokens + line_tokens > max_window_tokens and current_window:
|
| 1333 |
# Create window
|
| 1334 |
window_content = '\n'.join(current_window)
|
|
@@ -1339,7 +1639,6 @@ def summarize_advanced(
|
|
| 1339 |
end_turn=line_num - 1,
|
| 1340 |
token_count=current_tokens
|
| 1341 |
))
|
| 1342 |
-
# Log window to tracer for debugging
|
| 1343 |
tracer.log_window(
|
| 1344 |
window_id=window_id,
|
| 1345 |
content=window_content,
|
|
@@ -1348,35 +1647,51 @@ def summarize_advanced(
|
|
| 1348 |
end_turn=line_num - 1
|
| 1349 |
)
|
| 1350 |
window_id += 1
|
| 1351 |
-
|
| 1352 |
-
# Start new window with overlap
|
| 1353 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1354 |
current_window = overlap_lines + [line]
|
| 1355 |
-
|
|
|
|
| 1356 |
else:
|
| 1357 |
current_window.append(line)
|
|
|
|
| 1358 |
current_tokens += line_tokens
|
| 1359 |
-
|
| 1360 |
# Add final window
|
| 1361 |
if current_window:
|
| 1362 |
window_content = '\n'.join(current_window)
|
| 1363 |
windows.append(Window(
|
| 1364 |
id=window_id,
|
| 1365 |
content=window_content,
|
| 1366 |
-
start_turn=
|
| 1367 |
-
end_turn=
|
| 1368 |
token_count=current_tokens
|
| 1369 |
))
|
| 1370 |
-
# Log window to tracer for debugging
|
| 1371 |
tracer.log_window(
|
| 1372 |
window_id=window_id,
|
| 1373 |
content=window_content,
|
| 1374 |
token_count=current_tokens,
|
| 1375 |
-
start_turn=
|
| 1376 |
-
end_turn=
|
| 1377 |
)
|
| 1378 |
-
|
| 1379 |
total_windows = len(windows)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1380 |
yield {"stage": "extraction", "ticker": f"Created {total_windows} windows", "thinking": "", "summary": ""}
|
| 1381 |
|
| 1382 |
# Extract from each window
|
|
@@ -1453,45 +1768,74 @@ def summarize_advanced(
|
|
| 1453 |
}
|
| 1454 |
|
| 1455 |
# ===== STAGE 3: SYNTHESIS =====
|
| 1456 |
-
yield {"stage": "synthesis", "ticker": "", "thinking": "Loading synthesis model...", "summary": ""}
|
| 1457 |
-
|
| 1458 |
-
synthesis_llm, load_msg = load_model_for_role(
|
| 1459 |
-
model_key=synthesis_model_key,
|
| 1460 |
-
model_role="synthesis",
|
| 1461 |
-
n_threads=n_threads
|
| 1462 |
-
)
|
| 1463 |
-
|
| 1464 |
-
yield {"stage": "synthesis", "ticker": "", "thinking": f"✅ {load_msg}", "summary": ""}
|
| 1465 |
-
|
| 1466 |
-
# Synthesize
|
| 1467 |
-
synthesis_config = get_model_config(synthesis_model_key, "synthesis")
|
| 1468 |
-
# Override inference settings with custom parameters
|
| 1469 |
-
synthesis_config["inference_settings"] = {
|
| 1470 |
-
"temperature": temperature,
|
| 1471 |
-
"top_p": top_p,
|
| 1472 |
-
"top_k": top_k,
|
| 1473 |
-
"repeat_penalty": 1.1
|
| 1474 |
-
}
|
| 1475 |
final_summary = ""
|
| 1476 |
final_thinking = ""
|
| 1477 |
-
|
| 1478 |
-
|
| 1479 |
-
|
| 1480 |
-
|
| 1481 |
-
|
| 1482 |
-
|
| 1483 |
-
|
| 1484 |
-
|
| 1485 |
-
|
| 1486 |
-
|
| 1487 |
-
|
| 1488 |
-
|
| 1489 |
-
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1495 |
# Apply Chinese conversion if needed
|
| 1496 |
if output_language == "zh-TW":
|
| 1497 |
converter = OpenCC('s2twp')
|
|
@@ -1703,12 +2047,14 @@ def calculate_effective_max_tokens(model_key: str, max_tokens: int, enable_reaso
|
|
| 1703 |
supports_reasoning = model_config.get("supports_reasoning", False)
|
| 1704 |
|
| 1705 |
if supports_reasoning:
|
| 1706 |
-
# Add
|
| 1707 |
-
|
|
|
|
|
|
|
| 1708 |
effective_max = max_tokens + thinking_headroom
|
| 1709 |
logger.info(f"Reasoning enabled for {model_key}: extending max_tokens from {max_tokens} to {effective_max}")
|
| 1710 |
return effective_max
|
| 1711 |
-
|
| 1712 |
return max_tokens
|
| 1713 |
|
| 1714 |
|
|
@@ -1928,7 +2274,7 @@ def summarize_streaming(
|
|
| 1928 |
path = file_obj.name if hasattr(file_obj, 'name') else file_obj
|
| 1929 |
source_name = os.path.basename(path)
|
| 1930 |
source_size = os.path.getsize(path)
|
| 1931 |
-
with open(path, 'r', encoding='utf-8') as f:
|
| 1932 |
transcript = f.read()
|
| 1933 |
else:
|
| 1934 |
system_prompt_preview = build_system_prompt(output_language, False, enable_reasoning)
|
|
@@ -1951,8 +2297,10 @@ def summarize_streaming(
|
|
| 1951 |
yield ("", "Error: File is empty", "", metrics, system_prompt_preview)
|
| 1952 |
return
|
| 1953 |
|
| 1954 |
-
# Calculate context and check truncation
|
| 1955 |
-
|
|
|
|
|
|
|
| 1956 |
metrics["n_ctx"] = n_ctx
|
| 1957 |
|
| 1958 |
# Truncate if needed (estimate max chars from available tokens)
|
|
@@ -2014,10 +2362,17 @@ def summarize_streaming(
|
|
| 2014 |
logger.info(load_msg)
|
| 2015 |
metrics["model_load_time_ms"] = (time.time() - model_load_start) * 1000
|
| 2016 |
except Exception as e:
|
|
|
|
| 2017 |
system_prompt_preview = build_system_prompt(output_language, False, enable_reasoning)
|
| 2018 |
-
yield ("", f"
|
| 2019 |
return
|
| 2020 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2021 |
# Prepare system prompt with reasoning toggle for Qwen3 models
|
| 2022 |
if model_key == "custom_hf":
|
| 2023 |
# Use default settings for custom models
|
|
@@ -2084,7 +2439,7 @@ def summarize_streaming(
|
|
| 2084 |
messages=messages,
|
| 2085 |
max_tokens=max_tokens,
|
| 2086 |
temperature=effective_temperature,
|
| 2087 |
-
min_p=0.0,
|
| 2088 |
top_p=final_top_p,
|
| 2089 |
top_k=final_top_k,
|
| 2090 |
repeat_penalty=repeat_penalty,
|
|
@@ -2093,9 +2448,14 @@ def summarize_streaming(
|
|
| 2093 |
|
| 2094 |
metrics["generation_start_time"] = time.time()
|
| 2095 |
|
|
|
|
|
|
|
| 2096 |
for chunk in stream:
|
| 2097 |
if 'choices' in chunk and len(chunk['choices']) > 0:
|
| 2098 |
-
|
|
|
|
|
|
|
|
|
|
| 2099 |
content = delta.get('content', '')
|
| 2100 |
if content:
|
| 2101 |
# Track time to first token
|
|
@@ -2105,16 +2465,22 @@ def summarize_streaming(
|
|
| 2105 |
|
| 2106 |
token_count += 1
|
| 2107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2108 |
if output_language == "zh-TW":
|
| 2109 |
-
|
| 2110 |
-
full_response += converted
|
| 2111 |
else:
|
| 2112 |
full_response += content
|
| 2113 |
|
| 2114 |
-
|
| 2115 |
-
|
| 2116 |
-
|
| 2117 |
-
|
|
|
|
|
|
|
|
|
|
| 2118 |
|
| 2119 |
# Final timing calculations
|
| 2120 |
metrics["generation_end_time"] = time.time()
|
|
@@ -2145,12 +2511,20 @@ def summarize_streaming(
|
|
| 2145 |
# Final parse and token counts
|
| 2146 |
thinking, summary = parse_thinking_blocks(full_response)
|
| 2147 |
|
| 2148 |
-
# Calculate output tokens
|
| 2149 |
metrics["output_tokens"] = estimate_tokens(summary) if summary else 0
|
| 2150 |
metrics["thinking_tokens"] = estimate_tokens(thinking) if thinking else 0
|
| 2151 |
|
| 2152 |
# Update totals
|
| 2153 |
metrics["total_tokens"] = metrics["input_tokens"] + metrics["output_tokens"] + metrics["thinking_tokens"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2154 |
|
| 2155 |
yield (thinking or "", summary or "", info, metrics, system_content)
|
| 2156 |
|
|
@@ -2219,12 +2593,9 @@ custom_css = """
|
|
| 2219 |
width: 200%;
|
| 2220 |
height: 200%;
|
| 2221 |
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
|
| 2222 |
-
animation: rotate 20s linear infinite
|
| 2223 |
-
|
| 2224 |
-
|
| 2225 |
-
@keyframes rotate {
|
| 2226 |
-
from { transform: rotate(0deg); }
|
| 2227 |
-
to { transform: rotate(360deg); }
|
| 2228 |
}
|
| 2229 |
|
| 2230 |
.app-header h1 {
|
|
@@ -2425,17 +2796,19 @@ custom_css = """
|
|
| 2425 |
}
|
| 2426 |
}
|
| 2427 |
|
| 2428 |
-
/* =====
|
| 2429 |
-
|
| 2430 |
-
|
| 2431 |
-
|
|
|
|
|
|
|
|
|
|
| 2432 |
}
|
| 2433 |
|
| 2434 |
-
/*
|
| 2435 |
-
.gradio-
|
| 2436 |
-
|
| 2437 |
-
|
| 2438 |
-
background: linear-gradient(90deg, rgba(99, 102, 241, 0.03) 0%, transparent 100%);
|
| 2439 |
}
|
| 2440 |
"""
|
| 2441 |
|
|
@@ -2446,7 +2819,9 @@ def create_interface():
|
|
| 2446 |
"""Create and configure the Gradio interface."""
|
| 2447 |
|
| 2448 |
with gr.Blocks(
|
| 2449 |
-
title="Tiny Scribe - AI Transcript Summarizer"
|
|
|
|
|
|
|
| 2450 |
) as demo:
|
| 2451 |
|
| 2452 |
# Header section (simplified - no Row/Column wrapper needed for full-width)
|
|
@@ -2455,24 +2830,21 @@ def create_interface():
|
|
| 2455 |
<h1>📄 Tiny Scribe</h1>
|
| 2456 |
<p>AI-Powered Transcript Summarization with Real-Time Streaming</p>
|
| 2457 |
<div class="model-badge">
|
| 2458 |
-
<span>
|
| 2459 |
</div>
|
| 2460 |
</div>
|
| 2461 |
""")
|
| 2462 |
-
|
| 2463 |
-
# Instructions (
|
| 2464 |
-
gr.
|
| 2465 |
-
|
| 2466 |
-
|
| 2467 |
-
|
| 2468 |
-
|
| 2469 |
-
|
| 2470 |
-
|
| 2471 |
-
|
| 2472 |
-
|
| 2473 |
-
</ul>
|
| 2474 |
-
</div>
|
| 2475 |
-
""")
|
| 2476 |
|
| 2477 |
# Main content area
|
| 2478 |
with gr.Row():
|
|
@@ -2512,11 +2884,10 @@ def create_interface():
|
|
| 2512 |
)
|
| 2513 |
|
| 2514 |
# ==========================================
|
| 2515 |
-
# Section 2: Hardware Configuration (Global)
|
|
|
|
| 2516 |
# ==========================================
|
| 2517 |
-
with gr.
|
| 2518 |
-
gr.HTML('<div class="section-header"><span class="section-icon">🖥️</span> Hardware Configuration</div>')
|
| 2519 |
-
|
| 2520 |
thread_config_dropdown = gr.Dropdown(
|
| 2521 |
choices=[
|
| 2522 |
("HF Spaces Free Tier (2 vCPUs)", "free"),
|
|
@@ -2562,22 +2933,20 @@ def create_interface():
|
|
| 2562 |
|
| 2563 |
# Preset Models Group
|
| 2564 |
with gr.Group(visible=True) as preset_models_group:
|
| 2565 |
-
#
|
| 2566 |
-
|
| 2567 |
-
(info["name"] + (" ⚡" if info.get("supports_reasoning", False) and not info.get("supports_toggle", False) else ""), key)
|
| 2568 |
-
for key, info in AVAILABLE_MODELS.items()
|
| 2569 |
-
if key != "custom_hf"
|
| 2570 |
-
]
|
| 2571 |
-
|
| 2572 |
model_dropdown = gr.Dropdown(
|
| 2573 |
-
choices=
|
| 2574 |
value=DEFAULT_MODEL_KEY,
|
| 2575 |
label="Select Model",
|
| 2576 |
-
info="
|
| 2577 |
)
|
| 2578 |
-
|
|
|
|
|
|
|
|
|
|
| 2579 |
enable_reasoning = gr.Checkbox(
|
| 2580 |
-
value=
|
| 2581 |
label="Enable Reasoning Mode",
|
| 2582 |
info="Uses /think for deeper analysis (slower) or /no_think for direct output (faster).",
|
| 2583 |
interactive=True,
|
|
@@ -2617,42 +2986,42 @@ def create_interface():
|
|
| 2617 |
|
| 2618 |
retry_btn = gr.Button("🔄 Retry", variant="secondary", visible=False)
|
| 2619 |
|
| 2620 |
-
# Inference Parameters (Standard Mode)
|
| 2621 |
-
|
| 2622 |
-
|
| 2623 |
-
|
| 2624 |
-
|
| 2625 |
-
|
| 2626 |
-
|
| 2627 |
-
|
| 2628 |
-
|
| 2629 |
-
|
| 2630 |
-
|
| 2631 |
-
|
| 2632 |
-
|
| 2633 |
-
|
| 2634 |
-
|
| 2635 |
-
|
| 2636 |
-
|
| 2637 |
-
|
| 2638 |
-
|
| 2639 |
-
|
| 2640 |
-
|
| 2641 |
-
|
| 2642 |
-
|
| 2643 |
-
|
| 2644 |
-
|
| 2645 |
-
|
| 2646 |
-
|
| 2647 |
-
|
| 2648 |
-
|
| 2649 |
-
|
| 2650 |
-
|
| 2651 |
-
|
| 2652 |
-
|
| 2653 |
-
|
| 2654 |
-
|
| 2655 |
-
|
| 2656 |
# ===== ADVANCED MODE =====
|
| 2657 |
with gr.Group(visible=False) as advanced_mode_group:
|
| 2658 |
gr.HTML('<div style="font-size: 0.9em; color: #64748b; margin-bottom: 16px;">🧠 <strong>Advanced Mode (3-Model Pipeline)</strong> - Extraction → Deduplication → Synthesis</div>')
|
|
@@ -2669,7 +3038,7 @@ def create_interface():
|
|
| 2669 |
|
| 2670 |
with gr.Row():
|
| 2671 |
extraction_n_ctx = gr.Slider(
|
| 2672 |
-
minimum=
|
| 2673 |
maximum=8192,
|
| 2674 |
step=1024,
|
| 2675 |
value=4096,
|
|
@@ -2810,53 +3179,51 @@ def create_interface():
|
|
| 2810 |
|
| 2811 |
# Right column - Outputs
|
| 2812 |
with gr.Column(scale=2):
|
| 2813 |
-
#
|
| 2814 |
-
with gr.Group():
|
| 2815 |
-
gr.HTML('<div class="section-header"><span class="section-icon">📊</span> Model Information</div>')
|
| 2816 |
-
_default_threads = DEFAULT_CUSTOM_THREADS if DEFAULT_CUSTOM_THREADS > 0 else 2
|
| 2817 |
-
_default_info = get_model_info(DEFAULT_MODEL_KEY, n_threads=_default_threads)[0]
|
| 2818 |
-
model_info_output = gr.Markdown(
|
| 2819 |
-
value=_default_info,
|
| 2820 |
-
elem_classes=["info-box"]
|
| 2821 |
-
)
|
| 2822 |
-
|
| 2823 |
-
# Thinking Process
|
| 2824 |
-
with gr.Group():
|
| 2825 |
-
gr.HTML('<div class="section-header"><span class="section-icon">🧠</span> Model Thinking Process</div>')
|
| 2826 |
-
thinking_output = gr.Textbox(
|
| 2827 |
-
label="",
|
| 2828 |
-
lines=12,
|
| 2829 |
-
max_lines=20,
|
| 2830 |
-
show_label=False,
|
| 2831 |
-
placeholder="The AI's reasoning process will appear here in real-time...",
|
| 2832 |
-
elem_classes=["thinking-box"]
|
| 2833 |
-
)
|
| 2834 |
-
# Copy Thinking button - now in the correct group
|
| 2835 |
-
copy_thinking_btn = gr.Button("📋 Copy Thinking", size="sm")
|
| 2836 |
-
|
| 2837 |
-
# Summary Output
|
| 2838 |
with gr.Group():
|
| 2839 |
gr.HTML('<div class="section-header"><span class="section-icon">📝</span> Final Summary</div>')
|
| 2840 |
summary_output = gr.Markdown(
|
| 2841 |
value="*Your summarized content will appear here...*",
|
| 2842 |
elem_classes=["summary-box"]
|
| 2843 |
)
|
| 2844 |
-
|
| 2845 |
# Action buttons for summary
|
| 2846 |
with gr.Row():
|
| 2847 |
copy_summary_btn = gr.Button("📋 Copy Summary", size="sm")
|
| 2848 |
download_btn = gr.Button("⬇️ Download (JSON)", size="sm")
|
| 2849 |
-
|
| 2850 |
# File output component for download (hidden until generated)
|
| 2851 |
download_output = gr.File(label="Download JSON", visible=False)
|
| 2852 |
-
|
| 2853 |
-
# Completion Metrics (
|
| 2854 |
with gr.Group():
|
| 2855 |
gr.HTML('<div class="section-header"><span class="section-icon">📊</span> Generation Metrics</div>')
|
| 2856 |
info_output = gr.Markdown(
|
| 2857 |
value="*Metrics will appear here after generation...*",
|
| 2858 |
elem_classes=["completion-info"]
|
| 2859 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2860 |
|
| 2861 |
# Function to update settings when model changes
|
| 2862 |
def update_settings_on_model_change(model_key, thread_config, custom_threads, custom_metadata=None):
|
|
@@ -3274,7 +3641,9 @@ def create_interface():
|
|
| 3274 |
adv_max_tokens_val, enable_logging_val,
|
| 3275 |
adv_temperature_val, adv_top_p_val, adv_top_k_val,
|
| 3276 |
# Mode selector
|
| 3277 |
-
mode_radio_val
|
|
|
|
|
|
|
| 3278 |
):
|
| 3279 |
"""Route to Standard or Advanced mode based on selected mode radio button."""
|
| 3280 |
|
|
@@ -3290,8 +3659,15 @@ def create_interface():
|
|
| 3290 |
# Get transcript
|
| 3291 |
transcript = ""
|
| 3292 |
if file_input_val:
|
| 3293 |
-
|
| 3294 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3295 |
elif text_input_val:
|
| 3296 |
transcript = text_input_val
|
| 3297 |
else:
|
|
@@ -3365,16 +3741,29 @@ def create_interface():
|
|
| 3365 |
return
|
| 3366 |
|
| 3367 |
else:
|
| 3368 |
-
# Standard Mode
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3369 |
for thinking, summary, info, metrics, system_prompt in summarize_streaming(
|
| 3370 |
-
file_input_val, text_input_val,
|
| 3371 |
max_tokens_val, temperature_val, top_p_val, top_k_val, language_val,
|
| 3372 |
thread_config_val, custom_threads_val, custom_model_val
|
| 3373 |
):
|
| 3374 |
yield (thinking, summary, info, metrics, system_prompt)
|
| 3375 |
|
| 3376 |
# Wire up submit button with router
|
|
|
|
|
|
|
|
|
|
| 3377 |
submit_btn.click(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3378 |
fn=route_summarize,
|
| 3379 |
inputs=[
|
| 3380 |
# Standard mode inputs
|
|
@@ -3388,10 +3777,19 @@ def create_interface():
|
|
| 3388 |
adv_max_tokens, enable_detailed_logging,
|
| 3389 |
adv_temperature_slider, adv_top_p, adv_top_k,
|
| 3390 |
# Mode selector
|
| 3391 |
-
mode_radio
|
|
|
|
|
|
|
| 3392 |
],
|
| 3393 |
outputs=[thinking_output, summary_output, info_output, metrics_state, system_prompt_debug],
|
| 3394 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3395 |
)
|
| 3396 |
|
| 3397 |
# Footer
|
|
@@ -3412,7 +3810,10 @@ if __name__ == "__main__":
|
|
| 3412 |
|
| 3413 |
# Create and launch interface
|
| 3414 |
demo = create_interface()
|
| 3415 |
-
|
|
|
|
|
|
|
|
|
|
| 3416 |
demo.launch(
|
| 3417 |
server_name="0.0.0.0",
|
| 3418 |
server_port=7860,
|
|
|
|
| 484 |
},
|
| 485 |
},
|
| 486 |
"lfm2_2_6b_transcript": {
|
| 487 |
+
# Key kept for backward-compat; repo repointed from the dead
|
| 488 |
+
# LiquidAI/LFM-2.6B-Transcript-GGUF (404) to the live LFM2-2.6B.
|
| 489 |
+
"name": "LFM2 2.6B (32K Context)",
|
| 490 |
+
"repo_id": "LiquidAI/LFM2-2.6B-GGUF",
|
| 491 |
"filename": "*Q4_0.gguf",
|
| 492 |
"max_context": 32768,
|
| 493 |
+
"default_temperature": 0.3,
|
| 494 |
"supports_reasoning": False,
|
| 495 |
"supports_toggle": False,
|
| 496 |
"inference_settings": {
|
| 497 |
+
"temperature": 0.3,
|
| 498 |
+
"top_p": 1.0,
|
| 499 |
+
"top_k": 0,
|
| 500 |
+
"min_p": 0.15,
|
| 501 |
+
"repeat_penalty": 1.05,
|
| 502 |
},
|
| 503 |
},
|
| 504 |
"breeze_3b_q4": {
|
|
|
|
| 547 |
},
|
| 548 |
},
|
| 549 |
"granite4_tiny_q3": {
|
| 550 |
+
# Key kept for backward-compat; repo repointed from the dead
|
| 551 |
+
# ibm-research/granite-4.0-Tiny-7B-Instruct-GGUF (404) to the live
|
| 552 |
+
# unsloth Granite 4.0 H-Tiny (7B-total MoE, ~1B active).
|
| 553 |
+
"name": "Granite 4.0 H-Tiny 7B MoE (128K Context)",
|
| 554 |
+
"repo_id": "unsloth/granite-4.0-h-tiny-GGUF",
|
| 555 |
+
"filename": "*Q4_K_M.gguf",
|
| 556 |
"max_context": 131072,
|
| 557 |
"default_temperature": 0.7,
|
| 558 |
"supports_reasoning": False,
|
|
|
|
| 654 |
"repeat_penalty": 1.0,
|
| 655 |
},
|
| 656 |
},
|
| 657 |
+
# ===== 2026 additions (all repos+quants verified live on HF Hub, June 2026) =====
|
| 658 |
+
"qwen3_5_800m": {
|
| 659 |
+
"name": "Qwen3.5 0.8B Instruct (256K Context)",
|
| 660 |
+
"repo_id": "unsloth/Qwen3.5-0.8B-GGUF",
|
| 661 |
+
"filename": "*Q4_K_M.gguf",
|
| 662 |
+
"max_context": 262144,
|
| 663 |
+
"default_temperature": 0.7,
|
| 664 |
+
# Qwen3.5 reasoning toggles via chat_template_kwargs(enable_thinking),
|
| 665 |
+
# NOT the /think slash command, and defaults to non-thinking — so we
|
| 666 |
+
# expose it as a plain instruct model (best fit for CPU summaries).
|
| 667 |
+
"supports_reasoning": False,
|
| 668 |
+
"supports_toggle": False,
|
| 669 |
+
"inference_settings": {
|
| 670 |
+
"temperature": 0.7,
|
| 671 |
+
"top_p": 0.8,
|
| 672 |
+
"top_k": 20,
|
| 673 |
+
"repeat_penalty": 1.0,
|
| 674 |
+
},
|
| 675 |
+
},
|
| 676 |
+
"granite_4_h_1b": {
|
| 677 |
+
"name": "Granite 4.0 H-1B (hybrid Mamba, 128K Context)",
|
| 678 |
+
"repo_id": "unsloth/granite-4.0-h-1b-GGUF",
|
| 679 |
+
"filename": "*Q4_K_M.gguf",
|
| 680 |
+
"max_context": 131072,
|
| 681 |
+
"default_temperature": 0.6,
|
| 682 |
+
"supports_reasoning": False,
|
| 683 |
+
"supports_toggle": False,
|
| 684 |
+
"inference_settings": {
|
| 685 |
+
"temperature": 0.6,
|
| 686 |
+
"top_p": 0.95,
|
| 687 |
+
"top_k": 40,
|
| 688 |
+
"repeat_penalty": 1.05,
|
| 689 |
+
},
|
| 690 |
+
},
|
| 691 |
+
"lfm2_5_1_2b": {
|
| 692 |
+
"name": "LFM2.5 1.2B Instruct (32K Context)",
|
| 693 |
+
"repo_id": "LiquidAI/LFM2.5-1.2B-Instruct-GGUF",
|
| 694 |
+
"filename": "*Q4_K_M.gguf",
|
| 695 |
+
"max_context": 32768,
|
| 696 |
+
"default_temperature": 0.3,
|
| 697 |
+
"supports_reasoning": False,
|
| 698 |
+
"supports_toggle": False,
|
| 699 |
+
"inference_settings": {
|
| 700 |
+
"temperature": 0.3,
|
| 701 |
+
"top_p": 1.0,
|
| 702 |
+
"top_k": 50,
|
| 703 |
+
"repeat_penalty": 1.05,
|
| 704 |
+
},
|
| 705 |
+
},
|
| 706 |
+
"smollm3_3b": {
|
| 707 |
+
"name": "SmolLM3 3B (hybrid /think, 64K Context)",
|
| 708 |
+
"repo_id": "unsloth/SmolLM3-3B-GGUF",
|
| 709 |
+
"filename": "*Q4_K_M.gguf",
|
| 710 |
+
"max_context": 65536,
|
| 711 |
+
"default_temperature": 0.6,
|
| 712 |
+
# SmolLM3 uses /think and /no_think in the system prompt (same
|
| 713 |
+
# mechanism as Qwen3), so the existing toggle machinery applies.
|
| 714 |
+
"supports_reasoning": True,
|
| 715 |
+
"supports_toggle": True,
|
| 716 |
+
"inference_settings": {
|
| 717 |
+
"temperature": 0.6,
|
| 718 |
+
"top_p": 0.95,
|
| 719 |
+
"top_k": 40,
|
| 720 |
+
"repeat_penalty": 1.1,
|
| 721 |
+
},
|
| 722 |
+
},
|
| 723 |
+
"qwen3_5_4b": {
|
| 724 |
+
"name": "Qwen3.5 4B Instruct (256K Context)",
|
| 725 |
+
"repo_id": "unsloth/Qwen3.5-4B-GGUF",
|
| 726 |
+
"filename": "*Q4_K_M.gguf",
|
| 727 |
+
"max_context": 262144,
|
| 728 |
+
"default_temperature": 0.7,
|
| 729 |
+
"supports_reasoning": False,
|
| 730 |
+
"supports_toggle": False,
|
| 731 |
+
"inference_settings": {
|
| 732 |
+
"temperature": 0.7,
|
| 733 |
+
"top_p": 0.8,
|
| 734 |
+
"top_k": 20,
|
| 735 |
+
"repeat_penalty": 1.0,
|
| 736 |
+
},
|
| 737 |
+
},
|
| 738 |
"custom_hf": {
|
| 739 |
"name": "🔧 Custom HF GGUF...",
|
| 740 |
"repo_id": None,
|
|
|
|
| 754 |
|
| 755 |
DEFAULT_MODEL_KEY = "qwen3_600m_q4"
|
| 756 |
|
| 757 |
+
# ===== Speed tiers (free CPU tier: 2 vCPU / 16GB) =====
|
| 758 |
+
# Groups the model picker so users can tell what actually runs fast.
|
| 759 |
+
# fast: <1B · balanced: 1-3B dense · experimental: >3B or giant MoE (slow on CPU)
|
| 760 |
+
TIER_META = {
|
| 761 |
+
"fast": ("⚡", "Fast (<1B)"),
|
| 762 |
+
"balanced": ("✅", "Balanced (1-3B)"),
|
| 763 |
+
"experimental": ("🐢", "Experimental (slow on free CPU)"),
|
| 764 |
+
}
|
| 765 |
+
MODEL_TIERS = {
|
| 766 |
+
# fast — sub-1B, comfortably interactive on 2 vCPU
|
| 767 |
+
"falcon_h1_100m": "fast", "gemma3_270m": "fast", "ernie_300m": "fast",
|
| 768 |
+
"granite_350m": "fast", "lfm2_350m": "fast", "bitcpm4_500m": "fast",
|
| 769 |
+
"hunyuan_500m": "fast", "qwen3_600m_q4": "fast", "qwen3_5_800m": "fast",
|
| 770 |
+
# balanced — 1-3B dense, usable
|
| 771 |
+
"granite_3_1_1b_q8": "balanced", "lfm2_5_1_2b": "balanced",
|
| 772 |
+
"granite_4_h_1b": "balanced", "falcon_h1_1.5b_q4": "balanced",
|
| 773 |
+
"qwen3_1.7b_q4": "balanced", "granite_3_3_2b_q4": "balanced",
|
| 774 |
+
"youtu_llm_2b_q8": "balanced", "breeze_3b_q4": "balanced",
|
| 775 |
+
"granite_3_1_3b_q4": "balanced",
|
| 776 |
+
# experimental — >3B or giant MoE at TQ1_0/IQ2 (slow/borderline on CPU)
|
| 777 |
+
"smollm3_3b": "experimental", "qwen3_5_4b": "experimental",
|
| 778 |
+
"qwen3_4b_thinking_q3": "experimental", "lfm2_2_6b_transcript": "experimental",
|
| 779 |
+
"granite4_tiny_q3": "experimental", "ernie_21b_pt_q1": "experimental",
|
| 780 |
+
"ernie_21b_thinking_q1": "experimental", "glm_4_7_flash_reap_30b": "experimental",
|
| 781 |
+
"glm_4_7_flash_30b_iq2": "experimental", "qwen3_30b_thinking_q1": "experimental",
|
| 782 |
+
"qwen3_30b_instruct_q1": "experimental",
|
| 783 |
+
}
|
| 784 |
+
TIER_ORDER = {"fast": 0, "balanced": 1, "experimental": 2}
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
def get_model_tier(model_key: str) -> str:
|
| 788 |
+
"""Return the speed tier ('fast'|'balanced'|'experimental') for a model key."""
|
| 789 |
+
return MODEL_TIERS.get(model_key, "balanced")
|
| 790 |
+
|
| 791 |
+
|
| 792 |
+
def build_preset_choices() -> list:
|
| 793 |
+
"""Build (label, key) tuples for the preset dropdown, grouped by speed tier.
|
| 794 |
+
|
| 795 |
+
Gradio has no native option groups, so the tier is encoded as an emoji
|
| 796 |
+
prefix and entries are sorted fast -> balanced -> experimental. The tuple
|
| 797 |
+
value (model key) stays byte-identical to AVAILABLE_MODELS keys.
|
| 798 |
+
"""
|
| 799 |
+
rows = []
|
| 800 |
+
for key, info in AVAILABLE_MODELS.items():
|
| 801 |
+
if key == "custom_hf":
|
| 802 |
+
continue
|
| 803 |
+
tier = get_model_tier(key)
|
| 804 |
+
emoji = TIER_META[tier][0]
|
| 805 |
+
reasons = info.get("supports_reasoning", False)
|
| 806 |
+
toggles = info.get("supports_toggle", False)
|
| 807 |
+
mark = " · reasoning" if (reasons and not toggles) else (" · /think" if toggles else "")
|
| 808 |
+
label = f"{emoji} {info['name']}{mark}"
|
| 809 |
+
rows.append((TIER_ORDER[tier], info["name"], label, key))
|
| 810 |
+
rows.sort(key=lambda r: (r[0], r[1]))
|
| 811 |
+
return [(label, key) for _, _, label, key in rows]
|
| 812 |
+
|
| 813 |
|
| 814 |
# ===== ADVANCED MODE: EXTRACTION MODELS REGISTRY (13 models, ≤1.7B) =====
|
| 815 |
# Used exclusively for Stage 1: Extraction (transcript windows → structured JSON)
|
|
|
|
| 832 |
"repeat_penalty": 1.0,
|
| 833 |
},
|
| 834 |
},
|
| 835 |
+
# 2026 additions — verified live, strong CPU extractors per research.
|
| 836 |
+
"granite_4_h_1b": {
|
| 837 |
+
"name": "Granite 4.0 H-1B (hybrid Mamba, 128K)",
|
| 838 |
+
"repo_id": "unsloth/granite-4.0-h-1b-GGUF",
|
| 839 |
+
"filename": "*Q4_K_M.gguf",
|
| 840 |
+
"max_context": 131072,
|
| 841 |
+
"default_n_ctx": 4096,
|
| 842 |
+
"params_size": "1.5B",
|
| 843 |
+
"supports_reasoning": False,
|
| 844 |
+
"supports_toggle": False,
|
| 845 |
+
"inference_settings": {
|
| 846 |
+
"temperature": 0.1,
|
| 847 |
+
"top_p": 0.95,
|
| 848 |
+
"top_k": 40,
|
| 849 |
+
"repeat_penalty": 1.05,
|
| 850 |
+
},
|
| 851 |
+
},
|
| 852 |
+
"lfm2_5_1_2b": {
|
| 853 |
+
"name": "LFM2.5 1.2B Instruct (32K)",
|
| 854 |
+
"repo_id": "LiquidAI/LFM2.5-1.2B-Instruct-GGUF",
|
| 855 |
+
"filename": "*Q4_K_M.gguf",
|
| 856 |
+
"max_context": 32768,
|
| 857 |
+
"default_n_ctx": 4096,
|
| 858 |
+
"params_size": "1.2B",
|
| 859 |
+
"supports_reasoning": False,
|
| 860 |
+
"supports_toggle": False,
|
| 861 |
+
"inference_settings": {
|
| 862 |
+
"temperature": 0.1,
|
| 863 |
+
"top_p": 1.0,
|
| 864 |
+
"top_k": 50,
|
| 865 |
+
"repeat_penalty": 1.05,
|
| 866 |
+
},
|
| 867 |
+
},
|
| 868 |
}
|
| 869 |
|
| 870 |
DEFAULT_EXTRACTION_MODEL = "qwen2.5_1.5b"
|
|
|
|
| 947 |
},
|
| 948 |
},
|
| 949 |
"lfm2_2_6b_transcript": {
|
| 950 |
+
# Repo repointed from the dead LFM-2.6B-Transcript-GGUF (404).
|
| 951 |
+
"name": "LFM2 2.6B (32K Context)",
|
| 952 |
+
"repo_id": "LiquidAI/LFM2-2.6B-GGUF",
|
| 953 |
"filename": "*Q4_0.gguf",
|
| 954 |
"max_context": 32768,
|
| 955 |
"supports_reasoning": False,
|
|
|
|
| 1004 |
},
|
| 1005 |
},
|
| 1006 |
"granite4_tiny_q3": {
|
| 1007 |
+
# Repo repointed from the dead granite-4.0-Tiny-7B-Instruct-GGUF (404).
|
| 1008 |
+
"name": "Granite 4.0 H-Tiny 7B MoE (128K Context)",
|
| 1009 |
+
"repo_id": "unsloth/granite-4.0-h-tiny-GGUF",
|
| 1010 |
+
"filename": "*Q4_K_M.gguf",
|
| 1011 |
"max_context": 131072,
|
| 1012 |
"supports_reasoning": False,
|
| 1013 |
"supports_toggle": False,
|
|
|
|
| 1102 |
"repeat_penalty": 1.0,
|
| 1103 |
},
|
| 1104 |
},
|
| 1105 |
+
# 2026 additions — verified live; good CPU synthesizers per research.
|
| 1106 |
+
"lfm2_5_1_2b": {
|
| 1107 |
+
"name": "LFM2.5 1.2B Instruct (32K)",
|
| 1108 |
+
"repo_id": "LiquidAI/LFM2.5-1.2B-Instruct-GGUF",
|
| 1109 |
+
"filename": "*Q4_K_M.gguf",
|
| 1110 |
+
"max_context": 32768,
|
| 1111 |
+
"supports_reasoning": False,
|
| 1112 |
+
"supports_toggle": False,
|
| 1113 |
+
"inference_settings": {
|
| 1114 |
+
"temperature": 0.7,
|
| 1115 |
+
"top_p": 1.0,
|
| 1116 |
+
"top_k": 50,
|
| 1117 |
+
"repeat_penalty": 1.05,
|
| 1118 |
+
},
|
| 1119 |
+
},
|
| 1120 |
+
"granite_4_h_1b": {
|
| 1121 |
+
"name": "Granite 4.0 H-1B (hybrid Mamba, 128K)",
|
| 1122 |
+
"repo_id": "unsloth/granite-4.0-h-1b-GGUF",
|
| 1123 |
+
"filename": "*Q4_K_M.gguf",
|
| 1124 |
+
"max_context": 131072,
|
| 1125 |
+
"supports_reasoning": False,
|
| 1126 |
+
"supports_toggle": False,
|
| 1127 |
+
"inference_settings": {
|
| 1128 |
+
"temperature": 0.7,
|
| 1129 |
+
"top_p": 0.95,
|
| 1130 |
+
"top_k": 40,
|
| 1131 |
+
"repeat_penalty": 1.05,
|
| 1132 |
+
},
|
| 1133 |
+
},
|
| 1134 |
+
"smollm3_3b": {
|
| 1135 |
+
"name": "SmolLM3 3B (hybrid /think, 64K)",
|
| 1136 |
+
"repo_id": "unsloth/SmolLM3-3B-GGUF",
|
| 1137 |
+
"filename": "*Q4_K_M.gguf",
|
| 1138 |
+
"max_context": 65536,
|
| 1139 |
+
"supports_reasoning": True,
|
| 1140 |
+
"supports_toggle": True,
|
| 1141 |
+
"inference_settings": {
|
| 1142 |
+
"temperature": 0.6,
|
| 1143 |
+
"top_p": 0.95,
|
| 1144 |
+
"top_k": 40,
|
| 1145 |
+
"repeat_penalty": 1.1,
|
| 1146 |
+
},
|
| 1147 |
+
},
|
| 1148 |
}
|
| 1149 |
|
| 1150 |
DEFAULT_SYNTHESIS_MODEL = "qwen3_1.7b_q4"
|
|
|
|
| 1210 |
repo_id=model["repo_id"],
|
| 1211 |
filename=model["filename"],
|
| 1212 |
n_ctx=n_ctx,
|
| 1213 |
+
# 512 is the effective micro-batch (n_ubatch) anyway; a larger
|
| 1214 |
+
# n_batch only inflates the transient scores buffer on a 2-core box.
|
| 1215 |
+
n_batch=min(512, n_ctx),
|
| 1216 |
n_threads=n_threads, # Configurable thread count
|
| 1217 |
n_threads_batch=n_threads, # Parallel batch processing
|
| 1218 |
n_gpu_layers=n_gpu_layers, # 0=CPU only, -1=all GPU layers (if available)
|
| 1219 |
verbose=False,
|
| 1220 |
seed=1337,
|
| 1221 |
+
# NOTE: previous code passed v_type/k_type=2 to quantize the KV
|
| 1222 |
+
# cache, but those are not real Llama kwargs (correct names are
|
| 1223 |
+
# type_k/type_v) so they were silently dropped — the cache ran at
|
| 1224 |
+
# the f16 default. Quantizing the V cache needs flash-attention,
|
| 1225 |
+
# which is unverified on this OpenBLAS CPU wheel, so we keep the
|
| 1226 |
+
# working f16 default rather than enable it blind.
|
| 1227 |
)
|
| 1228 |
+
|
| 1229 |
current_model_key = model_key
|
| 1230 |
info_msg = f"Loaded: {model['name']} ({n_ctx:,} context)"
|
| 1231 |
logger.info(info_msg)
|
| 1232 |
return llm, info_msg
|
| 1233 |
+
|
| 1234 |
except Exception as e:
|
| 1235 |
+
# Reset global state so a stale current_model_key can't be reused, and
|
| 1236 |
+
# reclaim any partial allocation (e.g. OOM on a large MoE).
|
| 1237 |
+
error_msg = str(e)
|
| 1238 |
+
logger.error(f"Error loading model {model_key}: {error_msg}", exc_info=True)
|
| 1239 |
+
llm = None
|
| 1240 |
+
current_model_key = None
|
| 1241 |
+
gc.collect()
|
| 1242 |
+
low = error_msg.lower()
|
| 1243 |
+
if any(s in low for s in ("not found", "404", "does not exist")):
|
| 1244 |
+
friendly = f"Model not found for '{model['name']}' — this repo may be unavailable. Please pick another model."
|
| 1245 |
+
elif any(s in low for s in ("permission", "gated", "401", "403", "access")):
|
| 1246 |
+
friendly = f"Access denied for '{model['name']}' (model may be private/gated). Please pick another model."
|
| 1247 |
+
elif any(s in low for s in ("memory", "oom", "alloc")):
|
| 1248 |
+
friendly = f"Out of memory loading '{model['name']}'. Try a smaller model or a 🐢 model only on the upgraded tier."
|
| 1249 |
+
else:
|
| 1250 |
+
friendly = f"Could not load '{model['name']}': {error_msg}"
|
| 1251 |
+
raise RuntimeError(friendly) from e
|
| 1252 |
|
| 1253 |
|
| 1254 |
def update_reasoning_visibility(model_key):
|
|
|
|
| 1268 |
|
| 1269 |
if not supports_reasoning:
|
| 1270 |
# Non-reasoning model: hide checkbox
|
| 1271 |
+
return gr.update(visible=False, value=False, interactive=False,
|
| 1272 |
+
label="Enable Reasoning Mode",
|
| 1273 |
+
info="This model does not support a reasoning mode.")
|
| 1274 |
elif supports_reasoning and not supports_toggle:
|
| 1275 |
+
# Thinking-only model: show, check, lock (and keep the help text honest)
|
| 1276 |
+
return gr.update(visible=True, value=True, interactive=False,
|
| 1277 |
+
label="⚡ Reasoning Mode (Always On)",
|
| 1278 |
+
info="This model always reasons before answering; it cannot be disabled.")
|
| 1279 |
else:
|
| 1280 |
+
# Hybrid model: show, toggleable. Default OFF — thinking is slow on CPU,
|
| 1281 |
+
# so it's opt-in (matches the initial checkbox state).
|
| 1282 |
+
return gr.update(visible=True, value=False, interactive=True,
|
| 1283 |
+
label="Enable Reasoning Mode",
|
| 1284 |
+
info="Off = /no_think (fast, direct). On = /think (deeper analysis, slower on CPU).")
|
| 1285 |
|
| 1286 |
|
| 1287 |
# ===== ADVANCED MODE: HELPER FUNCTIONS =====
|
|
|
|
| 1374 |
logger.warning(f"Could not detect GPU: {e}. Using CPU.")
|
| 1375 |
n_gpu_layers = 0
|
| 1376 |
|
| 1377 |
+
# Load model. Retry ONCE on transient network errors only (a common HF
|
| 1378 |
+
# download timeout on the free Space would otherwise discard minutes of
|
| 1379 |
+
# already-completed extraction work). Permanent errors (404/OOM) are not
|
| 1380 |
+
# retried — they never recover and would just waste another timeout.
|
| 1381 |
logger.info(f"Loading {config['name']} for {model_role} role (n_ctx={n_ctx:,})")
|
| 1382 |
+
|
| 1383 |
+
last_err = None
|
| 1384 |
+
for attempt in range(2):
|
| 1385 |
+
try:
|
| 1386 |
+
llm = Llama.from_pretrained(
|
| 1387 |
+
repo_id=config["repo_id"],
|
| 1388 |
+
filename=config["filename"],
|
| 1389 |
+
n_ctx=n_ctx,
|
| 1390 |
+
n_batch=min(512, n_ctx),
|
| 1391 |
+
n_threads=n_threads,
|
| 1392 |
+
n_threads_batch=n_threads,
|
| 1393 |
+
n_gpu_layers=n_gpu_layers,
|
| 1394 |
+
verbose=False,
|
| 1395 |
+
seed=1337,
|
| 1396 |
+
)
|
| 1397 |
+
info_msg = (
|
| 1398 |
+
f"✅ Loaded: {config['name']} for {model_role} "
|
| 1399 |
+
f"(n_ctx={n_ctx:,}, threads={n_threads})"
|
| 1400 |
+
)
|
| 1401 |
+
logger.info(info_msg)
|
| 1402 |
+
return llm, info_msg
|
| 1403 |
+
except Exception as e:
|
| 1404 |
+
last_err = e
|
| 1405 |
+
transient = any(t in str(e).lower() for t in
|
| 1406 |
+
("timeout", "timed out", "connection", "temporarily", "read timed"))
|
| 1407 |
+
if transient and attempt == 0:
|
| 1408 |
+
logger.warning(f"Transient load error for {model_key}, retrying once: {e}")
|
| 1409 |
+
time.sleep(2)
|
| 1410 |
+
continue
|
| 1411 |
+
break
|
| 1412 |
+
raise last_err # surfaced to summarize_advanced's except below
|
| 1413 |
+
|
| 1414 |
except Exception as e:
|
| 1415 |
+
# Graceful failure - let user select different model. Single-line message
|
| 1416 |
+
# (no nested ❌) so the consumer renders it cleanly.
|
| 1417 |
+
error_msg = f"Failed to load {config.get('name', model_key)} for {model_role}: {str(e)}"
|
|
|
|
|
|
|
| 1418 |
logger.error(error_msg, exc_info=True)
|
| 1419 |
raise Exception(error_msg)
|
| 1420 |
|
|
|
|
| 1423 |
"""Explicitly unload model and trigger garbage collection."""
|
| 1424 |
if llm:
|
| 1425 |
logger.info(f"Unloading {model_name}")
|
| 1426 |
+
# close() synchronously frees the C++ model/context; del + gc.collect()
|
| 1427 |
+
# then reclaim the Python wrapper. The old time.sleep(0.5) did nothing
|
| 1428 |
+
# for memory reclamation and just stalled the single worker (3x/run in
|
| 1429 |
+
# Advanced mode).
|
| 1430 |
+
try:
|
| 1431 |
+
llm.close()
|
| 1432 |
+
except Exception:
|
| 1433 |
+
pass
|
| 1434 |
del llm
|
| 1435 |
gc.collect()
|
|
|
|
| 1436 |
|
| 1437 |
|
| 1438 |
def get_extraction_model_info(model_key: str) -> str:
|
|
|
|
| 1584 |
|
| 1585 |
# Create windows from preprocessed transcript
|
| 1586 |
lines = [l.strip() for l in transcript.split('\n') if l.strip()]
|
| 1587 |
+
|
| 1588 |
+
# Reserve tokens for the system prompt (~256) and the extraction output
|
| 1589 |
+
# (>=2048, see stream_extract_from_window). Clamp so a small n_ctx can
|
| 1590 |
+
# never make the budget zero/negative — that previously made every line
|
| 1591 |
+
# its own window and effectively hung the run on 2 vCPU.
|
| 1592 |
+
output_reserve = 2048
|
| 1593 |
+
system_reserve = 256
|
| 1594 |
+
max_window_tokens = max(512, extraction_n_ctx - output_reserve - system_reserve)
|
| 1595 |
+
|
| 1596 |
+
# Hard-split any single line that alone exceeds the window budget, so no
|
| 1597 |
+
# window can ever overflow n_ctx (a long no-newline paste, or a CSV row
|
| 1598 |
+
# with a huge text field). Tokenize each (sub)line exactly once and carry
|
| 1599 |
+
# the count forward so the windowing loop never re-tokenizes.
|
| 1600 |
+
def _split_oversized(line: str, line_tok: int) -> List[str]:
|
| 1601 |
+
approx_chars = max(1, max_window_tokens * 3) # ~3 bytes/token heuristic
|
| 1602 |
+
chunks, start = [], 0
|
| 1603 |
+
while start < len(line):
|
| 1604 |
+
chunk = line[start:start + approx_chars]
|
| 1605 |
+
while chunk and count_tokens(chunk) > max_window_tokens:
|
| 1606 |
+
chunk = chunk[:max(1, int(len(chunk) * 0.85))]
|
| 1607 |
+
if not chunk:
|
| 1608 |
+
chunk = line[start:start + 1]
|
| 1609 |
+
chunks.append(chunk)
|
| 1610 |
+
start += len(chunk)
|
| 1611 |
+
logger.warning("Split oversized line (%d tok) into %d sub-chunks (budget %d tok)",
|
| 1612 |
+
line_tok, len(chunks), max_window_tokens)
|
| 1613 |
+
return chunks
|
| 1614 |
+
|
| 1615 |
+
toked = [] # list of (line, token_count), each line tokenized once
|
| 1616 |
+
for line in lines:
|
| 1617 |
+
lt = count_tokens(line)
|
| 1618 |
+
if lt > max_window_tokens:
|
| 1619 |
+
toked.extend((c, count_tokens(c)) for c in _split_oversized(line, lt))
|
| 1620 |
+
else:
|
| 1621 |
+
toked.append((line, lt))
|
| 1622 |
+
n_lines = len(toked)
|
| 1623 |
+
|
| 1624 |
+
# Simple windowing: split into chunks based on token count.
|
| 1625 |
windows = []
|
| 1626 |
current_window = []
|
| 1627 |
+
current_window_tokens = [] # parallel per-line counts (no re-tokenize)
|
| 1628 |
current_tokens = 0
|
| 1629 |
window_id = 1
|
| 1630 |
+
|
| 1631 |
+
for line_num, (line, line_tokens) in enumerate(toked):
|
|
|
|
|
|
|
| 1632 |
if current_tokens + line_tokens > max_window_tokens and current_window:
|
| 1633 |
# Create window
|
| 1634 |
window_content = '\n'.join(current_window)
|
|
|
|
| 1639 |
end_turn=line_num - 1,
|
| 1640 |
token_count=current_tokens
|
| 1641 |
))
|
|
|
|
| 1642 |
tracer.log_window(
|
| 1643 |
window_id=window_id,
|
| 1644 |
content=window_content,
|
|
|
|
| 1647 |
end_turn=line_num - 1
|
| 1648 |
)
|
| 1649 |
window_id += 1
|
| 1650 |
+
|
| 1651 |
+
# Start new window with overlap (carry cached counts too)
|
| 1652 |
+
if len(current_window) >= overlap_turns:
|
| 1653 |
+
overlap_lines = current_window[-overlap_turns:]
|
| 1654 |
+
overlap_tokens = current_window_tokens[-overlap_turns:]
|
| 1655 |
+
else:
|
| 1656 |
+
overlap_lines = current_window
|
| 1657 |
+
overlap_tokens = current_window_tokens
|
| 1658 |
current_window = overlap_lines + [line]
|
| 1659 |
+
current_window_tokens = overlap_tokens + [line_tokens]
|
| 1660 |
+
current_tokens = sum(current_window_tokens)
|
| 1661 |
else:
|
| 1662 |
current_window.append(line)
|
| 1663 |
+
current_window_tokens.append(line_tokens)
|
| 1664 |
current_tokens += line_tokens
|
| 1665 |
+
|
| 1666 |
# Add final window
|
| 1667 |
if current_window:
|
| 1668 |
window_content = '\n'.join(current_window)
|
| 1669 |
windows.append(Window(
|
| 1670 |
id=window_id,
|
| 1671 |
content=window_content,
|
| 1672 |
+
start_turn=n_lines - len(current_window),
|
| 1673 |
+
end_turn=n_lines - 1,
|
| 1674 |
token_count=current_tokens
|
| 1675 |
))
|
|
|
|
| 1676 |
tracer.log_window(
|
| 1677 |
window_id=window_id,
|
| 1678 |
content=window_content,
|
| 1679 |
token_count=current_tokens,
|
| 1680 |
+
start_turn=n_lines - len(current_window),
|
| 1681 |
+
end_turn=n_lines - 1
|
| 1682 |
)
|
| 1683 |
+
|
| 1684 |
total_windows = len(windows)
|
| 1685 |
+
# Empty or pure-noise input -> nothing to summarize. Free the extraction
|
| 1686 |
+
# model and surface a clear message instead of an opaque empty summary.
|
| 1687 |
+
if not windows:
|
| 1688 |
+
unload_model(extraction_llm, "extraction model")
|
| 1689 |
+
extraction_llm = None
|
| 1690 |
+
yield {
|
| 1691 |
+
"stage": "error", "ticker": "", "thinking": "", "summary": "",
|
| 1692 |
+
"error": "No meaningful content found after preprocessing. The input may be empty, whitespace-only, or pure ASR-hallucination noise.",
|
| 1693 |
+
}
|
| 1694 |
+
return
|
| 1695 |
yield {"stage": "extraction", "ticker": f"Created {total_windows} windows", "thinking": "", "summary": ""}
|
| 1696 |
|
| 1697 |
# Extract from each window
|
|
|
|
| 1768 |
}
|
| 1769 |
|
| 1770 |
# ===== STAGE 3: SYNTHESIS =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1771 |
final_summary = ""
|
| 1772 |
final_thinking = ""
|
| 1773 |
+
synthesis_degraded = False
|
| 1774 |
+
try:
|
| 1775 |
+
yield {"stage": "synthesis", "ticker": "", "thinking": "Loading synthesis model...", "summary": ""}
|
| 1776 |
+
|
| 1777 |
+
synthesis_llm, load_msg = load_model_for_role(
|
| 1778 |
+
model_key=synthesis_model_key,
|
| 1779 |
+
model_role="synthesis",
|
| 1780 |
+
n_threads=n_threads
|
| 1781 |
+
)
|
| 1782 |
+
|
| 1783 |
+
yield {"stage": "synthesis", "ticker": "", "thinking": f"✅ {load_msg}", "summary": ""}
|
| 1784 |
+
|
| 1785 |
+
# Synthesize
|
| 1786 |
+
# Shallow-copy so reassigning inference_settings below does not mutate
|
| 1787 |
+
# (and permanently corrupt) the shared global SYNTHESIS_MODELS entry.
|
| 1788 |
+
synthesis_config = dict(get_model_config(synthesis_model_key, "synthesis"))
|
| 1789 |
+
# Override inference settings with custom parameters
|
| 1790 |
+
synthesis_config["inference_settings"] = {
|
| 1791 |
+
"temperature": temperature,
|
| 1792 |
+
"top_p": top_p,
|
| 1793 |
+
"top_k": top_k,
|
| 1794 |
+
"repeat_penalty": 1.1
|
| 1795 |
+
}
|
| 1796 |
+
|
| 1797 |
+
for summary_chunk, thinking_chunk, is_complete in stream_synthesize_executive_summary(
|
| 1798 |
+
synthesis_llm=synthesis_llm,
|
| 1799 |
+
deduplicated_items=deduplicated_items,
|
| 1800 |
+
model_config=synthesis_config,
|
| 1801 |
+
output_language=output_language,
|
| 1802 |
+
enable_reasoning=enable_synthesis_reasoning,
|
| 1803 |
+
max_tokens=max_tokens,
|
| 1804 |
+
tracer=tracer
|
| 1805 |
+
):
|
| 1806 |
+
final_summary = summary_chunk
|
| 1807 |
+
final_thinking = thinking_chunk
|
| 1808 |
+
yield {"stage": "synthesis", "ticker": "", "thinking": thinking_chunk, "summary": summary_chunk}
|
| 1809 |
+
|
| 1810 |
+
# Unload synthesis model
|
| 1811 |
+
unload_model(synthesis_llm, "synthesis model")
|
| 1812 |
+
synthesis_llm = None
|
| 1813 |
+
except Exception as syn_err:
|
| 1814 |
+
# Synthesis failed (e.g. model load timeout/OOM). Don't throw away the
|
| 1815 |
+
# minutes already spent extracting+deduplicating — degrade to a plain
|
| 1816 |
+
# bulleted list of the deduplicated items so the user still gets value.
|
| 1817 |
+
logger.error(f"Synthesis unavailable, degrading to bulleted list: {syn_err}", exc_info=True)
|
| 1818 |
+
synthesis_degraded = True
|
| 1819 |
+
if synthesis_llm:
|
| 1820 |
+
unload_model(synthesis_llm, "synthesis model")
|
| 1821 |
+
synthesis_llm = None
|
| 1822 |
+
labels = ({"action_items": "行動項目", "decisions": "決策",
|
| 1823 |
+
"key_points": "關鍵要點", "open_questions": "未解決問題"}
|
| 1824 |
+
if output_language == "zh-TW" else
|
| 1825 |
+
{"action_items": "Action Items", "decisions": "Decisions",
|
| 1826 |
+
"key_points": "Key Points", "open_questions": "Open Questions"})
|
| 1827 |
+
header = ("**摘要(合成模型暫時無法載入,以下為擷取項目)**"
|
| 1828 |
+
if output_language == "zh-TW" else
|
| 1829 |
+
"**Summary (synthesis model unavailable — showing extracted items)**")
|
| 1830 |
+
parts = [header]
|
| 1831 |
+
for cat, items in deduplicated_items.items():
|
| 1832 |
+
if items:
|
| 1833 |
+
parts.append(f"\n**{labels[cat]}**")
|
| 1834 |
+
parts.extend(f"- {it}" for it in items)
|
| 1835 |
+
final_summary = "\n".join(parts)
|
| 1836 |
+
final_thinking = ""
|
| 1837 |
+
|
| 1838 |
+
|
| 1839 |
# Apply Chinese conversion if needed
|
| 1840 |
if output_language == "zh-TW":
|
| 1841 |
converter = OpenCC('s2twp')
|
|
|
|
| 2047 |
supports_reasoning = model_config.get("supports_reasoning", False)
|
| 2048 |
|
| 2049 |
if supports_reasoning:
|
| 2050 |
+
# Add bounded headroom for the thinking block. Capped at +1024 tokens so
|
| 2051 |
+
# opting into reasoning can't balloon CPU generation time on the free tier
|
| 2052 |
+
# (every extra 1024 tokens is tens of seconds at single-digit tok/s).
|
| 2053 |
+
thinking_headroom = min(int(max_tokens * 0.25), 1024)
|
| 2054 |
effective_max = max_tokens + thinking_headroom
|
| 2055 |
logger.info(f"Reasoning enabled for {model_key}: extending max_tokens from {max_tokens} to {effective_max}")
|
| 2056 |
return effective_max
|
| 2057 |
+
|
| 2058 |
return max_tokens
|
| 2059 |
|
| 2060 |
|
|
|
|
| 2274 |
path = file_obj.name if hasattr(file_obj, 'name') else file_obj
|
| 2275 |
source_name = os.path.basename(path)
|
| 2276 |
source_size = os.path.getsize(path)
|
| 2277 |
+
with open(path, 'r', encoding='utf-8', errors='replace') as f:
|
| 2278 |
transcript = f.read()
|
| 2279 |
else:
|
| 2280 |
system_prompt_preview = build_system_prompt(output_language, False, enable_reasoning)
|
|
|
|
| 2297 |
yield ("", "Error: File is empty", "", metrics, system_prompt_preview)
|
| 2298 |
return
|
| 2299 |
|
| 2300 |
+
# Calculate context and check truncation. max_tokens already includes the
|
| 2301 |
+
# thinking headroom from calculate_effective_max_tokens(), so pass
|
| 2302 |
+
# enable_reasoning=False here to avoid reserving the buffer twice.
|
| 2303 |
+
n_ctx, warning = calculate_n_ctx(model_key, transcript, max_tokens, enable_reasoning=False)
|
| 2304 |
metrics["n_ctx"] = n_ctx
|
| 2305 |
|
| 2306 |
# Truncate if needed (estimate max chars from available tokens)
|
|
|
|
| 2362 |
logger.info(load_msg)
|
| 2363 |
metrics["model_load_time_ms"] = (time.time() - model_load_start) * 1000
|
| 2364 |
except Exception as e:
|
| 2365 |
+
# load_model already raises a user-friendly RuntimeError; show it as-is.
|
| 2366 |
system_prompt_preview = build_system_prompt(output_language, False, enable_reasoning)
|
| 2367 |
+
yield ("", f"❌ {e}", "", metrics, system_prompt_preview)
|
| 2368 |
return
|
| 2369 |
+
|
| 2370 |
+
# OpenCC converter is normally initialised inside load_model(); the custom_hf
|
| 2371 |
+
# path skips load_model, so lazily init here to avoid a NoneType crash on the
|
| 2372 |
+
# first zh-TW token. converter is in this function's `global` declaration.
|
| 2373 |
+
if output_language == "zh-TW" and converter is None:
|
| 2374 |
+
converter = OpenCC('s2twp')
|
| 2375 |
+
|
| 2376 |
# Prepare system prompt with reasoning toggle for Qwen3 models
|
| 2377 |
if model_key == "custom_hf":
|
| 2378 |
# Use default settings for custom models
|
|
|
|
| 2439 |
messages=messages,
|
| 2440 |
max_tokens=max_tokens,
|
| 2441 |
temperature=effective_temperature,
|
| 2442 |
+
min_p=inference_settings.get("min_p", 0.0),
|
| 2443 |
top_p=final_top_p,
|
| 2444 |
top_k=final_top_k,
|
| 2445 |
repeat_penalty=repeat_penalty,
|
|
|
|
| 2448 |
|
| 2449 |
metrics["generation_start_time"] = time.time()
|
| 2450 |
|
| 2451 |
+
finish_reason = None
|
| 2452 |
+
last_emit = 0.0
|
| 2453 |
for chunk in stream:
|
| 2454 |
if 'choices' in chunk and len(chunk['choices']) > 0:
|
| 2455 |
+
choice = chunk['choices'][0]
|
| 2456 |
+
if choice.get('finish_reason'):
|
| 2457 |
+
finish_reason = choice['finish_reason']
|
| 2458 |
+
delta = choice.get('delta', {})
|
| 2459 |
content = delta.get('content', '')
|
| 2460 |
if content:
|
| 2461 |
# Track time to first token
|
|
|
|
| 2465 |
|
| 2466 |
token_count += 1
|
| 2467 |
|
| 2468 |
+
# OpenCC must run per fragment (phrase rules aren't associative
|
| 2469 |
+
# across fragment boundaries), so keep conversion per-token. Only
|
| 2470 |
+
# the regex parse + websocket yield are throttled (every 8 tokens
|
| 2471 |
+
# or 100ms) to stop the hot loop from going O(n^2) on long output.
|
| 2472 |
if output_language == "zh-TW":
|
| 2473 |
+
full_response += converter.convert(content)
|
|
|
|
| 2474 |
else:
|
| 2475 |
full_response += content
|
| 2476 |
|
| 2477 |
+
now = time.time()
|
| 2478 |
+
if token_count % 8 == 0 or (now - last_emit) >= 0.10:
|
| 2479 |
+
last_emit = now
|
| 2480 |
+
thinking, summary = parse_thinking_blocks(full_response, streaming=True)
|
| 2481 |
+
current_thinking = thinking or ""
|
| 2482 |
+
current_summary = summary or ""
|
| 2483 |
+
yield (current_thinking, current_summary, info, metrics, system_content)
|
| 2484 |
|
| 2485 |
# Final timing calculations
|
| 2486 |
metrics["generation_end_time"] = time.time()
|
|
|
|
| 2511 |
# Final parse and token counts
|
| 2512 |
thinking, summary = parse_thinking_blocks(full_response)
|
| 2513 |
|
| 2514 |
+
# Calculate output tokens (on the model's real output, before any note)
|
| 2515 |
metrics["output_tokens"] = estimate_tokens(summary) if summary else 0
|
| 2516 |
metrics["thinking_tokens"] = estimate_tokens(thinking) if thinking else 0
|
| 2517 |
|
| 2518 |
# Update totals
|
| 2519 |
metrics["total_tokens"] = metrics["input_tokens"] + metrics["output_tokens"] + metrics["thinking_tokens"]
|
| 2520 |
+
metrics["output_truncated"] = (finish_reason == "length")
|
| 2521 |
+
|
| 2522 |
+
# If generation was cut off by the max-tokens limit, say so rather than
|
| 2523 |
+
# presenting a half-finished summary as if it were complete.
|
| 2524 |
+
if finish_reason == "length":
|
| 2525 |
+
note = ("\n\n> ⚠️ Output truncated — generation hit the max-tokens limit. "
|
| 2526 |
+
"Increase **Max Output Tokens** for a complete summary.")
|
| 2527 |
+
summary = (summary + note) if summary else note.strip()
|
| 2528 |
|
| 2529 |
yield (thinking or "", summary or "", info, metrics, system_content)
|
| 2530 |
|
|
|
|
| 2593 |
width: 200%;
|
| 2594 |
height: 200%;
|
| 2595 |
background: radial-gradient(circle, rgba(255,255,255,0.1) 0%, transparent 60%);
|
| 2596 |
+
/* Static glow — the previous `animation: rotate 20s linear infinite` forced
|
| 2597 |
+
a perpetual repaint of this oversized element, burning client CPU/battery
|
| 2598 |
+
the entire time the page was open (incl. while reading output). */
|
|
|
|
|
|
|
|
|
|
| 2599 |
}
|
| 2600 |
|
| 2601 |
.app-header h1 {
|
|
|
|
| 2796 |
}
|
| 2797 |
}
|
| 2798 |
|
| 2799 |
+
/* ===== FOOTER ===== */
|
| 2800 |
+
.footer {
|
| 2801 |
+
text-align: center;
|
| 2802 |
+
color: var(--text-muted);
|
| 2803 |
+
font-size: 0.85rem;
|
| 2804 |
+
padding: 1.5rem 0;
|
| 2805 |
+
line-height: 1.6;
|
| 2806 |
}
|
| 2807 |
|
| 2808 |
+
/* Tab labels a touch larger for the Standard/Advanced mode switch */
|
| 2809 |
+
.gradio-container .tab-nav button {
|
| 2810 |
+
font-size: 1rem;
|
| 2811 |
+
font-weight: 600;
|
|
|
|
| 2812 |
}
|
| 2813 |
"""
|
| 2814 |
|
|
|
|
| 2819 |
"""Create and configure the Gradio interface."""
|
| 2820 |
|
| 2821 |
with gr.Blocks(
|
| 2822 |
+
title="Tiny Scribe - AI Transcript Summarizer",
|
| 2823 |
+
css=custom_css,
|
| 2824 |
+
theme=gr.themes.Soft(primary_hue="indigo"),
|
| 2825 |
) as demo:
|
| 2826 |
|
| 2827 |
# Header section (simplified - no Row/Column wrapper needed for full-width)
|
|
|
|
| 2830 |
<h1>📄 Tiny Scribe</h1>
|
| 2831 |
<p>AI-Powered Transcript Summarization with Real-Time Streaming</p>
|
| 2832 |
<div class="model-badge">
|
| 2833 |
+
<span>Runs entirely on free CPU · ⚡ fast · ✅ balanced · 🐢 experimental models</span>
|
| 2834 |
</div>
|
| 2835 |
</div>
|
| 2836 |
""")
|
| 2837 |
+
|
| 2838 |
+
# Instructions — collapsed, mode-agnostic (no stale left/right framing).
|
| 2839 |
+
with gr.Accordion("ℹ️ How it works", open=False):
|
| 2840 |
+
gr.Markdown(
|
| 2841 |
+
"1. Upload a `.txt` file **or** paste your transcript.\n"
|
| 2842 |
+
"2. Pick the **output language** and a **model** (⚡ fast / ✅ balanced / 🐢 experimental).\n"
|
| 2843 |
+
"3. Click **✨ Generate Summary** — the **Final Summary** streams in real time; "
|
| 2844 |
+
"expand **🧠 Show model reasoning** to watch the model think.\n\n"
|
| 2845 |
+
"*Standard Mode* = one model. *Advanced Mode* = a 3-stage pipeline "
|
| 2846 |
+
"(extract → deduplicate → synthesize) for long, noisy transcripts."
|
| 2847 |
+
)
|
|
|
|
|
|
|
|
|
|
| 2848 |
|
| 2849 |
# Main content area
|
| 2850 |
with gr.Row():
|
|
|
|
| 2884 |
)
|
| 2885 |
|
| 2886 |
# ==========================================
|
| 2887 |
+
# Section 2: Hardware Configuration (Global) — collapsed; the
|
| 2888 |
+
# free-tier default is correct for most users, so it's tucked away.
|
| 2889 |
# ==========================================
|
| 2890 |
+
with gr.Accordion("🖥️ Performance (CPU threads)", open=False):
|
|
|
|
|
|
|
| 2891 |
thread_config_dropdown = gr.Dropdown(
|
| 2892 |
choices=[
|
| 2893 |
("HF Spaces Free Tier (2 vCPUs)", "free"),
|
|
|
|
| 2933 |
|
| 2934 |
# Preset Models Group
|
| 2935 |
with gr.Group(visible=True) as preset_models_group:
|
| 2936 |
+
# Choices grouped by speed tier (⚡ fast / ✅ balanced /
|
| 2937 |
+
# 🐢 experimental) so users can tell what runs fast on CPU.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2938 |
model_dropdown = gr.Dropdown(
|
| 2939 |
+
choices=build_preset_choices(),
|
| 2940 |
value=DEFAULT_MODEL_KEY,
|
| 2941 |
label="Select Model",
|
| 2942 |
+
info="⚡ fast (<1B) · ✅ balanced (1–3B) · 🐢 experimental (slow on free CPU)"
|
| 2943 |
)
|
| 2944 |
+
|
| 2945 |
+
# Default OFF: thinking mode multiplies CPU generation time;
|
| 2946 |
+
# it's opt-in. update_reasoning_visibility() manages this per
|
| 2947 |
+
# model (hidden / always-on / toggleable).
|
| 2948 |
enable_reasoning = gr.Checkbox(
|
| 2949 |
+
value=False,
|
| 2950 |
label="Enable Reasoning Mode",
|
| 2951 |
info="Uses /think for deeper analysis (slower) or /no_think for direct output (faster).",
|
| 2952 |
interactive=True,
|
|
|
|
| 2986 |
|
| 2987 |
retry_btn = gr.Button("🔄 Retry", variant="secondary", visible=False)
|
| 2988 |
|
| 2989 |
+
# Inference Parameters (Standard Mode) — collapsed by default;
|
| 2990 |
+
# they auto-populate per model, so most users never touch them.
|
| 2991 |
+
with gr.Accordion("⚙️ Advanced inference settings", open=False):
|
| 2992 |
+
temperature_slider = gr.Slider(
|
| 2993 |
+
minimum=0.0,
|
| 2994 |
+
maximum=2.0,
|
| 2995 |
+
value=0.6,
|
| 2996 |
+
step=0.1,
|
| 2997 |
+
label="Temperature",
|
| 2998 |
+
info="Lower = more focused, Higher = more creative"
|
| 2999 |
+
)
|
| 3000 |
+
max_tokens = gr.Slider(
|
| 3001 |
+
minimum=256,
|
| 3002 |
+
maximum=4096,
|
| 3003 |
+
value=2048,
|
| 3004 |
+
step=256,
|
| 3005 |
+
label="Max Output Tokens",
|
| 3006 |
+
info="Higher = more detailed summary"
|
| 3007 |
+
)
|
| 3008 |
+
top_p = gr.Slider(
|
| 3009 |
+
minimum=0.0,
|
| 3010 |
+
maximum=1.0,
|
| 3011 |
+
value=0.95,
|
| 3012 |
+
step=0.05,
|
| 3013 |
+
label="Top P (Nucleus Sampling)",
|
| 3014 |
+
info="Lower = more focused, Higher = more diverse"
|
| 3015 |
+
)
|
| 3016 |
+
top_k = gr.Slider(
|
| 3017 |
+
minimum=0,
|
| 3018 |
+
maximum=100,
|
| 3019 |
+
value=20,
|
| 3020 |
+
step=5,
|
| 3021 |
+
label="Top K",
|
| 3022 |
+
info="Limits token selection to top K tokens (0 = disabled)"
|
| 3023 |
+
)
|
| 3024 |
+
|
| 3025 |
# ===== ADVANCED MODE =====
|
| 3026 |
with gr.Group(visible=False) as advanced_mode_group:
|
| 3027 |
gr.HTML('<div style="font-size: 0.9em; color: #64748b; margin-bottom: 16px;">🧠 <strong>Advanced Mode (3-Model Pipeline)</strong> - Extraction → Deduplication → Synthesis</div>')
|
|
|
|
| 3038 |
|
| 3039 |
with gr.Row():
|
| 3040 |
extraction_n_ctx = gr.Slider(
|
| 3041 |
+
minimum=4096, # below ~4096 the output reserve leaves no room for a window
|
| 3042 |
maximum=8192,
|
| 3043 |
step=1024,
|
| 3044 |
value=4096,
|
|
|
|
| 3179 |
|
| 3180 |
# Right column - Outputs
|
| 3181 |
with gr.Column(scale=2):
|
| 3182 |
+
# ===== Final Summary (the hero output — first/largest) =====
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3183 |
with gr.Group():
|
| 3184 |
gr.HTML('<div class="section-header"><span class="section-icon">📝</span> Final Summary</div>')
|
| 3185 |
summary_output = gr.Markdown(
|
| 3186 |
value="*Your summarized content will appear here...*",
|
| 3187 |
elem_classes=["summary-box"]
|
| 3188 |
)
|
| 3189 |
+
|
| 3190 |
# Action buttons for summary
|
| 3191 |
with gr.Row():
|
| 3192 |
copy_summary_btn = gr.Button("📋 Copy Summary", size="sm")
|
| 3193 |
download_btn = gr.Button("⬇️ Download (JSON)", size="sm")
|
| 3194 |
+
|
| 3195 |
# File output component for download (hidden until generated)
|
| 3196 |
download_output = gr.File(label="Download JSON", visible=False)
|
| 3197 |
+
|
| 3198 |
+
# Completion Metrics (compact, below the summary)
|
| 3199 |
with gr.Group():
|
| 3200 |
gr.HTML('<div class="section-header"><span class="section-icon">📊</span> Generation Metrics</div>')
|
| 3201 |
info_output = gr.Markdown(
|
| 3202 |
value="*Metrics will appear here after generation...*",
|
| 3203 |
elem_classes=["completion-info"]
|
| 3204 |
)
|
| 3205 |
+
|
| 3206 |
+
# Model reasoning — supporting detail, collapsed by default so the
|
| 3207 |
+
# summary stays the hero. Streams live when reasoning is enabled.
|
| 3208 |
+
with gr.Accordion("🧠 Show model reasoning", open=False):
|
| 3209 |
+
thinking_output = gr.Textbox(
|
| 3210 |
+
label="",
|
| 3211 |
+
lines=12,
|
| 3212 |
+
max_lines=20,
|
| 3213 |
+
show_label=False,
|
| 3214 |
+
placeholder="The AI's reasoning process will appear here in real-time...",
|
| 3215 |
+
elem_classes=["thinking-box"]
|
| 3216 |
+
)
|
| 3217 |
+
copy_thinking_btn = gr.Button("📋 Copy Thinking", size="sm")
|
| 3218 |
+
|
| 3219 |
+
# Model details — specs/settings, collapsed by default.
|
| 3220 |
+
with gr.Accordion("📊 Model details", open=False):
|
| 3221 |
+
_default_threads = DEFAULT_CUSTOM_THREADS if DEFAULT_CUSTOM_THREADS > 0 else 2
|
| 3222 |
+
_default_info = get_model_info(DEFAULT_MODEL_KEY, n_threads=_default_threads)[0]
|
| 3223 |
+
model_info_output = gr.Markdown(
|
| 3224 |
+
value=_default_info,
|
| 3225 |
+
elem_classes=["info-box"]
|
| 3226 |
+
)
|
| 3227 |
|
| 3228 |
# Function to update settings when model changes
|
| 3229 |
def update_settings_on_model_change(model_key, thread_config, custom_threads, custom_metadata=None):
|
|
|
|
| 3641 |
adv_max_tokens_val, enable_logging_val,
|
| 3642 |
adv_temperature_val, adv_top_p_val, adv_top_k_val,
|
| 3643 |
# Mode selector
|
| 3644 |
+
mode_radio_val,
|
| 3645 |
+
# Standard-mode model source (Preset vs Custom GGUF)
|
| 3646 |
+
model_source_val,
|
| 3647 |
):
|
| 3648 |
"""Route to Standard or Advanced mode based on selected mode radio button."""
|
| 3649 |
|
|
|
|
| 3659 |
# Get transcript
|
| 3660 |
transcript = ""
|
| 3661 |
if file_input_val:
|
| 3662 |
+
if not os.path.exists(file_input_val):
|
| 3663 |
+
yield ("", "⚠️ Uploaded file is no longer available. Please re-upload.", "", {}, "")
|
| 3664 |
+
return
|
| 3665 |
+
try:
|
| 3666 |
+
with open(file_input_val, 'r', encoding='utf-8', errors='replace') as f:
|
| 3667 |
+
transcript = f.read()
|
| 3668 |
+
except Exception as e:
|
| 3669 |
+
yield ("", f"⚠️ Could not read file (please save it as a UTF-8 .txt). Details: {e}", "", {}, "")
|
| 3670 |
+
return
|
| 3671 |
elif text_input_val:
|
| 3672 |
transcript = text_input_val
|
| 3673 |
else:
|
|
|
|
| 3741 |
return
|
| 3742 |
|
| 3743 |
else:
|
| 3744 |
+
# Standard Mode. If the user is on the Custom GGUF source and has
|
| 3745 |
+
# actually loaded a model, route to the custom_hf path — otherwise
|
| 3746 |
+
# the loaded custom model is silently ignored and a preset runs.
|
| 3747 |
+
effective_model_key = model_dropdown_val
|
| 3748 |
+
if model_source_val == "Custom GGUF" and custom_model_val is not None:
|
| 3749 |
+
effective_model_key = "custom_hf"
|
| 3750 |
for thinking, summary, info, metrics, system_prompt in summarize_streaming(
|
| 3751 |
+
file_input_val, text_input_val, effective_model_key, enable_reasoning_val,
|
| 3752 |
max_tokens_val, temperature_val, top_p_val, top_k_val, language_val,
|
| 3753 |
thread_config_val, custom_threads_val, custom_model_val
|
| 3754 |
):
|
| 3755 |
yield (thinking, summary, info, metrics, system_prompt)
|
| 3756 |
|
| 3757 |
# Wire up submit button with router
|
| 3758 |
+
# Disable the button immediately (queue=False so it fires even while the
|
| 3759 |
+
# queue is busy) -> run generation (serialized via concurrency_id so two
|
| 3760 |
+
# clicks never contend for the shared global llm on 2 vCPU) -> re-enable.
|
| 3761 |
submit_btn.click(
|
| 3762 |
+
fn=lambda: gr.update(interactive=False, value="⏳ Generating…"),
|
| 3763 |
+
inputs=None,
|
| 3764 |
+
outputs=[submit_btn],
|
| 3765 |
+
queue=False,
|
| 3766 |
+
).then(
|
| 3767 |
fn=route_summarize,
|
| 3768 |
inputs=[
|
| 3769 |
# Standard mode inputs
|
|
|
|
| 3777 |
adv_max_tokens, enable_detailed_logging,
|
| 3778 |
adv_temperature_slider, adv_top_p, adv_top_k,
|
| 3779 |
# Mode selector
|
| 3780 |
+
mode_radio,
|
| 3781 |
+
# Standard-mode model source (Preset vs Custom GGUF)
|
| 3782 |
+
model_source_radio,
|
| 3783 |
],
|
| 3784 |
outputs=[thinking_output, summary_output, info_output, metrics_state, system_prompt_debug],
|
| 3785 |
+
concurrency_id="summarize",
|
| 3786 |
+
concurrency_limit=1,
|
| 3787 |
+
show_progress="full",
|
| 3788 |
+
).then(
|
| 3789 |
+
fn=lambda: gr.update(interactive=True, value="✨ Generate Summary"),
|
| 3790 |
+
inputs=None,
|
| 3791 |
+
outputs=[submit_btn],
|
| 3792 |
+
queue=False,
|
| 3793 |
)
|
| 3794 |
|
| 3795 |
# Footer
|
|
|
|
| 3810 |
|
| 3811 |
# Create and launch interface
|
| 3812 |
demo = create_interface()
|
| 3813 |
+
|
| 3814 |
+
# Serialize generation: default_concurrency_limit=1 guarantees the summarize
|
| 3815 |
+
# generator never runs concurrently against the shared global llm singleton.
|
| 3816 |
+
demo.queue(max_size=8, default_concurrency_limit=1)
|
| 3817 |
demo.launch(
|
| 3818 |
server_name="0.0.0.0",
|
| 3819 |
server_port=7860,
|
|
@@ -376,13 +376,36 @@ class EmbeddingModel:
|
|
| 376 |
|
| 377 |
# Get embedding
|
| 378 |
embedding = self.llm.embed(text)
|
| 379 |
-
|
| 380 |
# Normalize vector
|
| 381 |
norm = np.linalg.norm(embedding)
|
| 382 |
if norm > 0:
|
| 383 |
embedding = embedding / norm
|
| 384 |
-
|
| 385 |
return embedding
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 386 |
|
| 387 |
def unload(self) -> None:
|
| 388 |
"""Unload model and free memory."""
|
|
@@ -390,10 +413,9 @@ class EmbeddingModel:
|
|
| 390 |
logger.info(f"Unloading embedding model: {self.config['name']}")
|
| 391 |
del self.llm
|
| 392 |
self.llm = None
|
| 393 |
-
|
| 394 |
import gc
|
| 395 |
gc.collect()
|
| 396 |
-
time.sleep(0.5)
|
| 397 |
|
| 398 |
|
| 399 |
# ===== HELPER FUNCTIONS =====
|
|
@@ -843,7 +865,11 @@ def stream_extract_from_window(
|
|
| 843 |
start_time = time.time()
|
| 844 |
first_token_time = None
|
| 845 |
token_count = 0
|
| 846 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 847 |
try:
|
| 848 |
max_gen_tokens = max(2048, window.token_count // 2)
|
| 849 |
settings = model_config["inference_settings"].copy()
|
|
@@ -875,24 +901,26 @@ def stream_extract_from_window(
|
|
| 875 |
|
| 876 |
token_count += 1
|
| 877 |
full_response += content
|
| 878 |
-
|
| 879 |
-
#
|
| 880 |
-
|
| 881 |
-
|
| 882 |
-
|
| 883 |
-
|
| 884 |
-
|
| 885 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 886 |
else:
|
| 887 |
json_text = full_response
|
| 888 |
-
|
| 889 |
-
|
| 890 |
-
|
| 891 |
-
|
| 892 |
-
partial_items = _try_parse_extraction_json(json_text)
|
| 893 |
-
if not partial_items:
|
| 894 |
-
partial_items = {"action_items": [], "decisions": [], "key_points": [], "open_questions": []}
|
| 895 |
-
|
| 896 |
# Calculate metrics
|
| 897 |
elapsed = time.time() - start_time
|
| 898 |
tps = token_count / elapsed if elapsed > 0 else 0
|
|
@@ -1073,12 +1101,10 @@ def deduplicate_items(
|
|
| 1073 |
|
| 1074 |
original_count = len(items)
|
| 1075 |
|
| 1076 |
-
# Compute embeddings for all items
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
embeddings.append(emb)
|
| 1081 |
-
|
| 1082 |
# Mark duplicates and track duplicate groups
|
| 1083 |
keep_indices = []
|
| 1084 |
duplicate_groups = []
|
|
|
|
| 376 |
|
| 377 |
# Get embedding
|
| 378 |
embedding = self.llm.embed(text)
|
| 379 |
+
|
| 380 |
# Normalize vector
|
| 381 |
norm = np.linalg.norm(embedding)
|
| 382 |
if norm > 0:
|
| 383 |
embedding = embedding / norm
|
| 384 |
+
|
| 385 |
return embedding
|
| 386 |
+
|
| 387 |
+
def embed_batch(self, texts: List[str]) -> List[np.ndarray]:
|
| 388 |
+
"""Embed many texts in a single llama.cpp call.
|
| 389 |
+
|
| 390 |
+
Each text is independently truncated (and the model truncates to its
|
| 391 |
+
context internally), so this avoids the per-item Python/FFI round trips
|
| 392 |
+
of calling embed() in a loop — the costly part of dedup on CPU.
|
| 393 |
+
"""
|
| 394 |
+
if self.llm is None:
|
| 395 |
+
raise RuntimeError("Model not loaded. Call load() first.")
|
| 396 |
+
if not texts:
|
| 397 |
+
return []
|
| 398 |
+
max_chars = self.config["max_context"] * 4
|
| 399 |
+
truncated = [t[:max_chars] for t in texts]
|
| 400 |
+
raw = self.llm.embed(truncated) # List[List[float]]
|
| 401 |
+
out = []
|
| 402 |
+
for emb in raw:
|
| 403 |
+
arr = np.asarray(emb, dtype=np.float32)
|
| 404 |
+
norm = np.linalg.norm(arr)
|
| 405 |
+
if norm > 0:
|
| 406 |
+
arr = arr / norm
|
| 407 |
+
out.append(arr)
|
| 408 |
+
return out
|
| 409 |
|
| 410 |
def unload(self) -> None:
|
| 411 |
"""Unload model and free memory."""
|
|
|
|
| 413 |
logger.info(f"Unloading embedding model: {self.config['name']}")
|
| 414 |
del self.llm
|
| 415 |
self.llm = None
|
| 416 |
+
|
| 417 |
import gc
|
| 418 |
gc.collect()
|
|
|
|
| 419 |
|
| 420 |
|
| 421 |
# ===== HELPER FUNCTIONS =====
|
|
|
|
| 865 |
start_time = time.time()
|
| 866 |
first_token_time = None
|
| 867 |
token_count = 0
|
| 868 |
+
# Cached partial parse, recomputed on an interval (not every token) so the
|
| 869 |
+
# streaming loop doesn't re-parse the whole JSON O(n^2) times on CPU.
|
| 870 |
+
partial_items = {"action_items": [], "decisions": [], "key_points": [], "open_questions": []}
|
| 871 |
+
last_parse_t = 0.0
|
| 872 |
+
|
| 873 |
try:
|
| 874 |
max_gen_tokens = max(2048, window.token_count // 2)
|
| 875 |
settings = model_config["inference_settings"].copy()
|
|
|
|
| 901 |
|
| 902 |
token_count += 1
|
| 903 |
full_response += content
|
| 904 |
+
|
| 905 |
+
# Recompute the (expensive) thinking-regex + JSON parse at
|
| 906 |
+
# most ~2x/sec; the ticker below still updates every token,
|
| 907 |
+
# so the UI stays responsive without the O(n^2) cost.
|
| 908 |
+
now = time.time()
|
| 909 |
+
if now - last_parse_t >= 0.5:
|
| 910 |
+
last_parse_t = now
|
| 911 |
+
if enable_reasoning and supports_reasoning:
|
| 912 |
+
thinking_match = re.search(r'<think(?:ing)?>(.*?)</think(?:ing)?>', full_response, re.DOTALL)
|
| 913 |
+
if thinking_match:
|
| 914 |
+
thinking_content = thinking_match.group(1).strip()
|
| 915 |
+
json_text = full_response[:thinking_match.start()] + full_response[thinking_match.end():]
|
| 916 |
+
else:
|
| 917 |
+
json_text = full_response
|
| 918 |
else:
|
| 919 |
json_text = full_response
|
| 920 |
+
parsed = _try_parse_extraction_json(json_text)
|
| 921 |
+
if parsed:
|
| 922 |
+
partial_items = parsed
|
| 923 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
# Calculate metrics
|
| 925 |
elapsed = time.time() - start_time
|
| 926 |
tps = token_count / elapsed if elapsed > 0 else 0
|
|
|
|
| 1101 |
|
| 1102 |
original_count = len(items)
|
| 1103 |
|
| 1104 |
+
# Compute embeddings for all items in one batched call (CPU-friendlier
|
| 1105 |
+
# than a per-item Python loop).
|
| 1106 |
+
embeddings = embedding_model.embed_batch(items)
|
| 1107 |
+
|
|
|
|
|
|
|
| 1108 |
# Mark duplicates and track duplicate groups
|
| 1109 |
keep_indices = []
|
| 1110 |
duplicate_groups = []
|
|
@@ -22,8 +22,8 @@ def load_model(repo_id, filename, cpu_only=False):
|
|
| 22 |
seed=1337,
|
| 23 |
n_ctx=32768, # Context size
|
| 24 |
verbose=True, # Reduced verbosity for cleaner output
|
| 25 |
-
v_type=2
|
| 26 |
-
|
| 27 |
)
|
| 28 |
|
| 29 |
return llm
|
|
|
|
| 22 |
seed=1337,
|
| 23 |
n_ctx=32768, # Context size
|
| 24 |
verbose=True, # Reduced verbosity for cleaner output
|
| 25 |
+
# (Removed v_type/k_type=2: not real Llama kwargs — correct names are
|
| 26 |
+
# type_k/type_v — so they were silently ignored; KV cache stays f16.)
|
| 27 |
)
|
| 28 |
|
| 29 |
return llm
|