Luigi Claude Opus 4.8 commited on
Commit
4bb47bb
·
1 Parent(s): 9d88146

feat: 2026 model refresh + robustness, CPU-efficiency & UI overhaul

Browse files

Models (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

Files changed (5) hide show
  1. CLAUDE.md +154 -266
  2. Dockerfile +19 -4
  3. app.py +651 -250
  4. meeting_summarizer/extraction.py +53 -27
  5. summarize_transcript.py +2 -2
CLAUDE.md CHANGED
@@ -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 with two interfaces:
8
- 1. **CLI tool** (`summarize_transcript.py`) - Standalone script for local use with SYCL/CPU acceleration
9
- 2. **Gradio web app** (`app.py`) - HuggingFace Spaces deployment with streaming UI
10
 
11
- Both use llama-cpp-python to run GGUF quantized models (Qwen3, ERNIE, Granite, Gemma, etc.) and convert output to Traditional Chinese (zh-TW) via OpenCC.
 
 
 
 
12
 
13
- ## Development Commands
 
 
14
 
15
- ### Running the CLI
 
 
16
 
17
- ```bash
18
- # Basic usage (default model: Qwen3-0.6B Q4_0)
19
- python summarize_transcript.py -i ./transcripts/short.txt
20
 
21
- # Specify model (format: repo_id:quantization)
22
- python summarize_transcript.py -m unsloth/Qwen3-1.7B-GGUF:Q2_K_L
23
 
24
- # Force CPU-only (disable SYCL)
25
- python summarize_transcript.py -c
 
 
 
26
  ```
27
 
28
- ### Running the Gradio App
 
 
 
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
- ### Testing
38
 
39
- No test suite exists in the root project. To test llama-cpp-python submodule:
 
40
 
41
  ```bash
42
- cd llama-cpp-python
43
- pip install ".[test]"
44
- pytest tests/test_llama.py -v
45
 
46
- # Single test
47
- pytest tests/test_llama.py::test_function_name -v
48
  ```
49
 
50
- ### Docker Deployment
51
 
52
  ```bash
53
- # Build locally
54
- docker build -t tiny-scribe .
55
-
56
- # Run
57
- docker run -p 7860:7860 tiny-scribe
58
  ```
59
 
60
- ## Architecture
61
-
62
- ### Two Execution Paths
63
 
64
- **CLI Path:**
65
- ```
66
- User summarize_transcript.py Llama.from_pretrained() GGUF model
67
-
68
- Stream tokens → OpenCC (s2twp) → stdout
69
-
70
- parse_thinking_blocks() → thinking.txt + summary.txt
71
  ```
72
 
73
- **Gradio Path:**
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
- ### Key Differences
85
 
86
- | Feature | CLI (`summarize_transcript.py`) | Gradio (`app.py`) |
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
- ### Model Loading Pattern
 
 
 
98
 
99
- Both scripts use `Llama.from_pretrained()` with HuggingFace Hub integration:
 
100
 
101
- ```python
102
- llm = Llama.from_pretrained(
103
- repo_id="unsloth/Qwen3-0.6B-GGUF",
104
- filename="*Q4_K_M.gguf", # Wildcard for flexible matching
105
- n_gpu_layers=0, # 0=CPU, -1=all layers on GPU
106
- n_ctx=32768, # 32K context window
107
- seed=1337, # Reproducibility
108
- verbose=False, # Reduce log noise
109
- )
110
  ```
111
 
112
- **Important:** Always call `llm.reset()` after each completion to clear KV cache and ensure state isolation.
 
 
 
113
 
114
- ### Streaming Implementation
115
 
116
- The Gradio app (`app.py`) implements real-time streaming with dual outputs:
 
117
 
118
- 1. **Raw stream** `thinking_output` textbox (shows every token as generated)
119
- 2. **Parsed summary** → `summary_output` markdown (extracts content outside `<thinking>` tags)
 
 
 
120
 
121
- Generator pattern:
122
- ```python
123
- def summarize_streaming(...) -> Generator[Tuple[str, str], None, None]:
124
- for chunk in stream:
125
- content = chunk['choices'][0]['delta'].get('content', '')
126
- full_response += content
127
 
128
- # Show all tokens in thinking field
129
- current_thinking += content
 
130
 
131
- # Extract summary (content outside thinking tags)
132
- thinking_blocks, summary = parse_thinking_blocks(full_response)
133
- current_summary = summary
134
 
135
- # Yield both on every token
136
- yield (current_thinking, current_summary)
 
 
 
 
 
 
 
 
137
  ```
138
 
139
- ### Thinking Block Parsing
 
 
140
 
141
- Models may wrap reasoning in special tags that should be separated from final output.
142
 
143
- **Both versions now support both tag formats:**
144
- - `<think>reasoning</think>` (common with Qwen models)
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
- The Gradio app also handles streaming mode with unclosed `<think>` tags for real-time display.
157
-
158
- ### Qwen3 Thinking Mode
159
 
160
- Qwen3 models support a special "thinking mode" that generates `<think>...</think>` blocks for reasoning before the final answer.
 
 
 
 
161
 
162
- **Implementation (llama.cpp/llama-cpp-python):**
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
- **Official Recommended Settings (from Unsloth):**
 
 
168
 
169
- | Setting | Non-Thinking Mode | Thinking Mode |
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
- **Important Notes:**
177
- - **DO NOT use greedy decoding** in thinking mode (causes endless repetitions)
178
- - In thinking mode, model generates `<think>...</think>` block before final answer
179
- - For non-thinking mode, empty `<think></think>` tags are purposely used
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
- **Current Implementation:**
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
- ### Chinese Text Conversion
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
- Applied token-by-token during streaming to maintain real-time display.
 
 
 
 
198
 
199
- ## HuggingFace Spaces Deployment
200
 
201
- The Gradio app is optimized for HF Spaces Free Tier (2 vCPUs):
 
 
 
202
 
203
- - **Models**: 10 models available (100M to 1.7B parameters), default: Qwen3-0.6B Q4_K_M (~400MB)
204
- - **Dockerfile**: Uses prebuilt llama-cpp-python wheel (skips 10-min compilation)
205
- - **Context limits**: Per-model context windows (32K to 262K tokens), capped at 32K for CPU performance
206
 
207
- See `DEPLOY.md` for full deployment instructions.
208
 
209
- ### Deployment Workflow
 
 
 
210
 
211
- The `deploy.sh` script ensures meaningful commit messages:
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
- This reduces build time from 10+ minutes to ~2 minutes.
 
 
 
235
 
236
- ## Git Submodule
237
 
238
- The `llama-cpp-python/` directory is a Git submodule tracking upstream development:
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
- ## Model Format
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
- Note: HF Spaces Free Tier has no GPU access.
 
 
 
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.
Dockerfile CHANGED
@@ -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 build needed!)
13
- RUN pip install --no-cache-dir \
14
- 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
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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-02-01-v1
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"]
app.py CHANGED
@@ -484,18 +484,21 @@ AVAILABLE_MODELS = {
484
  },
