Mini project / POC · end to end
AI Support Ticket Triage System: a complete Spring AI mini project.
Three services, Retrieval-Augmented Generation over a pgvector knowledge base, tool-calling against live customer data, confidence-based human escalation, Kafka event routing, JWT RBAC, and AWS S3 attachments — with full diagrams, real code, and a responsible-AI guardrails section.
Project overview and requirements
Support teams drown in ticket volume that needs to be read, categorized, prioritized, and routed before anyone can actually help the customer. This project automates that first triage step with an LLM, while keeping a human in the loop wherever the model isn't confident — the central design tension of any production AI feature.
Functional requirements
Non-functional requirements
Technology stack and why each piece is there
| Technology | Role in this project | Why this one |
|---|---|---|
| Java 21 + Spring Boot 3.x | Runtime and framework for all three services. | Same reasoning as the e-commerce project — virtual threads, records, auto-configuration. |
| Spring AI | ChatClient, RAG advisors, tool calling, structured output. | Portable across model providers; the advisor chain is the natural place for RAG and guardrails. |
| PostgreSQL + pgvector | One database per service; the Triage Service's database also stores KB embeddings. | Avoids standing up a separate vector database for a moderate-sized knowledge base — one less moving part. |
| Apache Kafka | Async events for triage results, escalation, and assignment. | Durable, replayable log fits a multi-step, partially-automated workflow better than direct calls. |
| Spring Security + JWT | Stateless auth with CUSTOMER / AGENT / ADMIN roles. | Identical pattern to the e-commerce project — every service verifies its own JWT. |
| AWS S3 | Ticket attachment and knowledge-base source document storage. | Presigned URLs again, so large files never pass through application memory. |
| OpenFeign | Triage Service → Ticket Service sync lookups. | Fetches full ticket text and customer tier only when the event payload isn't enough. |
Jump to a section
High-level architecture
The Ticket Service owns intake and is the source of truth for ticket state. The AI Triage Service is the only service that talks to the LLM provider and the only one that reads the pgvector knowledge base; it never writes ticket state directly — it publishes an opinion as an event, and the Agent Service decides what to do with it. That separation keeps "the model said so" from ever being the same thing as "the system did it."
Service breakdown
Ticket Service — intake and system of record
Owns ticket creation, status, and the audit trail of comments. It's the only service a customer or agent writes ticket data through, and the only place attachment metadata lives.
POST /tickets— CUSTOMER, creates a ticket, publishesticket.createdGET /tickets/{id}— CUSTOMER (own), AGENT/ADMIN (any)POST /tickets/{id}/attachment-upload-url— presigned S3 PUT URL- consumes
ticket.triaged/ticket.assigned— updates category, priority, and assignee
AI Triage Service — RAG classification and tool calling
Consumes every new ticket, retrieves relevant knowledge-base context, classifies category/priority/sentiment via structured output, optionally calls a tool to check the customer's account tier, and publishes its result as an event. It owns the pgvector knowledge base and never writes ticket state.
- consumes
ticket.created→ runs RAG + classification - publishes
ticket.triaged(confident) orticket.escalated(low confidence) POST /kb/articles— ADMIN, ingest a new knowledge-base article- sync calls Ticket Service (OpenFeign) for full ticket text when the event payload is insufficient
Agent Service — routing and notification
Owns the agent roster and workload, and decides who a ticket goes to. High-confidence ticket.triaged events auto-assign to the least-loaded matching specialist; ticket.escalated events go straight to a senior-review queue instead of the normal load-balancing logic.
- consumes
ticket.triaged→ auto-assign by specialty and current load - consumes
ticket.escalated→ assign to senior review queue, flag for manual look - publishes
ticket.assigned; sends the agent and customer a notification GET /agents/{id}/queue— AGENT, view assigned tickets with the AI-suggested response
Database design
Same database-per-service discipline as the e-commerce project, with one addition: the Triage Service's database has the pgvector extension enabled so knowledge-base embeddings live alongside its relational data instead of in a separate vector database.
RAG knowledge base pipeline
Support articles, past resolved-ticket writeups, and product docs are ingested once into pgvector; every incoming ticket then runs a similarity search against that store to ground the classification in your actual support history instead of the model's generic training knowledge.
// KnowledgeBaseIngestionService.java
public void ingestArticle(String s3Key, String title) {
Resource resource = s3ResourceLoader.load(s3Key);
List docs = new TikaDocumentReader(resource).get();
List chunks = new TokenTextSplitter().apply(docs);
chunks.forEach(c -> c.getMetadata().put("articleTitle", title));
vectorStore.add(chunks); // PGVectorStore, backed by triage_db
}
AI classification and tool calling
Classification uses structured output so the result is a typed Java record, not a string to parse, and one tool call lets the model factor in live account data (a Tier-1 enterprise customer's "login broken" ticket should outrank the same words from a free-tier trial account).
public record TriageResult(
TicketCategory category, // enum: BILLING, TECHNICAL, ACCOUNT, FEATURE_REQUEST, OTHER
Priority priority, // enum: LOW, MEDIUM, HIGH, URGENT
Sentiment sentiment, // enum: NEUTRAL, FRUSTRATED, ANGRY
double confidence, // 0.0 - 1.0
String suggestedResponse
) {}
@Service
public class TriageService {
private final ChatClient chatClient; // built with QuestionAnswerAdvisor over the pgvector store
private final CustomerTools customerTools;
public TriageResult classify(Ticket ticket) {
return chatClient.prompt()
.system("""
You triage customer support tickets. Use the retrieved knowledge-base
context to ground your category and suggested response. Never invent
policy details that aren't in the retrieved context.
""")
.user(u -> u.text("Ticket subject: {subject}\nDescription: {description}")
.param("subject", ticket.subject())
.param("description", ticket.description()))
.tools(customerTools) // exposes getCustomerTier(customerId)
.advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "type == 'kb_article'"))
.call()
.entity(TriageResult.class);
}
}
public class CustomerTools {
@Tool(description = "Get the account tier (FREE, PRO, ENTERPRISE) for a customer id")
public String getCustomerTier(String customerId) {
return ticketClient.getCustomerTier(customerId); // OpenFeign call
}
}
confidence field only ever decides who handles the ticket next (auto-assign vs. senior review queue) — it never decides whether the ticket gets closed or a reply gets sent automatically. That boundary is what keeps this "AI-assisted" rather than "AI-autonomous."Security and role-based access control
Identical JWT/RBAC pattern to the e-commerce project: every service independently validates the signed token rather than trusting an upstream header. See that project's security section for the full JwtAuthFilter code — the only thing that changes here is the role set.
| Role | Can do |
|---|---|
ROLE_CUSTOMER | Submit tickets, view own tickets, add comments to own tickets. |
ROLE_AGENT | View assigned tickets, view the AI-suggested response, reply, change status. |
ROLE_ADMIN | Ingest/manage knowledge-base articles, view all tickets, reassign, view triage confidence scores. |
// SecurityConfig.java (AI Triage Service)
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.POST, "/kb/articles").hasRole("ADMIN")
.requestMatchers(HttpMethod.GET, "/kb/articles/**").hasAnyRole("ADMIN", "AGENT")
.anyRequest().authenticated())
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.build();
}
Service communication: Kafka topics and the triage sequence
| Topic | Producer | Consumer(s) | Payload |
|---|---|---|---|
ticket.created | Ticket Service | AI Triage Service | ticketId, customerId (event-notification style, not full text) |
ticket.triaged | AI Triage Service | Ticket Service, Agent Service | ticketId, category, priority, confidence, suggestedResponse |
ticket.escalated | AI Triage Service | Agent Service | ticketId, reason ("low_confidence") |
ticket.assigned | Agent Service | Ticket Service | ticketId, agentId, assignedAt |
ticket.created carries only IDs (event-notification), forcing the Triage Service to fetch full text via a sync call when needed, while ticket.triaged carries the full classification result (event-carried-state-transfer) since Agent Service needs all of it. The choice per event is deliberate: keep the broker payload small when only one consumer needs the detail and can fetch it on demand; embed it when multiple consumers need the same data and a round trip would be wasteful.AWS S3: ticket attachments and KB source documents
Same presigned-URL pattern as the e-commerce project's product images, reused here for two purposes: customer-uploaded ticket attachments (screenshots, logs) and the source documents an admin uploads before they're ingested into the knowledge base.
POST /tickets/{id}/attachment-upload-url— CUSTOMER, presigned PUT for a screenshot or log filePOST /kb/source-upload-url— ADMIN, presigned PUT for a new KB source document before ingestion
AI safety and guardrails
This is the section that separates a demo from something a real support team could trust. Every guardrail here maps to a concrete failure mode this system would otherwise have.
Project folder structure
ai-ticket-triage-poc/ ├── api-gateway/ │ ├── src/main/java/com/jiquest/gateway/GatewayApplication.java │ ├── src/main/resources/application.yml │ ├── Dockerfile │ └── pom.xml │ ├── ticket-service/ │ ├── src/main/java/com/jiquest/ticket/ │ │ ├── TicketServiceApplication.java │ │ ├── config/SecurityConfig.java, KafkaProducerConfig.java, KafkaConsumerConfig.java │ │ ├── controller/TicketController.java, FileController.java │ │ ├── service/TicketService.java, S3StorageService.java │ │ ├── listener/TriageResultListener.java, AssignmentListener.java │ │ ├── repository/TicketRepository.java, CommentRepository.java │ │ └── entity/Ticket.java, TicketComment.java │ ├── src/main/resources/application.yml │ ├── Dockerfile │ └── pom.xml │ ├── ai-triage-service/ │ ├── src/main/java/com/jiquest/triage/ │ │ ├── TriageServiceApplication.java │ │ ├── config/SecurityConfig.java, VectorStoreConfig.java │ │ ├── client/TicketClient.java // OpenFeign │ │ ├── controller/KnowledgeBaseController.java │ │ ├── service/TriageService.java, KnowledgeBaseIngestionService.java │ │ ├── tools/CustomerTools.java // @Tool methods │ │ ├── listener/TicketCreatedListener.java │ │ ├── repository/KbArticleRepository.java, TriageResultRepository.java │ │ └── entity/KbArticle.java, TriageResult.java │ ├── src/main/resources/application.yml │ ├── Dockerfile │ └── pom.xml │ ├── agent-service/ │ ├── src/main/java/com/jiquest/agent/ │ │ ├── AgentServiceApplication.java │ │ ├── config/KafkaConsumerConfig.java, KafkaProducerConfig.java │ │ ├── controller/AgentController.java │ │ ├── service/RoutingService.java, NotificationService.java │ │ ├── listener/TicketTriagedListener.java, TicketEscalatedListener.java │ │ ├── repository/AgentRepository.java, AssignmentRepository.java │ │ └── entity/Agent.java, TicketAssignment.java │ ├── src/main/resources/application.yml │ ├── Dockerfile │ └── pom.xml │ ├── docker-compose.yml ├── .env.example └── README.md
Local development setup
# docker-compose.yml (excerpt)
services:
postgres-ticket:
image: postgres:16
environment: { POSTGRES_DB: ticket_db, POSTGRES_PASSWORD: postgres }
ports: ["5433:5432"]
postgres-triage:
image: pgvector/pgvector:pg16 # ships the vector extension pre-installed
environment: { POSTGRES_DB: triage_db, POSTGRES_PASSWORD: postgres }
ports: ["5434:5432"]
postgres-agent:
image: postgres:16
environment: { POSTGRES_DB: agent_db, POSTGRES_PASSWORD: postgres }
ports: ["5435:5432"]
kafka:
image: apache/kafka:3.7.0
ports: ["9092:9092"]
ai-triage-service:
build: ./ai-triage-service
depends_on: [postgres-triage, kafka]
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres-triage:5432/triage_db
SPRING_AI_OPENAI_API_KEY: ${OPENAI_API_KEY}
ports: ["8082:8080"]
# ticket-service and agent-service follow the same shape as the e-commerce project's services
For fully offline development, point spring.ai.openai.base-url at a local Ollama instance instead of a paid provider — the ChatClient code doesn't change.
Testing strategy
@Test
void lowConfidenceClassificationTriggersEscalationNotAutoAssign() {
ChatModel mockModel = mock(ChatModel.class);
when(mockModel.call(any(Prompt.class)))
.thenReturn(fakeResponse("""
{"category":"TECHNICAL","priority":"HIGH","sentiment":"FRUSTRATED",
"confidence":0.42,"suggestedResponse":"..."}"""));
ChatClient client = ChatClient.builder(mockModel).build();
TriageResult result = new TriageService(client, customerTools).classify(sampleTicket());
assertThat(result.confidence()).isLessThan(0.75);
// and assert the service published to ticket.escalated, not ticket.triaged
}
Observability and cost monitoring
The Triage Service is the only service with a variable, usage-based cost, so it gets extra instrumentation the other two don't need.
ticket.escalated volume is an early warning the KB is missing coverage for a new issue.Path to production on AWS
| Local (this POC) | AWS equivalent |
|---|---|
| 3 services in Docker Compose | ECS Fargate or EKS, one service per task definition |
| pgvector/pgvector container | Amazon RDS PostgreSQL with the pgvector extension enabled |
| Single-broker Kafka container | Amazon MSK |
| S3 with local credentials | S3 + IAM task roles, no static keys |
| Direct OpenAI/Anthropic API key | Same provider via Spring AI, key sourced from AWS Secrets Manager |
Key design decisions and interview talking points
Why does the AI only classify and never auto-resolve tickets?
An LLM can be confidently wrong, and a wrong auto-resolution or auto-reply directly damages a customer relationship with no human check in the loop. Limiting the model's authority to classification and suggestion keeps the failure mode of a bad AI call bounded to "an agent has to fix a mis-tagged ticket," not "a customer got a wrong or unauthorized answer."
How is the confidence threshold of 0.75 chosen, and is it fixed forever?
It starts as a reasonable default and should be tuned empirically: track how often auto-assigned (high-confidence) tickets get reassigned or corrected by an agent versus escalated ones, and adjust the threshold to minimize both wasted human review and incorrect auto-routing. It's a live operational parameter, not a constant to set once and forget.
Why pgvector inside the Triage Service's own database instead of a shared vector store used by multiple services?
The knowledge base is exclusively the Triage Service's concern — no other service needs semantic search over support articles — so a shared vector store would violate the same database-per-service boundary the rest of the system follows, for a capability only one service actually uses.
What happens if the LLM provider is down or times out when a ticket needs triage?
The consumer catches the failure, and rather than dropping the ticket, publishes directly to ticket.escalated with reason "triage_unavailable" so it still reaches a human queue — a triage outage degrades to "everything needs manual review," not to tickets silently getting stuck untriaged.
How would you prevent a malicious ticket description from manipulating the classifier via prompt injection?
Ticket text is always treated as untrusted user content in the user message, never elevated into the system message; the system prompt explicitly instructs the model to classify the ticket rather than follow instructions found within it; and because the model's only available tool is a read-only lookup, even a successful injection has nothing destructive to reach for.
Why give the classifier a tool call to check customer tier instead of just including it in the prompt payload?
Both work, but a tool call keeps the customer-tier lookup live and authoritative at classification time rather than baked into whatever the Ticket Service happened to send in the event, and it's the same tool-calling pattern used elsewhere in the system, so there's one consistent way services expose live data to the model instead of two.
Why does ticket.created carry only IDs while ticket.triaged carries the full classification result?
Only the Triage Service needs full ticket text, and it can fetch that on demand, so keeping the event thin avoids duplicating potentially large description text into every message on the broker. ticket.triaged, by contrast, is needed identically by both Ticket Service and Agent Service, so embedding the full result once avoids two redundant fetches.
How would you evaluate whether the RAG-grounded classifier is actually more accurate than a non-RAG baseline?
Build a labeled evaluation set of real historical tickets with agent-confirmed correct categories, run both a RAG-enabled and a RAG-disabled version of the classifier against it, and compare category accuracy and confidence calibration — if RAG isn't measurably improving results, the added retrieval latency and complexity isn't earning its cost.
What stops an ADMIN from poisoning the knowledge base with incorrect information that the classifier then treats as authoritative?
Nothing at the technical layer beyond the RBAC restriction itself — this is fundamentally a process control, not a code control. A real deployment would add a review/approval step before a new KB article goes live, and an audit log of who ingested what, the same governance you'd want over any authoritative internal documentation.
Why is the suggested response shown to the agent for editing rather than sent directly to the customer?
Even a well-grounded RAG response can be subtly wrong, tonally off, or missing account-specific context the model doesn't have access to; routing it through an agent as a draft captures most of the time-saving benefit of AI assistance while keeping a human accountable for what the customer actually receives.
How would you handle a ticket in a language the knowledge base has no content for?
Detect this indirectly through the confidence score: a query with no relevant KB matches produces low-quality retrieved context, which should legitimately lower the model's classification confidence and route the ticket to escalation — the same guardrail that handles "the model doesn't know" also handles "the knowledge base doesn't cover this," without needing separate language-detection logic.
Why is Agent Service kept free of any dependency on the LLM provider?
Routing and notification are core operational functions that must keep working even during an AI outage or a provider API change; isolating the LLM dependency entirely inside the Triage Service means an AI-related incident degrades triage quality, not the ability to route and notify on tickets that are already classified or escalated.
How would you extend this system to learn from agent corrections over time?
Log every case where an agent changes the AI's category or priority as a labeled correction, and periodically review that correction log both to refine the prompt and confidence threshold, and to identify systematic KB gaps — this is evaluation-driven prompt iteration (see the Spring AI guide's golden-dataset discussion) rather than model fine-tuning, which is unnecessary at this scale.
What would break first if ticket volume grew 100x overnight?
The AI Triage Service's LLM calls: unlike the relational writes in the other two services, each classification is a network call to an external provider with its own rate limits and latency, making it the natural bottleneck and the first place you'd add a queue-depth-based autoscaling policy and provider-side rate-limit handling.
Why database-per-service here too, given this project is smaller than the e-commerce one?
Consistency of architectural discipline matters more than the absolute size of the project — applying the same boundary here keeps the pattern reusable and teachable across projects, and specifically prevents a future "just add a foreign key, it's faster" shortcut that would recouple the Triage Service to Ticket Service's schema.
If an interviewer asks you to justify the single biggest architectural decision in this project, what do you say?
Separating "the AI's opinion" (a published event) from "the system's action" (a state change made by a different service) — because it's the decision that turns an LLM from something the system blindly trusts into something the system treats as one more, fallible input, which is the core judgment production AI system design actually tests.
Related guides
Update these hrefs to your published Blogger post URLs once each page is live.
Post a Comment
Add