Spring AI · Alət çağırışları və əməliyyatların icrası · Dərs 3

Sorğudan əməliyyatın icrasına keçid

Modelin API və verilənlər bazasından məlumat alması, sorğuya uyğun əməliyyatlar yerinə yetirməsi üçün alətlər təqdim et.

Sorğudan əməliyyatın icrasına keçid

Qeyd oxumaq öyrənənin vəziyyətini dəyişmir. Tədris planını saxlamaq isə dəyişir. Model arqument uydursa, çağırışı təkrarlasa və ya istifadəçinin artıq təsdiq verdiyini desə belə, əməliyyat üçün icazə yoxlanmalıdır. Kiçik prosesdaxili yaddaşda yalnız sintetik planlar saxlayacağıq.

1. Təklifi, təsdiqi və icranı ayırın

Model dəstəklənən mövzu və 5 ilə 60 arasında tədris dəqiqəsi təklif edə bilər. Server sahibi, ID-si və beş dəqiqəlik müddəti olan dəyişməz təklif yaradır. Etibarlı UI təsdiq metodunu çağırmazdan əvvəl autentifikasiya edilmiş öyrənənə dəqiq mövzunu və müddəti göstərməlidir.

Təsdiq @Tool annotasiyası olmayan server əməliyyatıdır. Onu “istifadəçi təsdiqlədi” deyən generasiya edilmiş mətn başlatmamalıdır. Sonrakı saxlama sorğusu yalnız təsdiqlənmiş təklifin ID-sinə istinad edir; model icra zamanı təsdiqlənmiş mövzunu və müddəti dəyişə bilməz. Əhəmiyyətli nəticəsi olan əməliyyatlarda təsdiqi qəbul etməzdən əvvəl real ünvanı və təsiri göstərən belə açıq baxış addımı qurun.

2. İcazəli və təkrarlana bilən saxlama qurun

src/main/java/academy/study/StudyPlanTools.java faylını yaradın:

Java
package academy.study;

import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;

public final class StudyPlanTools {
    private final Clock clock;
    private final Map<String, Entry> proposals = new HashMap<>();
    private final Map<String, SavedPlan> plans = new HashMap<>();

    public StudyPlanTools(Clock clock) {
        this.clock = Objects.requireNonNull(clock);
    }

    @Tool(name = "propose_study_plan", description = "Prepare a study plan proposal requiring separate human approval")
    public synchronized Proposal propose(@ToolParam(description = "Supported Java topic") StudyTipTools.Topic topic,
                                         @ToolParam(description = "Study minutes from 5 to 60") Integer minutes,
                                         ToolContext context) {
        String owner = ToolSession.from(context).use(ToolSession.Permission.WRITE_PLAN);
        if (topic == null || minutes == null || minutes < 5 || minutes > 60) {
            throw new IllegalArgumentException("Invalid plan parameters");
        }
        purge();
        if (proposals.size() >= 20) throw new IllegalStateException("Proposal capacity reached");
        var proposal = new Proposal(UUID.randomUUID().toString(), topic, minutes,
                clock.instant().plus(Duration.ofMinutes(5)));
        proposals.put(proposal.id(), new Entry(owner, proposal));
        return proposal;
    }

    public synchronized void approve(ToolSession authenticatedSession, String proposalId) {
        String owner = authenticatedSession.use(ToolSession.Permission.WRITE_PLAN);
        require(owner, proposalId).approved = true;
    }

    @Tool(name = "save_approved_study_plan", description = "Save an already human-approved proposal once; cannot approve it")
    public synchronized Receipt save(@ToolParam(description = "ID of the exact server-approved proposal") String proposalId,
                                     ToolContext context) {
        String owner = ToolSession.from(context).use(ToolSession.Permission.WRITE_PLAN);
        Entry entry = require(owner, proposalId);
        if (!entry.approved) throw new IllegalArgumentException("Human approval required");
        if (entry.receipt != null) return entry.receipt;
        if (plans.size() >= 20) throw new IllegalStateException("Plan capacity reached");
        var receipt = new Receipt(UUID.randomUUID().toString(), entry.proposal.topic(), entry.proposal.minutes());
        plans.put(receipt.planId(), new SavedPlan(owner, receipt));
        entry.receipt = receipt;
        return receipt;
    }

