← back to Videos Agentabrams Worker

poll.py

223 lines

#!/usr/bin/env python3
"""
videos.agentabrams.com chat → auto-regenerate videos.

Polls /api/requests, processes new entries, edits flow JSON via local Ollama
(qwen3:14b on Mac1), re-runs ship-video which re-publishes to Kamatera.

Cursor (last-processed timestamp) lives at cursor.txt to avoid re-processing.
First run starts cursor at "now" so legacy/test entries are skipped.
"""
import os, sys, json, time, base64, subprocess, urllib.request, urllib.error, re
from pathlib import Path
from datetime import datetime, timezone

ROOT = Path.home() / 'Projects' / 'videos-agentabrams-worker'
ROOT.mkdir(parents=True, exist_ok=True)
CURSOR = ROOT / 'cursor.txt'
LOG = ROOT / 'log.jsonl'
MAP_FILE = ROOT / 'flow-map.json'
FLOWS_DIR = Path.home() / '.claude' / 'skills' / 'app-demo-video' / 'flows'
SHIP_VIDEO = Path.home() / 'bin' / 'ship-video'
OLLAMA_URL = os.environ.get('OLLAMA_URL', 'http://localhost:11434')
OLLAMA_MODEL = os.environ.get('OLLAMA_MODEL', 'gemma3:4b')  # fast + no-thinking for flow JSON edits

API_BASE = 'https://videos.agentabrams.com'
API_AUTH = ('admin', 'DWSecure2024!')


def log_event(entry):
    entry['ts'] = datetime.now(timezone.utc).isoformat()
    with LOG.open('a') as f:
        f.write(json.dumps(entry) + '\n')


def http_get(url):
    req = urllib.request.Request(url)
    token = base64.b64encode(f'{API_AUTH[0]}:{API_AUTH[1]}'.encode()).decode()
    req.add_header('Authorization', f'Basic {token}')
    with urllib.request.urlopen(req, timeout=30) as r:
        return json.loads(r.read())


def call_ollama(system, user, timeout=360):
    body = json.dumps({
        'model': OLLAMA_MODEL,
        'prompt': system + '\n\n' + user + '\n\nOutput only the updated JSON object, no explanation, no markdown.',
        'stream': False,
        'keep_alive': '30m',
        'options': {'temperature': 0.4, 'num_predict': 1500, 'num_ctx': 4096},
    }).encode()
    req = urllib.request.Request(
        f'{OLLAMA_URL}/api/generate',
        data=body,
        headers={'Content-Type': 'application/json'},
    )
    with urllib.request.urlopen(req, timeout=timeout) as r:
        return json.loads(r.read())['response']


def vid_id_to_slug(vid_id):
    """'Animals-Pitch-2026-05-06.mp4' → 'Animals-Pitch'"""
    base = vid_id[:-4] if vid_id.endswith('.mp4') else vid_id
    return re.sub(r'-\d{4}-\d{2}-\d{2}$', '', base)


def get_cursor():
    if CURSOR.exists():
        return CURSOR.read_text().strip()
    now = datetime.now(timezone.utc).isoformat()
    CURSOR.write_text(now)
    return now


def set_cursor(ts):
    CURSOR.write_text(ts)


VALID_ACTIONS = {'wait', 'scroll', 'click', 'hover'}


def validate_flow(flow):
    if not isinstance(flow, dict): return False, 'not a dict'
    if 'steps' not in flow or not isinstance(flow['steps'], list): return False, 'no steps[]'
    if not flow['steps']: return False, 'empty steps'
    if len(flow['steps']) > 16: return False, f'too many steps ({len(flow["steps"])})'
    for i, s in enumerate(flow['steps']):
        if not isinstance(s, dict): return False, f'step {i} not dict'
        if s.get('action') not in VALID_ACTIONS:
            return False, f'step {i} invalid action: {s.get("action")}'
        if s['action'] in ('click', 'hover') and not s.get('selector'):
            return False, f'step {i} ({s["action"]}) missing selector'
    return True, 'ok'


