Skip to main content

qualia_client_core/
studio_pane_llm.rs

1//! Optional local-LLM layout generation for `/generate_pane`.
2//!
3//! When a model is active, asks the orchestrated chat path for a JSON layout plan.
4//! Falls back to the keyword planner in `studio_pane_generator` on any failure.
5
6use 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
16/// Generate a pane layout, preferring local LLM output when available.
17pub 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
59/// Parse model JSON into a layout plan; tolerates fenced code blocks.
60pub 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}