Add demo app with mock, Ollama, OpenAI, and Anthropic provider support

Example iOS app that seeds a task management SQLite database and
renders SwiftDBAI's DataChatView. Ships with a mock LLM for offline
use and an OllamaWithSystemPrompt wrapper that fixes AnyLanguageModel's
Ollama adapter dropping system instructions.

Tested successfully with:
- DemoLanguageModel (mock, pattern-matched SQL)
- Ollama qwen3-coder-30b (local, real SQL generation)
- OpenAI gpt-4o-mini (JOINs, GROUP BY, multi-turn)
- Anthropic claude-haiku-4.5 (complex aggregations)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-04-04 12:25:22 -05:00
parent b1724fe7ca
commit 80109d702c
5 changed files with 405 additions and 0 deletions

View File

@@ -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
}
}

View File

@@ -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<Content>(
within session: LanguageModelSession,
to prompt: Prompt,
generating type: Content.Type,
includeSchemaInPrompt: Bool,
options: GenerationOptions
) async throws -> LanguageModelSession.Response<Content> 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<Content>(
within session: LanguageModelSession,
to prompt: Prompt,
generating type: Content.Type,
includeSchemaInPrompt: Bool,
options: GenerationOptions
) -> sending LanguageModelSession.ResponseStream<Content> 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."
}
}

View File

@@ -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<Content>(
within session: LanguageModelSession,
to prompt: Prompt,
generating type: Content.Type,
includeSchemaInPrompt: Bool,
options: GenerationOptions
) async throws -> LanguageModelSession.Response<Content> 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<Content>(
within session: LanguageModelSession,
to prompt: Prompt,
generating type: Content.Type,
includeSchemaInPrompt: Bool,
options: GenerationOptions
) -> sending LanguageModelSession.ResponseStream<Content> where Content: Generable {
inner.streamResponse(
within: session,
to: prompt,
generating: type,
includeSchemaInPrompt: includeSchemaInPrompt,
options: options
)
}
}

View File

@@ -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
}
}
}
}
}

View File

@@ -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