1use crate::config::load_repo_config;
4use crate::types::*;
5use std::collections::{HashMap, HashSet};
6use std::env;
7use std::fs;
8use std::path::PathBuf;
9use std::sync::LazyLock;
10use std::time::Duration;
11
12pub const DEFAULT_MODEL: &str = "jev-latest";
13pub const DEFAULT_TIMEOUT_MS: u64 = 10000;
14pub const TYPESAFE_API_URL: &str = "https://api.typesafe.ai/v1/systemone";
15pub const COMMANDCODE_API_URL: &str = "https://api.commandcode.ai/provider/v1/systemone";
16pub const OPENCODE_API_URL: &str = "https://opencode.ai/zen/v1/systemone";
17pub const OPENROUTER_API_URL: &str = "https://openrouter.ai/api/alpha/decisions";
18pub const VERCEL_API_URL: &str = "https://ai-gateway.vercel.sh/v1/evaluate";
19pub const DEFAULT_USER_AGENT: &str = concat!(
20 "Mozilla/5.0 (compatible; JevHarness/",
21 env!("CARGO_PKG_VERSION"),
22 "; +https://github.com/ismaelsoilet/jev-harness)"
23);
24
25static SECRET_PATTERNS: LazyLock<Vec<(&'static str, regex::Regex)>> = LazyLock::new(|| {
29 [
30 (
31 "group",
32 r#"(?i)\b(?:api[_-]?key|apikey|access[_-]?token|auth[_-]?token|secret|password|passwd|pwd|client[_-]?secret|private[_-]?key|bearer)\b\s*[:=]\s*["']?([A-Za-z0-9._\-/+]{6,})["']?"#,
33 ),
34 ("plain", r"(?i)\b(?:sk|pk|rk|vck|xox[baprs])[-_][A-Za-z0-9._\-]{12,}"),
35 ("plain", r"(?i)\bgh[pousr]_[A-Za-z0-9]{20,}"),
36 ("plain", r"\bAKIA[0-9A-Z]{16}\b"),
37 ("key", r"(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----"),
38 ("plain", r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}"),
39 ("url", r"(?i)((?:postgres|mysql|mongodb|redis)(?:\+\w+)?)://[^\s:@/]+:[^\s@/]+@"),
40 ]
41 .iter()
42 .map(|(kind, pattern)| (*kind, regex::Regex::new(pattern).expect("secret pattern")))
43 .collect()
44});
45
46pub fn redact_secrets(text: &str) -> String {
47 if text.is_empty() {
48 return String::new();
49 }
50 let mut redacted = text.to_string();
51 for (kind, pattern) in SECRET_PATTERNS.iter() {
52 redacted = match *kind {
53 "group" => pattern
54 .replace_all(&redacted, |caps: ®ex::Captures<'_>| match caps.get(1) {
55 Some(value) => caps[0].replace(value.as_str(), "[REDACTED]"),
56 None => "[REDACTED]".to_string(),
57 })
58 .to_string(),
59 "key" => pattern
60 .replace_all(&redacted, "[REDACTED PRIVATE KEY]")
61 .to_string(),
62 "url" => pattern
63 .replace_all(&redacted, "${1}://[REDACTED]@")
64 .to_string(),
65 _ => pattern.replace_all(&redacted, "[REDACTED]").to_string(),
66 };
67 }
68 redacted
69}
70
71pub fn render_state_text(state: &str) -> String {
78 let trimmed = state.trim_start();
79 if !trimmed.starts_with('{') {
80 return state.to_string();
81 }
82 let parsed: serde_json::Value = match serde_json::from_str(trimmed) {
83 Ok(value) => value,
84 Err(_) => return state.to_string(),
85 };
86 let object = match parsed.as_object() {
87 Some(map) => map,
88 None => return state.to_string(),
89 };
90 let mut parts: Vec<String> = Vec::new();
91 for (key, value) in object {
92 if matches!(
95 key.as_str(),
96 "focused_slice" | "causal_context" | "raw_log_ref"
97 ) {
98 continue;
99 }
100 let rendered = match value {
101 serde_json::Value::String(text) => text.clone(),
102 other => other.to_string(),
103 };
104 parts.push(format!("{key}:\n{rendered}"));
105 }
106 parts.join("\n\n")
107}
108
109pub fn build_state(fields: serde_json::Map<String, serde_json::Value>) -> String {
111 let mut state = serde_json::Map::new();
112 for (key, value) in fields {
113 let empty = match &value {
114 serde_json::Value::Null => true,
115 serde_json::Value::String(text) => text.is_empty(),
116 serde_json::Value::Array(items) => items.is_empty(),
117 serde_json::Value::Object(map) => map.is_empty(),
118 _ => false,
119 };
120 if !empty {
121 state.insert(key, value);
122 }
123 }
124 serde_json::Value::Object(state).to_string()
125}
126
127pub const MOCK_CHOICE_BEST_PEAKED: f64 = 0.85;
132pub const MOCK_CHOICE_BEST_CONFLICT: f64 = 0.55;
133pub const MOCK_SCORE_BEST_PEAKED: f64 = 0.80;
134
135pub fn mock_abort_action_choice(criteria: &HashMap<String, String>, state_lower: &str) -> String {
137 let mut step = state_lower.to_string();
138 for marker in ["proposed next step:", "proposed_next_step:"] {
139 if let Some((_, tail)) = step.split_once(marker) {
140 step = tail.to_string();
141 }
142 }
143 let forward = [
144 "implement",
145 "fix",
146 "resolve",
147 "correct",
148 "update",
149 "create",
150 "write",
151 "add",
152 "install",
153 "apply",
154 "corrigir",
155 "implementar",
156 "executar",
157 "validar",
158 "corregir",
159 ]
160 .iter()
161 .any(|w| step.contains(w));
162 let repetitive = [
163 "same",
164 "repetir",
165 "tentar novamente",
166 "intentar de nuevo",
167 "4a vez",
168 "again",
169 "identical",
170 ]
171 .iter()
172 .any(|w| step.contains(w));
173 let fatal = [
174 "impossible",
175 "impossivel",
176 "imposible",
177 "circular",
178 "deadlock",
179 "dead end",
180 "inviavel",
181 "inviable",
182 "hopeless",
183 "fatal",
184 ]
185 .iter()
186 .any(|w| state_lower.contains(w));
187 if repetitive || fatal {
188 return if criteria.contains_key("abort_and_ask") {
189 "abort_and_ask".to_string()
190 } else {
191 criteria.keys().next().cloned().unwrap_or_default()
192 };
193 }
194 if forward && criteria.contains_key("proceed") {
195 return "proceed".to_string();
196 }
197 String::new()
198}
199
200pub fn mock_distribution(options: &[String], best: &str, peak: f64) -> HashMap<String, f64> {
202 let mut out = HashMap::new();
203 if options.len() <= 1 {
204 for option in options {
205 out.insert(option.clone(), 1.0);
206 }
207 return out;
208 }
209 let rest = (1.0 - peak) / (options.len() - 1) as f64;
210 for option in options {
211 out.insert(option.clone(), if option == best { peak } else { rest });
212 }
213 out
214}
215
216static ASSERTION_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
217 regex::Regex::new(r"(?i)(?:assertionerror|assertionfailed|assertionfailederror|assert\b|assert_eq!|assertthat|expect\(.*?\)\.to|expected:.*received:|failures?:|fail(?:ed)?\s+test|falha de asserção|fallo de aserción|opentest4j)").expect("Invalid assertion regex")
218});
219
220static SUCCESS_PATTERNS: LazyLock<Vec<regex::Regex>> = LazyLock::new(|| {
223 [
224 r"test result:\s*ok",
225 r"[1-9][\d,]*\s+passed\b",
226 r"[1-9]\d*\s+passing\b",
227 r"test suites?:\s*[1-9]\d*\s+passed",
228 r"[1-9]\d*\s+examples?,\s*0\s+failures",
229 r"all tests? passed",
230 r"\bbuild success(?:ful)?\b",
231 r"(?m)^\s*ok\s+\S+",
232 ]
233 .iter()
234 .map(|p| regex::Regex::new(p).expect("Invalid success pattern"))
235 .collect()
236});
237
238static FAILURE_COUNT_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
239 regex::Regex::new(r"[1-9\x{FF11}-\x{FF19}\x{0661}-\x{0669}\x{06F1}-\x{06F9}][\d,._\x{00A0} \x{FF10}-\x{FF19}\x{0660}-\x{0669}\x{06F0}-\x{06F9}]*\s*(?:failures|failure|failed|failing|errors?)\b")
240 .expect("count")
241});
242static FAILURE_NOUN_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
243 regex::Regex::new(
244 r"\b[1-9\x{FF11}-\x{FF19}\x{0661}-\x{0669}\x{06F1}-\x{06F9}][\d,._\x{00A0} \x{FF10}-\x{FF19}\x{0660}-\x{0669}\x{06F0}-\x{06F9}]*\s+tests?\s+failed\b",
245 )
246 .expect("noun")
247});
248
249static FAILURE_ASSIGN_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
250 regex::Regex::new(r"(?:failures?|errors?|failed|failing)\s*[:=]\s*[1-9]").expect("assign")
251});
252static FAILURE_MARKER_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
253 regex::Regex::new(
255 r"\b(?:traceback|panic|panicked|assertionerror|assertion failed|not ok)\b|error\s*:",
256 )
257 .expect("marker")
258});
259static UPPER_FAILED_REGEX: LazyLock<regex::Regex> =
260 LazyLock::new(|| regex::Regex::new(r"\bFAILED\b").expect("FAILED"));
261static UPPER_FAIL_LINE_REGEX: LazyLock<regex::Regex> =
262 LazyLock::new(|| regex::Regex::new(r"(?m)^\s*FAIL\b|(?m)---\s*FAIL\b").expect("FAIL"));
263static RAN_TESTS_REGEX: LazyLock<regex::Regex> =
264 LazyLock::new(|| regex::Regex::new(r"ran\s+[1-9]\d*\s+tests?").expect("ran tests"));
265static BARE_OK_LINE_REGEX: LazyLock<regex::Regex> =
266 LazyLock::new(|| regex::Regex::new(r"(?m)^\s*ok\s*$").expect("ok line"));
267
268static INJECTION_PATTERNS: LazyLock<Vec<regex::Regex>> = LazyLock::new(|| {
272 [
273 r"(?i)ignore\s+(?:all\s+|any\s+|the\s+)?(?:previous|prior|above|earlier|foregoing)\s+(?:instruction|prompt|rule|direction|message)s?",
274 r"(?i)disregard\s+(?:all\s+|any\s+|the\s+)?(?:above|previous|prior|earlier|system)",
275 r"(?i)\b(?:ignore|bypass|override)\s+(?:the\s+)?(?:gate|harness|instructions?|safety|polic(?:y|ies))\b",
276 r"(?i)<\|(?:im_start|im_end|system|assistant|user)\|>",
277 r"\[/?(?:INST|SYS)\]",
278 r"(?im)###\s*(?:system|instruction|assistant)\b",
279 r#"(?i)"role"\s*:\s*"(?:system|assistant)"\s*,\s*"content""#,
280 r"(?i)\bskip_llm\s*[:=]\s*(?:true|false)\b",
281 r"(?i)\b(?:classif|labell?|mark|report|record|return|output|respond|answer)\w*\b[^.\n]{0,60}\b(?:as\s+)?(?:env_missing|flaky_transient|syntax_trivial|no_failure)\b",
282 r"(?i)\b(?:do\s+not|don't|never)\s+(?:call|invoke|use|escalate\s+to)\s+(?:the\s+)?(?:llm|model|api|system\s*2|frontier)\b",
283 ]
284 .iter()
285 .map(|p| regex::Regex::new(p).expect("injection pattern"))
286 .collect()
287});
288
289pub fn looks_like_prompt_injection(log: &str) -> bool {
291 if log.is_empty() {
292 return false;
293 }
294 INJECTION_PATTERNS
295 .iter()
296 .any(|pattern| pattern.is_match(log))
297}
298
299pub fn looks_like_test_success(log: &str) -> bool {
305 if log.trim().is_empty() {
306 return false;
307 }
308 let text = log.to_lowercase();
309
310 if FAILURE_COUNT_REGEX.is_match(&text)
311 || FAILURE_NOUN_REGEX.is_match(&text)
312 || FAILURE_ASSIGN_REGEX.is_match(&text)
313 || FAILURE_MARKER_REGEX.is_match(&text)
314 || UPPER_FAILED_REGEX.is_match(log)
315 || UPPER_FAIL_LINE_REGEX.is_match(log)
316 {
317 return false;
318 }
319 if ["✗", "❌", "✘", "✕", "×", "‼"]
320 .iter()
321 .any(|m| log.contains(m))
322 {
323 return false;
324 }
325 let unclean = [
326 "module not found",
327 "no module named",
328 "cannot find module",
329 "cannot find crate",
330 "command not found",
331 "connection refused",
332 "connection reset",
333 "econnrefused",
334 "econnreset",
335 "etimedout",
336 "socket hang up",
337 "address already in use",
338 "timed out",
339 "timeout",
340 ];
341 if unclean.iter().any(|s| text.contains(s)) {
342 return false;
343 }
344
345 if SUCCESS_PATTERNS.iter().any(|p| p.is_match(&text)) {
346 return true;
347 }
348 RAN_TESTS_REGEX.is_match(&text) && BARE_OK_LINE_REGEX.is_match(&text)
349}
350
351static FAIL_LINE_REGEX: LazyLock<regex::Regex> =
352 LazyLock::new(|| regex::Regex::new(r"(?i)^fail(?:ed)?\b").expect("Invalid fail line regex"));
353
354static FAIL_TO_REGEX: LazyLock<regex::Regex> =
355 LazyLock::new(|| regex::Regex::new(r"(?i)^fail(?:ed)?\s+to\b").expect("Invalid fail-to regex"));
356
357static EXPECTED_RECEIVED_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
358 regex::Regex::new(r"(?is)expected:.{0,300}?received:").expect("Invalid expected/received regex")
359});
360
361static BARE_EXCEPTION_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
362 regex::Regex::new(r"(?i)^(?:valueerror|runtimeerror|typeerror|keyerror|indexerror|zerodivisionerror|attributeerror|overflowerror|arithmeticerror|illegalargumentexception|illegalstateexception):").expect("Invalid bare exception regex")
363});
364
365static FAILURE_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
366 regex::Regex::new(r"(?i)(?:assertionerror|assertionfailed|assertionfailederror|failures?:\s*[1-9]|failed\b|falhou\b|\d+\s+failed\b|not\s+ok\b|segmentation\s+fault|sigsegv|panic\b|core\s+dumped)").expect("Invalid failure regex")
367});
368
369static NEGATION_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
370 regex::Regex::new(r"(?i)\b(not|do\s+not|don't|não|nao|no|never|sem|evitar|avoid)\s+(\w+\s+){0,3}(abort|abortar|stop|parar|detener|falhar|fail|deadlock|circular|dead\s*end)").expect("Invalid negation regex")
371});
372
373pub const MAX_STATE_CHARS: usize = 128_000;
376pub const MAX_TOTAL_CHARS: usize = 256_000;
377
378#[derive(Debug, Clone)]
379pub struct JevClient {
380 pub api_key: Option<String>,
381 pub base_url: String,
382 pub model: String,
383 pub model_source: String,
385 pub provider: String,
386 pub timeout_ms: u64,
387 pub force_mock: bool,
388 pub fail_open: bool,
390 pub max_retries: u32,
392 pub retry_base_delay_ms: u64,
394 http_client: reqwest::Client,
395}
396
397impl Default for JevClient {
398 fn default() -> Self {
399 Self::new(None, None, None, None, false)
400 }
401}
402
403impl JevClient {
404 pub fn new(
405 api_key: Option<String>,
406 base_url: Option<String>,
407 model: Option<String>,
408 timeout_ms: Option<u64>,
409 force_mock: bool,
410 ) -> Self {
411 let (resolved_key, resolved_provider, resolved_url) = Self::resolve_credentials(api_key);
412 let final_url = base_url.unwrap_or(resolved_url);
413 let (final_model, model_source) = Self::resolve_model(model, &resolved_provider);
414 let final_timeout = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
415
416 let http_client = reqwest::Client::builder()
417 .timeout(Duration::from_millis(final_timeout))
418 .user_agent(DEFAULT_USER_AGENT)
419 .build()
420 .unwrap_or_else(|_| reqwest::Client::new());
421
422 Self {
423 api_key: resolved_key,
424 base_url: final_url,
425 model: final_model,
426 model_source: model_source.to_string(),
427 provider: resolved_provider,
428 timeout_ms: final_timeout,
429 force_mock,
430 fail_open: true,
431 max_retries: 3,
432 retry_base_delay_ms: 500,
433 http_client,
434 }
435 }
436
437 pub fn resolve_model(model: Option<String>, provider: &str) -> (String, &'static str) {
442 if let Some(explicit) = model {
443 return (explicit, "argument");
444 }
445 if let Ok(from_env) = std::env::var("JEV_MODEL") {
446 let trimmed = from_env.trim();
447 if !trimmed.is_empty() {
448 return (trimmed.to_string(), "env");
449 }
450 }
451 if let Some(repo_model) = load_repo_config()
452 .model
453 .as_ref()
454 .filter(|m| m.as_str() != DEFAULT_MODEL)
455 {
456 return (repo_model.clone(), ".jev.json");
457 }
458 let provider_default = match provider {
459 "commandcode" => "typesafe/jev",
460 "opencode" => "jev-1.13-free",
461 "openrouter" => "typesafe/jev-1.13",
462 "vercel" => "typesafe-ai/jev",
463 _ => DEFAULT_MODEL,
464 };
465 (provider_default.to_string(), "provider_default")
466 }
467
468 pub fn with_mock() -> Self {
469 Self {
470 api_key: None,
471 base_url: TYPESAFE_API_URL.to_string(),
472 model: DEFAULT_MODEL.to_string(),
473 model_source: "provider_default".to_string(),
474 provider: "mock".to_string(),
475 timeout_ms: DEFAULT_TIMEOUT_MS,
476 force_mock: true,
477 fail_open: true,
478 max_retries: 3,
479 retry_base_delay_ms: 500,
480 http_client: reqwest::Client::new(),
481 }
482 }
483
484 pub fn with_provider(provider: &str, api_key: Option<String>) -> Self {
485 let prov = provider.trim().to_lowercase();
486 let base_url = match prov.as_str() {
487 "commandcode" => COMMANDCODE_API_URL.to_string(),
488 "opencode" => OPENCODE_API_URL.to_string(),
489 "openrouter" => OPENROUTER_API_URL.to_string(),
490 "vercel" => VERCEL_API_URL.to_string(),
491 _ => TYPESAFE_API_URL.to_string(),
492 };
493 let repo_config = load_repo_config();
494 let model = if let Some(repo_model) = repo_config
495 .model
496 .as_ref()
497 .filter(|m| m.as_str() != DEFAULT_MODEL)
498 {
499 repo_model.clone()
500 } else {
501 match prov.as_str() {
502 "commandcode" => "typesafe/jev".to_string(),
503 "opencode" => "jev-1.13-free".to_string(),
504 "openrouter" => "typesafe/jev-1.13".to_string(),
505 "vercel" => "typesafe-ai/jev".to_string(),
506 _ => DEFAULT_MODEL.to_string(),
507 }
508 };
509 let model_source = Self::resolve_model(None, &prov).1;
510 Self {
511 api_key,
512 base_url,
513 model,
514 model_source: model_source.to_string(),
515 provider: prov,
516 timeout_ms: DEFAULT_TIMEOUT_MS,
517 force_mock: false,
518 fail_open: true,
519 max_retries: 3,
520 retry_base_delay_ms: 500,
521 http_client: reqwest::Client::new(),
522 }
523 }
524
525 pub fn redact_secrets(text: &str, secret: Option<&str>) -> String {
526 if text.is_empty() {
527 return String::new();
528 }
529 let mut cleaned = text.to_string();
530 if let Some(sec) = secret {
531 if sec.len() >= 4 {
532 cleaned = cleaned.replace(sec, "[REDACTED]");
533 }
534 }
535 for prefix in ["Bearer ", "bearer ", "sk-", "vck_"] {
536 while let Some(idx) = cleaned.find(prefix) {
537 let end = cleaned[idx + prefix.len()..]
538 .find(|c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '_' && c != '-')
539 .map(|offset| idx + prefix.len() + offset)
540 .unwrap_or(cleaned.len());
541 if end > idx + prefix.len() {
542 cleaned.replace_range(idx..end, "[REDACTED]");
543 } else {
544 break;
545 }
546 }
547 }
548 cleaned.chars().take(400).collect()
549 }
550
551 fn resolve_credentials(explicit_key: Option<String>) -> (Option<String>, String, String) {
552 if let Some(key) = explicit_key {
553 if !key.trim().is_empty() {
554 return (
555 Some(key),
556 "typesafe".to_string(),
557 TYPESAFE_API_URL.to_string(),
558 );
559 }
560 }
561
562 if let Ok(p) = env::var("JEV_PROVIDER") {
564 let pt = p.trim();
565 if pt == "commandcode" {
566 let key = env::var("CMD_API_KEY")
567 .or_else(|_| env::var("COMMAND_CODE_API_KEY"))
568 .ok();
569 if let Some(k) = key {
570 if !k.trim().is_empty() {
571 return (
572 Some(k),
573 "commandcode".to_string(),
574 COMMANDCODE_API_URL.to_string(),
575 );
576 }
577 }
578 } else if pt == "opencode" {
579 let key = env::var("OPENCODE_API_KEY").ok();
580 return (key, "opencode".to_string(), OPENCODE_API_URL.to_string());
581 }
582 }
583
584 if let Ok(key) = env::var("TYPESAFE_API_KEY") {
585 if !key.trim().is_empty() {
586 return (
587 Some(key),
588 "typesafe".to_string(),
589 TYPESAFE_API_URL.to_string(),
590 );
591 }
592 }
593
594 for env_name in ["CMD_API_KEY", "COMMAND_CODE_API_KEY"] {
595 if let Ok(key) = env::var(env_name) {
596 if !key.trim().is_empty() {
597 return (
598 Some(key),
599 "commandcode".to_string(),
600 COMMANDCODE_API_URL.to_string(),
601 );
602 }
603 }
604 }
605
606 if let Ok(key) = env::var("VERCEL_AI_GATEWAY_API_KEY") {
607 if !key.trim().is_empty() {
608 return (Some(key), "vercel".to_string(), VERCEL_API_URL.to_string());
609 }
610 }
611
612 if let Ok(key) = env::var("VERCEL_API_KEY") {
613 if !key.trim().is_empty() {
614 return (Some(key), "vercel".to_string(), VERCEL_API_URL.to_string());
615 }
616 }
617
618 if let Ok(key) = env::var("AI_GATEWAY_API_KEY") {
619 if !key.trim().is_empty() {
620 return (Some(key), "vercel".to_string(), VERCEL_API_URL.to_string());
621 }
622 }
623
624 if let Ok(key) = env::var("OPENCODE_API_KEY") {
625 if !key.trim().is_empty() {
626 return (
627 Some(key),
628 "opencode".to_string(),
629 OPENCODE_API_URL.to_string(),
630 );
631 }
632 }
633
634 if let Ok(key) = env::var("OPENROUTER_API_KEY") {
635 if !key.trim().is_empty() {
636 return (
637 Some(key),
638 "openrouter".to_string(),
639 OPENROUTER_API_URL.to_string(),
640 );
641 }
642 }
643
644 if let Ok(content) = fs::read_to_string(".jev.json") {
646 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
647 let prov = val
648 .get("provider")
649 .and_then(|v| v.as_str())
650 .unwrap_or("typesafe");
651 if prov == "opencode" {
652 let k = val
653 .get("api_key")
654 .and_then(|v| v.as_str())
655 .map(|s| s.to_string());
656 return (k, "opencode".to_string(), OPENCODE_API_URL.to_string());
657 }
658 if let Some(k) = val.get("api_key").and_then(|v| v.as_str()) {
659 if !k.trim().is_empty() {
660 let url = match prov {
661 "commandcode" => COMMANDCODE_API_URL,
662 "openrouter" => OPENROUTER_API_URL,
663 "vercel" => VERCEL_API_URL,
664 _ => TYPESAFE_API_URL,
665 };
666 return (Some(k.to_string()), prov.to_string(), url.to_string());
667 }
668 }
669 }
670 }
671
672 if let Ok(home) = env::var("HOME").or_else(|_| env::var("USERPROFILE")) {
674 let p = PathBuf::from(&home).join(".config/jev/credentials.env");
675 if let Ok(content) = fs::read_to_string(p) {
676 for line in content.lines() {
677 let trimmed = line.trim();
678 if trimmed.starts_with("JEV_PROVIDER=") && trimmed.contains("opencode") {
679 return (None, "opencode".to_string(), OPENCODE_API_URL.to_string());
680 }
681 if trimmed.starts_with("CMD_API_KEY=")
682 || trimmed.starts_with("COMMAND_CODE_API_KEY=")
683 {
684 let k = trimmed
685 .split('=')
686 .nth(1)
687 .unwrap_or("")
688 .trim_matches('"')
689 .trim();
690 if !k.is_empty() {
691 return (
692 Some(k.to_string()),
693 "commandcode".to_string(),
694 COMMANDCODE_API_URL.to_string(),
695 );
696 }
697 }
698 if trimmed.starts_with("OPENCODE_API_KEY=") {
699 let k = trimmed
700 .trim_start_matches("OPENCODE_API_KEY=")
701 .trim_matches('"');
702 if !k.is_empty() {
703 return (
704 Some(k.to_string()),
705 "opencode".to_string(),
706 OPENCODE_API_URL.to_string(),
707 );
708 }
709 }
710 if trimmed.starts_with("TYPESAFE_API_KEY=") {
711 let k = trimmed
712 .trim_start_matches("TYPESAFE_API_KEY=")
713 .trim_matches('"');
714 if !k.is_empty() {
715 return (
716 Some(k.to_string()),
717 "typesafe".to_string(),
718 TYPESAFE_API_URL.to_string(),
719 );
720 }
721 }
722 if trimmed.starts_with("VERCEL_AI_GATEWAY_API_KEY=")
723 || trimmed.starts_with("VERCEL_API_KEY=")
724 || trimmed.starts_with("AI_GATEWAY_API_KEY=")
725 {
726 let k = trimmed
727 .split('=')
728 .nth(1)
729 .unwrap_or("")
730 .trim_matches('"')
731 .trim();
732 if !k.is_empty() {
733 return (
734 Some(k.to_string()),
735 "vercel".to_string(),
736 VERCEL_API_URL.to_string(),
737 );
738 }
739 }
740 }
741 }
742
743 let cmd_auth = PathBuf::from(&home).join(".commandcode/auth.json");
744 if let Ok(content) = fs::read_to_string(cmd_auth) {
745 if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
746 let key_opt = val
747 .get("apiKey")
748 .or_else(|| val.get("api_key"))
749 .and_then(|v| v.as_str());
750 if let Some(k) = key_opt {
751 let kt = k.trim();
752 if !kt.is_empty() {
753 return (
754 Some(kt.to_string()),
755 "commandcode".to_string(),
756 COMMANDCODE_API_URL.to_string(),
757 );
758 }
759 }
760 }
761 }
762 }
763
764 (None, "mock".to_string(), TYPESAFE_API_URL.to_string())
765 }
766
767 pub fn with_failure_policy(
770 mut self,
771 fail_open: bool,
772 max_retries: u32,
773 retry_base_delay_ms: u64,
774 ) -> Self {
775 self.fail_open = fail_open;
776 self.max_retries = max_retries.max(1);
777 self.retry_base_delay_ms = retry_base_delay_ms;
778 self
779 }
780
781 pub fn retry_delay_ms(&self, attempt: u32, retry_after: Option<f64>) -> u64 {
784 if let Some(seconds) = retry_after {
785 if seconds.is_finite() && seconds >= 0.0 {
786 return ((seconds * 1000.0) as u64).min(30_000);
787 }
788 }
789 let factor = 1u64 << attempt.saturating_sub(1).min(6);
790 self.retry_base_delay_ms.saturating_mul(factor).min(5_000)
791 }
792
793 pub async fn system_one(
794 &self,
795 state: &str,
796 questions: HashMap<String, Question>,
797 ) -> Result<JevResponse, JevError> {
798 for question in questions.values() {
800 match question {
801 Question::Choice(choice) => {
802 crate::uncertainty::validate_question_options(choice.criteria.len())
803 .map_err(JevError::Config)?
804 }
805 Question::Score(score) => {
806 crate::uncertainty::validate_question_options(score.criteria.len())
807 .map_err(JevError::Config)?
808 }
809 Question::Noul(_) => {}
810 }
811 }
812 let redacted_state = redact_secrets(state);
813 let state = redacted_state.as_str();
814 let is_live = !self.force_mock && (self.provider == "opencode" || self.api_key.is_some());
815 if !is_live {
816 return Ok(self.simulate_system_one(state, &questions, &self.model));
817 }
818
819 let state_chars = state.chars().count();
821 let questions_chars = serde_json::to_string(&questions)
822 .map(|s| s.chars().count())
823 .unwrap_or(0);
824 if state_chars > MAX_STATE_CHARS || state_chars + questions_chars > MAX_TOTAL_CHARS {
825 return Err(JevError::Config(format!(
826 "Payload exceeds the provider limit: {} state chars + {} question chars (limit: {} state / {} total, ~32k/64k tokens). Trim the state or split the questions.",
827 state_chars,
828 questions_chars,
829 MAX_STATE_CHARS,
830 MAX_TOTAL_CHARS
831 )));
832 }
833
834 let mut payload = serde_json::json!({
835 "model": self.model,
836 "state": state,
837 "questions": questions
838 });
839 if self.provider == "openrouter" {
840 payload["provider"] =
841 serde_json::json!({ "only": ["typesafe"], "allow_fallbacks": false });
842 } else if self.provider == "vercel" {
843 payload["providerOptions"] =
844 serde_json::json!({ "gateway": { "only": ["typesafe-ai"] } });
845 }
846
847 let mut attempt: u32 = 0;
848 loop {
849 attempt += 1;
850 let attempt_started = std::time::Instant::now();
851 let mut req = self
852 .http_client
853 .post(&self.base_url)
854 .header("Content-Type", "application/json");
855
856 if let Some(ref key) = self.api_key {
857 if key != "zen" {
858 req = req.header("Authorization", format!("Bearer {}", key));
859 }
860 }
861 if self.provider == "openrouter" {
862 req = req
863 .header(
864 "HTTP-Referer",
865 "https://github.com/ismaelsoilet/jev-harness",
866 )
867 .header("X-Title", "Jev Harness");
868 }
869
870 let resp_result = req.json(&payload).send().await;
871
872 match resp_result {
873 Ok(resp) => {
874 let status = resp.status().as_u16();
875 if !resp.status().is_success() {
876 let retry_after = resp
877 .headers()
878 .get("retry-after")
879 .and_then(|v| v.to_str().ok())
880 .and_then(|s| s.trim().parse::<f64>().ok());
881 let raw_text = resp.text().await.unwrap_or_default();
882 let text = Self::redact_secrets(&raw_text, self.api_key.as_deref());
883 if status == 401 || status == 403 {
884 if !self.fail_open {
885 return Err(JevError::Api {
886 status,
887 message: text,
888 });
889 }
890 eprintln!("[JEV WARNING] {} auth failed (HTTP {}); falling back to offline simulation.", self.provider, status);
891 let mut sim = self.simulate_system_one(state, &questions, &self.model);
892 sim.degraded_reason = format!("auth_{}", status);
893 return Ok(sim);
894 }
895 let retryable = status == 429 || status >= 500;
896 if retryable && attempt < self.max_retries {
897 let delay = self.retry_delay_ms(attempt, retry_after);
898 if delay > 0 {
899 tokio::time::sleep(Duration::from_millis(delay)).await;
900 }
901 continue;
902 }
903 if self.fail_open {
904 eprintln!(
905 "[JEV WARNING] {} API HTTP {}; falling back to offline simulation.",
906 self.provider, status
907 );
908 let mut sim = self.simulate_system_one(state, &questions, &self.model);
909 sim.degraded_reason = format!("http_{}", status);
910 return Ok(sim);
911 }
912 return Err(JevError::Api {
913 status,
914 message: text,
915 });
916 }
917
918 match resp.json::<serde_json::Value>().await {
919 Ok(parsed) => match self.parse_api_response(&parsed) {
920 Ok(response) => return Ok(response),
921 Err(e) => {
922 if attempt < self.max_retries {
923 let delay = self.retry_delay_ms(attempt, None);
924 if delay > 0 {
925 tokio::time::sleep(Duration::from_millis(delay)).await;
926 }
927 continue;
928 }
929 if self.fail_open {
930 eprintln!(
931 "[JEV WARNING] {} returned a malformed response: {}; falling back to offline simulation.",
932 self.provider, e
933 );
934 let mut sim =
935 self.simulate_system_one(state, &questions, &self.model);
936 sim.degraded_reason = "invalid_response".to_string();
937 return Ok(sim);
938 }
939 return Err(e);
940 }
941 },
942 Err(_) => {
943 if attempt < self.max_retries {
944 let delay = self.retry_delay_ms(attempt, None);
945 if delay > 0 {
946 tokio::time::sleep(Duration::from_millis(delay)).await;
947 }
948 continue;
949 }
950 if self.fail_open {
951 eprintln!("[JEV WARNING] {} returned a non-JSON response; falling back to offline simulation.", self.provider);
952 let mut sim =
953 self.simulate_system_one(state, &questions, &self.model);
954 sim.degraded_reason = "invalid_response".to_string();
955 return Ok(sim);
956 }
957 return Err(JevError::Config(
958 "Provider returned a non-JSON response".to_string(),
959 ));
960 }
961 }
962 }
963 Err(e) => {
964 let retryable = e.is_timeout() || e.is_connect() || e.is_request();
965 if retryable && attempt < self.max_retries {
966 let delay = self.retry_delay_ms(attempt, None);
967 if delay > 0 {
968 tokio::time::sleep(Duration::from_millis(delay)).await;
969 }
970 continue;
971 }
972 if self.fail_open {
973 let elapsed_ratio = attempt_started.elapsed().as_millis() as u64;
976 let timed_out = e.is_timeout()
977 || (!e.is_connect()
978 && elapsed_ratio >= self.timeout_ms.saturating_mul(9) / 10);
979 eprintln!("[JEV WARNING] {} transport error ({}); falling back to offline simulation.", self.provider, e);
980 let mut sim = self.simulate_system_one(state, &questions, &self.model);
981 sim.degraded_reason = if timed_out {
982 "timeout".to_string()
983 } else {
984 "connection".to_string()
985 };
986 return Ok(sim);
987 }
988 return Err(JevError::Http(e));
989 }
990 }
991 }
992 }
993
994 pub fn parse_api_response(&self, val: &serde_json::Value) -> Result<JevResponse, JevError> {
997 let model = val
998 .get("model")
999 .and_then(|v| v.as_str())
1000 .unwrap_or(&self.model)
1001 .to_string();
1002
1003 let mut answers = HashMap::new();
1004
1005 let raw_answers = val.get("answers").filter(|v| !v.is_null());
1006 if let Some(raw) = raw_answers {
1007 let ans_obj = raw.as_object().ok_or_else(|| {
1008 JevError::Config("malformed response: 'answers' must be a JSON object".to_string())
1009 })?;
1010 for (k, v) in ans_obj {
1011 let parsed = serde_json::from_value::<Answer>(v.clone()).map_err(|e| {
1012 JevError::Config(format!(
1013 "malformed response: answer '{}' could not be parsed: {}",
1014 k, e
1015 ))
1016 })?;
1017 answers.insert(k.clone(), parsed);
1018 }
1019 }
1020
1021 if answers.is_empty() {
1022 return Err(JevError::Config(
1025 "malformed response: no answers could be parsed".to_string(),
1026 ));
1027 }
1028
1029 let usage = val
1030 .get("usage")
1031 .and_then(|v| serde_json::from_value::<JevUsage>(v.clone()).ok())
1032 .unwrap_or_default();
1033
1034 Ok(JevResponse {
1035 model,
1036 answers,
1037 usage,
1038 is_mock: false,
1039 degraded_reason: String::new(),
1040 })
1041 }
1042
1043 pub fn simulate_system_one(
1045 &self,
1046 state: &str,
1047 questions: &HashMap<String, Question>,
1048 model_name: &str,
1049 ) -> JevResponse {
1050 let rendered_state = render_state_text(state);
1051 let state = rendered_state.as_str();
1052 let state_lower = state.to_lowercase();
1053 let state_tokens: HashSet<String> = state_lower
1054 .split(|c: char| !c.is_alphanumeric() && c != '_')
1055 .filter(|s| !s.is_empty())
1056 .map(|s| s.to_string())
1057 .collect();
1058
1059 let real_assertion = state_lower.lines().any(|line| {
1060 let t = line.trim();
1061 t.starts_with("panicked at")
1062 || t.starts_with("panic:")
1063 || ASSERTION_REGEX.is_match(t)
1064 || (FAIL_LINE_REGEX.is_match(t) && !FAIL_TO_REGEX.is_match(t))
1069 }) || EXPECTED_RECEIVED_REGEX.is_match(&state_lower);
1070 let bare_exception = state_lower
1071 .lines()
1072 .any(|line| BARE_EXCEPTION_REGEX.is_match(line.trim()));
1073 let has_explicit_failure = FAILURE_REGEX.is_match(&state_lower);
1074
1075 let heavy_kw = [
1076 "kernel",
1077 "distributed",
1078 "architecture",
1079 "refactor",
1080 "concurrency",
1081 "deadlock",
1082 "multi-file",
1083 "consensus",
1084 "supervision tree",
1085 "arquitetura",
1086 "distribuído",
1087 "distribuída",
1088 "distribuido",
1089 "refatorar",
1090 "refatoração",
1091 "concorrência",
1092 "concorrencia",
1093 "consenso",
1094 "múltiplos arquivos",
1095 "condição de corrida",
1096 "arquitectura",
1097 "concurrencia",
1098 "condición de carrera",
1099 "múltiples archivos",
1100 ];
1101 let has_heavy_keywords = heavy_kw.iter().any(|k| state_lower.contains(k));
1102
1103 let env_missing_triggers = [
1104 "modulenotfounderror",
1105 "no module named",
1106 "importerror",
1107 "cannot find module",
1108 "err_module_not_found",
1109 "ts2307",
1110 "cannot find crate",
1111 "can't find crate",
1112 "e0463",
1113 "cannot find package",
1114 "no required module provides package",
1115 "classnotfoundexception",
1116 "noclassdeffounderror",
1117 "package does not exist",
1118 "no such file or directory",
1119 "command not found",
1120 "module not found",
1121 "package not found",
1122 "crate not found",
1123 "cs0246",
1124 "type or namespace name",
1125 "cannot load such file",
1126 "loaderror",
1127 "módulo não encontrado",
1128 "modulo nao encontrado",
1129 "nenhum módulo chamado",
1130 "pacote não encontrado",
1131 "módulo no encontrado",
1132 "modulo no encontrado",
1133 "no se encontró el módulo",
1134 "paquete no encontrado",
1135 ];
1136 let flaky_triggers = [
1137 "connectionreset",
1138 "timeout",
1139 "timed out",
1140 "econnreset",
1141 "econnrefused",
1142 "etimedout",
1143 "socket hang up",
1144 "gateway timeout",
1145 "503 service unavailable",
1146 "tempo limite",
1147 "tempo limite esgotado",
1148 "conexão recusada",
1149 "conexao recusada",
1150 "tiempo de espera agotado",
1151 "conexión rechazada",
1152 "conexion rechazada",
1153 "already in use",
1154 "address already in use",
1155 "eaddrinuse",
1156 "port already in use",
1157 "port is already in use",
1158 "porta já está em uso",
1159 "puerto ya está en uso",
1160 ];
1161
1162 let has_env_signal = env_missing_triggers.iter().any(|k| state_lower.contains(k));
1166 let has_flaky_signal = flaky_triggers.iter().any(|k| state_lower.contains(k));
1167 let is_explicit_assertion =
1168 real_assertion || (bare_exception && !(has_env_signal || has_flaky_signal));
1169 let syntax_triggers = [
1170 "syntaxerror",
1171 "indentationerror",
1172 "expected ';'",
1173 "ts1005",
1174 "missing bracket",
1175 "erro de sintaxe",
1176 "sintaxe inválida",
1177 "indentação inesperada",
1178 "error de sintaxis",
1179 "sintaxis inválida",
1180 ];
1181 let deep_logic_triggers = [
1182 "assertionerror",
1183 "assertionfailed",
1184 "assertionfailederror",
1185 "assert ",
1186 "panicked at",
1187 "panic:",
1188 "panic",
1189 "deadlock",
1190 "goroutines are asleep",
1191 "infinite loop",
1192 "loop infinito",
1193 "bucle infinito",
1194 "bloqueo mutuo",
1195 "mutex",
1196 "segmentation fault",
1197 "sigsegv",
1198 "addresssanitizer",
1199 "core dumped",
1200 "nullpointerexception",
1201 "nullreferenceexception",
1202 "arrayindexoutofboundsexception",
1203 "nil pointer dereference",
1204 "index out of bounds",
1205 "falha de asserção",
1206 "asserção",
1207 "erro de lógica",
1208 "fallo de aserción",
1209 "error de lógica",
1210 "expect(",
1211 ];
1212 let single_word_mech = [
1213 "git", "diff", "typo", "flake8", "eslint", "prettier", "linter", "echo", "pwd",
1214 "format", "black", "lint", "cat", "ls",
1215 ];
1216 let multi_word_mech = [
1217 "git status",
1218 "git diff",
1219 "git log",
1220 "view file",
1221 "read file",
1222 "cat file",
1223 "check status",
1224 "run linter",
1225 "fix typo",
1226 "ler arquivo",
1227 "verificar arquivo",
1228 "formatar código",
1229 "leer archivo",
1230 "corregir errata",
1231 "listar arquivos",
1232 "listar diretório",
1233 ];
1234 let has_mech_trigger = single_word_mech.iter().any(|w| state_tokens.contains(*w))
1235 || multi_word_mech.iter().any(|p| state_lower.contains(p));
1236
1237 let is_negated_abort = NEGATION_REGEX.is_match(state);
1238
1239 let mut answers = HashMap::new();
1240
1241 for (qid, q) in questions {
1242 match q {
1243 Question::Choice(cq) => {
1244 let mut best_choice = if cq.criteria.contains_key("deep_logic") {
1245 "deep_logic".to_string()
1246 } else if cq.criteria.contains_key("lightweight_system2") {
1247 "lightweight_system2".to_string()
1248 } else if cq.criteria.contains_key("proceed") {
1249 "proceed".to_string()
1250 } else {
1251 cq.criteria.keys().next().cloned().unwrap_or_default()
1252 };
1253 let derived_abort_action = if cq.criteria.contains_key("abort_and_ask")
1257 && cq.criteria.contains_key("proceed")
1258 {
1259 mock_abort_action_choice(&cq.criteria, &state_lower)
1260 } else {
1261 String::new()
1262 };
1263 if !derived_abort_action.is_empty() {
1264 best_choice = derived_abort_action.clone();
1265 }
1266 let mut best_score: i32 = 0;
1267
1268 let mut ordered_keys: Vec<&String> = cq.criteria.keys().collect();
1270 ordered_keys.sort();
1271 for opt in ordered_keys {
1272 let desc = &cq.criteria[opt];
1273 let opt_text = format!("{} {}", opt, desc).to_lowercase();
1274 let opt_tokens: Vec<&str> = opt_text
1275 .split(|c: char| !c.is_alphanumeric() && c != '_')
1276 .filter(|s| !s.is_empty())
1277 .collect();
1278
1279 let mut score = opt_tokens
1280 .iter()
1281 .filter(|t| state_tokens.contains(**t))
1282 .count() as i32;
1283
1284 if state_lower.contains(&opt.to_lowercase()) {
1285 score += 3;
1286 }
1287
1288 let has_deadlock_or_loop = [
1289 "infinite loop",
1290 "loop infinito",
1291 "bucle infinito",
1292 "deadlock",
1293 "deadlock!",
1294 "bloqueo mutuo",
1295 "goroutines are asleep",
1296 "mutex",
1297 "thread hung",
1298 ]
1299 .iter()
1300 .any(|k| state_lower.contains(k));
1301
1302 if opt == "deep_logic" {
1304 if deep_logic_triggers.iter().any(|t| state_lower.contains(t)) {
1305 score += 8;
1306 }
1307 if is_explicit_assertion || has_deadlock_or_loop {
1308 score += 18;
1309 }
1310 } else if opt == "env_missing" {
1311 if env_missing_triggers.iter().any(|t| state_lower.contains(t)) {
1312 score += if is_explicit_assertion { 0 } else { 7 };
1313 }
1314 } else if opt == "flaky_transient" && !has_deadlock_or_loop {
1315 if flaky_triggers.iter().any(|t| state_lower.contains(t)) {
1316 score += if is_explicit_assertion { 0 } else { 7 };
1317 }
1318 } else if opt == "syntax_trivial" {
1319 if syntax_triggers.iter().any(|t| state_lower.contains(t)) {
1320 score += 6;
1321 }
1322 } else if opt == "deterministic" {
1323 if has_mech_trigger
1324 || ["bash", "regex", "script"]
1325 .iter()
1326 .any(|k| state_tokens.contains(*k))
1327 {
1328 score += if has_heavy_keywords { 2 } else { 7 };
1329 }
1330 } else if opt == "heavy_system2" {
1331 if has_heavy_keywords {
1332 score += 15;
1333 }
1334 } else if opt == "abort_and_ask" {
1335 if !is_negated_abort {
1336 let triggers = [
1337 "repeat",
1338 "circular",
1339 "deadlock",
1340 "same",
1341 "tentar novamente",
1342 "intentar de nuevo",
1343 "mesma",
1344 "abort",
1345 ];
1346 if triggers.iter().any(|t| state_lower.contains(t)) {
1347 score += 8;
1348 }
1349 }
1350 } else if opt == "proceed" {
1351 let triggers = [
1352 "proceed",
1353 "unit test",
1354 "test",
1355 "verify",
1356 "verifying",
1357 "incremental",
1358 "progress",
1359 "implement",
1360 "add",
1361 "adicionar",
1362 "migration",
1363 ];
1364 if is_negated_abort || triggers.iter().any(|t| state_lower.contains(t))
1365 {
1366 score += 8;
1367 }
1368 }
1369
1370 if !derived_abort_action.is_empty() {
1371 continue; }
1373 if score > best_score {
1374 best_score = score;
1375 best_choice = opt.clone();
1376 }
1377 }
1378
1379 let is_effort_q = qid == "effort"
1380 || [
1381 "none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra",
1382 ]
1383 .iter()
1384 .any(|eff| cq.criteria.contains_key(*eff));
1385
1386 if is_effort_q {
1387 if has_heavy_keywords
1388 || [
1389 "deadlock",
1390 "race condition",
1391 "distributed",
1392 "concurrency",
1393 "kernel",
1394 "supervision",
1395 "architectural",
1396 "complex",
1397 "algorithmic",
1398 "deadlocks",
1399 "concorrência",
1400 "arquitetura",
1401 "condição de corrida",
1402 "arquitectura",
1403 "concurrencia",
1404 ]
1405 .iter()
1406 .any(|k| state_lower.contains(k))
1407 {
1408 best_choice = if cq.criteria.contains_key("ultra")
1409 && state_lower.contains("beyond max")
1410 {
1411 "ultra".to_string()
1412 } else if cq.criteria.contains_key("max")
1413 && (state_lower.contains("first principles")
1414 || state_lower.contains("proof"))
1415 {
1416 "max".to_string()
1417 } else if cq.criteria.contains_key("xhigh")
1418 && (state_lower.contains("first principles")
1419 || state_lower.contains("subsystems"))
1420 {
1421 "xhigh".to_string()
1422 } else if cq.criteria.contains_key("high") {
1423 "high".to_string()
1424 } else if cq.criteria.contains_key("xhigh") {
1425 "xhigh".to_string()
1426 } else if cq.criteria.contains_key("max") {
1427 "max".to_string()
1428 } else if cq.criteria.contains_key("medium") {
1429 "medium".to_string()
1430 } else {
1431 cq.criteria.keys().last().cloned().unwrap_or_default()
1432 };
1433 } else if has_mech_trigger && !has_heavy_keywords {
1434 best_choice = if cq.criteria.contains_key("none")
1435 && ["git status", "pwd", "echo", "version"]
1436 .iter()
1437 .any(|m| state_lower.contains(m))
1438 {
1439 "none".to_string()
1440 } else if cq.criteria.contains_key("minimal")
1441 && ["git status", "pwd", "echo", "version"]
1442 .iter()
1443 .any(|m| state_lower.contains(m))
1444 {
1445 "minimal".to_string()
1446 } else if cq.criteria.contains_key("low") {
1447 "low".to_string()
1448 } else if cq.criteria.contains_key("minimal") {
1449 "minimal".to_string()
1450 } else if cq.criteria.contains_key("none") {
1451 "none".to_string()
1452 } else if cq.criteria.contains_key("medium") {
1453 "medium".to_string()
1454 } else {
1455 cq.criteria.keys().next().cloned().unwrap_or_default()
1456 };
1457 } else {
1458 best_choice = if cq.criteria.contains_key("medium") {
1459 "medium".to_string()
1460 } else if cq.criteria.contains_key("high") {
1461 "high".to_string()
1462 } else if cq.criteria.contains_key("low") {
1463 "low".to_string()
1464 } else {
1465 cq.criteria.keys().next().cloned().unwrap_or_default()
1466 };
1467 }
1468 } else if qid == "lease"
1469 || (cq.criteria.contains_key("1")
1470 && ["2", "5", "10"]
1471 .iter()
1472 .any(|x| cq.criteria.contains_key(*x)))
1473 {
1474 if [
1476 "error",
1477 "fail",
1478 "erro",
1479 "falha",
1480 "deadlock",
1481 "panic",
1482 "exception",
1483 ]
1484 .iter()
1485 .any(|k| state_lower.contains(k))
1486 {
1487 best_choice = "1".to_string();
1488 } else if has_mech_trigger && !has_heavy_keywords {
1489 best_choice = if cq.criteria.contains_key("5") {
1490 "5".to_string()
1491 } else if cq.criteria.contains_key("2") {
1492 "2".to_string()
1493 } else {
1494 "1".to_string()
1495 };
1496 } else {
1497 best_choice = if cq.criteria.contains_key("2") {
1498 "2".to_string()
1499 } else if cq.criteria.contains_key("5") {
1500 "5".to_string()
1501 } else {
1502 "1".to_string()
1503 };
1504 }
1505 if !cq.criteria.contains_key(&best_choice) {
1506 best_choice = cq.criteria.keys().next().cloned().unwrap_or_default();
1507 }
1508 } else if qid == "workflow_phase"
1509 || (cq.criteria.contains_key("execute")
1510 && cq.criteria.contains_key("complete"))
1511 {
1512 let is_waiting_q = state_lower.contains('?')
1514 || [
1515 "waiting for your",
1516 "waiting on user",
1517 "wait for my go-ahead",
1518 "please confirm",
1519 "which option",
1520 "do you approve",
1521 "would you like me to",
1522 "do you want me to",
1523 "need your api key",
1524 "aguardando sua aprovação",
1525 "aguardando usuário",
1526 "qual opção você prefere",
1527 "qual opção",
1528 "preciso que você confirme",
1529 "preciso de permissão",
1530 "need clarification",
1531 "please clarify",
1532 "need permission",
1533 ]
1534 .iter()
1535 .any(|w| state_lower.contains(w));
1536 let is_unverified = [
1537 "without running tests",
1538 "tests not run",
1539 "unverified",
1540 "haven't run pytest",
1541 "todo: run tests",
1542 "falta rodar os testes",
1543 "sem testar",
1544 "need to verify",
1545 "to verify",
1546 "run pytest",
1547 "run cargo test",
1548 "run npm test",
1549 "need to run",
1550 "updated file",
1551 "edited file",
1552 "finished editing",
1553 "modified file",
1554 "wrote code",
1555 "atualizei o arquivo",
1556 "alterei o arquivo",
1557 "terminei de editar",
1558 "arquivo alterado",
1559 ]
1560 .iter()
1561 .any(|w| state_lower.contains(w));
1562 let is_unfinished = [
1563 "next i'll",
1564 "next i will",
1565 "now i will",
1566 "continuarei",
1567 "a seguir vou",
1568 "próximo passo farei",
1569 "1 of 5",
1570 "2 of 5",
1571 "3 of 5",
1572 "4 of 5",
1573 "step 1 of",
1574 "step 1 done",
1575 "unfinished",
1576 "remaining",
1577 "todo:",
1578 "pendente",
1579 "partial",
1580 "parcial",
1581 "in progress",
1582 "falta implementar",
1583 "falta rodar os testes",
1584 "sem testar",
1585 "without running tests",
1586 "tests not run",
1587 "haven't run pytest",
1588 "need to run",
1589 "need to verify",
1590 "to verify",
1591 "unverified",
1592 "run pytest",
1593 "run cargo test",
1594 "run npm test",
1595 "updated file",
1596 "edited file",
1597 "finished editing",
1598 "modified file",
1599 "wrote code",
1600 "atualizei o arquivo",
1601 "alterei o arquivo",
1602 "terminei de editar",
1603 "arquivo alterado",
1604 "next step",
1605 ]
1606 .iter()
1607 .any(|w| state_lower.contains(w));
1608 let is_complete = [
1609 "all done",
1610 "100% passing",
1611 "all criteria satisfied",
1612 "tudo concluído",
1613 "todas as etapas concluídas",
1614 "task complete",
1615 "konnichiwa! all done",
1616 "all tests passed",
1617 "tests passed (0 failed)",
1618 "completed and verified",
1619 "completed all",
1620 "concluído com sucesso",
1621 "todos os testes passaram",
1622 ]
1623 .iter()
1624 .any(|w| state_lower.contains(w));
1625
1626 if is_waiting_q && cq.criteria.contains_key("ask") {
1627 best_choice = "ask".to_string();
1628 } else if is_complete && cq.criteria.contains_key("complete") {
1629 best_choice = "complete".to_string();
1630 } else if is_unverified && cq.criteria.contains_key("verify") {
1631 best_choice = "verify".to_string();
1632 } else if is_unfinished && cq.criteria.contains_key("execute") {
1633 best_choice = "execute".to_string();
1634 }
1635 }
1638
1639 let has_deadlock_or_loop = [
1640 "infinite loop",
1641 "loop infinito",
1642 "bucle infinito",
1643 "deadlock",
1644 "deadlock!",
1645 "bloqueo mutuo",
1646 "goroutines are asleep",
1647 "mutex",
1648 ]
1649 .iter()
1650 .any(|k| state_lower.contains(k));
1651 let has_signal_conflict = (is_explicit_assertion || has_deadlock_or_loop)
1652 && (has_env_signal || has_flaky_signal);
1653 let options: Vec<String> = cq.criteria.keys().cloned().collect();
1654 let probs = mock_distribution(
1655 &options,
1656 &best_choice,
1657 if has_signal_conflict {
1658 MOCK_CHOICE_BEST_CONFLICT
1659 } else {
1660 MOCK_CHOICE_BEST_PEAKED
1661 },
1662 );
1663 answers.insert(
1664 qid.clone(),
1665 Answer::Choice(ChoiceAnswer {
1666 choice: best_choice,
1667 confidence: 0.88,
1668 probabilities: Some(probs),
1669 }),
1670 );
1671 }
1672 Question::Score(sq) => {
1673 let n_levels = sq.criteria.len() as i32;
1674 let mut matched_idx = if qid == "viability" { 3 } else { 2 };
1675
1676 let positive_words = [
1677 "satisfy",
1678 "satisfaz",
1679 "satisface",
1680 "atende",
1681 "passed",
1682 "passou",
1683 "pasó",
1684 "pass",
1685 "sucesso",
1686 "éxito",
1687 "success",
1688 "excellent",
1689 "exhaustively",
1690 "complete",
1691 "concluido",
1692 "completado",
1693 "proceed",
1694 ];
1695 let trivial_words = ["trivial", "minor", "pequeno", "menor"];
1696 let critical_words = [
1697 "critical",
1698 "critico",
1699 "crítico",
1700 "fatal",
1701 "disaster",
1702 "destrutivo",
1703 "complex",
1704 "complexo",
1705 "complejo",
1706 ];
1707 let has_deadlock_or_loop = [
1708 "infinite loop",
1709 "loop infinito",
1710 "bucle infinito",
1711 "deadlock",
1712 "deadlock!",
1713 "bloqueo mutuo",
1714 "goroutines are asleep",
1715 "mutex",
1716 ]
1717 .iter()
1718 .any(|k| state_lower.contains(k));
1719
1720 let neg_signals = ["not ok", "failed", "falhou"];
1721
1722 let is_satisfaction_failure =
1723 has_explicit_failure && (qid == "satisfaction" || qid == "rigor");
1724 let is_unviable_step = qid == "viability"
1725 && ([
1726 "deadlock",
1727 "circular",
1728 "impossible",
1729 "impossivel",
1730 "imposible",
1731 "doomed",
1732 "inviavel",
1733 "inviable",
1734 ]
1735 .iter()
1736 .any(|w| state_lower.contains(w))
1737 || has_deadlock_or_loop);
1738
1739 if is_satisfaction_failure || is_unviable_step {
1740 matched_idx = 1;
1741 } else if !has_explicit_failure
1742 && positive_words.iter().any(|w| state_lower.contains(w))
1743 && !neg_signals.iter().any(|neg| state_lower.contains(neg))
1744 {
1745 matched_idx = n_levels;
1746 } else if qid != "viability"
1747 && trivial_words.iter().any(|w| state_lower.contains(w))
1748 && !has_heavy_keywords
1749 {
1750 matched_idx = 1;
1751 } else if qid != "viability"
1752 && (has_heavy_keywords
1753 || critical_words.iter().any(|w| state_lower.contains(w)))
1754 {
1755 matched_idx = n_levels;
1756 }
1757
1758 for (idx, level_label) in sq.criteria.iter().enumerate() {
1759 let lvl_lower = level_label.to_lowercase();
1760 let lvl_tokens: Vec<&str> = lvl_lower
1761 .split(|c: char| !c.is_alphanumeric() && c != '_')
1762 .filter(|s| !s.is_empty())
1763 .collect();
1764 if lvl_tokens.iter().any(|t| state_tokens.contains(*t))
1765 && !(has_explicit_failure
1766 && idx > 0
1767 && (qid == "satisfaction" || qid == "rigor"))
1768 {
1769 matched_idx = (idx + 1) as i32;
1770 }
1771 }
1772
1773 let levels: Vec<String> = (1..=n_levels).map(|i| i.to_string()).collect();
1774 let probs = mock_distribution(
1775 &levels,
1776 &matched_idx.to_string(),
1777 MOCK_SCORE_BEST_PEAKED,
1778 );
1779 answers.insert(
1780 qid.clone(),
1781 Answer::Score(ScoreAnswer {
1782 score: matched_idx as f64,
1783 confidence: 0.85,
1784 legend: Some(serde_json::json!(sq.criteria)),
1785 probabilities: Some(probs),
1786 }),
1787 );
1788 }
1789 Question::Noul(nq) => {
1790 let inst = nq.instructions.to_lowercase();
1791 let mut prob = 0.15;
1792
1793 let negative_signals = [
1794 "abort",
1795 "abortar",
1796 "fail",
1797 "falha",
1798 "fallo",
1799 "error",
1800 "erro",
1801 "impossible",
1802 "impossivel",
1803 "imposible",
1804 "fatal",
1805 "circular",
1806 "deadlock",
1807 "dead end",
1808 "broken",
1809 "quebrado",
1810 "unviable",
1811 "inviavel",
1812 "inviable",
1813 "deletar",
1814 "apagar",
1815 "destrutivo",
1816 ];
1817 let positive_signals = [
1818 "pass",
1819 "passed",
1820 "passou",
1821 "pasó",
1822 "success",
1823 "sucesso",
1824 "éxito",
1825 "resolved",
1826 "resolvido",
1827 "resuelto",
1828 "good",
1829 "bom",
1830 "valid",
1831 "valido",
1832 "válido",
1833 "satisfy",
1834 "satisfaz",
1835 "satisface",
1836 "atende",
1837 "all criteria",
1838 "todos os criterios",
1839 "concluido",
1840 "completado",
1841 "complete",
1842 "proceed",
1843 "linear",
1844 ];
1845 let mut proposed_part: &str = &state_lower;
1847 for marker in ["proposed next step:", "proposed_next_step:"] {
1848 if let Some((_, tail)) = proposed_part.split_once(marker) {
1849 proposed_part = tail;
1850 }
1851 }
1852 let is_forward_progress = [
1853 "implement",
1854 "fix",
1855 "resolve",
1856 "correct",
1857 "update",
1858 "create",
1859 "write",
1860 "corrigir",
1861 "implementar",
1862 "executar",
1863 "validar",
1864 "corregir",
1865 ]
1866 .iter()
1867 .any(|w| proposed_part.contains(w));
1868 let is_repetitive_loop = [
1869 "same",
1870 "repetir",
1871 "tentar novamente",
1872 "intentar de nuevo",
1873 "4a vez",
1874 "again",
1875 "identical",
1876 ]
1877 .iter()
1878 .any(|w| proposed_part.contains(w));
1879 let is_fatal_deadlock = [
1880 "impossible",
1881 "impossivel",
1882 "imposible",
1883 "circular",
1884 "deadlock",
1885 "dead end",
1886 "inviavel",
1887 "inviable",
1888 "hopeless",
1889 "fatal",
1890 ]
1891 .iter()
1892 .any(|w| state_lower.contains(w));
1893 let has_deadlock_or_loop = [
1894 "infinite loop",
1895 "loop infinito",
1896 "bucle infinito",
1897 "deadlock",
1898 "deadlock!",
1899 "bloqueo mutuo",
1900 "goroutines are asleep",
1901 "mutex",
1902 ]
1903 .iter()
1904 .any(|k| state_lower.contains(k));
1905
1906 if has_explicit_failure
1907 && ["pass", "valid", "satisfy", "complete", "verif"]
1908 .iter()
1909 .any(|w| inst.contains(w))
1910 {
1911 prob = 0.05;
1912 } else if is_negated_abort
1913 && ["abort", "dead", "unviable", "destructive"]
1914 .iter()
1915 .any(|w| inst.contains(w))
1916 {
1917 prob = 0.08;
1918 } else if (is_fatal_deadlock || has_deadlock_or_loop)
1919 && [
1920 "abort",
1921 "dead",
1922 "fail",
1923 "urgent",
1924 "invalid",
1925 "unviable",
1926 "destructive",
1927 "dead end",
1928 ]
1929 .iter()
1930 .any(|w| inst.contains(w))
1931 {
1932 prob = 0.88;
1933 } else if [
1934 "abort",
1935 "dead",
1936 "fail",
1937 "urgent",
1938 "invalid",
1939 "unviable",
1940 "destructive",
1941 "dead end",
1942 ]
1943 .iter()
1944 .any(|w| inst.contains(w))
1945 {
1946 if is_repetitive_loop {
1947 prob = 0.88;
1948 } else if is_forward_progress {
1949 prob = 0.12;
1950 } else if negative_signals.iter().any(|s| state_lower.contains(s)) {
1951 prob = 0.85;
1952 } else {
1953 prob = 0.15;
1954 }
1955 }
1956
1957 if !has_explicit_failure
1958 && positive_signals.iter().any(|s| state_lower.contains(s))
1959 && !["not ok", "failed", "falhou"]
1960 .iter()
1961 .any(|neg| state_lower.contains(neg))
1962 {
1963 if ["pass", "valid", "satisfy", "complete", "verif"]
1964 .iter()
1965 .any(|w| inst.contains(w))
1966 {
1967 prob = 0.92;
1968 } else if ["abort", "dead", "unviable"]
1969 .iter()
1970 .any(|w| inst.contains(w))
1971 {
1972 prob = 0.08;
1973 }
1974 }
1975
1976 if (is_explicit_assertion || has_deadlock_or_loop)
1977 && (inst.contains("deterministically") || inst.contains("skip"))
1978 {
1979 prob = 0.05;
1980 } else if env_missing_triggers
1981 .iter()
1982 .chain(flaky_triggers.iter())
1983 .chain(["pip install", "npm install", "cargo add"].iter())
1984 .any(|w| state_lower.contains(w))
1985 && !(is_explicit_assertion || has_deadlock_or_loop)
1986 && (inst.contains("deterministically") || inst.contains("skip"))
1987 {
1988 prob = 0.95;
1989 }
1990
1991 let is_waiting_on_user = state_lower.contains('?')
1993 || [
1994 "waiting on user",
1995 "need permission",
1996 "please clarify",
1997 "which option",
1998 "would you like me to",
1999 "do you want me to",
2000 "aguardando usuário",
2001 "preciso de permissão",
2002 "qual opção",
2003 ]
2004 .iter()
2005 .any(|w| state_lower.contains(w));
2006 let is_done = [
2007 "all done",
2008 "100% passing",
2009 "all criteria satisfied",
2010 "tudo concluído",
2011 "todas as etapas concluídas",
2012 "task complete",
2013 "konnichiwa! all done",
2014 "all tests passed",
2015 "tests passed (0 failed)",
2016 "completed and verified",
2017 ]
2018 .iter()
2019 .any(|w| state_lower.contains(w));
2020 let has_unfinished_work = [
2021 "next i'll",
2022 "next i will",
2023 "now i will",
2024 "continuarei",
2025 "a seguir vou",
2026 "próximo passo farei",
2027 "1 of 5",
2028 "2 of 5",
2029 "3 of 5",
2030 "4 of 5",
2031 "step 1 of",
2032 "step 1 done",
2033 "unfinished",
2034 "remaining",
2035 "todo:",
2036 "pendente",
2037 "partial",
2038 "parcial",
2039 "in progress",
2040 "falta implementar",
2041 "falta rodar os testes",
2042 "sem testar",
2043 "without running tests",
2044 "tests not run",
2045 "haven't run pytest",
2046 "need to run",
2047 "need to verify",
2048 "to verify",
2049 "unverified",
2050 "run pytest",
2051 "run cargo test",
2052 "run npm test",
2053 "updated file",
2054 "edited file",
2055 "finished editing",
2056 "modified file",
2057 "wrote code",
2058 "atualizei o arquivo",
2059 "alterei o arquivo",
2060 "terminei de editar",
2061 "arquivo alterado",
2062 "next step",
2063 ]
2064 .iter()
2065 .any(|w| state_lower.contains(w));
2066 let has_no_progress = [
2067 "no progress",
2068 "stuck",
2069 "same output",
2070 "unchanged",
2071 "repeated without change",
2072 "sem progresso",
2073 "mesma saída",
2074 ]
2075 .iter()
2076 .any(|w| state_lower.contains(w));
2077
2078 if qid == "waiting" || inst.contains("waiting on the user") {
2079 prob = if is_waiting_on_user { 0.88 } else { 0.08 };
2080 } else if qid == "progress" || inst.contains("last nudge produce real progress")
2081 {
2082 prob = if has_no_progress { 0.12 } else { 0.85 };
2083 } else if qid == "nudge" || inst.contains("gentle nudge") {
2084 if is_waiting_on_user || has_no_progress || is_done {
2085 prob = 0.06;
2086 } else if has_unfinished_work {
2087 prob = 0.82;
2088 } else {
2089 prob = 0.20;
2090 }
2091 }
2092
2093 answers.insert(qid.clone(), Answer::Noul(NoulAnswer { noul: prob }));
2094 }
2095 }
2096 }
2097
2098 JevResponse {
2099 model: model_name.to_string(),
2100 answers,
2101 usage: JevUsage {
2102 input_tokens: std::cmp::max(10, (state.len() / 4) as u32),
2103 output_tokens: 0,
2104 },
2105 is_mock: true,
2106 degraded_reason: String::new(),
2107 }
2108 }
2109}