Az első tool – IChatbotTool
Egy egyszerű, működő tool felépítése lépésről lépésre: a request bean-től az interfész metódusain át a teljes implementációig.
3.1 Az IChatbotTool szerepe
Az IChatbotTool a platform legalapvetőbb bővítési pontja.
Minden plugin tool – közvetlenül vagy abstract alaposztályon keresztül –
ezt az interfészt implementálja.
Az aktiválás folyamata:
- A felhasználó kérdést tesz fel.
- A platform LLM-je eldönti, hogy a kérdés megválaszolásához szükség van-e erre a tool-ra
(a
getExecutableInstruction()alapján). - Ha igen, a platform kinyeri a szükséges paramétereket a kérdésből
(a
getRequestAsJsonNode()által leírt JSON séma alapján). - Meghívja az
executeWithResult()metódust. - A tool visszaad egy
ToolExecutionResult-ot: kontextust, kész választ, vagy jelzi, hogy nem kezeli a kérést (null).
3.2 A request bean
Minden tool-hoz tartozik egy request bean: egy egyszerű Java osztály,
amelynek mezői azokat a paramétereket írják le, amelyeket a platform az LLM segítségével
kinyeri a felhasználói szövegből. A mezők lehetnek null értékűek, ha
az adott paraméter nem derül ki a kérdésből.
Példa egy egyszerű kérés bean-re:
package hu.medical.rag.plugin.myplugin.tool.beans;
import lombok.Data;
@Data
public class DepartmentInfoRequest {
/** A lekérdezett osztály neve (pl. "belgyógyászat", "kardiológia").
Null, ha a kérdésből nem derül ki. */
private String department;
}
A platform a JsonSchemaGenerator.generateSchema() segítségével
automatikusan JSON sémát épít a bean mezőiből, amelyet az LLM constrained
decoding-hoz használ. A típusleképezés:
| Java típus | JSON séma típus |
|---|---|
String | string |
Integer, Long, Double | number |
Boolean | boolean |
List<String> | array of string |
List<T> ahol T nem primitív | array of object (rekurzív) |
| Enum | string + enum értékek |
| Egyéb osztály | object (rekurzív) |
department,
location, days). A Javadoc komment a fejlesztőnek
szól, az LLM-nek a getExecutableInstruction() ad kontextust.
JsonSchemaGenerator sima Field reflection alapján
dolgozik, és semmilyen Jackson annotációt nem olvas ki – egy
@JsonPropertyDescription vagy @JsonProperty a mezőn
hatástalan, nem jelenik meg az LLM-nek küldött sémában. A mezőkhöz tartozó
leírás kizárólag a getInstruction() szövegéből származhat, nem a
request bean annotációiból. Ha egy mezőt kizárólag a hívó/feldolgozó kód tölt
ki vagy olvas, sosem az LLM, a saját @ExcludeFromSchema
annotációval kihagyható a sémából – ez a tényleges JSON
(de)szerializációt nem érinti, csak a séma-generálást. Egy önmagára hivatkozó
request bean (pl. egy fa-szerkezetű mező, amely ugyanazt a típust tartalmazza
listaként) esetén a séma-generálás az önhivatkozás pontján üres object-ként
zárja le a beágyazott sémát, végtelen rekurzió nélkül.
3.3 Az interfész metódusai
getName()
A tool egyedi neve – ez azonosítja a tool-t a platformon belül. Kisbetűs, aláhúzásjeles formátum javasolt. Ütközés esetén a platform az utoljára betöltött tool-t tartja meg.
@Override
public String getName() {
return "department_info";
}
getDomain() és getEligibleSystem()
Meghatározza, melyik domain-ben és system-ben aktiválódhat a tool.
getDomain()-nál a null visszatérési érték minden domain-en
aktiválódó tool-t jelent. getEligibleSystem()-nél viszont a null
nem jelent univerzális elérhetőséget: csak akkor aktiválódik, ha a kérésben
nincs explicit system szűrő – ha a kérés konkrét system(eke)t ad meg, a null
deklarációjú tool kimarad. Ha egy tool minden system mellett elérhető kíván lenni, akkor
is, ha a kérés explicit system szűrőt tartalmaz, az IChatbotTool.ALL-t kell
visszaadnia null helyett. A domain és system fogalmáról, valamint a pontos
aktiválási szabályokról részletesen az 1.2-es fejezet szól.
@Override
public String getDomain() {
return "korhaz"; // csak a "korhaz" domain-ben aktiválódik
}
@Override
public String getEligibleSystem() {
return null; // csak akkor aktiválódik, ha a kérésben nincs system szűrő
}
getInstruction()
Az LLM-nek szóló, részletes mezőkitöltési instrukció a paraméter-kinyeréshez.
Az ILlmAssistant.generateToolRequest() hívásakor, ha a rendszer
prompt %s helyőrzőt tartalmaz, a platform a második helyre ezt
az értéket helyettesíti be. Ha a rendszer promptod nem tartalmaz %s-t,
az értéke figyelmen kívül marad.
Például így néz ki egy részletes kitöltési instrukció:
@Override
public String getInstruction() {
return """
department_info: Töltsd ki a következő mezőket:
- department (type: string): Az osztály neve magyarul (pl. "belgyógyászat").
Ha nem derül ki, hagyd üresen.
""";
}
Ha a rendszer promptod már tartalmazza az összes instrukciót és nincs benne
%s, akkor elegendő üres stringet visszaadni – vagy a metódust
felülírni egy rövid leírással, amely a tool nevével kapcsolatos,
de az LLM routing nem ebből dolgozik.
getExecutableInstruction()
Ez az a szöveg, amelyet a platform az LLM routing promptjába illeszt.
Az LLM ennek alapján dönti el, hogy a felhasználói kérdésre szükség
van-e erre a tool-ra. A formátum kötött: a tool neve (%s)
a sor elején szerepel, utána a feltétel leírása következik.
@Override
public String getExecutableInstruction() {
return """
%s: Kórházi osztállyal kapcsolatos információra van szükség?
- true, ha:
- Rendelési időt, helyszínt, telefonszámot kérdez
- Osztályra, szakrendelőre kérdez rá
- false, minden más esetben
""".formatted(getName());
}
getRequestAsJsonNode() és getRequestClass()
A platform ezeken keresztül tudja, milyen struktúrában várja a kinyert
paramétereket. getRequestClass()-t mindig implementálni kell.
getRequestAsJsonNode()-nak viszont van egy default implementációja
az interfészen (JsonSchemaGenerator.generateSchema(getRequestClass())),
ami már a getRequestClass() alapján automatikusan a helyes sémát
adja vissza – ha ettől nem térsz el, ne írd felül feleslegesen.
@Override
public Class<? extends DepartmentInfoRequest> getRequestClass() {
return DepartmentInfoRequest.class;
}
getRequestAsJsonNode() felülírása csak akkor indokolt, ha a
séma nem a getRequestClass() beanből generálható közvetlenül
(pl. dinamikusan összeállított séma). A legtöbb tool esetén a default
implementáció elegendő. A visszaadott csomópont lehet a mezők leírása (ezt adja a
generateSchema()) vagy egy teljes, "type": "object" gyökerű
séma is: a platform a mezőleírást beágyazáskor maga teszi objektumséma-keretbe
(JsonSchemaGenerator.asObjectSchema()), a teljes sémát változatlanul hagyja.
isEligible()
Nem-LLM kapu: az LLM döntése előtt fut, és nem-funkcionális feltételek
alapján dönti el, hogy a tool aktiválható-e. Alapértelmezés: mindig true.
A userData paraméter a bejelentkezett felhasználó adatait tartalmazza.
@Override
public boolean isEligible(Map userData) {
// Pl. csak belső (nem anonim) felhasználóknak
return userData != null;
}
getSystemPromptSupplement()
Opcionális kiegészítés a rendszer prompthoz. Ha nem null,
a platform a tool neve alapján tárolja, és az alap domain leíráshoz fűzi
hozzá, amikor a tool aktiválódott. Csak akkor érdemes használni, ha a
végső LLM választ befolyásolni szeretnéd a tool kontextusában.
@Override
public String getSystemPromptSupplement() {
return "Az osztály-információk az intézményi nyilvántartásból származnak, "
+ "kezelj megbízható adatként.";
}
3.4 A fő végrehajtási metódus: executeWithResult()
Ez az a metódus, amelyet a platform a tool aktiválásakor hív meg.
ToolExecutionResult executeWithResult(
List<String> systems, // aktív system szűrők (lehet üres lista)
String userPrompt, // a felhasználói kérdés szövege
String userLang, // a válasz elvárt nyelve (pl. "magyar", "angol")
List<String> importantWords, // az LLM által kiemelten fontosnak jelölt szavak
FluxSink<TextGenerationResponse> progressEmitter, // progress jelzésekhez
CallbackWithErrorHandler<OpenAIResponseVO, Exception> finalCallback // streaming-hez
)
A userLang-ot a platform a felhasználói kérdés nyelv-normalizálása során
állapítja meg, még a tool aktiválása előtt – ha a tool maga is LLM-et hív (pl. saját,
dedikált system prompt-tal), ezt kell átadnia a válasz nyelveként, hogy ne az alapértelmezett
nyelven feleljen.
domain paraméterrel bővített overload is
(executeWithResult(String domain, ...)) azoknak a tool-oknak, amelyek több
domain-en is aktiválódnak, és futásidőben kell megtudniuk, melyik domain hívta őket –
részletek az API referenciában.
Visszatérési értékek:
null– a tool nem kezeli ezt a kérést; a platform a saját RAG pipeline-ját futtatja tovább.ToolExecutionResult.asContext(...)– a tool dokumentumokat ad a RAG kontextushoz; az LLM ezekből generál választ.ToolExecutionResult.asAnswer(...)– a tool kész választ adott; az LLM hívás elmarad.ToolExecutionResult.asAnswered(...)– a tool maga streamelte a választ; a pipeline végzett.
A visszatérési módokról részletesen a 9. fejezet szól.
progressEmitter és a finalCallback lehet null
(pl. nem-streaming hívás esetén). Mindig ellenőrizd, mielőtt használod.
A progressEmitter.complete() vagy error() metódusát
soha ne hívd meg – a pipeline-t csak a platform zárhatja le.
3.5 Progress jelzések küldése
Ha a tool hosszabb műveletet végez (pl. külső API hívás), progress eseményeket
küldhet a kliensnek. Ehhez az interfész beépített toolProgressEmitter()
segédmetódusát kell használni – ez becsomagolja a jelzéseket
TOOL_PROGRESS:<TOOL_NAME>:<ESEMÉNY> formátumba,
és gondoskodik a null emitter biztonságos kezeléséről.
FluxSink<TextGenerationResponse> emitter = toolProgressEmitter(progressEmitter);
emitter.next(new TextGenerationResponse("FETCHING_DATA"));
// ... külső hívás ...
emitter.next(new TextGenerationResponse("PROCESSING_RESULT"));
Az esemény nevét a kliens TOOL_PROGRESS:DEPARTMENT_INFO:FETCHING_DATA
formában kapja meg, és saját maga dönt a megjelenítésről.
toolProgressEmitter() csak tetszőleges, tájékoztató jellegű
progress-eseményekhez való. Van néhány, a platform által lefoglalt
progressStatus érték (pl. LLM_GENERATION,
SEGMENT_BREAK), amelyeket egy ANSWERED módú tool a
progressEmitter-en közvetlenül, a
toolProgressEmitter() becsomagolása nélkül küld ki – ezekről a
9.4 alfejezet szól.
3.6 Teljes példa
Az alábbi tool egy egyszerű osztályinformáció-lekérdezőt valósít meg. Az LLM kinyeri az osztály nevét a kérdésből, a tool egy belső térképből előhúzza az adatokat, és CONTEXT módban visszaadja az LLM-nek.
package hu.medical.rag.plugin.myplugin.tool;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import org.springframework.beans.factory.annotation.Autowired;
import com.fasterxml.jackson.databind.ObjectMapper;
import hu.medical.rag.common.beans.TextGenerationResponse;
import hu.medical.rag.common.beans.UniqueTextDocument;
import hu.medical.rag.common.intf.CallbackWithErrorHandler;
import hu.medical.rag.common.openai.beans.OpenAIResponseVO;
import hu.medical.rag.common.tool.beans.ToolExecutionResult;
import hu.medical.rag.common.tool.intf.IChatbotTool;
import hu.medical.rag.common.tool.intf.ILlmAssistant;
import hu.medical.rag.plugin.myplugin.tool.beans.DepartmentInfoRequest;
import reactor.core.publisher.FluxSink;
public class DepartmentInfoTool implements IChatbotTool<DepartmentInfoRequest> {
// Az LLM paraméter-kinyeréshez – a platform tölti fel (autowire)
@Autowired
private ILlmAssistant llmAssistant;
@Autowired
private ObjectMapper objectMapper;
// Egyszerű belső adatforrás – valódi esetben ez lehetne DB hívás, REST API stb.
private static final Map<String, String> DEPARTMENTS = Map.of(
"belgyógyászat", "Fő épület, 2. emelet. Rendelés: H–P 8:00–14:00.",
"kardiológia", "B épület, 3. emelet. Rendelés: H–Cs 9:00–13:00.",
"bőrgyógyászat", "C épület, fszt. Rendelés: K, Cs 10:00–14:00."
);
private static final String SYSTEM_PROMPT = """
A felhasználó kórházi osztályról kérdez. Azonosítsd:
- "department": az osztály neve (pl. "belgyógyászat", "kardiológia").
Ha nem derül ki, hagyd null-on.
""";
@Override
public String getName() { return "department_info"; }
@Override
public String getDomain() { return "korhaz"; }
@Override
public String getEligibleSystem() { return null; }
@Override
public String getInstruction() {
return "Kórházi osztályok elérhetőségét és rendelési idejét kérdezi le.";
}
@Override
public String getExecutableInstruction() {
return """
%s: Kórházi osztállyal kapcsolatos információra van szükség?
- true, ha rendelési időt, helyszínt vagy telefonszámot kérdez
- false, minden más esetben
""".formatted(getName());
}
// getRequestAsJsonNode()-ot nem írjuk felül – a default interfész-implementáció
// (JsonSchemaGenerator.generateSchema(getRequestClass())) elegendő
@Override
public Class<? extends DepartmentInfoRequest> getRequestClass() {
return DepartmentInfoRequest.class;
}
@Override
public String getSystemPromptSupplement() {
return "Az osztály-adatok az intézményi nyilvántartásból származnak.";
}
@Override
public ToolExecutionResult executeWithResult(
List<String> systems, String userPrompt, String userLang, List<String> importantWords,
FluxSink<TextGenerationResponse> progressEmitter,
CallbackWithErrorHandler<OpenAIResponseVO, Exception> finalCallback) {
try {
// 1. Paraméterek kinyerése LLM segítségével
FluxSink<TextGenerationResponse> emitter = toolProgressEmitter(progressEmitter);
emitter.next(new TextGenerationResponse("EXTRACTING_PARAMS"));
HashMap<String, Object> extracted =
llmAssistant.generateToolRequest(userPrompt, SYSTEM_PROMPT, null, this);
DepartmentInfoRequest request = objectMapper.convertValue(
extracted.get(getName()), DepartmentInfoRequest.class);
if (request == null || request.getDepartment() == null) {
return null; // nem tudtuk azonosítani az osztályt → platform RAG folytatja
}
// 2. Adat lekérdezés
emitter.next(new TextGenerationResponse("FETCHING_DATA"));
String dept = request.getDepartment().toLowerCase();
String info = DEPARTMENTS.entrySet().stream()
.filter(e -> dept.contains(e.getKey()))
.map(Map.Entry::getValue)
.findFirst()
.orElse(null);
if (info == null) {
return null; // ismeretlen osztály → platform RAG folytatja
}
// 3. Eredmény összerakása – UniqueTextDocument kontextusként
String content = "Osztály: " + request.getDepartment() + "\n" + info;
UniqueTextDocument doc = new UniqueTextDocument(
1L, // documentId
"Osztályok", // documentName
null, // documentUrl (nincs)
0, // page
1, // sentenceNumber
content, // text
1.0 // sentenceScore
);
HashSet<UniqueTextDocument> docs = new HashSet<>();
docs.add(doc);
return ToolExecutionResult.asContext(getName(), docs);
} catch (Exception e) {
System.err.println("[DepartmentInfoTool] Hiba: " + e.getMessage());
return null;
}
}
}
return null azt jelenti: „nem tudok segíteni,
a platform próbálja a saját RAG keresésével". Ez mindig biztonságos
visszatérési érték – a platform soha nem szakad meg tőle.
3.7 Az ILlmAssistant injektálása
Az ILlmAssistant a platform LLM elérési pontja a plugin számára.
Nem kell példányosítani – a platform tölti fel automatikusan (@Autowired)
a plugin betöltésekor.
A leggyakoribb használat a paraméter-kinyerés:
HashMap<String, Object> extracted =
llmAssistant.generateToolRequest(userPrompt, systemPrompt, tasks, this);
A paraméterek:
userPrompt– a felhasználói kérdés szövegesystemPrompt– az LLM-nek szóló instrukció; ha%shelyőrzőt tartalmaz, a platform a első helyre atasksértékét, a második helyre agetInstruction()visszatérési értékét helyettesíti betasks– opcionális kiegészítő instrukció szövege (az első%shelyére kerül); ha nincs, adjunknull-tthis– a tool referencia, amelyből a platform a nevet, az instrukciót és a sémát olvassa
A visszaadott Map kulcsa a tool neve (getName()),
értéke a kinyert paraméterek objektuma. Az ObjectMapper-rel
konvertálható a request bean-re:
MyRequest request = objectMapper.convertValue(
extracted.get(getName()), MyRequest.class);
Az ILlmAssistant részletes leírása a
8. fejezetben olvasható.
3.8 Összefoglalás – a minimális tool checklist
| Lépés | Mit kell csinálni? |
|---|---|
| 1. Request bean | Létrehozni a paraméter osztályt (@Data Lombok-kal vagy kézzel) |
| 2. IChatbotTool implementálása | getName(), getDomain(), getExecutableInstruction(),
getInstruction(), getRequestClass(),
executeWithResult() (getRequestAsJsonNode() csak akkor,
ha eltérsz a default implementációtól) |
| 3. ILlmAssistant, ObjectMapper | @Autowired mezőként felvenni |
| 4. ServiceLoader regisztráció | A teljes osztálynevet felvenni a
META-INF/services/hu.medical.rag.common.tool.intf.IChatbotTool fájlba |
| 5. Build és deploy | mvn clean package, majd a
target/plugins/{artifactId}/ könyvtár átmásolása a szerverre |