    private Entry require(String owner, String id) {
        purge();
        if (id == null || id.length() > 100) throw new IllegalArgumentException("Proposal unavailable");
        Entry entry = proposals.get(id);
        if (entry == null || !entry.owner.equals(owner)) throw new IllegalArgumentException("Proposal unavailable");
        return entry;
    }

    private void purge() {
        Instant now = clock.instant();
        proposals.values().removeIf(entry -> !now.isBefore(entry.proposal.expiresAt()));
    }

    synchronized int savedPlanCount() {
        return plans.size();
    }

    public record Proposal(String id, StudyTipTools.Topic topic, int minutes, Instant expiresAt) {}
    public record Receipt(String planId, StudyTipTools.Topic topic, int minutes) {}
    private record SavedPlan(String owner, Receipt receipt) {}
    private static final class Entry {
        private final String owner;
        private final Proposal proposal;
        private boolean approved;
        private Receipt receipt;
        private Entry(String owner, Proposal proposal) {
            this.owner = owner;
            this.proposal = proposal;
        }
    }
}

ToolCallbacks.from(planTools) yalnız propose və save metodlarını alətə çevirir. İcazə yoxlaması hər əməliyyatın daxilində işləyir. Təklif ID-sini bilmək kifayət deyil: sahib, təsdiq və müddət də uyğun olmalıdır. approve metodu model JSON-undan çıxarılan məlumatı deyil, autentifikasiya edilmiş təsdiq sorğusundan yeni etibarlı sessiyanı alır.

Təklif yaratmaq gözləyən təkliflər yaddaşını dəyişir, amma tədris planını saxlamır. Hər iki reyestrin 20 qeyd limiti var; müddəti bitmiş təkliflər müraciət zamanı silinir. Saxlanmış planlar bu laboratoriya instansiyası ləğv edilənədək qalır və sahib əlaqəsini qoruyur. Nümunədə baza və ya xarici yan təsir yoxdur.

3. İdempotentlik müddətini dəqiq müəyyən edin

İlk saxlama icra qəbzi yaradır və onu təklif üzərində saxlayır. Müddət bitməzdən əvvəl həmin təklif ID-sini təkrar göndərmək yeni plan yaratmadan eyni qəbzi qaytarır. synchronized metod paralel təkrarları bir instansiya daxilində atomik edir. Əməliyyatın idempotentlik kimliyi sabit təklif ID-sidir; modelin ayrı-ayrı alət çağırışı ID-ləri bu məqsədlə istifadə olunmur.

Yeni təklif ID-si yeni əməliyyatdır. İlkin təklifin müddəti bitdikdən sonra plan əvvəl saxlanmış olsa belə, köhnə ID rədd edilir. Nəticə qeyri-müəyyəndirsə, avtomatik əvəzedici təklif yaratmayın. Laboratoriyada vəziyyəti uzlaşdırmaq üçün endpoint yoxdur; yerləşdirilmiş sistemdə daimi əməliyyat ID-ləri və təkrar cəhd siyasətinə yetəcək müddət saxlanan qəbzlərlə belə mexanizm lazımdır.

Prosesin yenidən başlaması bu vəziyyəti itirir. Bir neçə instansiya kilidi və reyestrləri paylaşmır. Real baza implementasiyası sahibə görə ayrılmış təkrarsız əməliyyat açarı və həm dəyişikliyi, həm də qəbzi əhatə edən tranzaksiya tələb edərdi. Xarici API-nin ayrıca idempotentlik açarı tələb oluna bilər. Lokal map proses qəzası və şəbəkə sərhədləri boyunca yalnız bir dəfə icra zəmanəti deyil.

4. Xəta emalını modelin iddialarından ayırın

