qualia_client_core/
studio_pane_llm.rs1use crate::chat_inference;
7use crate::model_lifecycle;
8use crate::studio_pane_generator::{
9 generate_panes_from_request, GeneratePaneRequest, PaneGenerationPlan, PanePlacement,
10 PresentationMode,
11};
12use qualia_core_db::orchestrator::ModelLifecycle;
13
14const LAYOUT_SESSION: &str = "studio-pane-layout";
15
16pub fn generate_panes_with_llm_or_fallback(req: &GeneratePaneRequest) -> PaneGenerationPlan {
18 if req.use_llm.unwrap_or(true) {
19 if let Some(plan) = try_llm_layout(req) {
20 return plan;
21 }
22 }
23 generate_panes_from_request(req)
24}
25
26fn try_llm_layout(req: &GeneratePaneRequest) -> Option<PaneGenerationPlan> {
27 if model_lifecycle::get_model_lifecycle_state() != ModelLifecycle::Active {
28 return None;
29 }
30
31 let palette_hint = if req.palette_ids.is_empty() {
32 "health-monitor, sparql-explorer, n3-logic-studio, neuro-symbolic-chat, card-view"
33 .to_string()
34 } else {
35 req.palette_ids.join(", ")
36 };
37
38 let prompt = format!(
39 "You are a Qualia Webizen studio layout planner. Return ONLY valid JSON (no markdown) with this shape:\n\
40 {{\"presentation\":\"GridBound|NodeRelational|Spatial\",\"summary\":\"...\",\"panes\":[{{\"component_id\":\"...\",\"x\":0,\"y\":0,\"w\":40,\"h\":30,\"data_bindings\":[\"ns:term\"]}}]}}\n\
41 Grid is 96x64 points. Use component_id values from this palette: {palette_hint}.\n\
42 User request: {}",
43 req.prompt
44 );
45
46 let result = chat_inference::run_chat_inference_with_options(LAYOUT_SESSION, &prompt, None);
47 if !result.committed || result.block_reason.is_some() {
48 return None;
49 }
50
51 parse_llm_plan_json(&result.text).map(|mut plan| {
52 if plan.summary.is_empty() {
53 plan.summary = format!("LLM layout for: {}", req.prompt);
54 }
55 plan
56 })
57}
58
59pub fn parse_llm_plan_json(text: &str) -> Option<PaneGenerationPlan> {
61 let trimmed = text.trim();
62 let json_body = extract_json_object(trimmed)?;
63 let raw: LlmPlanJson = serde_json::from_str(json_body).ok()?;
64 if raw.panes.is_empty() {
65 return None;
66 }
67 let presentation = match raw.presentation.to_ascii_lowercase().as_str() {
68 "spatial" => PresentationMode::Spatial,
69 "noderelational" | "node_relational" | "nodes" => PresentationMode::NodeRelational,
70 _ => PresentationMode::GridBound,
71 };
72 Some(PaneGenerationPlan {
73 panes: raw
74 .panes
75 .into_iter()
76 .map(|p| PanePlacement {
77 component_id: p.component_id,
78 x: p.x,
79 y: p.y,
80 w: p.w,
81 h: p.h,
82 data_bindings: p.data_bindings,
83 })
84 .collect(),
85 presentation,
86 summary: raw
87 .summary
88 .unwrap_or_else(|| "LLM-generated layout".to_string()),
89 })
90}
91
92#[derive(serde::Deserialize)]
93struct LlmPlanJson {
94 #[serde(default)]
95 presentation: String,
96 #[serde(default)]
97 summary: Option<String>,
98 panes: Vec<LlmPaneJson>,
99}
100
101#[derive(serde::Deserialize)]
102struct LlmPaneJson {
103 component_id: String,
104 x: u16,
105 y: u16,
106 w: u16,
107 h: u16,
108 #[serde(default)]
109 data_bindings: Vec<String>,
110}
111
112fn extract_json_object(text: &str) -> Option<&str> {
113 if text.starts_with('{') {
114 return Some(text);
115 }
116 if let Some(start) = text.find("```json") {
117 let rest = &text[start + 7..];
118 if let Some(end) = rest.find("```") {
119 return Some(rest[..end].trim());
120 }
121 }
122 if let Some(start) = text.find('{') {
123 if let Some(end) = text.rfind('}') {
124 return Some(&text[start..=end]);
125 }
126 }
127 None
128}
129
130#[cfg(test)]
131mod tests {
132 use super::*;
133
134 #[test]
135 fn parses_llm_json_layout() {
136 let json = r#"{"presentation":"Spatial","summary":"test","panes":[{"component_id":"nexus","x":0,"y":0,"w":40,"h":36,"data_bindings":[]}]}"#;
137 let plan = parse_llm_plan_json(json).unwrap();
138 assert_eq!(plan.presentation, PresentationMode::Spatial);
139 assert_eq!(plan.panes[0].component_id, "nexus");
140 }
141
142 #[test]
143 fn fallback_when_llm_inactive() {
144 let plan = generate_panes_with_llm_or_fallback(&GeneratePaneRequest {
145 prompt: "health vitals".to_string(),
146 palette_ids: vec![],
147 ontology_domain: None,
148 use_llm: Some(true),
149 });
150 assert!(!plan.panes.is_empty());
151 }
152}