File size: 21,257 Bytes
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ea7b176
 
 
 
 
c14ceee
ea7b176
 
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
"""harness/analyst.py β€” the AIOS Analyst loop (OM-4, 2026-07-11).

A CHEAP small model (via OpenRouter, OpenAI-compatible) drives the compounding tool registry
(harness/tools.py) to answer questions, build charts, and spawn dashboards over the semantic
layer. The model NEVER writes SQL β€” it picks registry keys, guided by the in-product SKILL
recipes (model/skills/*.skill.yml) injected into its system prompt. Plan: omni-adoption addendum.

Architecture (mirrors the Omni coordinator pattern):
  ask(question) -> bounded tool loop: chat -> tool_calls -> tools.dispatch() -> results -> chat …
  until a plain answer or MAX_ITERS. chat_fn is INJECTABLE: live = OpenRouter POST; tests = a
  scripted double β€” the loop mechanics are provable offline (no key, no tokens).

Correctness posture: bounded iterations (never endless); tool errors go BACK to the model as
readable envelopes (it self-corrects); the final answer carries the tool trace (auditability +
the drill guarantee); exhausted-budget is reported AS exhausted, never as success (loop rules).
Cost posture: DEFAULT_MODEL is a cheap tool-calling model; override per-tenant/env; prompt = the
skills + topic summary (small, cacheable), NOT raw data.
"""
import json
import os
from pathlib import Path

import harness.tools as T

# Provider LADDER (owner 2026-07-12: "use Groq and Cerebras"): all OpenAI-compatible chat
# endpoints; tried in order, failing over on payment/rate/server errors β€” an exhausted or
# rate-limited provider degrades to the next instead of taking the Analyst down (the
# OpenRouter-402 incident). Pin one with ANALYST_PROVIDER=groq|cerebras|openrouter; override
# its model with ANALYST_MODEL. Every model here must re-pass the eval gate (Β§4b).
PROVIDERS = [
    {"name": "groq", "env": "GROQ_API_KEY",
     "url": "https://api.groq.com/openai/v1/chat/completions",
     "model": "llama-3.3-70b-versatile"},
    {"name": "cerebras", "env": "CEREBRAS_API_KEY",
     "url": "https://api.cerebras.ai/v1/chat/completions",
     "model": "gpt-oss-120b"},          # this account's tool-capable model (from /v1/models)
    {"name": "openrouter", "env": "OPENROUTER_API_KEY",
     "url": "https://openrouter.ai/api/v1/chat/completions",
     "model": "openai/gpt-4o-mini"},
]
DEFAULT_MODEL = None          # None = the selected provider's default model
MAX_ITERS = 8


def available_providers():
    pin = os.environ.get("ANALYST_PROVIDER")
    ps = [p for p in PROVIDERS if os.environ.get(p["env"])]
    if pin:
        ps = [p for p in ps if p["name"] == pin] or ps
    return ps


def llm_configured():
    return bool(available_providers())
SKILLS_DIR = Path(__file__).resolve().parents[1] / "model" / "skills"


def _skills_text():
    parts = []
    for f in sorted(SKILLS_DIR.glob("*.skill.yml")):
        parts.append(f.read_text(encoding="utf-8"))
    return "\n\n".join(parts) or "(no skill files installed)"


