1use super::*;
4use crate::protocol::{RetryClassification, RetryGuidance};
5use serde::{Deserialize, Serialize};
6use serde_json::{Map, Value};
7use std::time::Duration;
8
9const MAX_EXTRACTION_FIELDS: usize = 32;
10const MAX_EXTRACTION_ITEMS: usize = 256;
11const MAX_EXTRACTION_BYTES: usize = 256 * 1024;
12
13#[derive(Debug, Clone, Serialize)]
15#[serde(rename_all = "camelCase")]
16pub struct InspectPageResult {
17 pub page: SemanticPage,
18 pub revision: u64,
19 pub regions: Vec<SemanticRegion>,
20 pub limits: SemanticObservationLimits,
21 pub focused_target: Option<SemanticTarget>,
22 pub alerts: Vec<String>,
23}
24
25#[derive(Debug, Clone, Serialize)]
27#[serde(rename_all = "camelCase")]
28pub struct FindTargetResult {
29 pub normalized_intent: String,
30 pub revision: Option<u64>,
31 pub candidates: Vec<SemanticIntentCandidate>,
32 pub ambiguity: String,
33 pub suggested_constraints: Vec<IntentConstraintSuggestion>,
34}
35
36#[derive(Debug, Clone, Serialize)]
38#[serde(rename_all = "camelCase")]
39pub struct ActAndVerifyResult {
40 pub status: String,
41 pub phase: String,
42 pub mutation_possible: bool,
43 pub execution: SemanticIntentExecutionResult,
44 #[serde(skip_serializing_if = "Option::is_none")]
45 pub verification: Option<VerificationOutcome>,
46 pub retry: RetryGuidance,
47}
48
49#[derive(Debug, Clone, Deserialize, Serialize)]
51#[serde(rename_all = "camelCase")]
52pub struct ExtractionField {
53 pub name: String,
54 pub path: String,
55 pub kind: ExtractionKind,
56}
57
58#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
60#[serde(rename_all = "camelCase")]
61pub enum ExtractionKind {
62 Scalar,
63 OptionalScalar,
64 List,
65 Object,
66 Table,
67 RepeatedItems,
68}
69
70#[derive(Debug, Clone, Deserialize, Serialize)]
72#[serde(rename_all = "camelCase")]
73pub struct StructuredExtractionRequest {
74 pub fields: Vec<ExtractionField>,
75 #[serde(default)]
76 pub region_id: Option<String>,
77 #[serde(default = "default_extraction_items")]
78 pub max_items: usize,
79 #[serde(default = "default_extraction_bytes")]
80 pub max_bytes: usize,
81}
82
83#[derive(Debug, Clone, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct StructuredExtractionResult {
87 pub source_revision: u64,
88 pub source_route: SemanticRouteIdentity,
89 pub records: Vec<Value>,
90 pub truncated: bool,
91 pub provenance: Vec<String>,
92}
93
94#[derive(Debug, Clone, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct RecoverRunResult {
98 pub execution_id: String,
99 pub known: bool,
100 pub phase: String,
101 pub dispatch_happened: bool,
102 pub mutation_possible: bool,
103 pub reconciliation: String,
104 pub retry: RetryGuidance,
105}
106
107impl BrowserSession {
108 pub async fn inspect_page(&self) -> BrowserResult<InspectPageResult> {
110 let observation = self
111 .semantic_observe(SemanticObservationLevel::Structured)
112 .await?;
113 Ok(InspectPageResult {
114 page: observation.page,
115 revision: observation.revision,
116 regions: observation.regions,
117 limits: observation.limits,
118 focused_target: None,
119 alerts: Vec::new(),
120 })
121 }
122
123 pub async fn find_target(
125 &self,
126 request: &SemanticIntentRequest,
127 ) -> BrowserResult<FindTargetResult> {
128 let result = self.resolve_intent(request).await?;
129 let ambiguity = match result.resolution {
130 SemanticResolution::Exact
131 | SemanticResolution::UniqueHighConfidence
132 | SemanticResolution::UniqueLowConfidence => "none",
133 SemanticResolution::Ambiguous => "ambiguous",
134 SemanticResolution::NotFound => "not_found",
135 SemanticResolution::StaleRevision => "stale_revision",
136 SemanticResolution::PolicyRejected => "policy_rejected",
137 SemanticResolution::UnsupportedIntent => "unsupported_intent",
138 };
139 Ok(FindTargetResult {
140 normalized_intent: result.normalized_intent,
141 revision: result.revision,
142 candidates: result.candidates,
143 ambiguity: ambiguity.into(),
144 suggested_constraints: result.suggested_constraints,
145 })
146 }
147
148 pub async fn act_and_verify(
150 &self,
151 execution: &SemanticIntentExecutionRequest,
152 predicate: Option<VerificationPredicate>,
153 timeout: Duration,
154 ) -> BrowserResult<ActAndVerifyResult> {
155 let execution_result = self.execute_intent(execution).await?;
156 let Some(_) = execution_result.action.as_ref() else {
157 return Ok(ActAndVerifyResult {
158 status: "not_executed".into(),
159 phase: "preflight".into(),
160 mutation_possible: false,
161 execution: execution_result,
162 verification: None,
163 retry: RetryGuidance {
164 classification: RetryClassification::SafeAfterReobserve,
165 recommended_operation: "find_target".into(),
166 },
167 });
168 };
169 let Some(predicate) = predicate else {
170 return Ok(ActAndVerifyResult {
171 status: "dispatched_unverified".into(),
172 phase: "post_dispatch".into(),
173 mutation_possible: true,
174 execution: execution_result,
175 verification: None,
176 retry: RetryGuidance {
177 classification: RetryClassification::RequiresUserDecision,
178 recommended_operation: "inspect_page".into(),
179 },
180 });
181 };
182 let verification = self.verify(predicate, timeout).await;
183 match verification {
184 Ok(verification) => Ok(ActAndVerifyResult {
185 status: "verified".into(),
186 phase: "verification".into(),
187 mutation_possible: false,
188 execution: execution_result,
189 verification: Some(verification),
190 retry: RetryGuidance {
191 classification: RetryClassification::SafeImmediate,
192 recommended_operation: "inspect_page".into(),
193 },
194 }),
195 Err(_error) => Ok(ActAndVerifyResult {
196 status: "indeterminate".into(),
197 phase: "verification".into(),
198 mutation_possible: true,
199 execution: execution_result,
200 verification: None,
201 retry: RetryGuidance {
202 classification: RetryClassification::UnsafeUntilReconciled,
203 recommended_operation: "recover_run".into(),
204 },
205 }),
206 }
207 }
208
209 pub async fn extract_structured(
211 &self,
212 request: &StructuredExtractionRequest,
213 ) -> BrowserResult<StructuredExtractionResult> {
214 validate_extraction_request(request)?;
215 let observation = self
216 .semantic_observe(SemanticObservationLevel::Structured)
217 .await?;
218 let source = if let Some(region_id) = &request.region_id {
219 observation
220 .regions
221 .iter()
222 .find(|region| region.id == *region_id)
223 .map(serde_json::to_value)
224 .transpose()?
225 .ok_or_else(|| format!("region not found: {region_id}"))?
226 } else {
227 serde_json::to_value(&observation)?
228 };
229 let mut record = Map::new();
230 let mut truncated = false;
231 for field in &request.fields {
232 let value = value_at_path(&source, &field.path).cloned();
233 let mut value = validate_extracted_value(value, field)?;
234 if let Value::Array(items) = &mut value
235 && items.len() > request.max_items
236 {
237 items.truncate(request.max_items);
238 truncated = true;
239 }
240 record.insert(field.name.clone(), value);
241 }
242 let serialized = serde_json::to_vec(&record)?;
243 if serialized.len() > request.max_bytes {
244 return Err(format!(
245 "extraction exceeds maxBytes ({} > {})",
246 serialized.len(),
247 request.max_bytes
248 )
249 .into());
250 }
251 Ok(StructuredExtractionResult {
252 source_revision: observation.revision,
253 source_route: observation.route,
254 records: vec![record.into()],
255 truncated,
256 provenance: request
257 .fields
258 .iter()
259 .map(|field| field.path.clone())
260 .collect(),
261 })
262 }
263
264 pub fn recover_run(&self, execution_id: &str) -> BrowserResult<RecoverRunResult> {
266 if execution_id.is_empty() || execution_id.len() > 128 {
267 return Err("execution ID must be 1..=128 bytes".into());
268 }
269 Ok(RecoverRunResult {
270 execution_id: execution_id.into(),
271 known: false,
272 phase: "reconciliation".into(),
273 dispatch_happened: false,
274 mutation_possible: true,
275 reconciliation: "session-local execution evidence is unavailable; observe before retry"
276 .into(),
277 retry: RetryGuidance {
278 classification: RetryClassification::UnsafeUntilReconciled,
279 recommended_operation: "inspect_page".into(),
280 },
281 })
282 }
283}
284
285fn validate_extraction_request(request: &StructuredExtractionRequest) -> BrowserResult<()> {
286 if request.fields.is_empty() || request.fields.len() > MAX_EXTRACTION_FIELDS {
287 return Err(format!("fields must contain 1..={MAX_EXTRACTION_FIELDS} entries").into());
288 }
289 if !(1..=MAX_EXTRACTION_ITEMS).contains(&request.max_items) {
290 return Err(format!("maxItems must be 1..={MAX_EXTRACTION_ITEMS}").into());
291 }
292 if !(1..=MAX_EXTRACTION_BYTES).contains(&request.max_bytes) {
293 return Err(format!("maxBytes must be 1..={MAX_EXTRACTION_BYTES}").into());
294 }
295 for field in &request.fields {
296 if field.name.is_empty() || field.name.len() > 128 || field.path.len() > 512 {
297 return Err("extraction field names and paths must be bounded".into());
298 }
299 }
300 Ok(())
301}
302
303fn value_at_path<'a>(value: &'a Value, path: &str) -> Option<&'a Value> {
304 if path.is_empty() || path == "$" {
305 return Some(value);
306 }
307 path.trim_start_matches("$.")
308 .split('.')
309 .try_fold(value, |current, segment| current.get(segment))
310}
311
312fn validate_extracted_value(value: Option<Value>, field: &ExtractionField) -> BrowserResult<Value> {
313 let value = value.unwrap_or(Value::Null);
314 let valid = match field.kind {
315 ExtractionKind::Scalar => value.is_string() || value.is_number() || value.is_boolean(),
316 ExtractionKind::OptionalScalar => {
317 value.is_null() || value.is_string() || value.is_number() || value.is_boolean()
318 }
319 ExtractionKind::List | ExtractionKind::RepeatedItems | ExtractionKind::Table => {
320 value.is_array()
321 }
322 ExtractionKind::Object => value.is_object(),
323 };
324 if !valid {
325 return Err(format!("field {} does not match its declared type", field.name).into());
326 }
327 Ok(value)
328}
329
330fn default_extraction_items() -> usize {
331 64
332}
333
334fn default_extraction_bytes() -> usize {
335 64 * 1024
336}
337
338#[cfg(test)]
339mod tests {
340 use super::*;
341
342 #[test]
343 fn extraction_bounds_and_types_are_checked() {
344 let request = StructuredExtractionRequest {
345 fields: vec![ExtractionField {
346 name: "title".into(),
347 path: "page.title".into(),
348 kind: ExtractionKind::Scalar,
349 }],
350 region_id: None,
351 max_items: 1,
352 max_bytes: 1024,
353 };
354 validate_extraction_request(&request).unwrap();
355 assert_eq!(
356 validate_extracted_value(Some(Value::String("Glass".into())), &request.fields[0])
357 .unwrap(),
358 Value::String("Glass".into())
359 );
360 }
361}