OXYGENHUB
# c08cec372c81 oxygenhub / crates / hub / src / assistants.rs
45.9 KiB · Rust · last changed Assistants: what to do when a proposal is too much for the model 21 days 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;
5360cea3 Andy McCall ↶ 25
/// How long a call may take while a person waits for it (a draft, a
26
/// connection test): inside Cloudflare's 100 seconds for a proxied request.
27
pub const CALL_TIMEOUT: Duration = Duration::from_secs(90);
28
/// How long a call answered in the background may take (a review, a
29
/// summary): a model that thinks first needs minutes on a large proposal
30
/// (oxygenhub#201).
31
pub const BACKGROUND_TIMEOUT: Duration = Duration::from_secs(300);
32
/// The longest answer asked for: room for a model that thinks before it
33
/// writes (oxygenhub#201: 4096 was spent on thinking alone).
34
pub const MAX_ANSWER_TOKENS: u32 = 16_000;
e71846aa Andy McCall ↶ 35
/// The most an answer is kept.
36
const MAX_ANSWER: usize = 64 * 1024;
37
2a83f530 Andy McCall ↶ 38
pub use crate::levels::Scope;
e71846aa Andy McCall ↶ 39
40
#[derive(Clone, Debug, FromRow)]
41
pub struct Assistant {
42
    pub id: i64,
43
    pub scope: String,
44
    pub scope_id: i64,
45
    pub name: String,
46
    pub endpoint: String,
47
    /// One of [`APIS`].
48
    pub api: String,
49
    pub model: String,
50
    /// Extra request headers, one `Name: value` per line.
51
    pub headers: String,
52
    /// The sealed token; empty when the endpoint needs none.
53
    pub token: Vec<u8>,
54
    pub enabled: bool,
55
    pub budget: i64,
56
    pub created_ms: i64,
57
    pub updated_ms: i64,
58
}
59
60
impl Assistant {
61
    pub fn scope(&self) -> Scope {
62
        match self.scope.as_str() {
63
            "org" => Scope::Org(self.scope_id),
64
            "repo" => Scope::Repo(self.scope_id),
65
            _ => Scope::Site,
66
        }
67
    }
68
69
    pub fn has_token(&self) -> bool {
70
        !self.token.is_empty()
71
    }
72
73
    /// The extra headers as pairs; malformed lines are skipped.
74
    pub fn header_list(&self) -> Vec<(String, String)> {
75
        parse_headers(&self.headers).unwrap_or_default()
76
    }
77
}
78
79
/// What a settings form supplies for an assistant.
80
#[derive(Clone, Debug, Default)]
81
pub struct Fields {
82
    pub name: String,
83
    pub endpoint: String,
84
    pub api: String,
85
    pub model: String,
86
    pub headers: String,
87
    pub enabled: bool,
88
    pub budget: i64,
89
}
90
91
/// `Name: value` lines to pairs: `Err(line)` names the first bad one.
92
pub fn parse_headers(text: &str) -> Result<Vec<(String, String)>, String> {
93
    let mut out = vec![];
94
    for line in text.lines().map(str::trim).filter(|l| !l.is_empty()) {
95
        let Some((name, value)) = line.split_once(':') else { return Err(line.to_string()) };
96
        let (name, value) = (name.trim(), value.trim());
97
        let name_ok = !name.is_empty() && name.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
98
        if !name_ok || value.chars().any(|c| c.is_control()) {
99
            return Err(line.to_string());
100
        }
101
        out.push((name.to_string(), value.to_string()));
102
    }
103
    Ok(out)
104
}
105
106
// -------------------------------------------------------------- sealing
107
108
/// The key that seals tokens, kept in `<data>/keys/assistants.key`.
109
#[derive(Clone)]
110
pub struct SecretKey([u8; 32]);
111
112
impl SecretKey {
113
    pub fn load_or_create(keys: &Path) -> anyhow::Result<SecretKey> {
4ac01a73 Andy McCall ↶ 114
        SecretKey::load_or_create_named(keys, "assistants.key")
115
    }
116
117
    /// The same, for another key file beside it (`secrets.key` seals Flow
118
    /// secrets, #107).
119
    pub fn load_or_create_named(keys: &Path, file: &str) -> anyhow::Result<SecretKey> {
e71846aa Andy McCall ↶ 120
        std::fs::create_dir_all(keys)?;
4ac01a73 Andy McCall ↶ 121
        let path = keys.join(file);
e71846aa Andy McCall ↶ 122
        if let Ok(bytes) = std::fs::read(&path) {
123
            if bytes.len() == 32 {
124
                return Ok(SecretKey(bytes.try_into().unwrap()));
125
            }
126
        }
127
        let mut k = [0u8; 32];
128
        rand::rngs::OsRng.fill_bytes(&mut k);
129
        std::fs::write(&path, k)?;
130
        #[cfg(unix)]
131
        {
132
            use std::os::unix::fs::PermissionsExt;
133
            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
134
        }
135
        Ok(SecretKey(k))
136
    }
137
138
    fn key(&self) -> aead::LessSafeKey {
139
        aead::LessSafeKey::new(aead::UnboundKey::new(&aead::CHACHA20_POLY1305, &self.0).expect("key length"))
140
    }
141
142
    /// Seal a token: a random nonce, then the ciphertext and tag. An
143
    /// empty token seals to nothing, meaning "no token".
144
    pub fn seal(&self, plain: &str) -> Vec<u8> {
145
        if plain.is_empty() {
146
            return vec![];
147
        }
148
        let mut nonce = [0u8; 12];
149
        rand::rngs::OsRng.fill_bytes(&mut nonce);
150
        let mut out = plain.as_bytes().to_vec();
151
        self.key().seal_in_place_append_tag(aead::Nonce::assume_unique_for_key(nonce), aead::Aad::empty(), &mut out).expect("seal");
152
        let mut sealed = nonce.to_vec();
153
        sealed.extend(out);
154
        sealed
155
    }
156
157
    /// The token back, or `None` when the bytes were sealed under another
158
    /// key or damaged. Nothing opens to an empty token.
159
    pub fn open(&self, sealed: &[u8]) -> Option<String> {
160
        if sealed.is_empty() {
161
            return Some(String::new());
162
        }
163
        let (nonce, rest) = sealed.split_at_checked(12)?;
164
        let mut buf = rest.to_vec();
165
        let nonce = aead::Nonce::try_assume_unique_for_key(nonce).ok()?;
166
        let plain = self.key().open_in_place(nonce, aead::Aad::empty(), &mut buf).ok()?;
167
        String::from_utf8(plain.to_vec()).ok()
168
    }
169
}
170
171
// ------------------------------------------------------------- storage
172
173
pub async fn list(pool: &Pool, scope: Scope) -> sqlx::Result<Vec<Assistant>> {
174
    sqlx::query_as("SELECT * FROM assistants WHERE scope = ? AND scope_id = ? ORDER BY name")
175
        .bind(scope.kind())
176
        .bind(scope.id())
177
        .fetch_all(pool)
178
        .await
179
}
180
181
pub async fn get(pool: &Pool, id: i64) -> sqlx::Result<Option<Assistant>> {
182
    sqlx::query_as("SELECT * FROM assistants WHERE id = ?").bind(id).fetch_optional(pool).await
183
}
184
185
/// Add an assistant; `None` when the level already has one of that name.
186
pub async fn create(pool: &Pool, scope: Scope, f: &Fields, token: &[u8]) -> sqlx::Result<Option<Assistant>> {
187
    let now = now_ms();
188
    let r = sqlx::query_as(
189
        "INSERT INTO assistants (scope, scope_id, name, endpoint, api, model, headers, token, enabled, budget, created_ms, updated_ms) \
190
         VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT DO NOTHING RETURNING *",
191
    )
192
    .bind(scope.kind())
193
    .bind(scope.id())
194
    .bind(&f.name)
195
    .bind(&f.endpoint)
196
    .bind(&f.api)
197
    .bind(&f.model)
198
    .bind(&f.headers)
199
    .bind(token)
200
    .bind(f.enabled)
201
    .bind(f.budget)
202
    .bind(now)
203
    .bind(now)
204
    .fetch_optional(pool)
205
    .await?;
206
    Ok(r)
207
}
208
209
/// Change an assistant's details; `token` is `None` to keep the stored
210
/// one. False when the name is taken at that level.
211
pub async fn update(pool: &Pool, id: i64, f: &Fields, token: Option<&[u8]>) -> sqlx::Result<bool> {
212
    let r = match token {
213
        Some(t) => sqlx::query(
214
            "UPDATE assistants SET name = ?, endpoint = ?, api = ?, model = ?, headers = ?, enabled = ?, budget = ?, token = ?, updated_ms = ? \
215
             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 <> ?)",
216
        )
217
        .bind(&f.name)
218
        .bind(&f.endpoint)
219
        .bind(&f.api)
220
        .bind(&f.model)
221
        .bind(&f.headers)
222
        .bind(f.enabled)
223
        .bind(f.budget)
224
        .bind(t)
225
        .bind(now_ms())
226
        .bind(id)
227
        .bind(&f.name)
228
        .bind(id)
229
        .execute(pool)
230
        .await?,
231
        None => sqlx::query(
232
            "UPDATE assistants SET name = ?, endpoint = ?, api = ?, model = ?, headers = ?, enabled = ?, budget = ?, updated_ms = ? \
233
             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 <> ?)",
234
        )
