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",