def system_prompt():
    """The small model's standing instructions: the platform rules + the skill recipes.
    Stable content (cache-friendly); per-question content goes in the user turn."""
    topics = T.list_topics()
    topic_summary = "\n".join(
        f"- {t['topic']}: {t['label']} | metrics: {', '.join(t['metrics'])} | dims: {', '.join(t['dims'])}"
        for t in topics)
    return f"""You are the AIOS Analyst for a wholesale business. You answer data questions by
calling TOOLS over a governed semantic layer. THE RULES (non-negotiable):
1. You NEVER write SQL or invent field names β€” only registry keys from the tools' schemas.
2. Start unknown asks with list_topics/describe_topic. Resolve typed names (customers, products)
   with get_field_values BEFORE filtering. Business units: Fisch=5, Royal=6 β€” never mix them in
   one series unless explicitly comparing.
3. Every number you present must come from a tool result and keep its result_id (drillable).
   If a tool errors, read the error and correct your call β€” do not make numbers up, ever.
4. Artifact actions (save_view, compose_dashboard, schedule_report, create_alert) need the
   user's explicit confirmation first.
5. Answer plainly and lead with the finding. State the window and scope you used β€” READ the
   window from the tool result's 'window' field, never guess it: a query without dates covers
   ALL recorded history (there is no hidden default window). Time-bound asks ("this year",
   "past 4 weeks") must pass explicit date_from/date_to.
6. A single-number question gets THE SINGLE NUMBER first (the total for the asked window/scope),
   THEN any breakdown. Never answer a "what was X" question with only components: no monthly
   lists without the total, no per-BU splits when the consolidated figure was asked. If the ask
   is consolidated, query WITHOUT group_by (or sum before answering).
7. NEVER state an aggregate you did not obtain from a tool result. If you want to mention a
   total across the rows you fetched, either run the scalar query for it or ADD UP those exact
   rows β€” a round "approximately" figure is a fabrication and forbidden. When in doubt, present
   the rows without a total. Joining or diffing two result sets BY HAND (e.g. this year vs last
   year per customer) is equally forbidden β€” that is transform_result's job ({{"op":"yoy"}} adds
   <m>_ly, <m>_delta, <m>_yoy_pct per row); if no transform can compute it, say you cannot.
8. THE GAP PROTOCOL. If β€” and only if β€” after checking the schema (list_topics/describe_topic)
   no registered dimension, metric, transform or chart kind can answer, do NOT guess and do NOT
   force a wrong query: call report_gap(kind, missing, question, workaround) ONCE, then answer
   in one honest sentence what is missing and offer the nearest ask that IS answerable. Before
   concluding a gap, check the TRANSFORMS: year-over-year / decline / growth comparisons (yoy),
   rankings (top_n/sort/head), shares, running totals and distributions are ALL answerable via
   transform_result β€” a derived-analytics question is never a gap. Both directions are
   failures: refusing without report_gap, and calling report_gap for something the tools
   support.
9. NEVER emit markdown images, data: URIs, or links to charts β€” you cannot draw. Charts exist
   ONLY through make_chart / make_table; the platform renders them below your answer. Refer to
   a chart as "the chart below". Derived analytics (top-N, YoY, shares, running totals, moving
   averages, ranks, bins) exist ONLY through transform_result β€” never arithmetic in your head.

CHART PICKER (the form follows the comparison β€” pick by what the question compares):
- trend over time -> line (few periods -> bar); seasonal/two-dim intensity -> heatmap
- this year vs last year by period -> transform_result [{{"op":"yoy"}}] then yoy_bars
- ranking / "top N" -> transform_result top_n then ranked_bar (sorted, labeled)
- part-to-whole -> pie|donut ONLY when <=6 slices (top_n first); over time -> stacked_bar;
  shares of a shifting total -> stacked_pct; hierarchy of shares -> treemap
- what drove the change / contribution -> waterfall (signed steps; platform adds the Total)
- concentration ("do the top X carry the book?") -> pareto
- correlation of two measures -> scatter; + a third measure -> bubble (size)
- distribution of a measure -> histogram (or transform bin -> bar)
- stage conversion -> funnel; actual vs target -> bullet (y=actual, y2=target)
- level + rate together (e.g. GM$ + GM%) -> combo (y=bars left, y2=line right)
- same small chart per segment -> facet on line|bar|area|scatter
- exact figures, many columns, lists -> make_table (totals row included)
- customer geography -> map. When two forms tie, pick the simpler one.

DATASETS:
{topic_summary}

YOUR SKILL RECIPES (follow these plans; adjust the [bracketed] parameters):
{_skills_text()}"""


