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 <[email protected]>
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) <[email protected]>
    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",

Reply via email to