OXYGENHUB
# ed89fae5180b oxygenhub / crates / hub / src / assistants.rs
32.0 KiB · Rust · last changed Renaming an assistant renames it in its reviews and events; settings pages return to the section saved 1 month ago source blame history raw
e71846aa Andy McCall ↶ 1
//! AI assistants (hub spec §13.4, #97): chat-completion endpoints the hub
2
//! may ask to review a proposal or draft its description. Assistants are
3
//! kept at three levels, site, organisation and repository, and combined
4
//! downwards: a repository sees its organisation's and the site's beside
5
//! its own, the innermost of a name wins, and one disabled at an outer
6
//! level is off for everything inside it. Each level adds a prompt; the
7
//! prompt in force for a repository is the site's, the organisation's and
8
//! the repository's own, each under a heading. Tokens are sealed with
9
//! ChaCha20-Poly1305 under `keys/assistants.key`, shown masked, and never
10
//! leave the server.
11
12
use crate::db::{now_ms, Pool};
13
use crate::Shared;
14
use rand::RngCore;
15
use ring::aead;
16
use sqlx::FromRow;
17
use std::path::Path;
18
use std::time::Duration;
19
20
/// The request shapes an assistant may speak.
21
pub const APIS: [&str; 2] = ["chat-completions", "anthropic-messages"];
22
/// Characters of context (prompt, description, diffs) a call may carry
23
/// unless the assistant says otherwise.
24
pub const DEFAULT_BUDGET: i64 = 120_000;
25
/// How long one call may take.
26
pub const CALL_TIMEOUT: Duration = Duration::from_secs(60);
27
/// The most an answer is kept.
28
const MAX_ANSWER: usize = 64 * 1024;
29
30
/// Which level an assistant or a prompt belongs to.
31
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32
pub enum Scope {
33
    Site,
34
    Org(i64),
35
    Repo(i64),
36
}
37
38
impl Scope {
39
    pub fn kind(self) -> &'static str {
40
        match self {
41
            Scope::Site => "site",
42
            Scope::Org(_) => "org",
43
            Scope::Repo(_) => "repo",
44
        }
45
    }
46
47
    pub fn id(self) -> i64 {
48
        match self {
49
            Scope::Site => 0,
50
            Scope::Org(id) | Scope::Repo(id) => id,
51
        }
52
    }
53
}
54
55
#[derive(Clone, Debug, FromRow)]
56
pub struct Assistant {
57
    pub id: i64,
58
    pub scope: String,
59
    pub scope_id: i64,
60
    pub name: String,
61
    pub endpoint: String,
62
    /// One of [`APIS`].
63
    pub api: String,
64
    pub model: String,
65
    /// Extra request headers, one `Name: value` per line.
66
    pub headers: String,
67
    /// The sealed token; empty when the endpoint needs none.
68
    pub token: Vec<u8>,
69
    pub enabled: bool,
70
    pub budget: i64,
71
    pub created_ms: i64,
72
    pub updated_ms: i64,
73
}
74
75
impl Assistant {
76
    pub fn scope(&self) -> Scope {
77
        match self.scope.as_str() {
78
            "org" => Scope::Org(self.scope_id),
79
            "repo" => Scope::Repo(self.scope_id),
80
            _ => Scope::Site,
81
        }
82
    }
83
84
    pub fn has_token(&self) -> bool {
85
        !self.token.is_empty()
86
    }
87
88
    /// The extra headers as pairs; malformed lines are skipped.
89
    pub fn header_list(&self) -> Vec<(String, String)> {
90
        parse_headers(&self.headers).unwrap_or_default()
91
    }
92
}
93
94
/// What a settings form supplies for an assistant.
95
#[derive(Clone, Debug, Default)]
96
pub struct Fields {
97
    pub name: String,
98
    pub endpoint: String,
99
    pub api: String,
100
    pub model: String,
101
    pub headers: String,
102
    pub enabled: bool,
103
    pub budget: i64,
104
}
105
106
/// `Name: value` lines to pairs: `Err(line)` names the first bad one.
107
pub fn parse_headers(text: &str) -> Result<Vec<(String, String)>, String> {
108
    let mut out = vec![];
109
    for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
110
        let Some((name, value)) = line.split_once(':') else { return Err(line.to_string()) };
111
        let (name, value) = (name.trim(), value.trim());
112
        let name_ok = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
113
        if !name_ok || value.chars().any(|c| c.is_control()) {
114
            return Err(line.to_string());
115
        }
116
        out.push((name.to_string(), value.to_string()));
117
    }