_DEAD_STATUS = {401, 402, 403}        # payment/auth: skip the provider immediately, no retry
_RETRY_STATUS = {429, 500, 502, 503}  # rate/server: retry with backoff before failing over
_MAX_ANSWER_TOKENS = 1500             # answers are short; also keeps low-credit providers usable
_COOLDOWN = {}                        # provider name -> unix ts until which we skip it (in-proc)


def _live_chat(messages, tools, model, tool_choice="auto"):
    """POST down the provider ladder with LATENCY-FIRST failover (a chat user is waiting):
    a rate-limited provider is SKIPPED IMMEDIATELY in favor of the next one β€” we only sleep
    when the ENTIRE ladder is limited (then short ladder-level backoffs). A LONG Retry-After
    (daily quota, e.g. Groq TPD) puts the provider in an in-process COOLDOWN; 401/402/403 skip
    the provider outright."""
    import time as _time

    import requests
    provs = available_providers()
    if not provs:
        raise RuntimeError("no LLM provider configured β€” set GROQ_API_KEY / CEREBRAS_API_KEY / "
                           "OPENROUTER_API_KEY in platform/.env")
    last_err = None
    # Two patience profiles: CHAT (default) fails over fast β€” a user is waiting; BATCH
    # (ANALYST_PATIENT=1, set by the eval gate / insight runner) waits out per-minute rate
    # windows across more ladder rounds instead of exhausting the ladder in seconds.
    patient = os.environ.get("ANALYST_PATIENT") == "1"
    round_sleeps = (5, 10, 20, 30, 45, 60) if patient else (2, 6, 12)
    for rnd in range(len(round_sleeps)):                   # ladder rounds
        saw_retryable = False
        for p in provs:
            if _COOLDOWN.get(p["name"], 0) > _time.time():
                last_err = RuntimeError(f"{p['name']}: cooling down (quota)")
                continue
            try:
                r = requests.post(p["url"], timeout=120,
                                  headers={"Authorization": f"Bearer {os.environ[p['env']]}",
                                           "HTTP-Referer": "https://aios.local",
                                           "X-Title": "AIOS Analyst"},
                                  json={"model": (model or os.environ.get("ANALYST_MODEL")
                                                  or p["model"]),
                                        "messages": messages, "tools": tools,
                                        "tool_choice": tool_choice, "temperature": 0.1,
                                        "max_tokens": _MAX_ANSWER_TOKENS})
                if r.status_code in _DEAD_STATUS:
                    last_err = RuntimeError(f"{p['name']}: HTTP {r.status_code}")
                    _COOLDOWN[p["name"]] = _time.time() + 300   # don't re-poke dead auth/payment
                    continue
                if r.status_code == 400:
                    # β›” WAVE 32 (W32-T50/T54) β€” A 400 FROM THIS LADDER IS NOT A TRANSPORT FACT,
                    # AND THIS FUNCTION USED TO THROW ITS EXPLANATION AWAY. Groq validates the
                    # model's tool ARGUMENTS against the tool schema server-side and answers
                    # `400 tool_use_failed` with the generation it rejected in `failed_generation`.
                    # Measured 2026-08-13: a correct refusal that omitted a `required` property
                    # arrived here, `raise_for_status()` turned it into "400 Client Error", and the
                    # caller was told `all LLM providers failed` β€” for a request the model had
                    # answered. The control flow is unchanged (still fail over); what changes is
                    # that the message NAMES the cause, because a schema this ladder sent is the
                    # one thing the operator can actually fix.
                    detail = ""
                    try:
                        e400 = (r.json() or {}).get("error") or {}
                        detail = str(e400.get("message") or "")[:300]
                        if e400.get("failed_generation"):
                            detail += f" | rejected generation: " \
                                      f"{str(e400['failed_generation'])[:300]}"
                    except Exception:
                        detail = (r.text or "")[:300]
                    last_err = RuntimeError(f"{p['name']}: HTTP 400 {detail}")
                    continue
                if r.status_code in _RETRY_STATUS:
                    last_err = RuntimeError(f"{p['name']}: HTTP {r.status_code}")
                    try:
                        wait = float(r.headers.get("retry-after") or 0)
                    except ValueError:
                        wait = 0
                    if wait > 60:                          # daily/long quota β€” cool down
                        _COOLDOWN[p["name"]] = _time.time() + wait
                    else:
                        saw_retryable = True               # NO sleep β€” next provider NOW
                    continue
                r.raise_for_status()
                usage = r.json().get("usage", {})
                return r.json()["choices"][0]["message"], usage
            except requests.RequestException as e:
                last_err = e
                continue                                   # network blip β€” next provider
        if not saw_retryable:
            break                                          # nothing retryable left anywhere
        _time.sleep(round_sleeps[rnd])                     # whole ladder limited β€” pause a round
    raise RuntimeError(f"all LLM providers failed (last: {last_err})")