def edit_flow_via_llm(current_flow, user_msg, entry):
    system = (
        "Edit a JSON flow for a demo recorder. Action types: wait{ms,narration}, "
        "scroll{y,duration,narration}, click{selector,narration}, hover{selector,narration}. "
        "6-14 steps. Narration 5-10 words. Output ONE JSON object, nothing else."
    )
    user = (
        f"FLOW:\n{json.dumps(current_flow)}\n\n"
        f"SELECTORS: {entry.get('selectors_hint', '(none)')}\n\n"
        f"REQUEST: {user_msg}\n\n"
        "Output the updated flow JSON."
    )
    try:
        resp = call_ollama(system, user)
    except Exception as e:
        log_event({'event': 'ollama_error', 'error': str(e)})
        return None

    log_event({'event': 'llm_response_raw', 'head': (resp or '')[:400]})
    new_flow = None
    try:
        new_flow = json.loads(resp)
    except json.JSONDecodeError:
        m = re.search(r'\{[\s\S]*\}', resp)
        if m:
            try:
                new_flow = json.loads(m.group(0))
            except json.JSONDecodeError:
                pass
    if new_flow is None:
        log_event({'event': 'llm_invalid_json', 'response_head': resp[:400]})
        return None

    ok, why = validate_flow(new_flow)
    if not ok:
        log_event({'event': 'llm_invalid_schema', 'reason': why, 'flow': new_flow})
        return None

    if new_flow == current_flow:
        return None
    return new_flow


def process_request(req, flow_map):
    msg = req.get('message', '')
    ids = req.get('video_ids', []) or []
    log_event({'event': 'process_start', 'request_ts': req['ts'], 'video_ids': ids, 'message': msg})

    for vid_id in ids:
        slug = vid_id_to_slug(vid_id)
        if slug not in flow_map:
            log_event({'event': 'unknown_slug', 'video_id': vid_id, 'slug': slug})
            continue
        entry = flow_map[slug]
        flow_path = FLOWS_DIR / entry['flow_file']
        if not flow_path.exists():
            log_event({'event': 'flow_file_missing', 'video_id': vid_id, 'path': str(flow_path)})
            continue

        current = json.loads(flow_path.read_text())
        new_flow = edit_flow_via_llm(current, msg, entry)
        if not new_flow:
            log_event({'event': 'no_change', 'video_id': vid_id})
            continue

        # Backup + write
        backup = flow_path.with_suffix(f'.{int(time.time())}.bak.json')
        backup.write_text(flow_path.read_text())
        flow_path.write_text(json.dumps(new_flow, indent=2) + '\n')
        log_event({
            'event': 'flow_updated',
            'video_id': vid_id,
            'flow_file': str(flow_path),
            'backup': str(backup),
            'old_steps': len(current.get('steps', [])),
            'new_steps': len(new_flow.get('steps', [])),
        })

        # Re-record
        env = os.environ.copy()
        try:
            result = subprocess.run(
                [str(SHIP_VIDEO), entry['app_name'], entry['url']],
                capture_output=True, text=True, env=env, timeout=300,
            )
            log_event({
                'event': 'ship_video_done',
                'video_id': vid_id,
                'app_name': entry['app_name'],
                'returncode': result.returncode,
                'stdout_tail': (result.stdout or '')[-500:],
                'stderr_tail': (result.stderr or '')[-500:],
            })
        except subprocess.TimeoutExpired:
            log_event({'event': 'ship_video_timeout', 'video_id': vid_id})


def main():
    if not MAP_FILE.exists():
        print(f'flow-map.json missing at {MAP_FILE}', file=sys.stderr)
        sys.exit(2)
    flow_map = json.loads(MAP_FILE.read_text())

    cursor = get_cursor()
    try:
        data = http_get(f'{API_BASE}/api/requests')
    except Exception as e:
        log_event({'event': 'fetch_failed', 'error': str(e)})
        return

    requests = data.get('requests', [])
    new = [r for r in requests if r.get('ts', '') > cursor]
    if not new:
        return

    new.sort(key=lambda r: r['ts'])  # oldest first
    log_event({'event': 'tick', 'new_count': len(new), 'cursor': cursor})

    for req in new:
        try:
            process_request(req, flow_map)
        except Exception as e:
            log_event({'event': 'process_exception', 'request_ts': req['ts'], 'error': str(e)})
        set_cursor(req['ts'])


if __name__ == '__main__':
    main()