485
  },
486
  "lfm2_2_6b_transcript": {
487
- "name": "LFM2 2.6B Transcript (32K Context)",
488
- "repo_id": "LiquidAI/LFM-2.6B-Transcript-GGUF",
 
 
489
  "filename": "*Q4_0.gguf",
490
  "max_context": 32768,
491
- "default_temperature": 0.6,
492
  "supports_reasoning": False,
493
  "supports_toggle": False,
494
  "inference_settings": {
495
- "temperature": 0.6,
496
- "top_p": 0.95,
497
- "top_k": 20,
498
- "repeat_penalty": 1.1,
 
499
  },
500
  },
501
  "breeze_3b_q4": {
@@ -544,9 +547,12 @@ AVAILABLE_MODELS = {
544
  },
545
  },
546
  "granite4_tiny_q3": {
547
- "name": "Granite 4.0 Tiny 7B (128K Context)",
548
- "repo_id": "ibm-research/granite-4.0-Tiny-7B-Instruct-GGUF",
549
- "filename": "*Q3_K_M.gguf",
 
 
 
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
- "name": "LFM2 2.6B Transcript (32K Context)",
775
- "repo_id": "LiquidAI/LFM-2.6B-Transcript-GGUF",
 
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
- "name": "Granite 4.0 Tiny 7B (128K Context)",
831
- "repo_id": "ibm-research/granite-4.0-Tiny-7B-Instruct-GGUF",
832
- "filename": "*Q3_K_M.gguf",
 
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
- n_batch=min(2048, n_ctx), # Batch size for throughput
 
 
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
- k_type=2,
 
 
 
 
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
- logger.error(f"Error loading model: {e}")
1009
- raise
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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, label="Enable Reasoning Mode")
 
 
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, label="⚡ Reasoning Mode (Always On)")
 
 
1033
  else:
