~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
232 lines · 8.4 KB Raw History
//! A decision model as the classifier: any that speaks the decision API
//! TypeSafe's Jev does, as Cloudflare's Clef does too. Such a model reads
//! an issue and answers typed questions about it, each with a probability,
//! and writes no text of its own, so what an issue says can move
//! probabilities but never step outside the labels asked about.
//!
//! The questions come from the repository's labels. A scope whose labels
//! are all exclusive, as `kind/bug` and `kind/feature` can be, is one
//! `choice` among them, with a way to say none fits; any other scope asks
//! of each label whether it fits (`noul`). An answer short of the
//! threshold sets nothing, so the triager says nothing of a scope rather
//! than guess, and every probability goes into the reason it gives.

use anyhow::{Context, Result, bail};
use knotless::records::Label;
use knotless::triage::{Choice, Pending};
use serde_json::{Map, Value, json};

/// The option of a `choice` that says none of its labels fits.
const NONE: &str = "none_of_these";

/// Where and how decisions are asked for.
#[derive(Clone, PartialEq)]
pub struct Decider {
    /// The endpoint taking `{model, state, questions}`, as
    /// `https://api.typesafe.ai/v1/systemone`, or a model's on Workers AI.
    pub endpoint: String,
    /// Sent as a bearer token.
    pub token: String,
    /// The model asked, as `jev-latest` or `clef`.
    pub model: String,
    /// The least probability that sets a label.
    pub threshold: f64,
}

/// What one question asks: which of a scope's labels fits, or whether one
/// label does.
enum Question<'a> {
    Choice {
        scope: &'a str,
        options: Vec<(String, &'a Label)>,
    },
    Fits(&'a Label),
}

impl Question<'_> {
    fn schema(&self) -> Value {
        match self {
            Question::Choice { scope, options } => {
                let mut criteria: Map<String, Value> = (options.iter())
                    .map(|(key, label)| (key.clone(), meaning(label).into()))
                    .collect();
                criteria.insert(NONE.into(), "None of these fits".into());
                let instructions = format!("Which {scope} is this issue?");
                json!({"type": "choice", "instructions": instructions, "criteria": criteria})
            }
            Question::Fits(label) => {
                let instructions = format!("Does the label {} fit this issue?", meaning(label));
                json!({"type": "noul", "instructions": instructions})
            }
        }
    }
}

/// A label's name, and what it means when its definition says.
fn meaning(label: &Label) -> String {
    match &label.description {
        Some(about) => format!("{}: {about}", label.name),
        None => label.name.clone(),
    }
}

/// `name` as a question's or option's key, lowercase letters, digits and `_`, made
/// unlike every key in `taken`.
fn key(name: &str, taken: &[String]) -> String {
    let mut key: String = (name.chars())
        .map(|c| match c.is_ascii_alphanumeric() {
            true => c.to_ascii_lowercase(),
            false => '_',
        })
        .collect();
    while taken.contains(&key) {
        key.push('_');
    }
    key
}

/// The questions `labels` make, by key, in the order the repository
/// defines them.
fn ask(labels: &[Label]) -> Vec<(String, Question<'_>)> {
    let mut scopes: Vec<&str> = Vec::new();
    for scope in labels.iter().filter_map(Label::scope) {
        if !scopes.contains(&scope) {
            scopes.push(scope);
        }
    }
    let mut asked: Vec<(String, Question)> = Vec::new();
    for scope in scopes {
        let of: Vec<&Label> = labels.iter().filter(|l| l.scope() == Some(scope)).collect();
        let taken: Vec<String> = asked.iter().map(|(k, _)| k.clone()).collect();
        if of.iter().all(|l| l.exclusive) {
            let mut options: Vec<(String, &Label)> = Vec::new();
            for label in of {
                let leaf = &label.name[scope.len() + 1..];
                let mut keys: Vec<String> = options.iter().map(|(k, _)| k.clone()).collect();
                keys.push(NONE.into());
                options.push((key(leaf, &keys), label));
            }
            asked.push((key(scope, &taken), Question::Choice { scope, options }));
        } else {
            for label in of {
                let taken: Vec<String> = asked.iter().map(|(k, _)| k.clone()).collect();
                asked.push((key(&label.name, &taken), Question::Fits(label)));
            }
        }
    }
    asked
}

