SDK
12. fejezet

Aszinkron feladatok

Az IAsyncTaskTool egy külső hívó által, explicit módon indítható, hosszú futásidejű feladatokhoz való – sem a chat pipeline, sem az ingestion pipeline nem aktiválja. Ez a fejezet a szerződést, a regisztrációt és az élő előrehaladás-jelzés eszközeit mutatja be.

12.1   Mikor válaszd az IAsyncTaskTool-t?

Az eddig megismert tool-típusok mindegyike egy adott pipeline-hoz kötött: az IChatbotTool a felhasználói kérdésre válaszol, az IIngestionTool a dokumentum-feltöltés pipeline végén fut. Az IAsyncTaskTool egyik pipeline-hoz sem tartozik – egy külső backend integrátor a Platform API-n keresztül, tetszőleges időpontban indítja el, a feladat típusazonosítójának megnevezésével.

Tipikus felhasználási eset: egy már betöltött tudásgráf utólagos, célzott bővítése (pl. új entitás- vagy relációtípus pótlása a teljes ingestion újrafuttatása nélkül), vagy bármilyen más, a chat-kérésektől független, percekig-órákig futó feldolgozás, amelynek állapotát a hívó fél időről időre lekérdezi.

ℹ️
Ha a feladatot egy felhasználói chat-üzenetnek kell aktiválnia, az IChatbotTool a megfelelő eszköz. Ha a feladat a dokumentum-feltöltés pipeline részeként, minden batch-nél automatikusan lefusson, az IIngestionTool a megfelelő eszköz. Az IAsyncTaskTool kizárólag akkor indokolt, ha egyik pipeline sem illik – a feladatot egy külső hívó, önállóan, saját ütemezése szerint indítja.

12.2   A szerződés – IAsyncTaskTool<Request>

MetódusLeírás
getTaskType() A feladat egyedi típusazonosítója, amelynek megadásával a feladat elindítható – a domain-en belül egyedinek kell lennie.
getDomain() A domain, amelyhez a feladat tartozik. Default: null, ha a feladat nem egy konkrét domain-hez kötött.
getRequestClass() A feladat indításához szükséges bemeneti paraméterek osztálya – az indításkor kapott JSON erre deszerializálódik.
executeTask(taskId, domain, request, progressReporter) A feladat tényleges végrehajtása, a kiszolgáló válaszadási szálaitól elkülönített, saját szálon – tetszőlegesen hosszú ideig futhat. A domain paraméter a feladat indításakor megadott domain-t hordozza – az ALL állandót visszaadó feladat-végrehajtóknak ez az egyetlen módja, hogy megtudják, melyik domain-hez érkezett az adott indítási kérés.

Az egyenként feldolgozott elemek közötti, elszigetelt hibákat a végrehajtás saját maga kezeli – naplózza, a progressReporter hiba-számlálóján jelzi –, és a feldolgozást a többi elemmel folytatja. Ha viszont az executeTask() kivétellel tér vissza, azt a hívó a teljes feladat meghiúsulásaként kezeli, és újrapróbálkozást indíthat.

@Component
public class ExampleBackfillTask implements IAsyncTaskTool<ExampleBackfillRequest> {

    @Override
    public String getTaskType() {
        return "example_backfill";
    }

    @Override
    public String getDomain() {
        return "EXAMPLE";
    }

    @Override
    public Class<ExampleBackfillRequest> getRequestClass() {
        return ExampleBackfillRequest.class;
    }

    @Override
    public void executeTask(String taskId, String domain, ExampleBackfillRequest request,
            AsyncTaskProgressReporter progressReporter) throws Exception {
        progressReporter.reportPhase("PROCESSING");
        // ... a feladat saját feldolgozása
    }
}

Ha a feladat indításához nincs szükség semmilyen bemeneti paraméterre, a getRequestClass() az EmptyAsyncTaskRequest közös, mezők nélküli típust adhatja vissza – nem kell minden paraméter nélküli feladathoz külön, azonos szerepű, üres kérés-osztályt létrehozni.

