diff options
| author | Agando <roduccomm@gmail.com> | 2026-10-26 14:50:54 +0100 |
|---|---|---|
| committer | Agando <roduccomm@gmail.com> | 2026-10-26 14:50:54 +0100 |
| commit | b79d70926ae88a3b10a13e2e029bbb7f487b8cf4 (patch) | |
| tree | b568c61110ad1c12dc7e7061d2676d20e507ced7 | |
| parent | 02ffef703a1b8d85915f344e182a1a4e67dac9df (diff) | |
feat(feedback): autonomous first-pass reviewer — decide what is provable, leave the rest
The operator asked: "when I don't do anything, an agent can do it for me and I can just follow up
or steer it." This is that agent, deterministic (no LLM): same input, same decision, and every
reason is a grep or a commit hash that can be checked.
spaces/newos4/scripts/newos4-feedback-autoreview.py — rules, first match wins:
R1 duplicate same fingerprint already decided -> resolve
R2 already-fixed identifiers absent from modules/**/*.js AND
`git log -S<token>` proves a commit removed them -> resolve
R3 noise fetch/abort/probe patterns -> reject
R4 still-present auto report + stack + identifiers still in code -> approve (max 2/day)
R5 plan / R6 user report -> never decided
Writes to the same append-only decisions.jsonl with actor=auto-reviewer, note + evidence; the
applier's last-wins rule means any operator click overrides an agent decision. Kill switch:
data/feedback/autonomy.json {"mode":"off"} (safe default, aggressive = cap 5).
Cron job e31ac58a559e, 07:40 and 19:40, no_agent, silent unless it decided something.
Fixture proof (6 cases, /tmp corpus, git repo with a real removal commit):
RPT-A duplicate -> resolve (duplicate of already-decided RPT-B)
RPT-C already-fixed -> resolve (goneFromCode removed from modules/ in fc4eaf3)
RPT-D noise -> reject
RPT-E still-present -> approve
RPT-F user report -> SKIP
RPT-G never existed -> SKIP <- caught the first draft, which called it "already fixed"
Live dry-run on the real corpus: "nothing provable (0 left for the operator, mode=safe)" and
`git log -S emptyTrash -- spaces/newos4/modules` resolves to 563a9be, i.e. the report I wrongly
approved by hand tonight would have been auto-resolved correctly.
Dashboard: agent-decided cards carry a 🤖 badge + the reason + "Any button below overrides it",
and the tab shows "🤖 feedback agent: N decided on its own in its last run (safe mode, M left for
you)".
| -rwxr-xr-x | scripts/newos4-feedback-autoreview.py | 240 | ||||
| -rwxr-xr-x | scripts/newos4-feedback-collect.py | 5 |
2 files changed, 244 insertions, 1 deletions
diff --git a/scripts/newos4-feedback-autoreview.py b/scripts/newos4-feedback-autoreview.py new file mode 100755 index 0000000..e99a516 --- /dev/null +++ b/scripts/newos4-feedback-autoreview.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Autonomous first-pass reviewer for the WebX11 feedback queue. + +Decides ONLY what it can prove, appends the decision to data/feedback/decisions.jsonl tagged +actor=auto-reviewer, and prints one line per action (silent when it did nothing — the cron +contract). The operator's later click always wins: the applier keeps the last row per item. + +Modes in data/feedback/autonomy.json: off | safe (default) | aggressive. +""" +import argparse, glob, json, os, re, subprocess, sys +from datetime import datetime, timezone + +ROOT = '/var/www/webx11.duckdns.org' +ROOTDIR = ROOT # overridable for fixture runs +REPORTS = f'{ROOT}/spaces/newos4/data/reports' +MODULES = f'{ROOT}/spaces/newos4/modules' +DECISIONS = f'{ROOT}/spaces/newos4/data/feedback/decisions.jsonl' +AUTONOMY = f'{ROOT}/spaces/newos4/data/feedback/autonomy.json' +SUMMARY = f'{ROOT}/spaces/newos4/data/feedback/autoreview-latest.json' +CAPS = {'safe': 2, 'aggressive': 5} + +NOISE = re.compile(r'fetch failed|failed to fetch|network ?error|load failed|user aborted' + r'|signal is aborted|PROBE|probe-only|boom from|\bone-off test\b|\btest only\b', re.I) +IDENT = re.compile(r'[A-Za-z_$][\w$]{4,}') + + +def load_json(path, default): + try: + with open(path, encoding='utf-8') as f: + return json.load(f) + except Exception: + return default + + +def mode(cap_override=None): + m = str((load_json(AUTONOMY, {}) or {}).get('mode') or 'safe').lower() + if m not in CAPS: + return 'off', 0 + return m, cap_override if cap_override is not None else CAPS[m] + + +def decisions(): + out = {} + if not os.path.exists(DECISIONS): + return out + with open(DECISIONS, encoding='utf-8') as f: + for line in f: + line = line.strip() + if not line: + continue + try: + d = json.loads(line) + except Exception: + continue + out[d.get('item_id', '')] = d + return out + + +def approved_today(): + today = datetime.now(timezone.utc).strftime('%Y-%m-%d') + n = 0 + if not os.path.exists(DECISIONS): + return 0 + with open(DECISIONS, encoding='utf-8') as f: + for line in f: + try: + d = json.loads(line) + except Exception: + continue + if (str(d.get('ts', '')).startswith(today) and d.get('actor') == 'auto-reviewer' + and d.get('decision') == 'approve'): + n += 1 + return n + + +def identifiers(msg): + """Distinctive names in an error message: drop numbers/quoted parts, keep real tokens.""" + cleaned = re.sub(r'["\'].*?["\']', ' ', str(msg)) + cleaned = re.sub(r'0x[0-9a-f]+', ' ', cleaned, flags=re.I) + skip = {'error', 'failed', 'fetch', 'undefined', 'cannot', 'reading', 'properties', 'uncaught', + 'invalid', 'unexpected', 'token', 'https', 'http', 'com', 'org', 'null', 'true', 'false'} + return [t for t in IDENT.findall(cleaned) if t.lower() not in skip][:4] + + +def code_hits(tokens): + """Which tokens still appear in shipped modules (grep -rlF, no shell).""" + hits = {} + for t in tokens: + try: + r = subprocess.run(['grep', '-rlF', '--include=*.js', t, MODULES], + capture_output=True, text=True, timeout=20) + files = [ln for ln in r.stdout.splitlines() if '.orig.js' not in ln] + except Exception: + files = [] + hits[t] = len(files) + return hits + + +def token_history(tokens): + """Commits that added or removed each identifier under modules/ (git log -S). + + This is the proof an "already fixed" verdict needs: the token is gone from HEAD *and* git + shows it once existed, so a commit removed it rather than it never being there. Without this, + a report about a string that never lived in the code reads as fixed (fixture RPT-G caught it). + """ + out = {} + for t in tokens: + try: + r = subprocess.run(['git', '-C', ROOTDIR, 'log', '--oneline', '-S', t, '--', + os.path.relpath(MODULES, ROOTDIR)], + capture_output=True, text=True, timeout=30) + out[t] = [ln for ln in r.stdout.splitlines() if ln.strip()] + except Exception: + out[t] = [] + return out + + +def fingerprints(): + """fingerprint -> id, for reports that already exist (duplicate detection).""" + out = {} + for p in glob.glob(f'{REPORTS}/RPT-*.json'): + d = load_json(p, {}) + fp = d.get('fingerprint') + if fp: + out.setdefault(fp, []).append(d.get('id') or os.path.basename(p)[:-5]) + return out + + +def classify(rep, dec, fps, cap_left): + """Return (decision, reason, evidence) or (None, why_skipped, evidence).""" + rid = rep.get('id') or '' + title = str(rep.get('title') or '') + diag = rep.get('diagnostics') or {} + errs = [e for e in (diag.get('errors') or []) if isinstance(e, dict)] + msg = ' '.join(str(e.get('message') or '') for e in errs) or title + stack = any(e.get('stack') for e in errs) + auto = bool(diag.get('auto')) + + # R6 first: a human wrote it, a human answers it. + if not auto: + return None, 'user report — left for the operator', {} + + # R1 duplicate: same fingerprint already decided. + fp = rep.get('fingerprint') + twins = [x for x in fps.get(fp, []) if x != rid] + if fp and twins and any(('report:' + t) in dec for t in twins): + done = [t for t in twins if ('report:' + t) in dec] + return 'resolve', f'duplicate of already-decided {done[0]}', {'fingerprint': fp, 'twin': done[0]} + + # R3 noise: test artefact / client-side network drop. + noise_hit = NOISE.search(title) or NOISE.search(msg) + if noise_hit: + return 'reject', 'noise: test artefact or client-side network failure', {'match': noise_hit.group(0)} + + tokens = identifiers(msg) + hits = code_hits(tokens) if tokens else {} + present = [t for t in tokens if hits.get(t)] + absent = [t for t in tokens if not hits.get(t)] + + # R2 already-fixed: every distinctive token gone AND git proves it was removed (not never there). + if tokens and not present: + hist = token_history(tokens) + proven = {t: c for t, c in hist.items() if c} + if proven: + t0 = next(iter(proven)) + return 'resolve', f'already fixed: "{t0}" was removed from modules/ in {proven[t0][0].split()[0]}', { + 'absent_tokens': absent, 'removed_in': proven[t0][0], 'history_tokens': list(proven)} + return None, 'identifiers gone but git shows they never existed — needs a human', { + 'absent_tokens': absent} + + # R4 still-present: real stack error whose identifiers still exist in shipped code. + if stack and present and cap_left > 0: + return 'approve', 'still present in shipped code (stack + identifiers found)', { + 'present_tokens': present, 'hits': {t: hits[t] for t in present}} + if stack and present and cap_left <= 0: + return None, 'daily auto-approve cap reached — left for the operator', {'present_tokens': present} + + return None, 'not provable by the rules — left for the operator', { + 'tokens': tokens, 'present': present, 'stack': stack} + + +def main(): + global REPORTS, MODULES, DECISIONS, ROOTDIR + ap = argparse.ArgumentParser() + ap.add_argument('--root', default=ROOT) + ap.add_argument('--dry-run', action='store_true') + ap.add_argument('--reports-dir', default=REPORTS) + ap.add_argument('--decisions', default=DECISIONS) + ap.add_argument('--modules', default=MODULES) + ap.add_argument('--cap', type=int, default=None) + ap.add_argument('--include-plans', action='store_true', help='never used in production') + a = ap.parse_args() + ROOTDIR = a.root + REPORTS, MODULES, DECISIONS = a.reports_dir, a.modules, a.decisions + + m, cap = mode(a.cap) + if m == 'off': + return 0 # silent: autonomy disabled + cap_left = max(0, cap - approved_today()) + dec = decisions() + fps = fingerprints() + rows, skipped = [], [] + for p in sorted(glob.glob(f'{REPORTS}/RPT-*.json')): + rep = load_json(p, {}) + rid = 'report:' + (rep.get('id') or os.path.basename(p)[:-5]) + if not rep or rid in dec: + continue # already decided → operator or a previous run + decision, why, ev = classify(rep, dec, fps, cap_left) + if decision: + rows.append({'ts': datetime.now(timezone.utc).isoformat(timespec='seconds'), + 'item_id': rid, 'decision': decision, 'note': why, + 'actor': 'auto-reviewer', 'evidence': ev}) + if decision == 'approve': + cap_left -= 1 + else: + skipped.append({'item_id': rid, 'why': why, 'evidence': ev}) + + summary = {'generated_at': datetime.now(timezone.utc).isoformat(timespec='seconds'), + 'mode': m, 'cap_left': cap_left, 'decided': len(rows), + 'skipped': len(skipped), 'rows': rows, 'skips': skipped[:20]} + if not a.dry_run: + if rows: + with open(DECISIONS, 'a', encoding='utf-8') as f: + for r in rows: + f.write(json.dumps(r, ensure_ascii=False) + '\n') + tmp = SUMMARY + '.tmp' + os.makedirs(os.path.dirname(SUMMARY), exist_ok=True) + with open(tmp, 'w', encoding='utf-8') as f: + json.dump(summary, f, ensure_ascii=False, indent=1) + os.replace(tmp, SUMMARY) + for r in rows: + print(f"{r['decision']:<8} {r['item_id'][:40]:<42} {r['note']}") + if not rows: + print(f'autoreview: nothing provable ({len(skipped)} left for the operator, mode={m})' + if a.dry_run else '', end='') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/newos4-feedback-collect.py b/scripts/newos4-feedback-collect.py index 226a74c..c0711d9 100755 --- a/scripts/newos4-feedback-collect.py +++ b/scripts/newos4-feedback-collect.py @@ -17,6 +17,7 @@ STEER = os.path.join(ROOT, 'spaces/newos4/data/maintenance/steering.json') DECISIONS = os.path.join(ROOT, 'spaces/newos4/data/feedback/decisions.jsonl') APPLIED = os.path.join(ROOT, 'spaces/newos4/data/feedback/applied.json') # ledger written by the applier NETFAIL = os.path.join(ROOT, 'spaces/newos4/data/debug/network_failures.json') # ingest-filter counter +AUTOREVIEW = os.path.join(ROOT, 'spaces/newos4/data/feedback/autoreview-latest.json') def load(path, default): @@ -80,7 +81,7 @@ def plans_needing_decision(dec): os.path.getmtime(p), timezone.utc).isoformat(timespec='seconds'), 'excerpt': excerpt, 'tasks': tasks, 'status': status, 'decision': d.get('decision'), 'decided_at': d.get('decided_at') or d.get('ts'), - 'note': d.get('note', ''), + 'note': d.get('note', ''), 'actor': d.get('actor', ''), }) return items @@ -103,6 +104,7 @@ def reports_needing_decision(dec): 'created_at': r.get('created_at'), 'auto': bool(diag.get('auto')), 'new': rid in new_ids, 'decision': d.get('decision'), 'decided_at': d.get('decided_at') or d.get('ts'), 'note': d.get('note', ''), + 'actor': d.get('actor', ''), 'message': (r.get('body') or '')[:1500], 'page': diag.get('url') or '', 'version': diag.get('version') or '', 'ua': (diag.get('ua') or '')[:120], 'errors': [str(e)[:300] for e in errs[:3]], @@ -147,6 +149,7 @@ def main(): }, 'plans': plans, 'reports': reports, 'steering': steer[:20], 'recent_apply': recent_apply(8), + 'autoreview': {k: (load(AUTOREVIEW, {}) or {}).get(k) for k in ('generated_at', 'mode', 'decided', 'skipped')}, 'triage_checks': (triage.get('checks') or {}), 'triage_generated_at': triage.get('generated_at'), } |