| # Licensed to the Apache Software Foundation (ASF) under one | |
| # or more contributor license agreements. See the NOTICE file | |
| # distributed with this work for additional information | |
| # regarding copyright ownership. The ASF licenses this file | |
| # to you under the Apache License, Version 2.0 (the | |
| # "License"); you may not use this file except in compliance | |
| # with the License. You may obtain a copy of the License at | |
| # | |
| # http://www.apache.org/licenses/LICENSE-2.0 | |
| # | |
| # Unless required by applicable law or agreed to in writing, | |
| # software distributed under the License is distributed on an | |
| # "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| # KIND, either express or implied. See the License for the | |
| # specific language governing permissions and limitations | |
| # under the License. | |
| """What the analysis scripts share: the settings, the days, the clusters and their store. | |
| The analyses of the prompt log are R&D and live in this directory, outside | |
| the llmao package (docs/prompt-logging-design.md, "The post-process"): | |
| nothing in llmao/ or bin/ imports from here. The scripts import | |
| llmao.prompt_log for the records themselves and this module for what they | |
| need around them. | |
| config.yaml is read here, from the checkout root beside this directory, | |
| because a script runs at a shell on the gateway host with no portal around | |
| it. The portal reads its config once in main.py and nothing in llmao/ | |
| opens the file. Relative paths in it are relative to that root, so | |
| ``./analysis/prompts`` and ``cd analysis && ./prompts`` read the | |
| same file and the same store. | |
| Three words, kept apart. A FINGERPRINT is a hash key over a record: the | |
| project, the person, the tool family and the opening of the system prompt. | |
| A CLUSTER is the set of records sharing a fingerprint, which is pure string | |
| work over the day files, no model involved. An ACTIVITY is what the people | |
| behind a cluster were doing, which a model is asked to say (the judge, a | |
| later script) and which is never the cluster's name. | |
| """ | |
| from __future__ import annotations | |
| import csv | |
| import datetime as dt | |
| import hashlib | |
| import json | |
| import pathlib | |
| import re | |
| import sqlite3 | |
| import sys | |
| from typing import TYPE_CHECKING, Any | |
| import httpx | |
| from easydict import EasyDict as edict # noqa: N813 | |
| from llmao.models import load_models, models_path_from_cfg | |
| from llmao.prompt_log import NO_PROJECT, counts_as_demand, percentile, read_day, tool_family | |
| if TYPE_CHECKING: | |
| import argparse | |
| from collections.abc import Iterable, Iterator | |
| # ---- settings ----------------------------------------------------------- | |
| ROOT = pathlib.Path(__file__).resolve().parent.parent # the checkout, where config.yaml lives | |
| CONFIG = ROOT / "config.yaml" | |
| ANALYSIS_KEY_PURPOSE = "llmao-analysis" # the judge's service key; its own requests are never clustered | |
| def _settings(config: pathlib.Path) -> dict: | |
| if not config.exists(): | |
| raise SystemExit(f"{config}: not found; give the path on the command line") | |
| import yaml | |
| return yaml.safe_load(config.read_text()) or {} | |
| def _path(raw: Any, config: pathlib.Path) -> pathlib.Path: | |
| """A path from the config, relative ones taken from the config's own directory.""" | |
| return config.resolve().parent / str(raw) | |
| def required(key: str, config: pathlib.Path = CONFIG) -> Any: | |
| """A required value from config.yaml; every analysis_* key is one, as prompt_log_dir is. | |
| A missing file or key stops the script with a message. A script can | |
| fail harder than the portal: a startup error there takes a service | |
| down, here it makes the admin fix their invocation. p6 renders every | |
| key, so a missing one is a config.yaml behind the module. | |
| """ | |
| raw = _settings(config).get(key) | |
| if not raw: | |
| raise SystemExit(f"{config}: {key} is required") | |
| return raw | |
| def configured(key: str, config: pathlib.Path = CONFIG) -> pathlib.Path: | |
| """A required path from config.yaml.""" | |
| return _path(required(key, config), config) | |
| def log_dir(config: pathlib.Path = CONFIG) -> pathlib.Path: | |
| """prompt_log_dir: where the proxy's hook writes the day files.""" | |
| return configured("prompt_log_dir", config) | |
| def analysis_dir(config: pathlib.Path = CONFIG) -> pathlib.Path: | |
| """analysis_dir: where the scripts keep their store.""" | |
| return configured("analysis_dir", config) | |
| def retention_days(config: pathlib.Path = CONFIG) -> int: | |
| """analysis_retention_days: how long every row of the store is kept, one value for all analysis output.""" | |
| return int(required("analysis_retention_days", config)) | |
| def tiers(config: pathlib.Path = CONFIG) -> dict[str, list[str]]: | |
| """analysis_tiers: which models serve which tier, lowest first within a tier; the first is the one to recommend.""" | |
| raw = required("analysis_tiers", config) | |
| if not isinstance(raw, dict): | |
| raise SystemExit(f"{config}: analysis_tiers should be a map of tier to a list of model names") | |
| return {t: [str(m) for m in raw.get(t) or []] for t in TIERS} | |
| def require_models(names: set[str], config: pathlib.Path = CONFIG) -> None: | |
| """Every model a run mentions must be one models.yaml defines, or the script stops. | |
| The judges, a model to compare with, and every model in the tier map. | |
| A typo would otherwise be a quiet "no verdicts" or a tier report that | |
| places nothing. The portal, reading the same config, would warn; a | |
| script fails. | |
| """ | |
| known = {m["model_name"] for m in load_models(models_path_from_cfg(edict(_settings(config))))} | |
| unknown = sorted(names - known) | |
| if unknown: | |
| raise SystemExit(f"not in models.yaml: {', '.join(unknown)} (known: {', '.join(sorted(known))})") | |
| def require_private(judges: Iterable[str], config: pathlib.Path = CONFIG) -> None: | |
| """A judge's prompts are other projects' prompts: a model a paid provider also serves is refused. | |
| config.yaml providers with an api_base are paid, and the gateway falls | |
| back to them when our rentals cannot answer; the judge must never be | |
| the request that goes there. Until the proxy can honour a request's | |
| own "self-hosted only" (docs/analysis-design.md, the privacy | |
| mechanism), the guard is here, at startup. | |
| """ | |
| paid: dict[str, list[str]] = {} | |
| for name, entry in (_settings(config).get("providers") or {}).items(): | |
| if (entry or {}).get("api_base"): | |
| for model_name in entry.get("models") or {}: | |
| paid.setdefault(str(model_name), []).append(name) | |
| offsite = {j: paid[j] for j in judges if j in paid} | |
| if offsite: | |
| said = "; ".join(f"{j} is also served by {', '.join(p)}" for j, p in offsite.items()) | |
| raise SystemExit(f"a judge must run only on our rentals: {said}") | |
| def judge_settings(config: pathlib.Path = CONFIG) -> edict: | |
| """What the judge needs to call the gateway: its URL, the judges, the key. | |
| The front door, ``analysis_gateway``, not the proxy's local address: | |
| the judge is a client like any other, so it runs the same from a | |
| developer's machine as from the host, and its requests are logged and | |
| counted like anyone's. ``analysis_models`` is a list: several judges | |
| on each cluster is the normal operation. Without ``analysis_key`` the | |
| judge stops before its first call. | |
| """ | |
| key = _settings(config).get("analysis_key") | |
| if not key: | |
| raise SystemExit(f"{config}: analysis_key is required (a service key whose purpose is {ANALYSIS_KEY_PURPOSE})") | |
| return edict(gateway=gateway(config), models=judge_models(config), key=str(key)) | |
| def gateway(config: pathlib.Path = CONFIG) -> str: | |
| """analysis_gateway: the front door the judge calls, without a trailing slash.""" | |
| return str(required("analysis_gateway", config)).rstrip("/") | |
| def judge_models(config: pathlib.Path = CONFIG) -> list[str]: | |
| """analysis_models: the judges, in the order they run.""" | |
| raw = required("analysis_models", config) | |
| if not isinstance(raw, list): | |
| raise SystemExit(f"{config}: analysis_models should be a list of model names") | |
| return [str(m) for m in raw] | |
| def span(*, days: int = 7, start: dt.date | None = None, end: dt.date | None = None) -> list[dt.date]: | |
| """The UTC days to read: the last ``days`` closed ones, or ``start`` to ``end``, in order. | |
| ``end`` defaults to yesterday, the last closed day; ``start`` to ``days`` | |
| before it. A start after its end is a ValueError. | |
| """ | |
| end = end or dt.datetime.now(dt.UTC).date() - dt.timedelta(days=1) | |
| start = start or end - dt.timedelta(days=days - 1) | |
| if start > end: | |
| raise ValueError(f"span starts {start}, after it ends {end}") | |
| return [start + dt.timedelta(days=i) for i in range((end - start).days + 1)] | |
| # ---- the fingerprint ---------------------------------------------------- | |
| # | |
| # The OPENING of the system prompt: its first sentence or line, whichever | |
| # ends first, at most OPENING_CHARS after whitespace is collapsed. Not more, | |
| # because a prompt template carries its variable part early ("Extract an | |
| # inventory from each file below. ## Domain: ...") and a longer cut makes a | |
| # cluster per file; not the end, because agents append the working directory | |
| # and the date there. The person rather than the key, because a person | |
| # evaluating models holds one key per model and the work is the same. | |
| # Changing OPENING_CHARS or the boundaries changes every fingerprint, so | |
| # they are set once. The hash is blake2b at 8 bytes: fast, stable across | |
| # runs, and a clash would only merge two clusters, which is no harm. | |
| # | |
| # The HEAD, the first HEAD_CHARS of the same text, is kept with the exemplar | |
| # for a person or the judge to read; it is not part of the key. | |
| OPENING_CHARS = 200 | |
| HEAD_CHARS = 1024 | |
| _WS = re.compile(r"\s+") | |
| _BOUNDARY = re.compile(r"[.?!](?=\s)|\n") | |
| def digest(*parts: str) -> str: | |
| h = hashlib.blake2b(digest_size=8) | |
| for p in parts: | |
| h.update(p.encode("utf-8", "replace")) | |
| h.update(b"\0") | |
| return h.hexdigest() | |
| def normalised(text: str) -> str: | |
| return _WS.sub(" ", text).strip() | |
| def message_text(content: Any) -> str | None: | |
| """A message's text: the string, or its text parts; None when it has none or carries anything else. | |
| A user message with an image, a tool result or a tool call is not a | |
| question a person typed, so it is not text for the head or an exemplar. | |
| """ | |
| if isinstance(content, str): | |
| return content | |
| if not isinstance(content, list): | |
| return None | |
| texts = [] | |
| for part in content: | |
| if not isinstance(part, dict) or part.get("type") != "text": | |
| return None | |
| texts.append(part.get("text") or "") | |
| return "\n".join(texts) if texts else None | |
| def instructions(record: dict) -> tuple[str, str]: | |
| """The text that says what the request is for, raw, and where it came from. | |
| The first system (or developer) message; else the Anthropic API's | |
| ``system`` parameter, which the record keeps under ``params``; else | |
| the opening user message. ``("none", "")`` when the record has no | |
| text at all, which still clusters: a tool that sends only images, say. | |
| """ | |
| messages = record.get("messages") | |
| messages = messages if isinstance(messages, list) else [] | |
| for m in messages: | |
| if m.get("role") in ("system", "developer"): | |
| text = message_text(m.get("content")) | |
| if text: | |
| return "system", text | |
| break | |
| system = (record.get("params") or {}).get("system") | |
| if system: | |
| text = system if isinstance(system, str) else message_text(system) | |
| if text: | |
| return "param", text | |
| for m in messages: | |
| if m.get("role") == "user": | |
| text = message_text(m.get("content")) | |
| if text: | |
| return "user", text | |
| break | |
| return "none", "" | |
| def opening(text: str) -> str: | |
| """The first sentence or line, whichever ends first, whitespace collapsed, at most OPENING_CHARS.""" | |
| cut = _BOUNDARY.search(text.lstrip()) | |
| first = text.lstrip()[: cut.end()] if cut else text | |
| return normalised(first)[:OPENING_CHARS] | |
| def head(text: str) -> str: | |
| return normalised(text)[:HEAD_CHARS] | |
| MASTER_KEY_HASH = "litellm_proxy_master_key" # LiteLLM's placeholder: no team, user default_user_id | |
| MASTER_KEY = "llmao:master-key" # the operators' own requests: smoke tests, never judged; prefix as NO_PROJECT | |
| def who(key: dict) -> str: | |
| """The person behind a key, else the key's purpose, else the start of its hash: never empty. | |
| The purpose is the name typed when the key was made, so a service | |
| key reads as "tooling-gofannon" rather than a hash. The master key is | |
| named as such: LiteLLM files it under a placeholder user, and | |
| "default_user_id" in a listing reads as a person. | |
| """ | |
| if key.get("hash") == MASTER_KEY_HASH: | |
| return MASTER_KEY | |
| return key.get("user") or key.get("purpose") or (key.get("hash") or "?")[:12] | |
| def fingerprint(record: dict) -> edict | None: | |
| """The record's fingerprint with the parts it is made of; None when the record is not demand.""" | |
| key = record.get("key") or {} | |
| if not counts_as_demand(record.get("model"), key.get("project")) or key.get("purpose") == ANALYSIS_KEY_PURPOSE: | |
| return None | |
| project = key.get("project") or NO_PROJECT | |
| tool = tool_family(record.get("user_agent")) | |
| source, text = instructions(record) | |
| start = opening(text) | |
| return edict( | |
| fingerprint=digest(project, who(key), tool, start), | |
| project=project, | |
| who=who(key), | |
| tool=tool, | |
| source=source, | |
| opening=start, | |
| ) | |
| # ---- the exemplar ------------------------------------------------------- | |
| # | |
| # One per cluster, for a person or the judge to read: the head of one | |
| # record's instructions and up to EXEMPLAR_MESSAGES user messages, chosen | |
| # from the cluster's records. Every step is deterministic, so the same | |
| # records give the same exemplar; it is chosen when the cluster is first | |
| # seen and re-picked when the cluster has at least doubled since the pick, | |
| # or when the day it was picked from has been pruned, so a daily run does | |
| # not change it and a judge's verdict stays good, while a steady cluster | |
| # past the retention, whose count no longer grows, still gets a fresh | |
| # exemplar once per retention. | |
| EXEMPLAR_MESSAGES = 3 | |
| MESSAGE_CHARS = 1500 | |
| MIN_MESSAGE_CHARS = 20 | |
| def last_user_text(record: dict) -> str | None: | |
| """The last user message that is text: skipping tool results and images on the way back.""" | |
| messages = record.get("messages") | |
| for m in reversed(messages if isinstance(messages, list) else []): | |
| if m.get("role") == "user": | |
| text = message_text(m.get("content")) | |
| if text: | |
| return normalised(text) | |
| return None | |
| def prompt_tokens(record: dict) -> int: | |
| return (record.get("tokens") or {}).get("prompt") or 0 | |
| def completion_tokens(record: dict) -> int: | |
| return (record.get("tokens") or {}).get("completion") or 0 | |
| def candidate(record: dict, text: str) -> edict | None: | |
| """What the exemplar choice needs from one record, so a day is not held in memory whole. | |
| ``text`` is the record's instructions, already extracted for the | |
| fingerprint. A record whose last user message is too short to say | |
| anything ("continue", "y") is not a candidate. | |
| """ | |
| message = last_user_text(record) | |
| if not message or len(message) < MIN_MESSAGE_CHARS: | |
| return None | |
| return edict( | |
| tokens=prompt_tokens(record), | |
| ts=record.get("ts") or "", | |
| call_id=record.get("call_id") or "", | |
| head=head(text), | |
| message=message[:MESSAGE_CHARS], | |
| ) | |
| def choose_exemplar(candidates: Iterable[edict], lengths: list[int]) -> edict | None: | |
| """Up to EXEMPLAR_MESSAGES distinct messages, the ones most typical of the cluster. | |
| Ranked by how close the record's prompt length is to the cluster's | |
| median, earliest first among equals: the typical request, not the | |
| biggest and not the first, which is often a smoke test. The call id | |
| and the head are the first message's. The hash covers both and the | |
| messages. | |
| """ | |
| median = percentile(lengths, 0.5) or 0 | |
| seen: set[str] = set() | |
| ranked = [] | |
| for c in candidates: | |
| h = digest(c.message) | |
| if h in seen: | |
| continue | |
| seen.add(h) | |
| ranked.append((abs(c.tokens - median), c.ts, c.call_id, c.head, c.message)) | |
| ranked.sort() | |
| picked = ranked[:EXEMPLAR_MESSAGES] | |
| if not picked: | |
| return None | |
| messages = [p[4] for p in picked] | |
| first = picked[0] | |
| return edict(call_id=first[2], head=first[3], messages=messages, hash=digest(first[3], *messages)) | |
| # ---- the store ---------------------------------------------------------- | |
| # | |
| # One SQLite file in analysis_dir. A day is scanned once: its figures per | |
| # cluster go to cluster_days, the cluster rows carry what does not change | |
| # per day plus the exemplar, and the days table says which days are in. | |
| # Rebuilding drops the clusters and the days, never a judge's verdict. | |
| DB_NAME = "prompts.db" | |
| _SCHEMA = """ | |
| CREATE TABLE IF NOT EXISTS days ( | |
| day TEXT PRIMARY KEY, | |
| scanned_at TEXT NOT NULL, | |
| records INTEGER NOT NULL | |
| ); | |
| CREATE TABLE IF NOT EXISTS clusters ( | |
| fingerprint TEXT PRIMARY KEY, | |
| project TEXT NOT NULL, | |
| who TEXT NOT NULL, | |
| tool TEXT NOT NULL, | |
| source TEXT NOT NULL, | |
| opening TEXT NOT NULL, | |
| first_seen TEXT NOT NULL, | |
| last_seen TEXT NOT NULL, | |
| records INTEGER NOT NULL, | |
| exemplar_call_id TEXT, | |
| exemplar_head TEXT, | |
| exemplar_json TEXT, | |
| exemplar_hash TEXT, | |
| exemplar_records INTEGER NOT NULL DEFAULT 0, | |
| exemplar_picked TEXT | |
| ); | |
| CREATE TABLE IF NOT EXISTS cluster_days ( | |
| fingerprint TEXT NOT NULL, | |
| day TEXT NOT NULL, | |
| requests INTEGER NOT NULL, | |
| failures INTEGER NOT NULL, | |
| prompt_tokens INTEGER NOT NULL, | |
| completion_tokens INTEGER NOT NULL, | |
| prompt_p50 INTEGER, | |
| completion_p50 INTEGER, | |
| models_json TEXT NOT NULL, | |
| paired INTEGER NOT NULL DEFAULT 0, | |
| PRIMARY KEY (fingerprint, day) | |
| ); | |
| CREATE TABLE IF NOT EXISTS verdicts ( | |
| fingerprint TEXT NOT NULL, | |
| prompt_hash TEXT NOT NULL, | |
| model TEXT NOT NULL, | |
| exemplar_hash TEXT NOT NULL, | |
| judged_at TEXT NOT NULL, | |
| asf_work INTEGER NOT NULL, | |
| tier INTEGER NOT NULL, | |
| activity TEXT NOT NULL, | |
| verdict_json TEXT NOT NULL, | |
| PRIMARY KEY (fingerprint, prompt_hash, model) | |
| ); | |
| """ | |
| # The answers as the judge gives them, in words; stored as their index | |
| # here, so the views aggregate integers: Foundation work ordered so MIN | |
| # leans towards "no", the tier so MAX leans towards "high". | |
| ASF_WORK = ("no", "unclear", "yes") | |
| TIERS = ("low", "medium", "high") | |
| # Rebuilt at every open, so a change here reaches an existing store. | |
| _VIEWS = """ | |
| DROP VIEW IF EXISTS consensus; | |
| DROP VIEW IF EXISTS current_verdicts; | |
| -- The verdicts that still hold: the exemplar they were given is the | |
| -- cluster's current one. | |
| CREATE VIEW current_verdicts AS | |
| SELECT v.fingerprint, v.prompt_hash, v.model, v.judged_at, v.asf_work, v.tier, v.activity, | |
| json_extract(v.verdict_json, '$.reason') AS reason | |
| FROM verdicts v JOIN clusters c USING (fingerprint) | |
| WHERE v.exemplar_hash = c.exemplar_hash; | |
| -- What the judges together say about a cluster under one judge prompt. | |
| -- The tier leans high: the highest any judge gave. Foundation work leans | |
| -- the other way: one "no" is a "no", for an admin to look at. The activity | |
| -- is the majority, the alphabetically first among equals. | |
| CREATE VIEW consensus AS | |
| WITH activities AS ( | |
| SELECT fingerprint, prompt_hash, activity, | |
| ROW_NUMBER() OVER (PARTITION BY fingerprint, prompt_hash ORDER BY COUNT(*) DESC, activity) AS rank | |
| FROM current_verdicts GROUP BY fingerprint, prompt_hash, activity | |
| ) | |
| SELECT v.fingerprint, v.prompt_hash, COUNT(*) AS judges, | |
| MIN(v.asf_work) AS asf_work, | |
| MAX(v.tier) AS tier, | |
| (SELECT activity FROM activities a | |
| WHERE a.fingerprint = v.fingerprint AND a.prompt_hash = v.prompt_hash AND a.rank = 1) AS activity, | |
| COUNT(DISTINCT v.asf_work) AS asf_answers, | |
| COUNT(DISTINCT v.tier) AS tier_answers, | |
| COUNT(DISTINCT v.activity) AS activity_answers | |
| FROM current_verdicts v GROUP BY v.fingerprint, v.prompt_hash; | |
| """ | |
| class Store: | |
| def __init__(self, directory: pathlib.Path): | |
| directory.mkdir(parents=True, exist_ok=True) | |
| self.path = directory / DB_NAME | |
| self.db = sqlite3.connect(self.path) | |
| self.db.row_factory = sqlite3.Row | |
| self.db.execute("PRAGMA journal_mode=WAL") | |
| # No migrations while this is R&D: a store whose tables are out of | |
| # date is deleted and rebuilt. The views are recreated at every | |
| # open, so a change to them reaches a store without that. | |
| self.db.executescript(_SCHEMA) | |
| self.db.executescript(_VIEWS) | |
| def close(self) -> None: | |
| self.db.close() | |
| def scanned_days(self) -> set[dt.date]: | |
| return {dt.date.fromisoformat(r["day"]) for r in self.db.execute("SELECT day FROM days")} | |
| def cluster(self, fp: str) -> edict | None: | |
| row = self.db.execute("SELECT * FROM clusters WHERE fingerprint = ?", (fp,)).fetchone() | |
| return row and edict(row) | |
| def rebuild(self) -> None: | |
| """Forget every scanned day and cluster. Verdicts, when there are any, stay.""" | |
| with self.db: | |
| self.db.execute("DELETE FROM cluster_days") | |
| self.db.execute("DELETE FROM clusters") | |
| self.db.execute("DELETE FROM days") | |
| def prune(self, retention: int, today: dt.date | None = None) -> int: | |
| """Drop every row older than the retention; clusters with no day left go too.""" | |
| cutoff = ((today or dt.datetime.now(dt.UTC).date()) - dt.timedelta(days=retention)).isoformat() | |
| with self.db: | |
| self.db.execute("DELETE FROM cluster_days WHERE day < ?", (cutoff,)) | |
| self.db.execute("DELETE FROM days WHERE day < ?", (cutoff,)) | |
| self.db.execute( | |
| "UPDATE clusters SET records = COALESCE(" | |
| "(SELECT SUM(requests) FROM cluster_days d WHERE d.fingerprint = clusters.fingerprint), 0)" | |
| ) | |
| gone = self.db.execute("DELETE FROM clusters WHERE records = 0").rowcount | |
| self.db.execute("DELETE FROM verdicts WHERE fingerprint NOT IN (SELECT fingerprint FROM clusters)") | |
| return gone | |
| def scan_day(self, log_dir: pathlib.Path, day: dt.date) -> int: | |
| """Cluster one day's records and write them, one transaction; returns the records clustered.""" | |
| groups: dict[str, edict] = {} | |
| # The same messages sent to several models is a person comparing | |
| # models, which the judge cannot see from one exemplar: per group, | |
| # which models each distinct request went to. | |
| sent_to: dict[str, dict[str, set[str]]] = {} | |
| for r in read_day(log_dir, day): | |
| fp = fingerprint(r) | |
| if fp is None: | |
| continue | |
| g = groups.get(fp.fingerprint) | |
| if g is None: | |
| g = groups[fp.fingerprint] = edict( | |
| fp, requests=0, failures=0, prompt=[], completion=[], models={}, candidates=[], paired=0 | |
| ) | |
| sent_to[fp.fingerprint] = {} | |
| g.requests += 1 | |
| if r.get("status") == "failure": | |
| g.failures += 1 | |
| else: | |
| g.prompt.append(prompt_tokens(r)) | |
| g.completion.append(completion_tokens(r)) | |
| g.models[r["model"]] = g.models.get(r["model"], 0) + 1 | |
| c = candidate(r, instructions(r)[1]) | |
| if c is not None: | |
| g.candidates.append(c) | |
| request = digest(json.dumps(r.get("messages"), sort_keys=True)) | |
| sent_to[fp.fingerprint].setdefault(request, set()).add(r["model"]) | |
| for fp, requests in sent_to.items(): | |
| groups[fp].paired = sum(len(models) for models in requests.values() if len(models) > 1) | |
| records = sum(g.requests for g in groups.values()) | |
| now = dt.datetime.now(dt.UTC).isoformat(timespec="seconds") | |
| with self.db: | |
| for g in groups.values(): | |
| self._write_cluster_day(day, g) | |
| self.db.execute( | |
| "INSERT OR REPLACE INTO days (day, scanned_at, records) VALUES (?, ?, ?)", | |
| (day.isoformat(), now, records), | |
| ) | |
| return records | |
| def _write_cluster_day(self, day: dt.date, g: edict) -> None: | |
| d = day.isoformat() | |
| self.db.execute( | |
| "INSERT OR REPLACE INTO cluster_days (fingerprint, day, requests, failures, prompt_tokens, " | |
| "completion_tokens, prompt_p50, completion_p50, models_json, paired) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| ( | |
| g.fingerprint, | |
| d, | |
| g.requests, | |
| g.failures, | |
| sum(g.prompt), | |
| sum(g.completion), | |
| percentile(g.prompt, 0.5), | |
| percentile(g.completion, 0.5), | |
| json.dumps(g.models, sort_keys=True), | |
| g.paired, | |
| ), | |
| ) | |
| row = self.cluster(g.fingerprint) | |
| if row is None: | |
| self.db.execute( | |
| "INSERT INTO clusters (fingerprint, project, who, tool, source, opening, first_seen, last_seen, " | |
| "records) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0)", | |
| (g.fingerprint, g.project, g.who, g.tool, g.source, g.opening, d, d), | |
| ) | |
| # The cluster's count is the sum of its days, so a rescanned day is | |
| # not counted twice. | |
| self.db.execute( | |
| "UPDATE clusters SET first_seen = MIN(first_seen, ?), last_seen = MAX(last_seen, ?), " | |
| "records = (SELECT SUM(requests) FROM cluster_days d WHERE d.fingerprint = clusters.fingerprint) " | |
| "WHERE fingerprint = ?", | |
| (d, d, g.fingerprint), | |
| ) | |
| row = self.cluster(g.fingerprint) | |
| # The exemplar stands while the cluster has not doubled since the | |
| # pick and the day it was picked from is still in the store. | |
| held = row.exemplar_hash and self._holds(g.fingerprint, row.exemplar_picked) | |
| if held and row.records < 2 * row.exemplar_records: | |
| return | |
| ex = choose_exemplar(g.candidates, g.prompt) | |
| if ex is None or ex.hash == row.exemplar_hash: | |
| return | |
| self.db.execute( | |
| "UPDATE clusters SET exemplar_call_id = ?, exemplar_head = ?, exemplar_json = ?, exemplar_hash = ?, " | |
| "exemplar_records = ?, exemplar_picked = ? WHERE fingerprint = ?", | |
| (ex.call_id, ex.head, json.dumps(ex.messages), ex.hash, row.records, d, g.fingerprint), | |
| ) | |
| def _holds(self, fp: str, day: str) -> bool: | |
| """Whether the store still has the cluster's figures for that day; the prune takes them.""" | |
| row = self.db.execute("SELECT 1 FROM cluster_days WHERE fingerprint = ? AND day = ?", (fp, day)).fetchone() | |
| return row is not None | |
| def clusters_over(self, days: list[dt.date]) -> Iterator[edict]: | |
| """Every cluster with a day in the span, with its figures over the span, most requests first.""" | |
| first, last = days[0].isoformat(), days[-1].isoformat() | |
| rows = self.db.execute( | |
| "SELECT c.*, SUM(d.requests) AS requests, SUM(d.failures) AS failures, COUNT(d.day) AS days, " | |
| "SUM(d.prompt_tokens) AS prompt_tokens, SUM(d.completion_tokens) AS completion_tokens, " | |
| "SUM(d.paired) AS paired, GROUP_CONCAT(d.models_json, '\n') AS models_rows " | |
| "FROM clusters c JOIN cluster_days d USING (fingerprint) WHERE d.day BETWEEN ? AND ? " | |
| "GROUP BY c.fingerprint ORDER BY requests DESC, c.fingerprint", | |
| (first, last), | |
| ) | |
| for row in rows: | |
| c = edict(row) | |
| models: dict[str, int] = {} | |
| for line in (c.pop("models_rows") or "").split("\n"): | |
| for model, n in json.loads(line or "{}").items(): | |
| models[model] = models.get(model, 0) + n | |
| c.models = models | |
| c.exemplar = json.loads(c.exemplar_json) if c.exemplar_json else [] | |
| yield c | |
| # ---- verdicts ------------------------------------------------------- | |
| def stale(self, clusters: Iterable[edict], prompt_hash: str, model: str) -> list[edict]: | |
| """The clusters with an exemplar and no verdict from this judge prompt, this model and this exemplar.""" | |
| out = [] | |
| for c in clusters: | |
| if not c.exemplar_hash: | |
| continue | |
| row = self.db.execute( | |
| "SELECT exemplar_hash FROM verdicts WHERE fingerprint = ? AND prompt_hash = ? AND model = ?", | |
| (c.fingerprint, prompt_hash, model), | |
| ).fetchone() | |
| if row is None or row["exemplar_hash"] != c.exemplar_hash: | |
| out.append(c) | |
| return out | |
| def write_verdict(self, c: edict, prompt_hash: str, model: str, verdict: dict) -> None: | |
| """One verdict, one transaction: a run that stops keeps every verdict before the stop.""" | |
| with self.db: | |
| self.db.execute( | |
| "INSERT OR REPLACE INTO verdicts (fingerprint, prompt_hash, model, exemplar_hash, judged_at, " | |
| "asf_work, tier, activity, verdict_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", | |
| ( | |
| c.fingerprint, | |
| prompt_hash, | |
| model, | |
| c.exemplar_hash, | |
| dt.datetime.now(dt.UTC).isoformat(timespec="seconds"), | |
| ASF_WORK.index(verdict["asf_work"]), | |
| TIERS.index(verdict["tier"]), | |
| verdict["activity"], | |
| json.dumps(verdict), | |
| ), | |
| ) | |
| @staticmethod | |
| def _named(row: sqlite3.Row | None) -> edict | None: | |
| """A verdict row with its integers named again.""" | |
| if row is None: | |
| return None | |
| v = edict(row) | |
| v.asf_work = ASF_WORK[v.asf_work] | |
| v.tier = TIERS[v.tier] | |
| return v | |
| def with_verdicts(self, clusters: Iterable[edict], prompt_hash: str, model: str) -> Iterator[edict]: | |
| """Each cluster with one model's current verdict under ``verdict`` (None when there is none or it is stale).""" | |
| for c in clusters: | |
| row = self.db.execute( | |
| "SELECT asf_work, tier, activity, reason, judged_at FROM current_verdicts " | |
| "WHERE fingerprint = ? AND prompt_hash = ? AND model = ?", | |
| (c.fingerprint, prompt_hash, model), | |
| ).fetchone() | |
| c.verdict = self._named(row) | |
| yield c | |
| def with_consensus(self, clusters: Iterable[edict], prompt_hash: str) -> Iterator[edict]: | |
| """Each cluster with what its judges together say under ``verdict``, and every judge's say under ``verdicts``. | |
| ``verdict`` carries ``judges`` and how many distinct answers each | |
| question got, so a listing can show where the judges disagreed. | |
| """ | |
| for c in clusters: | |
| row = self.db.execute( | |
| "SELECT * FROM consensus WHERE fingerprint = ? AND prompt_hash = ?", (c.fingerprint, prompt_hash) | |
| ).fetchone() | |
| c.verdict = self._named(row) | |
| c.verdicts = [ | |
| self._named(r) | |
| for r in self.db.execute( | |
| "SELECT model, asf_work, tier, activity, reason FROM current_verdicts " | |
| "WHERE fingerprint = ? AND prompt_hash = ? ORDER BY model", | |
| (c.fingerprint, prompt_hash), | |
| ) | |
| ] | |
| yield c | |
| # ---- the judge ---------------------------------------------------------- | |
| # | |
| # One call per cluster: the judge prompt as the system message, the | |
| # exemplar and the cluster's figures as the user message, a JSON object | |
| # back. The judge prompt is a file, so rewording it is not a code change; | |
| # its hash, the exemplar's hash and the model name are what make a stored | |
| # verdict stale. The model runs on our own rentals: the exemplars are other | |
| # projects' prompts and do not leave them. | |
| JUDGE_PROMPT = pathlib.Path(__file__).resolve().parent / "judge-prompt.md" | |
| ACTIVITIES = ( | |
| "security-review", | |
| "code-review", | |
| "feature-or-bugfix", | |
| "model-evaluation", | |
| "classification-or-extraction", | |
| "writing-or-documentation", | |
| "conversation", | |
| "other", | |
| ) | |
| def judge_prompt(path: pathlib.Path = JUDGE_PROMPT) -> edict: | |
| text = path.read_text(encoding="utf-8") | |
| return edict(text=text, hash=digest(text)) | |
| def exemplar_message(c: edict) -> str: | |
| """The user message for one cluster: its figures, its head, its messages.""" | |
| ok = max(1, c.requests - c.failures) | |
| models = ", ".join(sorted(c.models)) | |
| paired = ( | |
| f"{c.paired:,} of the requests were the same request sent to more than one model" | |
| if c.paired | |
| else "no request was sent to more than one model" | |
| ) | |
| lines = [ | |
| f"Project: {c.project}", | |
| f"Tool: {c.tool}", | |
| f"Requests: {c.requests:,} over {c.days} day(s); mean prompt {c.prompt_tokens // ok:,} tokens; " | |
| f"mean completion {c.completion_tokens // ok:,} tokens; models used: {models}; {paired}", | |
| "", | |
| f"INSTRUCTIONS (first {HEAD_CHARS} characters):", | |
| c.exemplar_head or "(none)", | |
| "", | |
| f"USER MESSAGES (up to {EXEMPLAR_MESSAGES}, each cut to {MESSAGE_CHARS} characters):", | |
| ] | |
| lines.extend(f"[{i}] {m}" for i, m in enumerate(c.exemplar, 1)) | |
| return "\n".join(lines) | |
| def judge_request(c: edict, prompt: edict, model: str) -> dict: | |
| """The chat completion body for one cluster. Reasoning off: the answer is a short JSON object.""" | |
| return { | |
| "model": model, | |
| "messages": [ | |
| {"role": "system", "content": prompt.text}, | |
| {"role": "user", "content": exemplar_message(c)}, | |
| ], | |
| "temperature": 0.0, | |
| "max_tokens": 600, | |
| "chat_template_kwargs": {"enable_thinking": False}, | |
| } | |
| class JudgeError(Exception): | |
| """The gateway did not answer, or the answer was not a verdict.""" | |
| def ask(settings: edict, body: dict, timeout: float = 120.0) -> str: | |
| """One chat completion through the gateway, synchronously; the assistant's text, or JudgeError. | |
| One request in flight is the whole point, so the synchronous httpx | |
| client, not the async one the portal's transport uses. | |
| """ | |
| try: | |
| r = httpx.post( | |
| settings.gateway + "/v1/chat/completions", | |
| json=body, | |
| headers={"Authorization": f"Bearer {settings.key}"}, | |
| timeout=timeout, | |
| ) | |
| except httpx.HTTPError as e: | |
| raise JudgeError(f"{type(e).__name__}: {e}"[:200]) from None | |
| if r.status_code != 200: | |
| raise JudgeError(f"HTTP {r.status_code} {r.text[:200]}".replace("\n", " ")) | |
| try: | |
| return r.json()["choices"][0]["message"]["content"] or "" | |
| except (KeyError, IndexError, TypeError, ValueError): | |
| raise JudgeError(f"no message in the answer: {r.text[:200]}") from None | |
| def parse_verdict(text: str) -> edict: | |
| """The JSON object in the judge's answer, checked against the three answer sets. | |
| Lenient about what surrounds the object, since a small model will | |
| sometimes add a fence or a sentence despite being told not to; strict | |
| about the values, since a verdict outside the sets is not a verdict. | |
| """ | |
| start, end = text.find("{"), text.rfind("}") | |
| if start < 0 or end < start: | |
| raise JudgeError(f"no JSON object in the answer: {text[:200]!r}") | |
| try: | |
| v = json.loads(text[start : end + 1]) | |
| except ValueError: | |
| raise JudgeError(f"the answer is not JSON: {text[:200]!r}") from None | |
| for key, allowed in (("asf_work", ASF_WORK), ("tier", TIERS), ("activity", ACTIVITIES)): | |
| if v.get(key) not in allowed: | |
| raise JudgeError(f"{key} is {v.get(key)!r}, not one of {allowed}") | |
| return edict(asf_work=v["asf_work"], tier=v["tier"], activity=v["activity"], reason=str(v.get("reason") or "")) | |
| # ---- the tier report ---------------------------------------------------- | |
| # | |
| # The judge's tier against the models the cluster actually used. A request | |
| # on a model above the tier is one a cheaper model would have served; one | |
| # below is a risk the judge thinks the model is not up to. | |
| def tier_of(model: str, by_tier: dict[str, list[str]]) -> str | None: | |
| """The tier a model serves, or None for a model the map does not know (a paid provider's, say).""" | |
| for tier, models in by_tier.items(): | |
| if model in models: | |
| return tier | |
| return None | |
| def tier_report(clusters: Iterable[edict], by_tier: dict[str, list[str]]) -> list[edict]: | |
| """Per judged cluster: the tier, the model to recommend, and the requests above and below the tier. | |
| Heaviest excess first, so the top of the list is the biggest saving. | |
| """ | |
| rank = {t: i for i, t in enumerate(TIERS)} | |
| out = [] | |
| for c in clusters: | |
| if not c.verdict: | |
| continue | |
| verdict_rank = rank[c.verdict.tier] | |
| above = below = unranked = 0 | |
| for model, n in c.models.items(): | |
| t = tier_of(model, by_tier) | |
| if t is None: | |
| unranked += n | |
| elif rank[t] > verdict_rank: | |
| above += n | |
| elif rank[t] < verdict_rank: | |
| below += n | |
| recommended = (by_tier.get(c.verdict.tier) or ["?"])[0] | |
| out.append( | |
| edict(cluster=c, tier=c.verdict.tier, recommended=recommended, above=above, below=below, unranked=unranked) | |
| ) | |
| return sorted(out, key=lambda r: (-r.above, -r.below, r.cluster.fingerprint)) | |
| # ---- for the commands --------------------------------------------------- | |
| # | |
| # What every subcommand of analysis/prompts shares once its arguments are | |
| # parsed: the span of days they name. | |
| def span_of(ap: argparse.ArgumentParser, args: argparse.Namespace) -> list[dt.date]: | |
| try: | |
| return span(days=args.days, start=args.start, end=args.end) | |
| except ValueError: | |
| ap.error("--from is after --to") | |
| def span_text(days: list[dt.date]) -> str: | |
| return f"{days[0]} to {days[-1]}" if len(days) > 1 else str(days[0]) | |
| OPENING_SHOWN = 72 | |
| def models_text(c: edict) -> str: | |
| """A cluster's models with their request counts, most used first.""" | |
| return ", ".join(f"{m} {n}" for m, n in sorted(c.models.items(), key=lambda kv: -kv[1])) | |
| def opening_text(c: edict, shown: int = OPENING_SHOWN) -> str: | |
| """A cluster's opening for a listing: where it came from, and its first characters.""" | |
| if not c.opening: | |
| return "(no text)" | |
| return f"{c.source}: {c.opening[:shown]}" + ("…" if len(c.opening) > shown else "") | |
| def table(title: str, columns: list[str], rows: list[list], *, csv_out: bool = False) -> None: | |
| """One table, as aligned text with a title, or as CSV with the title as a comment. | |
| Cells are values, not text: an int gets its thousands separator and | |
| a numeric column is right-aligned; None prints as nothing. | |
| """ | |
| cells = [ | |
| [ | |
| ("" if v is None else f"{v:,}" if isinstance(v, int) else f"{v:g}" if isinstance(v, float) else str(v)) | |
| for v in row | |
| ] | |
| for row in rows | |
| ] | |
| if csv_out: | |
| out = csv.writer(sys.stdout) | |
| sys.stdout.write(f"# {title}\n") | |
| out.writerow(columns) | |
| out.writerows(cells) | |
| sys.stdout.write("\n") | |
| return | |
| widths = [max(len(c), *(len(r[i]) for r in cells)) if cells else len(c) for i, c in enumerate(columns)] | |
| numeric = [all(isinstance(r[i], (int, float)) or r[i] is None for r in rows) for i in range(len(columns))] | |
| def line(values): | |
| return " ".join(v.rjust(w) if n else v.ljust(w) for v, w, n in zip(values, widths, numeric, strict=True)) | |
| print(title) | |
| print(line(columns)) | |
| print(line(["-" * w for w in widths])) | |
| for row in cells: | |
| print(line(row)) | |
| print() |