feat(ai-assistant): bootstrap rust axum backend with providers, routes, and db scaffolding
This commit is contained in:
commit
dbba72478c
49 changed files with 4607 additions and 0 deletions
12
.env.example
Normal file
12
.env.example
Normal 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
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/target
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
.sqlx
|
||||||
3203
Cargo.lock
generated
Normal file
3203
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
23
Cargo.toml
Normal file
23
Cargo.toml
Normal 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
19
Dockerfile
Normal 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
60
README.md
Normal 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
33
migrations/0001_init.sql
Normal 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
23
seeds/help_articles.json
Normal 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
2
src/chat/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod models;
|
||||||
|
pub mod orchestrator;
|
||||||
15
src/chat/models.rs
Normal file
15
src/chat/models.rs
Normal 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
161
src/chat/orchestrator.rs
Normal 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
43
src/config/mod.rs
Normal 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
32
src/db/entities.rs
Normal 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
28
src/db/mod.rs
Normal 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
34
src/db/repository.rs
Normal 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
43
src/error.rs
Normal 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
2
src/forms/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod models;
|
||||||
|
pub mod service;
|
||||||
24
src/forms/models.rs
Normal file
24
src/forms/models.rs
Normal 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
77
src/forms/service.rs
Normal 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
21
src/handlers/chat.rs
Normal 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
21
src/handlers/forms.rs
Normal 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
19
src/handlers/health.rs
Normal 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
28
src/handlers/help.rs
Normal 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
19
src/handlers/jobs.rs
Normal 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
6
src/handlers/mod.rs
Normal 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
15
src/handlers/tickets.rs
Normal 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
2
src/jobs/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod models;
|
||||||
|
pub mod service;
|
||||||
22
src/jobs/models.rs
Normal file
22
src/jobs/models.rs
Normal 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
44
src/jobs/service.rs
Normal 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
82
src/main.rs
Normal 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();
|
||||||
|
}
|
||||||
18
src/providers/help_center/help_center_provider.rs
Normal file
18
src/providers/help_center/help_center_provider.rs
Normal 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>;
|
||||||
|
}
|
||||||
68
src/providers/help_center/local_help_center_provider.rs
Normal file
68
src/providers/help_center/local_help_center_provider.rs
Normal 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())
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/providers/help_center/mod.rs
Normal file
2
src/providers/help_center/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod help_center_provider;
|
||||||
|
pub mod local_help_center_provider;
|
||||||
8
src/providers/llm/ai_provider.rs
Normal file
8
src/providers/llm/ai_provider.rs
Normal 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
2
src/providers/llm/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod ai_provider;
|
||||||
|
pub mod ollama_provider;
|
||||||
71
src/providers/llm/ollama_provider.rs
Normal file
71
src/providers/llm/ollama_provider.rs
Normal 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
3
src/providers/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod help_center;
|
||||||
|
pub mod llm;
|
||||||
|
pub mod tickets;
|
||||||
34
src/providers/tickets/mock_ticket_provider.rs
Normal file
34
src/providers/tickets/mock_ticket_provider.rs
Normal 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),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/providers/tickets/mod.rs
Normal file
2
src/providers/tickets/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod mock_ticket_provider;
|
||||||
|
pub mod ticket_provider;
|
||||||
32
src/providers/tickets/ticket_provider.rs
Normal file
32
src/providers/tickets/ticket_provider.rs
Normal 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>;
|
||||||
|
}
|
||||||
8
src/retrieval/embeddings/embedding_provider.rs
Normal file
8
src/retrieval/embeddings/embedding_provider.rs
Normal 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>;
|
||||||
|
}
|
||||||
2
src/retrieval/embeddings/mod.rs
Normal file
2
src/retrieval/embeddings/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod embedding_provider;
|
||||||
|
pub mod ollama_embedding_provider;
|
||||||
67
src/retrieval/embeddings/ollama_embedding_provider.rs
Normal file
67
src/retrieval/embeddings/ollama_embedding_provider.rs
Normal 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
1
src/retrieval/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod embeddings;
|
||||||
27
src/routes/mod.rs
Normal file
27
src/routes/mod.rs
Normal 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
56
src/state.rs
Normal 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
2
src/tickets/mod.rs
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
pub mod models;
|
||||||
|
pub mod service;
|
||||||
21
src/tickets/models.rs
Normal file
21
src/tickets/models.rs
Normal 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
66
src/tickets/service.rs
Normal 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(())
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue