Spring AI · Söhbət yaddaşı və kontekst · Dərs 2

Söhbət tarixçəsinin saxlanması

Əvvəlki mesajları söhbətin kontekstində saxla və növbəti sorğularda modelə ötürərək ardıcıl dialoq qur.

Söhbət tarixçəsinin saxlanması

Pəncərə hansı mesajların əlçatan qalacağını idarə edir. Onların kimə məxsus olduğunu, neçə söhbətin yaradıla biləcəyini və nə vaxt silinməli olduğunu müəyyən etmir. Modeli qoşmazdan əvvəl bu siyasətləri kiçik bir tətbiq sinfində yazaq.

1. Yaddaş siyasətini saxlama mexanizmindən ayırın

InMemoryChatMemoryRepository mesajları cari Java prosesində saxlayır. Proses yenidən başladıqda onlar itir; tətbiqin ikinci instansiyasının isə ayrıca yaddaşı olur. MessageWindowChatMemory həmin repozitoridən oxuyur, pəncərə siyasətini tətbiq edir və alınan siyahını geri yazır.

Repozitorinin saveAll metodu ID üçün saxlanan siyahını əvəz edir; mövcud siyahıya əlavə etmir. Söhbət ID-si üzrə əməliyyatlarında autentifikasiya edilmiş istifadəçi və ya tenant yoxlaması yoxdur. Concurrent map istifadəsi də oxuma, model çağırışı və yazmadan ibarət bütün ardıcıllığı atomik etmir. Yaddaşdaxili repozitorinin implementasiyası

Bu yaddaşdan yalnız sintetik tədris söhbətləri üçün istifadə edəcəyik. O, nə audit jurnalıdır, nə də daimi dialoq arxivi. Daimi repozitori də sahiblik yoxlaması, saxlama müddəti, şifrələmə və giriş siyasəti, ehtiyat nüsxələrlə davranış və instansiyalar arasında koordinasiya tələb edərdi. Saxlama implementasiyasını dəyişmək bu qərarları avtomatik vermir.

2. Controller-dən əvvəl sahibliyi və müddəti müəyyən edin

Laboratoriya müqaviləsi kiçikdir: hər ConversationHistory instansiyasında ən çox 20 söhbət, hər söhbətdə altı dialoq mesajı və yaradılma anından başlayan sabit 30 dəqiqəlik ömür. Aktivlik bu müddəti uzatmır. Hər söhbət ID-sini server yaradır və sahibi ilə əlaqələndirir.

owner etibarlı server sərhədində artıq autentifikasiya edilmiş kimliyi təmsil edir. Testlərdəki sətirlər sintetik kimliklərdir. Sorğu göndərənin ixtiyari owner sahəsini və ya başlığını qəbul edib autentifikasiya kimi istifadə etməyin. Mövcud Study Coach HTTP tətbiqində autentifikasiya edilmiş kimlik axını yoxdur. Buna görə bu modul yeni HTTP endpoint-i deyil, adi Java sinifləri və testlər əlavə edir.

3. Saxlama sərhədini qurun

src/main/java/academy/study/ConversationHistory.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.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Function;
import org.springframework.ai.chat.memory.ChatMemory;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.memory.MessageWindowChatMemory;
import org.springframework.ai.chat.messages.Message;

public final class ConversationHistory {
    public static final int MAX_CONVERSATIONS = 20;
    public static final Duration LIFETIME = Duration.ofMinutes(30);
    private final Clock clock;
    private final Map<String, Session> sessions = new HashMap<>();
    private final InMemoryChatMemoryRepository repository = new InMemoryChatMemoryRepository();
    private final ChatMemory memory = MessageWindowChatMemory.builder()
            .chatMemoryRepository(repository).maxMessages(6).build();

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

    public synchronized String create(String owner) {
        requireOwner(owner);
        purgeExpired();
        if (sessions.size() >= MAX_CONVERSATIONS) {
            throw new IllegalStateException("Conversation capacity reached");
        }
        String id = UUID.randomUUID().toString();
        sessions.put(id, new Session(owner, clock.instant().plus(LIFETIME)));
        return id;
    }

    public synchronized List<Message> messages(String owner, String id) {
        requireSession(owner, id);
        return List.copyOf(memory.get(id));
    }

    public synchronized void delete(String owner, String id) {
        requireSession(owner, id);
        remove(id);
    }

    public synchronized void purgeExpired() {
        Instant now = clock.instant();
        List<String> expired = sessions.entrySet().stream()
                .filter(entry -> !now.isBefore(entry.getValue().expiresAt()))
                .map(Map.Entry::getKey).toList();
        expired.forEach(this::remove);
    }