A getDomain() az ALL állandót is visszaadhatja: az ezt visszaadó feladat-végrehajtó minden domain-hez érkező indítási kéréssel elindítható, függetlenül attól, milyen domain-t ad meg a kérés – az érintendő konkrét domain-t ilyenkor az executeTask() domain paramétere hordozza.

12.3   Regisztráció

Az IAsyncTaskTool nem terjeszti ki az IChatbotTool-t, ezért a discovery is elkülönül a chat tool-okétól: a plugin.jar-ban egy önálló META-INF/services fájl szükséges hozzá, saját sorral minden regisztrálandó osztályhoz:

META-INF/services/hu.medical.rag.common.tool.intf.IAsyncTaskTool
⚠️
Egy async task osztály nem szerepelhet egyszerre az IChatbotTool és az IAsyncTaskTool discovery fájljában – a két tool-fajta strukturálisan elkülönül egymástól.

A betöltés menete megegyezik a többi plugin tool-éval: a platform @Autowired-del feltölti a mezőket, majd lefuttatja a @PostConstruct-tal jelölt metódusokat – ezért az IAsyncTaskTool implementációnak is publikus, paraméter nélküli konstruktorral kell rendelkeznie, a domain-specifikus függőségeket (pl. egy NER ingestion tool referenciáját) @Autowired mezőn és @PostConstruct init()-en keresztül kell bekötni.

12.4   AsyncTaskProgressReporter – élő előrehaladás-jelzés

Az executeTask() egy AsyncTaskProgressReporter csatornát kap paraméterként – ezen keresztül teszi közzé a feladat, hol tart, anélkül hogy ismernie kellene, hogyan és hová kerül ez az információ tárolásra.

MetódusMikor használd
reportPhase(phase) Fázisváltás jelzése, ha a feladat több, egymást követő, jól elkülöníthető szakaszból áll. Az új szakasz számlálói nulláról indulnak.
reportProgress(succeeded, failed, total) A jelenlegi szakasz élő előrehaladása, a feldolgozandó elemek ismert teljes számával együtt.
reportProgress(succeeded, failed) Ugyanaz, ha a feldolgozandó elemek teljes száma előre nem ismert.
beginLiveProgress(phase, progressBasePath, succeededCounter, failedCounter, total) Egy szakaszt jelöl ki folyamatosan, önállóan pollozandó előrehaladásúnak – a tényleges számokat nem a hívó küldi elemenként, hanem egy fájlrendszer-alapú, partíciónkénti számlálóban teszi közzé (→ 12.6).
endLiveProgress(phase) Lezárja a megadott nevű, korábban beginLiveProgress()-szel kijelölt szakaszt.
💡
Egyszerű, driver-oldali feladatoknál a reportPhase() + reportProgress() pár elegendő. A beginLiveProgress()/ endLiveProgress() pár akkor hasznos, ha a feladat egy Spark transformer-en keresztül, sok partíción párhuzamosan dolgozik, és a partíciónkénti előrehaladást akarod élőben, elemszám-alapon látni.

12.5   AbstractGraphEnrichmentTask – kész alap tudásgráf-bővítéshez

Egy gyakori aszinkron feladat-fajta: egy már betöltött domain tudásgráfjának utólagos, célzott bővítése – új entitás- és/vagy relációtípus pótlása a teljes ingestion újrafuttatása nélkül, a domain már tárolt szövegén. Erre a platform egy kész abstract alaposztályt biztosít:

MetódusLeírás
defineEntityTypes() (implementálandó) Az ehhez a futáshoz keresendő entitástípusok – a domain NER ingestion tool-jának teljes vokabuláriumából egy rész vagy az egész.
defineRelationTypes() (implementálandó) Az ehhez a futáshoz keresendő relációtípusok, ugyanígy.
setAbstractNERIngestionTool(nerIngestionTool) Beállítja, melyik NER ingestion tool tárolt szövegén és gráfján dolgozzon a feladat.
getDomain() Már implementálva – a beállított NER ingestion tool domain-jét adja vissza.

