Skip to main content

component_qa/
lib.rs

1#[cfg(target_arch = "wasm32")]
2use std::collections::BTreeMap;
3
4#[cfg(target_arch = "wasm32")]
5use greentic_interfaces_guest::component_v0_6::node;
6#[cfg(target_arch = "wasm32")]
7use greentic_types::cbor::canonical;
8#[cfg(target_arch = "wasm32")]
9use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
10
11pub mod i18n;
12pub mod i18n_bundle;
13pub mod qa;
14pub use qa::{
15    apply_store, describe, get_answer_schema, get_example_answers, next, next_with_ctx,
16    render_card, render_json_ui, render_text, submit_all, submit_patch, validate_answers,
17};
18
19const COMPONENT_NAME: &str = "component-qa";
20const COMPONENT_ORG: &str = "ai.greentic";
21const COMPONENT_VERSION: &str = env!("CARGO_PKG_VERSION");
22
23#[cfg(target_arch = "wasm32")]
24#[used]
25#[unsafe(link_section = ".greentic.wasi")]
26static WASI_TARGET_MARKER: [u8; 13] = *b"wasm32-wasip2";
27
28#[cfg(target_arch = "wasm32")]
29struct Component;
30
31#[cfg(target_arch = "wasm32")]
32impl node::Guest for Component {
33    fn describe() -> node::ComponentDescriptor {
34        let input_schema_cbor = input_schema_cbor();
35        let output_schema_cbor = output_schema_cbor();
36        node::ComponentDescriptor {
37            name: COMPONENT_NAME.to_string(),
38            version: COMPONENT_VERSION.to_string(),
39            summary: Some("Greentic QA component".to_string()),
40            capabilities: Vec::new(),
41            ops: vec![
42                node::Op {
43                    name: "handle_message".to_string(),
44                    summary: Some("Handle a single message input".to_string()),
45                    input: node::IoSchema {
46                        schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
47                        content_type: "application/cbor".to_string(),
48                        schema_version: None,
49                    },
50                    output: node::IoSchema {
51                        schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
52                        content_type: "application/cbor".to_string(),
53                        schema_version: None,
54                    },
55                    examples: Vec::new(),
56                },
57                node::Op {
58                    name: "qa-spec".to_string(),
59                    summary: Some("Return QA spec (CBOR) for a requested mode".to_string()),
60                    input: node::IoSchema {
61                        schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
62                        content_type: "application/cbor".to_string(),
63                        schema_version: None,
64                    },
65                    output: node::IoSchema {
66                        schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
67                        content_type: "application/cbor".to_string(),
68                        schema_version: None,
69                    },
70                    examples: Vec::new(),
71                },
72                node::Op {
73                    name: "apply-answers".to_string(),
74                    summary: Some(
75                        "Apply QA answers and optionally return config override".to_string(),
76                    ),
77                    input: node::IoSchema {
78                        schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
79                        content_type: "application/cbor".to_string(),
80                        schema_version: None,
81                    },
82                    output: node::IoSchema {
83                        schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
84                        content_type: "application/cbor".to_string(),
85                        schema_version: None,
86                    },
87                    examples: Vec::new(),
88                },
89                node::Op {
90                    name: "i18n-keys".to_string(),
91                    summary: Some("Return i18n keys referenced by QA/setup".to_string()),
92                    input: node::IoSchema {
93                        schema: node::SchemaSource::InlineCbor(input_schema_cbor),
94                        content_type: "application/cbor".to_string(),
95                        schema_version: None,
96                    },
97                    output: node::IoSchema {
98                        schema: node::SchemaSource::InlineCbor(output_schema_cbor),
99                        content_type: "application/cbor".to_string(),
100                        schema_version: None,
101                    },
102                    examples: Vec::new(),
103                },
104            ],
105            schemas: Vec::new(),
106            setup: None,
107        }
108    }
109
110    fn invoke(
111        operation: String,
112        envelope: node::InvocationEnvelope,
113    ) -> Result<node::InvocationResult, node::NodeError> {
114        Ok(node::InvocationResult {
115            ok: true,
116            output_cbor: run_component_cbor(&operation, envelope.payload_cbor),
117            output_metadata_cbor: None,
118        })
119    }
120}
121
122#[cfg(target_arch = "wasm32")]
123greentic_interfaces_guest::export_component_v060!(Component);
124
125pub fn describe_payload() -> String {
126    serde_json::json!({
127        "component": {
128            "name": COMPONENT_NAME,
129            "org": COMPONENT_ORG,
130            "version": COMPONENT_VERSION,
131            "world": "greentic:component/component@0.6.0",
132            "self_describing": true
133        }
134    })
135    .to_string()
136}
137
138pub fn handle_message(operation: &str, input: &str) -> String {
139    format!("{COMPONENT_NAME}::{operation} => {}", input.trim())
140}
141
142#[cfg(target_arch = "wasm32")]
143fn encode_cbor<T: serde::Serialize>(value: &T) -> Vec<u8> {
144    canonical::to_canonical_cbor_allow_floats(value).expect("encode cbor")
145}
146
147#[cfg(target_arch = "wasm32")]
148fn parse_payload(input: &[u8]) -> serde_json::Value {
149    if let Ok(value) = canonical::from_cbor(input) {
150        return value;
151    }
152    serde_json::from_slice(input).unwrap_or_else(|_| serde_json::json!({}))
153}
154
155#[cfg(target_arch = "wasm32")]
156fn input_schema() -> SchemaIr {
157    SchemaIr::Object {
158        properties: BTreeMap::from([(
159            "input".to_string(),
160            SchemaIr::String {
161                min_len: Some(0),
162                max_len: None,
163                regex: None,
164                format: None,
165            },
166        )]),
167        required: vec!["input".to_string()],
168        additional: AdditionalProperties::Allow,
169    }
170}
171
172#[cfg(target_arch = "wasm32")]
173fn output_schema() -> SchemaIr {
174    SchemaIr::Object {
175        properties: BTreeMap::from([(
176            "message".to_string(),
177            SchemaIr::String {
178                min_len: Some(0),
179                max_len: None,
180                regex: None,
181                format: None,
182            },
183        )]),
184        required: vec!["message".to_string()],
185        additional: AdditionalProperties::Allow,
186    }
187}
188
189#[cfg(target_arch = "wasm32")]
190fn input_schema_cbor() -> Vec<u8> {
191    encode_cbor(&input_schema())
192}
193
194#[cfg(target_arch = "wasm32")]
195fn output_schema_cbor() -> Vec<u8> {
196    encode_cbor(&output_schema())
197}
198
199#[cfg(target_arch = "wasm32")]
200fn normalized_mode(payload: &serde_json::Value) -> qa::NormalizedMode {
201    let mode = payload
202        .get("mode")
203        .and_then(|v| v.as_str())
204        .or_else(|| payload.get("operation").and_then(|v| v.as_str()))
205        .unwrap_or("setup");
206    qa::normalize_mode(mode).unwrap_or(qa::NormalizedMode::Setup)
207}
208
209#[cfg(target_arch = "wasm32")]
210fn run_component_cbor(operation: &str, input: Vec<u8>) -> Vec<u8> {
211    let value = parse_payload(&input);
212    let output = match operation {
213        "qa-spec" => {
214            let mode = normalized_mode(&value);
215            qa::qa_spec_json(mode, &value)
216        }
217        "apply-answers" => {
218            let mode = normalized_mode(&value);
219            qa::apply_answers(mode, &value)
220        }
221        "i18n-keys" => serde_json::Value::Array(
222            qa::i18n_keys()
223                .into_iter()
224                .map(serde_json::Value::String)
225                .collect(),
226        ),
227        _ => {
228            let op_name = value
229                .get("operation")
230                .and_then(|v| v.as_str())
231                .unwrap_or(operation);
232            let input_text = value
233                .get("input")
234                .and_then(|v| v.as_str())
235                .map(ToOwned::to_owned)
236                .unwrap_or_else(|| value.to_string());
237            serde_json::json!({
238                "message": handle_message(op_name, &input_text)
239            })
240        }
241    };
242
243    encode_cbor(&output)
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn describe_payload_is_json() {
252        let payload = describe_payload();
253        let json: serde_json::Value = serde_json::from_str(&payload).expect("valid json");
254        assert_eq!(json["component"]["name"], "component-qa");
255    }
256
257    #[test]
258    fn handle_message_round_trips() {
259        let body = handle_message("handle", "demo");
260        assert!(body.contains("demo"));
261    }
262}