    synchronized <T> T withConversation(String owner, String id, Function<ChatMemory, T> action) {
        requireSession(owner, id);
        List<Message> before = List.copyOf(memory.get(id));
        try {
            T result = action.apply(memory);
            requireSession(owner, id);
            return result;
        } catch (RuntimeException failure) {
            if (sessions.containsKey(id)) {
                if (before.isEmpty()) {
                    memory.clear(id);
                } else {
                    repository.saveAll(id, before);
                }
            }
            throw failure;
        }
    }

    private void requireSession(String owner, String id) {
        requireOwner(owner);
        purgeExpired();
        Session session = sessions.get(id);
        if (session == null || !session.owner().equals(owner)) {
            throw new IllegalArgumentException("Conversation unavailable");
        }
    }

    private static void requireOwner(String owner) {
        if (owner == null || owner.isBlank() || owner.length() > 200) {
            throw new IllegalArgumentException("Invalid owner");
        }
    }

    private void remove(String id) {
        memory.clear(id);
        sessions.remove(id);
    }

    private record Session(String owner, Instant expiresAt) {}
}

Oxuma, silmə və dialoq addımı əməliyyatları seçilmiş tarixçəyə müraciətdən əvvəl sahibliyi yoxlayır. Naməlum, silinmiş, müddəti bitmiş və başqasına məxsus ID-lər eyni əlçatmazlıq xətasını qaytarır. UUID identifikatoru təxmin etməyi çətinləşdirir; icazə qərarını isə sahibin müqayisəsi verir.

Package-private callback etibarlı tətbiq kodu üçündür. Növbəti dərsin servisinə yalnız eyni monitor saxlanarkən yaddaşa giriş verir. Onu, repozitorini və ya birbaşa yaddaşı controller vasitəsilə açmayın. Qaytarılan tarixçə siyahısı siyahının cari vəziyyətinin surətidir; bu laboratoriyada saxlanan mesaj obyektlərini və onların metadatasını redaktə etmək nəzərdə tutulmur.

4. Həyat dövrünün sərhədlərini dəqiq ifadə edin

purgeExpired müddəti bitmiş sessiyaları və onların mesaj qeydlərini silir. Yaratma, oxuma, silmə və dialoq addımları bu metodu əməliyyat zamanı çağırır; onu ayrıca da çağırmaq olar. Burada fon scheduler-i yoxdur. Boş dayanan proses təmizləmə çağırılanadək müddəti bitmiş obyektləri saxlaya bilər; uzun çəkən dialoq addımı isə kilidi saxlayır. Bu, sonradan təmizləmə ilə giriş müddəti siyasətidir, məlumatın məhz 30-cu dəqiqədə fiziki silinməsi zəmanəti deyil.

Bu instansiyada bütün əməliyyatlar, o cümlədən növbəti dərsin model çağırışı, ardıcıl icra olunur. Bu seçim əvvəlki vəziyyətə qayıtmağı və tarixçənin sırasını sadələşdirir, amma yavaş çağırış digər söhbətləri də gözlədir. Bu, lokal tədris seçimidir, miqyaslana bilən paralellik dizaynı deyil. Bir neçə instansiya ortaq sahiblik vəziyyəti və uyğun kilidləmə və ya optimist paralellik strategiyası tələb edir. Provayder timeout-ları ayrıca konfiqurasiya edilməlidir.

Uğurlu callback-in nəticəsi qaytarılmadan əvvəl müddət yenidən yoxlanır. İcra zamanı xəta yaranarsa və sessiya hələ mövcuddursa, əvvəlki mesajlar bərpa olunur. Müddət yoxlaması sessiyanı silibsə, bərpa edilmir. Bu geri qaytarma lokal mesaj siyahısına aiddir; provayder sorğusunu, onun xərcini və artıq göndərilmiş məlumatı geri almır. Proses qəzasından bərpa və ya ləğv zəmanəti də vermir.

Silmə bu yaddaşdakı tətbiq istinadlarını aradan qaldırır. Provayder qeydlərini, ixrac edilmiş logları, heap dump-ları və ehtiyat nüsxələri silə bilməz. Təcrübələrə məxfi və şəxsi məlumat daxil etməyin, prompt mətnlərini loglamayın. Real söhbətləri toplamadan əvvəl razılıq, yalnız zəruri məlumatın toplanması və silmə davranışını müəyyən edin.

5. Əvəzetməni, izolyasiyanı və həyat dövrünü test edin

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

Java
package academy.study;

import java.time.Clock;
import java.time.Instant;
import java.time.ZoneOffset;
import java.util.List;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.memory.InMemoryChatMemoryRepository;
import org.springframework.ai.chat.messages.UserMessage;

import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;