1034
- # Hybrid model: show, toggleable
1035
- return gr.update(visible=True, value=True, interactive=True, label="Enable Reasoning Mode")
 
 
 
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
- llm = Llama.from_pretrained(
1132
- repo_id=config["repo_id"],
1133
- filename=config["filename"],
1134
- n_ctx=n_ctx,
1135
- n_batch=min(2048, n_ctx),
1136
- n_threads=n_threads,
1137
- n_threads_batch=n_threads,
1138
- n_gpu_layers=n_gpu_layers,
1139
- verbose=False,
1140
- seed=1337,
1141
- )
1142
-
1143
- info_msg = (
1144
- f"✅ Loaded: {config['name']} for {model_role} "
1145
- f"(n_ctx={n_ctx:,}, threads={n_threads})"
1146
- )
1147
- logger.info(info_msg)
1148
-
1149
- return llm, info_msg
1150
-
 
 
 
 
 
 
 
 
 
 
 
1151
  except Exception as e:
1152
- # Graceful failure - let user select different model
1153
- error_msg = (
1154
- f"Failed to load {model_key} for {model_role}: {str(e)}\n\n"
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 (~200) and output (~2048)
1321
- max_window_tokens = extraction_n_ctx - 2300 # Target ~1800 tokens per window
1322
-
1323
- # Simple windowing: split into chunks based on token count
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1324
  windows = []
1325
  current_window = []
 
1326
  current_tokens = 0
1327
  window_id = 1
1328
-
1329
- for line_num, line in enumerate(lines):
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
- overlap_lines = current_window[-overlap_turns:] if len(current_window) >= overlap_turns else current_window
 
 
 
 
 
1354
  current_window = overlap_lines + [line]
1355
- current_tokens = sum(count_tokens(l) for l in current_window)
 
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=len(lines) - len(current_window),
1367
- end_turn=len(lines) - 1,
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=len(lines) - len(current_window),
1376
- end_turn=len(lines) - 1
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
- for summary_chunk, thinking_chunk, is_complete in stream_synthesize_executive_summary(
1479
- synthesis_llm=synthesis_llm,
1480
- deduplicated_items=deduplicated_items,
1481
- model_config=synthesis_config,
1482
- output_language=output_language,
1483
- enable_reasoning=enable_synthesis_reasoning,
1484
- max_tokens=max_tokens,
1485
- tracer=tracer
1486
- ):
1487
- final_summary = summary_chunk
1488
- final_thinking = thinking_chunk
1489
- yield {"stage": "synthesis", "ticker": "", "thinking": thinking_chunk, "summary": summary_chunk}
1490
-
1491
- # Unload synthesis model
1492
- unload_model(synthesis_llm, "synthesis model")
1493
- synthesis_llm = None
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 50% headroom for thinking process
1707
- thinking_headroom = int(max_tokens * 0.5)
 
 
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 (with reasoning buffer if enabled)
1955
- n_ctx, warning = calculate_n_ctx(model_key, transcript, max_tokens, enable_reasoning)
 
 
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"Error loading model: {e}", "", metrics, system_prompt_preview)
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
- delta = chunk['choices'][0].get('delta', {})
 
 
 
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
- converted = converter.convert(content)
2110
- full_response += converted
2111
  else:
2112
  full_response += content
2113
 
