1use async_trait::async_trait;
19use pointlock_ir::{RectIR, VerdictStatus};
20
21pub const STUB_REASON: &str = "vision verifier not configured";
23
24#[derive(Debug, Clone, PartialEq)]
30pub struct VisionRequest<'a> {
31 pub prompt: &'a str,
35 pub region: Option<&'a RectIR>,
37 pub screenshot: &'a [u8],
39 pub media_type: &'a str,
41}
42
43#[derive(Debug, Clone, PartialEq)]
46pub struct VisionJudge {
47 pub provider: String,
49 pub model: Option<String>,
51}
52
53#[derive(Debug, Clone, PartialEq)]
60pub struct VisionVerdict {
61 pub status: VerdictStatus,
63 pub reason: String,
65 pub judge: Option<VisionJudge>,
68 pub observations: Vec<String>,
72}
73
74#[async_trait]
76pub trait VisionVerifier: Send + Sync {
77 async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict;
81}
82
83#[derive(Debug, Clone, Copy, Default)]
85pub struct StubVisionVerifier;
86
87#[async_trait]
88impl VisionVerifier for StubVisionVerifier {
89 async fn verify(&self, _request: VisionRequest<'_>) -> VisionVerdict {
90 VisionVerdict {
91 status: VerdictStatus::Unknown,
92 reason: STUB_REASON.to_owned(),
93 judge: None,
94 observations: Vec::new(),
95 }
96 }
97}
98
99pub const DEFAULT_VISION_MODEL: &str = "claude-opus-4-8";
103
104const REQUEST_TIMEOUT_SECS: u64 = 60;
110
111const CONNECT_TIMEOUT_SECS: u64 = 10;
114
115pub struct AnthropicVisionVerifier {
123 api_key: String,
124 model: String,
125 base_url: String,
126 client: reqwest::Client,
127}
128
129impl AnthropicVisionVerifier {
130 pub fn new(
132 api_key: impl Into<String>,
133 model: impl Into<String>,
134 base_url: impl Into<String>,
135 ) -> Self {
136 AnthropicVisionVerifier {
137 api_key: api_key.into(),
138 model: model.into(),
139 base_url: base_url.into(),
140 client: http_client(),
141 }
142 }
143
144 pub fn from_env() -> Option<Self> {
150 Self::from_lookup(|key| std::env::var(key).ok())
151 }
152
153 fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
156 let api_key = get("ANTHROPIC_API_KEY").filter(|key| !key.is_empty())?;
157 let model = get("POINTLOCK_VISION_MODEL")
158 .filter(|model| !model.is_empty())
159 .unwrap_or_else(|| DEFAULT_VISION_MODEL.to_owned());
160 let base_url = get("ANTHROPIC_BASE_URL")
161 .filter(|url| !url.is_empty())
162 .unwrap_or_else(|| "https://api.anthropic.com".to_owned());
163 Some(Self::new(api_key, model, base_url))
164 }
165
166 fn judge(&self) -> VisionJudge {
167 VisionJudge {
168 provider: "anthropic".to_owned(),
169 model: Some(self.model.clone()),
170 }
171 }
172
173 fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
174 VisionVerdict {
175 status: VerdictStatus::Unknown,
176 reason: reason.into(),
177 judge: Some(self.judge()),
178 observations: Vec::new(),
179 }
180 }
181}
182
183pub const MAX_OBSERVATIONS: usize = 16;
187pub const MAX_OBSERVATION_CHARS: usize = 300;
189
190fn verification_instruction(request: &VisionRequest<'_>) -> String {
196 let region_note = request.region.map_or(String::new(), |region| {
197 format!(
198 " Consider ONLY the region at x={}, y={}, width={}, height={} (pixels from the top-left).",
199 region.x, region.y, region.width, region.height
200 )
201 });
202 format!(
203 "You are a visual verification oracle for a device-automation audit trail. \
204 Judge the following claim against the screenshot.{region_note}\n\
205 Claim: {}\n\
206 Answer in EXACTLY this form and nothing else. First, zero or more lines, each:\n\
207 OBSERVED: <one concrete on-screen fact relevant to the claim>\n\
208 Then exactly one final line, one of:\n\
209 PASS: <what you see that confirms it>\n\
210 FAIL: <what you see that contradicts it>\n\
211 UNKNOWN: <why it cannot be determined>\n\
212 Answer UNKNOWN unless the claim is clearly confirmed or clearly contradicted.",
213 request.prompt
214 )
215}
216
217fn http_client() -> reqwest::Client {
223 reqwest::Client::builder()
224 .connect_timeout(std::time::Duration::from_secs(CONNECT_TIMEOUT_SECS))
225 .timeout(std::time::Duration::from_secs(REQUEST_TIMEOUT_SECS))
226 .no_proxy()
227 .build()
228 .expect("reqwest client construction")
229}
230
231fn parse_answer(text: &str, judge: &VisionJudge) -> VisionVerdict {
239 let mut observations: Vec<String> = Vec::new();
240 let mut verdict_line: Option<&str> = None;
241 for line in text.trim().lines().map(str::trim) {
242 if line.is_empty() {
243 continue;
244 }
245 if let Some(fact) = line.strip_prefix("OBSERVED:") {
246 if observations.len() < MAX_OBSERVATIONS {
247 observations.push(bounded_chars(fact.trim(), MAX_OBSERVATION_CHARS));
248 }
249 continue;
250 }
251 verdict_line = Some(line);
252 break;
253 }
254 let unknown = |reason: String, observations: Vec<String>| VisionVerdict {
255 status: VerdictStatus::Unknown,
256 reason,
257 judge: Some(judge.clone()),
258 observations,
259 };
260 let Some(first) = verdict_line else {
261 return unknown(
262 "the verifier answer carried no verdict line".to_owned(),
263 observations,
264 );
265 };
266 let (status, rest) = if let Some(rest) = first.strip_prefix("PASS:") {
267 (VerdictStatus::Pass, rest)
268 } else if let Some(rest) = first.strip_prefix("FAIL:") {
269 (VerdictStatus::Fail, rest)
270 } else if let Some(rest) = first.strip_prefix("UNKNOWN:") {
271 (VerdictStatus::Unknown, rest)
272 } else {
273 return unknown(
274 format!("unparseable verifier answer: {first:.120}"),
275 observations,
276 );
277 };
278 VisionVerdict {
279 status,
280 reason: format!("vision: {}", rest.trim()),
281 judge: Some(judge.clone()),
282 observations,
283 }
284}
285
286fn bounded_chars(value: &str, max_chars: usize) -> String {
288 if value.chars().count() <= max_chars {
289 return value.to_owned();
290 }
291 let mut bounded: String = value.chars().take(max_chars).collect();
292 bounded.push('…');
293 bounded
294}
295
296#[async_trait]
297impl VisionVerifier for AnthropicVisionVerifier {
298 async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
299 use base64::Engine as _;
300 let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
301 let instruction = verification_instruction(&request);
302 let body = serde_json::json!({
303 "model": self.model,
304 "max_tokens": 2048,
306 "messages": [{
307 "role": "user",
308 "content": [
309 { "type": "image", "source": {
310 "type": "base64",
311 "media_type": request.media_type,
312 "data": data,
313 }},
314 { "type": "text", "text": instruction },
315 ],
316 }],
317 });
318
319 let response = match self
320 .client
321 .post(format!("{}/v1/messages", self.base_url))
322 .header("x-api-key", &self.api_key)
323 .header("anthropic-version", "2023-06-01")
324 .json(&body)
325 .send()
326 .await
327 {
328 Ok(response) => response,
329 Err(err) => return self.unknown(format!("vision transport failed: {err}")),
330 };
331 if !response.status().is_success() {
332 let status = response.status();
333 let body = response.text().await.unwrap_or_default();
334 return self.unknown(format!(
335 "vision API answered {status}: {:.200}",
336 body.trim()
337 ));
338 }
339 let parsed: serde_json::Value = match response.json().await {
340 Ok(parsed) => parsed,
341 Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
342 };
343 let text: String = parsed
345 .get("content")
346 .and_then(|content| content.as_array())
347 .map(|blocks| {
348 blocks
349 .iter()
350 .filter(|block| block.get("type").and_then(|t| t.as_str()) == Some("text"))
351 .filter_map(|block| block.get("text").and_then(|t| t.as_str()))
352 .collect::<Vec<_>>()
353 .join("")
354 })
355 .unwrap_or_default();
356 if text.trim().is_empty() {
357 return self.unknown("vision response carried no text answer");
358 }
359 parse_answer(&text, &self.judge())
360 }
361}
362
363pub struct OpenAiCompatVisionVerifier {
373 api_key: Option<String>,
375 model: String,
376 base_url: String,
379 client: reqwest::Client,
380}
381
382impl OpenAiCompatVisionVerifier {
383 pub fn new(
385 api_key: Option<String>,
386 model: impl Into<String>,
387 base_url: impl Into<String>,
388 ) -> Self {
389 OpenAiCompatVisionVerifier {
390 api_key,
391 model: model.into(),
392 base_url: base_url.into(),
393 client: http_client(),
394 }
395 }
396
397 pub fn from_env() -> Option<Self> {
403 Self::from_lookup(|key| std::env::var(key).ok())
404 }
405
406 fn from_lookup(get: impl Fn(&str) -> Option<String>) -> Option<Self> {
409 let base_url = get("POINTLOCK_VISION_BASE_URL").filter(|url| !url.is_empty())?;
410 let model = get("POINTLOCK_VISION_MODEL").filter(|model| !model.is_empty())?;
411 let api_key = get("POINTLOCK_VISION_API_KEY").filter(|key| !key.is_empty());
412 Some(Self::new(api_key, model, base_url))
413 }
414
415 fn judge(&self) -> VisionJudge {
416 VisionJudge {
417 provider: "openai-compat".to_owned(),
418 model: Some(self.model.clone()),
419 }
420 }
421
422 fn unknown(&self, reason: impl Into<String>) -> VisionVerdict {
423 VisionVerdict {
424 status: VerdictStatus::Unknown,
425 reason: reason.into(),
426 judge: Some(self.judge()),
427 observations: Vec::new(),
428 }
429 }
430}
431
432#[async_trait]
433impl VisionVerifier for OpenAiCompatVisionVerifier {
434 async fn verify(&self, request: VisionRequest<'_>) -> VisionVerdict {
435 use base64::Engine as _;
436 let data = base64::engine::general_purpose::STANDARD.encode(request.screenshot);
437 let instruction = verification_instruction(&request);
438 let body = serde_json::json!({
439 "model": self.model,
440 "max_tokens": 2048,
442 "messages": [{
443 "role": "user",
444 "content": [
445 { "type": "image_url", "image_url": {
446 "url": format!("data:{};base64,{data}", request.media_type),
447 }},
448 { "type": "text", "text": instruction },
449 ],
450 }],
451 });
452
453 let mut post = self
454 .client
455 .post(format!("{}/chat/completions", self.base_url))
456 .json(&body);
457 if let Some(key) = &self.api_key {
458 post = post.bearer_auth(key);
459 }
460 let response = match post.send().await {
461 Ok(response) => response,
462 Err(err) => return self.unknown(format!("vision transport failed: {err}")),
463 };
464 if !response.status().is_success() {
465 let status = response.status();
466 let body = response.text().await.unwrap_or_default();
467 return self.unknown(format!(
468 "vision API answered {status}: {:.200}",
469 body.trim()
470 ));
471 }
472 let parsed: serde_json::Value = match response.json().await {
473 Ok(parsed) => parsed,
474 Err(err) => return self.unknown(format!("vision response unreadable: {err}")),
475 };
476 let content = &parsed["choices"][0]["message"]["content"];
480 let text: String = match content {
481 serde_json::Value::String(text) => text.clone(),
482 serde_json::Value::Array(parts) => parts
483 .iter()
484 .filter(|part| part.get("type").and_then(|t| t.as_str()) == Some("text"))
485 .filter_map(|part| part.get("text").and_then(|t| t.as_str()))
486 .collect::<Vec<_>>()
487 .join(""),
488 _ => String::new(),
489 };
490 if text.trim().is_empty() {
491 return self.unknown("vision response carried no text answer");
492 }
493 parse_answer(&text, &self.judge())
494 }
495}
496
497#[cfg(test)]
498mod tests {
499 use super::*;
500
501 #[test]
502 fn from_lookup_requires_a_nonempty_api_key() {
503 assert!(AnthropicVisionVerifier::from_lookup(|_| None).is_none());
504 assert!(
505 AnthropicVisionVerifier::from_lookup(|key| {
506 (key == "ANTHROPIC_API_KEY").then(String::new)
507 })
508 .is_none()
509 );
510 }
511
512 #[test]
513 fn from_lookup_honors_the_model_override_and_defaults_without_it() {
514 let with_override = AnthropicVisionVerifier::from_lookup(|key| match key {
515 "ANTHROPIC_API_KEY" => Some("k".to_owned()),
516 "POINTLOCK_VISION_MODEL" => Some("claude-haiku-4-5".to_owned()),
517 _ => None,
518 })
519 .expect("key present");
520 assert_eq!(with_override.model, "claude-haiku-4-5");
521
522 let defaulted = AnthropicVisionVerifier::from_lookup(|key| {
523 (key == "ANTHROPIC_API_KEY").then(|| "k".to_owned())
524 })
525 .expect("key present");
526 assert_eq!(defaulted.model, DEFAULT_VISION_MODEL);
527 assert_eq!(defaulted.base_url, "https://api.anthropic.com");
528 }
529
530 #[tokio::test]
531 async fn stub_always_answers_unknown_with_the_fixed_reason() {
532 let verdict = StubVisionVerifier
533 .verify(VisionRequest {
534 prompt: "the Wi-Fi toggle is visible",
535 region: None,
536 screenshot: b"png-bytes",
537 media_type: "image/png",
538 })
539 .await;
540 assert_eq!(verdict.status, VerdictStatus::Unknown);
541 assert_eq!(verdict.reason, STUB_REASON);
542 assert_eq!(verdict.judge, None);
544 assert!(verdict.observations.is_empty());
545 }
546
547 fn judge() -> VisionJudge {
548 VisionJudge {
549 provider: "test".to_owned(),
550 model: Some("test-model".to_owned()),
551 }
552 }
553
554 #[test]
555 fn parse_collects_observations_ahead_of_the_verdict() {
556 let verdict = parse_answer(
557 "OBSERVED: the SSID field shows HomeWifi\n\
558 OBSERVED: the connect button is enabled\n\
559 PASS: the field shows the requested name",
560 &judge(),
561 );
562 assert_eq!(verdict.status, VerdictStatus::Pass);
563 assert_eq!(verdict.reason, "vision: the field shows the requested name");
564 assert_eq!(verdict.judge, Some(judge()));
565 assert_eq!(
566 verdict.observations,
567 vec![
568 "the SSID field shows HomeWifi".to_owned(),
569 "the connect button is enabled".to_owned(),
570 ]
571 );
572 }
573
574 #[test]
575 fn parse_accepts_a_bare_verdict_without_observed_lines() {
576 let verdict = parse_answer("FAIL: the field is empty", &judge());
577 assert_eq!(verdict.status, VerdictStatus::Fail);
578 assert_eq!(verdict.judge, Some(judge()));
579 assert!(verdict.observations.is_empty());
580 }
581
582 #[test]
583 fn parse_fails_closed_but_keeps_observations_without_a_verdict() {
584 let missing = parse_answer("OBSERVED: a dialog covers the screen", &judge());
585 assert_eq!(missing.status, VerdictStatus::Unknown);
586 assert!(
587 missing.reason.contains("no verdict line"),
588 "{}",
589 missing.reason
590 );
591 assert_eq!(
592 missing.observations,
593 vec!["a dialog covers the screen".to_owned()]
594 );
595
596 let chatty = parse_answer(
597 "OBSERVED: a dialog covers the screen\nSure! I believe it passes.",
598 &judge(),
599 );
600 assert_eq!(chatty.status, VerdictStatus::Unknown);
601 assert!(chatty.reason.contains("unparseable"), "{}", chatty.reason);
602 assert_eq!(
603 chatty.observations,
604 vec!["a dialog covers the screen".to_owned()]
605 );
606 }
607
608 #[test]
609 fn observation_bounds_cap_count_and_length_on_char_boundaries() {
610 let mut answer = String::new();
611 for index in 0..(MAX_OBSERVATIONS + 3) {
612 answer.push_str(&format!("OBSERVED: fact {index}\n"));
613 }
614 answer.push_str("PASS: ok");
615 let verdict = parse_answer(&answer, &judge());
616 assert_eq!(verdict.observations.len(), MAX_OBSERVATIONS);
617
618 let long = format!(
620 "OBSERVED: {}\nPASS: ok",
621 "界".repeat(MAX_OBSERVATION_CHARS + 5)
622 );
623 let verdict = parse_answer(&long, &judge());
624 let kept = &verdict.observations[0];
625 assert_eq!(kept.chars().count(), MAX_OBSERVATION_CHARS + 1);
626 assert!(kept.ends_with('…'));
627 }
628
629 #[test]
630 fn openai_from_lookup_requires_base_url_and_model_with_the_key_optional() {
631 assert!(OpenAiCompatVisionVerifier::from_lookup(|_| None).is_none());
632 assert!(
633 OpenAiCompatVisionVerifier::from_lookup(|key| {
634 (key == "POINTLOCK_VISION_BASE_URL").then(|| "http://127.0.0.1:1/v1".to_owned())
635 })
636 .is_none()
637 );
638 let keyless = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
639 "POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
640 "POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
641 _ => None,
642 })
643 .expect("base url and model present");
644 assert_eq!(keyless.api_key, None);
645 assert_eq!(keyless.model, "qwen2.5-vl");
646 let keyed = OpenAiCompatVisionVerifier::from_lookup(|key| match key {
647 "POINTLOCK_VISION_BASE_URL" => Some("http://127.0.0.1:1/v1".to_owned()),
648 "POINTLOCK_VISION_MODEL" => Some("qwen2.5-vl".to_owned()),
649 "POINTLOCK_VISION_API_KEY" => Some("k".to_owned()),
650 _ => None,
651 })
652 .expect("all present");
653 assert_eq!(keyed.api_key.as_deref(), Some("k"));
654 }
655}