feat(ai-assistant): bootstrap rust axum backend with providers, routes, and db scaffolding

This commit is contained in:
Ashwin Kumar 2026-04-11 15:04:14 +02:00
commit dbba72478c
49 changed files with 4607 additions and 0 deletions

12
.env.example Normal file
View file

@ -0,0 +1,12 @@
APP_HOST=0.0.0.0
APP_PORT=8080
RUST_LOG=info
DATABASE_URL=postgres://postgres:postgres@localhost:5432/nxtgauge_ai_assistant
OLLAMA_BASE_URL=http://localhost:11434
OLLAMA_CHAT_MODEL=smollm2:360m
OLLAMA_EMBED_MODEL=nomic-embed-text
HELP_CENTER_SEED_PATH=./seeds/help_articles.json
TICKETS_SOURCE=chatbot

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
.env
.DS_Store
.sqlx

3203
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

23
Cargo.toml Normal file
View file

@ -0,0 +1,23 @@
[package]
name = "nxtgauge-ai-assistant"
version = "0.1.0"
edition = "2021"
[dependencies]
axum = { version = "0.7", features = ["macros"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
tower-http = { version = "0.5", features = ["cors", "trace"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
async-trait = "0.1"
uuid = { version = "1", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio-rustls", "postgres", "chrono", "uuid", "migrate", "macros"] }
url = "2"
[dev-dependencies]
http-body-util = "0.1"

19
Dockerfile Normal file
View file

@ -0,0 +1,19 @@
FROM rust:1.87-alpine AS builder
RUN apk add --no-cache musl-dev pkgconfig openssl-dev
WORKDIR /app
COPY Cargo.toml Cargo.lock* ./
COPY src ./src
COPY migrations ./migrations
COPY seeds ./seeds
RUN cargo build --release
FROM alpine:3.20
RUN apk add --no-cache ca-certificates
WORKDIR /app
COPY --from=builder /app/target/release/nxtgauge-ai-assistant /usr/local/bin/nxtgauge-ai-assistant
COPY --from=builder /app/migrations ./migrations
COPY --from=builder /app/seeds ./seeds
ENV APP_HOST=0.0.0.0
ENV APP_PORT=8080
EXPOSE 8080
CMD ["/usr/local/bin/nxtgauge-ai-assistant"]

60
README.md Normal file
View file

@ -0,0 +1,60 @@
# nxtgauge-ai-assistant
Backend-only Rust service for Nxtgauge AI workflows.
## Scope (MVP)
- Job description generation
- Form filling assistance
- Help article retrieval
- Support ticket creation via chatbot
## Stack
- Rust + Axum
- Ollama (default local runtime)
- Postgres scaffolding via `sqlx`
- Provider abstractions for future runtime swaps
## Run
```bash
cp .env.example .env
cargo run
```
## Endpoints
- `GET /health`
- `POST /api/v1/chat/message`
- `POST /api/v1/jobs/generate-description`
- `POST /api/v1/forms/extract`
- `POST /api/v1/tickets/create`
- `POST /api/v1/help/search`
## Environment
- `APP_HOST`
- `APP_PORT`
- `DATABASE_URL`
- `OLLAMA_BASE_URL`
- `OLLAMA_CHAT_MODEL` (default `smollm2:360m`)
- `OLLAMA_EMBED_MODEL` (default `nomic-embed-text`)
- `HELP_CENTER_SEED_PATH`
- `TICKETS_SOURCE`
## Architecture
- `chat/`: workflow-oriented orchestration
- `jobs/`, `forms/`, `tickets/`: domain modules
- `providers/llm`: `AiProvider` + Ollama implementation
- `providers/tickets`: `TicketProvider` + mock adapter
- `providers/help_center`: `HelpCenterProvider` + local seed implementation
- `retrieval/embeddings`: embedding abstraction + Ollama adapter
- `db/`: DB connection, entities, repository helpers
- `routes/`, `handlers/`: API layer
## Notes
- Service starts even if Ollama model is unavailable; provider returns graceful fallback responses.
- STT (`faster-whisper`) is intentionally deferred to phase 2.

33
migrations/0001_init.sql Normal file
View file

@ -0,0 +1,33 @@
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE IF NOT EXISTS support_tickets (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
external_ticket_id TEXT NOT NULL,
subject TEXT NOT NULL,
description TEXT NOT NULL,
priority TEXT NOT NULL,
category TEXT NOT NULL,
user_id TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'chatbot',
status TEXT NOT NULL DEFAULT 'open',
conversation_id TEXT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_support_tickets_user_id ON support_tickets(user_id);
CREATE INDEX IF NOT EXISTS idx_support_tickets_created_at ON support_tickets(created_at DESC);
CREATE TABLE IF NOT EXISTS help_articles (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
summary TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
source TEXT NOT NULL DEFAULT 'seed',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_help_articles_title ON help_articles(title);

23
seeds/help_articles.json Normal file
View file

@ -0,0 +1,23 @@
[
{
"id": "HELP-001",
"title": "Generate a Job Description",
"summary": "Create a role profile with responsibilities and requirements.",
"content": "Provide role title, level, must-have skills, and business context. Review AI draft before publishing.",
"tags": ["jobs", "job description", "hiring"]
},
{
"id": "HELP-002",
"title": "Form Filling Best Practices",
"summary": "How to complete structured forms with fewer validation errors.",
"content": "Use clear field names, provide expected formats, and validate required fields before submit.",
"tags": ["forms", "validation", "onboarding"]
},
{
"id": "HELP-003",
"title": "Submit a Support Ticket",
"summary": "How to report issues with enough context.",
"content": "Include expected vs actual behavior, affected user, severity, and reproduction steps.",
"tags": ["support", "tickets", "issues"]
}
]

2
src/chat/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod models;
pub mod orchestrator;

15
src/chat/models.rs Normal file
View file

@ -0,0 +1,15 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessageRequest {
pub message: String,
pub user_id: Option<String>,
pub conversation_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessageResponse {
pub intent: String,
pub reply: String,
pub data: serde_json::Value,
}

161
src/chat/orchestrator.rs Normal file
View file

@ -0,0 +1,161 @@
use std::sync::Arc;
use crate::{
chat::models::{ChatMessageRequest, ChatMessageResponse},
error::AppError,
forms::{models::FormExtractRequest, service::FormService},
jobs::{models::GenerateJobDescriptionRequest, service::JobsService},
providers::{
help_center::help_center_provider::HelpCenterProvider, llm::ai_provider::AiProvider,
},
tickets::{models::CreateTicketRequest, service::TicketService},
};
#[derive(Clone)]
pub struct ChatOrchestrator {
jobs_service: JobsService,
form_service: FormService,
help_center: Arc<dyn HelpCenterProvider>,
ticket_service: TicketService,
ai_provider: Arc<dyn AiProvider>,
}
impl ChatOrchestrator {
pub fn new(
jobs_service: JobsService,
form_service: FormService,
help_center: Arc<dyn HelpCenterProvider>,
ticket_service: TicketService,
ai_provider: Arc<dyn AiProvider>,
) -> Self {
Self {
jobs_service,
form_service,
help_center,
ticket_service,
ai_provider,
}
}
pub async fn handle_chat(
&self,
request: ChatMessageRequest,
) -> Result<ChatMessageResponse, AppError> {
let intent = classify_intent(&request.message);
match intent.as_str() {
"job_description_generation" => {
let jd = self
.jobs_service
.generate_description(GenerateJobDescriptionRequest {
role_title: request.message.clone(),
seniority: None,
department: None,
employment_type: None,
required_skills: vec!["communication".to_string()],
optional_skills: None,
responsibilities: None,
company_context: None,
})
.await?;
Ok(ChatMessageResponse {
intent,
reply: "Generated a draft job description.".to_string(),
data: serde_json::to_value(jd).unwrap_or(serde_json::Value::Null),
})
}
"form_filling_assistance" => {
let extracted = self
.form_service
.extract(FormExtractRequest {
raw_user_input: request.message.clone(),
expected_fields: None,
})
.await?;
Ok(ChatMessageResponse {
intent,
reply: extracted.suggested_next_step.clone(),
data: serde_json::to_value(extracted).unwrap_or(serde_json::Value::Null),
})
}
"help_article_retrieval" => {
let matches = self.help_center.search(&request.message).await?;
let reply = if matches.is_empty() {
"No help article match found yet.".to_string()
} else {
format!("Found {} help articles.", matches.len())
};
Ok(ChatMessageResponse {
intent,
reply,
data: serde_json::json!({ "matches": matches }),
})
}
"support_ticket_creation" => {
let created = self
.ticket_service
.create(CreateTicketRequest {
subject: request.message.chars().take(80).collect::<String>(),
description: request.message.clone(),
priority: "medium".to_string(),
category: "general".to_string(),
user_id: request.user_id.unwrap_or_else(|| "anonymous".to_string()),
conversation_id: request.conversation_id,
source: Some("chatbot".to_string()),
tags: Some(vec!["chat".to_string()]),
metadata: None,
})
.await?;
Ok(ChatMessageResponse {
intent,
reply: format!("Support ticket created: {}", created.ticket_id),
data: serde_json::to_value(created).unwrap_or(serde_json::Value::Null),
})
}
_ => {
let generic = self
.ai_provider
.complete(
"You are Nxtgauge workflow assistant. Keep answers concise and actionable.",
&request.message,
)
.await?;
Ok(ChatMessageResponse {
intent,
reply: generic,
data: serde_json::json!({}),
})
}
}
}
}
fn classify_intent(message: &str) -> String {
let text = message.to_lowercase();
if text.contains("job description") || text.contains("jd") || text.contains("role") {
return "job_description_generation".to_string();
}
if text.contains("form") || text.contains("field") || text.contains("fill") {
return "form_filling_assistance".to_string();
}
if text.contains("help")
|| text.contains("article")
|| text.contains("kb")
|| text.contains("docs")
{
return "help_article_retrieval".to_string();
}
if text.contains("ticket")
|| text.contains("support")
|| text.contains("issue")
|| text.contains("bug")
{
return "support_ticket_creation".to_string();
}
"general".to_string()
}

43
src/config/mod.rs Normal file
View file

@ -0,0 +1,43 @@
use std::net::SocketAddr;
#[derive(Debug, Clone)]
pub struct AppConfig {
pub app_host: String,
pub app_port: u16,
pub database_url: Option<String>,
pub ollama_base_url: String,
pub ollama_chat_model: String,
pub ollama_embed_model: String,
pub help_center_seed_path: String,
pub tickets_source: String,
}
impl AppConfig {
pub fn from_env() -> Self {
Self {
app_host: env_or_default("APP_HOST", "0.0.0.0"),
app_port: env_or_default("APP_PORT", "8080").parse().unwrap_or(8080),
database_url: std::env::var("DATABASE_URL")
.ok()
.filter(|v| !v.trim().is_empty()),
ollama_base_url: env_or_default("OLLAMA_BASE_URL", "http://localhost:11434"),
ollama_chat_model: env_or_default("OLLAMA_CHAT_MODEL", "smollm2:360m"),
ollama_embed_model: env_or_default("OLLAMA_EMBED_MODEL", "nomic-embed-text"),
help_center_seed_path: env_or_default(
"HELP_CENTER_SEED_PATH",
"./seeds/help_articles.json",
),
tickets_source: env_or_default("TICKETS_SOURCE", "chatbot"),
}
}
pub fn socket_addr(&self) -> SocketAddr {
format!("{}:{}", self.app_host, self.app_port)
.parse()
.expect("invalid APP_HOST/APP_PORT")
}
}
fn env_or_default(key: &str, default: &str) -> String {
std::env::var(key).unwrap_or_else(|_| default.to_string())
}

32
src/db/entities.rs Normal file
View file

@ -0,0 +1,32 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SupportTicketEntity {
pub id: Uuid,
pub external_ticket_id: String,
pub subject: String,
pub description: String,
pub priority: String,
pub category: String,
pub user_id: String,
pub source: String,
pub status: String,
pub conversation_id: Option<String>,
pub tags: Vec<String>,
pub metadata: serde_json::Value,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelpArticleEntity {
pub id: Uuid,
pub title: String,
pub summary: String,
pub content: String,
pub tags: Vec<String>,
pub source: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}

28
src/db/mod.rs Normal file
View file

@ -0,0 +1,28 @@
pub mod entities;
pub mod repository;
use sqlx::{postgres::PgPoolOptions, PgPool};
#[derive(Clone)]
pub struct Database {
pub pool: Option<PgPool>,
}
impl Database {
pub async fn connect(database_url: Option<&str>) -> Result<Self, sqlx::Error> {
if let Some(url) = database_url {
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(url)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(Self { pool: Some(pool) })
} else {
Ok(Self { pool: None })
}
}
pub fn disabled() -> Self {
Self { pool: None }
}
}

34
src/db/repository.rs Normal file
View file

@ -0,0 +1,34 @@
use crate::{db::Database, error::AppError, tickets::models::CreateTicketRequest};
pub async fn store_ticket_audit(
db: &Database,
request: &CreateTicketRequest,
external_ticket_id: &str,
) -> Result<(), AppError> {
let Some(pool) = &db.pool else {
return Ok(());
};
sqlx::query(
r#"
INSERT INTO support_tickets
(external_ticket_id, subject, description, priority, category, user_id, source, conversation_id, tags, metadata)
VALUES
($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
"#,
)
.bind(external_ticket_id)
.bind(&request.subject)
.bind(&request.description)
.bind(&request.priority)
.bind(&request.category)
.bind(&request.user_id)
.bind(request.source.as_deref().unwrap_or("chatbot"))
.bind(request.conversation_id.as_deref())
.bind(request.tags.clone().unwrap_or_default())
.bind(request.metadata.clone().unwrap_or(serde_json::Value::Null))
.execute(pool)
.await?;
Ok(())
}

43
src/error.rs Normal file
View file

@ -0,0 +1,43 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde::Serialize;
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("bad request: {0}")]
BadRequest(String),
#[error("provider unavailable: {0}")]
ProviderUnavailable(String),
#[error("internal error: {0}")]
Internal(String),
}
#[derive(Debug, Serialize)]
struct ErrorBody {
error: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match self {
AppError::BadRequest(_) => StatusCode::BAD_REQUEST,
AppError::ProviderUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
let body = Json(ErrorBody {
error: self.to_string(),
});
(status, body).into_response()
}
}
impl From<sqlx::Error> for AppError {
fn from(value: sqlx::Error) -> Self {
AppError::Internal(value.to_string())
}
}

2
src/forms/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod models;
pub mod service;

24
src/forms/models.rs Normal file
View file

@ -0,0 +1,24 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormExtractRequest {
pub raw_user_input: String,
pub expected_fields: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtractedField {
pub name: String,
pub value: String,
pub confidence: f32,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FormExtractResponse {
pub intent: String,
pub fields: Vec<ExtractedField>,
pub missing_fields: Vec<String>,
pub raw_user_input: String,
pub suggested_next_step: String,
}

77
src/forms/service.rs Normal file
View file

@ -0,0 +1,77 @@
use std::{collections::HashSet, sync::Arc};
use crate::{error::AppError, forms::models::*, providers::llm::ai_provider::AiProvider};
#[derive(Clone)]
pub struct FormService {
ai_provider: Arc<dyn AiProvider>,
}
impl FormService {
pub fn new(ai_provider: Arc<dyn AiProvider>) -> Self {
Self { ai_provider }
}
pub async fn extract(
&self,
request: FormExtractRequest,
) -> Result<FormExtractResponse, AppError> {
let mut fields = Vec::new();
for part in request.raw_user_input.split(',') {
if let Some((left, right)) = part.split_once(':') {
fields.push(ExtractedField {
name: left.trim().to_lowercase().replace(' ', "_"),
value: right.trim().to_string(),
confidence: 0.82,
reason: "Parsed key:value pair from user input".to_string(),
});
}
}
if fields.is_empty() {
let helper = self
.ai_provider
.complete(
"You extract form fields.",
&format!(
"Extract likely form fields from: {}",
request.raw_user_input
),
)
.await?;
fields.push(ExtractedField {
name: "details".to_string(),
value: request.raw_user_input.clone(),
confidence: 0.5,
reason: format!("Fallback extraction used. Model note: {}", helper),
});
}
let extracted_names: HashSet<&str> = fields.iter().map(|f| f.name.as_str()).collect();
let missing_fields = request
.expected_fields
.unwrap_or_default()
.into_iter()
.filter(|field| !extracted_names.contains(field.as_str()))
.collect::<Vec<_>>();
let suggested_next_step = if missing_fields.is_empty() {
"All expected fields look present. Proceed to validation and submit.".to_string()
} else {
format!(
"Ask the user to provide missing fields: {}",
missing_fields.join(", ")
)
};
Ok(FormExtractResponse {
intent: "form_filling_assistance".to_string(),
fields,
missing_fields,
raw_user_input: request.raw_user_input,
suggested_next_step,
})
}
}

21
src/handlers/chat.rs Normal file
View file

@ -0,0 +1,21 @@
use axum::{extract::State, Json};
use crate::{
chat::models::{ChatMessageRequest, ChatMessageResponse},
error::AppError,
state::AppState,
};
pub async fn message(
State(state): State<AppState>,
Json(request): Json<ChatMessageRequest>,
) -> Result<Json<ChatMessageResponse>, AppError> {
if request.message.trim().is_empty() {
return Err(AppError::BadRequest(
"message must not be empty".to_string(),
));
}
let response = state.chat_orchestrator.handle_chat(request).await?;
Ok(Json(response))
}

21
src/handlers/forms.rs Normal file
View file

@ -0,0 +1,21 @@
use axum::{extract::State, Json};
use crate::{
error::AppError,
forms::models::{FormExtractRequest, FormExtractResponse},
state::AppState,
};
pub async fn extract(
State(state): State<AppState>,
Json(request): Json<FormExtractRequest>,
) -> Result<Json<FormExtractResponse>, AppError> {
if request.raw_user_input.trim().is_empty() {
return Err(AppError::BadRequest(
"raw_user_input is required".to_string(),
));
}
let extracted = state.form_service.extract(request).await?;
Ok(Json(extracted))
}

19
src/handlers/health.rs Normal file
View file

@ -0,0 +1,19 @@
use axum::{extract::State, Json};
use serde::Serialize;
use crate::state::AppState;
#[derive(Debug, Serialize)]
pub struct HealthResponse {
pub status: &'static str,
pub db_connected: bool,
pub service: &'static str,
}
pub async fn health(State(state): State<AppState>) -> Json<HealthResponse> {
Json(HealthResponse {
status: "ok",
db_connected: state.db.pool.is_some(),
service: "nxtgauge-ai-assistant",
})
}

28
src/handlers/help.rs Normal file
View file

@ -0,0 +1,28 @@
use axum::{extract::State, Json};
use serde::{Deserialize, Serialize};
use crate::{
error::AppError, providers::help_center::help_center_provider::HelpArticle, state::AppState,
};
#[derive(Debug, Clone, Deserialize)]
pub struct HelpSearchRequest {
pub query: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct HelpSearchResponse {
pub results: Vec<HelpArticle>,
}
pub async fn search(
State(state): State<AppState>,
Json(request): Json<HelpSearchRequest>,
) -> Result<Json<HelpSearchResponse>, AppError> {
if request.query.trim().is_empty() {
return Err(AppError::BadRequest("query is required".to_string()));
}
let results = state.help_center.search(&request.query).await?;
Ok(Json(HelpSearchResponse { results }))
}

19
src/handlers/jobs.rs Normal file
View file

@ -0,0 +1,19 @@
use axum::{extract::State, Json};
use crate::{
error::AppError,
jobs::models::{GenerateJobDescriptionRequest, GenerateJobDescriptionResponse},
state::AppState,
};
pub async fn generate_description(
State(state): State<AppState>,
Json(request): Json<GenerateJobDescriptionRequest>,
) -> Result<Json<GenerateJobDescriptionResponse>, AppError> {
if request.role_title.trim().is_empty() {
return Err(AppError::BadRequest("role_title is required".to_string()));
}
let generated = state.jobs_service.generate_description(request).await?;
Ok(Json(generated))
}

6
src/handlers/mod.rs Normal file
View file

@ -0,0 +1,6 @@
pub mod chat;
pub mod forms;
pub mod health;
pub mod help;
pub mod jobs;
pub mod tickets;

15
src/handlers/tickets.rs Normal file
View file

@ -0,0 +1,15 @@
use axum::{extract::State, Json};
use crate::{
error::AppError,
state::AppState,
tickets::models::{CreateTicketRequest, CreateTicketResponse},
};
pub async fn create(
State(state): State<AppState>,
Json(request): Json<CreateTicketRequest>,
) -> Result<Json<CreateTicketResponse>, AppError> {
let created = state.ticket_service.create(request).await?;
Ok(Json(created))
}

2
src/jobs/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod models;
pub mod service;

22
src/jobs/models.rs Normal file
View file

@ -0,0 +1,22 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateJobDescriptionRequest {
pub role_title: String,
pub seniority: Option<String>,
pub department: Option<String>,
pub employment_type: Option<String>,
pub required_skills: Vec<String>,
pub optional_skills: Option<Vec<String>>,
pub responsibilities: Option<Vec<String>>,
pub company_context: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GenerateJobDescriptionResponse {
pub role_summary: String,
pub responsibilities: Vec<String>,
pub requirements: Vec<String>,
pub nice_to_have: Vec<String>,
pub raw_markdown: String,
}

44
src/jobs/service.rs Normal file
View file

@ -0,0 +1,44 @@
use std::sync::Arc;
use crate::{error::AppError, jobs::models::*, providers::llm::ai_provider::AiProvider};
#[derive(Clone)]
pub struct JobsService {
ai_provider: Arc<dyn AiProvider>,
}
impl JobsService {
pub fn new(ai_provider: Arc<dyn AiProvider>) -> Self {
Self { ai_provider }
}
pub async fn generate_description(
&self,
request: GenerateJobDescriptionRequest,
) -> Result<GenerateJobDescriptionResponse, AppError> {
let system = "You are Nxtgauge Job Description Assistant. Return concise and production-ready job descriptions.";
let user_prompt = format!(
"Generate a job description for role: {}\nSeniority: {:?}\nDepartment: {:?}\nEmployment type: {:?}\nRequired skills: {:?}\nOptional skills: {:?}\nResponsibilities: {:?}\nCompany context: {:?}\n\nReturn markdown sections: Role Summary, Responsibilities, Requirements, Nice to Have.",
request.role_title,
request.seniority,
request.department,
request.employment_type,
request.required_skills,
request.optional_skills,
request.responsibilities,
request.company_context
);
let generated = self.ai_provider.complete(system, &user_prompt).await?;
Ok(GenerateJobDescriptionResponse {
role_summary: format!("{} role for Nxtgauge platform.", request.role_title),
responsibilities: request
.responsibilities
.unwrap_or_else(|| vec!["Deliver key outcomes for the role".to_string()]),
requirements: request.required_skills,
nice_to_have: request.optional_skills.unwrap_or_default(),
raw_markdown: generated,
})
}
}

82
src/main.rs Normal file
View file

@ -0,0 +1,82 @@
mod chat;
mod config;
mod db;
mod error;
mod forms;
mod handlers;
mod jobs;
mod providers;
mod retrieval;
mod routes;
mod state;
mod tickets;
use std::sync::Arc;
use config::AppConfig;
use providers::help_center::local_help_center_provider::LocalHelpCenterProvider;
use providers::llm::ollama_provider::OllamaAiProvider;
use providers::tickets::mock_ticket_provider::MockTicketProvider;
use retrieval::embeddings::ollama_embedding_provider::OllamaEmbeddingProvider;
use state::AppState;
use tracing::{info, warn};
#[tokio::main]
async fn main() {
init_tracing();
let cfg = AppConfig::from_env();
let database = match db::Database::connect(cfg.database_url.as_deref()).await {
Ok(db) => db,
Err(err) => {
warn!("database initialization failed; continuing without DB: {err}");
db::Database::disabled()
}
};
let ai_provider = Arc::new(OllamaAiProvider::new(
cfg.ollama_base_url.clone(),
cfg.ollama_chat_model.clone(),
));
let embedding_provider = Arc::new(OllamaEmbeddingProvider::new(
cfg.ollama_base_url.clone(),
cfg.ollama_embed_model.clone(),
));
let help_center_provider = Arc::new(LocalHelpCenterProvider::from_seed_file(
&cfg.help_center_seed_path,
embedding_provider,
));
let ticket_provider = Arc::new(MockTicketProvider::new(cfg.tickets_source.clone()));
let state = AppState::new(
cfg.clone(),
database,
ai_provider,
help_center_provider,
ticket_provider,
);
let app = routes::build_router(state);
let bind_addr = cfg.socket_addr();
info!("starting nxtgauge-ai-assistant on {bind_addr}");
let listener = tokio::net::TcpListener::bind(bind_addr)
.await
.expect("failed to bind server socket");
axum::serve(listener, app).await.expect("server crashed");
}
fn init_tracing() {
let default_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".to_string());
tracing_subscriber::fmt()
.with_env_filter(default_filter)
.with_target(false)
.compact()
.init();
}

View file

@ -0,0 +1,18 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::AppError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HelpArticle {
pub id: String,
pub title: String,
pub summary: String,
pub content: String,
pub tags: Vec<String>,
}
#[async_trait]
pub trait HelpCenterProvider: Send + Sync {
async fn search(&self, query: &str) -> Result<Vec<HelpArticle>, AppError>;
}

View file

@ -0,0 +1,68 @@
use std::{fs, sync::Arc};
use async_trait::async_trait;
use crate::{
error::AppError,
providers::help_center::help_center_provider::{HelpArticle, HelpCenterProvider},
retrieval::embeddings::embedding_provider::EmbeddingProvider,
};
#[derive(Clone)]
pub struct LocalHelpCenterProvider {
articles: Vec<HelpArticle>,
embedding_provider: Arc<dyn EmbeddingProvider>,
}
impl LocalHelpCenterProvider {
pub fn from_seed_file(path: &str, embedding_provider: Arc<dyn EmbeddingProvider>) -> Self {
let articles = fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str::<Vec<HelpArticle>>(&raw).ok())
.unwrap_or_default();
Self {
articles,
embedding_provider,
}
}
}
#[async_trait]
impl HelpCenterProvider for LocalHelpCenterProvider {
async fn search(&self, query: &str) -> Result<Vec<HelpArticle>, AppError> {
let _ = self.embedding_provider.embed(query).await;
let lowered_query = query.to_lowercase();
let mut scored = self
.articles
.iter()
.cloned()
.map(|article| {
let haystack = format!(
"{} {} {} {}",
article.title,
article.summary,
article.content,
article.tags.join(" ")
)
.to_lowercase();
let score = lowered_query
.split_whitespace()
.filter(|token| haystack.contains(*token))
.count();
(article, score)
})
.filter(|(_, score)| *score > 0)
.collect::<Vec<_>>();
scored.sort_by(|a, b| b.1.cmp(&a.1));
Ok(scored
.into_iter()
.take(5)
.map(|(article, _)| article)
.collect())
}
}

View file

@ -0,0 +1,2 @@
pub mod help_center_provider;
pub mod local_help_center_provider;

View file

@ -0,0 +1,8 @@
use async_trait::async_trait;
use crate::error::AppError;
#[async_trait]
pub trait AiProvider: Send + Sync {
async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError>;
}

2
src/providers/llm/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod ai_provider;
pub mod ollama_provider;

View file

@ -0,0 +1,71 @@
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{error::AppError, providers::llm::ai_provider::AiProvider};
#[derive(Clone)]
pub struct OllamaAiProvider {
client: Client,
base_url: String,
model: String,
}
impl OllamaAiProvider {
pub fn new(base_url: String, model: String) -> Self {
Self {
client: Client::new(),
base_url,
model,
}
}
fn fallback_response(user_prompt: &str) -> String {
format!(
"Ollama model is unavailable right now. I captured your request so workflow can continue: {}",
user_prompt
)
}
}
#[derive(Debug, Serialize)]
struct GenerateRequest<'a> {
model: &'a str,
prompt: String,
stream: bool,
}
#[derive(Debug, Deserialize)]
struct GenerateResponse {
response: String,
}
#[async_trait]
impl AiProvider for OllamaAiProvider {
async fn complete(&self, system_prompt: &str, user_prompt: &str) -> Result<String, AppError> {
let url = format!("{}/api/generate", self.base_url.trim_end_matches('/'));
let payload = GenerateRequest {
model: &self.model,
prompt: format!("System: {}\nUser: {}", system_prompt, user_prompt),
stream: false,
};
let res = self.client.post(url).json(&payload).send().await;
let Ok(res) = res else {
return Ok(Self::fallback_response(user_prompt));
};
if !res.status().is_success() {
return Ok(Self::fallback_response(user_prompt));
}
let body: Result<GenerateResponse, _> = res.json().await;
match body {
Ok(parsed) if !parsed.response.trim().is_empty() => {
Ok(parsed.response.trim().to_string())
}
_ => Ok(Self::fallback_response(user_prompt)),
}
}
}

3
src/providers/mod.rs Normal file
View file

@ -0,0 +1,3 @@
pub mod help_center;
pub mod llm;
pub mod tickets;

View file

@ -0,0 +1,34 @@
use async_trait::async_trait;
use uuid::Uuid;
use crate::{
error::AppError,
providers::tickets::ticket_provider::{
TicketCreatePayload, TicketCreateResult, TicketProvider,
},
};
#[derive(Clone)]
pub struct MockTicketProvider {
source: String,
}
impl MockTicketProvider {
pub fn new(source: String) -> Self {
Self { source }
}
}
#[async_trait]
impl TicketProvider for MockTicketProvider {
async fn create_ticket(
&self,
_payload: TicketCreatePayload,
) -> Result<TicketCreateResult, AppError> {
Ok(TicketCreateResult {
ticket_id: format!("NG-TKT-{}", Uuid::new_v4().simple()),
status: "open".to_string(),
provider: format!("mock:{}", self.source),
})
}
}

View file

@ -0,0 +1,2 @@
pub mod mock_ticket_provider;
pub mod ticket_provider;

View file

@ -0,0 +1,32 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use crate::error::AppError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TicketCreatePayload {
pub subject: String,
pub description: String,
pub priority: String,
pub category: String,
pub user_id: String,
pub conversation_id: Option<String>,
pub source: Option<String>,
pub tags: Option<Vec<String>>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TicketCreateResult {
pub ticket_id: String,
pub status: String,
pub provider: String,
}
#[async_trait]
pub trait TicketProvider: Send + Sync {
async fn create_ticket(
&self,
payload: TicketCreatePayload,
) -> Result<TicketCreateResult, AppError>;
}

View file

@ -0,0 +1,8 @@
use async_trait::async_trait;
use crate::error::AppError;
#[async_trait]
pub trait EmbeddingProvider: Send + Sync {
async fn embed(&self, text: &str) -> Result<Vec<f32>, AppError>;
}

View file

@ -0,0 +1,2 @@
pub mod embedding_provider;
pub mod ollama_embedding_provider;

View file

@ -0,0 +1,67 @@
use async_trait::async_trait;
use reqwest::Client;
use serde::{Deserialize, Serialize};
use crate::{error::AppError, retrieval::embeddings::embedding_provider::EmbeddingProvider};
#[derive(Clone)]
pub struct OllamaEmbeddingProvider {
client: Client,
base_url: String,
model: String,
}
impl OllamaEmbeddingProvider {
pub fn new(base_url: String, model: String) -> Self {
Self {
client: Client::new(),
base_url,
model,
}
}
}
#[derive(Debug, Serialize)]
struct EmbedRequest<'a> {
model: &'a str,
prompt: &'a str,
}
#[derive(Debug, Deserialize)]
struct EmbedResponse {
embedding: Vec<f32>,
}
#[async_trait]
impl EmbeddingProvider for OllamaEmbeddingProvider {
async fn embed(&self, text: &str) -> Result<Vec<f32>, AppError> {
let url = format!("{}/api/embeddings", self.base_url.trim_end_matches('/'));
let payload = EmbedRequest {
model: &self.model,
prompt: text,
};
let res = self
.client
.post(url)
.json(&payload)
.send()
.await
.map_err(|e| {
AppError::ProviderUnavailable(format!("embedding provider unavailable: {e}"))
})?;
if !res.status().is_success() {
return Err(AppError::ProviderUnavailable(format!(
"embedding provider returned status {}",
res.status()
)));
}
let body: EmbedResponse = res.json().await.map_err(|e| {
AppError::ProviderUnavailable(format!("invalid embedding response: {e}"))
})?;
Ok(body.embedding)
}
}

1
src/retrieval/mod.rs Normal file
View file

@ -0,0 +1 @@
pub mod embeddings;

27
src/routes/mod.rs Normal file
View file

@ -0,0 +1,27 @@
use axum::{
routing::{get, post},
Router,
};
use tower_http::{cors::CorsLayer, trace::TraceLayer};
use crate::{handlers, state::AppState};
pub fn build_router(state: AppState) -> Router {
Router::new()
.route("/health", get(handlers::health::health))
.nest(
"/api/v1",
Router::new()
.route("/chat/message", post(handlers::chat::message))
.route(
"/jobs/generate-description",
post(handlers::jobs::generate_description),
)
.route("/forms/extract", post(handlers::forms::extract))
.route("/tickets/create", post(handlers::tickets::create))
.route("/help/search", post(handlers::help::search)),
)
.layer(CorsLayer::permissive())
.layer(TraceLayer::new_for_http())
.with_state(state)
}

56
src/state.rs Normal file
View file

@ -0,0 +1,56 @@
use std::sync::Arc;
use crate::{
chat::orchestrator::ChatOrchestrator,
config::AppConfig,
db::Database,
forms::service::FormService,
jobs::service::JobsService,
providers::{
help_center::help_center_provider::HelpCenterProvider, llm::ai_provider::AiProvider,
tickets::ticket_provider::TicketProvider,
},
tickets::service::TicketService,
};
#[derive(Clone)]
pub struct AppState {
pub config: AppConfig,
pub db: Database,
pub chat_orchestrator: ChatOrchestrator,
pub jobs_service: JobsService,
pub form_service: FormService,
pub ticket_service: TicketService,
pub help_center: Arc<dyn HelpCenterProvider>,
}
impl AppState {
pub fn new(
config: AppConfig,
db: Database,
ai_provider: Arc<dyn AiProvider>,
help_center: Arc<dyn HelpCenterProvider>,
ticket_provider: Arc<dyn TicketProvider>,
) -> Self {
let jobs_service = JobsService::new(ai_provider.clone());
let form_service = FormService::new(ai_provider.clone());
let ticket_service = TicketService::new(ticket_provider, db.clone());
let chat_orchestrator = ChatOrchestrator::new(
jobs_service.clone(),
form_service.clone(),
help_center.clone(),
ticket_service.clone(),
ai_provider,
);
Self {
config,
db,
chat_orchestrator,
jobs_service,
form_service,
ticket_service,
help_center,
}
}
}

2
src/tickets/mod.rs Normal file
View file

@ -0,0 +1,2 @@
pub mod models;
pub mod service;

21
src/tickets/models.rs Normal file
View file

@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTicketRequest {
pub subject: String,
pub description: String,
pub priority: String,
pub category: String,
pub user_id: String,
pub conversation_id: Option<String>,
pub source: Option<String>,
pub tags: Option<Vec<String>>,
pub metadata: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreateTicketResponse {
pub ticket_id: String,
pub status: String,
pub provider: String,
}

66
src/tickets/service.rs Normal file
View file

@ -0,0 +1,66 @@
use std::sync::Arc;
use crate::{
db::{repository::store_ticket_audit, Database},
error::AppError,
providers::tickets::ticket_provider::{TicketCreatePayload, TicketProvider},
tickets::models::{CreateTicketRequest, CreateTicketResponse},
};
#[derive(Clone)]
pub struct TicketService {
provider: Arc<dyn TicketProvider>,
db: Database,
}
impl TicketService {
pub fn new(provider: Arc<dyn TicketProvider>, db: Database) -> Self {
Self { provider, db }
}
pub async fn create(
&self,
request: CreateTicketRequest,
) -> Result<CreateTicketResponse, AppError> {
validate_payload(&request)?;
let payload = TicketCreatePayload {
subject: request.subject.clone(),
description: request.description.clone(),
priority: request.priority.clone(),
category: request.category.clone(),
user_id: request.user_id.clone(),
conversation_id: request.conversation_id.clone(),
source: request.source.clone(),
tags: request.tags.clone(),
metadata: request.metadata.clone(),
};
let created = self.provider.create_ticket(payload).await?;
store_ticket_audit(&self.db, &request, &created.ticket_id).await?;
Ok(CreateTicketResponse {
ticket_id: created.ticket_id,
status: created.status,
provider: created.provider,
})
}
}
fn validate_payload(request: &CreateTicketRequest) -> Result<(), AppError> {
let required = [
("subject", request.subject.trim()),
("description", request.description.trim()),
("priority", request.priority.trim()),
("category", request.category.trim()),
("user_id", request.user_id.trim()),
];
if let Some((field, _)) = required.into_iter().find(|(_, value)| value.is_empty()) {
return Err(AppError::BadRequest(format!(
"missing required field: {field}"
)));
}
Ok(())
}