A forrás-szöveg betöltését, az LLM-alapú kinyerést, a kereszt-oldal elemzést és a gráfba-írást az executeTask() már implementálva végzi el, a beállított NER ingestion tool-on keresztül – a leszármazottnak csak azt kell megadnia, milyen entitás- és relációtípusokat keressen ebben a futásban.

ℹ️
Ha a defineRelationTypes() által visszaadott relációtípusok valamelyike validFromTypes/validToTypes végpont-megkötést ad meg, és az ott hivatkozott entitástípus hiányzik a defineEntityTypes() szűkített listájából, a kinyerés automatikusan felveszi a hiányzó típust a NER ingestion tool teljes vokabuláriumából – a relációnak enélkül nem lenne honnan kapnia az egyik végpontját. Ha egy relációtípus valamelyik végpontján üres a megkötő halmaz (validFromTypes vagy validToTypes), azon a végponton bármely entitástípus érvényesnek számít, ezért ilyenkor az automatikus kiegészítés a NER ingestion tool teljes entitástípus-vokabuláriumát felveszi a szűkített listába.

A bekötés a többi plugin tool típusnál megszokott no-arg konstruktor + @PostConstruct init() mintát követi:

@Component
public class SupplierRelationBackfillTask
        extends AbstractGraphEnrichmentTask<EmptyAsyncTaskRequest> {

    private static final Set<String> TARGET_ENTITY_TYPES = Set.of("SUPPLIER");
    private static final Set<String> TARGET_RELATION_TYPES = Set.of("SUPPLIES");

    @Autowired
    private MyDomainNERIngestionTool nerIngestionTool;

    public SupplierRelationBackfillTask() {
    }

    @PostConstruct
    public void init() {
        setAbstractNERIngestionTool(nerIngestionTool);
    }

    @Override
    protected List<EntityTypeDescriptor> defineEntityTypes() {
        return nerIngestionTool.defineEntityTypes().stream()
            .filter(d -> TARGET_ENTITY_TYPES.contains(d.type()))
            .collect(Collectors.toList());
    }

    @Override
    protected List<RelationTypeDescriptor> defineRelationTypes() {
        return nerIngestionTool.defineRelationTypes().stream()
            .filter(d -> TARGET_RELATION_TYPES.contains(d.type()))
            .collect(Collectors.toList());
    }

    @Override
    public String getTaskType() {
        return "supplier_relation_backfill";
    }

    @Override
    public Class<EmptyAsyncTaskRequest> getRequestClass() {
        return EmptyAsyncTaskRequest.class;
    }
}
⚠️
A fenti mód a domain már tárolt szövegéből nyer ki új entitásokat/relációkat - nincs tudomása a tudásgráfban már meglévő fogalmakról. Ha egy entitástípus vagy relációtípus vokabuláriuma úgy változott, hogy a korábbi ingestion a szóban forgó fogalmakat már kinyerte, csak rossz típussal vagy szétaprózva több fogalomra, ez a mód nem a megfelelő eszköz - egy új, a réginél célzottabb típus alatt egy második, duplikált csomópontot hozna létre a régi, rosszul típusozott mellé (az upsertConcept dedup kulcsa normalizedLabel + type, tehát eltérő típusra új csomópont jön létre, nem módosul a régi). Erre az esetre szolgál az alábbi, újraosztályozási mód.

12.5b   AbstractGraphEnrichmentTask – újraosztályozási mód

Ha a defineReclassificationSourceTypes() hook felülírva egy nem üres entitástípus-halmazzal tér vissza, a feladat futáskor nem a fenti, szövegből kinyerő módban fut, hanem a tudásgráfban már meglévő, a megadott típusú fogalmakat vizsgálja végig: minden ilyen fogalom jelenlegi megnevezését és típusát egy LLM elé tárja a defineReclassificationTargetTypes() által felkínált típuslistával együtt, majd a kapott döntés alapján a fogalom típusát a helyén (azonos ArangoDB kulcs mellett) frissíti - a korábbi típust a fogalom previousType mezőjében, a változás időpontját a retypedAt mezőben megőrizve, hogy egy elhibázott átosztályozás utólag visszakereshető/visszaállítható legyen (egy fogalom ismételt átosztályozása esetén ez a két mező csak a legutóbbi változást tükrözi, nem egy teljes előzménylistát). A fogalom aliasai, mention-jei és a hozzá kapcsolódó élek túlnyomó része érintetlen marad - a _key nem változik, ezért a rá mutató kapcsolatok automatikusan a retípusozott csomópontra mutatnak tovább, migrálás nélkül.