235
        .bind(&f.name)
236
        .bind(&f.endpoint)
237
        .bind(&f.api)
238
        .bind(&f.model)
239
        .bind(&f.headers)
240
        .bind(f.enabled)
241
        .bind(f.budget)
242
        .bind(now_ms())
243
        .bind(id)
244
        .bind(&f.name)
245
        .bind(id)
246
        .execute(pool)
247
        .await?,
248
    };
249
    Ok(r.rows_affected() == 1)
250
}
251
ed89fae5 Andy McCall ↶ 252
/// A renamed assistant keeps its name on the reviews it answered and the
253
/// events that mention it, for the repositories its level covers: the
254
/// name is what people read, not a key.
255
pub async fn rename_in_history(pool: &Pool, scope: Scope, owner_hint: &str, old: &str, new: &str) -> sqlx::Result<()> {
256
    if old == new || old.is_empty() || new.is_empty() {
257
        return Ok(());
258
    }
259
    let like = format!("%{old}%");
260
    match scope {
261
        Scope::Site => {
ffb67a81 Andy McCall 262
            for table in ["proposal_reviews", "proposal_comments"] {
263
                sqlx::query(&format!("UPDATE {table} SET via = ? WHERE via = ?")).bind(new).bind(old).execute(pool).await?;
264
            }
ed89fae5 Andy McCall ↶ 265
            sqlx::query("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ?")
266
                .bind(old)
267
                .bind(new)
268
                .bind(&like)
269
                .execute(pool)
270
                .await?;
271
        }
272
        Scope::Org(_) => {
273
            let in_org = "proposal_id IN (SELECT p.id FROM proposals p JOIN repositories r ON r.id = p.repo_id WHERE r.owner = ?)";
ffb67a81 Andy McCall 274
            for table in ["proposal_reviews", "proposal_comments"] {
275
                sqlx::query(&format!("UPDATE {table} SET via = ? WHERE via = ? AND {in_org}"))
276
                    .bind(new)
277
                    .bind(old)
278
                    .bind(owner_hint)
279
                    .execute(pool)
280
                    .await?;
281
            }
ed89fae5 Andy McCall ↶ 282
            sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ? AND {in_org}"))
283
                .bind(old)
284
                .bind(new)
285
                .bind(&like)
286
                .bind(owner_hint)
287
                .execute(pool)
288
                .await?;
289
        }
290
        Scope::Repo(id) => {
291
            let in_repo = "proposal_id IN (SELECT id FROM proposals WHERE repo_id = ?)";
ffb67a81 Andy McCall 292
            for table in ["proposal_reviews", "proposal_comments"] {
293
                sqlx::query(&format!("UPDATE {table} SET via = ? WHERE via = ? AND {in_repo}"))
294
                    .bind(new)
295
                    .bind(old)
296
                    .bind(id)
297
                    .execute(pool)
298
                    .await?;
299
            }
ed89fae5 Andy McCall ↶ 300
            sqlx::query(&format!("UPDATE proposal_events SET event = replace(event, ?, ?) WHERE event LIKE ? AND {in_repo}"))
301
                .bind(old)
302
                .bind(new)
303
                .bind(&like)
304
                .bind(id)
305
                .execute(pool)
306
                .await?;
307
        }
308
    }
309
    Ok(())
310
}
311
e71846aa Andy McCall ↶ 312
pub async fn set_enabled(pool: &Pool, id: i64, enabled: bool) -> sqlx::Result<()> {
313
    sqlx::query("UPDATE assistants SET enabled = ?, updated_ms = ? WHERE id = ?")
314
        .bind(enabled)
315
        .bind(now_ms())
316
        .bind(id)
317
        .execute(pool)
318
        .await?;
319
    Ok(())
320
}
321
322
pub async fn delete(pool: &Pool, id: i64) -> sqlx::Result<()> {
323
    sqlx::query("DELETE FROM assistants WHERE id = ?").bind(id).execute(pool).await?;
324
    Ok(())
325
}
326
327
/// Forget a level's assistants and prompt, when the organisation or
328
/// repository goes.
329
pub async fn delete_scope(pool: &Pool, scope: Scope) -> sqlx::Result<()> {
330
    sqlx::query("DELETE FROM assistants WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
331
    sqlx::query("DELETE FROM assistant_prompts WHERE scope = ? AND scope_id = ?").bind(scope.kind()).bind(scope.id()).execute(pool).await?;
332
    Ok(())
333
}
334
335
/// The prompt a level adds, empty when none.
336
pub async fn prompt(pool: &Pool, scope: Scope) -> sqlx::Result<String> {
337
    Ok(sqlx::query_scalar("SELECT prompt FROM assistant_prompts WHERE scope = ? AND scope_id = ?")
338
        .bind(scope.kind())
339
        .bind(scope.id())
340
        .fetch_optional(pool)
341
        .await?
342
        .unwrap_or_default())
343
}
344
345
pub async fn set_prompt(pool: &Pool, scope: Scope, text: &str) -> sqlx::Result<()> {
346
    if text.trim().is_empty() {
347
        sqlx::query("DELETE FROM assistant_prompts WHERE scope = ? AND scope_id = ?")
348
            .bind(scope.kind())
349
            .bind(scope.id())
350
            .execute(pool)
351
            .await?;
352
    } else {
353
        sqlx::query(
354
            "INSERT INTO assistant_prompts (scope, scope_id, prompt, updated_ms) VALUES (?, ?, ?, ?) \
355
             ON CONFLICT (scope, scope_id) DO UPDATE SET prompt = excluded.prompt, updated_ms = excluded.updated_ms",
356
        )
357
        .bind(scope.kind())
358
        .bind(scope.id())
359
        .bind(text.trim())
360
        .bind(now_ms())
361
        .execute(pool)
362
        .await?;
363
    }
364
    Ok(())
365
}
366
367
// ----------------------------------------------------- what applies where
368
369
/// An assistant seen from a level: where it comes from, and whether it is
370
/// on once outer levels have had their say.
371
#[derive(Clone, Debug)]
372
pub struct Available {
373
    pub assistant: Assistant,
374
    /// `the site`, `the acme organisation`, or empty for the level's own.
375
    pub from: String,
376
    /// Off because a level above disabled an assistant of this name.
377
    pub off_above: bool,
378
}
379
380
impl Available {
381
    pub fn on(&self) -> bool {
382
        self.assistant.enabled && !self.off_above
383
    }
384
}
385
386
/// Every assistant a level sees, outermost first, one per name: the
387
/// innermost definition of a name wins, and a name disabled at an outer
388
/// level is off inside it whatever the inner level says.
389
pub async fn available(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<Available>> {
2a83f530 Andy McCall ↶ 390
    let mut levels = crate::levels::above(pool, scope, owner).await?;
e71846aa Andy McCall ↶ 391
    levels.push((scope, String::new()));
392
    let mut out: Vec<Available> = vec![];
393
    let mut off: Vec<String> = vec![];
394
    for (level, from) in levels {
395
        for a in list(pool, level).await? {
396
            let off_above = off.iter().any(|n| n.eq_ignore_ascii_case(&a.name));
397
            if !a.enabled && !off.iter().any(|n| n.eq_ignore_ascii_case(&a.name)) {
398
                off.push(a.name.clone());
399
            }
400
            out.retain(|o| !o.assistant.name.eq_ignore_ascii_case(&a.name));
401
            out.push(Available { assistant: a, from: from.clone(), off_above });
402
        }
403
    }
404
    Ok(out)
405
}
406
407
/// The assistants a repository may ask, by name.
408
pub async fn enabled_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<Vec<Assistant>> {
409
    Ok(available(pool, Scope::Repo(repo.id), &repo.owner).await?.into_iter().filter(Available::on).map(|a| a.assistant).collect())
410
}
411
412
/// The prompts a level inherits, outermost first: (where from, text),
413
/// only the levels that set one.
414
pub async fn inherited_prompts(pool: &Pool, scope: Scope, owner: &str) -> sqlx::Result<Vec<(String, String)>> {
415
    let mut out = vec![];
2a83f530 Andy McCall ↶ 416
    for (level, from) in crate::levels::above(pool, scope, owner).await? {
e71846aa Andy McCall ↶ 417
        let text = prompt(pool, level).await?;
418
        if !text.is_empty() {
419
            out.push((from, text));
420
        }
421
    }
422
    Ok(out)
423
}
424
425
/// The prompt in force for a repository: each level's text under a
426
/// heading, outermost first, so a project adds to what the site says.
427
pub async fn prompt_for_repo(pool: &Pool, repo: &crate::db::repos::RepoRow) -> sqlx::Result<String> {
428
    let scope = Scope::Repo(repo.id);
429
    let mut parts = inherited_prompts(pool, scope, &repo.owner).await?;
430
    let own = prompt(pool, scope).await?;
431
    if !own.is_empty() {
432
        parts.push(("this repository".to_string(), own));
433
    }
434
    Ok(parts.into_iter().map(|(from, text)| format!("## Instructions from {from}\n\n{text}\n")).collect::<Vec<_>>().join("\n"))
435
}
436
437
// -------------------------------------------------------------- calling
438
439
/// Ask an assistant: the system prompt and the request, the answer's
440
/// text. Errors are for the person who asked: a status and the first
441
/// line of what the endpoint said, or why no answer came.
5360cea3 Andy McCall ↶ 442
pub async fn ask(app: &Shared, a: &Assistant, system: &str, user: &str, wait: Duration) -> Result<String, String> {
e71846aa Andy McCall ↶ 443
    let Some(token) = app.assistant_key.open(&a.token) else {
444
        return Err("its token cannot be read: set it again".into());
445
    };
446
    let client = reqwest::Client::builder()
5360cea3 Andy McCall ↶ 447
        .timeout(wait)
e71846aa Andy McCall ↶ 448
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
449
        .build()
450
        .map_err(|e| e.to_string())?;
451
    let mut req = client.post(&a.endpoint).header("content-type", "application/json");
452
    let body = match a.api.as_str() {
453
        "anthropic-messages" => {
454
            if !token.is_empty() {
455
                req = req.header("x-api-key", &token);
456
            }
457
            req = req.header("anthropic-version", "2023-06-01");
458
            serde_json::json!({
459
                "model": a.model,
5360cea3 Andy McCall ↶ 460
                "max_tokens": MAX_ANSWER_TOKENS,
e71846aa Andy McCall ↶ 461
                "system": system,
462
                "messages": [{ "role": "user", "content": user }],
463
            })
464
        }
465
        _ => {
466
            if !token.is_empty() {
467
                req = req.header("authorization", format!("Bearer {token}"));
468
            }
5360cea3 Andy McCall ↶ 469
            // No length here: providers differ (`max_tokens`, or
470
            // `max_completion_tokens` for newer OpenAI models, with
471
            // different ceilings), and their defaults are generous.
e71846aa Andy McCall ↶ 472
            serde_json::json!({
473
                "model": a.model,
474
                "messages": [
475
                    { "role": "system", "content": system },
476
                    { "role": "user", "content": user },
477
                ],
478
            })
479
        }
480
    };
481
    for (name, value) in a.header_list() {
482
        req = req.header(name, value);
483
    }
484
    let r = req.json(&body).send().await.map_err(|e| {
485
        if e.is_timeout() {
5360cea3 Andy McCall ↶ 486
            format!("no answer within {} seconds", wait.as_secs())
e71846aa Andy McCall ↶ 487
        } else {
488
            scrub(&e.to_string(), &token)
489
        }
490
    })?;
491
    let status = r.status();
492
    let text: String = r.text().await.unwrap_or_default().chars().take(MAX_ANSWER).collect();
493
    if !status.is_success() {
494
        let why = serde_json::from_str::<serde_json::Value>(&text)
495
            .ok()
496
            .and_then(|v| v["error"]["message"].as_str().or(v["error"].as_str()).or(v["message"].as_str()).map(str::to_string))
497
            .unwrap_or_else(|| text.lines().next().unwrap_or("").chars().take(160).collect());
498
        return Err(scrub(&format!("{} {}", status.as_u16(), why.trim()), &token));
499
    }
500
    let v: serde_json::Value = serde_json::from_str(&text).map_err(|_| "the answer was not JSON".to_string())?;
5360cea3 Andy McCall ↶ 501
    let (answer, stop) = answer_of(&a.api, &v);
502
    finish(answer, stop)
503
}
504
505
/// Why an answer ended, as the person who asked needs to know it.
506
#[derive(Debug, PartialEq, Eq)]
507
enum Stop {
508
    /// It finished.
509
    Done,
510
    /// It reached the length it was allowed (thinking counts against it).
511
    Length,
512
    /// The provider declined to answer, with its category if it gave one.
513
    Declined(Option<String>),
514
}
515
516
/// The text of a provider's answer (text parts only: thinking is not
517
/// part of the answer), and why it ended.
518
fn answer_of(api: &str, v: &serde_json::Value) -> (String, Stop) {
519
    match api {
520
        "anthropic-messages" => {
521
            let text = v["content"]
522
                .as_array()
523
                .map(|parts| {
524
                    parts
525
                        .iter()
526
                        .filter(|p| p["type"].as_str().is_none_or(|t| t == "text"))
527
                        .filter_map(|p| p["text"].as_str())
528
                        .collect::<Vec<_>>()
529
                        .join("")
530
                })
531
                .unwrap_or_default();
532
            let stop = match v["stop_reason"].as_str() {
533
                Some("max_tokens") => Stop::Length,
534
                Some("refusal") => Stop::Declined(v["stop_details"]["category"].as_str().map(str::to_string)),
535
                _ => Stop::Done,
536
            };
537
            (text, stop)
538
        }
539
        _ => {
540
            let choice = &v["choices"][0];
541
            let text = match &choice["message"]["content"] {
542
                serde_json::Value::String(s) => s.clone(),
543
                serde_json::Value::Array(parts) => parts.iter().filter_map(|p| p["text"].as_str()).collect::<Vec<_>>().join(""),
544
                _ => String::new(),
545
            };
546
            let stop = match choice["finish_reason"].as_str() {
547
                Some("length") => Stop::Length,
548
                Some("content_filter") => Stop::Declined(None),
549
                _ => Stop::Done,
550
            };
551
            (text, stop)
552
        }
e71846aa Andy McCall ↶ 553
    }
554
}
555
5360cea3 Andy McCall ↶ 556
/// The answer, or why there is none. An answer cut short is kept, with a
557
/// note saying so.
558
fn finish(answer: String, stop: Stop) -> Result<String, String> {
559
    match stop {
560
        Stop::Declined(category) => Err(match category {
561
            Some(c) => format!("it declined to answer ({c})"),
562
            None => "it declined to answer".into(),
563
        }),
564
        Stop::Length if answer.trim().is_empty() => {
565
            Err("it ran out of room before writing an answer (it spent its length limit thinking): try a smaller proposal".into())
566
        }
567
        Stop::Length => Ok(format!("{}\n\n*(Cut short: the answer reached its length limit.)*", answer.trim_end())),
568
        Stop::Done if answer.trim().is_empty() => Err("the answer carried no text".into()),
569
        Stop::Done => Ok(answer),
570
    }
571
}
572
155cab14 Andy McCall ↶ 573
/// Providers the add form offers, with what they need (#109):
574
/// (id, label, name, endpoint, api).
575
pub const PRESETS: &[(&str, &str, &str, &str, &str)] = &[
576
    ("anthropic", "Anthropic (Claude)", "Claude", "https://api.anthropic.com/v1/messages", "anthropic-messages"),
577
    ("openai", "OpenAI (ChatGPT)", "ChatGPT", "https://api.openai.com/v1/chat/completions", "chat-completions"),
578
    ("gemini", "Google Gemini", "Gemini", "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", "chat-completions"),
579
    ("deepseek", "DeepSeek", "DeepSeek", "https://api.deepseek.com/chat/completions", "chat-completions"),
580
    ("mistral", "Mistral", "Mistral", "https://api.mistral.ai/v1/chat/completions", "chat-completions"),
581
    ("perplexity", "Perplexity", "Perplexity", "https://api.perplexity.ai/chat/completions", "chat-completions"),
582
    ("ollama", "Ollama (on your network)", "Ollama", "http://localhost:11434/v1/chat/completions", "chat-completions"),
583
];
584
585
/// Where an endpoint lists its models: `…/chat/completions` → `…/models`,
586
/// `…/messages` → `…/models`.
587
pub fn models_url(endpoint: &str) -> Option<String> {
588
    let e = endpoint.trim_end_matches('/');
589
    e.strip_suffix("/chat/completions").or_else(|| e.strip_suffix("/messages")).map(|base| format!("{base}/models"))
590
}
591
592
/// The chat models an endpoint offers for this token, sorted; `Err` when
593
/// it cannot say (no list, refused, unreachable). Embedding, speech and
594
/// image models are left out.
595
pub async fn list_models(endpoint: &str, api: &str, token: &str) -> Result<Vec<String>, String> {
596
    let url = models_url(endpoint).ok_or("this endpoint has no model list next to it")?;
597
    let client = reqwest::Client::builder()
598
        .timeout(std::time::Duration::from_secs(15))
599
        .user_agent(format!("oxygenhub/{}", env!("CARGO_PKG_VERSION")))
600
        .build()
601
        .map_err(|e| e.to_string())?;
602
    let mut req = client.get(&url);
603
    if api == "anthropic-messages" {
604
        req = req.header("anthropic-version", "2023-06-01");
605
        if !token.is_empty() {
606
            req = req.header("x-api-key", token);
607
        }
608
    } else if !token.is_empty() {
609
        req = req.header("authorization", format!("Bearer {token}"));
610
    }
611
    let r = req.send().await.map_err(|e| scrub(&e.to_string(), token))?;
612
    let status = r.status();
613
    if !status.is_success() {
614
        return Err(format!("the model list answered {}", status.as_u16()));
615
    }
616
    let v: serde_json::Value = r.json().await.map_err(|_| "the model list was not JSON".to_string())?;
617
    let skip = ["embed", "tts", "whisper", "dall-e", "moderation", "audio", "image", "realtime", "transcribe", "search-preview"];
618
    let mut out: Vec<String> = v["data"]
619
        .as_array()
620
        .or(v["models"].as_array())
621
        .ok_or("the model list had no data")?
622
        .iter()
623
        .filter_map(|m| m["id"].as_str().or(m["name"].as_str()))
624
        .map(|id| id.strip_prefix("models/").unwrap_or(id).to_string())
625
        .filter(|id| !skip.iter().any(|s| id.to_lowercase().contains(s)))
626
        .collect();
627
    out.sort();
628
    out.dedup();
629
    if out.is_empty() {
630
        return Err("the model list was empty".into());
631
    }
632
    Ok(out)
633
}
634
e71846aa Andy McCall ↶ 635
/// A tiny exchange, for the settings page's TEST: the start of the answer.
636
pub async fn test(app: &Shared, a: &Assistant) -> Result<String, String> {
5360cea3 Andy McCall ↶ 637
    let answer =
638
        ask(app, a, "You are being connected to an OxygenHub site. Answer briefly.", "Reply with the single word OK.", CALL_TIMEOUT)
639
            .await?;
e71846aa Andy McCall ↶ 640
    Ok(answer.trim().chars().take(80).collect())
641
}
642
643
/// Never let the token through in an error message.
644
fn scrub(text: &str, token: &str) -> String {
645
    if token.is_empty() {
646
        text.to_string()
647
    } else {
648
        text.replace(token, "***")
649
    }
650
}
651
652
#[cfg(test)]
653
mod tests {
654
    use super::*;
655
656
    #[test]
657
    fn tokens_seal_and_open_only_under_their_key() {
658
        let dir = tempfile::tempdir().unwrap();
659
        let k = SecretKey::load_or_create(dir.path()).unwrap();
660
        let sealed = k.seal("sk-very-secret");
661
        assert_ne!(sealed, b"sk-very-secret");
662
        assert!(!String::from_utf8_lossy(&sealed).contains("secret"));
663
        assert_eq!(k.open(&sealed).as_deref(), Some("sk-very-secret"));
664
        assert_eq!(SecretKey::load_or_create(dir.path()).unwrap().open(&sealed).as_deref(), Some("sk-very-secret"), "the key is kept");
665
        let other = SecretKey::load_or_create(tempfile::tempdir().unwrap().path()).unwrap();
666
        assert_eq!(other.open(&sealed), None);
667
        assert_eq!(k.open(&[1, 2, 3]), None);
668
        assert_eq!(k.seal(""), Vec::<u8>::new());
669
        assert_eq!(k.open(&[]).as_deref(), Some(""));
5360cea3 Andy McCall ↶ 670
    }
671
672
    #[test]
673
    fn answers_say_why_they_stopped() {
674
        let a = |v: serde_json::Value| {
675
            let (text, stop) = answer_of("anthropic-messages", &v);
676
            finish(text, stop)
677
        };
678
        // #201: all of the length spent thinking.
679
        let thought = serde_json::json!({ "stop_reason": "max_tokens", "content": [{ "type": "thinking", "thinking": "" }] });
680
        assert!(a(thought).unwrap_err().contains("ran out of room"));
681
        let cut = serde_json::json!({ "stop_reason": "max_tokens", "content": [{ "type": "thinking", "thinking": "" }, { "type": "text", "text": "Half" }] });
682
        assert!(a(cut).unwrap().starts_with("Half\n\n*(Cut short"));
683
        let declined = serde_json::json!({ "stop_reason": "refusal", "stop_details": { "category": "cyber" }, "content": [] });
684
        assert_eq!(a(declined).unwrap_err(), "it declined to answer (cyber)");
685
        let ok = serde_json::json!({ "stop_reason": "end_turn", "content": [{ "type": "text", "text": "Fine." }] });
686
        assert_eq!(a(ok).unwrap(), "Fine.");
687
        let chat = |v: serde_json::Value| {
688
            let (text, stop) = answer_of("chat-completions", &v);
689
            finish(text, stop)
690
        };
691
        assert!(chat(serde_json::json!({ "choices": [{ "finish_reason": "length", "message": { "content": "" } }] }))
692
            .unwrap_err()
693
            .contains("ran out of room"));
694
        assert_eq!(
695
            chat(serde_json::json!({ "choices": [{ "finish_reason": "content_filter", "message": { "content": "" } }] })).unwrap_err(),
696
            "it declined to answer"
697
        );
698
        assert_eq!(
699
            chat(serde_json::json!({ "choices": [{ "finish_reason": "stop", "message": { "content": "Yes." } }] })).unwrap(),
700
            "Yes."
701
        );
e71846aa Andy McCall ↶ 702
    }
703
704
    #[test]
c08cec37 Andy McCall ↶ 705
    fn a_refusal_for_length_is_told_from_others() {
706
        for e in [
707
            "400 This model's maximum context length is 8192 tokens. Please reduce the length of the messages.",
708
            "400 prompt is too long: 210000 tokens > 200000 maximum",
709
            "413 Request too large",
710
            "400 input length exceeds the context window",
711
        ] {
712
            assert!(super::too_long(e), "{e}");
713
        }
714
        for e in ["401 invalid api key", "500 too long a queue", "no answer within 90 seconds", "400 model not found"] {
715
            assert!(!super::too_long(e), "{e}");
716
        }
717
    }
718
719
    #[test]
e71846aa Andy McCall ↶ 720
    fn headers_parse_or_name_the_bad_line() {
721
        assert_eq!(
722
            parse_headers("X-Org: acme\n\n OpenAI-Beta : v1 ").unwrap(),
723
            vec![("X-Org".into(), "acme".into()), ("OpenAI-Beta".into(), "v1".into())]
724
        );
725
        assert_eq!(parse_headers("no colon").unwrap_err(), "no colon");
726
        assert_eq!(parse_headers("bad name: x").unwrap_err(), "bad name: x");
727
    }
728
}
7d2eb590 Andy McCall ↶ 729
730
// ------------------------------------------------------- reviewing
731
732
/// At most this many assistant calls per repository per hour.
733
pub const CALLS_PER_HOUR: usize = 30;
734
735
/// Who is answering what, and recent calls per repository.
736
#[derive(Default)]
737
pub struct Gate {
738
    busy: std::collections::HashSet<(i64, String)>,
739
    recent: std::collections::HashMap<i64, Vec<i64>>,
740
}
741
742
impl Gate {
743
    /// Take the slot for (proposal, assistant), or say why not.
744
    fn take(&mut self, repo_id: i64, proposal_id: i64, name: &str) -> Result<(), String> {
745
        let key = (proposal_id, name.to_lowercase());
746
        if self.busy.contains(&key) {
747
            return Err(format!("{name} is already answering this proposal"));
748
        }
749
        let now = now_ms();
750
        let calls = self.recent.entry(repo_id).or_default();
751
        calls.retain(|t| now - *t < 3_600_000);
752
        if calls.len() >= CALLS_PER_HOUR {
753
            return Err(format!("this repository has asked assistants {CALLS_PER_HOUR} times in the last hour; try later"));
754
        }
755
        calls.push(now);
756
        self.busy.insert(key);
757
        Ok(())
758
    }
759
760
    fn release(&mut self, proposal_id: i64, name: &str) {
761
        self.busy.remove(&(proposal_id, name.to_lowercase()));
762
    }
763
}
764
765
/// What a reviewer is told about a proposal: its title, description,
766
/// changes and diff, cut to `budget` characters.
767
pub fn review_context(
768
    row: &crate::db::repos::RepoRow,
769
    p: &crate::db::proposals::ProposalRow,
770
    loaded: &crate::web::proposals::Loaded,
771
    budget: usize,
772
) -> String {
773
    let mut head =
774
        format!("# Proposal #{}: {}\n\nRepository {}/{} · into {} · by {}\n\n", p.number, p.title, row.owner, row.name, p.target, p.author);
775
    if !p.body.trim().is_empty() {
776
        head.push_str("## Description\n\n");
777
        head.push_str(p.body.trim());
778
        head.push_str("\n\n");
779
    }
780
    context_text(head, &loaded.changes, &loaded.diffs, budget)
781
}
782
783
/// What a drafter is told: the title so far, the changes and the diff.
784
pub fn draft_context(
785
    row: &crate::db::repos::RepoRow,
786
    title: &str,
787
    target: &str,
788
    changes: &[crate::repos::view::ChangeView],
789
    diffs: &[oxy_core::repo::FileDiff],
790
    budget: usize,
791
) -> String {
792
    let head = format!(
793
        "# Proposal: {}\n\nRepository {}/{} · into {}\n\n",
794
        if title.trim().is_empty() { "(untitled)" } else { title.trim() },
795
        row.owner,
796
        row.name,
797
        target
798
    );
799
    context_text(head, changes, diffs, budget)
800
}
801
802
/// `head`, the list of changes, then the diff as `-`/`+` lines per hunk,
803
/// binary files noted, the diff cut so the whole fits `budget`.
804
fn context_text(mut text: String, changes: &[crate::repos::view::ChangeView], diffs: &[oxy_core::repo::FileDiff], budget: usize) -> String {
805
    text.push_str(&format!("## Changes ({})\n\n", changes.len()));
806
    for c in changes {
807
        text.push_str(&format!("- {} {}\n", c.short, c.title));
808
        let body = c.body.trim();
809
        if !body.is_empty() {
810
            for line in body.lines().take(20) {
811
                text.push_str("  ");
812
                text.push_str(line);
813
                text.push('\n');
814
            }
815
        }
816
    }
c08cec37 Andy McCall ↶ 817
    // Every file with its size first, so a model told only part of the
818
    // diff still knows the whole (oxygenhub#248).
819
    text.push_str(&format!("\n## Files ({})\n\n", diffs.len()));
820
    let mut parts: Vec<(String, String)> = Vec::with_capacity(diffs.len());
7d2eb590 Andy McCall ↶ 821
    for f in diffs {
c08cec37 Andy McCall ↶ 822
        let (added, removed) = f.hunks.iter().fold((0, 0), |(a, r), h| (a + h.added.len(), r + h.removed.len()));
823
        text.push_str(&format!("- {} ({}, +{added} -{removed})\n", f.path, f.kind));
824
        let mut diff = format!("### {} ({})\n", f.path, f.kind);
7d2eb590 Andy McCall ↶ 825
        if let Some(from) = &f.from {
826
            diff.push_str(&format!("renamed from {from}\n"));
827
        }
828
        let binary = f.hunks.iter().any(|h| h.added.iter().chain(h.removed.iter().map(|(_, l)| l)).any(|l| l.contains(&0)));
829
        if binary {
830
            diff.push_str("(binary)\n\n");
c08cec37 Andy McCall ↶ 831
            parts.push((f.path.clone(), diff));
7d2eb590 Andy McCall ↶ 832
            continue;
833
        }
834
        diff.push_str("```\n");
835
        for h in &f.hunks {
836
            diff.push_str(&format!("@@ after line {} @@\n", h.base_line));
837
            for (_, l) in &h.removed {
838
                diff.push('-');
839
                diff.push_str(String::from_utf8_lossy(l).trim_end_matches('\n'));
840
                diff.push('\n');
841
            }
842
            for l in &h.added {
843
                diff.push('+');
844
                diff.push_str(String::from_utf8_lossy(l).trim_end_matches('\n'));
845
                diff.push('\n');
846
            }
847
        }
848
        diff.push_str("```\n\n");
c08cec37 Andy McCall ↶ 849
        parts.push((f.path.clone(), diff));
7d2eb590 Andy McCall ↶ 850
    }
c08cec37 Andy McCall ↶ 851
    text.push_str("\n## Diff\n\n");
852
    // Whole files, in order, while they fit. One that doesn't is cut where
853
    // the room ends if there is a fair amount left, else left out (a
854
    // smaller one after it may still go in); either way it is named.
855
    let head = text.chars().count();
856
    let mut room = budget.saturating_sub(head + 200);
857
    let mut left_out = Vec::new();
858
    for (path, diff) in parts {
859
        let n = diff.chars().count();
860
        if n <= room {
861
            text.push_str(&diff);
862
            room -= n;
863
        } else if room >= 1_500 {
864
            let keep = room - 80;
865
            text.extend(diff.chars().take(keep));
866
            text.push_str("\n(the rest of this file was cut to fit)\n```\n\n");
867
            left_out.push(format!("{path} (cut)"));
868
            room = 0;
869
        } else {
870
            left_out.push(path);
871
        }
872
    }
873
    if !left_out.is_empty() {
874
        let names: String = left_out.join(", ").chars().take(room.max(400)).collect();
875
        text.push_str(&format!("(left out to fit the context budget, {} file(s): {names})\n", left_out.len()));
7d2eb590 Andy McCall ↶ 876
    }
877
    text
878
}
879
880
/// The system prompt for a review: who the assistant is, what a review
881
/// here is, then the prompts the levels set.
882
pub fn review_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
883
    let mut s = format!(
884
        "You are {name}, an AI assistant reviewing a proposal on {site}, an OxygenHub site. \
885
         A person asked for your review; it is recorded as a review comment made via you, under their name. \
886
         You cannot approve: only people approve. Answer in Markdown: start with a one-paragraph summary, \
887
         then findings (correctness first, then clarity), each naming the file and quoting the lines it is about; \
888
         keep praise out unless something is notably well done; say so plainly when you find nothing wrong.",
889
        name = a.name
890
    );
891
    if !prompts.trim().is_empty() {
892
        s.push_str("\n\n");
893
        s.push_str(prompts.trim());
894
    }
895
    s
896
}
897
898
/// Ask `a` to review proposal `p` for `by`: records the request, calls the
899
/// endpoint in the background, and stores the answer as a `comment` review
900
/// via the assistant (or an event saying why none came). `Err` is for the
901
/// person who asked, when nothing was started.
902
pub async fn request_review(
903
    app: &Shared,
904
    row: &crate::db::repos::RepoRow,
905
    p: &crate::db::proposals::ProposalRow,
906
    by: &crate::db::accounts::Account,
907
    a: Assistant,
908
) -> Result<(), String> {
909
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
910
    let pool = &app.db;
911
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} to review", a.name), "").await.ok();
912
    crate::db::accounts::audit(
913
        pool,
914
        Some(by),
915
        "assistant.asked",
916
        &format!("{}/{}", row.owner, row.name),
917
        &format!("{} #{}", a.name, p.number),
918
    )
919
    .await
920
    .ok();
921
    let (app, row, p, by) = (app.clone(), row.clone(), p.clone(), by.clone());
922
    tokio::spawn(async move {
923
        let outcome = answer_review(&app, &row, &p, &a).await;
924
        app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(p.id, &a.name);
925
        match outcome {
926
            Ok(body) => {
927
                if crate::db::proposals::add_review_via(&app.db, p.id, by.id, &by.username, "comment", &body, &a.name).await.is_ok() {
928
                    crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("review.comment via {}", a.name), "").await.ok();
929
                    crate::web::proposals::reviewed(&app, &row, &p, &by, "comment").await;
930
                    crate::hooks::emit(
931
                        &app,
932
                        &row,
933
                        "review",
934
                        &by.username,
935
                        serde_json::json!({ "number": p.number, "verdict": "comment", "via": a.name }),
936
                    )
937
                    .await;
938
                }
939
            }
940
            Err(why) => {
941
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
942
            }
943
        }
944
    });
