Spring AI · Səs və şəkillərlə iş · Dərs 1
Səsin mətnə çevrilməsi
Uyğun modellərlə səsi mətnə, mətni nitqə çevir. Şəkillər haqqında suallar ver və mətn sorğuları əsasında şəkillər yarat.
Səsin mətnə çevrilməsi
Tələbə Study Coach üçün qısa səsli sual yazır. Tətbiq onu söhbət mesajına çevirməzdən əvvəl faylı qəbul etməli, səsi mətnə çevirməli və tələbəyə mətni yoxlamaq imkanı verməlidir. Transkript model nəticəsidir; tələbənin qaytarılan hər sözü dediyini və ya əməliyyata icazə verdiyini sübut etmir.
1. Dörd media imkanını ayır
| Əməliyyat | Giriş | Çıxış | Bu laboratoriyada Spring AI API-si |
|---|---|---|---|
| Transkripsiya | Səs yazısı | Mətn | OpenAiAudioTranscriptionModel |
| Nitq sintezi | Yoxlanmış mətn | Audio baytları | OpenAiAudioSpeechModel |
| Şəklin anlaşılması | Mətn və şəkil | Mətn | Media ilə OpenAiChatModel |
| Şəklin yaradılması | Yoxlanmış təsvir | Şəkil məlumatı | OpenAiImageModel |
Bir sətri dəstəkləyən provayder və ya model digərlərini avtomatik dəstəkləmir. Transkripsiya nitqi başqa dilə tərcümə etməkdən də fərqlənir. Transkripsiya tapşırığını saxlayacaq, seçilən modelin dəstəkləmədiyi danışan etiketləri, zaman nişanları və ya tərcümə imkanları vəd etməyəcəyik. Spring AI transkripsiyası, provayderin transkripsiya təlimatı
Provayderin cari təlimatı bəzi Spring AI nümunələrindəkindən daha yeni transkripsiya modeli tövsiyə edir. Kitabxana enum-u və ya köhnə nümunə hazırda əlçatan modellərin siyahısı deyil. Provayderin modelini, endpoint-i, qəbul olunan seçimləri və hesabın çıxış imkanını birlikdə yoxla. İşlədilən testlər yalnız lokal stub-un tanıdığı uydurma lab-* model adlarından istifadə edir; bu adların OpenAI-da işlədiyini iddia etmir.
2. Ayrıca lokal media laboratoriyası qur
Study Coach nümunəsini yeni study-coach-media-lab Maven qovluğunda davam etdir. Əvvəlki tətbiqi və MCP laboratoriyasını saxla. Java 21 seç və pom.xml faylını yarat:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.9</version>
<relativePath/>
</parent>
<groupId>academy</groupId>
<artifactId>study-coach-media-lab</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<spring-ai.version>1.1.8</spring-ai.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai</artifactId>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-simple</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>Ayrıca spring-ai-openai-sdk inteqrasiyasını deyil, spring-ai-openai istifadə et. Onların sinif və seçimləri fərqlidir. Layihədə modellər açıq konstruktorlarla yaradılır; Boot tətbiq konteksti, model starter-i və mühitdən alınan API açarı yoxdur. Boot parent build versiyalarını idarə edir.
src/main/java/academy/study/media/LocalMediaTransport.java faylını yarat:
package academy.study.media;
import java.io.IOException;
import java.net.URI;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.time.Duration;
import org.springframework.http.HttpMethod;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.web.client.ResponseErrorHandler;
import org.springframework.web.client.RestClient;
import org.springframework.retry.support.RetryTemplate;
import org.springframework.ai.openai.api.OpenAiAudioApi;
public final class LocalMediaTransport {
private LocalMediaTransport() {}
public static String checkedBase(URI base) {
if (base == null || !"http".equals(base.getScheme()) || !"127.0.0.1".equals(base.getHost())
|| base.getPort() < 1 || base.getPort() > 65535 || base.getUserInfo() != null || base.getQuery() != null
|| base.getFragment() != null || !base.getPath().isEmpty()) {
throw new IllegalArgumentException("An explicit local stub address is required");
}
return base.toString();
}
public static RestClient.Builder rest() {
var client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2))
.followRedirects(HttpClient.Redirect.NEVER).proxy(ProxySelector.of(null)).build();
var factory = new JdkClientHttpRequestFactory(client);
factory.setReadTimeout(Duration.ofSeconds(2));
return RestClient.builder().requestFactory(factory);
}
public static RetryTemplate once() {
return RetryTemplate.builder().maxAttempts(1).build();
}
public static ResponseErrorHandler errors() {
return new ResponseErrorHandler() {
@Override
public boolean hasError(ClientHttpResponse response) throws IOException {
return !response.getStatusCode().is2xxSuccessful();
}
@Override
public void handleError(URI url, HttpMethod method, ClientHttpResponse response) throws IOException {
throw new IllegalStateException("Media request failed: HTTP " + response.getStatusCode().value());
}
};
}
public static OpenAiAudioApi audio(URI base) {
return OpenAiAudioApi.builder().baseUrl(checkedBase(base)).apiKey("local-stub-key")
.restClientBuilder(rest()).responseErrorHandler(errors()).build();
}
}Factory yalnız açıq göstərilmiş http://127.0.0.1:port stub ünvanını qəbul edir, yönləndirmələri və proxy-ni söndürür, sınaq açarı istifadə edir. Real provayderə keçid seçimi yoxdur. maxAttempts(1) Spring retry qatında avtomatik təkrarı dayandırır. İki saniyəlik bağlantı və oxuma limitləri transport timeout-larıdır, bütün real iş axınının ümumi vaxt həddi deyil. Xəta cavabı provayderin məzmununu köçürmədən yalnız statusu bildirən exception-a çevrilir.
Factory aşağıdakı bloklayan call() metodları üçündür. Reactive streaming konfiqurasiya etmir. Real adapter üçün ayrıca yoxlanmış HTTPS endpoint, təhlükəsiz açar ötürülməsi və cari model konfiqurasiyası lazımdır. Şəxsi səs yazısını sınamaq üçün bu test sərhədini zəiflətmə.
3. Resource yaratmazdan əvvəl baytları yoxla
src/main/java/academy/study/media/MediaFiles.java faylını yarat:
package academy.study.media;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
import javax.sound.sampled.UnsupportedAudioFileException;
public final class MediaFiles {
public static final int MAX_WAV_BYTES = 512_000;
private MediaFiles() {}
public static byte[] read(InputStream input, int limit) throws IOException {
if (input == null) throw new IllegalArgumentException("Media input required");
try (input) {
byte[] bytes = input.readNBytes(limit + 1);
if (bytes.length == 0 || bytes.length > limit) throw new IllegalArgumentException("Invalid media size");
return bytes;
}
}
public static byte[] wav(InputStream input) throws IOException {
byte[] bytes = read(input, MAX_WAV_BYTES);
if (bytes.length < 12 || bytes[0] != 'R' || bytes[1] != 'I' || bytes[2] != 'F' || bytes[3] != 'F'
|| bytes[8] != 'W' || bytes[9] != 'A' || bytes[10] != 'V' || bytes[11] != 'E') {
throw new IllegalArgumentException("WAV audio required");
}
try (var audio = AudioSystem.getAudioInputStream(new ByteArrayInputStream(bytes))) {
AudioFormat format = audio.getFormat();
float rate = format.getSampleRate();
long frames = audio.getFrameLength();
if (!AudioFormat.Encoding.PCM_SIGNED.equals(format.getEncoding())
|| format.getChannels() != 1 || format.getSampleSizeInBits() != 16
|| format.getFrameSize() != 2 || format.isBigEndian()
|| (rate != 16_000 && rate != 24_000) || format.getFrameRate() != rate
|| frames <= 0 || frames > rate * 10) {
throw new IllegalArgumentException("Use mono PCM16 WAV at 16 or 24 kHz, up to ten seconds");
}
byte[] pcm = audio.readNBytes(480_001);
if (pcm.length != frames * 2) throw new IllegalArgumentException("Truncated or oversized audio");
var output = new ByteArrayOutputStream();
try (var canonical = new AudioInputStream(new ByteArrayInputStream(pcm), format, frames)) {
AudioSystem.write(canonical, AudioFileFormat.Type.WAVE, output);
}
byte[] result = output.toByteArray();
if (result.length > MAX_WAV_BYTES) throw new IllegalArgumentException("Audio output too large");
return result;
} catch (UnsupportedAudioFileException ex) {
throw new IllegalArgumentException("Unsupported audio encoding");
}
}
public static String text(String text, int limit) {
if (text == null || text.isBlank() || text.length() > limit) {
throw new IllegalArgumentException("Invalid text length");
}
return text;
}
}Oxuyucu verilən axını bağlayır və maksimum bayt həddindən bir bayt artıq oxuyur. WAV mono, little-endian PCM16 formatında, 16 və ya 24 kHz tezlikdə açılmalı, məlum müddəti on saniyəni keçməməlidir. RIFF/WAVE işarəsi ilkin yoxlamadır; strukturu Java audio decoder-i və frame yoxlamaları təsdiqləyir. İstənilən məlumatın adını .wav etmək kifayət deyil.
WAV-ı açılmış PCM məlumatından yenidən qurmaq konteynerin əlavə metadata-sını çıxarır. 512,000 bayt və on saniyə hədləri Study Coach laboratoriyasının seçimidir, provayderin həddi deyil. Real fayl yükləmə endpoint-i bu metoddan əvvəl HTTP məzmununun ölçüsünü və yükləmə vaxtını da məhdudlaşdırmalıdır. Real media decoder-i üçün uyğun izolasiya və resurs hədləri lazımdır.
4. Bir transkripsiya sorğusu göndər
src/main/java/academy/study/media/StudyTranscription.java faylını yarat:
package academy.study.media;
import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import org.springframework.ai.audio.transcription.AudioTranscriptionPrompt;
import org.springframework.ai.openai.OpenAiAudioTranscriptionModel;
import org.springframework.ai.openai.OpenAiAudioTranscriptionOptions;
import org.springframework.ai.openai.api.OpenAiAudioApi;
import org.springframework.core.io.ByteArrayResource;
public final class StudyTranscription {
private final OpenAiAudioTranscriptionModel model;
public StudyTranscription(URI localStub) {
var options = OpenAiAudioTranscriptionOptions.builder().model("lab-transcribe")
.responseFormat(OpenAiAudioApi.TranscriptResponseFormat.JSON).build();
model = new OpenAiAudioTranscriptionModel(LocalMediaTransport.audio(localStub), options,
LocalMediaTransport.once());
}
public String transcribe(InputStream recording) throws IOException {
byte[] wav = MediaFiles.wav(recording);
var resource = new ByteArrayResource(wav) {
@Override public String getFilename() { return "question.wav"; }
};
var response = model.call(new AudioTranscriptionPrompt(resource));
if (response == null || response.getResult() == null) {
throw new IllegalStateException("Transcript unavailable");
}
return MediaFiles.text(response.getResult().getOutput(), 2_000);
}
}AudioTranscriptionPrompt Spring Resource daşıyır. Ölçüsü məhdudlaşdırılmış bayt resursuna neytral question.wav adı vermək adapterə tələbənin ilkin fayl adını və ya server yolunu ötürmədən multipart fayl hissəsi qurmağa imkan verir. Adapter text sahəsini götürsün deyə JSON istəyirik. Dilin müəyyənləşdirilməsini konfiqurasiya olunan modelə buraxırıq və tərcümə istəmirik.
Metod çatışmayan, boş və həddən uzun mətni rədd edir. 2,000 simvolluq yoxlama UTF-16 kod vahidlərini sayır və cavab deserializasiya olunduqdan sonra işləyir. HTTP cavab buferini məhdudlaşdırmır. Düzgün formalı transkript də etibarlı göstəriş deyil: söhbətə daxil etməzdən əvvəl düzəliş üçün göstər və əvvəlki alət təsdiqi sərhədini saxla. Transkripsiyanın sabit versiyadakı implementasiyası
5. Real HTTP adapterini lokal yoxla
src/test/java/academy/study/media/MediaStub.java faylını yarat. Ortaq test köməkçisi nitq və şəkil testlərinə də xidmət edəcək. O, sınaq üçün səssiz audio və kiçik boş PNG-lər yaradır, sorğuları yalnız test yaddaşında saxlayır; media və authorization başlıqlarını loqa yazmır.
package academy.study.media;
import com.sun.net.httpserver.HttpServer;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.atomic.AtomicInteger;
import javax.imageio.ImageIO;
import javax.sound.sampled.AudioFileFormat;
import javax.sound.sampled.AudioFormat;
import javax.sound.sampled.AudioInputStream;
import javax.sound.sampled.AudioSystem;
final class MediaStub implements AutoCloseable {
final AtomicInteger calls = new AtomicInteger();
volatile Request last;
private final HttpServer server;
private final ExecutorService executor = Executors.newFixedThreadPool(2);
private final Map<String, Reply> replies = new ConcurrentHashMap<>();
record Request(String path, String contentType, String authorization, byte[] body) {
String text() { return new String(body, StandardCharsets.ISO_8859_1); }
}
record Reply(int status, String contentType, byte[] body, long delayMillis) {}
MediaStub() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.setExecutor(executor);
server.createContext("/", exchange -> {
try (exchange) {
byte[] body = exchange.getRequestBody().readNBytes(3_000_001);
if (body.length > 3_000_000) {
exchange.sendResponseHeaders(413, -1);
return;
}
calls.incrementAndGet();
last = new Request(exchange.getRequestURI().getPath(),
exchange.getRequestHeaders().getFirst("Content-Type"),
exchange.getRequestHeaders().getFirst("Authorization"), body);
Reply reply = replies.getOrDefault(last.path(), new Reply(404, "text/plain", new byte[0], 0));
if (reply.delayMillis() > 0) Thread.sleep(reply.delayMillis());
exchange.getResponseHeaders().set("Content-Type", reply.contentType());
if (reply.status() == 302) exchange.getResponseHeaders().set("Location", base() + "/redirected");
exchange.sendResponseHeaders(reply.status(), reply.body().length);
exchange.getResponseBody().write(reply.body());
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
});
server.start();
}
URI base() { return URI.create("http://127.0.0.1:" + server.getAddress().getPort()); }
void reply(String path, int status, String type, byte[] bytes) {
replies.put(path, new Reply(status, type, bytes, 0));
}
void json(String path, String json) { reply(path, 200, "application/json", json.getBytes(StandardCharsets.UTF_8)); }
void delayed(String path) { replies.put(path, new Reply(200, "application/json", "{}".getBytes(StandardCharsets.UTF_8), 3_000)); }
com.fasterxml.jackson.databind.JsonNode lastJson() throws IOException { return new ObjectMapper().readTree(last.body()); }
static byte[] wav(int rate, int frames) throws IOException {
var format = new AudioFormat(rate, 16, 1, true, false);
try (var audio = new AudioInputStream(new ByteArrayInputStream(new byte[frames * 2]), format, frames)) {
var output = new ByteArrayOutputStream();
AudioSystem.write(audio, AudioFileFormat.Type.WAVE, output);
return output.toByteArray();
}
}
static byte[] png(int width, int height) throws IOException {
var image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
var output = new ByteArrayOutputStream();
ImageIO.write(image, "png", output);
return output.toByteArray();
}
@Override public void close() {
server.stop(0);
executor.shutdownNow();
}
}src/test/java/academy/study/media/StudyTranscriptionTest.java faylını yarat:
package academy.study.media;
import java.io.ByteArrayInputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class StudyTranscriptionTest {
private static final String PATH = "/v1/audio/transcriptions";
@Test
void sendsBoundedMultipartWithNeutralFilename() throws Exception {
try (var stub = new MediaStub()) {
stub.json(PATH, "{\"text\":\"Explain Java inheritance.\"}");
String text = new StudyTranscription(stub.base()).transcribe(
new ByteArrayInputStream(MediaStub.wav(16_000, 1_600)));
assertEquals("Explain Java inheritance.", text);
assertEquals(1, stub.calls.get());
assertTrue(stub.last.contentType().startsWith("multipart/form-data"));
assertTrue(stub.last.text().contains("filename=\"question.wav\""));
assertTrue(stub.last.text().contains("lab-transcribe"));
assertTrue(stub.last.text().contains("name=\"response_format\""));
assertEquals("Bearer local-stub-key", stub.last.authorization());
}
}
@Test
void rejectsEmptyAndOversizedInputBeforeHttp() throws Exception {
try (var stub = new MediaStub()) {
var model = new StudyTranscription(stub.base());
for (int size : new int[]{0, MediaFiles.MAX_WAV_BYTES + 1}) {
assertThrows(IllegalArgumentException.class, () -> model.transcribe(new ByteArrayInputStream(new byte[size])));
}
assertEquals(0, stub.calls.get());
}
}
@Test
void extensionOrMimeClaimsCannotMakeTextIntoWav() {
assertThrows(IllegalArgumentException.class, () -> MediaFiles.wav(
new ByteArrayInputStream("not audio.wav".getBytes(StandardCharsets.UTF_8))));
}
@Test
void rejectsWrongRateLongDurationAndTruncatedSamples() throws Exception {
byte[] wrongRate = MediaStub.wav(8_000, 800);
byte[] tooLong = MediaStub.wav(16_000, 160_001);
byte[] wav = MediaStub.wav(16_000, 1_600);
byte[] truncated = java.util.Arrays.copyOf(wav, wav.length - 2);
for (byte[] bytes : new byte[][]{wrongRate, tooLong, truncated}) {
assertThrows(IllegalArgumentException.class, () -> MediaFiles.wav(new ByteArrayInputStream(bytes)));
}
}
@Test
void acceptsBothDeclaredSampleRatesAndClosesInput() throws Exception {
for (int rate : new int[]{16_000, 24_000}) {
var closed = new java.util.concurrent.atomic.AtomicBoolean();
var input = new ByteArrayInputStream(MediaStub.wav(rate, rate / 10)) {
@Override public void close() { closed.set(true); }
};
assertTrue(MediaFiles.wav(input).length > 44);
assertTrue(closed.get());
}
}
@Test
void refusesExternalOrAmbiguousEndpointConfiguration() {
for (String base : new String[]{"https://api.openai.com", "http://localhost:8080", "http://127.0.0.1:80/x",
"http://[email protected]:80", "http://127.0.0.1:80?x=1"}) {
assertThrows(IllegalArgumentException.class, () -> new StudyTranscription(URI.create(base)));
}
}
@Test
void errorDoesNotRetryOrExposeProviderBody() throws Exception {
try (var stub = new MediaStub()) {
stub.reply(PATH, 429, "application/json", "private-provider-body".getBytes(StandardCharsets.UTF_8));
var failure = assertThrows(IllegalStateException.class, () -> new StudyTranscription(stub.base())
.transcribe(new ByteArrayInputStream(MediaStub.wav(16_000, 1_600))));
assertEquals("Media request failed: HTTP 429", failure.getMessage());
assertEquals(1, stub.calls.get());
}
}
@Test
void rejectsMissingOrOversizedTranscript() throws Exception {
for (String text : new String[]{"", "x".repeat(2_001)}) {
try (var stub = new MediaStub()) {
stub.json(PATH, new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsString(java.util.Map.of("text", text)));
assertThrows(IllegalArgumentException.class, () -> new StudyTranscription(stub.base())
.transcribe(new ByteArrayInputStream(MediaStub.wav(16_000, 1_600))));
}
}
}
@Test
void doesNotFollowRedirects() throws Exception {
try (var stub = new MediaStub()) {
stub.reply(PATH, 302, "text/plain", new byte[]{1});
assertThrows(IllegalStateException.class, () -> new StudyTranscription(stub.base())
.transcribe(new ByteArrayInputStream(MediaStub.wav(16_000, 1_600))));
assertEquals(1, stub.calls.get());
}
}
@Test
void slowResponseReachesReadTimeoutWithoutRetry() throws Exception {
try (var stub = new MediaStub()) {
stub.delayed(PATH);
assertTimeoutPreemptively(Duration.ofSeconds(5), () -> assertThrows(RuntimeException.class,
() -> new StudyTranscription(stub.base()).transcribe(
new ByteArrayInputStream(MediaStub.wav(16_000, 1_600)))));
assertEquals(1, stub.calls.get());
}
}
}Bu on metod multipart sorğunu, neytral fayl adını, lokal açarı, fayl hədlərini, format və müddət yoxlamalarını, axının bağlanmasını, endpoint məhdudiyyətlərini, təhlükəsiz xəta mətnini, təkrar cəhdin olmamasını, yönləndirmələri və gecikən cavabı yoxlayır. Giriş səssizlik olsa da, qaytarılan cümləni stub əvvəlcədən müəyyən edir. Bu, cavabın oxunmasına sübutdur, nitqin tanınma dəqiqliyinə yox.
6. Testləri işə sal və məxfilik sərhədini planlaşdır
Yuxarıdakı faylları saxladıqdan sonra işə sal:
mvn -B -ntp -Dtest=StudyTranscriptionTest testİlk asılılıq yükləməsi üçün Maven repozitoriyalarına çıxış lazımdır. Asılılıqlar və plugin-lər keşləndikdən sonra offline build üçün -o əlavə et. Provayder hesabı lazım deyil. Növbəti iki dərs bu ayrıca laboratoriyaya yeni testlər əlavə edir.
Real provayder çağırışından əvvəl faylın hara göndərildiyini aydın göstər və səsi yazılan şəxslərin uyğun razılığını al. Tələbəyə səs yazısını silmək və transkripti düzəltmək imkanı ver. Saxlanma müddətini qısa tut, mediaya çıxışı yoxla, audio, transkript mətni, sorğu başlıqları və ilkin fayl adlarını loqa yazma. Provayderin saxlama və məlumatdan istifadə qaydalarını seçilmiş xidmət və hesab üçün yoxlamaq lazımdır; Java resursunun yaddaşda olması provayderin heç nə saxlamadığını göstərmir.
Gecikmə və xərcə nəzarət üçün müddəti və sorğu tezliyini məhdudlaşdır. Provayder işi qəbul etdikdən sonra timeout baş verə bilər; kor-koranə təkrar eyni işi və ya ödənişi çoxalda bilər. Bayt həddi təkbaşına xərc limiti deyil.
Tapşırıq: transkriptdə “otuz dəqiqəlik tədris planı saxla” yazılıb, amma tələbə yalnız səs yazısının önizləməsini açıb. Tətbiq nə edə bilər?
Cavabı yoxla: transkripti yoxlama üçün göstər. Transkripsiya nəticəsindən plan dəyişdirmək təsdiqi çıxarma. Sonrakı əməliyyat əvvəlki kimlik yoxlaması, konkret təklif, açıq təsdiq və idempotentlik axınından keçməlidir.