diff options
| author | Pinapelz <yukais@pinapelz.com> | 2026-04-26 20:55:53 -0700 |
|---|---|---|
| committer | Pinapelz <yukais@pinapelz.com> | 2026-04-26 21:02:29 -0700 |
| commit | 58c5449a35c51d9edea0fedacec964a9e5196e8f (patch) | |
| tree | d549f9004ccddd9e1829380144fee52ecbba4d73 | |
| parent | 1ff76ea759b966bc7683fcaff17314788687c950 (diff) | |
feat: use ollama to cleanup context window
| -rw-r--r-- | README.md | 13 | ||||
| -rw-r--r-- | config.py | 24 | ||||
| -rw-r--r-- | gui.py | 114 | ||||
| -rw-r--r-- | pyproject.toml | 1 | ||||
| -rw-r--r-- | server.py | 190 | ||||
| -rw-r--r-- | uv.lock | 107 |
6 files changed, 410 insertions, 39 deletions
@@ -1,14 +1,25 @@ # auto-live-tl -A basic LOCAL translation backend that listens to an audio sink via PCM and runs translation via faster-whisper +A basic LOCAL translation backend that listens to an audio sink via PCM and runs translation via faster-whisper. Also supports the option to use `qwen2.5-7b-instruct` to format/clean-up subtitles based on sliding window context. Translations and trascriptions are transformers based, inaccuracies and hallucinations will occur. + +# Setup > It's highly recommended that you run this with a GPU, running with CPU is possible but inference will be very slow outside of using tiny models (which compromise accuracy) > > For this, you will need to install a Nvidia CUDA 12 toolkit. I am running with [CUDA Toolkit 12.9](https://developer.nvidia.com/cuda-12-9-0-download-archive) +``` +uv sync +uv run server.py +``` +A GUI is available for configuration + `server.py` serves a backend for translating incoming audio data. It expects some other client to hit the `/events` endpoint to fetch the translated data. + +# Clients: + `youtube-subtitle.user.js` is one such example client that can fetch data from this endpoint and render it beneath a YouTube video. You can install it as a userscript. <img width="1210" height="109" alt="image" src="https://github.com/user-attachments/assets/2bffde45-bc61-4d63-b779-b7a8cd183bc0" /> diff --git a/config.py b/config.py new file mode 100644 index 0000000..1101085 --- /dev/null +++ b/config.py @@ -0,0 +1,24 @@ +_SYSTEM_PROMPT: str = ( + "You are a live-stream subtitle deduplicator and sentence completer.\n" + "The speech-to-text engine uses a ROLLING AUDIO WINDOW, so every new " + "raw input re-transcribes the recent past verbatim. Most of the raw " + "input is old text already shown to the viewer.\n\n" + "ALREADY SHOWN lists every subtitle line already displayed.\n\n" + "YOUR JOB:\n" + "Extract only the genuinely NEW spoken content from the raw input, " + "while ensuring the output forms clean, complete, natural sentences.\n\n" + "STRICT RULES:\n" + " 1. NEVER repeat text that is already fully covered by ALREADY SHOWN.\n" + " 2. Prefer returning COMPLETE SENTENCES instead of cut-off fragments.\n" + " If the new content starts mid-sentence, use the rolling context " + " from the raw input to complete the full sentence naturally.\n" + " 3. Do NOT paraphrase, summarize, or invent meaning — preserve the " + " speaker's original wording as closely as possible.\n" + " 4. You may use overlapping words from the raw input only when needed " + " to reconstruct a full readable sentence, but avoid unnecessary repetition.\n" + " 5. Fix punctuation, capitalization, and obvious transcript artifacts " + " (like duplicated partial words) for readability.\n" + " 6. If the entire raw input is already covered by ALREADY SHOWN, " + " output an empty string and nothing else.\n" + " 7. Output ONLY the final subtitle text. No labels, no explanations." +) @@ -20,16 +20,21 @@ def select_settings( return settings.get(key, default_settings.get(key, fallback)) root = tk.Tk() - root.title("Audio & Whisper Settings") + root.title("Settings") root.resizable(False, False) - frame = ttk.Frame(root, padding=10) - frame.pack(fill="both", expand=True) - frame.columnconfigure(1, weight=1) + notebook = ttk.Notebook(root) + notebook.pack(fill="both", expand=True, padx=10, pady=(10, 0)) - def add_row(row: int, label_text: str, widget: tk.Widget) -> None: - label = ttk.Label(frame, text=label_text) - label.grid(row=row, column=0, sticky="w", pady=4) + # ------------------------------------------------------------------ # + # Tab 1 – Whisper # + # ------------------------------------------------------------------ # + whisper_tab = ttk.Frame(notebook, padding=10) + whisper_tab.columnconfigure(1, weight=1) + notebook.add(whisper_tab, text="Whisper") + + def add_row(parent: ttk.Frame, row: int, label_text: str, widget: tk.Widget) -> None: + ttk.Label(parent, text=label_text).grid(row=row, column=0, sticky="w", pady=4, padx=(0, 12)) widget.grid(row=row, column=1, sticky="ew", pady=4) device_options = [ @@ -37,70 +42,92 @@ def select_settings( for idx, dev in input_devices ] device_names = [dev["name"] for _idx, dev in input_devices] - device_combo = ttk.Combobox(frame, values=device_options, state="readonly", width=60) - + device_combo = ttk.Combobox(whisper_tab, values=device_options, state="readonly", width=60) default_device_name = get_value("audio_device_name", "") if default_device_name in device_names: device_combo.current(device_names.index(default_device_name)) else: device_combo.current(0) - add_row(0, "Audio input device:", device_combo) + add_row(whisper_tab, 0, "Audio input device:", device_combo) model_var = tk.StringVar(value=get_value("model_name", "medium")) - model_combo = ttk.Combobox(frame, values=list(model_choices), textvariable=model_var) + model_combo = ttk.Combobox(whisper_tab, values=list(model_choices), textvariable=model_var) model_combo.set(model_var.get()) - add_row(1, "Model:", model_combo) + add_row(whisper_tab, 1, "Model:", model_combo) device_type_var = tk.StringVar(value=get_value("device", "cpu")) device_type_combo = ttk.Combobox( - frame, values=list(device_choices), textvariable=device_type_var, state="readonly" + whisper_tab, values=list(device_choices), textvariable=device_type_var, state="readonly" ) device_type_combo.set(device_type_var.get()) - add_row(2, "Compute device:", device_type_combo) + add_row(whisper_tab, 2, "Compute device:", device_type_combo) compute_type_var = tk.StringVar(value=get_value("compute_type", "int8")) - compute_type_combo = ttk.Combobox( - frame, values=list(compute_choices), textvariable=compute_type_var - ) + compute_type_combo = ttk.Combobox(whisper_tab, values=list(compute_choices), textvariable=compute_type_var) compute_type_combo.set(compute_type_var.get()) - add_row(3, "Compute type:", compute_type_combo) + add_row(whisper_tab, 3, "Compute type:", compute_type_combo) task_var = tk.StringVar(value=get_value("task", "translate")) - task_combo = ttk.Combobox(frame, values=list(task_choices), textvariable=task_var, state="readonly") + task_combo = ttk.Combobox(whisper_tab, values=list(task_choices), textvariable=task_var, state="readonly") task_combo.set(task_var.get()) - add_row(4, "Task:", task_combo) + add_row(whisper_tab, 4, "Task:", task_combo) beam_size_var = tk.StringVar(value=str(get_value("beam_size", 3))) - beam_entry = ttk.Entry(frame, textvariable=beam_size_var, width=10) - add_row(5, "Beam size:", beam_entry) + add_row(whisper_tab, 5, "Beam size:", ttk.Entry(whisper_tab, textvariable=beam_size_var, width=10)) language_var = tk.StringVar(value=get_value("language", "")) - language_entry = ttk.Entry(frame, textvariable=language_var) - add_row(6, "Language (optional):", language_entry) + add_row(whisper_tab, 6, "Language (optional):", ttk.Entry(whisper_tab, textvariable=language_var)) context_seconds_var = tk.StringVar(value=str(get_value("context_seconds", 10))) - context_entry = ttk.Entry(frame, textvariable=context_seconds_var, width=10) - add_row(7, "Context seconds:", context_entry) + add_row(whisper_tab, 7, "Context seconds:", ttk.Entry(whisper_tab, textvariable=context_seconds_var, width=10)) update_interval_var = tk.StringVar(value=str(get_value("update_interval_seconds", 2))) - update_interval_entry = ttk.Entry(frame, textvariable=update_interval_var, width=10) - add_row(8, "Update interval (s):", update_interval_entry) + add_row(whisper_tab, 8, "Update interval (s):", ttk.Entry(whisper_tab, textvariable=update_interval_var, width=10)) + + # ------------------------------------------------------------------ # + # Tab 2 – Ollama # + # ------------------------------------------------------------------ # + ollama_tab = ttk.Frame(notebook, padding=10) + ollama_tab.columnconfigure(1, weight=1) + notebook.add(ollama_tab, text="Ollama") + + use_ollama_cleanup_var = tk.BooleanVar(value=get_value("use_ollama_cleanup", True)) + add_row(ollama_tab, 0, "LLM subtitle cleanup:", ttk.Checkbutton(ollama_tab, variable=use_ollama_cleanup_var)) + + ollama_device_var = tk.StringVar(value=get_value("ollama_device", "CPU")) + ollama_device_combo = ttk.Combobox( + ollama_tab, values=["CPU", "GPU"], textvariable=ollama_device_var, state="readonly", width=10 + ) + ollama_device_combo.set(ollama_device_var.get()) + add_row(ollama_tab, 1, "Ollama compute:", ollama_device_combo) - button_frame = ttk.Frame(root, padding=(10, 0, 10, 10)) + ollama_context_var = tk.StringVar(value=str(get_value("ollama_context_window", 6))) + add_row(ollama_tab, 2, "Context window (segments):", ttk.Entry(ollama_tab, textvariable=ollama_context_var, width=10)) + + ollama_batch_var = tk.StringVar(value=str(get_value("ollama_raw_batch_size", 3))) + add_row(ollama_tab, 3, "Batch size (lines per LLM call):", ttk.Entry(ollama_tab, textvariable=ollama_batch_var, width=10)) + + # ------------------------------------------------------------------ # + # Buttons # + # ------------------------------------------------------------------ # + button_frame = ttk.Frame(root, padding=(10, 6, 10, 10)) button_frame.pack(fill="x") selected_settings: Dict[str, Any] = {} def on_ok() -> None: nonlocal selected_settings + selection = device_combo.current() if selection < 0: messagebox.showwarning("Select a device", "Please select an audio input device.") return + model_name = model_var.get().strip() if not model_name: messagebox.showwarning("Model required", "Please select or enter a model name.") return + try: beam_size = int(beam_size_var.get().strip()) if beam_size <= 0: @@ -108,6 +135,7 @@ def select_settings( except ValueError: messagebox.showwarning("Invalid beam size", "Beam size must be a positive integer.") return + try: context_seconds = float(context_seconds_var.get().strip()) if context_seconds <= 0: @@ -115,15 +143,31 @@ def select_settings( except ValueError: messagebox.showwarning("Invalid context seconds", "Context seconds must be a positive number.") return + try: update_interval_seconds = float(update_interval_var.get().strip()) if update_interval_seconds <= 0: raise ValueError except ValueError: - messagebox.showwarning( - "Invalid update interval", "Update interval must be a positive number." - ) + messagebox.showwarning("Invalid update interval", "Update interval must be a positive number.") + return + + try: + ollama_context_window = int(ollama_context_var.get().strip()) + if ollama_context_window <= 0: + raise ValueError + except ValueError: + messagebox.showwarning("Invalid context window", "Context window must be a positive integer.") return + + try: + ollama_raw_batch_size = int(ollama_batch_var.get().strip()) + if ollama_raw_batch_size <= 0: + raise ValueError + except ValueError: + messagebox.showwarning("Invalid batch size", "Batch size must be a positive integer.") + return + selected_settings = { "audio_device_name": device_names[selection], "model_name": model_name, @@ -134,6 +178,10 @@ def select_settings( "language": language_var.get().strip(), "context_seconds": context_seconds, "update_interval_seconds": update_interval_seconds, + "use_ollama_cleanup": use_ollama_cleanup_var.get(), + "ollama_device": ollama_device_var.get(), + "ollama_context_window": ollama_context_window, + "ollama_raw_batch_size": ollama_raw_batch_size, } root.quit() @@ -185,4 +233,4 @@ def prompt_input_sample_rate(device_index: int, common_rates: Iterable[int] | No parent=root, ) finally: - root.destroy()
\ No newline at end of file + root.destroy() diff --git a/pyproject.toml b/pyproject.toml index 37c0cd5..0f28a93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,5 +8,6 @@ dependencies = [ "faster-whisper>=1.2.1", "flask>=3.1.3", "flask-cors>=6.0.2", + "ollama>=0.6.1", "sounddevice>=0.5.5", ] @@ -3,14 +3,20 @@ import threading import json import queue import os -from typing import Any, Dict, Optional, Set, List, Iterator +from collections import Counter, deque +import re +from typing import Any, Deque, Dict, Optional, Set, List, Iterator from flask import Flask from flask_cors import CORS +import ollama as _ollama +from ollama import chat +from ollama import ChatResponse import numpy as np import sounddevice as sd from faster_whisper import WhisperModel from gui import select_settings, prompt_input_sample_rate from routes import register_routes +from config import _SYSTEM_PROMPT TARGET_SAMPLE_RATE: int = 16000 CAPTURE_SAMPLE_RATE: int = 0 @@ -20,6 +26,12 @@ PROCESS_INTERVAL_SECONDS: float = 2 SSE_EVENT_SUBTITLE: str = "subtitle" SSE_KEEPALIVE_SECONDS: int = 15 +USE_OLLAMA_CLEANUP: bool = True +OLLAMA_MODEL: str = "qwen2.5:7b-instruct" +OLLAMA_CONTEXT_WINDOW: int = 6 # number of recent cleaned segments kept as context +OLLAMA_OPTIONS: Dict[str, Any] = {"num_gpu": 1} +RAW_BATCH_SIZE: int = 2 # accumulate this many raw Whisper lines before calling the LLM + SETTINGS_PATH: str = os.path.join(os.path.dirname(__file__), "settings.json") DEFAULT_SETTINGS: Dict[str, Any] = { @@ -32,6 +44,10 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "language": "", "context_seconds": 10, "update_interval_seconds": 2, + "use_ollama_cleanup": True, + "ollama_device": "GPU", + "ollama_context_window": 5, + "ollama_raw_batch_size": 2, } MODEL_CHOICES: List[str] = ["tiny", "base", "small", "medium", "large-v2", "large-v3", "distil-large-v3"] @@ -54,6 +70,12 @@ SERVER_PORT: int = 5000 app: Flask = Flask(__name__) CORS(app) +# OLLAMA stuff +llm_input_queue: queue.Queue = queue.Queue(maxsize=1) +subtitle_context: Deque[str] = deque(maxlen=OLLAMA_CONTEXT_WINDOW) # sliding window context +subtitle_context_lock: threading.Lock = threading.Lock() +_raw_batch: List[str] = [] +_raw_batch_lock: threading.Lock = threading.Lock() def resample_audio(audio_np: np.ndarray, src_rate: int, dst_rate: int) -> np.ndarray: if src_rate == dst_rate: @@ -90,18 +112,163 @@ def save_settings(settings: Dict[str, Any]) -> None: except OSError as exc: print(f"Failed to save settings: {exc}") +def cleanup_subtitle_with_ollama(raw_text: str, context: List[str]) -> Optional[str]: + if context: + context_block = "\n".join(f"- {seg}" for seg in context) + else: + context_block = "(none yet)" + + user_message = ( + f"ALREADY SHOWN:\n{context_block}\n\n" + "RAW INPUT (multiple consecutive transcriptions of the same rolling window — " + f"deduplicate and extract only the genuinely new spoken content as one subtitle):\n{raw_text}" + ) + + try: + response: ChatResponse = chat( + model=OLLAMA_MODEL, + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + {"role": "user", "content": user_message}, + ], + options=OLLAMA_OPTIONS, + ) + return response.message.content.strip() + except Exception as exc: + print(f"⚠️ OLLAMA cleanup error: {exc}") + return None + + +def ensure_ollama_ready() -> None: + try: + local = _ollama.list() + except Exception as exc: + raise RuntimeError( + f"Cannot reach Ollama — is the server running? ({exc})" + ) from exc + model_names: List[str] = [m.model for m in local.models] + if not any(name.startswith(OLLAMA_MODEL) for name in model_names): + print(f" '{OLLAMA_MODEL}' not found locally — pulling (this may take a while) ...") + try: + _ollama.pull(OLLAMA_MODEL) + print(" Pull complete.") + except Exception as exc: + raise RuntimeError(f"Failed to pull model '{OLLAMA_MODEL}': {exc}") from exc + else: + print(f" Model found locally.") + print(" Warming up model, almost done ...") + try: + chat( + model=OLLAMA_MODEL, + messages=[{"role": "user", "content": "Ready?"}], + options=OLLAMA_OPTIONS, + ) + print(" ✅ Ollama is ready.") + except Exception as exc: + raise RuntimeError(f"Ollama warm-up failed: {exc}") from exc + +_LLM_EMPTY_SENTINELS: frozenset = frozenset({ + "empty string", "empty", "(empty)", "[empty]", + "(empty string)", "[empty string]", "(none)", "none", "n/a", +}) + + +def normalize_llm_output(text: str) -> str: + if text.strip().lower().rstrip(".") in _LLM_EMPTY_SENTINELS: + return "" + return text + + +def is_hallucination(text: str) -> bool: + words = text.split() + if not words: + return False + max_expected = int(BUFFER_SECONDS * 4.5) + if len(words) > max_expected: + print(f"🔴 Hallucination (too long: {len(words)} words > {max_expected}): {text[:60]!r}") + return True + clean = [re.sub(r"[^\w']+", "", w).lower() for w in words] + clean = [w for w in clean if w] + for n in (2, 3): + if len(clean) < n * 3: + continue + ngrams = [" ".join(clean[i : i + n]) for i in range(len(clean) - n + 1)] + top, count = Counter(ngrams).most_common(1)[0] + if count >= 3: + print(f"🔴 Hallucination (\'{top}\' x{count}): {text[:60]!r}") + return True + top, count = Counter(clean).most_common(1)[0] + if count >= 4 and count / len(clean) > 0.40: + print(f"🔴 Hallucination (\'{top}\' x{count}, {count/len(clean):.0%}): {text[:60]!r}") + return True + return False + + +def llm_processing_loop() -> None: + print(f"LLM cleanup thread started (model={OLLAMA_MODEL})") + while True: + try: + raw_text: str = llm_input_queue.get(timeout=1) + except queue.Empty: + continue + + with subtitle_context_lock: + context = list(subtitle_context) + + cleaned: Optional[str] = cleanup_subtitle_with_ollama(raw_text, context) + + if cleaned is None: + cleaned = raw_text + else: + cleaned = normalize_llm_output(cleaned) + + if cleaned: + with subtitle_context_lock: + subtitle_context.append(cleaned) + print(f"🔵 (cleaned) {cleaned}") + broadcast_subtitle(cleaned) + else: + print("🟡 (LLM: no new content)") + def run_whisper(audio_np: np.ndarray) -> str: transcribe_kwargs: Dict[str, Any] = {"task": WHISPER_TASK, "beam_size": WHISPER_BEAM_SIZE} if WHISPER_LANGUAGE: transcribe_kwargs["language"] = WHISPER_LANGUAGE - # model is expected to be initialized in main() assert model is not None, "Whisper model is not initialized" segments, _info = model.transcribe(audio_np, **transcribe_kwargs) text = " ".join(seg.text for seg in segments).strip() - if text: - print("🟢", text) + if not text: + return text + + print(f"🟢 (raw) {text}") + + if is_hallucination(text): + return text + + if USE_OLLAMA_CLEANUP: + with _raw_batch_lock: + _raw_batch.append(text) + if len(_raw_batch) >= RAW_BATCH_SIZE: + batch_text = "\n".join(_raw_batch) + _raw_batch.clear() + else: + batch_text = None + if batch_text is not None: + try: + llm_input_queue.put_nowait(batch_text) + except queue.Full: + try: + llm_input_queue.get_nowait() + except queue.Empty: + pass + try: + llm_input_queue.put_nowait(batch_text) + except queue.Full: + pass + else: broadcast_subtitle(text) + return text @@ -225,7 +392,8 @@ def select_input_sample_rate(device_index: int, preferred_rate: int) -> int: def main() -> None: global CAPTURE_SAMPLE_RATE, MAX_SAMPLES, model, WHISPER_TASK, WHISPER_BEAM_SIZE, WHISPER_LANGUAGE - global BUFFER_SECONDS, PROCESS_INTERVAL_SECONDS + global BUFFER_SECONDS, PROCESS_INTERVAL_SECONDS, USE_OLLAMA_CLEANUP + global OLLAMA_CONTEXT_WINDOW, RAW_BATCH_SIZE, subtitle_context start_subtitle_server() settings: Dict[str, Any] = load_settings() @@ -242,6 +410,16 @@ def main() -> None: ) save_settings(settings) + USE_OLLAMA_CLEANUP = bool(settings.get("use_ollama_cleanup", True)) + OLLAMA_OPTIONS["num_gpu"] = 0 if settings.get("ollama_device", "CPU").upper() == "CPU" else 1 + OLLAMA_CONTEXT_WINDOW = int(settings.get("ollama_context_window", 6)) + subtitle_context = deque(maxlen=OLLAMA_CONTEXT_WINDOW) + RAW_BATCH_SIZE = int(settings.get("ollama_raw_batch_size", 3)) + if USE_OLLAMA_CLEANUP: + ensure_ollama_ready() + llm_thread = threading.Thread(target=llm_processing_loop, daemon=True) + llm_thread.start() + device_name: str = settings.get("audio_device_name", "") matched_index: Optional[int] = None for idx, dev in enumerate(devices): @@ -277,6 +455,8 @@ def main() -> None: print(f"Model: {model_name} | task={WHISPER_TASK} | beam_size={WHISPER_BEAM_SIZE}") print(f"Compute: device={whisper_device} | compute_type={compute_type}") print(f"Capture sample rate: {CAPTURE_SAMPLE_RATE} Hz (resampling to {TARGET_SAMPLE_RATE} Hz)") + print(f"Ollama cleanup: {'enabled' if USE_OLLAMA_CLEANUP else 'disabled'} (model={OLLAMA_MODEL})") + processing_thread = threading.Thread(target=processing_loop, daemon=True) processing_thread.start() with sd.InputStream( @@ -12,6 +12,15 @@ wheels = [ ] [[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] name = "anyio" version = "4.13.0" source = { registry = "https://pypi.org/simple" } @@ -31,6 +40,7 @@ dependencies = [ { name = "faster-whisper" }, { name = "flask" }, { name = "flask-cors" }, + { name = "ollama" }, { name = "sounddevice" }, ] @@ -39,6 +49,7 @@ requires-dist = [ { name = "faster-whisper", specifier = ">=1.2.1" }, { name = "flask", specifier = ">=3.1.3" }, { name = "flask-cors", specifier = ">=6.0.2" }, + { name = "ollama", specifier = ">=0.6.1" }, { name = "sounddevice", specifier = ">=0.5.5" }, ] @@ -501,6 +512,19 @@ wheels = [ ] [[package]] +name = "ollama" +version = "0.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "httpx" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/5a/652dac4b7affc2b37b95386f8ae78f22808af09d720689e3d7a86b6ed98e/ollama-0.6.1.tar.gz", hash = "sha256:478c67546836430034b415ed64fa890fd3d1ff91781a9d548b3325274e69d7c6", size = 51620, upload-time = "2025-11-13T23:02:17.416Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/47/4f/4a617ee93d8208d2bcf26b2d8b9402ceaed03e3853c754940e2290fed063/ollama-0.6.1-py3-none-any.whl", hash = "sha256:fc4c984b345735c5486faeee67d8a265214a31cbb828167782dc642ce0a2bf8c", size = 14354, upload-time = "2025-11-13T23:02:16.292Z" }, +] + +[[package]] name = "onnxruntime" version = "1.24.4" source = { registry = "https://pypi.org/simple" } @@ -562,6 +586,77 @@ wheels = [ ] [[package]] +name = "pydantic" +version = "2.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/e4/40d09941a2cebcb20609b86a559817d5b9291c49dd6f8c87e5feffbe703a/pydantic-2.13.3.tar.gz", hash = "sha256:af09e9d1d09f4e7fe37145c1f577e1d61ceb9a41924bf0094a36506285d0a84d", size = 844068, upload-time = "2026-04-20T14:46:43.632Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/0a/fd7d723f8f8153418fb40cf9c940e82004fce7e987026b08a68a36dd3fe7/pydantic-2.13.3-py3-none-any.whl", hash = "sha256:6db14ac8dfc9a1e57f87ea2c0de670c251240f43cb0c30a5130e9720dc612927", size = 471981, upload-time = "2026-04-20T14:46:41.402Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/ef/f7abb56c49382a246fd2ce9c799691e3c3e7175ec74b14d99e798bcddb1a/pydantic_core-2.46.3.tar.gz", hash = "sha256:41c178f65b8c29807239d47e6050262eb6bf84eb695e41101e62e38df4a5bc2c", size = 471412, upload-time = "2026-04-20T14:40:56.672Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/3c/9b5e8eb9821936d065439c3b0fb1490ffa64163bfe7e1595985a47896073/pydantic_core-2.46.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:12bc98de041458b80c86c56b24df1d23832f3e166cbaff011f25d187f5c62c37", size = 2102109, upload-time = "2026-04-20T14:41:24.219Z" }, + { url = "https://files.pythonhosted.org/packages/91/97/1c41d1f5a19f241d8069f1e249853bcce378cdb76eec8ab636d7bc426280/pydantic_core-2.46.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:85348b8f89d2c3508b65b16c3c33a4da22b8215138d8b996912bb1532868885f", size = 1951820, upload-time = "2026-04-20T14:42:14.236Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d03a7ae14571bc2b6b3c7b122441154720619afe9a336fa3a95434df5e2f/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1105677a6df914b1fb71a81b96c8cce7726857e1717d86001f29be06a25ee6f8", size = 1977785, upload-time = "2026-04-20T14:42:31.648Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0c/4086f808834b59e3c8f1aa26df8f4b6d998cdcf354a143d18ef41529d1fe/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87082cd65669a33adeba5470769e9704c7cf026cc30afb9cc77fd865578ebaad", size = 2062761, upload-time = "2026-04-20T14:40:37.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/a649be5a5064c2df0db06e0a512c2281134ed2fcc981f52a657936a7527c/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60e5f66e12c4f5212d08522963380eaaeac5ebd795826cfd19b2dfb0c7a52b9c", size = 2232989, upload-time = "2026-04-20T14:42:59.254Z" }, + { url = "https://files.pythonhosted.org/packages/a2/84/7756e75763e810b3a710f4724441d1ecc5883b94aacb07ca71c5fb5cfb69/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6cdf19bf84128d5e7c37e8a73a0c5c10d51103a650ac585d42dd6ae233f2b7f", size = 2303975, upload-time = "2026-04-20T14:41:32.287Z" }, + { url = "https://files.pythonhosted.org/packages/6c/35/68a762e0c1e31f35fa0dac733cbd9f5b118042853698de9509c8e5bf128b/pydantic_core-2.46.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:031bb17f4885a43773c8c763089499f242aee2ea85cf17154168775dccdecf35", size = 2095325, upload-time = "2026-04-20T14:42:47.685Z" }, + { url = "https://files.pythonhosted.org/packages/77/bf/1bf8c9a8e91836c926eae5e3e51dce009bf495a60ca56060689d3df3f340/pydantic_core-2.46.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:bcf2a8b2982a6673693eae7348ef3d8cf3979c1d63b54fca7c397a635cc68687", size = 2133368, upload-time = "2026-04-20T14:41:22.766Z" }, + { url = "https://files.pythonhosted.org/packages/e5/50/87d818d6bab915984995157ceb2380f5aac4e563dddbed6b56f0ed057aba/pydantic_core-2.46.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:28e8cf2f52d72ced402a137145923a762cbb5081e48b34312f7a0c8f55928ec3", size = 2173908, upload-time = "2026-04-20T14:42:52.044Z" }, + { url = "https://files.pythonhosted.org/packages/91/88/a311fb306d0bd6185db41fa14ae888fb81d0baf648a761ae760d30819d33/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:17eaface65d9fc5abb940003020309c1bf7a211f5f608d7870297c367e6f9022", size = 2186422, upload-time = "2026-04-20T14:43:29.55Z" }, + { url = "https://files.pythonhosted.org/packages/8f/79/28fd0d81508525ab2054fef7c77a638c8b5b0afcbbaeee493cf7c3fef7e1/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:93fd339f23408a07e98950a89644f92c54d8729719a40b30c0a30bb9ebc55d23", size = 2332709, upload-time = "2026-04-20T14:42:16.134Z" }, + { url = "https://files.pythonhosted.org/packages/b3/21/795bf5fe5c0f379308b8ef19c50dedab2e7711dbc8d0c2acf08f1c7daa05/pydantic_core-2.46.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:23cbdb3aaa74dfe0837975dbf69b469753bbde8eacace524519ffdb6b6e89eb7", size = 2372428, upload-time = "2026-04-20T14:41:10.974Z" }, + { url = "https://files.pythonhosted.org/packages/45/b3/ed14c659cbe7605e3ef063077680a64680aec81eb1a04763a05190d49b7f/pydantic_core-2.46.3-cp313-cp313-win32.whl", hash = "sha256:610eda2e3838f401105e6326ca304f5da1e15393ae25dacae5c5c63f2c275b13", size = 1965601, upload-time = "2026-04-20T14:41:42.128Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bb/adb70d9a762ddd002d723fbf1bd492244d37da41e3af7b74ad212609027e/pydantic_core-2.46.3-cp313-cp313-win_amd64.whl", hash = "sha256:68cc7866ed863db34351294187f9b729964c371ba33e31c26f478471c52e1ed0", size = 2071517, upload-time = "2026-04-20T14:43:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/52/eb/66faefabebfe68bd7788339c9c9127231e680b11906368c67ce112fdb47f/pydantic_core-2.46.3-cp313-cp313-win_arm64.whl", hash = "sha256:f64b5537ac62b231572879cd08ec05600308636a5d63bcbdb15063a466977bec", size = 2035802, upload-time = "2026-04-20T14:43:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/7f/db/a7bcb4940183fda36022cd18ba8dd12f2dff40740ec7b58ce7457befa416/pydantic_core-2.46.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:afa3aa644f74e290cdede48a7b0bee37d1c35e71b05105f6b340d484af536d9b", size = 2097614, upload-time = "2026-04-20T14:44:38.374Z" }, + { url = "https://files.pythonhosted.org/packages/24/35/e4066358a22e3e99519db370494c7528f5a2aa1367370e80e27e20283543/pydantic_core-2.46.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ced3310e51aa425f7f77da8bbbb5212616655bedbe82c70944320bc1dbe5e018", size = 1951896, upload-time = "2026-04-20T14:40:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/87/92/37cf4049d1636996e4b888c05a501f40a43ff218983a551d57f9d5e14f0d/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e29908922ce9da1a30b4da490bd1d3d82c01dcfdf864d2a74aacee674d0bfa34", size = 1979314, upload-time = "2026-04-20T14:41:49.446Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/9ff4d676dfbdfb2d591cf43f3d90ded01e15b1404fd101180ed2d62a2fd3/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0c9ff69140423eea8ed2d5477df3ba037f671f5e897d206d921bc9fdc39613e7", size = 2056133, upload-time = "2026-04-20T14:42:23.574Z" }, + { url = "https://files.pythonhosted.org/packages/bc/f0/405b442a4d7ba855b06eec8b2bf9c617d43b8432d099dfdc7bf999293495/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b675ab0a0d5b1c8fdb81195dc5bcefea3f3c240871cdd7ff9a2de8aa50772eb2", size = 2228726, upload-time = "2026-04-20T14:44:22.816Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/65cd92dd5a0bd89ba277a98ecbfaf6fc36bbd3300973c7a4b826d6ab1391/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0087084960f209a9a4af50ecd1fb063d9ad3658c07bb81a7a53f452dacbfb2ba", size = 2301214, upload-time = "2026-04-20T14:44:48.792Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/ef96a4c6e79e7a2d0410826a68fbc0eccc0fd44aa733be199d5fcac3bb87/pydantic_core-2.46.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed42e6cc8e1b0e2b9b96e2276bad70ae625d10d6d524aed0c93de974ae029f9f", size = 2099927, upload-time = "2026-04-20T14:41:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/6d/53/269caf30e0096e0a8a8f929d1982a27b3879872cca2d917d17c2f9fdf4fe/pydantic_core-2.46.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:f1771ce258afb3e4201e67d154edbbae712a76a6081079fe247c2f53c6322c22", size = 2128789, upload-time = "2026-04-20T14:41:15.868Z" }, + { url = "https://files.pythonhosted.org/packages/00/b0/1a6d9b6a587e118482910c244a1c5acf4d192604174132efd12bf0ac486f/pydantic_core-2.46.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a7610b6a5242a6c736d8ad47fd5fff87fcfe8f833b281b1c409c3d6835d9227f", size = 2173815, upload-time = "2026-04-20T14:44:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/87/56/e7e00d4041a7e62b5a40815590114db3b535bf3ca0bf4dca9f16cef25246/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:ff5e7783bcc5476e1db448bf268f11cb257b1c276d3e89f00b5727be86dd0127", size = 2181608, upload-time = "2026-04-20T14:41:28.933Z" }, + { url = "https://files.pythonhosted.org/packages/e8/22/4bd23c3d41f7c185d60808a1de83c76cf5aeabf792f6c636a55c3b1ec7f9/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:9d2e32edcc143bc01e95300671915d9ca052d4f745aa0a49c48d4803f8a85f2c", size = 2326968, upload-time = "2026-04-20T14:42:03.962Z" }, + { url = "https://files.pythonhosted.org/packages/24/ac/66cd45129e3915e5ade3b292cb3bc7fd537f58f8f8dbdaba6170f7cabb74/pydantic_core-2.46.3-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6e42d83d1c6b87fa56b521479cff237e626a292f3b31b6345c15a99121b454c1", size = 2369842, upload-time = "2026-04-20T14:41:35.52Z" }, + { url = "https://files.pythonhosted.org/packages/a2/51/dd4248abb84113615473aa20d5545b7c4cd73c8644003b5259686f93996c/pydantic_core-2.46.3-cp314-cp314-win32.whl", hash = "sha256:07bc6d2a28c3adb4f7c6ae46aa4f2d2929af127f587ed44057af50bf1ce0f505", size = 1959661, upload-time = "2026-04-20T14:41:00.042Z" }, + { url = "https://files.pythonhosted.org/packages/20/eb/59980e5f1ae54a3b86372bd9f0fa373ea2d402e8cdcd3459334430f91e91/pydantic_core-2.46.3-cp314-cp314-win_amd64.whl", hash = "sha256:8940562319bc621da30714617e6a7eaa6b98c84e8c685bcdc02d7ed5e7c7c44e", size = 2071686, upload-time = "2026-04-20T14:43:16.471Z" }, + { url = "https://files.pythonhosted.org/packages/8c/db/1cf77e5247047dfee34bc01fa9bca134854f528c8eb053e144298893d370/pydantic_core-2.46.3-cp314-cp314-win_arm64.whl", hash = "sha256:5dcbbcf4d22210ced8f837c96db941bdb078f419543472aca5d9a0bb7cddc7df", size = 2026907, upload-time = "2026-04-20T14:43:31.732Z" }, + { url = "https://files.pythonhosted.org/packages/57/c0/b3df9f6a543276eadba0a48487b082ca1f201745329d97dbfa287034a230/pydantic_core-2.46.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d0fe3dce1e836e418f912c1ad91c73357d03e556a4d286f441bf34fed2dbeecf", size = 2095047, upload-time = "2026-04-20T14:42:37.982Z" }, + { url = "https://files.pythonhosted.org/packages/66/57/886a938073b97556c168fd99e1a7305bb363cd30a6d2c76086bf0587b32a/pydantic_core-2.46.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9ce92e58abc722dac1bf835a6798a60b294e48eb0e625ec9fd994b932ac5feee", size = 1934329, upload-time = "2026-04-20T14:43:49.655Z" }, + { url = "https://files.pythonhosted.org/packages/0b/7c/b42eaa5c34b13b07ecb51da21761297a9b8eb43044c864a035999998f328/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a03e6467f0f5ab796a486146d1b887b2dc5e5f9b3288898c1b1c3ad974e53e4a", size = 1974847, upload-time = "2026-04-20T14:42:10.737Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9b/92b42db6543e7de4f99ae977101a2967b63122d4b6cf7773812da2d7d5b5/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2798b6ba041b9d70acfb9071a2ea13c8456dd1e6a5555798e41ba7b0790e329c", size = 2041742, upload-time = "2026-04-20T14:40:44.262Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/46fbe1efabb5aa2834b43b9454e70f9a83ad9c338c1291e48bdc4fecf167/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9be3e221bdc6d69abf294dcf7aff6af19c31a5cdcc8f0aa3b14be29df4bd03b1", size = 2236235, upload-time = "2026-04-20T14:41:27.307Z" }, + { url = "https://files.pythonhosted.org/packages/77/da/b3f95bc009ad60ec53120f5d16c6faa8cabdbe8a20d83849a1f2b8728148/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13936129ce841f2a5ddf6f126fea3c43cd128807b5a59588c37cf10178c2e64", size = 2282633, upload-time = "2026-04-20T14:44:33.271Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/401336117722e28f32fb8220df676769d28ebdf08f2f4469646d404c43a3/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:28b5f2ef03416facccb1c6ef744c69793175fd27e44ef15669201601cf423acb", size = 2109679, upload-time = "2026-04-20T14:44:41.065Z" }, + { url = "https://files.pythonhosted.org/packages/fc/53/b289f9bc8756a32fe718c46f55afaeaf8d489ee18d1a1e7be1db73f42cc4/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:830d1247d77ad23852314f069e9d7ddafeec5f684baf9d7e7065ed46a049c4e6", size = 2108342, upload-time = "2026-04-20T14:42:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/10/5b/8292fc7c1f9111f1b2b7c1b0dcf1179edcd014fc3ea4517499f50b829d71/pydantic_core-2.46.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0793c90c1a3c74966e7975eaef3ed30ebdff3260a0f815a62a22adc17e4c01c", size = 2157208, upload-time = "2026-04-20T14:42:08.133Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9e/f80044e9ec07580f057a89fc131f78dda7a58751ddf52bbe05eaf31db50f/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:d2d0aead851b66f5245ec0c4fb2612ef457f8bbafefdf65a2bf9d6bac6140f47", size = 2167237, upload-time = "2026-04-20T14:42:25.412Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6781a1b037f3b96be9227edbd1101f6d3946746056231bf4ac48cdff1a8d/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:2f40e4246676beb31c5ce77c38a55ca4e465c6b38d11ea1bd935420568e0b1ab", size = 2312540, upload-time = "2026-04-20T14:40:40.313Z" }, + { url = "https://files.pythonhosted.org/packages/3e/db/19c0839feeb728e7df03255581f198dfdf1c2aeb1e174a8420b63c5252e5/pydantic_core-2.46.3-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:cf489cf8986c543939aeee17a09c04d6ffb43bfef8ca16fcbcc5cfdcbed24dba", size = 2369556, upload-time = "2026-04-20T14:41:09.427Z" }, + { url = "https://files.pythonhosted.org/packages/e0/15/3228774cb7cd45f5f721ddf1b2242747f4eb834d0c491f0c02d606f09fed/pydantic_core-2.46.3-cp314-cp314t-win32.whl", hash = "sha256:ffe0883b56cfc05798bf994164d2b2ff03efe2d22022a2bb080f3b626176dd56", size = 1949756, upload-time = "2026-04-20T14:41:25.717Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2a/c79cf53fd91e5a87e30d481809f52f9a60dd221e39de66455cf04deaad37/pydantic_core-2.46.3-cp314-cp314t-win_amd64.whl", hash = "sha256:706d9d0ce9cf4593d07270d8e9f53b161f90c57d315aeec4fb4fd7a8b10240d8", size = 2051305, upload-time = "2026-04-20T14:43:18.627Z" }, + { url = "https://files.pythonhosted.org/packages/0b/db/d8182a7f1d9343a032265aae186eb063fe26ca4c40f256b21e8da4498e89/pydantic_core-2.46.3-cp314-cp314t-win_arm64.whl", hash = "sha256:77706aeb41df6a76568434701e0917da10692da28cb69d5fb6919ce5fdb07374", size = 2026310, upload-time = "2026-04-20T14:41:01.778Z" }, +] + +[[package]] name = "pygments" version = "2.20.0" source = { registry = "https://pypi.org/simple" } @@ -728,6 +823,18 @@ wheels = [ ] [[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] name = "werkzeug" version = "3.1.8" source = { registry = "https://pypi.org/simple" } |