Uzaq əməliyyat artıq tamamlandıqdan sonra timeout yarana bilər. Xəta qaytarmaq, böyük çıxışı rədd etmək və ya son model cavabında uğursuz olmaq həmin əməliyyatı geri almır. Təkrar cəhdə qərar verməzdən əvvəl əməliyyatın qeydə alınmış vəziyyətini yoxlayın. Uğuru və ya uğursuzluğu təkcə assistentin cümləsindən çıxarmayın.

Bizim açıq yönləndirici callback-i bir dəfə çağırır və əlavə generasiya olmadan nəticəni qaytarır. Xətalar ötürülür. Spring AI-nin avtomatik icra yolunda bəzi alət xətalarını model üçün mətnə çevirə bilən ayrıca xəta emalı var. Həmin davranış icazə nəzarəti deyil və bu yönləndiricidə istifadə olunmur. Alət xətalarının emalı

Planın saxlanması lokal və sinxrondur; konfiqurasiya ediləcək I/O timeout-u yoxdur. Baza və uzaq əməliyyat adapterləri vaxt sərhədi və nəticənin daimi izlənməsini tələb edərdi. Əvvəlki yaddaş modulunun geri qaytarması yalnız mesajları bərpa edir; saxlanmış planı geri ala bilmir. Əməliyyat qəbzlərini söhbət yaddaşından ayrı saxlayın.

5. Təsdiqi, sahibliyi və təkrar çağırışları test edin

src/test/java/academy/study/StudyPlanToolsTest.java faylını yaradın:

Java
package academy.study;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Arrays;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.execution.ToolExecutionException;

import static academy.study.StudyTipTools.Topic.INHERITANCE;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class StudyPlanToolsTest {
    private ToolSession session(String owner) {
        return new ToolSession(owner, Set.of(ToolSession.Permission.WRITE_PLAN));
    }
    private ToolContext context(String owner) {
        return new ToolContext(session(owner).context());
    }
    private StudyPlanTools tools() {
        return new StudyPlanTools(Clock.fixed(Instant.parse("2026-01-01T00:00:00Z"), ZoneOffset.UTC));
    }

    @Test
    void approvalMethodIsNotARegisteredTool() {
        assertEquals(Set.of("propose_study_plan", "save_approved_study_plan"),
                Arrays.stream(ToolCallbacks.from(tools())).map(tool -> tool.getToolDefinition().name())
                        .collect(java.util.stream.Collectors.toSet()));
    }

    @Test
    void proposalDoesNotSaveAPlanAndCannotApproveItself() {
        var tools = tools();
        var proposal = tools.propose(INHERITANCE, 15, context("learner-a"));
        var save = Arrays.stream(ToolCallbacks.from(tools()))
                .filter(tool -> tool.getToolDefinition().name().equals("save_approved_study_plan")).findFirst().orElseThrow();
        assertThrows(ToolExecutionException.class, () -> save.call(
                "{\"proposalId\":\"" + proposal.id() + "\",\"approved\":true}", context("learner-a")));
        assertEquals(0, tools.savedPlanCount());
    }

    @Test
    void approvedProposalIsSavedOnlyOnce() {
        var tools = tools();
        var proposal = tools.propose(INHERITANCE, 15, context("learner-a"));
        tools.approve(session("learner-a"), proposal.id());
        var first = tools.save(proposal.id(), context("learner-a"));
        var repeated = tools.save(proposal.id(), context("learner-a"));
        assertEquals(first, repeated);
        assertEquals(15, first.minutes());
        assertEquals(1, tools.savedPlanCount());
    }

    @Test
    void anotherOwnerCannotApproveOrSave() {
        var tools = tools();
        var proposal = tools.propose(INHERITANCE, 15, context("learner-a"));
        assertThrows(IllegalArgumentException.class, () -> tools.approve(session("learner-b"), proposal.id()));
        tools.approve(session("learner-a"), proposal.id());
        assertThrows(IllegalArgumentException.class, () -> tools.save(proposal.id(), context("learner-b")));
        assertEquals(0, tools.savedPlanCount());
    }

    @Test
    void expiryRejectsApprovalAndSave() {
        var start = Instant.parse("2026-01-01T00:00:00Z");
        Clock clock = mock(Clock.class);
        when(clock.instant()).thenReturn(start);
        var tools = new StudyPlanTools(clock);
        var proposal = tools.propose(INHERITANCE, 15, context("learner-a"));
        when(clock.instant()).thenReturn(start.plusSeconds(300));
        assertThrows(IllegalArgumentException.class, () -> tools.approve(session("learner-a"), proposal.id()));
        assertThrows(IllegalArgumentException.class, () -> tools.save(proposal.id(), context("learner-a")));
    }

    @Test
    void rejectsOutOfRangeDurationAndMissingPermission() {
        var tools = tools();
        assertThrows(IllegalArgumentException.class, () -> tools.propose(INHERITANCE, 61, context("learner-a")));
        var denied = new ToolContext(new ToolSession("learner-a", Set.of()).context());
        assertThrows(IllegalArgumentException.class, () -> tools.propose(INHERITANCE, 15, denied));
        assertEquals(0, tools.savedPlanCount());
    }
    @Test
    void concurrentRepeatsProduceOneSavedPlan() throws Exception {
        var tools = tools();
        var proposal = tools.propose(INHERITANCE, 15, context("learner-a"));
        tools.approve(session("learner-a"), proposal.id());
        try (var executor = java.util.concurrent.Executors.newFixedThreadPool(4)) {
            var tasks = java.util.stream.IntStream.range(0, 8)
                    .<java.util.concurrent.Callable<StudyPlanTools.Receipt>>mapToObj(
                            i -> () -> tools.save(proposal.id(), context("learner-a"))).toList();
            var results = executor.invokeAll(tasks);
            var expected = results.getFirst().get();
            for (var result : results) assertEquals(expected, result.get());
        }
        assertEquals(1, tools.savedPlanCount());
    }

}