/// What the model chooses for `issue`: of each question, the label it gives at
/// least the threshold's probability.
pub async fn classify(
    http: &reqwest::Client,
    decider: &Decider,
    issue: &Pending,
) -> Result<Choice> {
    let asked = ask(&issue.labels);
    let questions: Map<String, Value> = (asked.iter())
        .map(|(key, q)| (key.clone(), q.schema()))
        .collect();
    let state = json!({
        "what": "An issue opened on a git repository",
        "title": issue.title,
        "body": issue.body,
    });
    let body = json!({"model": decider.model, "state": state, "questions": questions});
    let res = http.post(&decider.endpoint).bearer_auth(&decider.token);
    let res = res.json(&body);
    let res = res.send().await?;
    let status = res.status();
    let text = res.text().await?;
    if !status.is_success() {
        bail!(
            "{}: {status}: {}",
            decider.endpoint,
            text.chars().take(300).collect::<String>()
        );
    }
    let body: Value = serde_json::from_str(&text)?;
    // Workers AI wraps the answer in `result`.
    let body = body.get("result").unwrap_or(&body);
    let answers = body
        .get("answers")
        .context("the model answered no questions")?;
    let mut chosen = Vec::new();
    let mut said = Vec::new();
    for (key, question) in &asked {
        let answer = &answers[key];
        let (label, p) = match question {
            Question::Choice { options, .. } => {
                let picked = options.iter().find(|(k, _)| answer["choice"] == k.as_str());
                (picked.map(|(_, l)| *l), answer["confidence"].as_f64())
            }
            Question::Fits(label) => (Some(*label), answer["noul"].as_f64()),
        };
        let p = p.unwrap_or(0.0);
        let named = label.map_or_else(|| format!("{key}: none"), |l| l.name.clone());
        said.push(format!("{named} {p:.2}"));
        if let Some(label) = label.filter(|_| p >= decider.threshold) {
            chosen.push(label.name.clone());
        }
    }
    Ok(Choice {
        labels: chosen,
        // The version that answered, when the answer says.
        model: Some(body["model"].as_str().unwrap_or(&decider.model).to_string()),
        reason: Some(said.join("; ")),
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn label(name: &str, exclusive: bool) -> Label {
        let mut label = Label::new(name);
        label.exclusive = exclusive;
        label
    }

    #[test]
    fn questions_from_labels() {
        let mut bug = label("kind/bug", true);
        bug.description = Some("Something is broken".into());
        let labels = [
            bug,
            label("kind/bug-fix", true),
            label("kind/bug_fix", true),
            label("kind/none-of-these", true),
            label("area/web", false),
            label("area/cli", true),
            label("kind_bug/x", false),
        ];
        let asked = ask(&labels);
        let keys: Vec<&str> = asked.iter().map(|(k, _)| k.as_str()).collect();
        assert_eq!(keys, ["kind", "area_web", "area_cli", "kind_bug_x"]);
        let kind = asked[0].1.schema();
        assert_eq!(kind["type"], "choice");
        assert_eq!(kind["instructions"], "Which kind is this issue?");
        let criteria = kind["criteria"].as_object().unwrap();
        let options: Vec<&str> = criteria.keys().map(String::as_str).collect();
        assert_eq!(
            options,
            [
                "bug",
                "bug_fix",
                "bug_fix_",
                "none_of_these",
                "none_of_these_"
            ]
        );
        assert_eq!(criteria["bug"], "kind/bug: Something is broken");
        assert_eq!(criteria["none_of_these"], "None of these fits");
        let web = asked[1].1.schema();
        assert_eq!(web["type"], "noul");
        assert_eq!(
            web["instructions"],
            "Does the label area/web fit this issue?"
        );
        assert_eq!(key("kind", &["kind".into(), "kind_".into()]), "kind__");
    }
}