Spring AI · Sorğular, şablonlar və cavab formatları · Dərs 2

Strukturlaşdırılmış və axın şəklində cavablar

Modelə sorğu göndərməyin müxtəlif üsullarını öyrən, cavab formatını müəyyən et və əlavə kontekstlə RAG üçün zəmin hazırla.

Strukturlaşdırılmış və axın şəklində cavablar

Tam Java obyekti ilə mətn parçalarının ardıcıllığı fərqli problemləri həll edir. Strukturlaşdırılmış cavab izah və nümunə üçün ayrı sahələri olan UI-a uyğundur. Axın isə tam cavab hazır olmamış mətni almağa imkan verir. Bu dərs Study Coach-a hər iki servis metodunu əlavə edir və onları provaydersiz test edir.

1. Əvvəl cavab müqaviləsini seç

Strukturlaşdırılmış çıxışda çağıran tərəf StudyAnswer obyektini yalnız bütöv cavab parse edilib yoxlanandan sonra alır. Axında isə ardıcıl String parçaları və tamamlanma və ya xəta siqnalı alır. Parça mütləq söz, token, cümlə və ya düzgün JSON sənədi deyil.

Giriş modulundakı spring-boot-starter-web asılılığını saxla. Aşağıdakı asılılığı onun yanına, POM-un əsas <dependencies> elementinə əlavə et. Versiyanı Boot parent müəyyən edir. Spring AI 1.1-in axın yolu reaktiv client stack tələb edir; onu əlavə etmək mövcud MVC tətbiqinin reaktiv server kimi yenidən yazılması demək deyil. ChatClient axın tələbləri

xml
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

2. Formatla yanaşı Java nəticəsini də yoxla

src/main/java/academy/study/StudyAnswer.java faylını saxla:

Java
package academy.study;

public record StudyAnswer(String explanation, String example) {
    public StudyAnswer {
        if (explanation == null || explanation.isBlank() || explanation.length() > 2000
                || example == null || example.isBlank() || example.length() > 1500) {
            throw new IllegalArgumentException("Invalid study answer fields");
        }
    }
}

Hər iki sahə mövcud və boş olmayan mətn olmalıdır. Limitlər tokenləri deyil, UTF-16 kod vahidlərini sayır. Jackson record-u yaradanda compact constructor bu tətbiq qaydalarını yoxlayır. İzahın doğru olduğunu və ya nümunənin kompilyasiya edildiyini müəyyən edə bilmir.

Format təlimatı və ya yaradılmış JSON Schema təkbaşına uyğunluğa zəmanət vermir. Converter müəyyən quruluş istəyir və nəticəni deserializasiya edir; səhv JSON, çatışmayan məlumat və ya etibarsız sahələr yenə xəta yarada bilər. Provayderin native structured output imkanı modeldən asılı olan ayrıca xüsusiyyətdir və burada aktiv edilməyib. Sxemaya uyğun cavabda da yanlış faktlar ola bilər. Strukturlaşdırılmış çıxış çeviriciləri

3. Açıq çevirmə və məhdud mətn ötürülməsi qur

src/main/java/academy/study/PromptResponseService.java faylını saxla:

Java
package academy.study;

import java.util.ArrayList;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.converter.BeanOutputConverter;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;

@Service
public class PromptResponseService {
    private final ChatClient chatClient;

    public PromptResponseService(ChatModel model) {
        this.chatClient = ChatClient.builder(model).build();
    }

    public StudyAnswer structured(String question, StudyPromptFactory.Language language) {
        var converter = new BeanOutputConverter<>(StudyAnswer.class);
        var base = StudyPromptFactory.create(question, language);
        var messages = new ArrayList<Message>(base.getInstructions());
        messages.add(1, new SystemMessage(converter.getFormat()));
        String raw = chatClient.prompt(new Prompt(messages)).call().content();
        if (AnswerChecks.check(raw) != AnswerChecks.Result.PASS) {
            throw new IllegalStateException("Invalid structured response length");
        }
        StudyAnswer answer = converter.convert(raw);
        if (answer == null) {
            throw new IllegalStateException("Missing structured answer");
        }
        return answer;
    }

