← back to Linkedin Voice Agent
scripts/_ollama.py
90 lines
#!/usr/bin/env python3
"""Shared local-Ollama helper for the linkedin-voice-agent skill.
Zero external dependencies (stdlib urllib). Runs on Mac2/Mac1 Ollama at $0.
Mirrors the call+fallback+think-strip pattern used by the kartiseira skill so
behaviour is consistent across Steve's persona-voice tools.
"""
import json
import os
import re
import sys
import urllib.request
DEFAULT_MODEL = os.environ.get("LVA_MODEL", "qwen3:14b")
BASE = os.environ.get("OLLAMA_URL", "http://localhost:11434")
# Preference order if the requested model isn't pulled locally.
FALLBACKS = ("qwen3", "hermes3", "gemma3", "qwen2.5", "llama3.1", "llama3")
def _available_models():
try:
with urllib.request.urlopen(f"{BASE}/api/tags", timeout=8) as r:
data = json.load(r)
return {m["name"].split(":")[0]: m["name"] for m in data.get("models", [])}
except Exception:
return {}
def _resolve_model(want):
avail = _available_models()
if not avail:
return want # let the call fail loudly if ollama is truly down
if want in avail.values():
return want
for fam in FALLBACKS:
if fam in avail:
return avail[fam]
return next(iter(avail.values()))
def ask(prompt, model=None, temperature=0.8, num_predict=1400):
"""Return (text, model_used). Strips <think> blocks. Raises on hard failure."""
model = _resolve_model(model or DEFAULT_MODEL)
body = json.dumps({
"model": model,
"prompt": prompt,
"stream": False,
"options": {"temperature": temperature, "num_predict": num_predict},
}).encode()
req = urllib.request.Request(f"{BASE}/api/generate", data=body,
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=240) as r:
out = json.load(r).get("response", "")
out = re.sub(r"<think>.*?</think>", "", out, flags=re.S | re.I).strip()
return out, model
def extract_json(text):
"""Pull the first JSON object/array out of a possibly-chatty model reply."""
text = text.strip()
m = re.search(r"```(?:json)?\s*(.+?)```", text, flags=re.S)
if m:
text = m.group(1).strip()
# Match whichever bracket appears FIRST — a top-level object may contain
# arrays (and vice-versa), so trying "[" unconditionally would grab an
# inner array out of an object.
pairs = [(o, c) for o, c in (("[", "]"), ("{", "}")) if text.find(o) != -1]
pairs.sort(key=lambda p: text.find(p[0]))
for opener, closer in pairs:
i = text.find(opener)
if i == -1:
continue
depth = 0
for j in range(i, len(text)):
if text[j] == opener:
depth += 1
elif text[j] == closer:
depth -= 1
if depth == 0:
try:
return json.loads(text[i:j + 1])
except Exception:
break
return None
if __name__ == "__main__":
txt, used = ask(sys.argv[1] if len(sys.argv) > 1 else "Say hi in one line.")
print(f"[model: {used}]\n{txt}")