Files
momentum-system/youtube_claims/recap/llm.py
T
momentum devandClaude Opus 5.5 91af4c2e93 feat: nightly Ross-recap learning loop on the owner's local Ollama
Why: the pilot in data/ross showed that Ross Cameron's daily recaps, joined to
the SIP tape and to what our bot saw, answer "which of his trades could we have
taken and why didn't we". It was a set of one-off scripts run by hand; the
server-side youtube_claims pipeline has been dead since ~08-24 (YouTube blocks
the TrueNAS IP) and its captions fallback hid that. This makes the pilot a
nightly, local, cumulative loop without a paid LLM API.

youtube_claims/recap/ (python -m youtube_claims.recap.nightly):
- channel: yt-dlp flat listing of @DaytradeWarrior, m4a audio, faster-whisper
  small.en on CPU, pilot-format bundles (meta + our scanner list + transcript).
- extract: Ollama structured output (JSON schema) per EXTRACT_SPEC, chunked
  with overlap for long transcripts and merged. The verbatim rule is enforced in
  code: every quote must be found (token-level, one-word tolerance) and is
  snapped to the transcript's words; unquoted items, trades with no surviving
  quote, prices never spoken, and passed-on names never said are dropped.
- llm: endpoint pool over RECAP_OLLAMA_HOSTS (round-robin + failover, per-host
  model/ctx/think pins) since the owner runs several Ollama boxes.
- tape/join/ourside: ported join.py/ourside.py -- tickers are price-verified on
  the Alpaca SIP tape (plus an order-free check for his own named ticker), then
  paper (:5433) and live (:5434) event views are attached. Bars cached on disk.
- store + schema.sql: idempotent recap_* tables in the transcripts schema,
  upserted per video_id. report: the pilot's sections + "what changed since last
  run" against the previous day's metrics JSON.
- evaluate: scores any model/prompt against the 36 gold extractions.

Model choice (36 gold videos, final code path, P/R):
  gpt-oss:20b on the workstation GPU, two focused calls, think=low:
    trades P .84 R .70; tickers P .82 R .77; entry +-2% P .54 R .50;
    passed-on P .66 R .76; temperature 16/31; kind 30/36; ~49 s/video.
  tools:latest (4B, server) one call: trades P .81 R .72, tickers P .83 R .85,
    passed-on P .80 R .75, temperature 20/31, but it invents prices
    (entry P .42) and occasionally runs away (5-9 min). Prices drive the tape
    verification, so the more precise local model is the default.
  Iterations: prompt v2 + spoken-price rescale (699 -> 6.99) + split calls took
  gpt-oss trades F1 .74 -> .76-.78 and tickers .78 -> .79-.80; think=medium
  blew the token budget. qwen3-30b-a3b on mason only finished 5 videos (host
  saturated) -- inconclusive.

Also: youtube_claims/transcription.py::_captions_fallback now picks the
youtube-transcript-api shape by feature detection instead of retrying the
removed get_transcript on any error, and transcribe() reports both the whisper
and captions causes -- the IpBlocked error was being masked as AttributeError.

No trading code touched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 16:23:18 -05:00

134 lines
5.3 KiB
Python