    public Flux<String> stream(String question, StudyPromptFactory.Language language) {
        Prompt prompt = StudyPromptFactory.create(question, language);
        return Flux.defer(() -> {
            var used = new AtomicInteger();
            return chatClient.prompt(prompt).stream().content().<String>handle((chunk, sink) -> {
                int remaining = AnswerChecks.MAX_LENGTH - used.get();
                if (chunk.length() > remaining) {
                    sink.error(new IllegalStateException("Stream exceeded display limit"));
                } else {
                    used.addAndGet(chunk.length());
                    sink.next(chunk);
                }
            });
        });
    }
}

Strukturlaşdırılmış metod BeanOutputConverter.getFormat() nəticəsini tətbiqə aid təlimat kimi əlavə edir, sonra çevirmədən əvvəl xam cavabın uzunluğunu yoxlayır. convert record yaradır, onun konstruktoru isə sahələri yoxlayır. Spring AI daha qısa call().entity(StudyAnswer.class) yolu da təqdim edir, amma çevirmə yenə tətbiq validasiyası və xəta emalı tələb edir. Açıq yazılmış versiya mərhələləri görünən edir.

Axın metodu hər abunə üçün yeni simvol sayğacı yaradır. Parçanı yalnız qalan 4 000 vahidlik göstərilmə büdcəsinə sığırsa ötürür. Büdcəni aşan parça səssizcə kəsilmiş uğurlu cavab əvəzinə xəta yaradır. Əvvəlki parçalar artıq çağıran tərəfə çatmış ola bilər. Flux.defer həm də hər abunənin ayrıca model axını başlatması deməkdir; iki dəfə abunə olmaq pulsuz təkrar oxuma deyil. Reactor Flux API

Bu yoxlamalar servisin ötürdüyü mətni məhdudlaşdırır. Provayder tokenlərini, parçanın qəbuldan əvvəlki ölçüsünü, müddəti və ya xərci məhdudlaşdırmır. Lokal abunənin ləğvi uzaqdakı işin və ya xərc hesablanmasının dayandığına zəmanət deyil. Avtomatik təkrar çağırış əlavə edilmir. Axın metodu yalnız ümumi uzunluğu yoxlayır; mətn olmadan tamamlana bilər, ona görə boş axının necə emal ediləcəyini çağıran tərəf seçməlidir.

4. Çevirməni, ardıcıllığı və limitləri test et

src/test/java/academy/study/PromptResponseServiceTest.java faylını saxla:

Java
package academy.study;

import java.time.Duration;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.Generation;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;

import static academy.study.StudyPromptFactory.Language.ENGLISH;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;

class PromptResponseServiceTest {
    private ChatModel model;
    private PromptResponseService service;
    private static final Duration WAIT = Duration.ofSeconds(3);

    @BeforeEach
    void setUp() {
        model = mock(ChatModel.class);
        service = new PromptResponseService(model);
    }

    private ChatResponse response(String text) {
        return new ChatResponse(List.of(new Generation(new AssistantMessage(text))));
    }

    @Test
    void convertsStructuredAnswerAndSendsFormatInstructions() {
        when(model.call(any(Prompt.class))).thenReturn(response("""
                {"explanation":"A class defines a type.","example":"class Sample {}"}
                """));
        var answer = service.structured("Explain classes", ENGLISH);
        assertEquals("class Sample {}", answer.example());
        var prompt = ArgumentCaptor.forClass(Prompt.class);
        verify(model).call(prompt.capture());
        String text = prompt.getValue().getContents();
        assertTrue(text.contains("explanation"));
        assertTrue(text.contains("example"));
    }

    @Test
    void rejectsMalformedAndIncompleteStructuredAnswers() {
        for (String raw : List.of("not JSON", "{}",
                "{\"explanation\":\"Valid text\",\"example\":\"\"}")) {
            when(model.call(any(Prompt.class))).thenReturn(response(raw));
            assertThrows(RuntimeException.class,
                    () -> service.structured("Explain classes", ENGLISH));
        }
    }

    @Test
    void rejectsOversizedStructuredTextBeforeConversion() {
        when(model.call(any(Prompt.class))).thenReturn(response("x".repeat(4001)));
        assertThrows(IllegalStateException.class,
                () -> service.structured("Explain classes", ENGLISH));
    }

