1use crate::compiler::models::*;
7use crate::map::types::*;
8use std::collections::HashMap;
9
10const OPCODE_NAV: u8 = 0x00;
12const OPCODE_FORM: u8 = 0x01;
13const OPCODE_CART: u8 = 0x02;
14const OPCODE_AUTH: u8 = 0x03;
15const OPCODE_MEDIA: u8 = 0x04;
16const OPCODE_SOCIAL: u8 = 0x05;
17const OPCODE_DATA: u8 = 0x06;
18
19pub fn compile_actions(site_map: &SiteMap, models: &[DataModel]) -> Vec<CompiledAction> {
24 let mut compiled: Vec<CompiledAction> = Vec::new();
25 let mut seen: HashMap<(String, String), bool> = HashMap::new();
26
27 let node_to_model = build_node_model_map(site_map, models);
29
30 for (node_idx, node) in site_map.nodes.iter().enumerate() {
32 let action_start = if node_idx < site_map.action_index.len() {
34 site_map.action_index[node_idx] as usize
35 } else {
36 continue;
37 };
38 let action_end = if node_idx + 1 < site_map.action_index.len() {
39 site_map.action_index[node_idx + 1] as usize
40 } else {
41 site_map.actions.len()
42 };
43
44 for action_idx in action_start..action_end {
45 if action_idx >= site_map.actions.len() {
46 break;
47 }
48
49 let action = &site_map.actions[action_idx];
50 let (action_name, belongs_to, is_instance) =
51 classify_action(action, node.page_type, &node_to_model, node_idx);
52
53 let key = (belongs_to.clone(), action_name.clone());
55 if seen.contains_key(&key) {
56 continue;
57 }
58 seen.insert(key, true);
59
60 let (http_method, endpoint, params) =
61 infer_action_details(action, &action_name, &belongs_to);
62
63 let execution_path = if action.http_executable {
64 "http".to_string()
65 } else {
66 "browser".to_string()
67 };
68
69 compiled.push(CompiledAction {
70 name: action_name,
71 belongs_to,
72 is_instance_method: is_instance,
73 http_method,
74 endpoint_template: endpoint,
75 params,
76 requires_auth: action.risk >= 1 || action.opcode.category == OPCODE_AUTH,
77 execution_path,
78 confidence: if action.http_executable { 0.9 } else { 0.7 },
79 });
80 }
81 }
82
83 add_global_actions(&mut compiled, site_map, &seen);
85
86 compiled
87}
88
89fn build_node_model_map(site_map: &SiteMap, models: &[DataModel]) -> HashMap<usize, String> {
91 let mut map: HashMap<usize, String> = HashMap::new();
92
93 for model in models {
94 for (idx, node) in site_map.nodes.iter().enumerate() {
95 if let Some(schema_type) = page_type_to_schema_name(node.page_type) {
96 if schema_type == model.schema_org_type {
97 map.insert(idx, model.name.clone());
98 }
99 }
100 }
101 }
102
103 map
104}
105
106fn page_type_to_schema_name(pt: PageType) -> Option<&'static str> {
108 match pt {
109 PageType::ProductDetail => Some("Product"),
110 PageType::ProductListing => Some("ProductListing"),
111 PageType::Article => Some("Article"),
112 PageType::ReviewList => Some("Review"),
113 PageType::Cart => Some("Cart"),
114 PageType::Checkout => Some("CheckoutPage"),
115 PageType::Account => Some("Account"),
116 PageType::Login => Some("LoginPage"),
117 PageType::Home => Some("WebSite"),
118 PageType::SearchResults => Some("SearchResultsPage"),
119 _ => None,
120 }
121}
122
123fn classify_action(
126 action: &ActionRecord,
127 _page_type: PageType,
128 node_to_model: &HashMap<usize, String>,
129 node_idx: usize,
130) -> (String, String, bool) {
131 let cat = action.opcode.category;
132 let act = action.opcode.action;
133
134 let (name, model, is_instance) = match cat {
135 OPCODE_NAV => match act {
136 0x00 => ("click".to_string(), "Site".to_string(), false),
137 0x01 => ("navigate".to_string(), "Site".to_string(), false),
138 0x02 => ("scroll".to_string(), "Site".to_string(), false),
139 _ => ("navigate".to_string(), "Site".to_string(), false),
140 },
141 OPCODE_FORM => match act {
142 0x00 => ("submit_form".to_string(), "Site".to_string(), false),
143 0x01 => ("search".to_string(), "Site".to_string(), false),
144 0x02 => ("filter".to_string(), "Site".to_string(), false),
145 0x03 => ("sort".to_string(), "Site".to_string(), false),
146 _ => ("submit".to_string(), "Site".to_string(), false),
147 },
148 OPCODE_CART => {
149 let model = node_to_model
150 .get(&node_idx)
151 .cloned()
152 .unwrap_or_else(|| "Product".to_string());
153 match act {
154 0x00 => ("add_to_cart".to_string(), model, true),
155 0x01 => ("remove_from_cart".to_string(), "Cart".to_string(), true),
156 0x02 => ("update_quantity".to_string(), "Cart".to_string(), true),
157 0x03 => ("apply_coupon".to_string(), "Cart".to_string(), false),
158 0x04 => ("checkout".to_string(), "Cart".to_string(), false),
159 0x05 => ("add_to_wishlist".to_string(), model, true),
160 _ => ("cart_action".to_string(), "Cart".to_string(), false),
161 }
162 }
163 OPCODE_AUTH => match act {
164 0x00 => ("login".to_string(), "Site".to_string(), false),
165 0x01 => ("logout".to_string(), "Site".to_string(), false),
166 0x02 => ("register".to_string(), "Site".to_string(), false),
167 _ => ("auth_action".to_string(), "Site".to_string(), false),
168 },
169 OPCODE_MEDIA => match act {
170 0x00 => ("play".to_string(), "Media".to_string(), true),
171 0x01 => ("pause".to_string(), "Media".to_string(), true),
172 0x02 => ("download".to_string(), "Media".to_string(), true),
173 _ => ("media_action".to_string(), "Media".to_string(), true),
174 },
175 OPCODE_SOCIAL => match act {
176 0x00 => ("like".to_string(), "Site".to_string(), true),
177 0x01 => ("share".to_string(), "Site".to_string(), true),
178 0x02 => ("comment".to_string(), "Site".to_string(), true),
179 0x03 => ("follow".to_string(), "Site".to_string(), true),
180 _ => ("social_action".to_string(), "Site".to_string(), true),
181 },
182 OPCODE_DATA => match act {
183 0x00 => ("export".to_string(), "Site".to_string(), false),
184 0x01 => ("import".to_string(), "Site".to_string(), false),
185 _ => ("data_action".to_string(), "Site".to_string(), false),
186 },
187 _ => {
188 let model = node_to_model
189 .get(&node_idx)
190 .cloned()
191 .unwrap_or_else(|| "Site".to_string());
192 (format!("action_{cat:02x}_{act:02x}"), model, false)
193 }
194 };
195
196 (name, model, is_instance)
197}
198
199fn infer_action_details(
201 action: &ActionRecord,
202 name: &str,
203 _belongs_to: &str,
204) -> (String, String, Vec<ActionParam>) {
205 let cat = action.opcode.category;
206
207 match name {
208 "search" => (
209 "GET".to_string(),
210 "/search?q={query}".to_string(),
211 vec![ActionParam {
212 name: "query".to_string(),
213 param_type: FieldType::String,
214 required: true,
215 default_value: None,
216 source: "url_param".to_string(),
217 }],
218 ),
219 "add_to_cart" => (
220 "POST".to_string(),
221 "/cart/add".to_string(),
222 vec![
223 ActionParam {
224 name: "node_id".to_string(),
225 param_type: FieldType::Integer,
226 required: true,
227 default_value: None,
228 source: "json_body".to_string(),
229 },
230 ActionParam {
231 name: "quantity".to_string(),
232 param_type: FieldType::Integer,
233 required: false,
234 default_value: Some("1".to_string()),
235 source: "json_body".to_string(),
236 },
237 ],
238 ),
239 "remove_from_cart" => (
240 "POST".to_string(),
241 "/cart/remove".to_string(),
242 vec![ActionParam {
243 name: "node_id".to_string(),
244 param_type: FieldType::Integer,
245 required: true,
246 default_value: None,
247 source: "json_body".to_string(),
248 }],
249 ),
250 "apply_coupon" => (
251 "POST".to_string(),
252 "/cart/coupon".to_string(),
253 vec![ActionParam {
254 name: "code".to_string(),
255 param_type: FieldType::String,
256 required: true,
257 default_value: None,
258 source: "json_body".to_string(),
259 }],
260 ),
261 "checkout" => (
262 "POST".to_string(),
263 "/checkout".to_string(),
264 vec![ActionParam {
265 name: "payment_method".to_string(),
266 param_type: FieldType::String,
267 required: false,
268 default_value: Some("saved_card".to_string()),
269 source: "json_body".to_string(),
270 }],
271 ),
272 "login" => (
273 "POST".to_string(),
274 "/auth/login".to_string(),
275 vec![
276 ActionParam {
277 name: "email".to_string(),
278 param_type: FieldType::String,
279 required: true,
280 default_value: None,
281 source: "json_body".to_string(),
282 },
283 ActionParam {
284 name: "password".to_string(),
285 param_type: FieldType::String,
286 required: true,
287 default_value: None,
288 source: "json_body".to_string(),
289 },
290 ],
291 ),
292 "filter" | "sort" => (
293 "GET".to_string(),
294 format!("/{name}"),
295 vec![ActionParam {
296 name: "criteria".to_string(),
297 param_type: FieldType::String,
298 required: true,
299 default_value: None,
300 source: "url_param".to_string(),
301 }],
302 ),
303 _ => {
304 let method = if cat == OPCODE_NAV || cat == OPCODE_FORM {
306 "GET"
307 } else {
308 "POST"
309 };
310 (
311 method.to_string(),
312 format!("/{}", name.replace('_', "-")),
313 Vec::new(),
314 )
315 }
316 }
317}
318
319fn add_global_actions(
321 compiled: &mut Vec<CompiledAction>,
322 site_map: &SiteMap,
323 seen: &HashMap<(String, String), bool>,
324) {
325 let has_search = site_map
327 .nodes
328 .iter()
329 .any(|n| n.page_type == PageType::SearchResults);
330 if has_search && !seen.contains_key(&("Site".to_string(), "search".to_string())) {
331 compiled.push(CompiledAction {
332 name: "search".to_string(),
333 belongs_to: "Site".to_string(),
334 is_instance_method: false,
335 http_method: "GET".to_string(),
336 endpoint_template: "/search?q={query}".to_string(),
337 params: vec![ActionParam {
338 name: "query".to_string(),
339 param_type: FieldType::String,
340 required: true,
341 default_value: None,
342 source: "url_param".to_string(),
343 }],
344 requires_auth: false,
345 execution_path: "http".to_string(),
346 confidence: 0.8,
347 });
348 }
349
350 let has_cart = site_map.nodes.iter().any(|n| n.page_type == PageType::Cart);
352 if has_cart && !seen.contains_key(&("Cart".to_string(), "view".to_string())) {
353 compiled.push(CompiledAction {
354 name: "view".to_string(),
355 belongs_to: "Cart".to_string(),
356 is_instance_method: false,
357 http_method: "GET".to_string(),
358 endpoint_template: "/cart".to_string(),
359 params: Vec::new(),
360 requires_auth: false,
361 execution_path: "http".to_string(),
362 confidence: 0.75,
363 });
364 }
365}
366
367#[cfg(test)]
368mod tests {
369 use super::*;
370 use crate::map::builder::SiteMapBuilder;
371
372 #[test]
373 fn test_compile_actions_basic() {
374 let mut builder = SiteMapBuilder::new("shop.com");
375
376 let feats = [0.0f32; FEATURE_DIM];
377 builder.add_node("https://shop.com/", PageType::Home, feats, 240);
378 builder.add_node(
379 "https://shop.com/product/1",
380 PageType::ProductDetail,
381 feats,
382 200,
383 );
384 builder.add_node("https://shop.com/cart", PageType::Cart, feats, 200);
385 builder.add_node(
386 "https://shop.com/search",
387 PageType::SearchResults,
388 feats,
389 200,
390 );
391
392 builder.add_action(1, OpCode::new(OPCODE_CART, 0x00), -1, 0, 1); builder.add_action(2, OpCode::new(OPCODE_CART, 0x04), -1, 0, 1); let map = builder.build();
397 let models = vec![
398 DataModel {
399 name: "Product".to_string(),
400 schema_org_type: "Product".to_string(),
401 fields: vec![],
402 instance_count: 1,
403 example_urls: vec![],
404 search_action: None,
405 list_url: None,
406 },
407 DataModel {
408 name: "Cart".to_string(),
409 schema_org_type: "Cart".to_string(),
410 fields: vec![],
411 instance_count: 1,
412 example_urls: vec![],
413 search_action: None,
414 list_url: None,
415 },
416 ];
417
418 let actions = compile_actions(&map, &models);
419 assert!(!actions.is_empty());
420
421 let atc = actions.iter().find(|a| a.name == "add_to_cart");
423 assert!(atc.is_some(), "should find add_to_cart action");
424 let atc = atc.unwrap();
425 assert!(atc.is_instance_method);
426 assert_eq!(atc.http_method, "POST");
427
428 let checkout = actions.iter().find(|a| a.name == "checkout");
430 assert!(checkout.is_some(), "should find checkout action");
431 }
432
433 #[test]
434 fn test_compile_actions_adds_global_search() {
435 let mut builder = SiteMapBuilder::new("news.com");
436 let feats = [0.0f32; FEATURE_DIM];
437
438 builder.add_node("https://news.com/", PageType::Home, feats, 240);
439 builder.add_node(
440 "https://news.com/search",
441 PageType::SearchResults,
442 feats,
443 200,
444 );
445
446 let map = builder.build();
447 let actions = compile_actions(&map, &[]);
448
449 let search = actions.iter().find(|a| a.name == "search");
450 assert!(search.is_some(), "should auto-add search action");
451 let search = search.unwrap();
452 assert_eq!(search.belongs_to, "Site");
453 assert!(!search.is_instance_method);
454 }
455
456 #[test]
457 fn test_classify_action_cart_opcode() {
458 let action = ActionRecord {
459 opcode: OpCode::new(OPCODE_CART, 0x00),
460 target_node: -1,
461 cost_hint: 0,
462 risk: 0,
463 http_executable: true,
464 };
465
466 let node_to_model = HashMap::from([(5usize, "Product".to_string())]);
467
468 let (name, model, is_instance) =
469 classify_action(&action, PageType::ProductDetail, &node_to_model, 5);
470
471 assert_eq!(name, "add_to_cart");
472 assert_eq!(model, "Product");
473 assert!(is_instance);
474 }
475}