Skip to content

JuggernautLabs/semantic-query

Repository files navigation

Semantic Query

Stream-first, schema-aware AI querying. Extract structured data from LLM responses while preserving explanatory text, with automatic JSON Schema guidance and real-time streaming support.

Quick Start

use serde::{Deserialize, Serialize};
use schemars::JsonSchema;
use semantic_query::core::{QueryResolver, RetryConfig};
use semantic_query::clients::flexible::FlexibleClient;

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct QuizQuestion {
    /// The main question text to be asked
    pub question: String,
    /// Answer choice A
    pub a: String,
    /// Answer choice B
    pub b: String,
    /// Answer choice C
    pub c: String,
    /// Answer choice D
    pub d: String,
    /// The correct answer (must be exactly one of: A, B, C, or D)
    #[schemars(regex(pattern = r"^[ABCD]$"))]
    pub correct_answer: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
struct Quiz {
    pub questions: Vec<QuizQuestion>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    // Load .env first so RUST_LOG in .env is seen
    let _ = dotenvy::dotenv();
    // Initialize tracing from RUST_LOG if provided
    let _ = tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .try_init();
    
    // Create client (env handled by FlexibleClient)
    let client = FlexibleClient::claude();
    let resolver = QueryResolver::new(client, RetryConfig::default());
    
    // Get 10 science quiz questions (schema guidance is automatic)
    let response = resolver.query::<Quiz>(
        "Create 10 high school science quiz questions with A, B, C, D answers".to_string()
    ).await?;
    
    // Extract the quiz data (new API returns ParsedResponse with mixed content)
    let quiz = response.first_required()?;
    
    // Administer the quiz
    administer_quiz(quiz.questions).await;
    Ok(())
}

async fn administer_quiz(questions: Vec<QuizQuestion>) {
    let mut score = 0;
    let total = questions.len();
    
    for (i, question) in questions.iter().enumerate() {
        println!("\nQuestion {}: {}", i + 1, question.question);
        println!("A) {}", question.a);
        println!("B) {}", question.b);
        println!("C) {}", question.c);
        println!("D) {}", question.d);
        
        let mut input = String::new();
        std::io::stdin().read_line(&mut input).unwrap();
        let answer = input.trim().to_uppercase();
        
        if answer == question.correct_answer.to_uppercase() {
            score += 1;
        }
    }
    
    println!("\n🎯 Quiz Complete! Final Score: {}/{} ({}%)", 
             score, total, (score * 100) / total);
}

Setup: Add ANTHROPIC_API_KEY=your_key_here to .env file.

Core Features

Mixed Content Support

LLMs naturally produce mixed content - explanatory text alongside structured data. Instead of forcing everything to be JSON, we preserve both:

// Query with automatic schema guidance
let response = resolver.query::<Analysis>("Analyze this code for issues").await?;

// Access different parts of the response
let all_analyses = response.data_only();        // Vec<&Analysis> - all structured data
let full_text = response.text_content();        // String - complete text including JSON
let first = response.first_required()?;         // Analysis - first item or error

// Iterate through mixed content preserving order
for item in &response.items {
    match item {
        ResponseItem::Text(text) => println!("Explanation: {}", text.text),
        ResponseItem::Data { data, original_text } => {
            println!("Found issue: {}", data.issue);
            println!("Original JSON: {}", original_text);
        }
    }
}

Real-Time Streaming

Stream responses token-by-token while automatically extracting structured data:

use futures_util::StreamExt;
use semantic_query::streaming::StreamItem;

let mut stream = resolver.stream_query::<ToolCall>("Help me debug this").await?;
while let Some(item) = stream.next().await {
    match item? {
        StreamItem::Token(tok) => print!("{}", tok),  // Real-time text
        StreamItem::Text(text) => {                    // Completed text chunk
            println!("\n[Assistant] {}", text.text);
        }
        StreamItem::Data(tool) => {                    // Structured data found
            println!("\n[Tool Call] {}: {:?}", tool.name, tool.args);
        }
    }
}

Architecture Overview

Core Components

  • QueryResolver<C: LowLevelClient> - Main API wrapper that adds schema guidance and parsing
  • LowLevelClient trait - Simple provider abstraction requiring only ask_raw() method
  • ParsedResponse<T> - Mixed content response with ordered ResponseItem<T>
  • JsonStreamProcessor - Incremental JSON parsing with structural scanning
  • SSE Providers - Provider-specific Server-Sent Events parsing

Supported Providers

  • Claude/Anthropic - Native streaming, automatic API key detection
  • DeepSeek - Full streaming support, automatic API key detection
  • ChatGPT/OpenAI - OpenAI and Azure OpenAI support with streaming
  • AWS Bedrock - Feature-gated Claude support via AWS SDK
  • Mock Client - Testing and development with controllable responses

Flexible Client Selection

// Auto-select based on available API keys
let client = FlexibleClient::auto();

// Explicit provider selection
let client = FlexibleClient::claude();
let client = FlexibleClient::deepseek();
let client = FlexibleClient::chatgpt();

// From environment detection
let client = FlexibleClient::from_type(ClientType::Claude);

// Mock for testing
let (client, handle) = FlexibleClient::mock();
handle.add_response("Mock response with JSON data");

API Reference

Main Query Methods

// Schema-guided mixed content extraction (recommended)
let response: ParsedResponse<T> = resolver.query::<T>(prompt).await?;

// Raw mixed content without schema guidance
let response: ParsedResponse<T> = resolver.query_mixed::<T>(prompt).await?;

// Real-time streaming with automatic JSON extraction
let mut stream = resolver.stream_query::<T>(prompt).await?;

Response Access

// Get all structured data items
let data_items: Vec<&T> = response.data_only();

// Get complete text content (including JSON)
let full_text: String = response.text_content();

// Get first data item (error if none)
let first: T = response.first_required()?;

// Get first data item (Option)
let first_opt: Option<&T> = response.first();

// Check if any data was found
let has_data: bool = response.has_data();

// Count data items
let count: usize = response.data_count();

Streaming Items

enum StreamItem<T> {
    Token(String),           // Individual tokens for real-time display
    Text(TextContent),       // Free-form text chunks
    Data(T),                 // Structured data matching schema T
}

Setup & Configuration

Environment Variables

Create a .env file with your API keys:

# Required for at least one provider
ANTHROPIC_API_KEY=your_anthropic_key_here
DEEPSEEK_API_KEY=your_deepseek_key_here  
OPENAI_API_KEY=your_openai_key_here

# Optional Azure OpenAI configuration
AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/
AZURE_OPENAI_API_KEY=your_azure_key
AZURE_OPENAI_DEPLOYMENT=your-deployment-name
AZURE_OPENAI_API_VERSION=2024-02-15-preview

# Optional AWS Bedrock (requires aws-bedrock-sdk feature)
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=your_access_key
AWS_SECRET_ACCESS_KEY=your_secret_key

# Optional logging
RUST_LOG=semantic_query=debug

Cargo Features

Enable specific providers via Cargo features:

[dependencies]
semantic-query = { version = "0.2", features = ["anthropic", "deepseek"] }

# For AWS Bedrock support
semantic-query = { version = "0.2", features = ["aws-bedrock-sdk", "bedrock", "anthropic"] }

Available features:

  • anthropic - Claude/Anthropic support
  • deepseek - DeepSeek support
  • bedrock - AWS Bedrock support (Claude only)
  • aws-bedrock-sdk - Full AWS SDK integration

Advanced Usage

Custom Client Configuration

use semantic_query::clients::{ClaudeConfig, ClaudeModel, DeepSeekConfig, DeepSeekModel};

// Custom Claude configuration
let claude_config = ClaudeConfig::anthropic(
    "your_api_key".to_string(),
    ClaudeModel::Sonnet35
);
let client = FlexibleClient::claude_with(claude_config);

// Custom DeepSeek configuration  
let deepseek_config = DeepSeekConfig {
    api_key: "your_api_key".to_string(),
    model: DeepSeekModel::Coder,
    max_tokens: 2048,
    temperature: 0.7,
};
let client = FlexibleClient::deepseek_with(deepseek_config);

Request Interceptors

use semantic_query::interceptors::FileInterceptor;

// Log all prompts and responses to files
let client = FlexibleClient::claude()
    .with_file_interceptor("./query_logs".into());

// Custom interceptor implementation
struct MyInterceptor;
#[async_trait]
impl Interceptor for MyInterceptor {
    async fn save(&self, prompt: &str, response: &str) -> Result<(), Box<dyn Error>> {
        // Custom logging logic
        Ok(())
    }
}

let client = client.with_interceptor(Arc::new(MyInterceptor));

Error Handling

match resolver.query::<MyData>(prompt).await {
    Ok(response) => {
        // Success - handle mixed content
    }
    Err(QueryResolverError::Ai(ai_error)) => {
        // AI provider error (rate limit, auth, etc.)
    }
    Err(QueryResolverError::DataExtraction(data_error)) => {
        // No structured data found in response
    }
    Err(QueryResolverError::JsonDeserialization(json_error, raw)) => {
        // JSON parsing failed
    }
    Err(QueryResolverError::MaxRetriesExceeded) => {
        // Retry limit reached
    }
}

Examples

Run the provided examples to see different features:

# Main demo with quiz generation
cargo run --example readme_demo

# Streaming demo with real-time output
cargo run --example readme_demo_streaming

# Mixed content demo showing V2 improvements
cargo run --example resolver_v2_demo

# Schema validation demo
cargo run --example schema_demo

# Benchmark tool (tests all providers)
cargo run --bin benchmark

# DeepSeek live tests (requires network + key)
cargo test --test deepseek_live -- --ignored --nocapture

Schema-Aware Prompt Generation

The library automatically converts your struct's doc comments and constraints into JSON schema and includes them in the AI prompt:

#[derive(Debug, Deserialize, JsonSchema)]
struct UserProfile {
    /// The user's full name
    pub name: String,
    /// Email address must be valid format
    #[schemars(regex(pattern = r"^[^@]+@[^@]+\.[^@]+$"))]
    pub email: String,
    /// Age must be between 0 and 150
    #[schemars(minimum = 0, maximum = 150)]
    pub age: u32,
    /// User's interests (max 10 items)
    #[schemars(max_items = 10)]
    pub interests: Vec<String>,
}

This ensures the AI understands exactly what each field represents and enforces constraints.

Migration from Legacy API

The old single-item APIs are deprecated. Migrate as follows:

// Old API (deprecated)
let result: T = resolver.query_with_schema::<T>(prompt).await?;

// New API - Option 1: Simple migration with first_required()
let result: T = resolver.query::<T>(prompt).await?.first_required()?;

// New API - Option 2: Handle multiple results
let response = resolver.query::<T>(prompt).await?;
for item in response.data_only() {
    process_item(item);
}

// New API - Option 3: Access mixed content
let response = resolver.query::<T>(prompt).await?;
println!("Full explanation: {}", response.text_content());
println!("Found {} data items", response.data_count());

Testing

Unit Tests

# Run all tests
cargo test

# Run specific test modules
cargo test --test stream_parser_tests
cargo test --test sse_aggregator_tests
cargo test --test json_extract_tests

# Run with logging
RUST_LOG=semantic_query=debug cargo test -- --nocapture

Live Provider Tests

# DeepSeek live tests (ignored by default)
DEEPSEEK_API_KEY=your_key cargo test --test deepseek_live -- --ignored --nocapture

# Provider comparison tests
cargo test --test provider_comparison_rstest -- --nocapture

Development

Linting & Code Quality

# Rustc warnings
cargo check --all-targets --examples

# Strict rustc
RUSTFLAGS='-D warnings -W unused_braces' cargo check --all-targets --examples

# Clippy (recommended)
cargo clippy --all-targets --all-features -- -W clippy::all -W clippy::nursery -W clippy::pedantic -W rust-2018-idioms -W warnings

Building with Features

# Default features (anthropic + deepseek)
cargo build

# With AWS Bedrock support
cargo build --features aws-bedrock-sdk,bedrock,anthropic

# All providers
cargo build --features anthropic,deepseek,bedrock,aws-bedrock-sdk

# Minimal build (mock only)
cargo build --no-default-features

Contributing

This project uses a modular architecture:

  1. Core (src/core.rs) - Main QueryResolver API and response types
  2. Clients (src/clients/) - Provider implementations (Claude, DeepSeek, ChatGPT)
  3. Streaming (src/streaming/) - SSE parsing and JSON extraction
  4. JSON Utilities (src/json_utils.rs) - Structural JSON scanning
  5. Interceptors (src/interceptors/) - Request/response logging

When adding new providers, implement the LowLevelClient trait and add corresponding SSE provider if streaming is supported.

About

A tool to query AI for structured meaning

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages