Skip to main content

cortex_runtime/compiler/
actions.rs

1//! Action compilation — transforms discovered HTTP actions into typed method definitions.
2//!
3//! Maps SiteMap action records to model methods (instance and class methods),
4//! infers parameter types, and classifies actions by the model they belong to.
5
6use crate::compiler::models::*;
7use crate::map::types::*;
8use std::collections::HashMap;
9
10/// OpCode category constants (from the spec).
11const 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
19/// Compile actions from SiteMap action records into typed method definitions.
20///
21/// Groups actions by model, classifies as instance or class methods,
22/// and infers parameter types.
23pub 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    // Build node→model lookup
28    let node_to_model = build_node_model_map(site_map, models);
29
30    // Iterate over all actions in the site map
31    for (node_idx, node) in site_map.nodes.iter().enumerate() {
32        // Get actions for this node using CSR index
33        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            // Deduplicate: only one definition per (model, action_name)
54            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 well-known global actions
84    add_global_actions(&mut compiled, site_map, &seen);
85
86    compiled
87}
88
89/// Build a lookup from node index to model name.
90fn 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
106/// Map PageType to Schema.org type name.
107fn 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
123/// Classify an action: determine its name, which model it belongs to, and whether
124/// it's an instance method.
125fn 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
199/// Infer HTTP details for a compiled action.
200fn 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            // Default: POST with empty params
305            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
319/// Add well-known global actions that most sites have.
320fn add_global_actions(
321    compiled: &mut Vec<CompiledAction>,
322    site_map: &SiteMap,
323    seen: &HashMap<(String, String), bool>,
324) {
325    // If site has search results pages but no search action, add one
326    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    // If site has a cart page but no view_cart action, add one
351    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        // Add actions
393        builder.add_action(1, OpCode::new(OPCODE_CART, 0x00), -1, 0, 1); // add_to_cart
394        builder.add_action(2, OpCode::new(OPCODE_CART, 0x04), -1, 0, 1); // checkout
395
396        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        // Should find add_to_cart
422        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        // Should find checkout
429        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}