ℹ️
Minden fogalom, amelyre az LLM ténylegesen adott érdemi verdiktet, megkapja a lastReviewedAt (a felülvizsgálat időpontja) és lastReviewReasoning (az LLM indoklása) mezőpárt is - attól függetlenül, hogy a verdikt végül típusváltást, logikai törlést vagy egyiket sem eredményezte. Ez teszi utólag is megállapíthatóvá, hogy egy adott fogalmat már átnézett egy felülvizsgálati kör, akkor is, ha az LLM szerint nem volt rajta mit változtatni - egy fogalom ismételt felülvizsgálata esetén ez a két mező csak a legutóbbi vizsgálatot tükrözi, nem egy teljes előzménylistát.
⚠️
A felülvizsgált fogalmak köre a beállított NER ingestion tool eligible system-jére szűkül: egy fogalom csak akkor kerül felülvizsgálatra, ha a systems mezője kizárólag ezt az egy system-et tartalmazza. Egy másik system-hez tartozó, semelyik system-hez nem kötött, vagy több system között megosztott fogalom típusa a reklasszifikáció során nem változik - ez azért fontos, mert a KnowledgeConcept csomópontok domain-szinten (nem system-szinten) megosztottak, tehát a felülvizsgálandó típusok (pl. ANATOMY, OTHER) alatt könnyen szerepelhetnek más system NER futásából származó fogalmak is.
HookLeírás
defineReclassificationSourceTypes() A felülvizsgálandó, jelenlegi fogalomtípusok halmaza. Alapértelmezés: üres halmaz - ez tartja meg a visszafelé kompatibilis, szövegből kinyerő alapviselkedést minden olyan leszármazottra, amely ezt a hook-ot nem írja felül.
defineReclassificationTargetTypes() (újraosztályozási módban kötelező) Az LLM-nek felkínált célentitástípusok listája - a jelenlegi típus is szerepelhet benne, ha az LLM szerint az a legpontosabb, azaz nincs teendő.
defineRelationTypeMigrations() Opcionális RelationTypeMigration(oldRelationType, newConceptType, newRelationType) szabálylista: ha egy fogalom új típusa és egy hozzá kapcsolódó él jelenlegi relációtípusa egyszerre illeszkedik egy szabályra, az adott él relációtípusa átíródik. A szabályban nem szereplő kombinációjú élek relációtípusa változatlan marad, még akkor is, ha az már nem a legpontosabb leírás az új típus mellett.
getReclassificationConfidenceThreshold() A legalacsonyabb LLM-bizalmi százalék, amely mellett egy javasolt új típus ténylegesen alkalmazásra kerül. Alapértelmezés: 70.
defineNotAnEntityMarkerType() Opcionális, a felkínált típuslistán kívüli válaszlehetőség leírása, amelyet az LLM akkor választhat, ha a fogalom megnevezése szerinte egyáltalán nem egy valódi, önálló fogalom (pl. relációtípus neve, önállóan álló kód, puszta szám/dátum, mondattöredék). Alapértelmezés: null, vagyis a lehetőség nincs felkínálva.
getNotAnEntityDeletionConfidenceThreshold() A legalacsonyabb LLM-bizalmi százalék, amely mellett egy "nem valódi fogalom" döntés ténylegesen logikai törlést vált ki. Alapértelmezés: 85 - magasabb, mint a típusváltás küszöbe, mert ez a döntés következményeit tekintve súlyosabb.
ℹ️
Ha egy fogalomra "nem valódi fogalom" döntés érkezik elég magas bizalommal, a feladat nem típust vált rajta, és nem is távolítja el fizikailag: a fogalom-dokumentumon beállítja a deletedAt (időbélyeg) és deletionReasoning (az LLM indoklása) mezőket, a rá mutató ConceptRelation élek és ConceptMention rekordok pedig érintetlenül megmaradnak. Ez a döntés a típusváltásnál szigorúbb, külön beállítható bizalmi küszöböt használ (getNotAnEntityDeletionConfidenceThreshold()). A deletedAt mezővel ellátott fogalmak a tudásgráf-szolgáltatás lekérdező metódusai számára láthatatlanná válnak, így a fogalom a normál működés szempontjából eltűnik, de az adat nem vész el: egy külön művelet vissza tudja állítani (törli a deletedAt/deletionReasoning mezőket), egy másik pedig - ha szükséges - véglegesen, a kapcsolódó mention-ökkel és élekkel együtt el tudja távolítani.
⚠️
Ha egy fogalom új típusa mellett a tudásgráfban már létezik egy másik, azonos normalizedLabel + type kulcsú fogalom, a feladat ezt a fogalmat változatlanul hagyja, és csak naplózza az ütközést - két ilyen fogalom összevonása egy fogalom-összevonó feladat (nem az újraosztályozás) felelőssége, mert az már nem típusváltás, hanem két önálló csomópont egyesítése aliasokkal, mention-ökkel és élekkel együtt.
@Component
public class AnatomyOtherReclassificationTask
        extends AbstractGraphEnrichmentTask<EmptyAsyncTaskRequest> {

    private static final Set<String> SOURCE_TYPES = Set.of("ANATOMY", "OTHER");

    private static final List<RelationTypeMigration> MIGRATIONS = List.of(
        new RelationTypeMigration("AFFECTS", "PHARMACOLOGICAL_TARGET", "MODULATES")
    );

    @Autowired
    private MyDomainNERIngestionTool nerIngestionTool;

    @PostConstruct
    public void init() {
        setAbstractNERIngestionTool(nerIngestionTool);
    }

    @Override
    protected Set<String> defineReclassificationSourceTypes() {
        return SOURCE_TYPES;
    }

    @Override
    protected List<EntityTypeDescriptor> defineReclassificationTargetTypes() {
        return nerIngestionTool.defineEntityTypes();
    }

    @Override
    protected List<RelationTypeMigration> defineRelationTypeMigrations() {
        return MIGRATIONS;
    }

    // defineEntityTypes()/defineRelationTypes() (a kinyerési mód hookjai) ilyenkor is
    // implementálandók a fordításhoz, de amíg defineReclassificationSourceTypes() nem üres,
    // futáskor nem kerülnek meghívásra.
}