2114
- thinking, summary = parse_thinking_blocks(full_response, streaming=True)
2115
- current_thinking = thinking or ""
2116
- current_summary = summary or ""
2117
- yield (current_thinking, current_summary, info, metrics, system_content)
 
 
 
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
- /* ===== MODE VISUAL INDICATORS ===== */
2429
- /* Style for visible mode groups to indicate they are active */
2430
- .gradio-group:not([style*="display: none"]) {
2431
- position: relative;
 
 
 
2432
  }
2433
 
2434
- /* Add subtle highlight border to active mode group */
2435
- .gradio-group:not([style*="display: none"]) > .form {
2436
- border-left: 3px solid var(--primary-color);
2437
- padding-left: 12px;
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>Select a model below to get started</span>
2459
  </div>
2460
  </div>
2461
  """)
2462
-
2463
- # Instructions (simplified)
2464
- gr.HTML("""
2465
- <div class="instructions">
2466
- <strong>📋 How to use:</strong>
2467
- <ul>
2468
- <li>Upload a .txt file containing your transcript, notes, or document</li>
2469
- <li>Click "Generate Summary" to start AI processing</li>
2470
- <li>Watch the <strong>Thinking Process</strong> (left) - see how the AI reasons</li>
2471
- <li>Read the <strong>Final Summary</strong> (right) - the polished result</li>
2472
- <li>Both outputs stream in real-time as the AI generates content</li>
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.Group():
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
- # Filter out custom_hf from preset choices
2566
- preset_choices = [
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=preset_choices,
2574
  value=DEFAULT_MODEL_KEY,
2575
  label="Select Model",
2576
- info="Smaller = faster. = Always-reasoning models."
2577
  )
2578
-
 
 
 
2579
  enable_reasoning = gr.Checkbox(
2580
- value=True,
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
- gr.HTML('<div class="section-header" style="margin-top: 16px;"><span class="section-icon">🎛️</span> Inference Parameters</div>')
2622
-
2623
- temperature_slider = gr.Slider(
2624
- minimum=0.0,
2625
- maximum=2.0,
2626
- value=0.6,
2627
- step=0.1,
2628
- label="Temperature",
2629
- info="Lower = more focused, Higher = more creative"
2630
- )
2631
- max_tokens = gr.Slider(
2632
- minimum=256,
2633
- maximum=4096,
2634
- value=2048,
2635
- step=256,
2636
- label="Max Output Tokens",
2637
- info="Higher = more detailed summary"
2638
- )
2639
- top_p = gr.Slider(
2640
- minimum=0.0,
2641
- maximum=1.0,
2642
- value=0.95,
2643
- step=0.05,
2644
- label="Top P (Nucleus Sampling)",
2645
- info="Lower = more focused, Higher = more diverse"
2646
- )
2647
- top_k = gr.Slider(
2648
- minimum=0,
2649
- maximum=100,
2650
- value=20,
2651
- step=5,
2652
- label="Top K",
2653
- info="Limits token selection to top K tokens (0 = disabled)"
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=2048,
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
- # Model Information (shows selected model specs)
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 (separate section)
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
- with open(file_input_val, 'r', encoding='utf-8') as f:
3294
- transcript = f.read()
 
 
 
 
 
 
 
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: Use existing summarize_streaming()
 
 
 
 
 
3369
  for thinking, summary, info, metrics, system_prompt in summarize_streaming(
3370
- file_input_val, text_input_val, model_dropdown_val, enable_reasoning_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
- show_progress="full"
 
 
 
 
 
 
 
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,
meeting_summarizer/extraction.py CHANGED
@@ -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
- # Parse thinking blocks if reasoning enabled
880
- if enable_reasoning and supports_reasoning:
881
- # Simple regex extraction
882
- thinking_match = re.search(r'<think(?:ing)?>(.*?)</think(?:ing)?>', full_response, re.DOTALL)
883
- if thinking_match:
884
- thinking_content = thinking_match.group(1).strip()
885
- json_text = full_response[:thinking_match.start()] + full_response[thinking_match.end():]
 
 
 
 
 
 
886
  else:
887
  json_text = full_response
888
- else:
889
- json_text = full_response
890
-
891
- # Try parse JSON
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
- embeddings = []
1078
- for item in items:
1079
- emb = embedding_model.embed(item)
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 = []
summarize_transcript.py CHANGED
@@ -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
- k_type=2,
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