mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-26 18:28:59 +03:00
413 lines
13 KiB
Python
413 lines
13 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
"""Interactive tester for GenAI review description prompts.
|
|||
|
|
|
|||
|
|
Reuses Frigate's GenAI provider plugins and prompt builders so results match
|
|||
|
|
what Frigate produces at runtime, without needing a running Frigate instance.
|
|||
|
|
|
|||
|
|
Setup:
|
|||
|
|
1. Enable `review.genai.debug_save_thumbnails: True` in Frigate so debug
|
|||
|
|
output is saved under clips/genai-requests/<review_id>/.
|
|||
|
|
2. Copy one or more of those folders (numbered frame images plus
|
|||
|
|
prompt.txt) into testing-scripts/genai-review-examples/.
|
|||
|
|
3. Run from the repo root:
|
|||
|
|
python3 testing-scripts/genai_review_tester.py
|
|||
|
|
|
|||
|
|
The script presents a menu to edit provider settings (provider, base URL,
|
|||
|
|
optional API key, model) or run an example. Examples are selected with the
|
|||
|
|
up/down arrow keys and launched with Enter. A writing style preset can be
|
|||
|
|
applied on top of the saved prompt to compare tone between runs, and after a
|
|||
|
|
response the model can be asked follow-up questions about the same frames.
|
|||
|
|
|
|||
|
|
Provider settings are stored in
|
|||
|
|
testing-scripts/genai-review-examples/.settings.json (gitignored).
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import importlib
|
|||
|
|
import json
|
|||
|
|
import logging
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import sys
|
|||
|
|
import termios
|
|||
|
|
import time
|
|||
|
|
import tty
|
|||
|
|
from dataclasses import asdict, dataclass, field
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|||
|
|
sys.path.insert(0, str(REPO_ROOT))
|
|||
|
|
|
|||
|
|
from frigate.config.camera.genai import GenAIConfig, GenAIProviderEnum # noqa: E402
|
|||
|
|
from frigate.genai.prompts import ( # noqa: E402
|
|||
|
|
REVIEW_RESPONSE_STYLES,
|
|||
|
|
build_review_description_response_format,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
EXAMPLES_DIR = Path(__file__).resolve().parent / "genai-review-examples"
|
|||
|
|
SETTINGS_FILE = EXAMPLES_DIR / ".settings.json"
|
|||
|
|
IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
|
|||
|
|
|
|||
|
|
# Providers are imported individually so a missing SDK for an unused provider
|
|||
|
|
# does not break the script. Keys match GenAIProviderEnum values, values are
|
|||
|
|
# the module names under frigate.genai.plugins.
|
|||
|
|
PROVIDER_MODULES = {
|
|||
|
|
GenAIProviderEnum.openai: "openai",
|
|||
|
|
GenAIProviderEnum.azure_openai: "azure-openai",
|
|||
|
|
GenAIProviderEnum.gemini: "gemini",
|
|||
|
|
GenAIProviderEnum.ollama: "ollama",
|
|||
|
|
GenAIProviderEnum.llamacpp: "llama_cpp",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class TesterSettings:
|
|||
|
|
provider: str = ""
|
|||
|
|
base_url: str = ""
|
|||
|
|
api_key: str = ""
|
|||
|
|
model: str = ""
|
|||
|
|
timeout: int = 120
|
|||
|
|
runtime_options: dict[str, Any] = field(default_factory=dict)
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def load(cls) -> "TesterSettings":
|
|||
|
|
if SETTINGS_FILE.is_file():
|
|||
|
|
try:
|
|||
|
|
data = json.loads(SETTINGS_FILE.read_text())
|
|||
|
|
return cls(
|
|||
|
|
**{k: v for k, v in data.items() if k in cls.__annotations__}
|
|||
|
|
)
|
|||
|
|
except (json.JSONDecodeError, TypeError) as e:
|
|||
|
|
print(f"Ignoring invalid settings file: {e}")
|
|||
|
|
return cls()
|
|||
|
|
|
|||
|
|
def save(self) -> None:
|
|||
|
|
EXAMPLES_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
SETTINGS_FILE.write_text(json.dumps(asdict(self), indent=2))
|
|||
|
|
|
|||
|
|
def describe(self) -> str:
|
|||
|
|
if not self.provider:
|
|||
|
|
return "not configured"
|
|||
|
|
key = "set" if self.api_key else "none"
|
|||
|
|
return (
|
|||
|
|
f"provider={self.provider} base_url={self.base_url or '(default)'} "
|
|||
|
|
f"model={self.model or '(none)'} api_key={key}"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def select_option(
|
|||
|
|
title: str, options: list[str], descriptions: list[str] | None = None
|
|||
|
|
) -> int | None:
|
|||
|
|
"""Render an arrow-key menu and return the selected index, or None.
|
|||
|
|
|
|||
|
|
Up/down (or j/k) moves, Enter selects, q or Esc cancels. Falls back to a
|
|||
|
|
numbered prompt when stdin is not a TTY.
|
|||
|
|
"""
|
|||
|
|
print(f"\n{title}")
|
|||
|
|
|
|||
|
|
if not sys.stdin.isatty():
|
|||
|
|
for i, option in enumerate(options):
|
|||
|
|
print(f" {i + 1}. {option}")
|
|||
|
|
choice = input("Selection (blank to cancel): ").strip()
|
|||
|
|
if not choice:
|
|||
|
|
return None
|
|||
|
|
try:
|
|||
|
|
index = int(choice) - 1
|
|||
|
|
except ValueError:
|
|||
|
|
return None
|
|||
|
|
return index if 0 <= index < len(options) else None
|
|||
|
|
|
|||
|
|
selected = 0
|
|||
|
|
|
|||
|
|
def render(first: bool) -> None:
|
|||
|
|
if not first:
|
|||
|
|
# Move the cursor back up and redraw in place
|
|||
|
|
sys.stdout.write(f"\x1b[{len(options)}A")
|
|||
|
|
for i, option in enumerate(options):
|
|||
|
|
marker = "❯" if i == selected else " "
|
|||
|
|
line = f" {marker} {option}"
|
|||
|
|
if descriptions and descriptions[i]:
|
|||
|
|
line += f" ({descriptions[i]})"
|
|||
|
|
sys.stdout.write(f"\x1b[2K{line}\n")
|
|||
|
|
sys.stdout.flush()
|
|||
|
|
|
|||
|
|
render(True)
|
|||
|
|
fd = sys.stdin.fileno()
|
|||
|
|
old_attrs = termios.tcgetattr(fd)
|
|||
|
|
try:
|
|||
|
|
tty.setcbreak(fd)
|
|||
|
|
while True:
|
|||
|
|
ch = sys.stdin.read(1)
|
|||
|
|
if ch == "\x1b":
|
|||
|
|
seq = sys.stdin.read(1)
|
|||
|
|
if seq != "[":
|
|||
|
|
return None # bare Esc cancels
|
|||
|
|
arrow = sys.stdin.read(1)
|
|||
|
|
if arrow == "A":
|
|||
|
|
selected = (selected - 1) % len(options)
|
|||
|
|
elif arrow == "B":
|
|||
|
|
selected = (selected + 1) % len(options)
|
|||
|
|
elif ch in ("k",):
|
|||
|
|
selected = (selected - 1) % len(options)
|
|||
|
|
elif ch in ("j",):
|
|||
|
|
selected = (selected + 1) % len(options)
|
|||
|
|
elif ch in ("\r", "\n"):
|
|||
|
|
return selected
|
|||
|
|
elif ch in ("q", "\x03"):
|
|||
|
|
return None
|
|||
|
|
render(False)
|
|||
|
|
finally:
|
|||
|
|
termios.tcsetattr(fd, termios.TCSADRAIN, old_attrs)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def prompt_text(label: str, current: str, secret: bool = False) -> str:
|
|||
|
|
"""Prompt for a text value, keeping the current value on empty input."""
|
|||
|
|
shown = ("***" if current else "") if secret else current
|
|||
|
|
value = input(f"{label} [{shown}]: ").strip()
|
|||
|
|
if not value:
|
|||
|
|
return current
|
|||
|
|
if value == "-":
|
|||
|
|
return ""
|
|||
|
|
return value
|
|||
|
|
|
|||
|
|
|
|||
|
|
def edit_settings(settings: TesterSettings) -> None:
|
|||
|
|
providers = [p.value for p in GenAIProviderEnum]
|
|||
|
|
index = select_option("Select a provider:", providers)
|
|||
|
|
if index is None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
settings.provider = providers[index]
|
|||
|
|
print("Enter a value, press Enter to keep the current value, or '-' to clear.")
|
|||
|
|
settings.base_url = prompt_text("Base URL", settings.base_url)
|
|||
|
|
settings.api_key = prompt_text("API key (optional)", settings.api_key, secret=True)
|
|||
|
|
settings.model = prompt_text("Model", settings.model)
|
|||
|
|
settings.save()
|
|||
|
|
print(f"Saved settings: {settings.describe()}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def build_client(settings: TesterSettings) -> Any | None:
|
|||
|
|
"""Instantiate the Frigate provider client for the saved settings."""
|
|||
|
|
from frigate.genai import PROVIDERS
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
provider = GenAIProviderEnum(settings.provider)
|
|||
|
|
except ValueError:
|
|||
|
|
print("No valid provider configured. Edit settings first.")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
module = PROVIDER_MODULES[provider]
|
|||
|
|
try:
|
|||
|
|
importlib.import_module(f"frigate.genai.plugins.{module}")
|
|||
|
|
except ImportError as e:
|
|||
|
|
print(f"Failed to import provider plugin '{module}': {e}")
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
config = GenAIConfig(
|
|||
|
|
provider=provider,
|
|||
|
|
base_url=settings.base_url or None,
|
|||
|
|
api_key=settings.api_key or None,
|
|||
|
|
model=settings.model,
|
|||
|
|
runtime_options=settings.runtime_options,
|
|||
|
|
)
|
|||
|
|
client = PROVIDERS[provider](config, timeout=settings.timeout, validate_model=False)
|
|||
|
|
if client.provider is None:
|
|||
|
|
print("Provider failed to initialize. Check the base URL and API key.")
|
|||
|
|
return None
|
|||
|
|
return client
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_examples() -> list[Path]:
|
|||
|
|
if not EXAMPLES_DIR.is_dir():
|
|||
|
|
return []
|
|||
|
|
return sorted(
|
|||
|
|
entry
|
|||
|
|
for entry in EXAMPLES_DIR.iterdir()
|
|||
|
|
if entry.is_dir() and not entry.name.startswith(".")
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def load_frames(example: Path) -> list[bytes]:
|
|||
|
|
"""Load the example's frames as JPEG bytes in frame order.
|
|||
|
|
|
|||
|
|
Frames are saved by Frigate as <index>.jpg or <index>.webp. Non-JPEG
|
|||
|
|
images are re-encoded to JPEG to match what Frigate sends to providers.
|
|||
|
|
"""
|
|||
|
|
import cv2
|
|||
|
|
|
|||
|
|
def frame_order(path: Path) -> tuple[int, str]:
|
|||
|
|
try:
|
|||
|
|
return (int(path.stem), path.name)
|
|||
|
|
except ValueError:
|
|||
|
|
return (1 << 30, path.name)
|
|||
|
|
|
|||
|
|
frames: list[bytes] = []
|
|||
|
|
files = sorted(
|
|||
|
|
(f for f in example.iterdir() if f.suffix.lower() in IMAGE_EXTENSIONS),
|
|||
|
|
key=frame_order,
|
|||
|
|
)
|
|||
|
|
for file in files:
|
|||
|
|
if file.suffix.lower() in (".jpg", ".jpeg"):
|
|||
|
|
frames.append(file.read_bytes())
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
image = cv2.imread(str(file))
|
|||
|
|
if image is None:
|
|||
|
|
print(f" Skipping unreadable image {file.name}")
|
|||
|
|
continue
|
|||
|
|
ok, jpg = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), 100])
|
|||
|
|
if ok:
|
|||
|
|
frames.append(jpg.tobytes())
|
|||
|
|
return frames
|
|||
|
|
|
|||
|
|
|
|||
|
|
def apply_style(prompt: str, style: str) -> str:
|
|||
|
|
"""Apply a style preset to a saved prompt.
|
|||
|
|
|
|||
|
|
Presets replace the per-field response guidance lines, matching what
|
|||
|
|
Frigate builds at runtime. Handles both the current guidance format
|
|||
|
|
("- `scene`: ...") and the 0.17 format ("- `scene` (string): ...").
|
|||
|
|
"""
|
|||
|
|
overrides = REVIEW_RESPONSE_STYLES.get(style, {})
|
|||
|
|
for field_name, guidance in overrides.items():
|
|||
|
|
pattern = rf"^- `{field_name}`(?: \(string\))?: .*$"
|
|||
|
|
prompt, count = re.subn(
|
|||
|
|
pattern,
|
|||
|
|
lambda _: f"- `{field_name}`: {guidance}",
|
|||
|
|
prompt,
|
|||
|
|
count=1,
|
|||
|
|
flags=re.M,
|
|||
|
|
)
|
|||
|
|
if count == 0:
|
|||
|
|
print(f" Warning: no `{field_name}` guidance found in saved prompt")
|
|||
|
|
return prompt
|
|||
|
|
|
|||
|
|
|
|||
|
|
def pretty_print_response(response: str) -> None:
|
|||
|
|
try:
|
|||
|
|
parsed = json.loads(response)
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
print(response)
|
|||
|
|
return
|
|||
|
|
print(json.dumps(parsed, indent=2, ensure_ascii=False))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def followup_loop(client: Any, base_prompt: str, frames: list[bytes]) -> None:
|
|||
|
|
"""Let the user ask the model follow-up questions about the same frames."""
|
|||
|
|
transcript = base_prompt
|
|||
|
|
print("\nAsk follow-up questions about this sequence (blank line to finish).")
|
|||
|
|
while True:
|
|||
|
|
try:
|
|||
|
|
question = input("follow-up> ").strip()
|
|||
|
|
except EOFError:
|
|||
|
|
return
|
|||
|
|
if not question:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
prompt = (
|
|||
|
|
f"{transcript}\n\n---\n"
|
|||
|
|
"Answer the follow-up question below about the same sequence of "
|
|||
|
|
"frames. Respond in plain text, not JSON.\n"
|
|||
|
|
f"Question: {question}"
|
|||
|
|
)
|
|||
|
|
start = time.monotonic()
|
|||
|
|
answer = client._send(prompt, frames)
|
|||
|
|
elapsed = time.monotonic() - start
|
|||
|
|
if answer is None:
|
|||
|
|
print("No response from provider (see logs above).")
|
|||
|
|
continue
|
|||
|
|
print(f"\n{answer}\n({elapsed:.1f}s)")
|
|||
|
|
transcript = f"{prompt}\n\nYour answer:\n{answer}"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def run_example(settings: TesterSettings) -> None:
|
|||
|
|
examples = list_examples()
|
|||
|
|
if not examples:
|
|||
|
|
print(
|
|||
|
|
f"No examples found. Copy debug output folders from "
|
|||
|
|
f"clips/genai-requests/ into {EXAMPLES_DIR}/"
|
|||
|
|
)
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
index = select_option(
|
|||
|
|
"Select an example (arrow keys, Enter to launch):",
|
|||
|
|
[e.name for e in examples],
|
|||
|
|
)
|
|||
|
|
if index is None:
|
|||
|
|
return
|
|||
|
|
example = examples[index]
|
|||
|
|
|
|||
|
|
styles = ["default"] + list(REVIEW_RESPONSE_STYLES)
|
|||
|
|
style_index = select_option("Select a response style:", styles)
|
|||
|
|
if style_index is None:
|
|||
|
|
return
|
|||
|
|
style = styles[style_index]
|
|||
|
|
|
|||
|
|
prompt_file = example / "prompt.txt"
|
|||
|
|
if not prompt_file.is_file():
|
|||
|
|
print(f"{example.name} has no prompt.txt, cannot run")
|
|||
|
|
return
|
|||
|
|
prompt = apply_style(prompt_file.read_text(), style)
|
|||
|
|
|
|||
|
|
frames = load_frames(example)
|
|||
|
|
if not frames:
|
|||
|
|
print(f"{example.name} contains no frame images, cannot run")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
client = build_client(settings)
|
|||
|
|
if client is None:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
# Keep the other_concerns field in the schema only when the saved prompt
|
|||
|
|
# asked for it; the schema builder only checks truthiness of the list.
|
|||
|
|
concerns = ["_"] if "other_concerns" in prompt else []
|
|||
|
|
response_format = build_review_description_response_format(concerns)
|
|||
|
|
|
|||
|
|
print(
|
|||
|
|
f"\nSending {len(frames)} frames to {settings.provider} "
|
|||
|
|
f"(model={settings.model or '(none)'}, style={style})..."
|
|||
|
|
)
|
|||
|
|
start = time.monotonic()
|
|||
|
|
response = client._send(prompt, frames, response_format)
|
|||
|
|
elapsed = time.monotonic() - start
|
|||
|
|
|
|||
|
|
if response is None:
|
|||
|
|
print("No response from provider (see logs above).")
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
print(f"\nResponse ({elapsed:.1f}s):\n")
|
|||
|
|
pretty_print_response(response)
|
|||
|
|
followup_loop(client, f"{prompt}\n\nYour analysis:\n{response}", frames)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> None:
|
|||
|
|
os.chdir(REPO_ROOT)
|
|||
|
|
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
|||
|
|
EXAMPLES_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
settings = TesterSettings.load()
|
|||
|
|
|
|||
|
|
print("Frigate GenAI review prompt tester")
|
|||
|
|
while True:
|
|||
|
|
choice = select_option(
|
|||
|
|
f"Menu (settings: {settings.describe()}):",
|
|||
|
|
["Run a review example", "Edit provider settings", "Quit"],
|
|||
|
|
)
|
|||
|
|
if choice == 0:
|
|||
|
|
if not settings.provider:
|
|||
|
|
print("Configure provider settings first.")
|
|||
|
|
edit_settings(settings)
|
|||
|
|
if not settings.provider:
|
|||
|
|
continue
|
|||
|
|
run_example(settings)
|
|||
|
|
elif choice == 1:
|
|||
|
|
edit_settings(settings)
|
|||
|
|
else:
|
|||
|
|
return
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
try:
|
|||
|
|
main()
|
|||
|
|
except KeyboardInterrupt:
|
|||
|
|
print()
|