#: Wave 15 R2 β€” what a restricted account is told instead of an answer.
ANALYST_DENIED = ("The Analyst is not available on your account. It answers from the full "
                  "governed model, which is wider than what your account is permitted to see.")


def may_ask(user):
    """β›” R2 β€” THE AI WALL, AND IT IS DELIBERATELY BLUNT.

    Owner, verbatim: *"If the AI is triggered by a user, the AI ITSELF should not be able to see
    the data."* Today the Analyst's tools run UNSCOPED β€” `harness/tools.run_semantic_query` takes
    no subject and no filter, so every question it answers is answered over the whole tenant.
    A restricted account asking "what were our top customers?" would therefore receive, in prose,
    precisely the rows the grid's permanent filter exists to withhold. Prose is not a lesser leak
    than a table; it is a harder one to notice.

    So the wall is REFUSAL, not narrowing: an account with ANY declared restriction β€” or any
    non-admin on a migrated record β€” does not reach the model at all. An AI that never runs
    cannot leak what it never read. Fail-closed on an unreadable record for the same reason.

    β­’ NAMED DEBT, owner-mandated (R2, doc Β§Debt, log 2026-08-02). The RIGHT answer is to thread
    the subject's permanent filter into every semantic query the Analyst runs β€” `filter_sql`
    already compiles that tree to SQL, so the shape exists; what does not exist is a per-subject
    query path through `harness/semantic.py`, which takes `team_id` and nothing else. Until that
    lands this refusal is the whole enforcement, and it must NOT be relaxed into "narrow the
    prompt" or "tell the model what it may not say" β€” a prompt is not a permission boundary.
    """
    import core.perms as perms
    import core.perm_scope as perm_scope

    if not isinstance(user, dict):
        return False                       # no identity resolved -> no Analyst
    if perms.is_admin(user):
        return True
    if perm_scope.is_migrated(user):
        # Migrated and not an admin: the account is governed by a wall the Analyst cannot honour.
        return False
    # Legacy record: restricted the old way is still restricted.
    return (perms.allowed_modules(user) is None
            and perms.scope_team_id(user) is None
            and not perms.scope_agent(user))


