You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@camel.apache.org by da...@apache.org on 2026/10/18 13:33:07 UTC
(camel-jbang-examples) branch CAMEL-25766-ticket-classifier created (now 5225b2e)
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a change to branch CAMEL-25766-ticket-classifier
in repository https://gitbox.apache.org/repos/asf/camel-jbang-examples.git
at 5225b2e CAMEL-25766: ai/ticket-classifier - a local model sorts support tickets, and the route checks its answer
This branch includes the following new commits:
new 5225b2e CAMEL-25766: ai/ticket-classifier - a local model sorts support tickets, and the route checks its answer
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
(camel-jbang-examples) 01/01: CAMEL-25766: ai/ticket-classifier - a local model sorts support tickets, and the route checks its answer
Posted by da...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
davsclaus pushed a commit to branch CAMEL-25766-ticket-classifier
in repository https://gitbox.apache.org/repos/asf/camel-jbang-examples.git
commit 5225b2efacb2f6501b6b5172195bf409e3d7c1fd
Author: Claus Ibsen <cl...@gmail.com>
AuthorDate: Sun Oct 18 15:32:59 2026 +0200
CAMEL-25766: ai/ticket-classifier - a local model sorts support tickets, and the route checks its answer
A local model names the category of each support ticket, shipping,
billing or account; the prompt goes from a variable and the answer to one,
so the ticket stays the body. The route normalizes the answer, files
anything else under unknown, and a circuit breaker with a timeout does the
same when the model is slow or down. Second in the ai group, with a
Citrus YAML test on the camel infra Ollama (ciSkip, as langchain4j-chat).
Co-Authored-By: Claude Opus 5.5 (1M context) <no...@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STT6whBgK1AqsSsUKrnE8m
---
README.md | 1 +
ai/README.md | 1 +
ai/docling-langchain4j-rag/metadata.json | 2 +-
ai/mcp-server/metadata.json | 2 +-
ai/openai-pii-redaction/metadata.json | 2 +-
ai/ticket-classifier/README.md | 93 ++++++++++++++++++++++
ai/ticket-classifier/application.properties | 23 ++++++
ai/ticket-classifier/metadata.json | 43 ++++++++++
.../test/ticket-classifier.citrus.it.yaml | 59 ++++++++++++++
ai/ticket-classifier/ticket-classifier.camel.yaml | 85 ++++++++++++++++++++
ai/ticket-classifier/tickets/T-1.json | 1 +
ai/ticket-classifier/tickets/T-2.json | 1 +
ai/ticket-classifier/tickets/T-3.json | 1 +
camel-jbang-example-catalog.json | 62 ++++++++++++++-
14 files changed, 370 insertions(+), 6 deletions(-)
diff --git a/README.md b/README.md
index 290139b..fb58377 100644
--- a/README.md
+++ b/README.md
@@ -125,6 +125,7 @@ A local model writing text, routes exposed as MCP tools, RAG over documents, PII
| Example | What you will see | Needs |
|---|---|---|
| [LangChain4j chat](ai/langchain4j-chat/) | A local Ollama model started with camel infra writes the shipping notification for each of the three orders; the chat model is a bean built from properties, the prompt comes from the order, and the log shows the reply with its token counts. | `camel infra run ollama`, a local model: `ollama pull granite4:3b` |
+| [Ticket classifier](ai/ticket-classifier/) | A local model reads each of three support tickets and names its category, shipping, billing or account; the route checks the answer and files the ticket in tickets-out/<category>/, and an answer outside the three, or no answer within the circuit breaker's timeout, files it under unknown. | `camel infra run ollama`, a local model: `ollama pull granite4:3b` |
| [MCP server](ai/mcp-server/) | Two routes are exposed as MCP tools, stock_level by SKU and order_status by order id, on http://localhost:8080/mcp with nothing but properties to switch the server on; any MCP client, a coding agent included, can list and call them, and the log shows each call. | nothing |
| [OpenAI PII Redaction](ai/openai-pii-redaction/) | Text typed on standard input is sent to an OpenAI-compatible model with a JSON schema that asks for the personal identifiers redacted, and the redacted text is printed on standard output. | an OpenAI-compatible API: set `OPENAI_API_KEY` |
| [Document Analysis with Docling and LangChain4j RAG](ai/docling-langchain4j-rag/) | Documents dropped in a directory are converted to Markdown by a Docling service, analysed by a local Ollama model through langchain4j-chat, and written as a report to an output directory; an HTTP endpoint answers questions against the latest document. | `camel infra run docling ollama`, a local model |
diff --git a/ai/README.md b/ai/README.md
index 04d6ac5..bdccb88 100644
--- a/ai/README.md
+++ b/ai/README.md
@@ -6,6 +6,7 @@ A local model writing text, routes exposed as MCP tools, RAG over documents, PII
| Example | What you will see | Needs |
|---|---|---|
| [LangChain4j chat](langchain4j-chat/) | A local Ollama model started with camel infra writes the shipping notification for each of the three orders; the chat model is a bean built from properties, the prompt comes from the order, and the log shows the reply with its token counts. | `camel infra run ollama`, a local model: `ollama pull granite4:3b` |
+| [Ticket classifier](ticket-classifier/) | A local model reads each of three support tickets and names its category, shipping, billing or account; the route checks the answer and files the ticket in tickets-out/<category>/, and an answer outside the three, or no answer within the circuit breaker's timeout, files it under unknown. | `camel infra run ollama`, a local model: `ollama pull granite4:3b` |
| [MCP server](mcp-server/) | Two routes are exposed as MCP tools, stock_level by SKU and order_status by order id, on http://localhost:8080/mcp with nothing but properties to switch the server on; any MCP client, a coding agent included, can list and call them, and the log shows each call. | nothing |
| [OpenAI PII Redaction](openai-pii-redaction/) | Text typed on standard input is sent to an OpenAI-compatible model with a JSON schema that asks for the personal identifiers redacted, and the redacted text is printed on standard output. | an OpenAI-compatible API: set `OPENAI_API_KEY` |
| [Document Analysis with Docling and LangChain4j RAG](docling-langchain4j-rag/) | Documents dropped in a directory are converted to Markdown by a Docling service, analysed by a local Ollama model through langchain4j-chat, and written as a report to an output directory; an HTTP endpoint answers questions against the latest document. | `camel infra run docling ollama`, a local model |
diff --git a/ai/docling-langchain4j-rag/metadata.json b/ai/docling-langchain4j-rag/metadata.json
index 12a130b..3a39cc1 100644
--- a/ai/docling-langchain4j-rag/metadata.json
+++ b/ai/docling-langchain4j-rag/metadata.json
@@ -2,7 +2,7 @@
"title": "Document Analysis with Docling and LangChain4j RAG",
"description": "Documents dropped in a directory are converted to Markdown by a Docling service, analysed by a local Ollama model through langchain4j-chat, and written as a report to an output directory; an HTTP endpoint answers questions against the latest document.",
"level": "ai",
- "order": 4,
+ "order": 5,
"teaches": {
"components": [
"file",
diff --git a/ai/mcp-server/metadata.json b/ai/mcp-server/metadata.json
index 3ccae52..321d3c1 100644
--- a/ai/mcp-server/metadata.json
+++ b/ai/mcp-server/metadata.json
@@ -2,7 +2,7 @@
"title": "MCP server",
"description": "Two routes are exposed as MCP tools, stock_level by SKU and order_status by order id, on http://localhost:8080/mcp with nothing but properties to switch the server on; any MCP client, a coding agent included, can list and call them, and the log shows each call.",
"level": "ai",
- "order": 2,
+ "order": 3,
"teaches": {
"components": [
"ai-tool",
diff --git a/ai/openai-pii-redaction/metadata.json b/ai/openai-pii-redaction/metadata.json
index 7553b07..6a45e1e 100644
--- a/ai/openai-pii-redaction/metadata.json
+++ b/ai/openai-pii-redaction/metadata.json
@@ -2,7 +2,7 @@
"title": "OpenAI PII Redaction",
"description": "Text typed on standard input is sent to an OpenAI-compatible model with a JSON schema that asks for the personal identifiers redacted, and the redacted text is printed on standard output.",
"level": "ai",
- "order": 3,
+ "order": 4,
"teaches": {
"components": [
"stream",
diff --git a/ai/ticket-classifier/README.md b/ai/ticket-classifier/README.md
new file mode 100644
index 0000000..6f87bb9
--- /dev/null
+++ b/ai/ticket-classifier/README.md
@@ -0,0 +1,93 @@
+# Ticket classifier
+
+A local model sorts the support tickets of the shop. It names the category of each ticket, shipping, billing or
+account, and the route files the ticket in that folder. The route does not trust the answer: anything else, or no
+answer in time, files the ticket under `unknown`.
+
+## What you will see
+
+```text
+INFO ... ticket-classifier.camel.yaml:26 : Ticket T-1: My order ORD-1001 has not arrived yet. It is two weeks now, where is my parcel?
+INFO ... ticket-classifier.camel.yaml:72 : Ticket T-1 is shipping
+INFO ... ticket-classifier.camel.yaml:26 : Ticket T-2: I was charged twice for order ORD-1002, please refund one payment.
+INFO ... ticket-classifier.camel.yaml:72 : Ticket T-2 is billing
+INFO ... ticket-classifier.camel.yaml:26 : Ticket T-3: How do I change the email address and the password of my account?
+INFO ... ticket-classifier.camel.yaml:72 : Ticket T-3 is account
+```
+
+and the tickets in `tickets-out/shipping/T-1.json`, `tickets-out/billing/T-2.json` and
+`tickets-out/account/T-3.json`. A model can answer differently, so your categories may differ.
+
+## Install Camel CLI
+
+Install [JBang](https://www.jbang.dev/download/) and the Camel CLI as described in the
+[root README](../../README.md#install-the-camel-cli); `camel --version` confirms the install.
+
+## Run it
+
+The example needs a local model. Either start Ollama in a container with the Camel CLI (Docker or Podman must be
+running), which pulls the model `granite4:3b` after it starts:
+
+```shell
+camel infra run ollama
+```
+
+or use an Ollama installed on the machine, with `ollama pull granite4:3b` once. Both answer at
+http://localhost:11434, as `application.properties` says. Then:
+
+```shell
+camel run *
+```
+
+Stop the example with `ctrl` + `c`, and the container with `camel infra stop ollama`.
+
+## How it works
+
+- The `beans` entry builds the chat model, an `OllamaChatModel`, from `application.properties`. Temperature 0 makes
+ the answers as steady as the model allows.
+- The route builds the prompt in the variable `prompt` and calls `langchain4j-chat` with `variableSend: prompt` and
+ `variableReceive: answer`. The ticket stays the body.
+- The answer is normalized to lower-case letters only, so "Shipping." becomes `shipping`.
+- `circuitBreaker` puts a timeout of 60 seconds around the call. `onFallback` sets the category to `unknown`, so a
+ slow or stopped model does not stop the tickets.
+- The `filter` treats any category other than the three as `unknown`: a model can answer anything.
+- `CamelFileName` with a folder, `${variable.category}/${body[ticketId]}.json`, makes the file producer write the
+ ticket into the folder of its category.
+
+## Build it step by step
+
+Ask your assistant, or type it yourself, one step at a time, and run after each:
+
+1. A file route on `tickets` that unmarshals each ticket and logs its id and text.
+2. Ask the local model for the category of each ticket (shipping, billing or account) with `langchain4j-chat`,
+ keep the answer in a variable, and log it.
+3. File each ticket in `tickets-out/<category>/`, and under `unknown` when the answer is not one of the three.
+4. Put the model call in a circuit breaker with a timeout, and file the ticket under `unknown` on fallback.
+
+## Try changing
+
+- Add a ticket that fits none of the categories, such as a question about the shop's opening hours.
+- Stop Ollama while the example runs and drop in a new ticket: it is filed under `unknown` after the timeout.
+- Try another model with `ollama.model` and compare the categories.
+
+## Integration testing
+
+The example comes with a test in the [Citrus](https://citrusframework.org/) YAML DSL,
+`test/ticket-classifier.citrus.it.yaml`, which the Camel CLI runs:
+
+```shell
+camel test run test/ticket-classifier.citrus.it.yaml
+```
+
+The test starts Ollama in a container on a side port, waits until the model is pulled, runs the route and checks
+that the tickets are filed. It needs a local model, so CI skips it.
+
+## Help and contributions
+
+If you hit any problem using Camel or have some feedback, then please
+[let us know](https://camel.apache.org/community/support/).
+
+We also love contributors, so
+[get involved](https://camel.apache.org/community/contributing/) :-)
+
+The Camel riders!
diff --git a/ai/ticket-classifier/application.properties b/ai/ticket-classifier/application.properties
new file mode 100644
index 0000000..c2c5399
--- /dev/null
+++ b/ai/ticket-classifier/application.properties
@@ -0,0 +1,23 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements. See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership. The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License. You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied. See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# The local model: camel infra run ollama, or an Ollama installed on the machine
+ollama.base.url=http://localhost:11434
+ollama.model=granite4:3b
+
+# tickets-out/ is written by the example: dev mode does not watch it
+camel.jbang.dataDir=tickets-out
diff --git a/ai/ticket-classifier/metadata.json b/ai/ticket-classifier/metadata.json
new file mode 100644
index 0000000..e8e0ccd
--- /dev/null
+++ b/ai/ticket-classifier/metadata.json
@@ -0,0 +1,43 @@
+{
+ "title": "Ticket classifier",
+ "description": "A local model reads each of three support tickets and names its category, shipping, billing or account; the route checks the answer and files the ticket in tickets-out/<category>/, and an answer outside the three, or no answer within the circuit breaker's timeout, files it under unknown.",
+ "level": "ai",
+ "order": 2,
+ "teaches": {
+ "components": [
+ "langchain4j-chat",
+ "file",
+ "log"
+ ],
+ "eips": [
+ "circuitBreaker",
+ "filter",
+ "setVariable",
+ "setHeader",
+ "unmarshal",
+ "marshal"
+ ],
+ "languages": [
+ "simple",
+ "constant"
+ ],
+ "dataformats": [
+ "json"
+ ]
+ },
+ "tags": [
+ "shop",
+ "ai",
+ "llm",
+ "ollama",
+ "langchain4j",
+ "classification"
+ ],
+ "infraServices": [
+ "ollama"
+ ],
+ "needs": "a local model: `ollama pull granite4:3b`",
+ "ciSkip": true,
+ "requiresDocker": true,
+ "bundled": false
+}
diff --git a/ai/ticket-classifier/test/ticket-classifier.citrus.it.yaml b/ai/ticket-classifier/test/ticket-classifier.citrus.it.yaml
new file mode 100644
index 0000000..24c0562
--- /dev/null
+++ b/ai/ticket-classifier/test/ticket-classifier.citrus.it.yaml
@@ -0,0 +1,59 @@
+name: ticket-classifier-test
+description: A local model sorts the support tickets
+actions:
+ - camel:
+ infra:
+ run:
+ service: ollama
+ # a side port, so an Ollama already installed on the machine does not get in the way
+ port: 11435
+ # the container pulls the model after it starts; wait until Ollama lists it
+ - repeatOnError:
+ until: i >= 60
+ index: i
+ autoSleep: 5000
+ actions:
+ - http:
+ client: "http://localhost:11435"
+ sendRequest:
+ GET:
+ path: "/api/tags"
+ - http:
+ client: "http://localhost:11435"
+ receiveResponse:
+ response:
+ status: "200"
+ validate:
+ - jsonPath:
+ - expression: "$.models[?(@.name=='granite4:3b')].name"
+ value: "granite4:3b"
+ - camel:
+ jbang:
+ run:
+ integration:
+ name: "ticket-classifier"
+ file: "../ticket-classifier.camel.yaml"
+ # the properties file would win over an inline property, so all of them are given here
+ systemProperties:
+ properties:
+ - name: ollama.base.url
+ value: http://localhost:11435
+ - name: ollama.model
+ value: granite4:3b
+ - name: camel.jbang.dataDir
+ value: tickets-out
+ resources:
+ - "tickets/T-1.json"
+ - "tickets/T-2.json"
+ - "tickets/T-3.json"
+ # the model's answer varies: the test checks that every ticket is filed, not the category
+ - camel:
+ jbang:
+ verify:
+ integration: "ticket-classifier"
+ logMessage: "Ticket T-1 is "
+ - camel:
+ jbang:
+ verify:
+ integration: "ticket-classifier"
+ logMessage: "Ticket T-3 is "
diff --git a/ai/ticket-classifier/ticket-classifier.camel.yaml b/ai/ticket-classifier/ticket-classifier.camel.yaml
new file mode 100644
index 0000000..d5904a1
--- /dev/null
+++ b/ai/ticket-classifier/ticket-classifier.camel.yaml
@@ -0,0 +1,85 @@
+# A local model sorts the support tickets of the shop: it names the category of each ticket, shipping, billing or
+# account, and the ticket is filed in that folder. Any other answer, or no answer in time, files it under unknown.
+# Start the model with: camel infra run ollama
+- beans:
+ - name: chatModel
+ type: dev.langchain4j.model.ollama.OllamaChatModel
+ builderClass: dev.langchain4j.model.ollama.OllamaChatModel$OllamaChatModelBuilder
+ builderMethod: build
+ properties:
+ baseUrl: "{{ollama.base.url}}"
+ modelName: "{{ollama.model}}"
+ temperature: 0.0
+
+- route:
+ id: classify-tickets
+ from:
+ uri: file
+ parameters:
+ directoryName: tickets
+ noop: true
+ sortBy: "file:name"
+ steps:
+ - unmarshal:
+ json:
+ library: Jackson
+ - log:
+ message: "Ticket ${body[ticketId]}: ${body[text]}"
+ # the model call; when the model is slow or down, the fallback files the ticket under unknown
+ - circuitBreaker:
+ resilience4jConfiguration:
+ timeoutEnabled: true
+ timeoutDuration: 60000
+ steps:
+ - setVariable:
+ name: prompt
+ expression:
+ simple:
+ expression: >-
+ You sort the support tickets of a web shop. Answer with one word only, the category of the
+ ticket: shipping, billing or account. Ticket: ${body[text]}
+ # the prompt is sent from a variable and the answer goes to one, so the ticket stays the body
+ - to:
+ uri: langchain4j-chat
+ parameters:
+ chatId: tickets
+ chatModel: "#chatModel"
+ variableSend: prompt
+ variableReceive: answer
+ - setVariable:
+ name: category
+ expression:
+ simple:
+ expression: "${variable.answer.toLowerCase().replaceAll('[^a-z]', '')}"
+ onFallback:
+ steps:
+ - setVariable:
+ name: category
+ expression:
+ constant:
+ expression: unknown
+ # the model may answer anything: only the three categories are folders
+ - filter:
+ expression:
+ simple:
+ expression: "${variable.category} not in 'shipping,billing,account'"
+ steps:
+ - setVariable:
+ name: category
+ expression:
+ constant:
+ expression: unknown
+ - log:
+ message: "Ticket ${body[ticketId]} is ${variable.category}"
+ - setHeader:
+ name: CamelFileName
+ expression:
+ simple:
+ expression: "${variable.category}/${body[ticketId]}.json"
+ - marshal:
+ json:
+ library: Jackson
+ - to:
+ uri: file
+ parameters:
+ directoryName: tickets-out
diff --git a/ai/ticket-classifier/tickets/T-1.json b/ai/ticket-classifier/tickets/T-1.json
new file mode 100644
index 0000000..48609de
--- /dev/null
+++ b/ai/ticket-classifier/tickets/T-1.json
@@ -0,0 +1 @@
+{"ticketId": "T-1", "customer": "C-482", "text": "My order ORD-1001 has not arrived yet. It is two weeks now, where is my parcel?"}
diff --git a/ai/ticket-classifier/tickets/T-2.json b/ai/ticket-classifier/tickets/T-2.json
new file mode 100644
index 0000000..d741186
--- /dev/null
+++ b/ai/ticket-classifier/tickets/T-2.json
@@ -0,0 +1 @@
+{"ticketId": "T-2", "customer": "C-207", "text": "I was charged twice for order ORD-1002, please refund one payment."}
diff --git a/ai/ticket-classifier/tickets/T-3.json b/ai/ticket-classifier/tickets/T-3.json
new file mode 100644
index 0000000..0a6f313
--- /dev/null
+++ b/ai/ticket-classifier/tickets/T-3.json
@@ -0,0 +1 @@
+{"ticketId": "T-3", "customer": "C-134", "text": "How do I change the email address and the password of my account?"}
diff --git a/camel-jbang-example-catalog.json b/camel-jbang-example-catalog.json
index 0be9e36..6195679 100644
--- a/camel-jbang-example-catalog.json
+++ b/camel-jbang-example-catalog.json
@@ -44,7 +44,7 @@
],
"needs": "a local model",
"ciSkip": true,
- "order": 4
+ "order": 5
},
{
"name": "ai/langchain4j-chat",
@@ -138,7 +138,7 @@
"orders/order-1003.json",
"stock.json"
],
- "order": 2
+ "order": 3
},
{
"name": "ai/openai-pii-redaction",
@@ -169,7 +169,63 @@
],
"needs": "an OpenAI-compatible API: set `OPENAI_API_KEY`",
"ciSkip": true,
- "order": 3
+ "order": 4
+ },
+ {
+ "name": "ai/ticket-classifier",
+ "title": "Ticket classifier",
+ "description": "A local model reads each of three support tickets and names its category, shipping, billing or account; the route checks the answer and files the ticket in tickets-out/<category>/, and an answer outside the three, or no answer within the circuit breaker's timeout, files it under unknown.",
+ "level": "ai",
+ "teaches": {
+ "components": [
+ "langchain4j-chat",
+ "file",
+ "log"
+ ],
+ "eips": [
+ "circuitBreaker",
+ "filter",
+ "setVariable",
+ "setHeader",
+ "unmarshal",
+ "marshal"
+ ],
+ "languages": [
+ "simple",
+ "constant"
+ ],
+ "dataformats": [
+ "json"
+ ]
+ },
+ "tags": [
+ "shop",
+ "ai",
+ "llm",
+ "ollama",
+ "langchain4j",
+ "classification"
+ ],
+ "bundled": false,
+ "requiresDocker": true,
+ "hasCitrusTests": true,
+ "files": [
+ "README.md",
+ "application.properties",
+ "ticket-classifier.camel.yaml",
+ "tickets-out/account/T-3.json",
+ "tickets-out/billing/T-2.json",
+ "tickets-out/shipping/T-1.json",
+ "tickets/T-1.json",
+ "tickets/T-2.json",
+ "tickets/T-3.json"
+ ],
+ "infraServices": [
+ "ollama"
+ ],
+ "needs": "a local model: `ollama pull granite4:3b`",
+ "ciSkip": true,
+ "order": 2
},
{
"name": "cloud/aws-sqs",