    @Test
    void streamsFragmentsInOrder() {
        when(model.stream(any(Prompt.class))).thenReturn(
                Flux.just(response("class "), response("Sample {}")));
        var chunks = service.stream("Explain classes", ENGLISH).collectList().block(WAIT);
        assertEquals(List.of("class ", "Sample {}"), chunks);
        verify(model).stream(any(Prompt.class));
    }

    @Test
    void resetsDisplayBudgetForEachSubscription() {
        when(model.stream(any(Prompt.class))).thenReturn(
                Flux.just(response("x".repeat(2000)), response("y".repeat(2000))));
        var stream = service.stream("Explain classes", ENGLISH);
        for (int i = 0; i < 2; i++) {
            var chunks = stream.collectList().block(WAIT);
            assertNotNull(chunks);
            assertEquals(4000, String.join("", chunks).length());
        }
        verify(model, times(2)).stream(any(Prompt.class));
    }

    @Test
    void reportsOverflowInsteadOfCompletingSuccessfully() {
        when(model.stream(any(Prompt.class))).thenReturn(
                Flux.just(response("x".repeat(4000)), response("y")));
        assertThrows(IllegalStateException.class,
                () -> service.stream("Explain classes", ENGLISH).collectList().block(WAIT));
    }
}

Altı test axın metodu da daxil olmaqla mock edilmiş ChatModel istifadə edir. Çevirməni, etibarsız strukturlaşdırılmış məlumatı, xam uzunluq həddinin rəddini, parça ardıcıllığını, müstəqil abunə büdcələrini və limit aşımı xətasını yoxlayır. Üç saniyəlik block limiti test mühitinə aiddir; tətbiqin HTTP vaxt həddi deyil.

5. Qismən xətanı çağıran tərəfin necə emal edəcəyini seç

Bunlar servis metodlarıdır, yeni HTTP route-lar deyil. Mövcud endpoint-lər dəyişmir. Gələcək controller çevirmə xətasını açıq cavaba çevirməlidir. Yarımçıq obyekti uğurlu cavab kimi qaytarma və parse təsadüfən alınana qədər xərc yaradan çağırışı avtomatik təkrarlama.

HTTP axını üçün server-sent events kimi protokol seç və müştərinin tamamlanmanı, xətanı və ləğvi necə tanıyacağını müəyyən et. Cavab artıq göndərilməyə başlayandan sonra sonrakı xətanı yeni HTTP xəta statusuna çevirmək həmişə mümkün olmur. Axın uğursuz olanda UI əvvəl göstərilmiş parçaları natamam kimi işarələməlidir. Hər mətn parçasını ayrıca tam StudyAnswer kimi parse etmə.

Əvvəlki qiymətləndirmə yolu cavabı buraxmazdan əvvəl bütöv namizədi gözləyir. Yoxlanılmamış parçaları birbaşa istifadəçiyə ötürmək həmin siyasəti dəyişir. Yoxlamadan əvvəl buferləmə erkən göstərilmədən imtina edir və ayrıca məhdud yaddaş tələb edir; buradakı axın metodu model əsaslı qiymətləndirmə aparmır.

6. Müqavilələri yoxla və müqayisə et

İcra et:

sh
env -u RUN_LIVE_EVALUATION mvn clean verify

İyirmi səkkiz offline metod keçməli, bir real qiymətləndirmə metodu buraxılmalıdır. Real model çağırışı tələb olunmur.

Tapşırıq: {"explanation": parçası olan axının niyə hələ strukturlaşdırılmış cavab olmadığını izah et. Sonra ilk parçadan sonra provayder xətasını canlandır və çağıran tərəfin qismən mətni necə işarələyəcəyini seç.

Cavabın yoxlanması: bütün tələb olunan məzmun gələnə, çevirmə və sahə validasiyası keçənə qədər obyekt natamamdır. Qismən axın uğurla yoxlanılmış cavab deyil, natamam çıxışdır. Bu testlər lokal davranışı təsdiqləyir; real provayder axını, modelin formata uyğunluğu və ötürmə davranışı ayrıca inteqrasiya sübutu tələb edir.