945
    Ok(())
946
}
947
948
async fn answer_review(
949
    app: &Shared,
950
    row: &crate::db::repos::RepoRow,
951
    p: &crate::db::proposals::ProposalRow,
952
    a: &Assistant,
953
) -> Result<String, String> {
954
    let repo = crate::repos::names::RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
955
    let loaded = crate::web::proposals::load(app, &repo, p.clone()).await.map_err(|e| format!("the proposal could not be loaded: {e}"))?;
956
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
957
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
958
    let system = review_system_prompt(a, &app.config.site.name, &prompts);
c08cec37 Andy McCall ↶ 959
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room)).await
960
}
961
962
/// Ask with the context cut to fit `budget` characters, system prompt
963
/// included (oxygenhub#248). A provider that says the prompt is too long
964
/// for its model is asked once more with a third of the room; whatever
965
/// fails is said plainly, with what to do about it.
966
async fn ask_within(
967
    app: &Shared,
968
    a: &Assistant,
969
    system: &str,
970
    budget: usize,
971
    wait: Duration,
972
    context: impl Fn(usize) -> String,
973
) -> Result<String, String> {
974
    let room = |b: usize| b.saturating_sub(system.chars().count());
975
    match ask(app, a, system, &context(room(budget)), wait).await {
976
        Err(e) if too_long(&e) => {
977
            let smaller = (budget / 3).max(1_000);
978
            tracing::info!("assistant {}: too long at {budget} characters ({e}); asking again at {smaller}", a.name);
979
            ask(app, a, system, &context(room(smaller)), wait).await.map_err(|e2| {
980
                format!(
981
                    "the proposal was too large for {}'s model at {budget} characters and again at {smaller} ({e2}): lower this assistant's context budget",
982
                    a.name
983
                )
984
            })
985
        }
986
        Err(e) if e.starts_with("no answer within") => Err(format!(
987
            "{e}: a large proposal on a slow or local model can take longer; lower this assistant's context budget, or use a faster model"
988
        )),
989
        other => other,
990
    }
991
}
992
993
/// Whether a provider's refusal says the prompt is too long for its model.
994
fn too_long(e: &str) -> bool {
995
    let l = e.to_lowercase();
996
    (l.starts_with("400") || l.starts_with("413") || l.starts_with("422"))
997
        && [
998
            "too long",
999
            "too large",
1000
            "context length",
1001
            "context window",
1002
            "maximum context",
1003
            "too many tokens",
1004
            "exceeds",
1005
            "reduce the length",
1006
            "input length",
1007
        ]
1008
        .iter()
1009
        .any(|k| l.contains(k))
ffb67a81 Andy McCall 1010
}
1011
1012
// -------------------------------------------------------- summaries
1013
1014
/// The system prompt for a summary posted as a comment (oxygenhub#190).
1015
pub fn summary_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
1016
    let mut s = format!(
1017
        "You are {name}, an AI assistant working on {site}, an OxygenHub site, with a next-generation version control system \
1018
         called Oxygen (its command is oxy). A proposal is Oxygen's equivalent of a merge request or pull request. \
1019
         Please summarise the proposal and comment on it. A person asked for this; it is posted as a comment made via you, \
1020
         under their name. Answer in Markdown: a short summary of what the proposal changes and why, then anything a reviewer \
1021
         should look at closely; keep to what the description and the diff show.",
1022
        name = a.name
1023
    );
1024
    if !prompts.trim().is_empty() {
1025
        s.push_str("\n\n");
1026
        s.push_str(prompts.trim());
1027
    }
1028
    s
1029
}
1030
1031
/// Ask `a` to summarise proposal `p` for `by`: like [`request_review`],
1032
/// but the answer is a conversation comment via the assistant.
1033
pub async fn request_summary(
1034
    app: &Shared,
1035
    row: &crate::db::repos::RepoRow,
1036
    p: &crate::db::proposals::ProposalRow,
1037
    by: &crate::db::accounts::Account,
1038
    a: Assistant,
1039
) -> Result<(), String> {
1040
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, p.id, &a.name)?;
1041
    let pool = &app.db;
1042
    crate::db::proposals::add_event(pool, p.id, &by.username, &format!("asked {} for a summary", a.name), "").await.ok();
1043
    crate::db::accounts::audit(
1044
        pool,
1045
        Some(by),
1046
        "assistant.asked",
1047
        &format!("{}/{}", row.owner, row.name),
1048
        &format!("{} #{} summary", a.name, p.number),
1049
    )
1050
    .await
1051
    .ok();
1052
    let (app, row, p, by) = (app.clone(), row.clone(), p.clone(), by.clone());
1053
    tokio::spawn(async move {
1054
        let outcome = answer_summary(&app, &row, &p, &a).await;
1055
        app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(p.id, &a.name);
1056
        match outcome {
1057
            Ok(body) => {
1058
                crate::db::proposals::add_comment_via(&app.db, p.id, by.id, &by.username, &body, &a.name).await.ok();
1059
            }
1060
            Err(why) => {
1061
                crate::db::proposals::add_event(&app.db, p.id, &by.username, &format!("{} did not answer", a.name), &why).await.ok();
1062
            }
1063
        }
1064
    });