class ConversationHistoryTest {
    private final Instant start = Instant.parse("2026-01-01T00:00:00Z");

    @Test
    void repositorySaveAllReplacesInsteadOfAppending() {
        var repository = new InMemoryChatMemoryRepository();
        repository.saveAll("example", List.of(new UserMessage("First")));
        repository.saveAll("example", List.of(new UserMessage("Replacement")));
        assertEquals(1, repository.findByConversationId("example").size());
        assertEquals("Replacement", repository.findByConversationId("example").getFirst().getText());
        repository.deleteByConversationId("example");
        assertFalse(repository.findConversationIds().contains("example"));
    }

    @Test
    void checksOwnerBeforeReadAndDelete() {
        var history = new ConversationHistory(Clock.fixed(start, ZoneOffset.UTC));
        String id = history.create("learner-a");
        assertThrows(IllegalArgumentException.class, () -> history.messages("learner-b", id));
        assertThrows(IllegalArgumentException.class, () -> history.delete("learner-b", id));
        assertTrue(history.messages("learner-a", id).isEmpty());
        history.delete("learner-a", id);
        assertThrows(IllegalArgumentException.class, () -> history.messages("learner-a", id));
    }

    @Test
    void expiresAtFixedDeadlineAndReclaimsCapacity() {
        Clock clock = mock(Clock.class);
        when(clock.instant()).thenReturn(start);
        var history = new ConversationHistory(clock);
        String first = history.create("learner-a");
        for (int i = 1; i < ConversationHistory.MAX_CONVERSATIONS; i++) {
            history.create("learner-a");
        }
        assertThrows(IllegalStateException.class, () -> history.create("learner-a"));
        when(clock.instant()).thenReturn(start.plus(ConversationHistory.LIFETIME));
        history.purgeExpired();
        assertThrows(IllegalArgumentException.class, () -> history.messages("learner-a", first));
        assertNotNull(history.create("learner-a"));
    }

    @Test
    void restoresSnapshotWhenAnOperationFails() {
        var history = new ConversationHistory(Clock.fixed(start, ZoneOffset.UTC));
        String id = history.create("learner-a");
        assertThrows(IllegalStateException.class, () -> history.withConversation("learner-a", id, memory -> {
            memory.add(id, new UserMessage("Unfinished turn"));
            throw new IllegalStateException("Local failure");
        }));
        assertTrue(history.messages("learner-a", id).isEmpty());
    }

    @Test
    void removesResultIfDeadlinePassesDuringOperation() {
        Clock clock = mock(Clock.class);
        when(clock.instant()).thenReturn(start);
        var history = new ConversationHistory(clock);
        String id = history.create("learner-a");
        assertThrows(IllegalArgumentException.class, () -> history.withConversation("learner-a", id, memory -> {
            memory.add(id, new UserMessage("In flight"));
            when(clock.instant()).thenReturn(start.plus(ConversationHistory.LIFETIME));
            return "Too late";
        }));
        assertThrows(IllegalArgumentException.class, () -> history.messages("learner-a", id));
    }

    @Test
    void rejectsMissingOwner() {
        var history = new ConversationHistory(Clock.fixed(start, ZoneOffset.UTC));
        assertThrows(IllegalArgumentException.class, () -> history.create(" "));
    }
}

İdarə olunan saat müddətin bitmə sərhədini təkrarlana bilən edir. Nə gözləmə, nə də provayder hesabı lazımdır. Tutum testi təmizləmədən sonra müddəti bitmiş söhbətlərin reyestrdə yer tutmadığını da yoxlayır.

6. Yoxlayın və bir siyasəti genişləndirin

İcra edin:

sh
env -u RUN_LIVE_EVALUATION mvn clean verify

Əvvəlki dərslərdən sonra əlli dörd oflayn metod keçməli, bir canlı qiymətləndirmə metodu isə ötürülməlidir. Testlər daimi silməni və ya paylanmış paralelliyi deyil, lokal həyat dövrünü yoxlayır.

Tapşırıq: eyni sahib üçün iki söhbət yaradın, birini silin və digərinin oxuna bildiyini yoxlayın. Sonra cədvəl üzrə işləyən təmizləmə işinin bizim əməliyyat zamanı etdiyimiz çağırışlardan fərqini izah edin.

Cavab yoxlaması: sahib eyni olsa da, iki ID-nin həyat dövrü ayrıdır. Cədvəl üzrə iş istifadəçi trafiki olmadan təmizləməyə cəhd edərdi, amma onun vaxtı və kilid səbəbli gecikmələr yenə ölçülməlidir. Təkcə scheduler-i dəqiq vaxtda silmə zəmanəti kimi təqdim etməyin.