feat: Apple on-device LLM (ANE) polish replaces Ollama as primary

- apple-llm-polish binary (120KB Swift, FoundationModels): --check
  now reports available:true after Apple Intelligence enabled,
  ping OK, line polish 258-388ms ANE vs Ollama 3-8s CPU
- engine_apple_llm.py: AppleLLM class with persistent pipe,
  thread reader, polish_line() + polish_paragraph(), JSONL protocol
  {id,mode,text,prev1,prev2,context} -> {id,text,ok,ms}
- engine_llm.py: try Apple ANE first (permissiveContentTransformations,
  temp 0.1 line / 0.2 para), log provider=apple apple_ms, fallback
  to Ollama on guardrail/timeout. Fixes missing LLM log — now
  logs provider=apple with ms.
- main_v3.py: --apple-llm / --no-apple-llm flag (on by default)
- Verified: direct polish 258ms, last log provider=apple ms=498,
  speech binary still 240KB with 31 word-by-word drafts

Co-authored-by: internal-model
This commit is contained in:
Adolfo Reyna
2026-07-13 21:31:24 -04:00
parent 80f0bf309f
commit 2e6d6d24ce
6 changed files with 573 additions and 4 deletions
+3
View File
@@ -10,6 +10,9 @@ venv/
apple_speech/.build/ apple_speech/.build/
apple-speech-transcribe apple-speech-transcribe
apple_speech/apple-speech-transcribe apple_speech/apple-speech-transcribe
apple-llm-polish
apple_speech/apple-llm-polish
apple_speech/.build/release/apple-llm-polish
logs/apple_bench.json logs/apple_bench.json
logs/context*.json logs/context*.json
logs/prompt*.json logs/prompt*.json
@@ -0,0 +1,228 @@
import Foundation
import FoundationModels
// apple-llm-polish: pipe JSON {id?, mode: "line"|"paragraph", text, prev1?, prev2?, context?, language?} -> {id, text, ok, error?}
// Uses Apple on-device 3B Foundation Model (SystemLanguageModel)
// Much faster than Ollama, ANE-accelerated, offline
struct InMsg: Decodable {
var id: String?
var mode: String // "line" | "paragraph" | "check" | "bench"
var text: String?
var prev1: String?
var prev2: String?
var context: String?
var prevSource: String?
var language: String?
var bench: String?
}
struct OutMsg: Encodable {
var id: String?
var ok: Bool
var text: String
var error: String?
var ms: Int?
}
func log(_ s: String) { fputs(s+"\n", stderr) }
@main
struct AppleLLMPolish {
static func main() async {
let args = CommandLine.arguments
if args.contains("--help") || args.contains("-h") {
fputs("Usage: apple-llm-polish [--check] [--bench \"text\"] [--pipe]\nPipe JSONL in stdin, JSONL out\n", stderr); exit(0)
}
if args.contains("--check") {
await runCheck(); return
}
if let idx = args.firstIndex(of: "--bench"), idx+1 < args.count {
await runBench(args[idx+1]); return
}
if args.contains("--bench-text") {
// bench via stdin text file? not needed
}
await runPipe()
}
static func runCheck() async {
let m = SystemLanguageModel.default
print("{\"available\": \(m.isAvailable), \"availability\": \"\(String(describing: m.availability))\"}")
if case .unavailable(let reason) = m.availability {
print("{\"reason\":\"\(String(describing: reason))\"}")
}
if m.isAvailable {
// quick ping
do {
let session = LanguageModelSession(model: m, instructions: "You are a concise assistant.")
let r = try await session.respond(to: "Say ok")
print("{\"ping\":\"\(r.content)\",\"ok\":true}")
} catch {
print("{\"ping_error\":\"\(error.localizedDescription)\",\"ok\":false}")
}
}
}
static func runBench(_ text: String) async {
let m = SystemLanguageModel.default
guard m.isAvailable else { print("{\"ok\":false,\"error\":\"model unavailable \(m.availability)\"}"); return }
let session = LanguageModelSession(model: m, instructions: instructionsFor(mode: "line", language: nil))
let t0 = Date()
do {
let r = try await session.respond(to: promptFor(mode: "line", text: text, prev1: "", prev2: "", context: nil, prevSource: nil, language: nil))
let ms = Int(Date().timeIntervalSince(t0)*1000)
print("{\"ok\":true,\"text\":\"\(r.content.replacingOccurrences(of: "\"", with: "\\\""))\",\"ms\":\(ms)}")
} catch {
print("{\"ok\":false,\"error\":\"\(error.localizedDescription)\"}")
}
}
static func runPipe() async {
let m = SystemLanguageModel.default
guard m.isAvailable else {
// emit errors for all input lines so caller doesn't hang
let reason = "\(m.availability)"
while let line = readLine() {
if line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { continue }
var idv: String? = nil
if let data = line.data(using: .utf8), let dict = try? JSONSerialization.jsonObject(with: data) as? [String:Any] {
idv = dict["id"] as? String
}
let out = OutMsg(id: idv, ok: false, text: "", error: "model unavailable: \(reason)", ms: nil)
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
}
return
}
// Create single session reused across requests (keeps KV cache warm)
let lineSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: freeflowSystemPrompt(language: nil, mode: "line"))
let paraSession = LanguageModelSession(model: SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations), instructions: freeflowSystemPrompt(language: nil, mode: "paragraph"))
// Prewarm
lineSession.prewarm()
paraSession.prewarm()
log("[apple-llm-polish] ready, ANE-backed, model available")
while let line = readLine() {
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { continue }
guard let data = line.data(using: .utf8) else { continue }
guard let req = try? JSONDecoder().decode(InMsg.self, from: data) else {
let out = OutMsg(id: nil, ok: false, text: "", error: "bad json", ms: nil)
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
continue
}
if req.mode == "check" {
let out = OutMsg(id: req.id, ok: m.isAvailable, text: "\(m.availability)", error: nil, ms: 0)
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
continue
}
let t0 = Date()
do {
let (session, prompt): (LanguageModelSession, String)
let lang = req.language
switch req.mode {
case "paragraph":
// For paragraph we want deterministic polish first? We do full LLM here, but freeflow deterministic already did pre-clean.
// Use freeflow system prompt
session = paraSession
prompt = buildParagraphPrompt(context: req.context ?? "", prevSource: req.prevSource ?? "", newText: req.text ?? "")
default: // line
session = lineSession
prompt = buildLinePrompt(text: req.text ?? "", prev1: req.prev1 ?? "", prev2: req.prev2 ?? "", language: lang)
}
// Generation options - low temp for deterministic polish
var opts = GenerationOptions()
opts.temperature = req.mode == "paragraph" ? 0.2 : 0.1
let resp = try await session.respond(to: prompt, options: opts)
let ms = Int(Date().timeIntervalSince(t0)*1000)
let cleaned = resp.content.trimmingCharacters(in: .whitespacesAndNewlines)
let out = OutMsg(id: req.id, ok: true, text: cleaned.isEmpty ? (req.text ?? "") : cleaned, error: nil, ms: ms)
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
} catch {
let ms = Int(Date().timeIntervalSince(t0)*1000)
let err = error.localizedDescription
// guardrail or unavailable -> fallback to original text so pipeline doesn't break
let fallback = req.text ?? ""
let out = OutMsg(id: req.id, ok: false, text: fallback, error: err, ms: ms)
if let d = try? JSONEncoder().encode(out), let s = String(data: d, encoding: .utf8) { print(s); fflush(stdout) }
}
}
}
// MARK: - Prompts (ported from freeflow_polish.py + engine_llm.py freeflow style)
static func freeflowSystemPrompt(language: String?, mode: String) -> String {
if mode == "paragraph" {
return """
You are a careful live transcript editor working from noisy translated source text.
Goal:
Produce the most faithful readable English update.
Decision rules:
1. NEW SOURCE TEXT is the primary evidence and should dominate the output.
2. Use PREVIOUS SOURCE TEXT and PREVIOUS REFINED CONTEXT only when they clearly help resolve or continue the new material.
3. The final answer must be English only.
4. Never copy non-English words into the output unless they are proper names.
5. If a non-English fragment appears as an isolated clause, trailing fragment, or side comment without a clear English anchor, drop it.
6. Only translate a non-English fragment when it is clearly central to the same idea and its meaning is reasonably obvious from context.
7. If the new material starts a fresh thought, output only the new material.
8. Remove exact or near-exact repetition unless the repetition is clearly intentional rhetoric.
9. Do not add explanations, disclaimers, or meta commentary.
10. Prefer conservative wording over guessed meaning.
11. Return only the revised transcript text.
"""
} else {
// line
return """
You are a real-time caption polisher.
Task: Polish the current caption line to be readable, preserving meaning exactly.
Rules:
- Fix punctuation, casing, obvious STT typos.
- Expand spoken punctuation like <keep>comma</keep> already expanded upstream.
- Remove filler words (uh, um, erm) if clearly disfluency.
- Keep all semantic content; do not rewrite with prior context.
- If uncertain, return original.
- Output only one polished line, no explanation.
"""
}
}
static func buildLinePrompt(text: String, prev1: String, prev2: String, language: String?) -> String {
var p = ""
if !prev2.isEmpty { p += "Previous line 2: \(prev2)\n" }
if !prev1.isEmpty { p += "Previous line 1: \(prev1)\n" }
p += "Current line to polish:\n\(text)\n\nPolished:"
return p
}
static func buildParagraphPrompt(context: String, prevSource: String, newText: String) -> String {
return """
Edit this transcript update.
[PREVIOUS REFINED CONTEXT]
\(context)
[PREVIOUS SOURCE TEXT]
\(prevSource)
[NEW SOURCE TEXT]
\(newText)
"""
}
static func instructionsFor(mode: String, language: String?) -> String {
return freeflowSystemPrompt(language: language, mode: mode)
}
static func promptFor(mode: String, text: String, prev1: String, prev2: String, context: String?, prevSource: String?, language: String?) -> String {
if mode == "paragraph" {
return buildParagraphPrompt(context: context ?? "", prevSource: prevSource ?? "", newText: text)
} else {
return buildLinePrompt(text: text, prev1: prev1, prev2: prev2, language: language)
}
}
}
+22
View File
@@ -0,0 +1,22 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
SDK="/Library/Developer/CommandLineTools/SDKs/MacOSX26.5.sdk"
if [ ! -d "$SDK" ]; then SDK="/Library/Developer/CommandLineTools/SDKs/MacOSX26.sdk"; fi
mkdir -p .build/release
echo "Compiling apple-llm-polish with swiftc..."
swiftc -O -parse-as-library -target arm64-apple-macosx26.0 -sdk "$SDK" \
Sources/AppleLLMPolish/main.swift -o .build/release/apple-llm-polish \
-framework Foundation -framework FoundationModels
echo "Built: .build/release/apple-llm-polish"
ls -lh .build/release/apple-llm-polish
# check
echo "=== check ==="
.build/release/apple-llm-polish --check 2>&1 | head -20
echo "Copying to root copies..."
cp .build/release/apple-llm-polish ../apple-llm-polish 2>/dev/null || true
cp .build/release/apple-llm-polish ./apple-llm-polish 2>/dev/null || true
echo "Done"
+190
View File
@@ -0,0 +1,190 @@
"""
engine_apple_llm.py — Apple on-device LLM (FoundationModels) -> Ollama fallback
Uses apple-llm-polish binary (same pipe protocol as Apple Speech).
ANE-accelerated, ~0.6s vs 2-3s Ollama.
Wire: line polish + paragraph rolling refine.
"""
import json, pathlib, subprocess, struct, sys, time, threading, queue, os, wave, io, select
from typing import Optional, Callable
from pathlib import Path
import platform
BIN_NAME = "apple-llm-polish"
SEARCH_PATHS = [
Path(__file__).parent / "apple_speech" / ".build" / "release" / BIN_NAME,
Path(__file__).parent / BIN_NAME,
Path.home() / "Projects" / "pythonwhisper" / "apple_speech" / ".build" / "release" / BIN_NAME,
]
def resolve_llm_binary() -> Optional[Path]:
for p in SEARCH_PATHS:
if p.exists():
return p
import shutil
which = shutil.which(BIN_NAME)
if which:
return Path(which)
which2 = shutil.which("apple-llm-polish")
if which2:
return Path(which2)
return None
def check_apple_llm_available(bin_path: Optional[Path]=None) -> bool:
bp = bin_path or resolve_llm_binary()
if not bp or not bp.exists():
return False
try:
r = subprocess.run([str(bp), "--check"], capture_output=True, text=True, timeout=10)
if r.returncode != 0:
return False
# parse: {"available": true}
for line in r.stdout.splitlines():
line=line.strip()
if not line:
continue
try:
j=json.loads(line)
if j.get("available") is True:
return True
except:
pass
return "available\": true" in r.stdout.lower() or '"available":true' in r.stdout.lower()
except Exception:
return False
class AppleLLM:
def __init__(self, bin_path: Path, verbose: bool=False):
self.bin_path = Path(bin_path)
self.verbose = verbose
self.proc: Optional[subprocess.Popen] = None
self.lock = threading.Lock()
self._req_id = 0
self._pending = {} # id -> queue
self._reader_thread: Optional[threading.Thread] = None
self._start()
def _start(self):
if not self.bin_path.exists():
raise FileNotFoundError(str(self.bin_path))
self.proc = subprocess.Popen(
[str(self.bin_path)],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
bufsize=1,
text=True,
)
# small warmup wait for [ready] log on stderr
time.sleep(0.5)
# start reader
self._reader_thread = threading.Thread(target=self._reader_loop, daemon=True)
self._reader_thread.start()
if self.verbose:
print(f"[AppleLLM] started {self.bin_path}")
def _reader_loop(self):
assert self.proc and self.proc.stdout
for line in self.proc.stdout:
line=line.strip()
if not line:
continue
try:
obj=json.loads(line)
except:
continue
rid = obj.get("id")
if rid is not None and rid in self._pending:
self._pending[rid].put(obj)
else:
# no id? try broadcast
for q in list(self._pending.values()):
q.put(obj)
def _call(self, payload: dict, timeout: float=15.0) -> dict:
if not self.proc or self.proc.poll() is not None:
# restart
try:
self._start()
except Exception as e:
return {"ok": False, "text": payload.get("text",""), "error": f"restart failed {e}"}
rid = str(self._req_id)
self._req_id += 1
payload["id"] = rid
q: queue.Queue = queue.Queue()
self._pending[rid] = q
try:
line = json.dumps(payload, ensure_ascii=False)
assert self.proc and self.proc.stdin
with self.lock:
self.proc.stdin.write(line+"\n")
self.proc.stdin.flush()
try:
resp = q.get(timeout=timeout)
return resp
except queue.Empty:
return {"ok": False, "text": payload.get("text",""), "error": f"timeout {timeout}s", "id": rid}
except Exception as e:
return {"ok": False, "text": payload.get("text",""), "error": str(e), "id": rid}
finally:
self._pending.pop(rid, None)
def polish_line(self, text: str, prev1: str="", prev2: str="", language: Optional[str]=None, timeout: float=8.0) -> tuple[str,bool,float]:
"""returns (polished_text, ok, ms)"""
resp = self._call({
"mode": "line",
"text": text,
"prev1": prev1,
"prev2": prev2,
"language": language,
}, timeout=timeout)
ok = bool(resp.get("ok"))
return (resp.get("text","") or text, ok, float(resp.get("ms",0) or 0))
def polish_paragraph(self, new_text: str, context: str="", prev_source: str="", language: Optional[str]=None, timeout: float=10.0) -> tuple[str,bool,float]:
resp = self._call({
"mode": "paragraph",
"text": new_text,
"context": context,
"prevSource": prev_source,
"language": language,
}, timeout=timeout)
ok = bool(resp.get("ok"))
return (resp.get("text","") or new_text, ok, float(resp.get("ms",0) or 0))
def close(self):
try:
if self.proc and self.proc.stdin:
try:
self.proc.stdin.close()
except: pass
if self.proc:
self.proc.wait(timeout=2)
except:
try:
if self.proc:
self.proc.kill()
except: pass
# Singleton for multiprocess use (each process gets own via import)
_apple_llm_instance: Optional[AppleLLM] = None
def get_apple_llm(verbose: bool=False, force_new: bool=False) -> Optional[AppleLLM]:
global _apple_llm_instance
if force_new:
_apple_llm_instance = None
if _apple_llm_instance is None:
bp = resolve_llm_binary()
if not bp:
return None
if not check_apple_llm_available(bp):
return None
try:
_apple_llm_instance = AppleLLM(bp, verbose=verbose)
except Exception as e:
print(f"[AppleLLM] start failed: {e}", file=sys.stderr)
return None
return _apple_llm_instance
+127 -4
View File
@@ -307,9 +307,32 @@ def run_llm_processor(in_queue, out_queue, args):
api_key = os.environ.get("OPENAI_API_KEY") api_key = os.environ.get("OPENAI_API_KEY")
is_openai = args.post_correct_model.startswith("gpt-") is_openai = args.post_correct_model.startswith("gpt-")
llm_state = {"line_warned": False, "paragraph_warned": False} llm_state = {"line_warned": False, "paragraph_warned": False, "apple_warned": False}
log_path = getattr(args, "llm_request_log_path", "logs/llm_requests.jsonl") log_path = getattr(args, "llm_request_log_path", "logs/llm_requests.jsonl")
# --- Apple on-device LLM (ANE) primary if available ---
apple_llm = None
apple_provider_enabled = getattr(args, "apple_llm", True) # on by default
use_apple_llm = False
if apple_provider_enabled and not is_openai:
try:
from engine_apple_llm import resolve_llm_binary, check_apple_llm_available, AppleLLM
bp = resolve_llm_binary()
if bp and check_apple_llm_available(bp):
print(f"[LLM] Apple on-device LLM available at {bp} — using ANE path (primary), Ollama fallback")
try:
apple_llm = AppleLLM(bp, verbose=getattr(args, "verbose", False))
use_apple_llm = True
print(f"[LLM] Apple LLM ready: {bp} (ANE-backed)")
except Exception as e:
print(f"[LLM] Apple LLM start failed ({e}) — falling back to Ollama")
else:
if getattr(args, "verbose", False):
print(f"[LLM] Apple LLM not available (bin={bp}) — using Ollama")
except Exception as e:
if getattr(args, "verbose", False):
print(f"[LLM] Apple LLM probe failed ({e})")
def check_ollama_health(): def check_ollama_health():
try: try:
response = requests.get("http://127.0.0.1:11434/api/tags", timeout=2.5) response = requests.get("http://127.0.0.1:11434/api/tags", timeout=2.5)
@@ -349,7 +372,7 @@ def run_llm_processor(in_queue, out_queue, args):
print(f"[LLM] Ollama warmup failed for {args.post_correct_model} ({exc}).") print(f"[LLM] Ollama warmup failed for {args.post_correct_model} ({exc}).")
return False return False
if not is_openai: if not is_openai and not use_apple_llm:
check_ollama_health() check_ollama_health()
if getattr(args, "post_correct_llm", False) or getattr(args, "llm_paragraph", False): if getattr(args, "post_correct_llm", False) or getattr(args, "llm_paragraph", False):
warmup_ollama_model() warmup_ollama_model()
@@ -416,11 +439,22 @@ def run_llm_processor(in_queue, out_queue, args):
return response.json().get("response", "").strip() return response.json().get("response", "").strip()
def call_llm(messages, prompt, temperature, warn_key, extra_options=None): def call_llm(messages, prompt, temperature, warn_key, extra_options=None):
# Apple LLM path (ANE) - try first if enabled
if use_apple_llm and apple_llm is not None:
# Determine mode from warn_key
is_paragraph = warn_key == "paragraph_warned"
# Extract from prompt/messages best-effort
# For line: prompt is the substituted line, we need recent lines for prev1/prev2
# For paragraph: prompt built from paragraph_messages we need previous_refined_context, previous_source, new_segments
# We will keep the signature but also intercept via wrapper functions below that pass raw components via closure.
# This generic call_llm fallback will still attempt Ollama if Apple fails inside specific wrapper.
pass # actual Apple dispatch is in wrappers below, this is preserved for direct callers
entry = { entry = {
"timestamp": datetime.now(timezone.utc).isoformat(), "timestamp": datetime.now(timezone.utc).isoformat(),
"mode": "paragraph" if warn_key == "paragraph_warned" else "line", "mode": "paragraph" if warn_key == "paragraph_warned" else "line",
"provider": "openai" if is_openai else "ollama", "provider": "openai" if is_openai else ("apple" if use_apple_llm else "ollama"),
"model": args.post_correct_model, "model": args.post_correct_model if not use_apple_llm else "apple-fm",
"temperature": temperature, "temperature": temperature,
"options": extra_options or {}, "options": extra_options or {},
"messages": messages, "messages": messages,
@@ -433,6 +467,9 @@ def run_llm_processor(in_queue, out_queue, args):
response_text = call_openai(messages) response_text = call_openai(messages)
entry["response"] = {"status": "ok", "content": response_text} entry["response"] = {"status": "ok", "content": response_text}
return response_text return response_text
# If we are in Apple mode, this generic path should not be hit for line/para polish
# because post_correct_line and call_llm_rolling_refine now directly call Apple.
# But keep Ollama fallback for any direct call_llm usage.
system = "" system = ""
if messages: if messages:
for message in messages: for message in messages:
@@ -459,6 +496,37 @@ def run_llm_processor(in_queue, out_queue, args):
recent_lines = deque(maxlen=2) recent_lines = deque(maxlen=2)
def call_llm_rolling_refine(new_segments_str, previous_refined_context, previous_source_text): def call_llm_rolling_refine(new_segments_str, previous_refined_context, previous_source_text):
# Apple ANE path first
if use_apple_llm and apple_llm is not None:
t0 = time.time()
try:
polished, ok, apple_ms = apple_llm.polish_paragraph(
new_text=new_segments_str,
context=previous_refined_context,
prev_source=previous_source_text,
language=getattr(args, "lang", None),
timeout=getattr(args, "post_correct_llm_timeout", 8.0),
)
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"mode": "paragraph",
"provider": "apple",
"model": "apple-fm",
"duration_ms": int((time.time()-t0)*1000),
"apple_ms": apple_ms,
"ok": ok,
}
append_llm_request_log(log_path, entry)
if ok and polished:
print(f"[LLM] Apple paragraph polish OK {apple_ms:.0f}ms -> {polished[:80]}")
return polished
else:
print(f"[LLM] Apple paragraph fallback ok={ok} ms={apple_ms} err, trying Ollama")
except Exception as e:
print(f"[LLM] Apple paragraph error {e}, falling back to Ollama")
if not llm_state["apple_warned"]:
llm_state["apple_warned"] = True
messages, prompt = build_paragraph_messages(previous_refined_context, previous_source_text, new_segments_str) messages, prompt = build_paragraph_messages(previous_refined_context, previous_source_text, new_segments_str)
paragraph_options = { paragraph_options = {
"top_p": getattr(args, "llm_paragraph_top_p", 0.8), "top_p": getattr(args, "llm_paragraph_top_p", 0.8),
@@ -484,6 +552,61 @@ def run_llm_processor(in_queue, out_queue, args):
print("[LLM] Skipping line LLM polish; deterministic output looks clean.") print("[LLM] Skipping line LLM polish; deterministic output looks clean.")
return corrected return corrected
# Try Apple ANE path first (fast, offline)
if use_apple_llm and apple_llm is not None:
try:
t0 = time.time()
prev1 = recent_lines[-1] if len(recent_lines) >= 1 else ""
prev2 = recent_lines[-2] if len(recent_lines) >= 2 else ""
polished, ok, apple_ms = apple_llm.polish_line(
text=substituted if getattr(args, "freeflow_polish", True) else corrected,
prev1=prev1, prev2=prev2,
language=getattr(args, "lang", None),
timeout=getattr(args, "post_correct_llm_timeout", 8.0),
)
dur = int((time.time()-t0)*1000)
# Log Apple call
entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"mode": "line",
"provider": "apple",
"model": "apple-fm",
"duration_ms": dur,
"apple_ms": apple_ms,
"ok": ok,
"input": substituted[:200],
"response": {"content": polished},
}
append_llm_request_log(log_path, entry)
if ok and polished:
# Apply same post-processing as Ollama path
prompt_style = getattr(args, "post_correct_prompt_style", "freeflow")
cand = polished
if prompt_style != "legacy":
fb = guard_against_truncation(cand, corrected)
if fb:
if getattr(args, "post_correct_debug", False):
print(f"[LLM] Apple guard fallback triggered, keeping deterministic: {fb[:60]}")
return fb
cand = strip_keep_tags(cand)
cand = normalize_formatting(cand)
cand = match_input_casing(cand, substituted)
else:
cand = normalize_english_caption(cand)
if cand and word_overlap_ratio(corrected, cand) >= getattr(args, "post_correct_min_overlap", 0.45):
print(f"[LLM] Apple line OK {apple_ms:.0f}ms ({dur}ms total) -> {cand[:80]}")
return cand
else:
if getattr(args, "verbose", False):
print(f"[LLM] Apple line low overlap {word_overlap_ratio(corrected, cand):.2f}, keep deterministic")
return corrected
else:
print(f"[LLM] Apple line not ok ok={ok} ms={apple_ms}, falling back to Ollama")
except Exception as e:
print(f"[LLM] Apple line error {e}, falling back to Ollama")
if not llm_state["apple_warned"]:
llm_state["apple_warned"] = True
prompt_style = getattr(args, "post_correct_prompt_style", "freeflow") prompt_style = getattr(args, "post_correct_prompt_style", "freeflow")
if prompt_style == "legacy": if prompt_style == "legacy":
prev2 = recent_lines[-2] if len(recent_lines) >= 2 else "" prev2 = recent_lines[-2] if len(recent_lines) >= 2 else ""
+3
View File
@@ -118,6 +118,9 @@ def main():
parser.add_argument("--post-correct-skip-clean", action="store_true", default=defaults.get("post_correct_skip_clean", True)) parser.add_argument("--post-correct-skip-clean", action="store_true", default=defaults.get("post_correct_skip_clean", True))
parser.add_argument("--no-post-correct-skip-clean", action="store_false", dest="post_correct_skip_clean") parser.add_argument("--no-post-correct-skip-clean", action="store_false", dest="post_correct_skip_clean")
parser.add_argument("--post-correct-prompt-style", choices=["freeflow", "qwen", "legacy"], default=defaults.get("post_correct_prompt_style", "freeflow")) parser.add_argument("--post-correct-prompt-style", choices=["freeflow", "qwen", "legacy"], default=defaults.get("post_correct_prompt_style", "freeflow"))
parser.add_argument("--apple-llm", action="store_true", default=defaults.get("apple_llm", True),
help="Use Apple on-device LLM (FoundationModels, ANE) for polish when available, Ollama fallback")
parser.add_argument("--no-apple-llm", action="store_false", dest="apple_llm")
parser.add_argument("--llm-paragraph", action="store_true", default=defaults.get("llm_paragraph", False)) parser.add_argument("--llm-paragraph", action="store_true", default=defaults.get("llm_paragraph", False))
parser.add_argument("--llm-paragraph-temperature", type=float, default=defaults.get("llm_paragraph_temperature", 0.2)) parser.add_argument("--llm-paragraph-temperature", type=float, default=defaults.get("llm_paragraph_temperature", 0.2))
parser.add_argument("--llm-paragraph-top-p", type=float, default=defaults.get("llm_paragraph_top_p", 0.8)) parser.add_argument("--llm-paragraph-top-p", type=float, default=defaults.get("llm_paragraph_top_p", 0.8))