1065
    Ok(())
1066
}
1067
1068
async fn answer_summary(
1069
    app: &Shared,
1070
    row: &crate::db::repos::RepoRow,
1071
    p: &crate::db::proposals::ProposalRow,
1072
    a: &Assistant,
1073
) -> Result<String, String> {
1074
    let repo = crate::repos::names::RepoName::new(&row.owner, &row.name).ok_or("bad repository name")?;
1075
    let loaded = crate::web::proposals::load(app, &repo, p.clone()).await.map_err(|e| format!("the proposal could not be loaded: {e}"))?;
1076
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
1077
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string())?;
1078
    let system = summary_system_prompt(a, &app.config.site.name, &prompts);
c08cec37 Andy McCall ↶ 1079
    ask_within(app, a, &system, budget, BACKGROUND_TIMEOUT, |room| review_context(row, p, &loaded, room)).await
7d2eb590 Andy McCall ↶ 1080
}
1081
1082
// -------------------------------------------------------- drafting
1083
1084
/// The system prompt for drafting a proposal's description.
1085
pub fn draft_system_prompt(a: &Assistant, site: &str, prompts: &str) -> String {
1086
    let mut s = format!(
1087
        "You are {name}, an AI assistant drafting the description of a proposal on {site}, an OxygenHub site, \
1088
         for the person who will open or edit it; they read and change your draft before saving, and the draft is theirs. \
1089
         Write Markdown for the description field only: no title line, no greeting, no sign-off. \
1090
         Say what the changes do and why, what a reviewer should look at, and how to verify them; \
1091
         keep to what the diff shows, and keep it short.",
1092
        name = a.name
1093
    );
1094
    if !prompts.trim().is_empty() {
1095
        s.push_str("\n\n");
1096
        s.push_str(prompts.trim());
1097
    }
1098
    s
1099
}
1100
1101
/// What a description is drafted from.
1102
pub struct Draft<'a> {
1103
    /// The proposal's id, or minus the repository's for one not yet opened:
