Files
memos/docs/superpowers/plans/2026-05-02-stt-audiollm-split.md
2026-07-02 19:14:14 +08:00

1591 lines
48 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# STT and Audio-LLM Split — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Refactor `internal/ai/` into a `stt/` (deterministic transcription) and `audiollm/` (multimodal audio LLM) package pair, port behavior from the current `openai.go` and `gemini.go`, refactor the `Transcribe` API handler to dispatch by provider type, then update proto comments and frontend i18n. End state preserves current end-to-end behavior; the response field for Gemini failures is more informative (`FinishReason`).
**Architecture:** Two new packages under `internal/ai/`. Each has a thin interface in the parent (`stt.Transcriber`, `audiollm.Model`), a factory that switches on `ai.ProviderType`, and a sub-package per provider (`stt/openai/`, `audiollm/gemini/`). Application-layer dispatch in the gRPC handler decides which interface to invoke. No proto field changes — only two comment updates.
**Tech Stack:** Go 1.x (existing Memos toolchain), `github.com/openai/openai-go/v3`, `google.golang.org/genai`, `github.com/pkg/errors`, `github.com/stretchr/testify`. Proto: buf v2. Frontend: TypeScript + bufbuild/es.
**Spec:** `docs/superpowers/specs/2026-05-02-stt-audiollm-split-design.md` — read it before starting; this plan implements §6–§7 of the spec.
**Worktree:** Run from a dedicated worktree (e.g., `git worktree add ../memos-stt-split`). Each stage is a separate commit; the branch should be PR-ready when all 7 stages are done.
---
## File Manifest
**Created (new files):**
- `internal/ai/stt/stt.go`
- `internal/ai/stt/options.go`
- `internal/ai/stt/factory.go`
- `internal/ai/stt/openai/openai.go`
- `internal/ai/stt/openai/openai_test.go`
- `internal/ai/audiollm/audiollm.go`
- `internal/ai/audiollm/options.go`
- `internal/ai/audiollm/factory.go`
- `internal/ai/audiollm/gemini/gemini.go`
- `internal/ai/audiollm/gemini/gemini_test.go`
**Modified:**
- `internal/ai/errors.go` (add 2 sentinel errors)
- `proto/store/instance_setting.proto` (2 field-comment blocks)
- `proto/gen/store/instance_setting.pb.go` (regenerated)
- `web/src/types/proto/store/instance_setting_pb.ts` (regenerated)
- `server/router/api/v1/ai_service.go` (refactor `Transcribe` handler)
- `web/src/locales/en.json` (3 strings)
**Deleted:**
- `internal/ai/transcription.go`
- `internal/ai/client.go`
- `internal/ai/openai.go`
- `internal/ai/openai_test.go`
- `internal/ai/gemini.go`
- `internal/ai/gemini_test.go`
**Untouched:**
- `internal/ai/ai.go`, `resolver.go`, `models.go`
- `internal/ai/audio/webm.go`, `audio/webm_test.go`
- `web/src/components/Settings/AISection.tsx` (no structural change; only the i18n strings it consumes change)
---
## Stage A: Scaffold new packages and add sentinel errors
**Files:**
- Modify: `internal/ai/errors.go`
- Create: `internal/ai/stt/stt.go`
- Create: `internal/ai/stt/options.go`
- Create: `internal/ai/stt/factory.go`
- Create: `internal/ai/audiollm/audiollm.go`
- Create: `internal/ai/audiollm/options.go`
- Create: `internal/ai/audiollm/factory.go`
This stage adds types and stubs only. No real provider implementation yet — both factories return errors for every provider type. We verify the project still builds.
- [ ] **A.1: Add sentinel errors**
Open `internal/ai/errors.go`, read the existing file. Append (or replace if you find a clearer location) so the file ends with these errors in addition to whatever is already there:
```go
// ErrSTTNotSupported indicates that the provider does not have a dedicated
// speech-to-text endpoint. Use the audiollm package for multimodal audio
// understanding when this is returned.
var ErrSTTNotSupported = errors.New("provider does not support speech-to-text capability")
// ErrAudioLLMNotSupported indicates that the provider does not have a
// multimodal-audio LLM available in this codebase.
var ErrAudioLLMNotSupported = errors.New("provider does not support multimodal audio capability")
```
If `errors.go` does not yet import `errors`, add:
```go
import "errors"
```
to its imports block. Use the standard library `errors`, not `github.com/pkg/errors``errors.New` from stdlib is what `errors.Is` uses for sentinel comparison.
- [ ] **A.2: Create `internal/ai/stt/stt.go`**
```go
// Package stt defines the speech-to-text capability for AI providers.
// Implementations call dedicated STT endpoints (e.g. OpenAI /audio/transcriptions)
// and return deterministic transcription output. For multimodal LLMs that
// happen to accept audio input, see internal/ai/audiollm.
package stt
import (
"context"
"io"
)
// Transcriber transcribes audio to text using a provider's dedicated STT endpoint.
type Transcriber interface {
Transcribe(ctx context.Context, req Request) (*Response, error)
}
// Request is the input to a transcription call.
type Request struct {
Audio io.Reader
Size int64
Filename string
ContentType string // IANA media type, e.g. "audio/wav"
Model string // provider-specific model id (e.g. "whisper-1", "gpt-4o-transcribe")
Prompt string // soft spelling/vocabulary hint (Whisper "prompt" parameter)
Language string // ISO 639-1, optional
}
// Response is the output of a transcription call.
type Response struct {
Text string
Language string // empty if provider did not return it
Segments []Segment // empty unless provider returned timestamps
}
// Segment is a timestamped portion of the transcript.
type Segment struct {
Text string
Start float64
End float64
Speaker string // empty unless using a diarization-capable model
}
```
- [ ] **A.3: Create `internal/ai/stt/options.go`**
```go
package stt
import (
"net/http"
"time"
)
const defaultHTTPTimeout = 2 * time.Minute
// Options is the resolved option set passed to provider implementations.
type Options struct {
HTTPClient *http.Client
}
// TranscriberOption customizes a Transcriber.
type TranscriberOption func(*Options)
// WithHTTPClient overrides the HTTP client used by the transcriber.
func WithHTTPClient(client *http.Client) TranscriberOption {
return func(o *Options) {
if client != nil {
o.HTTPClient = client
}
}
}
// ApplyOptions resolves a TranscriberOption slice into Options with defaults.
func ApplyOptions(opts []TranscriberOption) Options {
resolved := Options{HTTPClient: &http.Client{Timeout: defaultHTTPTimeout}}
for _, apply := range opts {
apply(&resolved)
}
return resolved
}
```
- [ ] **A.4: Create `internal/ai/stt/factory.go`**
```go
package stt
import (
"github.com/pkg/errors"
"github.com/usememos/memos/internal/ai"
)
// NewTranscriber returns a Transcriber for the given provider, or an error if
// the provider type does not have a dedicated STT endpoint.
func NewTranscriber(cfg ai.ProviderConfig, opts ...TranscriberOption) (Transcriber, error) {
switch cfg.Type {
case ai.ProviderOpenAI:
// Implemented in stage B; keep the stub return so the package compiles.
return nil, errors.New("stt/openai not implemented yet")
case ai.ProviderGemini:
return nil, errors.Wrap(ai.ErrSTTNotSupported,
"Gemini does not provide a dedicated STT endpoint; use audiollm.NewModel instead")
default:
return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type)
}
}
```
- [ ] **A.5: Create `internal/ai/audiollm/audiollm.go`**
```go
// Package audiollm defines the multimodal-audio capability for AI providers.
// Implementations call chat-completions or generate-content style APIs that
// accept audio as input. For deterministic transcription, prefer internal/ai/stt
// where a dedicated STT endpoint exists.
package audiollm
import (
"context"
"io"
)
// Model invokes a multimodal LLM with audio input.
type Model interface {
GenerateFromAudio(ctx context.Context, req Request) (*Response, error)
}
// Request is the input to a multimodal-audio call.
type Request struct {
Audio io.Reader
Size int64
ContentType string
Model string
Instructions string // literal instruction the model is expected to follow
Temperature *float32 // optional; nil leaves the provider default in place
}
// Response is the output of a multimodal-audio call.
type Response struct {
Text string
FinishReason FinishReason
}
// FinishReason describes why the model stopped generating.
type FinishReason string
const (
FinishStop FinishReason = "stop" // model finished normally
FinishLength FinishReason = "length" // truncated by max-tokens
FinishSafety FinishReason = "safety" // safety filter blocked output
FinishOther FinishReason = "other" // anything else, including unknown
)
```
- [ ] **A.6: Create `internal/ai/audiollm/options.go`**
```go
package audiollm
import (
"net/http"
"time"
)
const defaultHTTPTimeout = 2 * time.Minute
// Options is the resolved option set passed to provider implementations.
type Options struct {
HTTPClient *http.Client
}
// ModelOption customizes a Model.
type ModelOption func(*Options)
// WithHTTPClient overrides the HTTP client used by the model.
func WithHTTPClient(client *http.Client) ModelOption {
return func(o *Options) {
if client != nil {
o.HTTPClient = client
}
}
}
// ApplyOptions resolves a ModelOption slice into Options with defaults.
func ApplyOptions(opts []ModelOption) Options {
resolved := Options{HTTPClient: &http.Client{Timeout: defaultHTTPTimeout}}
for _, apply := range opts {
apply(&resolved)
}
return resolved
}
```
- [ ] **A.7: Create `internal/ai/audiollm/factory.go`**
```go
package audiollm
import (
"github.com/pkg/errors"
"github.com/usememos/memos/internal/ai"
)
// NewModel returns a Model for the given provider, or an error if the provider
// type does not expose a multimodal-audio capability in this codebase.
func NewModel(cfg ai.ProviderConfig, opts ...ModelOption) (Model, error) {
switch cfg.Type {
case ai.ProviderGemini:
// Implemented in stage C; keep the stub return so the package compiles.
return nil, errors.New("audiollm/gemini not implemented yet")
case ai.ProviderOpenAI:
// gpt-4o-audio-preview support is intentionally out of scope (see spec §2).
return nil, errors.Wrap(ai.ErrAudioLLMNotSupported,
"OpenAI multimodal audio (gpt-4o-audio) is not implemented")
default:
return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type)
}
}
```
- [ ] **A.8: Verify the project still builds**
```bash
go build ./...
```
Expected: exits 0, no output. (`internal/ai/transcription.go`, `client.go`, `openai.go`, `gemini.go` are still in place from before this plan; the new packages compile alongside them.)
- [ ] **A.9: Commit**
```bash
git add internal/ai/errors.go \
internal/ai/stt \
internal/ai/audiollm
git commit -m "$(cat <<'EOF'
feat(ai): scaffold stt and audiollm packages
Adds two new sub-packages under internal/ai/ that will replace the single
Transcriber abstraction:
- stt/ — deterministic speech-to-text (dedicated STT endpoints)
- audiollm/ — multimodal audio LLMs (chat-completions / generateContent)
This commit only adds the interface types, factories with stub returns,
and two sentinel errors. Provider implementations land in the next two
commits; until then the existing internal/ai/transcription.go path is
still the live code path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Stage B: Implement `stt/openai`
**Files:**
- Create: `internal/ai/stt/openai/openai.go`
- Create: `internal/ai/stt/openai/openai_test.go`
- Modify: `internal/ai/stt/factory.go`
Port the wire behavior of `internal/ai/openai.go::openAITranscriber.Transcribe` into the new package. TDD: write the test (adapted from `internal/ai/openai_test.go`) first.
- [ ] **B.1: Write the failing test**
Create `internal/ai/stt/openai/openai_test.go`:
```go
package openai_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/stt"
sttopenai "github.com/usememos/memos/internal/ai/stt/openai"
)
func TestTranscribe(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/audio/transcriptions", r.URL.Path)
require.Equal(t, "Bearer test-key", r.Header.Get("Authorization"))
require.NoError(t, r.ParseMultipartForm(10<<20))
require.Equal(t, "gpt-4o-transcribe", r.FormValue("model"))
require.Equal(t, "json", r.FormValue("response_format"))
require.Equal(t, "domain words", r.FormValue("prompt"))
require.Equal(t, "en", r.FormValue("language"))
file, header, err := r.FormFile("file")
require.NoError(t, err)
defer file.Close()
require.Equal(t, "voice.wav", header.Filename)
require.Equal(t, "audio/wav", header.Header.Get("Content-Type"))
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"text": "hello world",
"language": "en",
"duration": 1.5,
}))
}))
defer server.Close()
transcriber, err := sttopenai.New(ai.ProviderConfig{
Type: ai.ProviderOpenAI,
Endpoint: server.URL,
APIKey: "test-key",
}, stt.ApplyOptions(nil))
require.NoError(t, err)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
response, err := transcriber.Transcribe(ctx, stt.Request{
Model: "gpt-4o-transcribe",
Filename: "voice.wav",
ContentType: "audio/wav",
Audio: strings.NewReader("RIFF"),
Prompt: "domain words",
Language: "en",
})
require.NoError(t, err)
require.Equal(t, "hello world", response.Text)
require.Equal(t, "en", response.Language)
// Note: Duration intentionally omitted from stt.Response — not exposed in the new contract.
}
```
- [ ] **B.2: Run the test to verify it fails**
```bash
go test ./internal/ai/stt/openai/...
```
Expected: build failure with "undefined: openai.New" or similar — the implementation file does not exist yet.
- [ ] **B.3: Create `internal/ai/stt/openai/openai.go`**
Port from `internal/ai/openai.go`. The body is structurally identical to today's `openAITranscriber.Transcribe`, only the Request/Response types and package boundary change.
```go
// Package openai implements stt.Transcriber against the OpenAI
// /audio/transcriptions endpoint (and any compatible third-party endpoint
// such as Groq Whisper, faster-whisper self-hosted, or Azure Whisper).
package openai
import (
"context"
"mime"
"net/url"
"strings"
openaisdk "github.com/openai/openai-go/v3"
openaioption "github.com/openai/openai-go/v3/option"
"github.com/pkg/errors"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/stt"
)
const defaultEndpoint = "https://api.openai.com/v1"
// Transcriber implements stt.Transcriber for OpenAI-compatible STT endpoints.
type Transcriber struct {
client openaisdk.Client
}
// New constructs a Transcriber from a provider config.
func New(cfg ai.ProviderConfig, options stt.Options) (*Transcriber, error) {
endpoint, err := normalizeEndpoint(cfg.Endpoint)
if err != nil {
return nil, err
}
if cfg.APIKey == "" {
return nil, errors.New("OpenAI API key is required")
}
return &Transcriber{
client: openaisdk.NewClient(
openaioption.WithAPIKey(cfg.APIKey),
openaioption.WithBaseURL(endpoint),
openaioption.WithHTTPClient(options.HTTPClient),
),
}, nil
}
// Transcribe sends the audio to /audio/transcriptions.
func (t *Transcriber) Transcribe(ctx context.Context, req stt.Request) (*stt.Response, error) {
if strings.TrimSpace(req.Model) == "" {
return nil, errors.New("model is required")
}
if req.Audio == nil {
return nil, errors.New("audio is required")
}
filename, contentType, err := normalizeAudioMetadata(req)
if err != nil {
return nil, err
}
params := openaisdk.AudioTranscriptionNewParams{
File: openaisdk.File(req.Audio, filename, contentType),
Model: openaisdk.AudioModel(req.Model),
ResponseFormat: openaisdk.AudioResponseFormatJSON,
}
if req.Prompt != "" {
params.Prompt = openaisdk.String(req.Prompt)
}
if req.Language != "" {
params.Language = openaisdk.String(req.Language)
}
resp, err := t.client.Audio.Transcriptions.New(ctx, params)
if err != nil {
return nil, errors.Wrap(err, "failed to send OpenAI transcription request")
}
return &stt.Response{
Text: resp.Text,
Language: resp.Language,
}, nil
}
func normalizeEndpoint(endpoint string) (string, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
endpoint = defaultEndpoint
}
if _, err := url.ParseRequestURI(endpoint); err != nil {
return "", errors.Wrap(err, "invalid OpenAI endpoint")
}
return strings.TrimRight(endpoint, "/"), nil
}
func normalizeAudioMetadata(req stt.Request) (string, string, error) {
filename := strings.TrimSpace(req.Filename)
if filename == "" {
filename = "audio"
}
contentType := strings.TrimSpace(req.ContentType)
if contentType == "" {
contentType = "application/octet-stream"
} else {
mediaType, _, err := mime.ParseMediaType(contentType)
if err != nil {
return "", "", errors.Wrap(err, "invalid audio content type")
}
contentType = mediaType
}
return sanitizeFilename(filename), contentType, nil
}
func sanitizeFilename(filename string) string {
filename = strings.NewReplacer("\r", "_", "\n", "_").Replace(filename)
if strings.TrimSpace(filename) == "" {
return "audio"
}
return filename
}
```
- [ ] **B.4: Wire the factory to use the new implementation**
Replace the stub branch in `internal/ai/stt/factory.go`:
```go
package stt
import (
"github.com/pkg/errors"
"github.com/usememos/memos/internal/ai"
sttopenai "github.com/usememos/memos/internal/ai/stt/openai"
)
func NewTranscriber(cfg ai.ProviderConfig, opts ...TranscriberOption) (Transcriber, error) {
switch cfg.Type {
case ai.ProviderOpenAI:
return sttopenai.New(cfg, ApplyOptions(opts))
case ai.ProviderGemini:
return nil, errors.Wrap(ai.ErrSTTNotSupported,
"Gemini does not provide a dedicated STT endpoint; use audiollm.NewModel instead")
default:
return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type)
}
}
```
- [ ] **B.5: Run the test to verify it passes**
```bash
go test ./internal/ai/stt/openai/... -v
```
Expected: `--- PASS: TestTranscribe`. Exit 0.
- [ ] **B.6: Verify the whole project still builds**
```bash
go build ./...
```
Expected: exits 0, no output.
- [ ] **B.7: Commit**
```bash
git add internal/ai/stt
git commit -m "$(cat <<'EOF'
feat(ai/stt): implement OpenAI-compatible Transcriber
Ports the wire behavior of internal/ai/openai.go into the new
internal/ai/stt/openai package. Behavior is identical: same SDK, same
endpoint, same request shape; only the contract types (stt.Request,
stt.Response) change. Duration is intentionally dropped from the response
shape (audio file metadata, not transcription metadata).
The legacy internal/ai/openai.go is still in place — it will be removed
once the handler is refactored to call into the new stack.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Stage C: Implement `audiollm/gemini`
**Files:**
- Create: `internal/ai/audiollm/gemini/gemini.go`
- Create: `internal/ai/audiollm/gemini/gemini_test.go`
- Modify: `internal/ai/audiollm/factory.go`
Port the wire behavior of `internal/ai/gemini.go::geminiTranscriber.Transcribe` with two semantic upgrades:
1. The transcription instruction is no longer hardcoded in this package — the caller passes `Request.Instructions`.
2. The Gemini API's `FinishReason` is mapped to `audiollm.FinishReason` and surfaced in the response, instead of being collapsed into a generic error.
- [ ] **C.1: Write the failing tests**
Create `internal/ai/audiollm/gemini/gemini_test.go`. Two tests: happy path and unsupported-content-type rejection. The happy-path test asserts that the caller-supplied `Instructions` string flows through to the request and that `FinishStop` is returned. We also verify that WebM input is transcoded to WAV by re-using `internal/ai/audio.WebMOpusToWAV`.
```go
package gemini_test
import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/audiollm"
audiollmgemini "github.com/usememos/memos/internal/ai/audiollm/gemini"
)
func TestGenerateFromAudio(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPost, r.Method)
require.Equal(t, "/v1beta/models/gemini-2.5-flash:generateContent", r.URL.Path)
require.Equal(t, "test-key", r.Header.Get("x-goog-api-key"))
require.Equal(t, "application/json", r.Header.Get("Content-Type"))
var request struct {
Contents []struct {
Parts []struct {
Text string `json:"text"`
InlineData *struct {
MIMEType string `json:"mimeType"`
Data string `json:"data"`
} `json:"inlineData"`
} `json:"parts"`
} `json:"contents"`
GenerationConfig map[string]json.Number `json:"generationConfig"`
}
require.NoError(t, json.NewDecoder(r.Body).Decode(&request))
require.Len(t, request.Contents, 1)
require.Len(t, request.Contents[0].Parts, 2)
require.NotNil(t, request.Contents[0].Parts[0].InlineData)
require.Equal(t, "audio/mp3", request.Contents[0].Parts[0].InlineData.MIMEType)
audio, err := base64.StdEncoding.DecodeString(request.Contents[0].Parts[0].InlineData.Data)
require.NoError(t, err)
require.Equal(t, "audio bytes", string(audio))
require.Equal(t, "transcribe please", request.Contents[0].Parts[1].Text)
require.Equal(t, json.Number("0"), request.GenerationConfig["temperature"])
w.Header().Set("Content-Type", "application/json")
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{
"candidates": []map[string]any{
{
"finishReason": "STOP",
"content": map[string]any{
"parts": []map[string]string{{"text": "hello from gemini"}},
},
},
},
}))
}))
defer server.Close()
model, err := audiollmgemini.New(ai.ProviderConfig{
Type: ai.ProviderGemini,
Endpoint: server.URL + "/v1beta",
APIKey: "test-key",
}, audiollm.ApplyOptions(nil))
require.NoError(t, err)
temp := float32(0)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
resp, err := model.GenerateFromAudio(ctx, audiollm.Request{
Model: "models/gemini-2.5-flash",
ContentType: "audio/mpeg",
Audio: strings.NewReader("audio bytes"),
Instructions: "transcribe please",
Temperature: &temp,
})
require.NoError(t, err)
require.Equal(t, "hello from gemini", resp.Text)
require.Equal(t, audiollm.FinishStop, resp.FinishReason)
}
func TestGenerateFromAudioRejectsUnsupportedContentType(t *testing.T) {
t.Parallel()
model, err := audiollmgemini.New(ai.ProviderConfig{
Type: ai.ProviderGemini,
Endpoint: "https://example.com/v1beta",
APIKey: "test-key",
}, audiollm.ApplyOptions(nil))
require.NoError(t, err)
_, err = model.GenerateFromAudio(context.Background(), audiollm.Request{
Model: "gemini-2.5-flash",
ContentType: "video/mp4",
Audio: strings.NewReader("video bytes"),
Instructions: "transcribe please",
})
require.Error(t, err)
require.Contains(t, err.Error(), "not supported by Gemini")
}
```
- [ ] **C.2: Run the tests to verify they fail**
```bash
go test ./internal/ai/audiollm/gemini/...
```
Expected: build failure ("undefined: gemini.New" or similar).
- [ ] **C.3: Create `internal/ai/audiollm/gemini/gemini.go`**
Port from `internal/ai/gemini.go`. The structure mirrors the existing file; the differences are:
- No hardcoded transcription prompt (caller passes `Instructions`).
- `Temperature` comes from the request, not from a package constant.
- Response includes a mapped `FinishReason`, and we no longer treat empty text as an error here — that decision moves to the caller (the handler), which knows whether `FinishStop` is required.
```go
// Package gemini implements audiollm.Model against the Gemini generateContent
// endpoint. Used by Memos transcription when the user picks a Gemini provider:
// the handler issues a transcription instruction via audiollm.Request.Instructions.
package gemini
import (
"context"
"io"
"mime"
"net/url"
"strings"
"github.com/pkg/errors"
"google.golang.org/genai"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/audio"
"github.com/usememos/memos/internal/ai/audiollm"
)
const (
defaultEndpoint = "https://generativelanguage.googleapis.com/v1beta"
defaultAPIVersion = "v1beta"
maxInlineSize = 14 * 1024 * 1024
providerName = "Gemini"
)
var supportedContentTypes = map[string]string{
"audio/wav": "audio/wav",
"audio/x-wav": "audio/wav",
"audio/mp3": "audio/mp3",
"audio/mpeg": "audio/mp3",
"audio/aiff": "audio/aiff",
"audio/aac": "audio/aac",
"audio/ogg": "audio/ogg",
"audio/flac": "audio/flac",
"audio/x-flac": "audio/flac",
}
// Model implements audiollm.Model for Gemini generateContent.
type Model struct {
client *genai.Client
}
// New constructs a Model from a provider config.
func New(cfg ai.ProviderConfig, options audiollm.Options) (*Model, error) {
endpoint, err := normalizeEndpoint(cfg.Endpoint)
if err != nil {
return nil, err
}
if cfg.APIKey == "" {
return nil, errors.Errorf("%s API key is required", providerName)
}
baseURL, apiVersion, err := splitEndpoint(endpoint)
if err != nil {
return nil, err
}
httpOptions := genai.HTTPOptions{BaseURL: baseURL, APIVersion: apiVersion}
if options.HTTPClient != nil && options.HTTPClient.Timeout > 0 {
timeout := options.HTTPClient.Timeout
httpOptions.Timeout = &timeout
}
client, err := genai.NewClient(context.Background(), &genai.ClientConfig{
APIKey: cfg.APIKey,
Backend: genai.BackendGeminiAPI,
HTTPClient: options.HTTPClient,
HTTPOptions: httpOptions,
})
if err != nil {
return nil, errors.Wrap(err, "failed to create Gemini client")
}
return &Model{client: client}, nil
}
// GenerateFromAudio calls Gemini generateContent with the audio attached.
func (m *Model) GenerateFromAudio(ctx context.Context, req audiollm.Request) (*audiollm.Response, error) {
if strings.TrimSpace(req.Model) == "" {
return nil, errors.New("model is required")
}
if req.Audio == nil {
return nil, errors.New("audio is required")
}
if strings.TrimSpace(req.Instructions) == "" {
return nil, errors.New("instructions are required")
}
audioBytes, err := io.ReadAll(req.Audio)
if err != nil {
return nil, errors.Wrap(err, "failed to read audio")
}
if len(audioBytes) == 0 {
return nil, errors.New("audio is required")
}
contentType := req.ContentType
if audio.IsWebMContentType(contentType) {
wav, err := audio.WebMOpusToWAV(audioBytes)
if err != nil {
return nil, errors.Wrap(err, "failed to transcode webm audio for Gemini")
}
audioBytes = wav
contentType = "audio/wav"
}
if len(audioBytes) > maxInlineSize {
return nil, errors.Errorf("audio is too large for Gemini inline request; maximum size is %d bytes", maxInlineSize)
}
contentType, err = normalizeContentType(contentType)
if err != nil {
return nil, err
}
cfg := &genai.GenerateContentConfig{}
if req.Temperature != nil {
t := *req.Temperature
cfg.Temperature = &t
}
resp, err := m.client.Models.GenerateContent(ctx, normalizeModelName(req.Model), []*genai.Content{
genai.NewContentFromParts([]*genai.Part{
genai.NewPartFromBytes(audioBytes, contentType),
genai.NewPartFromText(req.Instructions),
}, genai.RoleUser),
}, cfg)
if err != nil {
return nil, errors.Wrap(err, "failed to send Gemini request")
}
return &audiollm.Response{
Text: strings.TrimSpace(resp.Text()),
FinishReason: mapFinishReason(resp),
}, nil
}
func mapFinishReason(resp *genai.GenerateContentResponse) audiollm.FinishReason {
if resp == nil || len(resp.Candidates) == 0 {
return audiollm.FinishOther
}
switch resp.Candidates[0].FinishReason {
case genai.FinishReasonStop:
return audiollm.FinishStop
case genai.FinishReasonMaxTokens:
return audiollm.FinishLength
case genai.FinishReasonSafety:
return audiollm.FinishSafety
default:
return audiollm.FinishOther
}
}
func normalizeEndpoint(endpoint string) (string, error) {
endpoint = strings.TrimSpace(endpoint)
if endpoint == "" {
endpoint = defaultEndpoint
}
if _, err := url.ParseRequestURI(endpoint); err != nil {
return "", errors.Wrapf(err, "invalid %s endpoint", providerName)
}
return strings.TrimRight(endpoint, "/"), nil
}
func splitEndpoint(endpoint string) (string, string, error) {
parsed, err := url.Parse(endpoint)
if err != nil {
return "", "", errors.Wrap(err, "invalid Gemini endpoint")
}
path := strings.TrimRight(parsed.Path, "/")
apiVersion := defaultAPIVersion
for _, supported := range []string{"v1alpha", "v1beta", "v1"} {
if path == "/"+supported || strings.HasSuffix(path, "/"+supported) {
apiVersion = supported
parsed.Path = strings.TrimSuffix(path, "/"+supported)
break
}
}
return strings.TrimRight(parsed.String(), "/"), apiVersion, nil
}
func normalizeContentType(contentType string) (string, error) {
mediaType, _, err := mime.ParseMediaType(strings.TrimSpace(contentType))
if err != nil {
return "", errors.Wrap(err, "invalid audio content type")
}
mediaType = strings.ToLower(mediaType)
normalized, ok := supportedContentTypes[mediaType]
if !ok {
return "", errors.Errorf("audio content type %q is not supported by Gemini", mediaType)
}
return normalized, nil
}
func normalizeModelName(model string) string {
return strings.TrimPrefix(strings.TrimSpace(model), "models/")
}
```
- [ ] **C.4: Wire the factory to use the new implementation**
Replace the stub branch in `internal/ai/audiollm/factory.go`:
```go
package audiollm
import (
"github.com/pkg/errors"
"github.com/usememos/memos/internal/ai"
audiollmgemini "github.com/usememos/memos/internal/ai/audiollm/gemini"
)
func NewModel(cfg ai.ProviderConfig, opts ...ModelOption) (Model, error) {
switch cfg.Type {
case ai.ProviderGemini:
return audiollmgemini.New(cfg, ApplyOptions(opts))
case ai.ProviderOpenAI:
return nil, errors.Wrap(ai.ErrAudioLLMNotSupported,
"OpenAI multimodal audio (gpt-4o-audio) is not implemented")
default:
return nil, errors.Wrapf(ai.ErrCapabilityUnsupported, "provider type %q", cfg.Type)
}
}
```
- [ ] **C.5: Run the tests to verify they pass**
```bash
go test ./internal/ai/audiollm/gemini/... -v
```
Expected: `--- PASS: TestGenerateFromAudio` and `--- PASS: TestGenerateFromAudioRejectsUnsupportedContentType`.
If `TestGenerateFromAudio` fails because the genai SDK constant for "STOP" is named differently from `genai.FinishReasonStop`, look up the exact constant name in `google.golang.org/genai` and adjust `mapFinishReason`. The behavioral expectation is unchanged: a JSON `"finishReason": "STOP"` from the server must map to `audiollm.FinishStop`.
- [ ] **C.6: Verify the whole project still builds**
```bash
go build ./...
```
Expected: exits 0.
- [ ] **C.7: Commit**
```bash
git add internal/ai/audiollm
git commit -m "$(cat <<'EOF'
feat(ai/audiollm): implement Gemini multimodal audio Model
Ports internal/ai/gemini.go into the new internal/ai/audiollm/gemini
package. Two behavior changes from the previous code:
- Transcription instructions are now caller-supplied via
audiollm.Request.Instructions instead of being hardcoded in the package.
This frees the audiollm/gemini package to serve future non-transcription
use cases (audio summarization, etc.) without changes.
- The genai FinishReason is mapped to audiollm.FinishReason and surfaced
on the response, replacing the previous "did not include text" error
that collapsed safety-filter, max-tokens, and other failure modes.
The legacy internal/ai/gemini.go is still in place and still wired to the
handler — that switches in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Stage D: Refactor the Transcribe handler to dispatch by provider type
**Files:**
- Modify: `server/router/api/v1/ai_service.go`
The handler currently calls `ai.NewTranscriber(provider)` and `transcriber.Transcribe(...)`. We replace that core block with a `switch provider.Type` that calls either `stt.NewTranscriber` or `audiollm.NewModel`. All input validation and provider resolution above it stays the same.
- [ ] **D.1: Replace the core call block**
In `server/router/api/v1/ai_service.go`, locate the existing call sequence inside the `Transcribe` method:
```go
transcriber, err := ai.NewTranscriber(provider)
if err != nil {
return nil, status.Errorf(codes.InvalidArgument, "failed to create AI transcriber: %v", err)
}
transcription, err := transcriber.Transcribe(ctx, ai.TranscribeRequest{
Model: model,
Filename: filename,
ContentType: contentType,
Audio: bytes.NewReader(content),
Size: int64(len(content)),
Prompt: persisted.GetPrompt(),
Language: persisted.GetLanguage(),
})
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to transcribe audio: %v", err)
}
return &v1pb.TranscribeResponse{
Text: transcription.Text,
}, nil
```
Replace that entire sequence (everything from `transcriber, err := ai.NewTranscriber(provider)` through the closing `}, nil` of the return statement) with:
```go
var text string
switch provider.Type {
case ai.ProviderOpenAI:
text, err = s.transcribeViaSTT(ctx, provider, persisted, model, content, filename, contentType)
case ai.ProviderGemini:
text, err = s.transcribeViaAudioLLM(ctx, provider, persisted, model, content, contentType)
default:
return nil, status.Errorf(codes.FailedPrecondition,
"provider type %q is not supported for transcription", provider.Type)
}
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to transcribe audio: %v", err)
}
return &v1pb.TranscribeResponse{Text: text}, nil
}
```
Remove the now-unused import of `bytes` if Go points it out — `bytes.NewReader(content)` moves into the helpers below.
- [ ] **D.2: Add the two helpers and the instruction builder**
Append to the same file, after the `Transcribe` function:
```go
func (*APIV1Service) transcribeViaSTT(
ctx context.Context,
provider ai.ProviderConfig,
persisted *storepb.TranscriptionConfig,
model string,
content []byte,
filename string,
contentType string,
) (string, error) {
transcriber, err := stt.NewTranscriber(provider)
if err != nil {
return "", errors.Wrap(err, "failed to create STT transcriber")
}
resp, err := transcriber.Transcribe(ctx, stt.Request{
Audio: bytes.NewReader(content),
Size: int64(len(content)),
Filename: filename,
ContentType: contentType,
Model: model,
Prompt: persisted.GetPrompt(),
Language: persisted.GetLanguage(),
})
if err != nil {
return "", err
}
return resp.Text, nil
}
func (*APIV1Service) transcribeViaAudioLLM(
ctx context.Context,
provider ai.ProviderConfig,
persisted *storepb.TranscriptionConfig,
model string,
content []byte,
contentType string,
) (string, error) {
m, err := audiollm.NewModel(provider)
if err != nil {
return "", errors.Wrap(err, "failed to create audio LLM")
}
resp, err := m.GenerateFromAudio(ctx, audiollm.Request{
Audio: bytes.NewReader(content),
Size: int64(len(content)),
ContentType: contentType,
Model: model,
Instructions: buildTranscriptionInstructions(persisted.GetPrompt(), persisted.GetLanguage()),
})
if err != nil {
return "", err
}
if resp.FinishReason != audiollm.FinishStop {
return "", errors.Errorf("transcription incomplete (finish reason: %s)", resp.FinishReason)
}
if strings.TrimSpace(resp.Text) == "" {
return "", errors.New("transcription response did not include text")
}
return resp.Text, nil
}
func buildTranscriptionInstructions(prompt, language string) string {
parts := []string{
"Transcribe the audio accurately. Return only the transcript text. " +
"Do not summarize, explain, or add content that is not spoken.",
}
if language = strings.TrimSpace(language); language != "" {
parts = append(parts, "The input language is "+language+".")
}
if prompt = strings.TrimSpace(prompt); prompt != "" {
parts = append(parts, "Context and spelling hints:\n"+prompt)
}
return strings.Join(parts, "\n\n")
}
```
- [ ] **D.3: Update the imports**
The imports block of `ai_service.go` needs:
```go
import (
"bytes"
"context"
"mime"
"net/http"
"strings"
"github.com/pkg/errors"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/usememos/memos/internal/ai"
"github.com/usememos/memos/internal/ai/audiollm"
"github.com/usememos/memos/internal/ai/stt"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
storepb "github.com/usememos/memos/proto/gen/store"
)
```
(`bytes` and `mime` were already there; `errors`, `audiollm`, and `stt` are new.)
- [ ] **D.4: Verify the project builds**
```bash
go build ./...
```
Expected: exits 0.
- [ ] **D.5: Run the existing handler tests**
```bash
go test ./server/router/api/v1/... -run Transcrib -v
```
Expected: any existing tests touching `Transcribe` still pass. If there are no Transcribe tests yet, the command will print `no tests to run` — that is fine.
- [ ] **D.6: Run the full Go test suite**
```bash
go test ./...
```
Expected: exits 0. Watch for any test that imports `internal/ai.Transcriber`, `internal/ai.NewTranscriber`, `internal/ai.TranscribeRequest`, or `internal/ai.TranscribeResponse` — if such a test exists outside the files we delete in stage E, it must be migrated now.
- [ ] **D.7: Commit**
```bash
git add server/router/api/v1/ai_service.go
git commit -m "$(cat <<'EOF'
refactor(ai_service): dispatch transcription by provider type
The Transcribe handler now switches on the resolved provider's Type and
calls into either internal/ai/stt (OpenAI-compatible) or internal/ai/audiollm
(Gemini multimodal). buildTranscriptionInstructions centralizes the
literal instruction sent to multimodal LLMs, replacing the previously
hardcoded prompt inside internal/ai/gemini.go.
The legacy internal/ai package is no longer called by the handler. It is
still on disk; it gets removed in the next commit.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Stage E: Delete legacy internal/ai files
**Files (deleted):**
- `internal/ai/transcription.go`
- `internal/ai/client.go`
- `internal/ai/openai.go`
- `internal/ai/openai_test.go`
- `internal/ai/gemini.go`
- `internal/ai/gemini_test.go`
After the handler refactor in stage D, nothing references the legacy `Transcriber` interface or its implementations. We delete them.
- [ ] **E.1: Confirm no callers remain**
```bash
git grep -nE 'ai\.(NewTranscriber|TranscribeRequest|TranscribeResponse|Transcriber)\b' -- ':(exclude)internal/ai/transcription.go' ':(exclude)internal/ai/client.go' ':(exclude)internal/ai/openai.go' ':(exclude)internal/ai/openai_test.go' ':(exclude)internal/ai/gemini.go' ':(exclude)internal/ai/gemini_test.go'
```
Expected: no output (no remaining external callers). If anything matches, fix it before deleting.
- [ ] **E.2: Delete the files**
```bash
git rm internal/ai/transcription.go \
internal/ai/client.go \
internal/ai/openai.go \
internal/ai/openai_test.go \
internal/ai/gemini.go \
internal/ai/gemini_test.go
```
- [ ] **E.3: Verify the project still builds**
```bash
go build ./...
```
Expected: exits 0.
- [ ] **E.4: Run the full Go test suite**
```bash
go test ./...
```
Expected: exits 0.
- [ ] **E.5: Confirm `internal/ai/` only has the surviving files**
```bash
ls internal/ai/
```
Expected output (alphabetical, may include `audio/`):
```
ai.go
audio
audiollm
errors.go
models.go
resolver.go
stt
```
(`audio/` is the existing webm package, untouched.)
- [ ] **E.6: Commit**
```bash
git commit -m "$(cat <<'EOF'
refactor(ai): remove legacy Transcriber implementations
The Transcribe handler now uses internal/ai/stt and internal/ai/audiollm
exclusively. Removes the obsolete single-interface Transcriber, its
factory, and both provider implementations. internal/ai/ now contains
only shared primitives (ProviderConfig, errors, default models, provider
resolver) plus the per-capability sub-packages.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Stage F: Update proto comments and regenerate bindings
**Files:**
- Modify: `proto/store/instance_setting.proto`
- Regenerate: `proto/gen/store/instance_setting.pb.go`
- Regenerate: `web/src/types/proto/store/instance_setting_pb.ts`
Documentation-only proto change; no field tags moved or renamed.
- [ ] **F.1: Verify `buf` is available**
```bash
buf --version
```
Expected: prints a version. If absent: `brew install bufbuild/buf/buf`.
- [ ] **F.2: Update the `model` field comment**
Edit `proto/store/instance_setting.proto`. Find the existing block (around lines 179182):
```proto
// model is the provider-specific model identifier.
// Empty string falls back to the engine default
// (whisper-1 for OPENAI providers, gemini-2.5-flash for GEMINI providers).
string model = 2;
```
Replace with:
```proto
// model is the provider-specific model identifier.
// Empty string falls back to the engine default.
// OPENAI examples:
// - whisper-1 (legacy, lower cost)
// - gpt-4o-transcribe, gpt-4o-mini-transcribe (higher quality)
// - gpt-4o-transcribe-diarize (includes speaker labels)
// GEMINI examples:
// - gemini-2.5-flash (default, multimodal call)
// - gemini-2.5-pro
string model = 2;
```
- [ ] **F.3: Update the `prompt` field comment**
In the same file, find (around lines 188191):
```proto
// prompt is a default spelling/vocabulary hint passed to the provider.
// Used as the OpenAI Whisper "prompt" parameter and folded into the Gemini
// generation prompt as a "Context and spelling hints" block.
string prompt = 4;
```
Replace with:
```proto
// prompt is a default spelling/vocabulary hint passed to the provider.
// Used as the OpenAI Whisper "prompt" parameter (a soft hint that the model
// may ignore) and folded into the Gemini generation prompt as a "Context and
// spelling hints" block (which the LLM will treat more literally).
string prompt = 4;
```
- [ ] **F.4: Format and regenerate**
```bash
cd proto && buf format -w && buf generate && cd ..
```
Expected: each command exits 0; `proto/gen/store/instance_setting.pb.go` and `web/src/types/proto/store/instance_setting_pb.ts` are rewritten in place.
- [ ] **F.5: Verify the generated Go file picked up both comments**
```bash
grep -A 9 "model is the provider-specific model identifier" proto/gen/store/instance_setting.pb.go
grep -A 4 "prompt is a default spelling/vocabulary hint" proto/gen/store/instance_setting.pb.go
```
Expected: both blocks contain the new wording (model: 9 lines including the OPENAI/GEMINI examples and `Model string ...`; prompt: 4 lines of comment + the `Prompt string ...` line).
- [ ] **F.6: Verify the generated TypeScript file picked up both comments**
```bash
grep -A 9 "model is the provider-specific model identifier" web/src/types/proto/store/instance_setting_pb.ts
grep -A 4 "prompt is a default spelling/vocabulary hint" web/src/types/proto/store/instance_setting_pb.ts
```
Expected: both blocks contain the new wording (TS comments are JSDoc-formatted with leading `*` per line).
- [ ] **F.7: Verify only three files changed**
```bash
git status --short
```
Expected: exactly:
```
M proto/store/instance_setting.proto
M proto/gen/store/instance_setting.pb.go
M web/src/types/proto/store/instance_setting_pb.ts
```
If other generated files appear modified, the regen touched something unrelated — inspect with `git diff --stat` and stop.
- [ ] **F.8: Verify Go still builds**
```bash
go build ./...
```
Expected: exits 0. (Comment-only proto changes cannot break the build, but this catches accidental edits that bled into adjacent fields.)
- [ ] **F.9: Commit**
```bash
git add proto/store/instance_setting.proto \
proto/gen/store/instance_setting.pb.go \
web/src/types/proto/store/instance_setting_pb.ts
git commit -m "$(cat <<'EOF'
docs(proto): clarify TranscriptionConfig model and prompt fields
- model: list current OpenAI (whisper-1, gpt-4o-transcribe family,
gpt-4o-transcribe-diarize) and Gemini (2.5-flash, 2.5-pro) examples.
The /audio/transcriptions endpoint is no longer Whisper-only.
- prompt: note that OpenAI Whisper treats it as a soft hint while
Gemini folds it literally into the generation prompt.
Documentation-only; no field changes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Stage G: Update frontend i18n strings
**Files:**
- Modify: `web/src/locales/en.json`
The `AISection.tsx` component already conditionally swaps the model placeholder by provider type and renders the prompt help string. Only the underlying strings need updating.
- [ ] **G.1: Update three strings in `web/src/locales/en.json`**
Find these three keys (they currently live under `setting.ai.*`, around lines 441449):
```json
"transcription-model-placeholder-gemini": "gemini-2.5-flash",
"transcription-model-placeholder-openai": "whisper-1",
"transcription-prompt-help": "Improves spelling of proper nouns and jargon. Whisper limit is roughly 224 tokens.",
```
Replace with:
```json
"transcription-model-placeholder-gemini": "gemini-2.5-flash, gemini-2.5-pro",
"transcription-model-placeholder-openai": "whisper-1, gpt-4o-transcribe, gpt-4o-mini-transcribe, gpt-4o-transcribe-diarize",
"transcription-prompt-help": "Improves spelling of proper nouns and jargon. OpenAI Whisper treats this as a soft hint (Whisper limit is roughly 224 tokens). Gemini treats it as a literal instruction inside the generation prompt.",
```
- [ ] **G.2: Verify the JSON file is still valid**
```bash
python3 -m json.tool web/src/locales/en.json > /dev/null
```
Expected: exits 0, no output.
- [ ] **G.3: Verify the frontend build still succeeds**
```bash
cd web && pnpm build && cd ..
```
(Use `npm run build` if pnpm is not the project's tool — check `web/package.json` for the canonical command.)
Expected: build succeeds with no TypeScript errors.
- [ ] **G.4: Commit**
```bash
git add web/src/locales/en.json
git commit -m "$(cat <<'EOF'
i18n(en): expand transcription model and prompt help text
- Model placeholders list the full set of supported transcription models
for OpenAI (whisper-1 plus the gpt-4o-transcribe family) and Gemini.
- Prompt help text notes the cross-provider semantic difference: Whisper
treats the field as a soft hint, Gemini treats it as a literal
instruction. Mirrors the proto field comments.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
EOF
)"
```
---
## Final validation
After Stage G commits, run a full validation pass:
- [ ] **V.1: Full Go test suite**
```bash
go test ./...
```
Expected: exits 0.
- [ ] **V.2: Full Go build**
```bash
go build ./...
```
Expected: exits 0.
- [ ] **V.3: Frontend build**
```bash
cd web && pnpm build && cd ..
```
Expected: exits 0.
- [ ] **V.4: Smoke-test the Transcribe endpoint manually**
If a local Memos instance is convenient, configure one OpenAI provider and one Gemini provider via the AI settings UI, then upload a short audio file via the voice-input feature for each. The end-to-end behavior should be identical to before this refactor (same transcript text). For Gemini specifically: a deliberately bad audio (e.g., silence) should now produce a clearer error message when `FinishReason != FinishStop`.
- [ ] **V.5: Inspect the commit log**
```bash
git log --oneline -8
```
Expected (most-recent-first):
```
<sha> i18n(en): expand transcription model and prompt help text
<sha> docs(proto): clarify TranscriptionConfig model and prompt fields
<sha> refactor(ai): remove legacy Transcriber implementations
<sha> refactor(ai_service): dispatch transcription by provider type
<sha> feat(ai/audiollm): implement Gemini multimodal audio Model
<sha> feat(ai/stt): implement OpenAI-compatible Transcriber
<sha> feat(ai): scaffold stt and audiollm packages
```
Each commit is independently revertable.