Callback testi modelin verdiyi approved sahəsini əlavə edir və heç bir planın saxlanmadığını yoxlayır. Digər testlər sahibliyi, səhv müddəti, vaxtın bitməsini və paralel təkrar saxlamanı əhatə edir. Bunlar Java vəziyyət keçidlərinin yoxlamasıdır; modelin etibarlı şəkildə təsdiq istədiyinə sübut deyil.

Gələcək adapterdə təklifi gözləyən UI obyekti kimi qaytarın. Ayrıca autentifikasiya edilmiş istifadəçi təsdiqindən sonra məhz həmin təklif üçün approve çağırın. Sonrakı icazəli save çağırışı onu icra edə bilər. Hər təklifi səssizcə təsdiqləməyin və çat mətninin təsdiq hadisəsini təqlid etməsinə imkan verməyin.

6. Yoxlayın və təkrar cəhd yolunu sınayın

İcra edin:

sh
env -u RUN_LIVE_EVALUATION mvn clean verify

Yığılmış layihədə səksən bir oflayn metod keçməli, bir canlı evaluator isə ötürülməlidir. Testlər lokal Java obyektləri, loopback HTTP və H2 istifadə edir. Real model və ya xarici əməliyyat işləmir.

Tapşırıq: təsdiqlənmiş təklifi saxlayın, ilk qəbzi nəzərə almayın və eyni ID-ni yenidən saxlayın. Test saatını müddətin sonrasına keçirin və bir dəfə də təkrarlayın. Hər addımda hansı nəticənin məlum olduğunu izah edin.

Cavab yoxlaması: müddət bitməzdən əvvəl təkrar çağırış saxlanmış qəbzi qaytarır və plan sayı bir qalır. Müddət bitdikdən sonra köhnə ID əlçatmaz olur; bu, əvvəlki planın heç vaxt saxlanmadığı demək deyil. İstehsal mühitində çağıran tərəf kor-koranə yeni əməliyyat göndərmək əvəzinə daimi əməliyyat vəziyyətini yoxlamalıdır.