1104
    /// one draft per assistant at a time for it.
1105
    pub slot: i64,
1106
    pub title: &'a str,
1107
    pub target: &'a str,
1108
    pub changes: &'a [crate::repos::view::ChangeView],
1109
    pub diffs: &'a [oxy_core::repo::FileDiff],
1110
}
1111
1112
/// Ask `a` for a description, now; the answer goes into the editor,
1113
/// nothing is saved. `Err` is for the person.
1114
pub async fn draft_description(
1115
    app: &Shared,
1116
    row: &crate::db::repos::RepoRow,
1117
    by: &crate::db::accounts::Account,
1118
    a: &Assistant,
1119
    d: Draft<'_>,
1120
) -> Result<String, String> {
1121
    let Draft { slot, title, target, changes, diffs } = d;
1122
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).take(row.id, slot, &a.name)?;
1123
    crate::db::accounts::audit(&app.db, Some(by), "assistant.drafted", &format!("{}/{}", row.owner, row.name), &a.name).await.ok();
1124
    let budget = usize::try_from(a.budget).unwrap_or(DEFAULT_BUDGET as usize).max(1_000);
1125
    let prompts = prompt_for_repo(&app.db, row).await.map_err(|e| e.to_string());
1126
    let outcome = match prompts {
1127
        Ok(prompts) => {
1128
            let system = draft_system_prompt(a, &app.config.site.name, &prompts);
c08cec37 Andy McCall ↶ 1129
            ask_within(app, a, &system, budget, CALL_TIMEOUT, |room| draft_context(row, title, target, changes, diffs, room)).await
7d2eb590 Andy McCall ↶ 1130
        }
1131
        Err(e) => Err(e),
1132
    };
1133
    app.assistant_gate.lock().unwrap_or_else(|e| e.into_inner()).release(slot, &a.name);
1134
    outcome.map(|t| t.trim().to_string())
1135
}