def ask(question, history=None, chat_fn=None, model=DEFAULT_MODEL, max_iters=MAX_ITERS,
        telemetry_kind="analyst", user=None):
    """Run one Analyst turn. Returns {answer, artifacts, tool_trace, iterations, exhausted,
    usage}. chat_fn(messages, tools, model) -> (assistant_message_dict, usage_dict).

    `user` is the SUBJECT asking. Passing it engages the R2 wall (`may_ask`); omitting it keeps
    the unscoped behaviour that `harness/evals.py` and `harness/routines.py` rely on β€” those run
    as the SYSTEM, not on behalf of a person, and gating them on a user record they do not have
    would break the eval ladder to no security benefit. Every call that IS on behalf of somebody
    passes it, and `app.py`'s chat entry point does.
    """
    if user is not None and not may_ask(user):
        return {"answer": ANALYST_DENIED, "artifacts": [], "tool_trace": [],
                "iterations": 0, "exhausted": False, "denied": True,
                "usage": {"prompt_tokens": 0, "completion_tokens": 0}}
    chat = chat_fn or _live_chat
    tools = T.openai_tools()
    messages = [{"role": "system", "content": system_prompt()}]
    messages += list(history or [])
    # The model has no clock: anchor relative windows ("last week", "this month") to today.
    # Lives in the USER turn so the system prompt stays byte-stable (prompt-cache friendly).
    import datetime as _dt
    messages.append({"role": "user",
                     "content": f"(today is {_dt.date.today().isoformat()})\n{question}"})
    trace, artifacts, usage_total = [], [], {"prompt_tokens": 0, "completion_tokens": 0}

    for i in range(max_iters):
        msg, usage = chat(messages, tools, model)
        for k in usage_total:
            usage_total[k] += usage.get(k, 0) or 0
        tool_calls = msg.get("tool_calls") or []
        # Free-tier models INTERMITTENTLY answer a data question without calling any tool
        # (~1 in 4 observed on gpt-oss-120b β€” the "no chart" defect). Deterministic fix: if
        # the VERY FIRST turn used no tools, redo it once with tool_choice='required'. Meta
        # questions just get a harmless schema lookup before answering.
        if i == 0 and not tool_calls and chat is _live_chat:
            msg, usage = _live_chat(messages, tools, model, tool_choice="required")
            for k in usage_total:
                usage_total[k] += usage.get(k, 0) or 0
            tool_calls = msg.get("tool_calls") or []
        messages.append({"role": "assistant", "content": msg.get("content") or "",
                         **({"tool_calls": tool_calls} if tool_calls else {})})
        if not tool_calls:
            answer = (msg.get("content") or "").strip()
            if not answer:
                # A model occasionally returns NO content and NO tool calls (observed live
                # 2026-07-16: the reply bubble was simply empty and the next turn hallucinated).
                # Never surface silence β€” report the failure honestly instead.
                answer = ("(the model returned an empty answer β€” nothing was computed; "
                          "please rephrase or ask again)")
            import harness.telemetry as _tel
            # Wave 19 (R4): ATTRIBUTED. The token count was previously logged against nobody, so
            # "which customer is spending model budget, and who inside it?" had no answer even in
            # principle. Read defensively off `user` because this function is deliberately
            # callable WITHOUT one β€” `harness/evals.py` and `harness/routines.py` run as the
            # system, and telemetry omits both keys for them rather than inventing a subject.
            _tel.log(telemetry_kind, question=question[:200],
                     user=(user or {}).get("username") if isinstance(user, dict) else None,
                     tenant=(user or {}).get("tenant") if isinstance(user, dict) else None,
                     model=model or (available_providers() or [{}])[0].get("name", "auto"),
                     iterations=i + 1,
                     tools=[t["tool"] for t in trace],
                     tokens=usage_total["prompt_tokens"] + usage_total["completion_tokens"])
            return {"answer": answer, "artifacts": artifacts,
                    "tool_trace": trace, "iterations": i + 1, "exhausted": False,
                    "usage": usage_total}
        for tc in tool_calls:
            name = tc["function"]["name"]
            args = tc["function"].get("arguments") or "{}"
            res = T.dispatch(name, args)
            trace.append({"tool": name, "args": args if isinstance(args, str) else json.dumps(args),
                          "ok": res["ok"], "error": res.get("error")})
            if res["ok"] and isinstance(res["data"], dict) and (
                    "chart" in res["data"] or "kpi" in res["data"] or "table" in res["data"]
                    or "dashboard" in res["data"]):
                artifacts.append(res["data"])
            messages.append({"role": "tool", "tool_call_id": tc.get("id", name),
                             "content": json.dumps(res, default=str)[:8000]})
    # budget exhausted β€” report honestly (never as success)
    return {"answer": "(stopped: tool-call budget exhausted before a final answer β€” "
                      "narrow the question or raise max_iters)",
            "artifacts": artifacts, "tool_trace": trace, "iterations": max_iters,
            "exhausted": True, "usage": usage_total}