118
    Ok(out)
119
}
120
121
// -------------------------------------------------------------- sealing
122
123
/// The key that seals tokens, kept in `<data>/keys/assistants.key`.
124
#[derive(Clone)]
125
pub struct SecretKey([u8; 32]);
126
127
impl SecretKey {
128
    pub fn load_or_create(keys: &Path) -> anyhow::Result<SecretKey> {
129
        std::fs::create_dir_all(keys)?;
130
        let path = keys.join("assistants.key");
131
        if let Ok(bytes) = std::fs::read(&path) {
132
            if bytes.len() == 32 {
133
                return Ok(SecretKey(bytes.try_into().unwrap()));
134
            }
135
        }
136
        let mut k = [0u8; 32];
137
        rand::rngs::OsRng.fill_bytes(&mut k);
138
        std::fs::write(&path, k)?;
139
        #[cfg(unix)]
140
        {
141
            use std::os::unix::fs::PermissionsExt;
142
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
143
        }
144
        Ok(SecretKey(k))
145
    }
146
147
    fn key(&self) -> aead::LessSafeKey {
148
        aead::LessSafeKey::new(aead::UnboundKey::new(&aead::CHACHA20_POLY1305, &self.0).expect("key length"))
149
    }
150
151
    /// Seal a token: a random nonce, then the ciphertext and tag. An
152
    /// empty token seals to nothing, meaning "no token".
153
    pub fn seal(&self, plain: &str) -> Vec<u8> {
154
        if plain.is_empty() {
155
            return vec![];
156
        }
157
        let mut nonce = [0u8; 12];
158
        rand::rngs::OsRng.fill_bytes(&mut nonce);
159
        let mut out = plain.as_bytes().to_vec();
160
        self.key().seal_in_place_append_tag(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::empty(), &mut out).expect("seal");
161
        let mut sealed = nonce.to_vec();
162
        sealed.extend(out);
163
        sealed
164
    }
165
166
    /// The token back, or `None` when the bytes were sealed under another
167
    /// key or damaged. Nothing opens to an empty token.
168
    pub fn open(&self, sealed: &[u8]) -> Option<String> {
169
        if sealed.is_empty() {
170
            return Some(String::new());
171
        }
172
        let (nonce, rest) = sealed.split_at_checked(12)?;
173
        let mut buf = rest.to_vec();
174
        let nonce = aead::Nonce::try_assume_unique_for_key(nonce).ok()?;
175
        let plain = self.key().open_in_place(nonce, aead::Aad::empty(), &mut buf).ok()?;
176
        String::from_utf8(plain.to_vec()).ok()
177
    }
178
}
179
180
// ------------------------------------------------------------- storage
181
182
pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Assistant>> {
183
    sqlx::query_as("SELECT * FROM assistants WHERE scope = ? AND scope_id = ? ORDER BY name")
184
        .bind(scope.kind())
185
        .bind(scope.id())
186
        .fetch_all(pool)
187
        .await
188
}
189
190
pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Assistant>> {
191
    sqlx::query_as("SELECT * FROM assistants WHERE id = ?").bind(id).fetch_optional(pool).await
192
}
193
194
/// Add an assistant; `None` when the level already has one of that name.
195
pub async fn create(pool: &Pool, scope: Scope, f: &Fields, token: &[u8]) -> sqlx::Result<Option<Assistant>> {
196
    let now = now_ms();
197
    let r = sqlx::query_as(
198
        "INSERT INTO assistants (scope, scope_id, name, endpoint, api, model, headers, token, enabled, budget, created_ms, updated_ms) \
199
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING *",
200
    )
201
    .bind(scope.kind())
202
    .bind(scope.id())
203
    .bind(&f.name)
204
    .bind(&f.endpoint)
205
    .bind(&f.api)
206
    .bind(&f.model)
207
    .bind(&f.headers)
208
    .bind(token)
209
    .bind(f.enabled)
210
    .bind(f.budget)
211
    .bind(now)
212
    .bind(now)
213
    .fetch_optional(pool)
214
    .await?;
215
    Ok(r)
216
}
217
218
/// Change an assistant's details; `token` is `None` to keep the stored
219
/// one. False when the name is taken at that level.
220
pub async fn update(pool: &Pool, id: i64, f: &Fields, token: Option<&[u8]>) -> sqlx::Result<bool> {
221
    let r = match token {
222
        Some(t) => sqlx::query(
223
            "UPDATE assistants SET name = ?, endpoint = ?, api = ?, model = ?, headers = ?, enabled = ?, budget = ?, token = ?, updated_ms = ? \
224
             WHERE id = ? AND NOT EXISTS (SELECT 1 FROM assistants o WHERE o.scope = assistants.scope AND o.scope_id = assistants.scope_id AND o.name = ? AND o.id <> ?)",
225
        )
226
        .bind(&f.name)
227
        .bind(&f.endpoint)
228
        .bind(&f.api)
229
        .bind(&f.model)
230
        .bind(&f.headers)
231
        .bind(f.enabled)
232
        .bind(f.budget)
233
        .bind(t)
234
        .bind(now_ms())
235
        .bind(id)
236
        .bind(&f.name)
237
        .bind(id)
238
        .execute(pool)
239
        .await?,
240
        None => sqlx::query(
241
            "UPDATE assistants SET name = ?, endpoint = ?, api = ?, model = ?, headers = ?, enabled = ?, budget = ?, updated_ms = ? \
242
             WHERE id = ? AND NOT EXISTS (SELECT 1 FROM assistants o WHERE o.scope = assistants.scope AND o.scope_id = assistants.scope_id AND o.name = ? AND o.id <> ?)",
243
        )
244
        .bind(&f.name)
245
        .bind(&f.endpoint)
246
        .bind(&f.api)
247
        .bind(&f.model)
248
        .bind(&f.headers)
249
        .bind(f.enabled)
250
        .bind(f.budget)
251
        .bind(now_ms())
252
        .bind(id)
253
        .bind(&f.name)
254
        .bind(id)
255
        .execute(pool)
256
        .await?,
257
    };
258
    Ok(r.rows_affected() == 1)
259
}
260
ed89fae5 Andy McCall ↶ 261
/// A renamed assistant keeps its name on the reviews it answered and the
262
/// events that mention it, for the repositories its level covers: the
263
/// name is what people read, not a key.
264
pub async fn rename_in_history(pool: &Pool, scope: Scope, owner_hint: &str, old: &str, new: &str) -> sqlx::Result<()> {
265
    if old == new || old.is_empty() || new.is_empty() {
266
        return Ok(());
267
    }
268
    let like = format!("%{old}%");
269
    match scope {
270
        Scope::Site => {
271
            sqlx::query("UPDATE proposal_reviews SET via = ? WHERE via = ?").bind(new).bind(old).execute(pool).await?;
272
            sqlx::query("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ?")
273
                .bind(old)
274
                .bind(new)
275
                .bind(&like)
276
                .execute(pool)
277
                .await?;
278
        }
279
        Scope::Org(_) => {
280
            let in_org = "proposal_id IN (SELECT p.id FROM proposals p JOIN repositories r ON r.id = p.repo_id WHERE r.owner = ?)";
281
            sqlx::query(&format!("UPDATE proposal_reviews SET via = ? WHERE via = ? AND {in_org}"))
282
                .bind(new)
283
                .bind(old)
284
                .bind(owner_hint)
285
                .execute(pool)
286
                .await?;
287
            sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ? AND {in_org}"))
288
                .bind(old)
289
                .bind(new)
290
                .bind(&like)
291
                .bind(owner_hint)
292
                .execute(pool)
293
                .await?;
294
        }
295
        Scope::Repo(id) => {
296
            let in_repo = "proposal_id IN (SELECT id FROM proposals WHERE repo_id = ?)";
297
            sqlx::query(&format!("UPDATE proposal_reviews SET via = ? WHERE via = ? AND {in_repo}"))
298
                .bind(new)
299
                .bind(old)
300
                .bind(id)
301
                .execute(pool)
302
                .await?;
303
            sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ? AND {in_repo}"))
304
                .bind(old)
305
                .bind(new)
306
                .bind(&like)
307
                .bind(id)
308
                .execute(pool)
309
                .await?;
310
        }
311
    }
312
    Ok(())
313
}
314
e71846aa Andy McCall ↶ 315
pub async fn set_enabled(pool: &Pool, id: i64, enabled: bool) -> sqlx::Result<()> {
316
    sqlx::query("UPDATE assistants SET enabled = ?, updated_ms = ? WHERE id = ?")
317
        .bind(enabled)
318
        .bind(now_ms())
319
        .bind(id)
320
        .execute(pool)
321
        .await?;
322
    Ok(())
323
}
324
325
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
326
    sqlx::query("DELETE FROM assistants WHERE id = ?").bind(id).execute(pool).await?;
