File size: 10,706 Bytes
c3e4cb4 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 | """AI REVIEW β let a model decide which stage a card moves to (wave 23, owner ruling R4/R14).
β WHAT THIS IS. A review stage holds a record until somebody decides where it goes next. R4 made
that somebody optionally a MODEL: the engine hands over the record's own values, the review's
prompt, and the list of stages the review is allowed to send a card to, and gets back ONE of
those stage labels plus a one-line reason. Every decision is written to the same `reviews` audit
log a human click writes to, tagged `by: "ai"` with the provider and model that made it.
β FAIL-CLOSED IN EVERY DIRECTION, and this is the whole safety story. No key configured, a
network failure, a slow answer, a malformed answer, or an answer naming a stage the review does
not offer β all return `("", {...})`, and the caller leaves the card exactly where a human would
have found it. The feature can be absent, broken or wrong and the worst outcome is a person doing
the work. Nothing here can move a card somewhere the review does not already permit.
β CHEAP FIRST (owner R14, verbatim: *"Claude is a bit too expensive"*). The ladder is
`groq β cerebras β openrouter β anthropic`; the first CONFIGURED provider wins, and Anthropic is
last rather than absent β it is the quality backstop, not the default. `AIOS_AI_REVIEW_PROVIDER`
pins one; `AIOS_AI_MODEL` overrides the model.
β WHY RAW HTTP RATHER THAN THE `anthropic` SDK, stated because it is a deliberate deviation from
the /claude-api skill's default and not an oversight. Three of the four legs are OpenAI-chat-shaped
endpoints with no shared SDK, so a ladder built on the SDK would be one SDK leg beside three
hand-rolled ones β two implementations of the same call, the seam this repo keeps closing. And
`aios-web/api/requirements.txt` is PINNED to what the verify battery proves ([[pin-deps-space-
rebuilds]]): adding a dependency there rebuilds the container, which is a real deploy risk to take
for one leg of an optional feature. `requests` is already a dependency and `harness/analyst.py`
established per-provider raw HTTP as the house pattern. Booked as a DEBT line so the integrator
can overturn it deliberately rather than by drift.
The Messages shape below is the current one: `x-api-key` + `anthropic-version: 2023-06-01`, and
`stop_reason: "refusal"` is checked BEFORE reading `content` β a refusal answers HTTP 200 with an
empty content list, so code that indexes `content[0]` unconditionally breaks on it.
"""
from __future__ import annotations
import json
import os
import re
import requests
#: The ladder. Order IS the policy (R14) β cheapest capable first, Anthropic last.
PROVIDERS = [
{"name": "groq", "env": "GROQ_API_KEY", "shape": "openai",
"url": "https://api.groq.com/openai/v1/chat/completions",
"model": "llama-3.3-70b-versatile"},
{"name": "cerebras", "env": "CEREBRAS_API_KEY", "shape": "openai",
"url": "https://api.cerebras.ai/v1/chat/completions",
"model": "gpt-oss-120b"},
{"name": "openrouter", "env": "OPENROUTER_API_KEY", "shape": "openai",
"url": "https://openrouter.ai/api/v1/chat/completions",
"model": "openai/gpt-4o-mini"},
# β haiku-class DELIBERATELY, not the default Opus tier: R14 put Anthropic on this ladder as
# the backstop for a one-line classification, and this is the cheapest current Claude that
# does it well. A bigger model here would be spending the owner's money to pick between two
# labels it already has in front of it.
{"name": "anthropic", "env": "ANTHROPIC_API_KEY", "shape": "anthropic",
"url": "https://api.anthropic.com/v1/messages",
"model": "claude-haiku-4-5"},
]
ANTHROPIC_VERSION = "2023-06-01"
TIMEOUT_SECONDS = float(os.environ.get("AIOS_AI_REVIEW_TIMEOUT") or 20)
MAX_FIELD_CHARS = 200 # per value handed to the model
MAX_FIELDS = 30 # columns handed to the model
MAX_REASON = 200
def ladder():
"""The providers that are actually usable here, in order. Empty = the feature is off."""
pin = (os.environ.get("AIOS_AI_REVIEW_PROVIDER") or "").strip().lower()
live = [p for p in PROVIDERS if (os.environ.get(p["env"]) or "").strip()]
if pin:
live = [p for p in live if p["name"] == pin]
return live
def configured():
return bool(ladder())
def _record_text(row, fields):
"""The record, as the model sees it. Values are truncated and the column set is bounded β
an automation table can carry a 32 KB JSON blob per row (C7) and a review decision does not
need it. Machine bookkeeping columns are dropped: a stage cell naming the stage the card is
sitting at would be the model reading its own question back."""
keys = [k for k in (fields or list((row or {}).keys()))
if not str(k).startswith("stage_")][:MAX_FIELDS]
lines = []
for k in keys:
v = (row or {}).get(k)
if v is None or str(v).strip() == "":
continue
lines.append(f"{k}: {str(v)[:MAX_FIELD_CHARS]}")
return "\n".join(lines) or "(this record has no filled-in values)"
def _instruction(prompt, options, label):
return (
f"You are deciding what happens to one record waiting at a review step called "
f"{label!r} in a workflow.\n\n"
f"The person who built this workflow told you: {prompt}\n\n"
f"Choose EXACTLY ONE of these next steps, by its exact name:\n"
+ "\n".join(f"- {o}" for o in options)
+ "\n\nAnswer with one line of JSON and nothing else:\n"
'{"choice": "<one name from the list above>", "reason": "<one short sentence>"}\n'
"If the record does not give you enough to decide, answer "
'{"choice": "", "reason": "why not"} and a person will decide instead.'
)
def _parse(text, options):
"""The model's line β `(choice, reason)`. A choice that is not one of the offered stages is
DISCARDED, not fuzzy-matched: the offered list is a permission boundary, and a near-miss
resolved by string distance is how a card ends up somewhere nobody authorised."""
raw = str(text or "").strip()
obj = None
m = re.search(r"\{.*\}", raw, re.S)
if m:
try:
obj = json.loads(m.group(0))
except (ValueError, TypeError):
obj = None
if not isinstance(obj, dict):
return "", ""
choice = str(obj.get("choice") or "").strip()
reason = str(obj.get("reason") or "").strip()[:MAX_REASON]
for opt in options:
if choice.lower() == str(opt).lower():
return str(opt), reason # the OFFERED spelling wins, never the model's
return "", reason
def _call_openai(p, model, system, user, timeout):
r = requests.post(p["url"], timeout=timeout,
headers={"Authorization": f"Bearer {os.environ[p['env']].strip()}",
"Content-Type": "application/json"},
json={"model": model, "max_tokens": 300, "temperature": 0,
"messages": [{"role": "system", "content": system},
{"role": "user", "content": user}]})
if r.status_code >= 400:
return "", f"{p['name']} answered {r.status_code}"
body = r.json()
choices = body.get("choices") or []
if not choices:
return "", f"{p['name']} returned no choices"
return str(((choices[0] or {}).get("message") or {}).get("content") or ""), ""
def _call_anthropic(p, model, system, user, timeout):
r = requests.post(p["url"], timeout=timeout,
headers={"x-api-key": os.environ[p["env"]].strip(),
"anthropic-version": ANTHROPIC_VERSION,
"content-type": "application/json"},
json={"model": model, "max_tokens": 300, "system": system,
"messages": [{"role": "user", "content": user}]})
if r.status_code >= 400:
return "", f"anthropic answered {r.status_code}"
body = r.json()
# β stop_reason FIRST. A safety refusal is a successful 200 with an EMPTY content list, so
# reading content[0] before this check turns a refusal into an IndexError inside a run.
if body.get("stop_reason") == "refusal":
return "", "anthropic declined to answer this record"
parts = [b.get("text") or "" for b in (body.get("content") or [])
if isinstance(b, dict) and b.get("type") == "text"]
if not parts:
return "", "anthropic returned no text"
return "".join(parts), ""
def decide(*, prompt, options, row, fields=(), label="Review", timeout=None):
"""Pick this record's next stage. Returns `(choice, meta)`.
`choice` is "" whenever a person should decide β which is every failure mode there is.
`meta` carries `provider`, `model`, `reason` on success, and `problem` on refusal to answer.
"""
opts = [str(o) for o in (options or []) if str(o).strip()]
if not opts:
return "", {"problem": "the review offers no next stages"}
if not str(prompt or "").strip():
return "", {"problem": "the review has no prompt for the model to follow"}
live = ladder()
if not live:
return "", {"problem": "no AI provider is configured on this deployment"}
system = _instruction(prompt, opts, label)
user = "Here is the record:\n\n" + _record_text(row, fields)
tmo = float(timeout or TIMEOUT_SECONDS)
override = (os.environ.get("AIOS_AI_MODEL") or "").strip()
problems = []
for p in live:
model = override or p["model"]
try:
text, err = (_call_anthropic if p["shape"] == "anthropic" else _call_openai)(
p, model, system, user, tmo)
except Exception as e: # noqa: BLE001
text, err = "", f"{p['name']} failed: {type(e).__name__}"
if err:
problems.append(err)
continue # ladder: a dead provider degrades to the next one
choice, reason = _parse(text, opts)
if not choice:
# The provider ANSWERED and declined (or answered unusably). That is a decision about
# this record, not a fault in the provider, so it does NOT fall through to a more
# expensive one β the card goes to a human, which is what the model just asked for.
return "", {"provider": p["name"], "model": model,
"problem": reason or "the model did not choose one of the stages"}
return choice, {"provider": p["name"], "model": model, "reason": reason}
return "", {"problem": "; ".join(problems)[:300] or "no provider answered"}
|