SDK
3. fejezet

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:

  1. A felhasználó kérdést tesz fel.
  2. 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).
  3. 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).
  4. Meghívja az executeWithResult() metódust.
  5. A tool visszaad egy ToolExecutionResult-ot: kontextust, kész választ, vagy jelzi, hogy nem kezeli a kérést (null).
ℹ️
Ez a fejezet az alap IChatbotTool-t mutatja be – amelyet a platform LLM-alapú routing-ja aktivál. Az orchestrátor által aktivált tool-okról (IOrchestratorActivatedTool) a 4–5. fejezet szól.

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ípusJSON séma típus
Stringstring
Integer, Long, Doublenumber
Booleanboolean
List<String>array of string
List<T> ahol T nem primitívarray of object (rekurzív)
Enumstring + enum értékek
Egyéb osztályobject (rekurzív)
💡
A mezők nevei közvetlenül megjelennek az LLM számára – érdemes értelmes, angolul is jól olvasható néveket választani (pl. department, location, days). A Javadoc komment a fejlesztőnek szól, az LLM-nek a getExecutableInstruction() ad kontextust.
⚠️
A 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.

💡
Van egy másik, 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:

A visszatérési módokról részletesen a 9. fejezet szól.

⚠️
A 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.

💡
A 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;
        }
    }
}
💡
A példában a 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:

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ésMit 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