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