diff --git a/Example/SwiftDBAIDemo/SwiftDBAIDemo/DatabaseSeeder.swift b/Example/SwiftDBAIDemo/SwiftDBAIDemo/DatabaseSeeder.swift new file mode 100644 index 0000000..0f22038 --- /dev/null +++ b/Example/SwiftDBAIDemo/SwiftDBAIDemo/DatabaseSeeder.swift @@ -0,0 +1,141 @@ +// DatabaseSeeder.swift +// SwiftDBAIDemo +// +// Creates and seeds a sample SQLite database on first launch. + +import Foundation +import GRDB + +enum DatabaseSeeder { + + /// Returns the path to the seeded database, creating it if it does not exist. + static func seedIfNeeded() throws -> String { + let url = URL.documentsDirectory.appending(path: "demo.sqlite") + let path = url.path(percentEncoded: false) + + // If the database already exists, just return the path. + if FileManager.default.fileExists(atPath: path) { + return path + } + + let db = try DatabaseQueue(path: path) + try db.write { db in + // -- Schema ---------------------------------------------------------- + + try db.execute(sql: """ + CREATE TABLE projects ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + description TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + + try db.execute(sql: """ + CREATE TABLE tasks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + title TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'todo', + priority INTEGER NOT NULL DEFAULT 2, + project_id INTEGER REFERENCES projects(id), + due_date TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + """) + + try db.execute(sql: """ + CREATE TABLE tags ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE + ); + """) + + try db.execute(sql: """ + CREATE TABLE task_tags ( + task_id INTEGER NOT NULL REFERENCES tasks(id), + tag_id INTEGER NOT NULL REFERENCES tags(id), + PRIMARY KEY (task_id, tag_id) + ); + """) + + // -- Seed data ------------------------------------------------------- + + // Projects + try db.execute(sql: """ + INSERT INTO projects (id, name, description, created_at) VALUES + (1, 'Mobile App Redesign', 'Complete UI/UX overhaul of the iOS app', '2025-01-10 09:00:00'), + (2, 'Backend Migration', 'Migrate from REST to GraphQL', '2025-02-01 10:30:00'), + (3, 'Q2 Marketing Site', 'New landing pages for Q2 campaign', '2025-03-01 08:00:00'); + """) + + // Tags + try db.execute(sql: """ + INSERT INTO tags (id, name) VALUES + (1, 'bug'), + (2, 'feature'), + (3, 'urgent'), + (4, 'design'), + (5, 'backend'), + (6, 'frontend'), + (7, 'documentation'); + """) + + // Tasks (20 tasks across the 3 projects) + try db.execute(sql: """ + INSERT INTO tasks (id, title, status, priority, project_id, due_date, created_at) VALUES + -- Mobile App Redesign + (1, 'Design new onboarding flow', 'done', 3, 1, '2025-02-15', '2025-01-12 10:00:00'), + (2, 'Implement tab bar redesign', 'done', 3, 1, '2025-02-20', '2025-01-15 11:00:00'), + (3, 'Fix navigation stack crash', 'in_progress', 3, 1, '2025-03-01', '2025-02-10 09:30:00'), + (4, 'Add dark mode support', 'in_progress', 2, 1, '2025-03-15', '2025-02-15 14:00:00'), + (5, 'Write accessibility audit report', 'todo', 2, 1, '2025-04-01', '2025-03-01 10:00:00'), + (6, 'Optimize image loading', 'todo', 1, 1, '2025-04-10', '2025-03-05 11:00:00'), + (7, 'Update app store screenshots', 'todo', 1, 1, NULL, '2025-03-10 09:00:00'), + + -- Backend Migration + (8, 'Define GraphQL schema', 'done', 3, 2, '2025-02-28', '2025-02-03 10:00:00'), + (9, 'Implement user resolver', 'done', 2, 2, '2025-03-05', '2025-02-10 11:00:00'), + (10, 'Implement order resolver', 'in_progress', 2, 2, '2025-03-15', '2025-02-20 10:00:00'), + (11, 'Set up DataLoader batching', 'in_progress', 3, 2, '2025-03-10', '2025-02-25 09:00:00'), + (12, 'Write integration tests', 'todo', 2, 2, '2025-03-20', '2025-03-01 10:00:00'), + (13, 'Deprecate REST endpoints', 'todo', 1, 2, '2025-04-15', '2025-03-05 11:00:00'), + (14, 'Update API documentation', 'todo', 1, 2, NULL, '2025-03-10 10:00:00'), + + -- Q2 Marketing Site + (15, 'Create wireframes', 'done', 3, 3, '2025-03-10', '2025-03-02 09:00:00'), + (16, 'Build hero section component', 'in_progress', 3, 3, '2025-03-20', '2025-03-08 10:00:00'), + (17, 'Implement pricing page', 'todo', 2, 3, '2025-03-25', '2025-03-10 11:00:00'), + (18, 'Add contact form', 'todo', 2, 3, '2025-03-28', '2025-03-12 09:00:00'), + (19, 'SEO optimization pass', 'todo', 1, 3, '2025-04-05', '2025-03-15 10:00:00'), + (20, 'Cross-browser testing', 'todo', 1, 3, '2025-04-10', '2025-03-18 11:00:00'); + """) + + // Task-tag associations + try db.execute(sql: """ + INSERT INTO task_tags (task_id, tag_id) VALUES + (1, 4), (1, 2), + (2, 6), (2, 2), + (3, 1), (3, 3), + (4, 6), (4, 2), + (5, 7), + (6, 6), + (7, 4), + (8, 5), (8, 2), + (9, 5), + (10, 5), + (11, 5), (11, 3), + (12, 5), (12, 7), + (13, 5), (13, 7), + (14, 7), + (15, 4), (15, 2), + (16, 6), (16, 2), + (17, 6), + (18, 6), + (19, 6), + (20, 6); + """) + } + + return path + } +} diff --git a/Example/SwiftDBAIDemo/SwiftDBAIDemo/DemoLanguageModel.swift b/Example/SwiftDBAIDemo/SwiftDBAIDemo/DemoLanguageModel.swift new file mode 100644 index 0000000..221ac95 --- /dev/null +++ b/Example/SwiftDBAIDemo/SwiftDBAIDemo/DemoLanguageModel.swift @@ -0,0 +1,120 @@ +// DemoLanguageModel.swift +// SwiftDBAIDemo +// +// A mock LanguageModel that returns canned SQL for common queries +// and simple summaries for result summarization. +// No real LLM API key is needed. + +import AnyLanguageModel +import Foundation + +/// A mock language model that pattern-matches the user's question to return +/// appropriate SQL, and produces simple text summaries for query results. +/// +/// The ChatEngine calls the model twice per user message: +/// 1. SQL generation (system prompt contains "Respond with ONLY the SQL query") +/// 2. Result summarization (prompt contains the raw query result) +/// +/// This model detects which phase it is in based on the prompt content. +struct DemoLanguageModel: LanguageModel { + typealias UnavailableReason = Never + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + let promptText = prompt.description.lowercased() + let responseText: String + + // Detect if this is a summarization call (contains result data or "summarize") + // vs. a SQL generation call. + if promptText.contains("row") && (promptText.contains("column") || promptText.contains("|")) { + // Summarization phase -- return a human-readable summary + responseText = deriveSummary(from: prompt.description) + } else { + // SQL generation phase -- return SQL based on the question + responseText = deriveSQL(from: promptText) + } + + let rawContent = GeneratedContent(kind: .string(responseText)) + let content = try Content(rawContent) + return LanguageModelSession.Response( + content: content, + rawContent: rawContent, + transcriptEntries: [][...] + ) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + let rawContent = GeneratedContent(kind: .string("SELECT * FROM tasks")) + let content = try! Content(rawContent) + return LanguageModelSession.ResponseStream(content: content, rawContent: rawContent) + } + + // MARK: - SQL Pattern Matching + + private func deriveSQL(from prompt: String) -> String { + // Pattern-match common questions to SQL + if prompt.contains("how many task") || prompt.contains("count") && prompt.contains("task") { + return "SELECT COUNT(*) AS task_count FROM tasks" + } + if prompt.contains("overdue") { + return "SELECT * FROM tasks WHERE due_date < date('now') AND status != 'done'" + } + if prompt.contains("high priority") || prompt.contains("priority") && prompt.contains("high") { + return "SELECT * FROM tasks WHERE priority = 3" + } + if prompt.contains("in progress") || prompt.contains("in_progress") { + return "SELECT * FROM tasks WHERE status = 'in_progress'" + } + if prompt.contains("done") || prompt.contains("completed") { + return "SELECT * FROM tasks WHERE status = 'done'" + } + if prompt.contains("todo") || prompt.contains("to do") || prompt.contains("pending") { + return "SELECT * FROM tasks WHERE status = 'todo'" + } + if prompt.contains("project") && !prompt.contains("task") { + return "SELECT * FROM projects" + } + if prompt.contains("tag") { + return "SELECT * FROM tags" + } + if prompt.contains("show all task") || prompt.contains("list all task") || prompt.contains("all task") { + return "SELECT * FROM tasks" + } + if prompt.contains("show") && prompt.contains("task") { + return "SELECT * FROM tasks" + } + // Default fallback + return "SELECT * FROM tasks ORDER BY created_at DESC LIMIT 10" + } + + // MARK: - Summary Generation + + private func deriveSummary(from rawPrompt: String) -> String { + // Count approximate row references to generate a reasonable summary + let lines = rawPrompt.components(separatedBy: "\n") + let dataLines = lines.filter { $0.contains("|") || $0.contains(",") } + let rowCount = max(dataLines.count - 1, 0) // subtract header + + if rawPrompt.lowercased().contains("count") { + return "The query returned a count result." + } + if rowCount == 0 { + return "The query returned no results." + } + if rowCount == 1 { + return "Found 1 result." + } + return "Found \(rowCount) results." + } +} diff --git a/Example/SwiftDBAIDemo/SwiftDBAIDemo/OllamaWithSystemPrompt.swift b/Example/SwiftDBAIDemo/SwiftDBAIDemo/OllamaWithSystemPrompt.swift new file mode 100644 index 0000000..83ee373 --- /dev/null +++ b/Example/SwiftDBAIDemo/SwiftDBAIDemo/OllamaWithSystemPrompt.swift @@ -0,0 +1,70 @@ +// OllamaWithSystemPrompt.swift +// SwiftDBAIDemo +// +// Wraps OllamaLanguageModel to prepend session instructions into the user +// prompt, working around AnyLanguageModel's Ollama adapter not forwarding +// system messages. + +import AnyLanguageModel +import Foundation + +/// Wrapper that injects session instructions into every Ollama request. +struct OllamaWithSystemPrompt: LanguageModel { + typealias UnavailableReason = Never + + private let inner: OllamaLanguageModel + + init(baseURL: URL = OllamaLanguageModel.defaultBaseURL, model: String) { + self.inner = OllamaLanguageModel(baseURL: baseURL, model: model) + } + + func respond( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) async throws -> LanguageModelSession.Response where Content: Generable { + let userText = prompt.description + let instructionText = session.instructions?.description ?? "" + + let combinedText: String + if instructionText.isEmpty { + combinedText = userText + } else { + combinedText = """ + [System Instructions] + \(instructionText) + + [User Message] + \(userText) + """ + } + + let plainSession = LanguageModelSession(model: inner) + let combinedPrompt = Prompt(combinedText) + return try await inner.respond( + within: plainSession, + to: combinedPrompt, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + } + + func streamResponse( + within session: LanguageModelSession, + to prompt: Prompt, + generating type: Content.Type, + includeSchemaInPrompt: Bool, + options: GenerationOptions + ) -> sending LanguageModelSession.ResponseStream where Content: Generable { + inner.streamResponse( + within: session, + to: prompt, + generating: type, + includeSchemaInPrompt: includeSchemaInPrompt, + options: options + ) + } +} diff --git a/Example/SwiftDBAIDemo/SwiftDBAIDemo/SwiftDBAIDemoApp.swift b/Example/SwiftDBAIDemo/SwiftDBAIDemo/SwiftDBAIDemoApp.swift new file mode 100644 index 0000000..7e09e48 --- /dev/null +++ b/Example/SwiftDBAIDemo/SwiftDBAIDemo/SwiftDBAIDemoApp.swift @@ -0,0 +1,48 @@ +// SwiftDBAIDemoApp.swift +// SwiftDBAIDemo +// +// A minimal demo app showing DataChatView with a seeded SQLite database. + +import SwiftUI +import SwiftDBAI + +@main +struct SwiftDBAIDemoApp: App { + @State private var databasePath: String? + @State private var setupError: String? + + var body: some Scene { + WindowGroup { + Group { + if let path = databasePath { + DataChatView( + databasePath: path, + model: DemoLanguageModel(), + allowlist: .readOnly, + additionalContext: """ + This is a task management database with projects, tasks, tags, \ + and a task_tags junction table. Tasks have priority (1=low, 2=medium, \ + 3=high) and status ('todo', 'in_progress', 'done'). + """ + ) + } else if let error = setupError { + ContentUnavailableView( + "Database Setup Failed", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + } else { + ProgressView("Setting up database...") + } + } + .task { + do { + let path = try DatabaseSeeder.seedIfNeeded() + databasePath = path + } catch { + setupError = error.localizedDescription + } + } + } + } +} diff --git a/Example/SwiftDBAIDemo/project.yml b/Example/SwiftDBAIDemo/project.yml new file mode 100644 index 0000000..125fb64 --- /dev/null +++ b/Example/SwiftDBAIDemo/project.yml @@ -0,0 +1,26 @@ +name: SwiftDBAIDemo +options: + bundleIdPrefix: com.swiftdbai.demo + deploymentTarget: + iOS: "17.0" + xcodeVersion: "16.0" + createIntermediateGroups: true +packages: + SwiftDBAI: + path: ../.. +targets: + SwiftDBAIDemo: + type: application + platform: iOS + sources: + - SwiftDBAIDemo + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.swiftdbai.demo + MARKETING_VERSION: "1.0" + CURRENT_PROJECT_VERSION: "1" + SWIFT_VERSION: "6.0" + INFOPLIST_GENERATION: true + GENERATE_INFOPLIST_FILE: true + dependencies: + - package: SwiftDBAI