12.6   Élő progress Spark partíciókból – PartitionProgressCounter

Ha a feladat saját Spark transformer-en keresztül, sok partíción párhuzamosan dolgozik, a PartitionProgressCounter statikus segédosztály ad módot arra, hogy minden partíció a saját, elkülönített számlálóját írja – anélkül hogy a partíciók egymást felülírnák vagy versenyhelyzetbe kerülnének.

MetódusLeírás
increment(basePath, phase, partitionId, counterName) A megadott partíció számlálóját eggyel megnöveli.
incrementBy(basePath, phase, partitionId, counterName, delta) Ugyanaz, tetszőleges delta értékkel – batch-szerűen feldolgozott munkánál hasznos, hogy ne kelljen elemenként külön hívást tenni.

A basePath és a phase paraméternek meg kell egyeznie azzal, amit a feladat a progressReporter.beginLiveProgress(phase, progressBasePath, ...) hívásban megadott – a jelentő csatorna ez alapján találja meg és olvassa fel a partíciónkénti számlálókat.

dataset.foreachPartition(rows -> {
    int partitionId = TaskContext.getPartitionId();
    for (Row row : rows) {
        // ... egy elem feldolgozása
        PartitionProgressCounter.increment(progressBasePath, "PROCESSING", partitionId, "succeeded");
    }
});