feat: version ai result schemas

This commit is contained in:
Grendgi
2026-06-17 16:46:03 +03:00
parent f32265400b
commit 63553fba33
5 changed files with 94 additions and 22 deletions

View File

@@ -43,7 +43,8 @@ Input can be either explicit messages:
``` ```
or compact `system` / `user` fields. The completed job result contains or compact `system` / `user` fields. The completed job result contains
`content`, `model`, `usage` and `duration_ms`. `schema_version=ai.chat_result.v1`, `content`, `model`, `usage` and
`duration_ms`.
`call_analysis` and `transcript_summary` use the same input contract as `call_analysis` and `transcript_summary` use the same input contract as
`llm_chat`; callers may include domain metadata fields in `input`, but the `llm_chat`; callers may include domain metadata fields in `input`, but the
@@ -55,6 +56,9 @@ worker only reads chat fields such as `system`, `user`, `messages`,
`/v1/audio/transcriptions` endpoint. The returned `segments` field stays `/v1/audio/transcriptions` endpoint. The returned `segments` field stays
compatible with telephony. If the provider returns one long segment, AI Service compatible with telephony. If the provider returns one long segment, AI Service
splits it into smaller transcript segments without inventing speaker labels. splits it into smaller transcript segments without inventing speaker labels.
The completed job result contains
`schema_version=ai.transcription_result.v1`, `provider`, `model`, `language`,
`segments`, optional provider `attempts` and `duration_ms`.
AI-server compose snippet for Whisper Large v3 lives in AI-server compose snippet for Whisper Large v3 lives in
`deploy/ai-server/docker-compose.audio.yml`: `deploy/ai-server/docker-compose.audio.yml`:
@@ -110,6 +114,20 @@ explicitly retryable categories while attempts remain.
Domain services may still expose manual retry for terminal errors after the Domain services may still expose manual retry for terminal errors after the
underlying data or prompt is corrected. underlying data or prompt is corrected.
## Result schemas
AI Service result payloads are versioned with `schema_version`. Consumers should
ignore unknown fields and reject only unsupported major schema names.
Current schemas:
- `ai.chat_result.v1`: `{schema_version, content, model, usage?, duration_ms}`.
- `ai.transcription_result.v1`:
`{schema_version, provider?, model?, attempts?, language, segments, duration_ms}`.
New optional fields may be added to a `v1` schema without a breaking change.
Breaking shape changes require a new schema name.
## Configuration ## Configuration
- `HTTP_HOST`, default `0.0.0.0` - `HTTP_HOST`, default `0.0.0.0`

View File

@@ -38,7 +38,10 @@ type Usage struct {
TotalTokens int `json:"total_tokens"` TotalTokens int `json:"total_tokens"`
} }
const ChatResultSchemaVersion = "ai.chat_result.v1"
type ChatResult struct { type ChatResult struct {
SchemaVersion string `json:"schema_version"`
Content string `json:"content"` Content string `json:"content"`
Model string `json:"model"` Model string `json:"model"`
Usage *Usage `json:"usage,omitempty"` Usage *Usage `json:"usage,omitempty"`
@@ -137,6 +140,7 @@ func (c *Client) Chat(ctx context.Context, in ChatInput) (*ChatResult, error) {
modelName = c.model modelName = c.model
} }
return &ChatResult{ return &ChatResult{
SchemaVersion: ChatResultSchemaVersion,
Content: out.Choices[0].Message.Content, Content: out.Choices[0].Message.Content,
Model: modelName, Model: modelName,
Usage: out.Usage, Usage: out.Usage,

View File

@@ -0,0 +1,43 @@
package llm
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestChatResultIncludesSchemaVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %q, want /v1/chat/completions", r.URL.Path)
}
_ = json.NewEncoder(w).Encode(map[string]any{
"model": "qwen2.5-14b",
"choices": []map[string]any{
{"message": map[string]string{"role": "assistant", "content": `{"ok":true}`}},
},
"usage": map[string]int{
"prompt_tokens": 10,
"completion_tokens": 2,
"total_tokens": 12,
},
})
}))
defer server.Close()
client := New(server.URL, "", "fallback-model", 0)
got, err := client.Chat(t.Context(), ChatInput{User: "test", MaxTokens: 32})
if err != nil {
t.Fatalf("Chat: %v", err)
}
if got.SchemaVersion != ChatResultSchemaVersion {
t.Fatalf("schema_version = %q, want %q", got.SchemaVersion, ChatResultSchemaVersion)
}
if got.Content != `{"ok":true}` {
t.Fatalf("content = %q", got.Content)
}
if got.Usage == nil || got.Usage.TotalTokens != 12 {
t.Fatalf("usage = %#v", got.Usage)
}
}

View File

@@ -60,7 +60,10 @@ type Segment struct {
Speaker string `json:"speaker,omitempty"` Speaker string `json:"speaker,omitempty"`
} }
const ResultSchemaVersion = "ai.transcription_result.v1"
type Result struct { type Result struct {
SchemaVersion string `json:"schema_version"`
Provider string `json:"provider,omitempty"` Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Attempts []Attempt `json:"attempts,omitempty"` Attempts []Attempt `json:"attempts,omitempty"`
@@ -199,6 +202,7 @@ func (c *Client) transcribeWithProvider(ctx context.Context, provider ProviderCo
attempt.Text = text attempt.Text = text
attempt.Segments = segments attempt.Segments = segments
return &Result{ return &Result{
SchemaVersion: ResultSchemaVersion,
Provider: provider.Name, Provider: provider.Name,
Model: resp.Model, Model: resp.Model,
Language: firstNonEmpty(resp.Language, in.Language, "unknown"), Language: firstNonEmpty(resp.Language, in.Language, "unknown"),

View File

@@ -84,6 +84,9 @@ func TestWhisperUsesAudioTranscriptionsEndpoint(t *testing.T) {
if len(got.Segments) != 2 || got.Segments[0].Text != "Алло, тест." || got.Segments[1].Start != 1.2 { if len(got.Segments) != 2 || got.Segments[0].Text != "Алло, тест." || got.Segments[1].Start != 1.2 {
t.Fatalf("segments = %#v", got.Segments) t.Fatalf("segments = %#v", got.Segments)
} }
if got.SchemaVersion != ResultSchemaVersion {
t.Fatalf("schema_version = %q, want %q", got.SchemaVersion, ResultSchemaVersion)
}
} }
func TestWhisperFallsBackToJSONWhenVerboseJSONUnsupported(t *testing.T) { func TestWhisperFallsBackToJSONWhenVerboseJSONUnsupported(t *testing.T) {