327
    Ok(())
328
}
329
330
/// Forget a level's assistants and prompt, when the organisation or
331
/// repository goes.
332
pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> {
333
    sqlx::query("DELETE FROM assistants WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
334
    sqlx::query("DELETE FROM assistant_prompts WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
335
    Ok(())
336
}
337
338
/// The prompt a level adds, empty when none.
339
pub async fn prompt(pool: &Pool, scope: Scope) -> sqlx::Result<String> {
340
    Ok(sqlx::query_scalar("SELECT prompt FROM assistant_prompts WHERE scope = ? AND scope_id = ?")
341
        .bind(scope.kind())
342
        .bind(scope.id())
343
        .fetch_optional(pool)
344
        .await?
345
        .unwrap_or_default())
346
}
347
348
pub async fn set_prompt(pool: &Pool, scope: Scope, text: &str) -> sqlx::Result<()> {
349
    if text.trim().is_empty() {
350
        sqlx::query("DELETE FROM assistant_prompts WHERE scope = ? AND scope_id = ?")
351
            .bind(scope.kind())
352
            .bind(scope.id())
353
            .execute(pool)
354
            .await?;
355
    } else {
356
        sqlx::query(
357
            "INSERT INTO assistant_prompts (scope, scope_id, prompt, updated_ms) VALUES (?, ?, ?, ?) \
358
             ON CONFLICT (scope, scope_id) DO UPDATE SET prompt = excluded.prompt, updated_ms = excluded.updated_ms",
359
        )
360
        .bind(scope.kind())
361
        .bind(scope.id())
362
        .bind(text.trim())
363
        .bind(now_ms())
364
        .execute(pool)
365
        .await?;
366
    }
367
    Ok(())
368
}
369
370
// ----------------------------------------------------- what applies where
371
372
/// The levels above a scope, outermost first: the site, then the
373
/// organisation when the owner is one.
374
pub async fn levels_above(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(Scope, String)>> {
375
    let mut out = vec![];
376
    match scope {
377
        Scope::Site => {}
378
        Scope::Org(_) => out.push((Scope::Site, "the site".to_string())),
379
        Scope::Repo(_) => {
380
            out.push((Scope::Site, "the site".to_string()));
381
            if let Some(org) = crate::db::accounts::org_by_name(pool, owner).await? {
382
                out.push((Scope::Org(org.id), format!("the {} organisation", org.name)));
383
            }
384
        }
385
    }
386
    Ok(out)
387
}
388
389
/// An assistant seen from a level: where it comes from, and whether it is
390
/// on once outer levels have had their say.
391
#[derive(Clone, Debug)]
392
pub struct Available {
393
    pub assistant: Assistant,
394
    /// `the site`, `the acme organisation`, or empty for the level's own.
395
    pub from: String,
396
    /// Off because a level above disabled an assistant of this name.
397
    pub off_above: bool,
398
}
399
400
impl Available {
401
    pub fn on(&self) -> bool {
402
        self.assistant.enabled && !self.off_above
403
    }
404
}
405
406
/// Every assistant a level sees, outermost first, one per name: the
407
/// innermost definition of a name wins, and a name disabled at an outer
408
/// level is off inside it whatever the inner level says.
409
pub async fn available(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Available>> {
410
    let mut levels = levels_above(pool, scope, owner).await?;
411
    levels.push((scope, String::new()));
412
    let mut out: Vec<Available> = vec![];
413
    let mut off: Vec<String> = vec![];
414
    for (level, from) in levels {
415
        for a in list(pool, level).await? {
416
            let off_above = off.iter().any(|n| n.eq_ignore_ascii_case(&a.name));
417
            if !a.enabled && !off.iter().any(|n| n.eq_ignore_ascii_case(&a.name)) {
418
                off.push(a.name.clone());
419
            }
420
            out.retain(|o| !o.assistant.name.eq_ignore_ascii_case(&a.name));
421
            out.push(Available { assistant: a, from: from.clone(), off_above });
422
        }
423
    }
424
    Ok(out)
425
}
426
427
/// The assistants a repository may ask, by name.
428
pub async fn enabled_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<Vec<Assistant>> {
429
    Ok(available(pool, Scope::Repo(repo.id), &repo.owner).await?.into_iter().filter(Available::on).map(|a| a.assistant).collect())
430
}
431
432
/// The prompts a level inherits, outermost first: (where from, text),
433
/// only the levels that set one.
434
pub async fn inherited_prompts(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(String, String)>> {
435
    let mut out = vec![];
436
    for (level, from) in levels_above(pool, scope, owner).await? {
437
        let text = prompt(pool, level).await?;
438
        if !text.is_empty() {
439
            out.push((from, text));
440
        }
441
    }
442
    Ok(out)
443
}
444
445
/// The prompt in force for a repository: each level's text under a
446
/// heading, outermost first, so a project adds to what the site says.
447
pub async fn prompt_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<String> {
448
    let scope = Scope::Repo(repo.id);
449
    let mut parts = inherited_prompts(pool, scope, &repo.owner).await?;
450
    let own = prompt(pool, scope).await?;
451
    if !own.is_empty() {
452
        parts.push(("this repository".to_string(), own));
453
    }
454
    Ok(parts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::<Vec<_>>().join("\n"))
455
}
456
457
// -------------------------------------------------------------- calling
458
459
/// Ask an assistant: the system prompt and the request, the answer's
460
/// text. Errors are for the person who asked: a status and the first
461
/// line of what the endpoint said, or why no answer came.
462
pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str) -> Result<String, String> {
463
    let Some(token) = app.assistant_key.open(&a.token) else {
464
        return Err("its token cannot be read: set it again".into());
465
    };
466
    let client = reqwest::Client::builder()
467
        .timeout(CALL_TIMEOUT)
468
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
469
        .build()
470
        .map_err(|e| e.to_string())?;
471
    let mut req = client.post(&a.endpoint).header("content-type", "application/json");
472
    let body = match a.api.as_str() {
473
        "anthropic-messages" => {
474
            if !token.is_empty() {
475
                req = req.header("x-api-key", &token);
476
            }
477
            req = req.header("anthropic-version", "2023-06-01");
478
            serde_json::json!({
479
                "model": a.model,
480
                "max_tokens": 4096,
481
                "system": system,
482
                "messages": [{ "role": "user", "content": user }],
483
            })
484
        }
485
        _ => {
486
            if !token.is_empty() {
487
                req = req.header("authorization", format!("Bearer {token}"));
488
            }
489
            serde_json::json!({
490
                "model": a.model,
491
                "messages": [
492
                    { "role": "system", "content": system },
493
                    { "role": "user", "content": user },
494
                ],
495
            })
496
        }
497
    };
498
    for (name, value) in a.header_list() {
499
        req = req.header(name, value);
500
    }
501
    let r = req.json(&body).send().await.map_err(|e| {
502
        if e.is_timeout() {
503
            "no answer within 60 seconds".to_string()
504
        } else {
505
            scrub(&e.to_string(), &token)
506
        }
507
    })?;
508
    let status = r.status();
509
    let text: String = r.text().await.unwrap_or_default().chars().take(MAX_ANSWER).collect();
510
    if !status.is_success() {
511
        let why = serde_json::from_str::<serde_json::Value>(&text)
512
            .ok()
513
            .and_then(|v| v["error"]["message"].as_str().or(v["error"].as_str()).or(v["message"].as_str()).map(str::to_string))
514
            .unwrap_or_else(|| text.lines().next().unwrap_or("").chars().take(160).collect());
515
        return Err(scrub(&format!("{} {}", status.as_u16(), why.trim()), &token));
516
    }
517
    let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "the answer was not JSON".to_string())?;
518
    let answer = match a.api.as_str() {
519
        "anthropic-messages" => v["content"]
520
            .as_array()
521
            .map(|parts| parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""))
522
            .unwrap_or_default(),
523
        _ => match &v["choices"][0]["message"]["content"] {
524
            serde_json::Value::String(s) => s.clone(),
525
            serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""),
526
            _ => String::new(),
527
        },
528
    };
529
    if answer.trim().is_empty() {
530
        return Err("the answer carried no text".into());
531
    }
532
    Ok(answer)
533
}
534
535
/// A tiny exchange, for the settings page's TEST: the start of the answer.
536
pub async fn test(app: &Shared, a: &Assistant) -> Result<String, String> {
537
    let answer = ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.").await?;
538
    Ok(answer.trim().chars().take(80).collect())
539
}
540
541
/// Never let the token through in an error message.
542
fn scrub(text: &str, token: &str) -> String {
543
    if token.is_empty() {
544
        text.to_string()
545
    } else {
546
        text.replace(token, "***")
547
    }
548
}
549
550
#[cfg(test)]
551
mod tests {
552
    use super::*;
553
554
    #[test]
555
    fn tokens_seal_and_open_only_under_their_key() {
556
        let dir = tempfile::tempdir().unwrap();
557
        let k = SecretKey::load_or_create(dir.path()).unwrap();
558
        let sealed = k.seal("sk-very-secret");
559
        assert_ne!(sealed, b"sk-very-secret");
560
        assert!(!String::from_utf8_lossy(&sealed).contains("secret"));
561
        assert_eq!(k.open(&sealed).as_deref(), Some("sk-very-secret"));
562
        assert_eq!(SecretKey::load_or_create(dir.path()).unwrap().open(&sealed).as_deref(), Some("sk-very-secret"), "the key is kept");
563
        let other = SecretKey::load_or_create(tempfile::tempdir().unwrap().path()).unwrap();
564
        assert_eq!(other.open(&sealed), None);
565
        assert_eq!(k.open(&[1, 2, 3]), None);
566
        assert_eq!(k.seal(""), Vec::<u8>::new());
567
        assert_eq!(k.open(&[]).as_deref(), Some(""));
568
    }
569
570
    #[test]
571
    fn headers_parse_or_name_the_bad_line() {
572
        assert_eq!(
573
            parse_headers("X-Org: acme\n\n OpenAI-Beta : v1 ").unwrap(),
574
            vec![("X-Org".into(), "acme".into()), ("OpenAI-Beta".into(), "v1".into())]
575
        );
576
        assert_eq!(parse_headers("no colon").unwrap_err(), "no colon");
577
        assert_eq!(parse_headers("bad name: x").unwrap_err(), "bad name: x");
578
    }
579
}
7d2eb590 Andy McCall ↶ 580
581
// ------------------------------------------------------- reviewing
582
583
/// At most this many assistant calls per repository per hour.
584
pub const CALLS_PER_HOUR: usize = 30;
585
586
/// Who is answering what, and recent calls per repository.
587
#[derive(Default)]
588
pub struct Gate {
589
    busy: std::collections::HashSet<(i64, String)>,
590
    recent: std::collections::HashMap<i64, Vec<i64>>,
591
}
592
593
impl Gate {
594
    /// Take the slot for (proposal, assistant), or say why not.
595
    fn take(&mut self, repo_id: i64, proposal_id: i64, name: &str) -> Result<(), String> {
596
        let key = (proposal_id, name.to_lowercase());
597
        if self.busy.contains(&key) {
598
            return Err(format!("{name} is already answering this proposal"));
599
        }
600
        let now = now_ms();
601
        let calls = self.recent.entry(repo_id).or_default();
602
        calls.retain(|t| now - *t < 3_600_000);
603
        if calls.len() >= CALLS_PER_HOUR {
604
            return Err(format!("this repository has asked assistants {CALLS_PER_HOUR} times in the last hour; try later"));
605
        }
606
        calls.push(now);
607
        self.busy.insert(key);
608
        Ok(())
609
    }
610
611
    fn release(&mut self, proposal_id: i64, name: &str) {
612
        self.busy.remove(&(proposal_id, name.to_lowercase()));
613
    }
614
}
615
616
/// What a reviewer is told about a proposal: its title, description,
617
/// changes and diff, cut to `budget` characters.
618
pub fn review_context(
619
    row: &crate::db::repos::RepoRow,
620
    p: &crate::db::proposals::ProposalRow,
621
    loaded: &crate::web::proposals::Loaded,
622
    budget: usize,
623
) -> String {
624
    let mut head =
625
        format!("# Proposal #{}: {}\n\nRepository {}/{} · into {} · by {}\n\n", p.number, p.title, row.owner, row.name, p.target, p.author);
626
    if !p.body.trim().is_empty() {
627
        head.push_str("## Description\n\n");
628
        head.push_str(p.body.trim());
629
        head.push_str("\n\n");
630
    }
631
    context_text(head, &loaded.changes, &loaded.diffs, budget)
632
}
633
634
/// What a drafter is told: the title so far, the changes and the diff.
635
pub fn draft_context(
636
    row: &crate::db::repos::RepoRow,
637
    title: &str,
638
    target: &str,
639
    changes: &[crate::repos::view::ChangeView],
640
    diffs: &[oxy_core::repo::FileDiff],
641
    budget: usize,
642
) -> String {
643
    let head = format!(
644
        "# Proposal: {}\n\nRepository {}/{} · into {}\n\n",
645
        if title.trim().is_empty() { "(untitled)" } else { title.trim() },
646
        row.owner,
647
        row.name,
648
        target
649
    );
650
    context_text(head, changes, diffs, budget)
651
}
652
653
/// `head`, the list of changes, then the diff as `-`/`+` lines per hunk,
654
/// binary files noted, the diff cut so the whole fits `budget`.
655
fn context_text(mut text: String, changes: &[crate::repos::view::ChangeView], diffs: &[oxy_core::repo::FileDiff], budget: usize) -> String {
656
    text.push_str(&format!("## Changes ({})\n\n", changes.len()));
657
    for c in changes {
658
        text.push_str(&format!("- {} {}\n", c.short, c.title));
659
        let body = c.body.trim();
660
        if !body.is_empty() {
661
            for line in body.lines().take(20) {
662
                text.push_str("  ");
663
                text.push_str(line);
664
                text.push('\n');
665
            }
666
        }
667
    }
668
    text.push_str("\n## Diff\n\n");
669
    let head = text.chars().count();
670
    let mut diff = String::new();
671
    for f in diffs {
672
        diff.push_str(&format!("### {} ({})\n", f.path, f.kind));
673
        if let Some(from) = &f.from {
674
            diff.push_str(&format!("renamed from {from}\n"));
675
        }
676
        let binary = f.hunks.iter().any(|h| h.added.iter().chain(h.removed.iter().map(|(_, l)| l)).any(|l| l.contains(&0)));
677
        if binary {
678
            diff.push_str("(binary)\n\n");
679
            continue;
680
        }
681
        diff.push_str("```\n");
682
        for h in &f.hunks {
683
            diff.push_str(&format!("@@ after line {} @@\n", h.base_line));
684
            for (_, l) in &h.removed {
685
                diff.push('-');
686
                diff.push_str(String::from_utf8_lossy(l).trim_end_matches('\n'));
687
                diff.push('\n');
688
            }
689
            for l in &h.added {
690
                diff.push('+');
691
                diff.push_str(String::from_utf8_lossy(l).trim_end_matches('\n'));
692
                diff.push('\n');
693
            }
694
        }
695
        diff.push_str("```\n\n");
696
    }
697
    let room = budget.saturating_sub(head + 80);
698
    if diff.chars().count() > room {
699
        let cut: String = diff.chars().take(room).collect();
700
        text.push_str(&cut);
701
        text.push_str("\n\n(the diff was cut here to fit the context budget)\n");
702
    } else {
703
        text.push_str(&diff);
704
    }
705
    text
706
}
707
708
/// The system prompt for a review: who the assistant is, what a review
709
/// here is, then the prompts the levels set.
710
pub fn review_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
711
    let mut s = format!(
712
        "You are {name}, an AI assistant reviewing a proposal on {site}, an OxygenHub site. \
713
         A person asked for your review; it is recorded as a review comment made via you, under their name. \
714
         You cannot approve: only people approve. Answer in Markdown: start with a one-paragraph summary, \
715
         then findings (correctness first, then clarity), each naming the file and quoting the lines it is about; \
716
         keep praise out unless something is notably well done; say so plainly when you find nothing wrong.",
717
        name = a.name
718
    );
719
    if !prompts.trim().is_empty() {
720
        s.push_str("\n\n");
721
        s.push_str(prompts.trim());
722
    }
723
    s
724
}
725
726
/// Ask `a` to review proposal `p` for `by`: records the request, calls the
727
/// endpoint in the background, and stores the answer as a `comment` review
728
/// via the assistant (or an event saying why none came). `Err` is for the
729
/// person who asked, when nothing was started.
730
pub async fn request_review(
731
    app: &Shared,
732
    row: &crate::db::repos::RepoRow,
733
    p: &crate::db::proposals::ProposalRow,
734
    by: &crate::db::accounts::Account,
735
    a: Assistant,
736
) -> Result<(), String> {
737
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
738
    let pool = &app.db;
739
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} to review", a.name), "").await.ok();
740
    crate::db::accounts::audit(
741
        pool,
742
        Some(by),
743
        "assistant.asked",
744
        &format!("{}/{}", row.owner, row.name),
745
        &format!("{} #{}", a.name, p.number),
746
    )
