e71846aa Andy McCall 1 month ago ↶
1
2
3
4
5
6
7
8
9
10
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
21
pub const APIS : [ & str ; 2 ] = [ " chat-completions" , " anthropic-messages" ] ;
22
23
24
pub const DEFAULT_BUDGET : i64 = 120_000 ;
25
26
pub const CALL_TIMEOUT : Duration = Duration:: from_secs( 60 ) ;
27
28
const MAX_ANSWER : usize = 64 * 1024 ;
29
30
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
63
pub api : String,
64
pub model : String,
65
66
pub headers : String,
67
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
89
pub fn header_list ( & self ) -> Vec< ( String, String) > {
90
parse_headers ( & self .headers) .unwrap_or_default ( )
91
}
92
}
93
94
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
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
122
123
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 = [ 0 u8 ; 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
152
153
pub fn seal ( & self , plain : & str ) -> Vec< u8 > {
154
if plain.is_empty ( ) {
155
return vec! [ ] ;
156
}
157
let mut nonce = [ 0 u8 ; 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
167
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
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
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
219
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 1 month ago ↶
261
262
263
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 1 month ago ↶
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
331
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
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
371
372
373
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
390
391
# [ derive ( Clone, Debug ) ]
392
pub struct Available {
393
pub assistant : Assistant,
394
395
pub from : String,
396
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
407
408
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
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
433
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
446
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
458
459
460
461
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
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
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 1 month ago ↶
580
581
582
583
584
pub const CALLS_PER_HOUR : usize = 30 ;
585
586
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
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
617
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 \n Repository {} /{} · 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
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 \n Repository {} /{} · 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
654
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
709
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
727
728
729
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
792
793
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
811
pub struct Draft < 'a > {
812
813
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
822
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
}