"""A small pool over the owner's several LAN Ollama endpoints: round-robin across the endpoints
that actually have the requested model, fail over on connection errors / timeouts / 5xx, and
park a dead endpoint for a few minutes. Endpoints come from RECAP_OLLAMA_HOSTS:
RECAP_OLLAMA_HOSTS="http://127.0.0.1:11434, http://192.168.1.178:11434=qwen3:30b-a3b-instruct-2507-q4_K_M"
An entry may pin its own model with `=model` (and `#ctx=N` / `#think=V` options), otherwise it
serves RECAP_OLLAMA_MODEL with the global options. The transport (`call`) is injected so the
rotation/failover logic is unit-tested without a network."""
from __future__ import annotations
import json
import socket
import threading
import time
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from typing import Callable
_THINK = {"false": False, "true": True, "none": None}
@dataclass
class Endpoint:
host: str
model: str | None = None # None -> the pool's default model
num_ctx: int | None = None # None -> the pool's default
think: object = "default" # "default" -> the pool's default
down_until: float = 0.0
models: set | None = None
models_at: float = 0.0
def parse_hosts(spec: str) -> list[Endpoint]:
"""'h1, h2=model#ctx=8192#think=false' -> endpoints (blank entries ignored)."""
out = []
for raw in (spec or "").split(","):
raw = raw.strip()
if not raw:
continue
head, *opts = raw.split("#")
host, _, model = head.partition("=")
ep = Endpoint(host=host.strip().rstrip("/"), model=model.strip() or None)
for o in opts:
k, _, v = o.partition("=")
k, v = k.strip().lower(), v.strip()
if k == "ctx" and v.isdigit():
ep.num_ctx = int(v) or None
elif k == "think":
ep.think = _THINK.get(v.lower(), v)
out.append(ep)
return out
def _tags(host: str) -> set[str]:
with urllib.request.urlopen(host + "/api/tags", timeout=5) as r:
return {m["name"] for m in json.load(r).get("models", [])}
@dataclass
class OllamaPool:
endpoints: list[Endpoint]
default_model: str
default_ctx: int | None = None
default_think: object = None
call: Callable | None = None # (system, user, model=, host=, ...) -> (str, stats)
list_models: Callable[[str], set] = _tags
down_seconds: float = 300.0
tags_ttl: float = 300.0
_rr: int = 0
_lock: threading.Lock = field(default_factory=threading.Lock)
def _serves(self, ep: Endpoint) -> str | None:
"""The model this endpoint would run, if it has it (tags cached); None otherwise."""
want = ep.model or self.default_model
now = time.monotonic()
if ep.models is None or now - ep.models_at > self.tags_ttl:
try:
ep.models, ep.models_at = self.list_models(ep.host), now
except Exception: # noqa: BLE001 -- unreachable: park it
ep.down_until = now + self.down_seconds
ep.models, ep.models_at = None, 0.0
return None
names = ep.models or set()
return want if (want in names or f"{want}:latest" in names) else None
def candidates(self) -> list[tuple[Endpoint, str]]:
now = time.monotonic()
with self._lock:
start = self._rr
self._rr += 1
n = len(self.endpoints)
order = [self.endpoints[(start + i) % n] for i in range(n)] if n else []
out = []
for ep in order:
if ep.down_until > now:
continue
m = self._serves(ep)
if m:
out.append((ep, m))
return out
def chat(self, system: str, user: str, **kw) -> tuple[str, dict]:
"""One chat call on the next healthy endpoint; fails over to the others."""
cands = self.candidates()
if not cands:
raise RuntimeError(f"no reachable Ollama endpoint serves {self.default_model!r} "
f"(tried {[e.host for e in self.endpoints]})")
last: Exception | None = None
for ep, model in cands:
think = self.default_think if ep.think == "default" else ep.think
ctx = ep.num_ctx if ep.num_ctx is not None else self.default_ctx
try:
content, stats = self.call(system, user, model=model, host=ep.host, num_ctx=ctx, think=think, **kw)
stats = {**stats, "host": ep.host, "model": model}
return content, stats
except (urllib.error.URLError, ConnectionError, TimeoutError, socket.timeout) as e:
last = e
except RuntimeError as e: # ollama_chat's HTTP errors: only 5xx is the host's fault
if "ollama 5" not in str(e):
raise
last = e
ep.down_until = time.monotonic() + self.down_seconds
raise RuntimeError(f"all Ollama endpoints failed; last error: {last}")
def from_settings(call=None) -> OllamaPool:
from youtube_claims.recap import settings
from youtube_claims.recap.extract import ollama_chat
return OllamaPool(parse_hosts(settings.OLLAMA_HOSTS), settings.OLLAMA_MODEL, settings.OLLAMA_NUM_CTX,
settings.OLLAMA_THINK, call=call or ollama_chat)