747
    .await
748
    .ok();
749
    let (app, row, p, by) = (app.clone(), row.clone(), p.clone(), by.clone());
750
    tokio::spawn(async move {
751
        let outcome = answer_review(&app, &row, &p, &a).await;
752
        app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(p.id, &a.name);
753
        match outcome {
754
            Ok(body) => {
755
                if crate::db::proposals::add_review_via(&app.db, p.id, by.id, &by.username, "comment", &body, &a.name).await.is_ok() {
756
                    crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("review.comment via {}", a.name), "").await.ok();
757
                    crate::web::proposals::reviewed(&app, &row, &p, &by, "comment").await;
758
                    crate::hooks::emit(
759
                        &app,
760
                        &row,
761
                        "review",
762
                        &by.username,
763
                        serde_json::json!({ "number": p.number, "verdict": "comment", "via": a.name }),
764
                    )
765
                    .await;
766
                }
767
            }
768
            Err(why) => {
769
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
770
            }
771
        }
772
    });
773
    Ok(())
774
}
775
776
async fn answer_review(
777
    app: &Shared,
778
    row: &crate::db::repos::RepoRow,
779
    p: &crate::db::proposals::ProposalRow,
780
    a: &Assistant,
781
) -> Result<String, String> {
782
    let repo = crate::repos::names::RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
783
    let loaded = crate::web::proposals::load(app, &repo, p.clone()).await.map_err(|e| format!("the proposal could not be loaded: {e}"))?;
784
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
785
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
786
    let system = review_system_prompt(a, &app.config.site.name, &prompts);
787
    let user = review_context(row, p, &loaded, budget.saturating_sub(system.chars().count()));
788
    ask(app, a, &system, &user).await
789
}
790
791
// -------------------------------------------------------- drafting
792
793
/// The system prompt for drafting a proposal's description.
794
pub fn draft_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
795
    let mut s = format!(
796
        "You are {name}, an AI assistant drafting the description of a proposal on {site}, an OxygenHub site, \
797
         for the person who will open or edit it; they read and change your draft before saving, and the draft is theirs. \
798
         Write Markdown for the description field only: no title line, no greeting, no sign-off. \
799
         Say what the changes do and why, what a reviewer should look at, and how to verify them; \
800
         keep to what the diff shows, and keep it short.",
801
        name = a.name
802
    );
