| 16 | 16 | | |
| 17 | 17 | | use std::collections::HashSet; |
| 18 | 18 | | |
| 19 | | − | use g1t_contracts::ai::{AiBriefArgs, MAX_BRIEF_INPUT_CHARS}; |
| 19 | + | use g1t_contracts::ai::{AiBriefArgs, MAX_BRIEF_INPUT_CHARS, MAX_BRIEF_TOKENS}; |
| 20 | 20 | | use g1t_contracts::events::WorkItemEvent; |
| 21 | 21 | | use g1t_contracts::time::rfc3339; |
| 22 | 22 | | use g1t_contracts::work::*; |
| ⋯ |
| 64 | 64 | | /// say a horizon it may suggest. `known` are the open work items it may |
| 65 | 65 | | /// say this one repeats. |
| 66 | 66 | | pub(crate) fn read_suggestion(answer: &str, known: &[u32], at: &str) -> Option<PlanSuggestion> { |
| 67 | + | // Some models answer with a JSON object instead of the four lines. |
| 68 | + | let json = answer.find('{').zip(answer.rfind('}')).and_then(|(open, close)| serde_json::from_str::<serde_json::Value>(&answer[open..=close]).ok()); |
| 69 | + | if let Some(serde_json::Value::Object(fields)) = json { |
| 70 | + | let text = |key: &str| match fields.get(key) { |
| 71 | + | Some(serde_json::Value::String(text)) => text.clone(), |
| 72 | + | Some(serde_json::Value::Number(number)) => number.to_string(), |
| 73 | + | _ => String::new(), |
| 74 | + | }; |
| 75 | + | let lines = format!( |
| 76 | + | "horizon: {}\nappetite: {}\nduplicate: {}\nwhy: {}", |
| 77 | + | text("horizon"), |
| 78 | + | text("appetite"), |
| 79 | + | if text("duplicate").is_empty() { text("duplicate_of") } else { text("duplicate") }, |
| 80 | + | text("why") |
| 81 | + | ); |
| 82 | + | return read_lines(&lines, known, at); |
| 83 | + | } |
| 84 | + | read_lines(answer, known, at) |
| 85 | + | } |
| 86 | + | |
| 87 | + | /// [`read_suggestion`] of the four `key: value` lines. |
| 88 | + | fn read_lines(answer: &str, known: &[u32], at: &str) -> Option<PlanSuggestion> { |
| 67 | 89 | | let mut horizon = None; |
| 68 | 90 | | let mut appetite = None; |
| 69 | 91 | | let mut duplicate_of = None; |
| ⋯ |
| 73 | 95 | | continue; |
| 74 | 96 | | }; |
| 75 | 97 | | let value = value.trim().trim_matches(|c: char| c == '*' || c == '`' || c == '"').trim(); |
| 76 | | − | match key.trim().trim_matches(|c: char| c == '*' || c == '-').trim().to_ascii_lowercase().as_str() { |
| 98 | + | match key.trim().trim_matches(|c: char| c == '*' || c == '-' || c == '#' || c == '"').trim().to_ascii_lowercase().as_str() { |
| 77 | 99 | | "horizon" => horizon = Horizon::parse(value).filter(|h| matches!(h, Horizon::Now | Horizon::Next | Horizon::Later)), |
| 78 | 100 | | "appetite" => appetite = Appetite::parse(value), |
| 79 | 101 | | "duplicate" => { |
| ⋯ |
| 170 | 192 | | match g1t_kit::call::<_, Outcome<Option<String>>>(integrations, "ai_brief", &args).await { |
| 171 | 193 | | Ok(Outcome::Ok(Some(answer))) => Some(answer), |
| 172 | 194 | | Ok(Outcome::Ok(None)) => { |
| 195 | + | worker::console_log!("{workspace}: the plan got no answer from the AI provider (none chosen, or an empty answer)"); |
| 173 | 196 | | budget.without.insert(workspace.to_owned()); |
| 174 | 197 | | None |
| 175 | 198 | | } |
| ⋯ |
| 281 | 304 | | listed.join("\n") |
| 282 | 305 | | ); |
| 283 | 306 | | let at = rfc3339(now_ms()); |
| 284 | | − | let answer = self.brief(&item.workspace, SUGGEST_SYSTEM, input, 120, budget).await; |
| 307 | + | let answer = self.brief(&item.workspace, SUGGEST_SYSTEM, input, MAX_BRIEF_TOKENS, budget).await; |
| 285 | 308 | | if answer.is_none() && !budget.without.contains(&item.workspace) { |
| 286 | 309 | | // Out of calls for this run: asked again on the next. |
| 287 | 310 | | break; |
| 288 | 311 | | } |
| 289 | 312 | | let suggestion = answer.as_deref().and_then(|answer| read_suggestion(answer, &known, &at)); |
| 313 | + | if let (Some(answer), None) = (&answer, &suggestion) { |
| 314 | + | worker::console_log!("{}#{}: the AI provider's suggestion did not read: {}", item.workspace, item.number, clip(answer, 300)); |
| 315 | + | } |
| 290 | 316 | | // Asked once: with no provider, or no usable answer, it is not |
| 291 | 317 | | // asked again. |
| 292 | 318 | | self.db |
| ⋯ |
| 365 | 391 | | if decisions.is_empty() { none() } else { decisions.join("\n") }, |
| 366 | 392 | | if pulls.is_empty() { none() } else { pulls.join("\n") }, |
| 367 | 393 | | ); |
| 368 | | − | let answer = self.brief(&item.workspace, STANDING_SYSTEM, input, 120, budget).await; |
| 394 | + | let answer = self.brief(&item.workspace, STANDING_SYSTEM, input, MAX_BRIEF_TOKENS, budget).await; |
| 369 | 395 | | if answer.is_none() && !budget.without.contains(&item.workspace) { |
| 370 | 396 | | break; |
| 371 | 397 | | } |
| ⋯ |
| 405 | 431 | | // A horizon it may not suggest, or none: no suggestion. |
| 406 | 432 | | assert_eq!(read_suggestion("horizon: set_aside\nwhy: old", &[], at), None); |
| 407 | 433 | | assert_eq!(read_suggestion("I think this should go next.", &[], at), None); |
| 434 | + | // A JSON object, in a code fence or not. |
| 435 | + | let json = "```json\n{\"horizon\": \"next\", \"appetite\": \"medium\", \"duplicate\": 12, \"why\": \"Same as #12.\"}\n```"; |
| 436 | + | let read = read_suggestion(json, &[12], at).unwrap(); |
| 437 | + | assert_eq!((read.horizon, read.appetite, read.duplicate_of, read.why.as_str()), (Horizon::Next, Some(Appetite::Medium), Some(12), "Same as #12.")); |
| 438 | + | let read = read_suggestion("{\"horizon\":\"later\",\"appetite\":\"small\",\"duplicate\":null,\"why\":\"Nice to have.\"}", &[], at).unwrap(); |
| 439 | + | assert_eq!((read.horizon, read.duplicate_of), (Horizon::Later, None)); |
| 440 | + | // Keys in quotes or headings. |
| 441 | + | let read = read_suggestion("## Horizon: next\n\"appetite\": small\nwhy: ok", &[], at).unwrap(); |
| 442 | + | assert_eq!((read.horizon, read.appetite), (Horizon::Next, Some(Appetite::Small))); |
| 408 | 443 | | } |
| 409 | 444 | | |
| 410 | 445 | | #[test] |