#![allow(dead_code, unused_imports, unused_variables)]
use anyhow::{Context, Result};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::time::Duration;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisualVerificationResult {
pub passed: bool,
pub confidence: f64,
pub description: String,
pub issues: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisualDiffResult {
pub changes_detected: bool,
pub expected_change_found: bool,
pub description: String,
pub unexpected_changes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UiElement {
pub name: String,
pub element_type: String,
pub expected_text: Option<String>,
pub expected_location: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementVerification {
pub element: UiElement,
pub found: bool,
pub location: Option<String>,
pub actual_text: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LayoutAnalysis {
pub overall_quality: String,
pub alignment_issues: Vec<String>,
pub spacing_issues: Vec<String>,
pub responsive_notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisualVerificationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_visual_endpoint")]
pub endpoint: String,
#[serde(default = "default_visual_model")]
pub model: String,
#[serde(default = "default_visual_timeout")]
pub timeout_secs: u64,
#[serde(default = "default_confidence_threshold")]
pub confidence_threshold: f64,
}
fn default_visual_endpoint() -> String {
"http://localhost:1234/v1".to_string()
}
fn default_visual_model() -> String {
"qwen2-vl-7b".to_string()
}
fn default_visual_timeout() -> u64 {
120
}
fn default_confidence_threshold() -> f64 {
0.7
}
impl Default for VisualVerificationConfig {
fn default() -> Self {
Self {
enabled: false,
endpoint: default_visual_endpoint(),
model: default_visual_model(),
timeout_secs: default_visual_timeout(),
confidence_threshold: default_confidence_threshold(),
}
}
}
pub struct VisualVerifier {
endpoint: String,
model: String,
api_key: Option<crate::config::RedactedString>,
timeout_secs: u64,
default_max_tokens: usize,
temperature: f64,
image_detail: String,
extra_body: Option<serde_json::Map<String, serde_json::Value>>,
}
const VERIFICATION_MAX_TOKENS: usize = 192;
const DIFF_MAX_TOKENS: usize = 160;
const JSON_TEMPERATURE: f64 = 0.0;
const DEFAULT_IMAGE_DETAIL: &str = "low";
impl VisualVerifier {
pub fn new(endpoint: impl Into<String>, model: impl Into<String>) -> Self {
Self {
endpoint: endpoint.into(),
model: model.into(),
api_key: None,
timeout_secs: default_visual_timeout(),
default_max_tokens: 4096,
temperature: JSON_TEMPERATURE,
image_detail: DEFAULT_IMAGE_DETAIL.to_string(),
extra_body: None,
}
}
pub fn from_config(config: &VisualVerificationConfig) -> Self {
Self::new(&config.endpoint, &config.model).with_timeout(config.timeout_secs)
}
pub fn from_model_profile(profile: &crate::config::ModelProfile) -> Self {
Self::new(&profile.endpoint, &profile.model)
.with_api_key(profile.api_key.clone())
.with_generation(profile.max_tokens, profile.temperature as f64)
.with_extra_body(profile.extra_body.clone())
}
pub fn from_app_config(config: &crate::config::Config) -> Self {
let mut verifier = config
.models
.get("vision")
.map(Self::from_model_profile)
.or_else(|| config.resolve_model(None).map(Self::from_model_profile))
.unwrap_or_else(|| {
Self::new(&config.endpoint, &config.model)
.with_api_key(config.api_key.clone())
.with_generation(config.max_tokens, config.temperature as f64)
.with_extra_body(config.extra_body.clone())
});
verifier.timeout_secs = config.agent.step_timeout_secs.max(1);
verifier
}
pub fn with_timeout(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
pub fn with_generation(mut self, max_tokens: usize, temperature: f64) -> Self {
self.default_max_tokens = max_tokens.max(1);
self.temperature = temperature;
self
}
pub fn with_api_key(mut self, api_key: Option<crate::config::RedactedString>) -> Self {
self.api_key = api_key;
self
}
pub fn with_image_detail(mut self, detail: impl Into<String>) -> Self {
self.image_detail = detail.into();
self
}
pub fn with_extra_body(
mut self,
extra_body: Option<serde_json::Map<String, serde_json::Value>>,
) -> Self {
self.extra_body = extra_body;
self
}
pub async fn verify_screenshot(
&self,
image_base64: &str,
expected: &str,
) -> Result<VisualVerificationResult> {
let prompt = build_verify_prompt(expected);
let body = self.build_single_image_request_with_options(
&prompt,
image_base64,
VERIFICATION_MAX_TOKENS,
)?;
let raw = self.call_vlm(&body).await?;
parse_verification_response(&raw)
}
pub async fn compare_screenshots(
&self,
before: &str,
after: &str,
change_description: &str,
) -> Result<VisualDiffResult> {
let prompt = build_compare_prompt(change_description);
let body =
self.build_two_image_request_with_options(&prompt, before, after, DIFF_MAX_TOKENS)?;
let raw = self.call_vlm(&body).await?;
parse_diff_response(&raw)
}
pub async fn verify_ui_elements(
&self,
image_base64: &str,
elements: &[UiElement],
) -> Result<Vec<ElementVerification>> {
let prompt = build_elements_prompt(elements);
let body = self.build_single_image_request(&prompt, image_base64)?;
let raw = self.call_vlm(&body).await?;
parse_elements_response(&raw, elements)
}
pub async fn visual_check(
&self,
image_base64: &str,
expected: &str,
) -> Result<super::verification::CheckResult> {
let start = std::time::Instant::now();
let result = self.verify_screenshot(image_base64, expected).await;
let duration_ms = start.elapsed().as_millis() as u64;
match result {
Ok(vr) => {
let errors = vr
.issues
.iter()
.map(|issue| super::verification::VerificationError {
file: String::new(),
line: None,
column: None,
message: issue.clone(),
code: None,
severity: super::verification::ErrorSeverity::Error,
suggestion: None,
})
.collect();
Ok(super::verification::CheckResult {
check_type: super::verification::CheckType::Custom,
passed: vr.passed,
duration_ms,
output: vr.description,
errors,
warnings: vec![],
suggestions: if !vr.passed {
vec!["Visual verification failed -- review screenshot against expected layout".to_string()]
} else {
vec![]
},
})
}
Err(e) => Ok(super::verification::CheckResult {
check_type: super::verification::CheckType::Custom,
passed: false,
duration_ms,
output: format!("Visual verification error: {}", e),
errors: vec![super::verification::VerificationError {
file: String::new(),
line: None,
column: None,
message: e.to_string(),
code: None,
severity: super::verification::ErrorSeverity::Error,
suggestion: None,
}],
warnings: vec![],
suggestions: vec![
"Ensure VLM endpoint is reachable and the model supports vision".to_string(),
],
}),
}
}
fn build_single_image_request(&self, prompt: &str, image_base64: &str) -> Result<Value> {
self.build_single_image_request_with_options(prompt, image_base64, self.default_max_tokens)
}
fn build_single_image_request_with_options(
&self,
prompt: &str,
image_base64: &str,
max_tokens: usize,
) -> Result<Value> {
let data_uri = format!("data:image/png;base64,{}", image_base64);
let mut body = json!({
"model": self.model,
"messages": [
{
"role": "system",
"content": "You are a precise visual verification assistant. Follow the user instruction exactly and answer directly."
},
{
"role": "user",
"content": [
{ "type": "text", "text": prompt },
{ "type": "image_url", "image_url": { "url": data_uri, "detail": self.image_detail } }
]
}
],
"max_tokens": self.clamp_max_tokens(max_tokens),
"temperature": self.temperature,
"stream": false
});
self.merge_extra_body(&mut body)?;
Ok(body)
}
fn build_two_image_request(
&self,
prompt: &str,
before_base64: &str,
after_base64: &str,
) -> Result<Value> {
self.build_two_image_request_with_options(
prompt,
before_base64,
after_base64,
self.default_max_tokens,
)
}
fn build_two_image_request_with_options(
&self,
prompt: &str,
before_base64: &str,
after_base64: &str,
max_tokens: usize,
) -> Result<Value> {
let uri_before = format!("data:image/png;base64,{}", before_base64);
let uri_after = format!("data:image/png;base64,{}", after_base64);
let mut body = json!({
"model": self.model,
"messages": [
{
"role": "system",
"content": "You are a precise visual verification assistant. Follow the user instruction exactly and answer directly."
},
{
"role": "user",
"content": [
{ "type": "text", "text": prompt },
{ "type": "image_url", "image_url": { "url": uri_before, "detail": self.image_detail } },
{ "type": "image_url", "image_url": { "url": uri_after, "detail": self.image_detail } }
]
}
],
"max_tokens": self.clamp_max_tokens(max_tokens),
"temperature": self.temperature,
"stream": false
});
self.merge_extra_body(&mut body)?;
Ok(body)
}
fn clamp_max_tokens(&self, max_tokens: usize) -> usize {
max_tokens.min(self.default_max_tokens.max(1))
}
fn merge_extra_body(&self, body: &mut Value) -> Result<()> {
let Some(extra_body) = &self.extra_body else {
return Ok(());
};
crate::api::merge_extra_body(body, Some(extra_body), "visual verification request")
}
async fn call_vlm(&self, body: &Value) -> Result<String> {
let url = format!("{}/chat/completions", self.endpoint.trim_end_matches('/'));
debug!("Calling VLM endpoint: {}", url);
let client = Client::builder()
.timeout(Duration::from_secs(self.timeout_secs))
.connect_timeout(Duration::from_secs(15))
.build()
.context("Failed to build HTTP client")?;
let request = crate::config::api_key::authorize_request(
client.post(&url).header("Content-Type", "application/json"),
&self.endpoint,
self.api_key.as_ref().map(|k| k.expose()),
)?;
let response = request
.json(body)
.send()
.await
.with_context(|| format!("Failed to connect to VLM endpoint: {}", url))?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await.unwrap_or_default();
anyhow::bail!(
"VLM API returned HTTP {}: {}",
status.as_u16(),
text.chars().take(500).collect::<String>()
);
}
let json_resp: Value = response
.json()
.await
.context("Failed to parse VLM response as JSON")?;
let content = json_resp["choices"][0]["message"]["content"]
.as_str()
.or_else(|| json_resp["choices"][0]["message"]["reasoning_content"].as_str())
.or_else(|| json_resp["choices"][0]["message"]["reasoning"].as_str())
.unwrap_or("")
.to_string();
if content.is_empty() {
warn!("VLM returned empty content");
}
Ok(content)
}
}
fn build_verify_prompt(expected: &str) -> String {
format!(
"You are a strict visual verification assistant. Analyze the provided screenshot \
and determine if it matches the following expected description:\n\n\
EXPECTED: {}\n\n\
Respond ONLY with a JSON object (no markdown fences, no extra text) with these fields:\n\
- \"passed\": boolean, true if the screenshot matches the expected description\n\
- \"confidence\": number between 0.0 and 1.0 indicating your confidence\n\
- \"description\": short string, at most 16 words, describing what you actually see\n\
- \"issues\": array of at most 3 short strings listing mismatches or problems\n\n\
Keep the response compact. If everything matches, set \"passed\" to true and \"issues\" \
to an empty array.",
expected
)
}
fn build_compare_prompt(change_description: &str) -> String {
format!(
"You are a strict visual diff assistant. Compare the two screenshots (image 1 = BEFORE, \
image 2 = AFTER) and determine whether the following expected change occurred:\n\n\
EXPECTED CHANGE: {}\n\n\
Respond ONLY with a JSON object (no markdown fences, no extra text) with these fields:\n\
- \"changes_detected\": boolean, true if the images differ\n\
- \"expected_change_found\": boolean, true if the specific expected change is visible\n\
- \"description\": short string, at most 16 words, describing the main difference\n\
- \"unexpected_changes\": array of at most 3 short strings listing changes NOT described above\n\n\
Keep the response compact and specific.",
change_description
)
}
fn build_elements_prompt(elements: &[UiElement]) -> String {
let elements_desc: Vec<String> = elements
.iter()
.enumerate()
.map(|(i, el)| {
let mut desc = format!("{}. \"{}\" (type: {})", i + 1, el.name, el.element_type);
if let Some(ref text) = el.expected_text {
desc.push_str(&format!(", expected text: \"{}\"", text));
}
if let Some(ref loc) = el.expected_location {
desc.push_str(&format!(", expected location: {}", loc));
}
desc
})
.collect();
format!(
"You are a UI element verification assistant. Analyze the screenshot and check \
for the presence of each of the following UI elements:\n\n{}\n\n\
Respond ONLY with a JSON array (no markdown fences, no extra text). Each element \
in the array should be a JSON object with these fields:\n\
- \"name\": string, the element name from the list above\n\
- \"found\": boolean, true if the element is visible in the screenshot\n\
- \"location\": string or null, where the element appears (e.g. \"top-left\", \"center\")\n\
- \"actual_text\": string or null, the actual text content if applicable",
elements_desc.join("\n")
)
}
fn extract_json_from_response(raw: &str) -> &str {
let trimmed = raw.trim();
if let Some(start) = trimmed.find("```json") {
let after_fence = &trimmed[start + 7..];
if let Some(end) = after_fence.find("```") {
return after_fence[..end].trim();
}
}
if let Some(start) = trimmed.find("```") {
let after_fence = &trimmed[start + 3..];
if let Some(end) = after_fence.find("```") {
return after_fence[..end].trim();
}
}
let obj_start = trimmed.find('{');
let arr_start = trimmed.find('[');
match (obj_start, arr_start) {
(Some(o), Some(a)) if a < o => {
if let Some(end) = trimmed.rfind(']') {
return &trimmed[a..=end];
}
}
(Some(o), _) => {
if let Some(end) = trimmed.rfind('}') {
return &trimmed[o..=end];
}
}
(None, Some(a)) => {
if let Some(end) = trimmed.rfind(']') {
return &trimmed[a..=end];
}
}
(None, None) => {}
}
trimmed
}
fn parse_verification_response(raw: &str) -> Result<VisualVerificationResult> {
let json_str = extract_json_from_response(raw);
let parsed: Value = serde_json::from_str(json_str).with_context(|| {
format!(
"Failed to parse VLM verification response as JSON: {}",
&raw[..raw.len().min(200)]
)
})?;
Ok(VisualVerificationResult {
passed: parsed["passed"].as_bool().unwrap_or(false),
confidence: parsed["confidence"]
.as_f64()
.unwrap_or_else(|| {
if parsed["passed"].as_bool().unwrap_or(false) {
1.0
} else {
0.0
}
})
.clamp(0.0, 1.0),
description: parsed["description"]
.as_str()
.or_else(|| parsed["summary"].as_str())
.unwrap_or("")
.to_string(),
issues: parsed["issues"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
})
}
fn parse_diff_response(raw: &str) -> Result<VisualDiffResult> {
let json_str = extract_json_from_response(raw);
let parsed: Value = serde_json::from_str(json_str).with_context(|| {
format!(
"Failed to parse VLM diff response as JSON: {}",
&raw[..raw.len().min(200)]
)
})?;
let changes_detected = parsed["changes_detected"]
.as_bool()
.or_else(|| parsed["changed"].as_bool())
.unwrap_or(false);
let expected_change_found = parsed["expected_change_found"]
.as_bool()
.or_else(|| parsed["changed"].as_bool())
.unwrap_or(false);
let description = parsed["description"]
.as_str()
.map(String::from)
.or_else(|| {
parsed["change_kind"].as_str().map(|kind| {
let diffs: Vec<&str> = parsed["differences"]
.as_array()
.map(|arr| arr.iter().filter_map(|v| v.as_str()).take(2).collect())
.unwrap_or_default();
if diffs.is_empty() {
kind.to_string()
} else {
format!("{}: {}", kind, diffs.join("; "))
}
})
})
.unwrap_or_default();
Ok(VisualDiffResult {
changes_detected,
expected_change_found,
description,
unexpected_changes: parsed["unexpected_changes"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
})
}
fn parse_elements_response(raw: &str, elements: &[UiElement]) -> Result<Vec<ElementVerification>> {
let json_str = extract_json_from_response(raw);
let parsed: Value = serde_json::from_str(json_str).with_context(|| {
format!(
"Failed to parse VLM elements response as JSON: {}",
&raw[..raw.len().min(200)]
)
})?;
let arr = parsed
.as_array()
.with_context(|| "Expected a JSON array from VLM elements response")?;
let mut results: Vec<ElementVerification> = Vec::with_capacity(elements.len());
for element in elements {
let matched = arr.iter().find(|item| {
item["name"]
.as_str()
.map(|n| n == element.name)
.unwrap_or(false)
});
match matched {
Some(item) => {
results.push(ElementVerification {
element: element.clone(),
found: item["found"].as_bool().unwrap_or(false),
location: item["location"].as_str().map(String::from),
actual_text: item["actual_text"].as_str().map(String::from),
});
}
None => {
results.push(ElementVerification {
element: element.clone(),
found: false,
location: None,
actual_text: None,
});
}
}
}
Ok(results)
}
fn parse_layout_response(raw: &str) -> Result<LayoutAnalysis> {
let json_str = extract_json_from_response(raw);
let parsed: Value = serde_json::from_str(json_str).with_context(|| {
format!(
"Failed to parse VLM layout response as JSON: {}",
&raw[..raw.len().min(200)]
)
})?;
Ok(LayoutAnalysis {
overall_quality: parsed["overall_quality"]
.as_str()
.unwrap_or("unknown")
.to_string(),
alignment_issues: parsed["alignment_issues"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
spacing_issues: parsed["spacing_issues"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
responsive_notes: parsed["responsive_notes"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default(),
})
}
use chrono::{DateTime, Utc};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct VisualStateTracker {
history: VecDeque<ScreenshotState>,
max_history: usize,
similarity_threshold: f32,
stuck_threshold: usize,
hash_similarity_threshold: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScreenshotState {
pub hash: String,
pub semantic_description: String,
pub timestamp: DateTime<Utc>,
pub action_taken: String,
pub action_succeeded: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub screenshot_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub enum LoopDetectionResult {
Proceed,
Warning {
similar_states: Vec<ScreenshotState>,
},
Stuck {
loop_pattern: Vec<ScreenshotState>,
suggested_recovery: RecoveryStrategy,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum RecoveryStrategy {
TryDifferentAction { alternatives: Vec<String> },
ResetToCheckpoint,
EscalateToUser { reason: String },
WaitAndRetry { delay_ms: u64 },
ReassessWithScreenshot,
ChangeInputMethod { suggestion: String },
}
impl std::fmt::Display for RecoveryStrategy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RecoveryStrategy::TryDifferentAction { alternatives } => {
write!(
f,
"Try different action. Alternatives: {}",
alternatives.join(", ")
)
}
RecoveryStrategy::ResetToCheckpoint => write!(f, "Reset to checkpoint"),
RecoveryStrategy::EscalateToUser { reason } => {
write!(f, "Escalate to user: {}", reason)
}
RecoveryStrategy::WaitAndRetry { delay_ms } => {
write!(f, "Wait {}ms and retry", delay_ms)
}
RecoveryStrategy::ReassessWithScreenshot => write!(f, "Reassess with fresh screenshot"),
RecoveryStrategy::ChangeInputMethod { suggestion } => {
write!(f, "Change input method: {}", suggestion)
}
}
}
}
impl VisualStateTracker {
pub fn new(max_history: usize, stuck_threshold: usize) -> Self {
Self {
history: VecDeque::with_capacity(max_history),
max_history,
similarity_threshold: 0.85,
stuck_threshold,
hash_similarity_threshold: 0.90,
}
}
pub fn with_thresholds(
max_history: usize,
stuck_threshold: usize,
similarity_threshold: f32,
hash_similarity_threshold: f32,
) -> Self {
Self {
history: VecDeque::with_capacity(max_history),
max_history,
similarity_threshold,
stuck_threshold,
hash_similarity_threshold,
}
}
pub fn default_config() -> Self {
Self::new(20, 2)
}
pub fn record_state(
&mut self,
screenshot_path: &Path,
semantic_description: String,
action_taken: String,
action_succeeded: bool,
) -> Result<LoopDetectionResult> {
let hash = compute_perceptual_hash(screenshot_path)?;
let state = ScreenshotState {
hash,
semantic_description,
timestamp: Utc::now(),
action_taken: action_taken.clone(),
action_succeeded,
screenshot_path: Some(screenshot_path.to_path_buf()),
};
let similar: Vec<_> = self
.history
.iter()
.filter(|h| self.is_same_screen(&state, h))
.cloned()
.collect();
let result = if similar.len() >= self.stuck_threshold {
let strategy = self.determine_recovery_strategy(&similar, action_succeeded);
LoopDetectionResult::Stuck {
loop_pattern: similar,
suggested_recovery: strategy,
}
} else if !similar.is_empty() {
LoopDetectionResult::Warning {
similar_states: similar,
}
} else {
LoopDetectionResult::Proceed
};
if self.history.len() >= self.max_history {
self.history.pop_front();
}
self.history.push_back(state);
Ok(result)
}
pub fn record_state_with_hash(
&mut self,
screenshot_hash: String,
semantic_description: String,
action_taken: String,
action_succeeded: bool,
) -> LoopDetectionResult {
let state = ScreenshotState {
hash: screenshot_hash,
semantic_description,
timestamp: Utc::now(),
action_taken: action_taken.clone(),
action_succeeded,
screenshot_path: None,
};
let similar: Vec<_> = self
.history
.iter()
.filter(|h| self.is_same_screen(&state, h))
.cloned()
.collect();
let result = if similar.len() >= self.stuck_threshold {
let strategy = self.determine_recovery_strategy(&similar, action_succeeded);
LoopDetectionResult::Stuck {
loop_pattern: similar,
suggested_recovery: strategy,
}
} else if !similar.is_empty() {
LoopDetectionResult::Warning {
similar_states: similar,
}
} else {
LoopDetectionResult::Proceed
};
if self.history.len() >= self.max_history {
self.history.pop_front();
}
self.history.push_back(state);
result
}
fn is_same_screen(&self, state1: &ScreenshotState, state2: &ScreenshotState) -> bool {
let hash_sim = compute_hash_similarity(&state1.hash, &state2.hash);
let action_same = state1.action_taken == state2.action_taken;
let both_failed = !state1.action_succeeded && !state2.action_succeeded;
hash_sim >= self.hash_similarity_threshold && action_same && both_failed
}
fn determine_recovery_strategy(
&self,
pattern: &[ScreenshotState],
_last_action_succeeded: bool,
) -> RecoveryStrategy {
let action = pattern
.last()
.map(|s| s.action_taken.clone())
.unwrap_or_default();
if pattern.len() >= 3 {
RecoveryStrategy::TryDifferentAction {
alternatives: vec![
format!("Use keyboard shortcut instead of: {}", action),
"Wait for animation to complete before next action".to_string(),
"Refresh the page/application and retry".to_string(),
"Try a different UI element or location".to_string(),
],
}
} else if pattern.len() == 2 {
RecoveryStrategy::TryDifferentAction {
alternatives: vec![
format!("Retry '{}' after brief pause", action),
"Check if element is interactable".to_string(),
"Try alternative selector or coordinates".to_string(),
],
}
} else {
RecoveryStrategy::WaitAndRetry { delay_ms: 1000 }
}
}
pub fn history_size(&self) -> usize {
self.history.len()
}
pub fn clear_history(&mut self) {
self.history.clear();
}
pub fn history(&self) -> &VecDeque<ScreenshotState> {
&self.history
}
pub fn has_similar_state(&self, hash: &str, action: &str) -> bool {
self.history.iter().any(|h| {
let hash_sim = compute_hash_similarity(&h.hash, hash);
hash_sim >= self.hash_similarity_threshold
&& h.action_taken == action
&& !h.action_succeeded
})
}
}
pub fn compute_perceptual_hash(screenshot_path: &Path) -> Result<String> {
use image::GenericImageView;
let img = image::open(screenshot_path)
.with_context(|| format!("Failed to open screenshot: {}", screenshot_path.display()))?;
let gray = img.to_luma8();
let resized = image::imageops::resize(&gray, 9, 8, image::imageops::FilterType::Lanczos3);
let mut hash_bits = Vec::with_capacity(64);
for y in 0..8 {
for x in 0..8 {
let left = resized.get_pixel(x, y)[0];
let right = resized.get_pixel(x + 1, y)[0];
hash_bits.push(if right > left { 1 } else { 0 });
}
}
let mut hex_string = String::with_capacity(16);
for chunk in hash_bits.chunks(4) {
let nibble = chunk.iter().fold(0u8, |acc, &bit| (acc << 1) | bit);
let hex_char = match nibble {
0..=9 => (b'0' + nibble) as char,
10..=15 => (b'a' + nibble - 10) as char,
_ => '0',
};
hex_string.push(hex_char);
}
Ok(hex_string)
}
pub fn compute_hash_similarity(hash1: &str, hash2: &str) -> f32 {
if hash1.len() != hash2.len() {
let min_len = hash1.len().min(hash2.len());
let max_len = hash1.len().max(hash2.len());
let common = hash1
.bytes()
.zip(hash2.bytes())
.take(min_len)
.filter(|(a, b)| a == b)
.count();
return common as f32 / max_len as f32;
}
let max_distance = hash1.len() * 4; let distance = hamming_distance(hash1, hash2);
1.0 - (distance as f32 / max_distance as f32)
}
fn hamming_distance(s1: &str, s2: &str) -> usize {
s1.bytes()
.zip(s2.bytes())
.map(|(b1, b2)| {
let n1 = hex_to_nibble(b1);
let n2 = hex_to_nibble(b2);
(n1 ^ n2).count_ones() as usize
})
.sum()
}
fn hex_to_nibble(c: u8) -> u8 {
match c {
b'0'..=b'9' => c - b'0',
b'a'..=b'f' => c - b'a' + 10,
b'A'..=b'F' => c - b'A' + 10,
_ => 0,
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VisualLoopConfig {
pub max_history: usize,
pub stuck_threshold: usize,
pub hash_similarity_threshold: f32,
pub semantic_similarity_threshold: f32,
pub auto_recovery: bool,
}
impl Default for VisualLoopConfig {
fn default() -> Self {
Self {
max_history: 20,
stuck_threshold: 2,
hash_similarity_threshold: 0.90,
semantic_similarity_threshold: 0.85,
auto_recovery: true,
}
}
}
impl VisualLoopConfig {
pub fn create_tracker(&self) -> VisualStateTracker {
VisualStateTracker::with_thresholds(
self.max_history,
self.stuck_threshold,
self.semantic_similarity_threshold,
self.hash_similarity_threshold,
)
}
}
#[cfg(test)]
#[path = "../../tests/unit/testing/visual_verification/visual_verification_test.rs"]
mod tests;