1use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10use std::collections::BTreeSet;
11
12#[cfg(test)]
13use super::{
14 SemanticExpansionHandle, SemanticObservation, SemanticPage, SemanticRegion, SemanticTarget,
15};
16use super::{SemanticPageKind, SemanticRegionKind, SemanticRouteIdentity};
17
18pub const INTENT_RESOLUTION_SCHEMA_VERSION: u32 = 1;
19const MAX_INTENT_BYTES: usize = 512;
20const MAX_ACTION_BYTES: usize = 64;
21const MAX_ID_BYTES: usize = 128;
22const MAX_LABEL_BYTES: usize = 256;
23const MAX_EVIDENCE_ITEMS: usize = 8;
24const MAX_EVIDENCE_BYTES: usize = 160;
25const MAX_EXCLUDE_TEXT: usize = 8;
26const MAX_CANDIDATES: usize = 32;
27const MAX_SUGGESTIONS: usize = 8;
28const MAX_EXECUTION_VALUE_BYTES: usize = 4_096;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "lowercase")]
33pub enum SemanticIntentAction {
34 Click,
35 Type,
36 Clear,
37 Check,
38 Uncheck,
39 Select,
40 Submit,
41 Open,
42 Close,
43 Search,
44 Filter,
45 Sort,
46 Paginate,
47 Toggle,
48 Expand,
49 Collapse,
50 Download,
51 Upload,
52 Inspect,
53 Extract,
54}
55
56#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59#[serde(rename_all = "camelCase", deny_unknown_fields)]
60pub struct SemanticIntentExecutionRequest {
61 pub request: SemanticIntentRequest,
62 pub candidate_id: String,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub value: Option<String>,
65}
66
67impl SemanticIntentExecutionRequest {
68 pub fn validate(&self) -> Result<(), IntentResolutionError> {
69 self.request.validate()?;
70 validate_text("candidateId", &self.candidate_id, MAX_ID_BYTES, false)?;
71 if let Some(value) = &self.value {
72 validate_text("value", value, MAX_EXECUTION_VALUE_BYTES, true)?;
73 }
74 match self.request.action {
75 SemanticIntentAction::Type | SemanticIntentAction::Select
76 if self.value.as_deref().is_none_or(str::is_empty) =>
77 {
78 return Err(IntentResolutionError::new(
79 "value",
80 "this action requires a non-empty value",
81 ));
82 }
83 SemanticIntentAction::Click
84 | SemanticIntentAction::Clear
85 | SemanticIntentAction::Check
86 | SemanticIntentAction::Uncheck
87 | SemanticIntentAction::Submit
88 | SemanticIntentAction::Open
89 | SemanticIntentAction::Close
90 | SemanticIntentAction::Search
91 | SemanticIntentAction::Filter
92 | SemanticIntentAction::Sort
93 | SemanticIntentAction::Paginate
94 | SemanticIntentAction::Expand
95 | SemanticIntentAction::Collapse
96 if self.value.is_some() =>
97 {
98 return Err(IntentResolutionError::new(
99 "value",
100 "this action does not accept a value",
101 ));
102 }
103 SemanticIntentAction::Toggle
104 | SemanticIntentAction::Download
105 | SemanticIntentAction::Upload
106 | SemanticIntentAction::Inspect
107 | SemanticIntentAction::Extract => {
108 return Err(IntentResolutionError::new(
109 "action",
110 "this intent action is not supported by the guarded execution boundary yet",
111 ));
112 }
113 SemanticIntentAction::Type | SemanticIntentAction::Select => {}
114 SemanticIntentAction::Click
115 | SemanticIntentAction::Clear
116 | SemanticIntentAction::Check
117 | SemanticIntentAction::Uncheck
118 | SemanticIntentAction::Submit
119 | SemanticIntentAction::Open
120 | SemanticIntentAction::Close
121 | SemanticIntentAction::Search
122 | SemanticIntentAction::Filter
123 | SemanticIntentAction::Sort
124 | SemanticIntentAction::Paginate
125 | SemanticIntentAction::Expand
126 | SemanticIntentAction::Collapse => {}
127 }
128 Ok(())
129 }
130
131 pub fn from_json(input: &str) -> Result<Self, IntentResolutionError> {
132 let request: Self = serde_json::from_str(input).map_err(|error| {
133 IntentResolutionError::new(
134 "$",
135 format!("invalid intent execution request shape: {error}"),
136 )
137 })?;
138 request.validate()?;
139 Ok(request)
140 }
141
142 pub fn to_canonical_json(&self) -> Result<String, IntentResolutionError> {
143 self.validate()?;
144 serde_json::to_string(self)
145 .map_err(|error| IntentResolutionError::new("$", error.to_string()))
146 }
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
151#[serde(rename_all = "snake_case")]
152pub enum SemanticIntentExecutionStatus {
153 Executed,
154 NotExecuted,
155}
156
157#[derive(Debug, Clone, Serialize)]
159#[serde(rename_all = "camelCase")]
160pub struct SemanticIntentExecutionResult {
161 pub resolution_id: String,
162 pub candidate_id: String,
163 pub status: SemanticIntentExecutionStatus,
164 pub resolution: SemanticIntentResult,
165 #[serde(skip_serializing_if = "Option::is_none")]
166 pub action: Option<super::ActionOutcome>,
167 #[serde(skip_serializing_if = "Option::is_none")]
168 pub execution_id: Option<String>,
169 #[serde(skip_serializing_if = "Option::is_none")]
170 pub reason: Option<String>,
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub enum SemanticIntentPurpose {
177 Activate,
178 Open,
179 Continue,
180 Submit,
181 Cancel,
182 Close,
183 Search,
184 Filter,
185 Sort,
186 PaginationNext,
187 PaginationPrevious,
188 Select,
189 Check,
190 Uncheck,
191 Toggle,
192 Enter,
193 Clear,
194 Replace,
195 Expand,
196 Collapse,
197 Download,
198 Upload,
199 Choose,
200 Inspect,
201 Extract,
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase", deny_unknown_fields)]
207pub struct NormalizedSemanticIntent {
208 pub canonical: String,
209 pub purpose: SemanticIntentPurpose,
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
211 pub terms: Vec<String>,
212}
213
214pub fn normalize_intent(
216 request: &SemanticIntentRequest,
217) -> Result<NormalizedSemanticIntent, IntentResolutionError> {
218 request.validate()?;
219 let normalized = request
220 .intent
221 .split_whitespace()
222 .collect::<Vec<_>>()
223 .join(" ")
224 .to_ascii_lowercase();
225 if normalized.is_empty()
226 || matches!(
227 normalized.as_str(),
228 "do it" | "do that" | "handle this" | "click it" | "use it"
229 )
230 {
231 return Err(IntentResolutionError::new(
232 "intent",
233 "unsupportedIntent: provide a concrete purpose or target constraint",
234 ));
235 }
236
237 let (purpose, canonical, terms) =
238 if normalized == "next" || normalized == "next page" || normalized == "go to the next page"
239 {
240 (
241 SemanticIntentPurpose::PaginationNext,
242 "paginate next".into(),
243 Vec::new(),
244 )
245 } else if normalized == "previous"
246 || normalized == "previous page"
247 || normalized == "go to the previous page"
248 {
249 (
250 SemanticIntentPurpose::PaginationPrevious,
251 "paginate previous".into(),
252 Vec::new(),
253 )
254 } else if let Some(value) = normalized.strip_prefix("search for ") {
255 concrete_phrase(
256 request.action,
257 SemanticIntentPurpose::Search,
258 "search",
259 value,
260 )?
261 } else if let Some(value) = normalized.strip_prefix("filter by ") {
262 concrete_phrase(
263 request.action,
264 SemanticIntentPurpose::Filter,
265 "filter",
266 value,
267 )?
268 } else if let Some(value) = normalized.strip_prefix("sort by ") {
269 concrete_phrase(request.action, SemanticIntentPurpose::Sort, "sort", value)?
270 } else if normalized.starts_with("continue ") {
271 compatible_phrase(
272 request.action,
273 SemanticIntentPurpose::Continue,
274 normalized.clone(),
275 )?
276 } else if normalized.starts_with("open ") {
277 compatible_phrase(
278 request.action,
279 SemanticIntentPurpose::Open,
280 normalized.clone(),
281 )?
282 } else if normalized == "submit" || normalized.starts_with("submit ") {
283 compatible_phrase(
284 request.action,
285 SemanticIntentPurpose::Submit,
286 normalized.clone(),
287 )?
288 } else if normalized == "cancel" || normalized.starts_with("cancel ") {
289 compatible_phrase(
290 request.action,
291 SemanticIntentPurpose::Cancel,
292 normalized.clone(),
293 )?
294 } else if normalized == "close" || normalized.starts_with("close ") {
295 compatible_phrase(
296 request.action,
297 SemanticIntentPurpose::Close,
298 normalized.clone(),
299 )?
300 } else {
301 let purpose = purpose_for_action(request.action);
302 let canonical = normalized.clone();
303 let terms = terms_from(&normalized);
304 (purpose, canonical, terms)
305 };
306
307 validate_text("normalizedIntent", &canonical, MAX_INTENT_BYTES, false)?;
308 Ok(NormalizedSemanticIntent {
309 canonical,
310 purpose,
311 terms,
312 })
313}
314
315pub fn resolve_intent(
317 request: &SemanticIntentRequest,
318 observation: &super::SemanticObservation,
319) -> SemanticIntentResult {
320 resolve_intent_with_historical_matches(request, observation, &BTreeSet::new())
321}
322
323pub fn resolve_intent_with_historical_matches(
328 request: &SemanticIntentRequest,
329 observation: &super::SemanticObservation,
330 historical_fingerprints: &BTreeSet<String>,
331) -> SemanticIntentResult {
332 let normalized = match normalize_intent(request) {
333 Ok(normalized) => normalized,
334 Err(error) => {
335 let normalized_intent = request
336 .intent
337 .split_whitespace()
338 .collect::<Vec<_>>()
339 .join(" ")
340 .to_ascii_lowercase();
341 return result_for(
342 request,
343 observation,
344 IntentResultParts {
345 normalized_intent: if normalized_intent.is_empty() {
346 "unsupported".into()
347 } else {
348 normalized_intent
349 },
350 resolution: SemanticResolution::UnsupportedIntent,
351 policy_decision: IntentPolicyDecision::Rejected,
352 candidates: Vec::new(),
353 excluded_candidates: Vec::new(),
354 selected_candidate: None,
355 suggested_constraints: Vec::new(),
356 reason: Some(error.reason),
357 },
358 );
359 }
360 };
361 if request
362 .expected_revision
363 .is_some_and(|revision| revision != observation.revision)
364 {
365 return result_for(
366 request,
367 observation,
368 IntentResultParts {
369 normalized_intent: normalized.canonical,
370 resolution: SemanticResolution::StaleRevision,
371 policy_decision: IntentPolicyDecision::Rejected,
372 candidates: Vec::new(),
373 excluded_candidates: Vec::new(),
374 selected_candidate: None,
375 suggested_constraints: Vec::new(),
376 reason: Some("expected revision is no longer current".into()),
377 },
378 );
379 }
380
381 let mut candidates = Vec::new();
382 let mut excluded = Vec::new();
383 let mut candidate_index = 0usize;
384 for region in &observation.regions {
385 for target in ®ion.targets {
386 let id = format!("candidate_{}", candidate_index + 1);
387 candidate_index += 1;
388 let (include, evidence, excluded_reason) = score_candidate(
389 request,
390 &normalized,
391 observation,
392 region,
393 target,
394 historical_fingerprints,
395 );
396 if !include {
397 if excluded.len() < request.constraints.max_candidates {
398 excluded.push(ExcludedIntentCandidate {
399 id,
400 reason: excluded_reason.unwrap_or(IntentEvidence {
401 category: IntentEvidenceCategory::NegativeConflict,
402 detail: "candidate did not satisfy the declared constraints".into(),
403 }),
404 });
405 }
406 continue;
407 }
408 if candidates.len() >= request.constraints.max_candidates {
409 continue;
410 }
411 let confidence = confidence_for(&evidence);
412 candidates.push(SemanticIntentCandidate {
413 id,
414 reference: target.reference.clone(),
415 role: target.role.clone(),
416 name: target.name.clone(),
417 input_type: target.input_type.clone(),
418 region_id: Some(region.id.clone()),
419 region_kind: Some(region.kind),
420 confidence,
421 evidence,
422 fingerprint: Some(SemanticTargetFingerprint {
423 revision: observation.revision,
424 route: observation.route.clone(),
425 role: target.role.clone(),
426 name: target.name.clone(),
427 input_type: target.input_type.clone(),
428 region_id: Some(region.id.clone()),
429 region_kind: Some(region.kind),
430 purpose: normalized.purpose,
431 invalidated_by: vec![
432 FingerprintInvalidation::Revision,
433 FingerprintInvalidation::Route,
434 FingerprintInvalidation::Role,
435 FingerprintInvalidation::Name,
436 FingerprintInvalidation::Region,
437 ],
438 }),
439 });
440 }
441 }
442
443 let classification = classify_resolution(&candidates);
444 let (resolution, policy_decision, selected_candidate, reason) =
445 apply_resolution_policy(request.resolution_policy, classification, &candidates);
446 let suggested_constraints = suggestions_for(&candidates);
447 result_for(
448 request,
449 observation,
450 IntentResultParts {
451 normalized_intent: normalized.canonical,
452 resolution,
453 policy_decision,
454 candidates,
455 excluded_candidates: excluded,
456 selected_candidate,
457 suggested_constraints,
458 reason,
459 },
460 )
461}
462
463fn score_candidate(
464 request: &SemanticIntentRequest,
465 normalized: &NormalizedSemanticIntent,
466 observation: &super::SemanticObservation,
467 region: &super::SemanticRegion,
468 target: &super::SemanticTarget,
469 historical_fingerprints: &BTreeSet<String>,
470) -> (bool, Vec<IntentEvidence>, Option<IntentEvidence>) {
471 if request.constraints.must_be_visible || request.constraints.must_be_enabled {
472 return (
473 false,
474 Vec::new(),
475 Some(IntentEvidence {
476 category: IntentEvidenceCategory::PolicyExclusion,
477 detail: "visibility or enabled state requires actionability validation".into(),
478 }),
479 );
480 }
481 if request
482 .scope
483 .page_kind
484 .is_some_and(|kind| kind != observation.page.kind)
485 {
486 return (
487 false,
488 Vec::new(),
489 Some(IntentEvidence {
490 category: IntentEvidenceCategory::NegativeConflict,
491 detail: "page kind is outside the requested scope".into(),
492 }),
493 );
494 }
495 if request
496 .scope
497 .region_kind
498 .is_some_and(|kind| kind != region.kind)
499 || request
500 .scope
501 .region_id
502 .as_deref()
503 .is_some_and(|id| id != region.id)
504 {
505 return (
506 false,
507 Vec::new(),
508 Some(IntentEvidence {
509 category: IntentEvidenceCategory::NegativeConflict,
510 detail: "region is outside the requested scope".into(),
511 }),
512 );
513 }
514 let name_lower = target.name.to_ascii_lowercase();
515 if request
516 .constraints
517 .exclude_text
518 .iter()
519 .any(|value| name_lower.contains(&value.to_ascii_lowercase()))
520 {
521 return (
522 false,
523 Vec::new(),
524 Some(IntentEvidence {
525 category: IntentEvidenceCategory::NegativeConflict,
526 detail: "accessible name contains excluded text".into(),
527 }),
528 );
529 }
530 if request
531 .constraints
532 .role
533 .as_deref()
534 .is_some_and(|role| role != target.role)
535 {
536 return (
537 false,
538 Vec::new(),
539 Some(IntentEvidence {
540 category: IntentEvidenceCategory::NegativeConflict,
541 detail: "role does not match the declared constraint".into(),
542 }),
543 );
544 }
545 if request
546 .constraints
547 .name
548 .as_deref()
549 .is_some_and(|name| name != target.name)
550 {
551 return (
552 false,
553 Vec::new(),
554 Some(IntentEvidence {
555 category: IntentEvidenceCategory::NegativeConflict,
556 detail: "accessible name does not match the declared constraint".into(),
557 }),
558 );
559 }
560 if request
561 .constraints
562 .name_contains
563 .as_deref()
564 .is_some_and(|value| !name_lower.contains(&value.to_ascii_lowercase()))
565 {
566 return (
567 false,
568 Vec::new(),
569 Some(IntentEvidence {
570 category: IntentEvidenceCategory::NegativeConflict,
571 detail: "accessible name does not contain the declared constraint".into(),
572 }),
573 );
574 }
575
576 let mut evidence = vec![IntentEvidence {
577 category: IntentEvidenceCategory::RouteMatch,
578 detail: "candidate belongs to the observed target and frame route".into(),
579 }];
580 if request.constraints.role.is_some() {
581 evidence.push(IntentEvidence {
582 category: IntentEvidenceCategory::ExactRole,
583 detail: "role matches the declared constraint".into(),
584 });
585 }
586 if request.constraints.name.is_some() {
587 evidence.push(IntentEvidence {
588 category: IntentEvidenceCategory::ExactName,
589 detail: "accessible name matches the declared constraint".into(),
590 });
591 }
592 if request.constraints.name_contains.is_some()
593 || normalized
594 .terms
595 .iter()
596 .any(|term| name_lower.contains(term))
597 || synonyms_match(normalized, &name_lower)
598 {
599 evidence.push(IntentEvidence {
600 category: if request.constraints.name_contains.is_some() {
601 IntentEvidenceCategory::ExactName
602 } else {
603 IntentEvidenceCategory::SemanticName
604 },
605 detail: "bounded intent terms match the accessible name".into(),
606 });
607 }
608 if request.scope.region_kind.is_some() || request.scope.region_id.is_some() {
609 evidence.push(IntentEvidence {
610 category: IntentEvidenceCategory::RegionMatch,
611 detail: "candidate belongs to the requested semantic region".into(),
612 });
613 }
614 if historical_fingerprints.contains(&target_fingerprint_digest(
615 &target.role,
616 &target.name,
617 target.input_type.as_deref(),
618 Some(region.kind),
619 normalized.purpose,
620 )) {
621 evidence.push(IntentEvidence {
622 category: IntentEvidenceCategory::HistoricalMatch,
623 detail: "eligible scoped knowledge matches this current candidate fingerprint".into(),
624 });
625 }
626 (true, evidence, None)
627}
628
629fn synonyms_match(normalized: &NormalizedSemanticIntent, name: &str) -> bool {
630 let synonyms: &[(&str, &[&str])] = &[
631 ("settings", &["preferences", "configuration", "options"]),
632 ("continue", &["proceed", "next"]),
633 ("close", &["dismiss", "exit"]),
634 ("open", &["view", "show"]),
635 ("search", &["find", "lookup"]),
636 ];
637 normalized.terms.iter().any(|term| {
638 synonyms
639 .iter()
640 .find(|(source, _)| source == term)
641 .is_some_and(|(_, alternatives)| alternatives.iter().any(|value| name.contains(value)))
642 })
643}
644
645fn confidence_for(evidence: &[IntentEvidence]) -> IntentConfidence {
646 let has = |category| evidence.iter().any(|item| item.category == category);
647 if has(IntentEvidenceCategory::ExactName) && has(IntentEvidenceCategory::ExactRole) {
648 IntentConfidence::Exact
649 } else if has(IntentEvidenceCategory::ExactName)
650 || (has(IntentEvidenceCategory::SemanticName) && has(IntentEvidenceCategory::RegionMatch))
651 {
652 IntentConfidence::High
653 } else if has(IntentEvidenceCategory::SemanticName) || has(IntentEvidenceCategory::RegionMatch)
654 {
655 IntentConfidence::Medium
656 } else {
657 IntentConfidence::Low
658 }
659}
660
661fn classify_resolution(candidates: &[SemanticIntentCandidate]) -> SemanticResolution {
662 match candidates {
663 [] => SemanticResolution::NotFound,
664 [candidate] => match candidate.confidence {
665 IntentConfidence::Exact => SemanticResolution::Exact,
666 IntentConfidence::High => SemanticResolution::UniqueHighConfidence,
667 IntentConfidence::Medium | IntentConfidence::Low | IntentConfidence::Insufficient => {
668 SemanticResolution::UniqueLowConfidence
669 }
670 },
671 _ => SemanticResolution::Ambiguous,
672 }
673}
674
675fn apply_resolution_policy(
676 policy: SemanticResolutionPolicy,
677 resolution: SemanticResolution,
678 candidates: &[SemanticIntentCandidate],
679) -> (
680 SemanticResolution,
681 IntentPolicyDecision,
682 Option<String>,
683 Option<String>,
684) {
685 if matches!(policy, SemanticResolutionPolicy::ReportOnly) {
686 return (
687 resolution,
688 IntentPolicyDecision::ReportOnly,
689 None,
690 Some("reportOnly never dispatches an action".into()),
691 );
692 }
693 if matches!(policy, SemanticResolutionPolicy::InteractiveConfirmation) {
694 return (
695 resolution,
696 IntentPolicyDecision::ConfirmationRequired,
697 None,
698 Some("caller confirmation is required before dispatch".into()),
699 );
700 }
701 let allowed = match policy {
702 SemanticResolutionPolicy::RequireExact => resolution == SemanticResolution::Exact,
703 SemanticResolutionPolicy::RequireUniqueHighConfidence => matches!(
704 resolution,
705 SemanticResolution::Exact | SemanticResolution::UniqueHighConfidence
706 ),
707 SemanticResolutionPolicy::AllowUniqueMediumConfidence => {
708 matches!(
709 resolution,
710 SemanticResolution::Exact
711 | SemanticResolution::UniqueHighConfidence
712 | SemanticResolution::UniqueLowConfidence
713 ) && candidates.first().is_some_and(|candidate| {
714 !matches!(
715 candidate.confidence,
716 IntentConfidence::Low | IntentConfidence::Insufficient
717 )
718 })
719 }
720 SemanticResolutionPolicy::ReportOnly
721 | SemanticResolutionPolicy::InteractiveConfirmation => false,
722 };
723 if allowed {
724 (
725 resolution,
726 IntentPolicyDecision::Allowed,
727 candidates.first().map(|candidate| candidate.id.clone()),
728 None,
729 )
730 } else if matches!(resolution, SemanticResolution::UniqueLowConfidence) {
731 (
732 SemanticResolution::PolicyRejected,
733 IntentPolicyDecision::Rejected,
734 None,
735 Some("candidate did not meet the declared confidence policy".into()),
736 )
737 } else {
738 (
739 resolution,
740 IntentPolicyDecision::Rejected,
741 None,
742 Some("resolution did not produce one policy-eligible candidate".into()),
743 )
744 }
745}
746
747struct IntentResultParts {
748 normalized_intent: String,
749 resolution: SemanticResolution,
750 policy_decision: IntentPolicyDecision,
751 candidates: Vec<SemanticIntentCandidate>,
752 excluded_candidates: Vec<ExcludedIntentCandidate>,
753 selected_candidate: Option<String>,
754 suggested_constraints: Vec<IntentConstraintSuggestion>,
755 reason: Option<String>,
756}
757
758fn suggestions_for(candidates: &[SemanticIntentCandidate]) -> Vec<IntentConstraintSuggestion> {
759 if candidates.len() < 2 {
760 return Vec::new();
761 }
762 let mut suggestions = Vec::new();
763 let mut region_kinds = Vec::new();
764 for candidate in candidates {
765 if let Some(kind) = candidate.region_kind
766 && !region_kinds.contains(&kind)
767 {
768 region_kinds.push(kind);
769 }
770 }
771 if region_kinds.len() > 1 {
772 for region_kind in region_kinds {
773 suggestions.push(IntentConstraintSuggestion {
774 region_kind: Some(region_kind),
775 ..IntentConstraintSuggestion::default()
776 });
777 }
778 }
779 let mut roles = Vec::new();
780 for candidate in candidates {
781 if !roles.contains(&candidate.role) {
782 roles.push(candidate.role.clone());
783 }
784 }
785 if roles.len() > 1 {
786 for role in roles {
787 suggestions.push(IntentConstraintSuggestion {
788 role: Some(role),
789 ..IntentConstraintSuggestion::default()
790 });
791 }
792 }
793 suggestions.truncate(MAX_SUGGESTIONS);
794 suggestions
795}
796
797fn result_for(
798 request: &SemanticIntentRequest,
799 observation: &super::SemanticObservation,
800 parts: IntentResultParts,
801) -> SemanticIntentResult {
802 let result = SemanticIntentResult {
803 schema_version: INTENT_RESOLUTION_SCHEMA_VERSION,
804 intent: request.intent.clone(),
805 action: request.action,
806 normalized_intent: parts.normalized_intent,
807 resolution: parts.resolution,
808 policy_decision: parts.policy_decision,
809 route: Some(observation.route.clone()),
810 revision: Some(observation.revision),
811 excluded_count: parts.excluded_candidates.len(),
812 candidates: parts.candidates,
813 excluded_candidates: parts.excluded_candidates,
814 selected_candidate: parts.selected_candidate,
815 suggested_constraints: parts.suggested_constraints,
816 reason: parts.reason,
817 };
818 debug_assert!(result.validate().is_ok());
819 result
820}
821
822fn concrete_phrase(
823 action: SemanticIntentAction,
824 purpose: SemanticIntentPurpose,
825 verb: &str,
826 value: &str,
827) -> Result<(SemanticIntentPurpose, String, Vec<String>), IntentResolutionError> {
828 if value.trim().is_empty() {
829 return Err(IntentResolutionError::new(
830 "intent",
831 "unsupportedIntent: the phrase needs a bounded value",
832 ));
833 }
834 let compatible = match purpose {
835 SemanticIntentPurpose::Search => {
836 matches!(
837 action,
838 SemanticIntentAction::Search | SemanticIntentAction::Click
839 )
840 }
841 SemanticIntentPurpose::Filter => {
842 matches!(
843 action,
844 SemanticIntentAction::Filter | SemanticIntentAction::Click
845 )
846 }
847 SemanticIntentPurpose::Sort => {
848 matches!(
849 action,
850 SemanticIntentAction::Sort | SemanticIntentAction::Click
851 )
852 }
853 _ => false,
854 };
855 if !compatible {
856 return Err(IntentResolutionError::new(
857 "action",
858 "action is incompatible with the normalized intent purpose",
859 ));
860 }
861 Ok((
862 purpose,
863 format!("{verb} {}", value.trim()),
864 terms_from(value),
865 ))
866}
867
868fn compatible_phrase(
869 action: SemanticIntentAction,
870 purpose: SemanticIntentPurpose,
871 canonical: String,
872) -> Result<(SemanticIntentPurpose, String, Vec<String>), IntentResolutionError> {
873 let compatible = match purpose {
874 SemanticIntentPurpose::Open | SemanticIntentPurpose::Continue => {
875 matches!(
876 action,
877 SemanticIntentAction::Click | SemanticIntentAction::Open
878 )
879 }
880 SemanticIntentPurpose::Submit => {
881 matches!(
882 action,
883 SemanticIntentAction::Click | SemanticIntentAction::Submit
884 )
885 }
886 SemanticIntentPurpose::Cancel | SemanticIntentPurpose::Close => {
887 matches!(
888 action,
889 SemanticIntentAction::Click | SemanticIntentAction::Close
890 )
891 }
892 _ => false,
893 };
894 if !compatible {
895 return Err(IntentResolutionError::new(
896 "action",
897 "action is incompatible with the normalized intent purpose",
898 ));
899 }
900 Ok((purpose, canonical.clone(), terms_from(&canonical)))
901}
902
903fn purpose_for_action(action: SemanticIntentAction) -> SemanticIntentPurpose {
904 match action {
905 SemanticIntentAction::Click => SemanticIntentPurpose::Activate,
906 SemanticIntentAction::Type => SemanticIntentPurpose::Enter,
907 SemanticIntentAction::Clear => SemanticIntentPurpose::Clear,
908 SemanticIntentAction::Check => SemanticIntentPurpose::Check,
909 SemanticIntentAction::Uncheck => SemanticIntentPurpose::Uncheck,
910 SemanticIntentAction::Select => SemanticIntentPurpose::Select,
911 SemanticIntentAction::Submit => SemanticIntentPurpose::Submit,
912 SemanticIntentAction::Open => SemanticIntentPurpose::Open,
913 SemanticIntentAction::Close => SemanticIntentPurpose::Close,
914 SemanticIntentAction::Search => SemanticIntentPurpose::Search,
915 SemanticIntentAction::Filter => SemanticIntentPurpose::Filter,
916 SemanticIntentAction::Sort => SemanticIntentPurpose::Sort,
917 SemanticIntentAction::Paginate => SemanticIntentPurpose::PaginationNext,
918 SemanticIntentAction::Toggle => SemanticIntentPurpose::Toggle,
919 SemanticIntentAction::Expand => SemanticIntentPurpose::Expand,
920 SemanticIntentAction::Collapse => SemanticIntentPurpose::Collapse,
921 SemanticIntentAction::Download => SemanticIntentPurpose::Download,
922 SemanticIntentAction::Upload => SemanticIntentPurpose::Upload,
923 SemanticIntentAction::Inspect => SemanticIntentPurpose::Inspect,
924 SemanticIntentAction::Extract => SemanticIntentPurpose::Extract,
925 }
926}
927
928fn terms_from(value: &str) -> Vec<String> {
929 value
930 .split_whitespace()
931 .filter(|term| !matches!(*term, "the" | "a" | "an" | "to" | "for" | "by"))
932 .take(8)
933 .map(|term| term.to_string())
934 .collect()
935}
936
937#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
939#[serde(rename_all = "camelCase")]
940pub enum SemanticResolution {
941 Exact,
942 UniqueHighConfidence,
943 UniqueLowConfidence,
944 Ambiguous,
945 NotFound,
946 StaleRevision,
947 PolicyRejected,
948 UnsupportedIntent,
949}
950
951#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
953#[serde(rename_all = "lowercase")]
954pub enum IntentConfidence {
955 Exact,
956 High,
957 Medium,
958 Low,
959 Insufficient,
960}
961
962#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
965#[serde(rename_all = "camelCase")]
966pub enum IntentEvidenceCategory {
967 ExactRole,
968 ExactName,
969 SemanticName,
970 RegionMatch,
971 FormRelationship,
972 HeadingContext,
973 StateMatch,
974 RouteMatch,
975 WorkflowContext,
976 HistoricalMatch,
977 NegativeConflict,
978 PolicyExclusion,
979}
980
981#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
983#[serde(rename_all = "camelCase", deny_unknown_fields)]
984pub struct IntentEvidence {
985 pub category: IntentEvidenceCategory,
986 pub detail: String,
987}
988
989#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
991#[serde(rename_all = "camelCase", deny_unknown_fields)]
992pub struct IntentScope {
993 #[serde(default, skip_serializing_if = "Option::is_none")]
994 pub page_kind: Option<SemanticPageKind>,
995 #[serde(default, skip_serializing_if = "Option::is_none")]
996 pub region_kind: Option<SemanticRegionKind>,
997 #[serde(default, skip_serializing_if = "Option::is_none")]
998 pub region_id: Option<String>,
999 #[serde(default, skip_serializing_if = "Option::is_none")]
1000 pub form_label: Option<String>,
1001}
1002
1003#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1005#[serde(rename_all = "camelCase", deny_unknown_fields)]
1006pub struct IntentConstraints {
1007 #[serde(default, skip_serializing_if = "Option::is_none")]
1008 pub role: Option<String>,
1009 #[serde(default, skip_serializing_if = "Option::is_none")]
1010 pub name: Option<String>,
1011 #[serde(default, skip_serializing_if = "Option::is_none")]
1012 pub name_contains: Option<String>,
1013 #[serde(default)]
1014 pub must_be_visible: bool,
1015 #[serde(default)]
1016 pub must_be_enabled: bool,
1017 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1018 pub exclude_text: Vec<String>,
1019 #[serde(default = "default_max_candidates")]
1020 pub max_candidates: usize,
1021}
1022
1023impl Default for IntentConstraints {
1024 fn default() -> Self {
1025 Self {
1026 role: None,
1027 name: None,
1028 name_contains: None,
1029 must_be_visible: false,
1030 must_be_enabled: false,
1031 exclude_text: Vec::new(),
1032 max_candidates: default_max_candidates(),
1033 }
1034 }
1035}
1036
1037fn default_max_candidates() -> usize {
1038 MAX_CANDIDATES
1039}
1040
1041#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1043#[serde(rename_all = "camelCase")]
1044pub enum SemanticResolutionPolicy {
1045 ReportOnly,
1046 RequireExact,
1047 RequireUniqueHighConfidence,
1048 AllowUniqueMediumConfidence,
1049 InteractiveConfirmation,
1050}
1051
1052#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1054#[serde(rename_all = "camelCase", deny_unknown_fields)]
1055pub struct SemanticIntentRequest {
1056 pub schema_version: u32,
1057 pub intent: String,
1058 pub action: SemanticIntentAction,
1059 #[serde(default)]
1060 pub scope: IntentScope,
1061 #[serde(default)]
1062 pub constraints: IntentConstraints,
1063 pub resolution_policy: SemanticResolutionPolicy,
1064 #[serde(default, skip_serializing_if = "Option::is_none")]
1065 pub expected_revision: Option<u64>,
1066}
1067
1068#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1070#[serde(rename_all = "camelCase", deny_unknown_fields)]
1071pub struct SemanticIntentCandidate {
1072 pub id: String,
1073 pub reference: String,
1074 pub role: String,
1075 pub name: String,
1076 #[serde(default, skip_serializing_if = "Option::is_none")]
1077 pub input_type: Option<String>,
1078 #[serde(default, skip_serializing_if = "Option::is_none")]
1079 pub region_id: Option<String>,
1080 #[serde(default, skip_serializing_if = "Option::is_none")]
1081 pub region_kind: Option<SemanticRegionKind>,
1082 pub confidence: IntentConfidence,
1083 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1084 pub evidence: Vec<IntentEvidence>,
1085 #[serde(default, skip_serializing_if = "Option::is_none")]
1086 pub fingerprint: Option<SemanticTargetFingerprint>,
1087}
1088
1089#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1091#[serde(rename_all = "camelCase")]
1092pub enum FingerprintInvalidation {
1093 #[serde(rename = "revisionChanged")]
1094 Revision,
1095 #[serde(rename = "routeChanged")]
1096 Route,
1097 #[serde(rename = "roleChanged")]
1098 Role,
1099 #[serde(rename = "nameChanged")]
1100 Name,
1101 #[serde(rename = "regionChanged")]
1102 Region,
1103}
1104
1105#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1107#[serde(rename_all = "camelCase", deny_unknown_fields)]
1108pub struct SemanticTargetFingerprint {
1109 pub revision: u64,
1110 pub route: SemanticRouteIdentity,
1111 pub role: String,
1112 pub name: String,
1113 #[serde(default, skip_serializing_if = "Option::is_none")]
1114 pub input_type: Option<String>,
1115 #[serde(default, skip_serializing_if = "Option::is_none")]
1116 pub region_id: Option<String>,
1117 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub region_kind: Option<SemanticRegionKind>,
1119 pub purpose: SemanticIntentPurpose,
1120 pub invalidated_by: Vec<FingerprintInvalidation>,
1121}
1122
1123pub fn target_fingerprint_digest(
1127 role: &str,
1128 name: &str,
1129 input_type: Option<&str>,
1130 region_kind: Option<SemanticRegionKind>,
1131 purpose: SemanticIntentPurpose,
1132) -> String {
1133 let canonical = serde_json::json!({
1134 "role": role,
1135 "name": name,
1136 "inputType": input_type,
1137 "regionKind": region_kind,
1138 "purpose": purpose,
1139 });
1140 let digest =
1141 Sha256::digest(serde_json::to_vec(&canonical).expect("JSON value is serializable"));
1142 format!("sha256:{digest:x}")
1143}
1144
1145#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1147#[serde(rename_all = "camelCase", deny_unknown_fields)]
1148pub struct ExcludedIntentCandidate {
1149 pub id: String,
1150 pub reason: IntentEvidence,
1151}
1152
1153#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1155#[serde(rename_all = "camelCase", deny_unknown_fields)]
1156pub struct IntentConstraintSuggestion {
1157 #[serde(default, skip_serializing_if = "Option::is_none")]
1158 pub region_kind: Option<SemanticRegionKind>,
1159 #[serde(default, skip_serializing_if = "Option::is_none")]
1160 pub name_contains: Option<String>,
1161 #[serde(default, skip_serializing_if = "Option::is_none")]
1162 pub role: Option<String>,
1163}
1164
1165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1167#[serde(rename_all = "camelCase")]
1168pub enum IntentPolicyDecision {
1169 Allowed,
1170 ReportOnly,
1171 ConfirmationRequired,
1172 Rejected,
1173}
1174
1175#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1177#[serde(rename_all = "camelCase", deny_unknown_fields)]
1178pub struct SemanticIntentResult {
1179 pub schema_version: u32,
1180 pub intent: String,
1181 pub action: SemanticIntentAction,
1182 pub normalized_intent: String,
1183 pub resolution: SemanticResolution,
1184 pub policy_decision: IntentPolicyDecision,
1185 #[serde(default, skip_serializing_if = "Option::is_none")]
1186 pub route: Option<SemanticRouteIdentity>,
1187 #[serde(default, skip_serializing_if = "Option::is_none")]
1188 pub revision: Option<u64>,
1189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1190 pub candidates: Vec<SemanticIntentCandidate>,
1191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1192 pub excluded_candidates: Vec<ExcludedIntentCandidate>,
1193 pub excluded_count: usize,
1194 #[serde(default, skip_serializing_if = "Option::is_none")]
1195 pub selected_candidate: Option<String>,
1196 #[serde(default, skip_serializing_if = "Vec::is_empty")]
1197 pub suggested_constraints: Vec<IntentConstraintSuggestion>,
1198 #[serde(default, skip_serializing_if = "Option::is_none")]
1199 pub reason: Option<String>,
1200}
1201
1202impl SemanticIntentRequest {
1203 pub fn validate(&self) -> Result<(), IntentResolutionError> {
1204 if self.schema_version != INTENT_RESOLUTION_SCHEMA_VERSION {
1205 return Err(IntentResolutionError::new(
1206 "schemaVersion",
1207 format!(
1208 "unsupported schema version {}; expected {}",
1209 self.schema_version, INTENT_RESOLUTION_SCHEMA_VERSION
1210 ),
1211 ));
1212 }
1213 validate_text("intent", &self.intent, MAX_INTENT_BYTES, false)?;
1214 validate_scope(&self.scope)?;
1215 validate_constraints(&self.constraints)?;
1216 if self.scope.region_id.is_some() && self.scope.region_kind.is_none() {
1217 return Err(IntentResolutionError::new(
1218 "scope.regionId",
1219 "regionId requires regionKind or a later concrete region handle",
1220 ));
1221 }
1222 Ok(())
1223 }
1224
1225 pub fn from_json(input: &str) -> Result<Self, IntentResolutionError> {
1226 let request: Self = serde_json::from_str(input).map_err(|error| {
1227 IntentResolutionError::new("$", format!("invalid intent request shape: {error}"))
1228 })?;
1229 request.validate()?;
1230 Ok(request)
1231 }
1232
1233 pub fn to_canonical_json(&self) -> Result<String, IntentResolutionError> {
1234 self.validate()?;
1235 serde_json::to_string(self)
1236 .map_err(|error| IntentResolutionError::new("$", error.to_string()))
1237 }
1238}
1239
1240impl SemanticIntentResult {
1241 pub fn validate(&self) -> Result<(), IntentResolutionError> {
1242 if self.schema_version != INTENT_RESOLUTION_SCHEMA_VERSION {
1243 return Err(IntentResolutionError::new(
1244 "schemaVersion",
1245 format!(
1246 "unsupported schema version {}; expected {}",
1247 self.schema_version, INTENT_RESOLUTION_SCHEMA_VERSION
1248 ),
1249 ));
1250 }
1251 validate_text("intent", &self.intent, MAX_INTENT_BYTES, false)?;
1252 validate_text(
1253 "normalizedIntent",
1254 &self.normalized_intent,
1255 MAX_INTENT_BYTES,
1256 false,
1257 )?;
1258 if let Some(route) = &self.route {
1259 validate_text("route.targetId", &route.target_id, MAX_ID_BYTES, false)?;
1260 validate_text("route.frameId", &route.frame_id, MAX_ID_BYTES, false)?;
1261 validate_text("route.url", &route.url, 2_048, false)?;
1262 }
1263 if self.candidates.len() > MAX_CANDIDATES {
1264 return Err(IntentResolutionError::new(
1265 "candidates",
1266 format!("contains more than {MAX_CANDIDATES} candidates"),
1267 ));
1268 }
1269 if self.excluded_candidates.len() > MAX_CANDIDATES {
1270 return Err(IntentResolutionError::new(
1271 "excludedCandidates",
1272 format!("contains more than {MAX_CANDIDATES} candidates"),
1273 ));
1274 }
1275 if self.suggested_constraints.len() > MAX_SUGGESTIONS {
1276 return Err(IntentResolutionError::new(
1277 "suggestedConstraints",
1278 format!("contains more than {MAX_SUGGESTIONS} suggestions"),
1279 ));
1280 }
1281 for (index, candidate) in self.candidates.iter().enumerate() {
1282 validate_candidate(&format!("candidates[{index}]"), candidate)?;
1283 }
1284 for (index, candidate) in self.excluded_candidates.iter().enumerate() {
1285 validate_text(
1286 &format!("excludedCandidates[{index}].id"),
1287 &candidate.id,
1288 MAX_ID_BYTES,
1289 false,
1290 )?;
1291 validate_evidence(
1292 &format!("excludedCandidates[{index}].reason"),
1293 std::slice::from_ref(&candidate.reason),
1294 )?;
1295 }
1296 if self.excluded_count < self.excluded_candidates.len() {
1297 return Err(IntentResolutionError::new(
1298 "excludedCount",
1299 "cannot be less than the returned excluded candidate count",
1300 ));
1301 }
1302 if let Some(selected) = &self.selected_candidate {
1303 validate_text("selectedCandidate", selected, MAX_ID_BYTES, false)?;
1304 if !self
1305 .candidates
1306 .iter()
1307 .any(|candidate| &candidate.id == selected)
1308 {
1309 return Err(IntentResolutionError::new(
1310 "selectedCandidate",
1311 "does not identify a returned candidate",
1312 ));
1313 }
1314 }
1315 for (index, suggestion) in self.suggested_constraints.iter().enumerate() {
1316 if let Some(value) = &suggestion.name_contains {
1317 validate_text(
1318 &format!("suggestedConstraints[{index}].nameContains"),
1319 value,
1320 MAX_LABEL_BYTES,
1321 false,
1322 )?;
1323 }
1324 if let Some(value) = &suggestion.role {
1325 validate_text(
1326 &format!("suggestedConstraints[{index}].role"),
1327 value,
1328 MAX_ACTION_BYTES,
1329 false,
1330 )?;
1331 }
1332 }
1333 if let Some(reason) = &self.reason {
1334 validate_text("reason", reason, MAX_EVIDENCE_BYTES, false)?;
1335 }
1336 Ok(())
1337 }
1338
1339 pub fn from_json(input: &str) -> Result<Self, IntentResolutionError> {
1340 let result: Self = serde_json::from_str(input).map_err(|error| {
1341 IntentResolutionError::new("$", format!("invalid intent result shape: {error}"))
1342 })?;
1343 result.validate()?;
1344 Ok(result)
1345 }
1346
1347 pub fn to_canonical_json(&self) -> Result<String, IntentResolutionError> {
1348 self.validate()?;
1349 serde_json::to_string(self)
1350 .map_err(|error| IntentResolutionError::new("$", error.to_string()))
1351 }
1352}
1353
1354fn validate_scope(scope: &IntentScope) -> Result<(), IntentResolutionError> {
1355 if let Some(value) = &scope.region_id {
1356 validate_text("scope.regionId", value, MAX_ID_BYTES, false)?;
1357 }
1358 if let Some(value) = &scope.form_label {
1359 validate_text("scope.formLabel", value, MAX_LABEL_BYTES, false)?;
1360 }
1361 Ok(())
1362}
1363
1364fn validate_constraints(constraints: &IntentConstraints) -> Result<(), IntentResolutionError> {
1365 if let Some(value) = &constraints.role {
1366 validate_text("constraints.role", value, MAX_ACTION_BYTES, false)?;
1367 }
1368 for (path, value) in [
1369 ("constraints.name", constraints.name.as_deref()),
1370 (
1371 "constraints.nameContains",
1372 constraints.name_contains.as_deref(),
1373 ),
1374 ] {
1375 if let Some(value) = value {
1376 validate_text(path, value, MAX_LABEL_BYTES, false)?;
1377 }
1378 }
1379 if constraints.exclude_text.len() > MAX_EXCLUDE_TEXT {
1380 return Err(IntentResolutionError::new(
1381 "constraints.excludeText",
1382 format!("contains more than {MAX_EXCLUDE_TEXT} values"),
1383 ));
1384 }
1385 for (index, value) in constraints.exclude_text.iter().enumerate() {
1386 validate_text(
1387 &format!("constraints.excludeText[{index}]"),
1388 value,
1389 MAX_LABEL_BYTES,
1390 false,
1391 )?;
1392 }
1393 if constraints.max_candidates == 0 || constraints.max_candidates > MAX_CANDIDATES {
1394 return Err(IntentResolutionError::new(
1395 "constraints.maxCandidates",
1396 format!("must be between 1 and {MAX_CANDIDATES}"),
1397 ));
1398 }
1399 Ok(())
1400}
1401
1402fn validate_candidate(
1403 path: &str,
1404 candidate: &SemanticIntentCandidate,
1405) -> Result<(), IntentResolutionError> {
1406 for (suffix, value, maximum) in [
1407 ("id", candidate.id.as_str(), MAX_ID_BYTES),
1408 ("reference", candidate.reference.as_str(), MAX_ID_BYTES),
1409 ("role", candidate.role.as_str(), MAX_ACTION_BYTES),
1410 ("name", candidate.name.as_str(), MAX_LABEL_BYTES),
1411 ] {
1412 validate_text(&format!("{path}.{suffix}"), value, maximum, false)?;
1413 }
1414 if let Some(input_type) = &candidate.input_type {
1415 validate_text(
1416 &format!("{path}.inputType"),
1417 input_type,
1418 MAX_ACTION_BYTES,
1419 false,
1420 )?;
1421 }
1422 if let Some(region_id) = &candidate.region_id {
1423 validate_text(&format!("{path}.regionId"), region_id, MAX_ID_BYTES, false)?;
1424 }
1425 validate_evidence(&format!("{path}.evidence"), &candidate.evidence)?;
1426 if let Some(fingerprint) = &candidate.fingerprint {
1427 validate_text(
1428 &format!("{path}.fingerprint.role"),
1429 &fingerprint.role,
1430 MAX_ACTION_BYTES,
1431 false,
1432 )?;
1433 validate_text(
1434 &format!("{path}.fingerprint.name"),
1435 &fingerprint.name,
1436 MAX_LABEL_BYTES,
1437 false,
1438 )?;
1439 if fingerprint.invalidated_by.is_empty() {
1440 return Err(IntentResolutionError::new(
1441 format!("{path}.fingerprint.invalidatedBy"),
1442 "must declare at least one invalidation condition",
1443 ));
1444 }
1445 }
1446 Ok(())
1447}
1448
1449fn validate_evidence(path: &str, evidence: &[IntentEvidence]) -> Result<(), IntentResolutionError> {
1450 if evidence.len() > MAX_EVIDENCE_ITEMS {
1451 return Err(IntentResolutionError::new(
1452 path,
1453 format!("contains more than {MAX_EVIDENCE_ITEMS} items"),
1454 ));
1455 }
1456 let mut categories = BTreeSet::new();
1457 for (index, item) in evidence.iter().enumerate() {
1458 if !categories.insert(item.category) {
1459 return Err(IntentResolutionError::new(
1460 format!("{path}[{index}].category"),
1461 "duplicate evidence category",
1462 ));
1463 }
1464 validate_text(
1465 &format!("{path}[{index}].detail"),
1466 &item.detail,
1467 MAX_EVIDENCE_BYTES,
1468 false,
1469 )?;
1470 }
1471 Ok(())
1472}
1473
1474fn validate_text(
1475 path: &str,
1476 value: &str,
1477 maximum: usize,
1478 allow_empty: bool,
1479) -> Result<(), IntentResolutionError> {
1480 if (!allow_empty && value.is_empty()) || value.len() > maximum {
1481 let requirement = if allow_empty {
1482 format!("at most {maximum} bytes")
1483 } else {
1484 format!("non-empty and at most {maximum} bytes")
1485 };
1486 return Err(IntentResolutionError::new(
1487 path,
1488 format!("must be {requirement}"),
1489 ));
1490 }
1491 Ok(())
1492}
1493
1494#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
1496pub struct IntentResolutionError {
1497 pub path: String,
1498 pub reason: String,
1499}
1500
1501impl IntentResolutionError {
1502 fn new(path: impl Into<String>, reason: impl Into<String>) -> Self {
1503 Self {
1504 path: path.into(),
1505 reason: reason.into(),
1506 }
1507 }
1508}
1509
1510impl std::fmt::Display for IntentResolutionError {
1511 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1512 write!(formatter, "{}: {}", self.path, self.reason)
1513 }
1514}
1515
1516impl std::error::Error for IntentResolutionError {}
1517
1518impl super::BrowserSession {
1519 pub async fn resolve_intent(
1522 &self,
1523 request: &SemanticIntentRequest,
1524 ) -> super::types::BrowserResult<SemanticIntentResult> {
1525 let observation = self
1526 .semantic_observe(super::SemanticObservationLevel::Interactive)
1527 .await?;
1528 Ok(resolve_intent(request, &observation))
1529 }
1530
1531 pub async fn resolve_intent_with_knowledge(
1536 &self,
1537 request: &SemanticIntentRequest,
1538 store: &super::KnowledgeStore,
1539 lookup_options: super::KnowledgeLookupOptions,
1540 ) -> super::types::BrowserResult<SemanticIntentResult> {
1541 let observation = self
1542 .semantic_observe(super::SemanticObservationLevel::Interactive)
1543 .await?;
1544 let context =
1545 super::KnowledgeLookupContext::from_observation(&observation, lookup_options)?;
1546 let assessments = store.assess(&context);
1547 let historical_fingerprints = store
1548 .records()
1549 .iter()
1550 .zip(assessments)
1551 .filter(|(record, assessment)| {
1552 record.kind == super::KnowledgeRecordKind::TargetFingerprint
1553 && assessment.status == super::KnowledgeAssessmentStatus::Eligible
1554 })
1555 .filter_map(|(record, _)| {
1556 record
1557 .data
1558 .get("fingerprint")
1559 .and_then(|value| value.as_str())
1560 })
1561 .map(str::to_string)
1562 .collect();
1563 Ok(resolve_intent_with_historical_matches(
1564 request,
1565 &observation,
1566 &historical_fingerprints,
1567 ))
1568 }
1569
1570 pub async fn execute_intent(
1579 &self,
1580 execution: &SemanticIntentExecutionRequest,
1581 ) -> super::types::BrowserResult<SemanticIntentExecutionResult> {
1582 execution.validate()?;
1583 let observation = self
1584 .semantic_observe(super::SemanticObservationLevel::Interactive)
1585 .await?;
1586 let resolution = resolve_intent(&execution.request, &observation);
1587 let resolution_id = intent_resolution_id(
1588 &execution.request,
1589 resolution.revision.unwrap_or(observation.revision),
1590 &execution.candidate_id,
1591 )?;
1592 let candidate = resolution
1593 .candidates
1594 .iter()
1595 .find(|candidate| candidate.id == execution.candidate_id);
1596 let eligible = candidate.is_some()
1597 && match resolution.policy_decision {
1598 IntentPolicyDecision::Allowed => {
1599 resolution.selected_candidate.as_deref() == Some(&execution.candidate_id)
1600 }
1601 IntentPolicyDecision::ConfirmationRequired => !matches!(
1602 resolution.resolution,
1603 SemanticResolution::NotFound
1604 | SemanticResolution::StaleRevision
1605 | SemanticResolution::PolicyRejected
1606 | SemanticResolution::UnsupportedIntent
1607 ),
1608 IntentPolicyDecision::ReportOnly | IntentPolicyDecision::Rejected => false,
1609 };
1610
1611 let Some(candidate) = candidate else {
1612 return Ok(SemanticIntentExecutionResult {
1613 resolution_id,
1614 candidate_id: execution.candidate_id.clone(),
1615 status: SemanticIntentExecutionStatus::NotExecuted,
1616 resolution,
1617 action: None,
1618 execution_id: None,
1619 reason: Some("selected candidate is not present in the fresh resolution".into()),
1620 });
1621 };
1622 if !eligible {
1623 return Ok(SemanticIntentExecutionResult {
1624 resolution_id,
1625 candidate_id: execution.candidate_id.clone(),
1626 status: SemanticIntentExecutionStatus::NotExecuted,
1627 reason: resolution
1628 .reason
1629 .clone()
1630 .or_else(|| Some("resolution policy did not authorize this candidate".into())),
1631 resolution,
1632 action: None,
1633 execution_id: None,
1634 });
1635 }
1636
1637 let expected_revision = resolution
1638 .revision
1639 .ok_or("eligible intent resolution did not contain a current revision")?;
1640 let action = match execution.request.action {
1641 SemanticIntentAction::Click
1642 | SemanticIntentAction::Submit
1643 | SemanticIntentAction::Open
1644 | SemanticIntentAction::Close
1645 | SemanticIntentAction::Search
1646 | SemanticIntentAction::Filter
1647 | SemanticIntentAction::Sort
1648 | SemanticIntentAction::Paginate
1649 | SemanticIntentAction::Expand
1650 | SemanticIntentAction::Collapse => {
1651 self.click_with_revision(&candidate.reference, expected_revision)
1652 .await?
1653 }
1654 SemanticIntentAction::Type => {
1655 self.type_text_with_expected_revision(
1656 execution.value.as_deref().ok_or("type requires a value")?,
1657 Some(&candidate.reference),
1658 Some(expected_revision),
1659 )
1660 .await?
1661 }
1662 SemanticIntentAction::Clear => {
1663 self.clear_with_revision(&candidate.reference, Some(expected_revision))
1664 .await?
1665 }
1666 SemanticIntentAction::Check => {
1667 self.check_with_revision(&candidate.reference, Some(expected_revision))
1668 .await?
1669 }
1670 SemanticIntentAction::Uncheck => {
1671 self.uncheck_with_revision(&candidate.reference, Some(expected_revision))
1672 .await?
1673 }
1674 SemanticIntentAction::Select => {
1675 self.select_option_with_revision(
1676 &candidate.reference,
1677 execution
1678 .value
1679 .as_deref()
1680 .ok_or("select requires a value")?,
1681 Some(expected_revision),
1682 )
1683 .await?
1684 }
1685 SemanticIntentAction::Toggle
1686 | SemanticIntentAction::Download
1687 | SemanticIntentAction::Upload
1688 | SemanticIntentAction::Inspect
1689 | SemanticIntentAction::Extract => unreachable!("validated intent action"),
1690 };
1691 let execution_id = action.execution_id.clone();
1692 Ok(SemanticIntentExecutionResult {
1693 resolution_id,
1694 candidate_id: execution.candidate_id.clone(),
1695 status: SemanticIntentExecutionStatus::Executed,
1696 resolution,
1697 action: Some(action),
1698 execution_id: Some(execution_id),
1699 reason: None,
1700 })
1701 }
1702}
1703
1704fn intent_resolution_id(
1705 request: &SemanticIntentRequest,
1706 revision: u64,
1707 candidate_id: &str,
1708) -> Result<String, super::IntentResolutionError> {
1709 let canonical = request.to_canonical_json()?;
1710 let mut hasher = Sha256::new();
1711 hasher.update(canonical.as_bytes());
1712 hasher.update(revision.to_le_bytes());
1713 hasher.update(candidate_id.as_bytes());
1714 Ok(format!("res_{}", hex_digest(hasher.finalize().as_slice())))
1715}
1716
1717fn hex_digest(bytes: &[u8]) -> String {
1718 bytes.iter().map(|byte| format!("{byte:02x}")).collect()
1719}
1720
1721#[cfg(test)]
1722mod tests {
1723 use super::*;
1724
1725 fn make_request() -> SemanticIntentRequest {
1726 SemanticIntentRequest {
1727 schema_version: INTENT_RESOLUTION_SCHEMA_VERSION,
1728 intent: "open settings".into(),
1729 action: SemanticIntentAction::Click,
1730 scope: IntentScope {
1731 page_kind: Some(SemanticPageKind::Dashboard),
1732 region_kind: Some(SemanticRegionKind::Navigation),
1733 ..IntentScope::default()
1734 },
1735 constraints: IntentConstraints {
1736 role: Some("button".into()),
1737 must_be_visible: true,
1738 ..IntentConstraints::default()
1739 },
1740 resolution_policy: SemanticResolutionPolicy::RequireUniqueHighConfidence,
1741 expected_revision: Some(42),
1742 }
1743 }
1744
1745 #[test]
1746 fn request_round_trip_is_canonical_and_bounded() {
1747 let request = make_request();
1748 let first = request.to_canonical_json().unwrap();
1749 assert!(first.contains("requireUniqueHighConfidence"));
1750 assert!(first.contains("pageKind"));
1751 assert_eq!(
1752 SemanticIntentRequest::from_json(&first)
1753 .unwrap()
1754 .to_canonical_json()
1755 .unwrap(),
1756 first
1757 );
1758 }
1759
1760 #[test]
1761 fn request_rejects_invalid_scope_and_limits() {
1762 let mut request = make_request();
1763 request.scope.region_id = Some("region_navigation".into());
1764 request.scope.region_kind = None;
1765 let error = request.validate().unwrap_err();
1766 assert_eq!(error.path, "scope.regionId");
1767
1768 let mut request = make_request();
1769 request.constraints.max_candidates = MAX_CANDIDATES + 1;
1770 let error = request.validate().unwrap_err();
1771 assert_eq!(error.path, "constraints.maxCandidates");
1772 }
1773
1774 #[test]
1775 fn execution_request_requires_bounded_action_inputs() {
1776 let mut request = make_request();
1777 request.constraints.must_be_visible = false;
1778 let mut execution = SemanticIntentExecutionRequest {
1779 request,
1780 candidate_id: "candidate_1".into(),
1781 value: None,
1782 };
1783 assert_eq!(execution.validate().unwrap(), ());
1784
1785 execution.request.action = SemanticIntentAction::Type;
1786 let error = execution.validate().unwrap_err();
1787 assert_eq!(error.path, "value");
1788
1789 execution.value = Some("settings".into());
1790 assert_eq!(execution.validate().unwrap(), ());
1791
1792 execution.request.action = SemanticIntentAction::Toggle;
1793 let error = execution.validate().unwrap_err();
1794 assert_eq!(error.path, "action");
1795 }
1796
1797 #[test]
1798 fn resolution_ids_are_stable_and_request_scoped() {
1799 let request = make_request();
1800 let first = intent_resolution_id(&request, 42, "candidate_1").unwrap();
1801 assert_eq!(
1802 first,
1803 intent_resolution_id(&request, 42, "candidate_1").unwrap()
1804 );
1805 assert_ne!(
1806 first,
1807 intent_resolution_id(&request, 43, "candidate_1").unwrap()
1808 );
1809 assert_ne!(
1810 first,
1811 intent_resolution_id(&request, 42, "candidate_2").unwrap()
1812 );
1813 }
1814
1815 #[test]
1816 fn normalization_is_deterministic_and_rejects_vague_phrases() {
1817 let mut request = make_request();
1818 request.intent = "go to the next page".into();
1819 request.action = SemanticIntentAction::Paginate;
1820 let normalized = normalize_intent(&request).unwrap();
1821 assert_eq!(normalized.canonical, "paginate next");
1822 assert_eq!(normalized.purpose, SemanticIntentPurpose::PaginationNext);
1823
1824 request.intent = "search for blue shoes".into();
1825 request.action = SemanticIntentAction::Search;
1826 let normalized = normalize_intent(&request).unwrap();
1827 assert_eq!(normalized.canonical, "search blue shoes");
1828 assert_eq!(
1829 normalized.terms,
1830 vec!["blue".to_string(), "shoes".to_string()]
1831 );
1832
1833 request.intent = "do it".into();
1834 let error = normalize_intent(&request).unwrap_err();
1835 assert_eq!(error.path, "intent");
1836
1837 request.intent = "search for blue shoes".into();
1838 request.action = SemanticIntentAction::Type;
1839 let error = normalize_intent(&request).unwrap_err();
1840 assert_eq!(error.path, "action");
1841 }
1842
1843 fn semantic_observation() -> SemanticObservation {
1844 let route = SemanticRouteIdentity {
1845 target_id: "target".into(),
1846 frame_id: "frame".into(),
1847 url: "https://example.test/dashboard".into(),
1848 };
1849 let page = SemanticPage {
1850 kind: SemanticPageKind::Dashboard,
1851 title: "Dashboard".into(),
1852 url: route.url.clone(),
1853 target_id: route.target_id.clone(),
1854 frame_id: route.frame_id.clone(),
1855 confidence: super::super::SemanticConfidence::High,
1856 evidence: vec!["fixture".into()],
1857 };
1858 let region = SemanticRegion {
1859 id: "region_navigation_1".into(),
1860 kind: SemanticRegionKind::Navigation,
1861 label: "Navigation".into(),
1862 interactive_count: 3,
1863 item_count: None,
1864 confidence: super::super::SemanticConfidence::Exact,
1865 evidence: vec!["aria-role=navigation".into()],
1866 targets: vec![
1867 SemanticTarget {
1868 reference: "r42:b1".into(),
1869 role: "button".into(),
1870 name: "Settings".into(),
1871 input_type: None,
1872 },
1873 SemanticTarget {
1874 reference: "r42:b2".into(),
1875 role: "button".into(),
1876 name: "Continue shopping".into(),
1877 input_type: None,
1878 },
1879 SemanticTarget {
1880 reference: "r42:b3".into(),
1881 role: "button".into(),
1882 name: "Continue to payment".into(),
1883 input_type: None,
1884 },
1885 ],
1886 expansion: Some(SemanticExpansionHandle {
1887 region_id: "region_navigation_1".into(),
1888 revision: 42,
1889 route: route.clone(),
1890 }),
1891 };
1892 SemanticObservation {
1893 schema_version: super::INTENT_RESOLUTION_SCHEMA_VERSION,
1894 revision: 42,
1895 level: super::super::SemanticObservationLevel::Interactive,
1896 route,
1897 page,
1898 regions: vec![region],
1899 text: None,
1900 accessibility: None,
1901 raw_accessibility: None,
1902 changes: None,
1903 limits: super::super::SemanticObservationLimits::default(),
1904 }
1905 }
1906
1907 #[test]
1908 fn resolver_returns_selected_exact_and_explicit_ambiguous_results() {
1909 let observation = semantic_observation();
1910 let mut request = make_request();
1911 request.constraints.must_be_visible = false;
1912 request.constraints.name = Some("Settings".into());
1913 let result = resolve_intent(&request, &observation);
1914 assert_eq!(result.resolution, SemanticResolution::Exact);
1915 assert_eq!(result.policy_decision, IntentPolicyDecision::Allowed);
1916 assert_eq!(result.selected_candidate.as_deref(), Some("candidate_1"));
1917
1918 request.intent = "continue checkout".into();
1919 request.constraints.name = None;
1920 request.constraints.name_contains = Some("continue".into());
1921 let result = resolve_intent(&request, &observation);
1922 assert_eq!(result.resolution, SemanticResolution::Ambiguous);
1923 assert_eq!(result.policy_decision, IntentPolicyDecision::Rejected);
1924 assert!(result.selected_candidate.is_none());
1925 assert_eq!(result.candidates.len(), 2);
1926 }
1927
1928 #[test]
1929 fn historical_fingerprint_explains_only_a_current_candidate() {
1930 let observation = semantic_observation();
1931 let mut request = make_request();
1932 request.constraints.must_be_visible = false;
1933 request.constraints.name = Some("Settings".into());
1934 let fingerprint = target_fingerprint_digest(
1935 "button",
1936 "Settings",
1937 None,
1938 Some(SemanticRegionKind::Navigation),
1939 SemanticIntentPurpose::Open,
1940 );
1941 let mut historical = BTreeSet::new();
1942 historical.insert(fingerprint);
1943 let result = resolve_intent_with_historical_matches(&request, &observation, &historical);
1944 assert_eq!(result.resolution, SemanticResolution::Exact);
1945 assert!(
1946 result.candidates[0]
1947 .evidence
1948 .iter()
1949 .any(|evidence| { evidence.category == IntentEvidenceCategory::HistoricalMatch })
1950 );
1951
1952 let mut unknown = BTreeSet::new();
1953 unknown.insert("sha256:not-current".into());
1954 let without_match =
1955 resolve_intent_with_historical_matches(&request, &observation, &unknown);
1956 assert_eq!(without_match.candidates.len(), result.candidates.len());
1957 assert!(
1958 !without_match.candidates[0]
1959 .evidence
1960 .iter()
1961 .any(|evidence| evidence.category == IntentEvidenceCategory::HistoricalMatch)
1962 );
1963 }
1964
1965 #[test]
1966 fn resolver_fails_closed_for_stale_revision_and_unproven_state() {
1967 let observation = semantic_observation();
1968 let mut request = make_request();
1969 request.expected_revision = Some(41);
1970 let result = resolve_intent(&request, &observation);
1971 assert_eq!(result.resolution, SemanticResolution::StaleRevision);
1972
1973 request.expected_revision = Some(42);
1974 request.constraints.must_be_enabled = true;
1975 let result = resolve_intent(&request, &observation);
1976 assert_eq!(result.resolution, SemanticResolution::NotFound);
1977 assert_eq!(result.excluded_count, 3);
1978 assert!(result.selected_candidate.is_none());
1979 }
1980
1981 #[test]
1982 fn result_rejects_duplicate_evidence_and_unknown_fields() {
1983 let result = SemanticIntentResult {
1984 schema_version: INTENT_RESOLUTION_SCHEMA_VERSION,
1985 intent: "open settings".into(),
1986 action: SemanticIntentAction::Click,
1987 normalized_intent: "open settings".into(),
1988 resolution: SemanticResolution::UniqueHighConfidence,
1989 policy_decision: IntentPolicyDecision::Allowed,
1990 route: None,
1991 revision: Some(42),
1992 candidates: vec![SemanticIntentCandidate {
1993 id: "candidate_1".into(),
1994 reference: "r42:b17".into(),
1995 role: "button".into(),
1996 name: "Settings".into(),
1997 input_type: None,
1998 region_id: None,
1999 region_kind: None,
2000 confidence: IntentConfidence::High,
2001 evidence: vec![IntentEvidence {
2002 category: IntentEvidenceCategory::ExactName,
2003 detail: "accessible name exact match".into(),
2004 }],
2005 fingerprint: None,
2006 }],
2007 excluded_candidates: Vec::new(),
2008 excluded_count: 0,
2009 selected_candidate: Some("candidate_1".into()),
2010 suggested_constraints: Vec::new(),
2011 reason: None,
2012 };
2013 let canonical = result.to_canonical_json().unwrap();
2014 let mut value: serde_json::Value = serde_json::from_str(&canonical).unwrap();
2015 value["futureField"] = true.into();
2016 assert_eq!(
2017 SemanticIntentResult::from_json(&value.to_string())
2018 .unwrap_err()
2019 .path,
2020 "$"
2021 );
2022
2023 let mut duplicate = result;
2024 duplicate.candidates[0].evidence.push(IntentEvidence {
2025 category: IntentEvidenceCategory::ExactName,
2026 detail: "duplicate".into(),
2027 });
2028 assert_eq!(
2029 duplicate.validate().unwrap_err().path,
2030 "candidates[0].evidence[1].category"
2031 );
2032 }
2033}