Skip to main content

rustio_core/ai/
intake.rs

1//! Free-text → typed `ProjectSketch` — the entry point of the
2//! AI-assisted onboarding flow (`rustio ai start`).
3//!
4//! ## Why a separate module
5//!
6//! The existing [`planner`](super::planner) takes a *single change*
7//! and emits a `Plan` of primitives. Intake operates one layer up: it
8//! takes the user's one-sentence project description and proposes a
9//! *starting shape* — two to four models with sensible default fields,
10//! built from a curated set of domain templates.
11//!
12//! ## The hard rule
13//!
14//! Intake is **deterministic** and **closed**. There is no LLM call,
15//! no fuzzy guessing, no free-form generation. Each domain template is
16//! a hard-coded `Vec<ModelSketch>`; a description that doesn't match
17//! any keyword set returns `None` (the wizard then asks the user to
18//! re-phrase or drop down to single-model mode).
19//!
20//! This matches the wider AI-layer posture: the planner refuses on
21//! ambiguity; the executor refuses unknown types; intake refuses
22//! unknown domains. Strictness flows top-to-bottom.
23//!
24//! ## What intake produces
25//!
26//! A `ProjectSketch` is shape-only — model names, field names, field
27//! types from [`VALID_TYPE_NAMES`](crate::schema::VALID_TYPE_NAMES),
28//! and a one-line rationale per model. It does **not** produce
29//! `Primitive` ops directly; the wizard converts each accepted
30//! `ModelSketch` into an `AddModel` primitive at apply time, after
31//! the user has accepted it.
32
33use serde::{Deserialize, Serialize};
34
35/// A starting shape proposed to the user. Carries the original
36/// description verbatim so the wizard can echo it back and the
37/// downstream `PlanDocument` can record what was asked for.
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
39pub struct ProjectSketch {
40    /// Short domain slug used in CLI output (`clinic`, `blog`, …).
41    pub domain: &'static str,
42    /// One-line summary of what this template is for.
43    pub headline: &'static str,
44    /// The user's original description, verbatim.
45    pub user_description: String,
46    /// 2–4 models in the order they should be introduced. Earlier
47    /// models are referenced by later ones via `belongs_to`, so order
48    /// matters.
49    pub models: Vec<ModelSketch>,
50}
51
52/// One proposed model. Field types and admin shape are determined
53/// at this layer; the wizard turns this into an `AddModel` primitive
54/// without further transformation.
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
56pub struct ModelSketch {
57    /// Rust struct name in PascalCase (`Patient`).
58    pub struct_name: &'static str,
59    /// Lowercase snake_case plural — the URL slug and the SQLite
60    /// table name (`patients`).
61    pub table: &'static str,
62    pub fields: Vec<FieldSketch>,
63    /// One-sentence rationale shown to the user when the model is
64    /// proposed. The wizard reads this verbatim — keep it plain.
65    pub rationale: &'static str,
66}
67
68/// One field on a proposed model. Type strings are constrained to
69/// [`VALID_TYPE_NAMES`](crate::schema::VALID_TYPE_NAMES) so the
70/// generated primitive validates without a translation step.
71#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
72pub struct FieldSketch {
73    pub name: &'static str,
74    /// `String`, `i64`, `i32`, `bool`, `DateTime`. Anything else is
75    /// rejected by the executor — keep this constrained.
76    pub ty: &'static str,
77    #[serde(default)]
78    pub nullable: bool,
79    /// When set, the wizard renders this field as a foreign key to
80    /// the named model (which must appear earlier in `models`).
81    /// `FieldType::ty` stays `i64`; the relation is added on top
82    /// when the wizard expands the sketch into a primitive.
83    #[serde(default)]
84    pub belongs_to: Option<&'static str>,
85}
86
87/// Parse a free-text description and return a project shape, or
88/// `None` when no domain template matches.
89///
90/// The matcher is intentionally simple: lowercase the input, look for
91/// any of a small set of keywords per domain, return the first
92/// match. We accept false positives (e.g. "I want to blog about
93/// patients" matches `blog` because it appears first in the order)
94/// rather than build a confidence-scoring stack — the user sees the
95/// proposal and can reject it.
96///
97/// Returning `None` is the **right answer** for ambiguous input. The
98/// wizard caller handles it with a clear refusal message and a
99/// follow-up question, not an apologetic best-effort guess.
100pub fn sketch(description: &str) -> Option<ProjectSketch> {
101    let lower = description.to_lowercase();
102    for (keywords, build) in DOMAIN_TABLE {
103        if keywords.iter().any(|k| lower.contains(k)) {
104            return Some(build(description.to_string()));
105        }
106    }
107    None
108}
109
110/// Ordered list of domain templates. First match wins. New domains
111/// append to the end. Keep keyword sets disjoint where possible so
112/// the order matters less in practice.
113type DomainBuilder = fn(String) -> ProjectSketch;
114const DOMAIN_TABLE: &[(&[&str], DomainBuilder)] = &[
115    (
116        &[
117            "clinic",
118            "patient",
119            "doctor",
120            "appointment",
121            "hospital",
122            "medical",
123        ],
124        clinic_sketch,
125    ),
126    (
127        &["blog", "post", "article", "comment", "publish"],
128        blog_sketch,
129    ),
130    (
131        &[
132            "shop",
133            "store",
134            "product",
135            "inventory",
136            "stock",
137            "sku",
138            "order",
139        ],
140        shop_sketch,
141    ),
142    (
143        &[
144            "crm",
145            "customer",
146            "lead",
147            "deal",
148            "contact",
149            "sales pipeline",
150        ],
151        crm_sketch,
152    ),
153    (
154        &["task", "todo", "project", "ticket", "issue", "kanban"],
155        tasks_sketch,
156    ),
157];
158
159// ---- Domain templates ----------------------------------------------------
160//
161// Each one is a small Rust function that returns a `ProjectSketch`.
162// Hand-crafted, not derived. They live here (not in `industry.rs`)
163// because they target the *intake* layer — they describe what to
164// build, not what compliance signals to surface.
165//
166// Constraints:
167//   - 2–4 models per template; more becomes overwhelming in the wizard.
168//   - Only types from VALID_TYPE_NAMES.
169//   - Foreign keys via `belongs_to` reference an earlier model.
170//   - Rationale strings are sentence-case, one line, no jargon.
171
172fn clinic_sketch(description: String) -> ProjectSketch {
173    ProjectSketch {
174        domain: "clinic",
175        headline: "A small clinic — patients, doctors, appointments.",
176        user_description: description,
177        models: vec![
178            ModelSketch {
179                struct_name: "Patient",
180                table: "patients",
181                rationale: "Each person you treat. Name is required; date of birth is useful for the chart, phone for reminders.",
182                fields: vec![
183                    FieldSketch { name: "name",          ty: "String",   nullable: false, belongs_to: None },
184                    FieldSketch { name: "date_of_birth", ty: "DateTime", nullable: true,  belongs_to: None },
185                    FieldSketch { name: "phone",         ty: "String",   nullable: true,  belongs_to: None },
186                ],
187            },
188            ModelSketch {
189                struct_name: "Doctor",
190                table: "doctors",
191                rationale: "The staff who see patients. Specialty helps when scheduling.",
192                fields: vec![
193                    FieldSketch { name: "name",      ty: "String", nullable: false, belongs_to: None },
194                    FieldSketch { name: "specialty", ty: "String", nullable: true,  belongs_to: None },
195                ],
196            },
197            ModelSketch {
198                struct_name: "Appointment",
199                table: "appointments",
200                rationale: "A scheduled visit — links a patient to a doctor with a time.",
201                fields: vec![
202                    FieldSketch { name: "patient_id", ty: "i64",      nullable: false, belongs_to: Some("Patient") },
203                    FieldSketch { name: "doctor_id",  ty: "i64",      nullable: false, belongs_to: Some("Doctor")  },
204                    FieldSketch { name: "scheduled_for", ty: "DateTime", nullable: false, belongs_to: None },
205                    FieldSketch { name: "notes",      ty: "String",   nullable: true,  belongs_to: None },
206                ],
207            },
208        ],
209    }
210}
211
212fn blog_sketch(description: String) -> ProjectSketch {
213    ProjectSketch {
214        domain: "blog",
215        headline: "A blog — authors and posts.",
216        user_description: description,
217        models: vec![
218            ModelSketch {
219                struct_name: "Author",
220                table: "authors",
221                rationale: "The people who write. Name is required; bio is optional.",
222                fields: vec![
223                    FieldSketch {
224                        name: "name",
225                        ty: "String",
226                        nullable: false,
227                        belongs_to: None,
228                    },
229                    FieldSketch {
230                        name: "bio",
231                        ty: "String",
232                        nullable: true,
233                        belongs_to: None,
234                    },
235                ],
236            },
237            ModelSketch {
238                struct_name: "Post",
239                table: "posts",
240                rationale: "One article. Title, body, and a publication timestamp.",
241                fields: vec![
242                    FieldSketch {
243                        name: "author_id",
244                        ty: "i64",
245                        nullable: false,
246                        belongs_to: Some("Author"),
247                    },
248                    FieldSketch {
249                        name: "title",
250                        ty: "String",
251                        nullable: false,
252                        belongs_to: None,
253                    },
254                    FieldSketch {
255                        name: "body",
256                        ty: "String",
257                        nullable: false,
258                        belongs_to: None,
259                    },
260                    FieldSketch {
261                        name: "published_at",
262                        ty: "DateTime",
263                        nullable: true,
264                        belongs_to: None,
265                    },
266                ],
267            },
268        ],
269    }
270}
271
272fn shop_sketch(description: String) -> ProjectSketch {
273    ProjectSketch {
274        domain: "shop",
275        headline: "A small shop — products and orders.",
276        user_description: description,
277        models: vec![
278            ModelSketch {
279                struct_name: "Product",
280                table: "products",
281                rationale: "What you sell. SKU is the unique identifier; stock is what's on hand.",
282                fields: vec![
283                    FieldSketch { name: "sku",       ty: "String", nullable: false, belongs_to: None },
284                    FieldSketch { name: "name",      ty: "String", nullable: false, belongs_to: None },
285                    FieldSketch { name: "price_cents",ty: "i64",   nullable: false, belongs_to: None },
286                    FieldSketch { name: "stock",     ty: "i64",    nullable: false, belongs_to: None },
287                ],
288            },
289            ModelSketch {
290                struct_name: "Order",
291                table: "orders",
292                rationale: "A single transaction. Carries the buyer's email so you can reach them without a separate Customer table on day one.",
293                fields: vec![
294                    FieldSketch { name: "product_id",  ty: "i64",      nullable: false, belongs_to: Some("Product") },
295                    FieldSketch { name: "quantity",    ty: "i64",      nullable: false, belongs_to: None },
296                    FieldSketch { name: "buyer_email", ty: "String",   nullable: false, belongs_to: None },
297                    FieldSketch { name: "placed_at",   ty: "DateTime", nullable: false, belongs_to: None },
298                ],
299            },
300        ],
301    }
302}
303
304fn crm_sketch(description: String) -> ProjectSketch {
305    ProjectSketch {
306        domain: "crm",
307        headline: "A small CRM — companies, contacts, deals.",
308        user_description: description,
309        models: vec![
310            ModelSketch {
311                struct_name: "Company",
312                table: "companies",
313                rationale: "An organisation you might sell to.",
314                fields: vec![
315                    FieldSketch { name: "name",    ty: "String", nullable: false, belongs_to: None },
316                    FieldSketch { name: "website", ty: "String", nullable: true,  belongs_to: None },
317                ],
318            },
319            ModelSketch {
320                struct_name: "Contact",
321                table: "contacts",
322                rationale: "A person at a company. Belongs to one Company.",
323                fields: vec![
324                    FieldSketch { name: "company_id", ty: "i64",    nullable: false, belongs_to: Some("Company") },
325                    FieldSketch { name: "name",       ty: "String", nullable: false, belongs_to: None },
326                    FieldSketch { name: "email",      ty: "String", nullable: true,  belongs_to: None },
327                    FieldSketch { name: "phone",      ty: "String", nullable: true,  belongs_to: None },
328                ],
329            },
330            ModelSketch {
331                struct_name: "Deal",
332                table: "deals",
333                rationale: "An opportunity. Linked to a Contact; status tracks stage; amount is in cents to keep arithmetic clean.",
334                fields: vec![
335                    FieldSketch { name: "contact_id",  ty: "i64",      nullable: false, belongs_to: Some("Contact") },
336                    FieldSketch { name: "title",       ty: "String",   nullable: false, belongs_to: None },
337                    FieldSketch { name: "status",      ty: "String",   nullable: false, belongs_to: None },
338                    FieldSketch { name: "amount_cents",ty: "i64",      nullable: true,  belongs_to: None },
339                    FieldSketch { name: "closed_at",   ty: "DateTime", nullable: true,  belongs_to: None },
340                ],
341            },
342        ],
343    }
344}
345
346fn tasks_sketch(description: String) -> ProjectSketch {
347    ProjectSketch {
348        domain: "tasks",
349        headline: "A task tracker — projects and tasks.",
350        user_description: description,
351        models: vec![
352            ModelSketch {
353                struct_name: "Project",
354                table: "projects",
355                rationale: "A container for related tasks.",
356                fields: vec![
357                    FieldSketch { name: "name",        ty: "String", nullable: false, belongs_to: None },
358                    FieldSketch { name: "description", ty: "String", nullable: true,  belongs_to: None },
359                ],
360            },
361            ModelSketch {
362                struct_name: "Task",
363                table: "tasks",
364                rationale: "One thing to do. Status moves from todo → in_progress → done; priority is a small integer.",
365                fields: vec![
366                    FieldSketch { name: "project_id", ty: "i64",      nullable: false, belongs_to: Some("Project") },
367                    FieldSketch { name: "title",      ty: "String",   nullable: false, belongs_to: None },
368                    FieldSketch { name: "status",     ty: "String",   nullable: false, belongs_to: None },
369                    FieldSketch { name: "priority",   ty: "i64",      nullable: true,  belongs_to: None },
370                    FieldSketch { name: "due_at",     ty: "DateTime", nullable: true,  belongs_to: None },
371                ],
372            },
373        ],
374    }
375}
376
377// ---- Expansion to a `Plan` -----------------------------------------------
378
379use crate::ai::{AddModel, AddRelation, FieldSpec, Plan, Primitive, RelationKind};
380
381/// Expand a single `ModelSketch` into the primitives that create it:
382/// one `AddModel` for the table, then one `AddRelation` per `belongs_to`
383/// field. The wizard calls this once per *accepted* model so primitives
384/// from skipped models never reach the plan.
385///
386/// All `AddRelation`s are emitted as `belongs_to`; the relation layer
387/// (0.9.x) enforces them at SQL FK level once the model lands.
388pub fn primitives_for(model: &ModelSketch) -> Vec<Primitive> {
389    let fields: Vec<FieldSpec> = model
390        .fields
391        .iter()
392        .map(|f| FieldSpec {
393            name: f.name.to_string(),
394            ty: f.ty.to_string(),
395            nullable: f.nullable,
396            editable: true,
397        })
398        .collect();
399
400    let mut out: Vec<Primitive> = Vec::with_capacity(1 + model.fields.len());
401    out.push(Primitive::AddModel(AddModel {
402        name: model.struct_name.to_string(),
403        table: model.table.to_string(),
404        fields,
405    }));
406    for f in &model.fields {
407        if let Some(target) = f.belongs_to {
408            // The wizard always introduces relations as `belongs_to` with
409            // the default `ON DELETE RESTRICT` posture. Fancier modes
410            // (Cascade, SetNull, required=true on top of an existing
411            // table) are out of scope for intake — they belong to a
412            // later `rustio ai plan` invocation if the user wants them.
413            out.push(Primitive::AddRelation(AddRelation {
414                from: model.struct_name.to_string(),
415                kind: RelationKind::BelongsTo,
416                to: target.to_string(),
417                via: f.name.to_string(),
418                required: false,
419                on_delete: Default::default(),
420            }));
421        }
422    }
423    out
424}
425
426/// Convenience: build a `Plan` from a list of accepted models. The
427/// caller is responsible for ordering — `belongs_to` targets must be
428/// added before the model that references them. The domain templates
429/// in this module already encode the right order.
430pub fn plan_for(accepted: &[ModelSketch]) -> Plan {
431    let mut steps: Vec<Primitive> = Vec::new();
432    for m in accepted {
433        steps.extend(primitives_for(m));
434    }
435    Plan { steps }
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn clinic_keyword_yields_clinic_sketch() {
444        let s = sketch("a small clinic with patients and appointments").unwrap();
445        assert_eq!(s.domain, "clinic");
446        let names: Vec<&str> = s.models.iter().map(|m| m.struct_name).collect();
447        assert_eq!(names, vec!["Patient", "Doctor", "Appointment"]);
448    }
449
450    #[test]
451    fn ambiguous_input_refuses() {
452        assert!(sketch("I want to build something").is_none());
453        assert!(sketch("").is_none());
454    }
455
456    #[test]
457    fn shop_template_uses_only_valid_types() {
458        use crate::schema::VALID_TYPE_NAMES;
459        let s = sketch("a shop with products and orders").unwrap();
460        for m in &s.models {
461            for f in &m.fields {
462                assert!(
463                    VALID_TYPE_NAMES.contains(&f.ty),
464                    "field {}.{} has invalid type `{}`",
465                    m.struct_name,
466                    f.name,
467                    f.ty
468                );
469            }
470        }
471    }
472
473    #[test]
474    fn belongs_to_targets_an_earlier_model() {
475        for descr in [
476            "clinic",
477            "blog",
478            "shop with products",
479            "crm with deals",
480            "tasks",
481        ] {
482            let s = sketch(descr).unwrap();
483            let mut seen: Vec<&str> = Vec::new();
484            for m in &s.models {
485                for f in &m.fields {
486                    if let Some(target) = f.belongs_to {
487                        assert!(
488                            seen.contains(&target),
489                            "{}.{} → `{}` references a model not yet introduced",
490                            m.struct_name,
491                            f.name,
492                            target
493                        );
494                    }
495                }
496                seen.push(m.struct_name);
497            }
498        }
499    }
500
501    #[test]
502    fn primitives_for_emits_add_model_then_relations() {
503        let s = sketch("clinic").unwrap();
504        let appointment = s
505            .models
506            .iter()
507            .find(|m| m.struct_name == "Appointment")
508            .unwrap();
509        let ops = primitives_for(appointment);
510        // Exactly one AddModel + one AddRelation per belongs_to field
511        // (Appointment has two: patient_id, doctor_id).
512        assert!(matches!(ops.first(), Some(Primitive::AddModel(_))));
513        let n_relations = ops
514            .iter()
515            .filter(|p| matches!(p, Primitive::AddRelation(_)))
516            .count();
517        assert_eq!(n_relations, 2);
518    }
519
520    #[test]
521    fn plan_for_full_sketch_validates_against_empty_schema() {
522        use crate::schema::{Schema, SCHEMA_VERSION};
523        // No `Schema::empty()` helper exists — build the minimal valid
524        // shape inline. `models: []` is the canonical empty schema; the
525        // planner / executor treat it as "fresh project, no tables yet."
526        let empty = Schema {
527            version: SCHEMA_VERSION,
528            rustio_version: env!("CARGO_PKG_VERSION").to_string(),
529            models: vec![],
530        };
531        let sk = sketch("a small clinic").unwrap();
532        let plan = plan_for(&sk.models);
533        // The plan should simulate cleanly against a fresh schema —
534        // belongs_to targets are added before referrers thanks to the
535        // template ordering.
536        plan.validate(&empty)
537            .expect("clinic sketch should simulate cleanly against empty schema");
538    }
539}