803
    if !prompts.trim().is_empty() {
804
        s.push_str("\n\n");
805
        s.push_str(prompts.trim());
806
    }
807
    s
808
}
809
810
/// What a description is drafted from.
811
pub struct Draft<'a> {
812
    /// The proposal's id, or minus the repository's for one not yet opened:
813
    /// one draft per assistant at a time for it.
814
    pub slot: i64,
815
    pub title: &'a str,
816
    pub target: &'a str,
817
    pub changes: &'a [crate::repos::view::ChangeView],
818
    pub diffs: &'a [oxy_core::repo::FileDiff],
819
}
820
821
/// Ask `a` for a description, now; the answer goes into the editor,
822
/// nothing is saved. `Err` is for the person.
823
pub async fn draft_description(
824
    app: &Shared,
825
    row: &crate::db::repos::RepoRow,
826
    by: &crate::db::accounts::Account,
827
    a: &Assistant,
828
    d: Draft<'_>,
829
) -> Result<String, String> {
830
    let Draft { slot, title, target, changes, diffs } = d;
831
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, slot, &a.name)?;
832
    crate::db::accounts::audit(&app.db, Some(by), "assistant.drafted", &format!("{}/{}", row.owner, row.name), &a.name).await.ok();
833
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
834
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string());
835
    let outcome = match prompts {
836
        Ok(prompts) => {
837
            let system = draft_system_prompt(a, &app.config.site.name, &prompts);
838
            let user = draft_context(row, title, target, changes, diffs, budget.saturating_sub(system.chars().count()));
839
            ask(app, a, &system, &user).await
840
        }
841
        Err(e) => Err(e),
842
    };
843
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(slot, &a.name);
844
    outcome.map(|t| t.trim().to_string())
845
}