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.
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ódus | Leí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
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ódus | Mikor 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. |
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ódus | Leí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.
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;
}
}
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.
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.
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.
| Hook | Leí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. |
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.
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ódus | Leí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");
}
});