diff --git a/ai/docs/CLS-08-PRUEFPROTOKOLL.md b/ai/docs/CLS-08-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..a80b5f6e --- /dev/null +++ b/ai/docs/CLS-08-PRUEFPROTOKOLL.md @@ -0,0 +1,34 @@ +# CLS-08 – Mehrsprachige Dokumentanalyse – Prüfprotokoll + +Datum: 2026-11-06. Host: Entwicklungshost 192.168.1.204 (Wegwerf-Rolle/DB `cls08_test`, nach Abschluss entfernt). + +## Umsetzung + +- Neues Paket `ai/internal/doclang`: `Registry` mit Stoppwortlisten je Sprache (Deutsch, Englisch eingebaut), `Detect(text)` und `Resolver.Resolve(ctx, tenant, explicitDocLang, text)`. +- Weitere Sprachen ohne Codeänderung: JSON-Datei über `NEXARCH_AI_LANGUAGES_FILE` (`[{"code","name","stopwords"}]`). +- Ausgabesprache je Mandant in Tabelle `ai_tenant_languages` (Migration `0013`), bei jedem Aufruf live gelesen. +- `tagsuggest.Suggester.Language` nimmt die Ausgabesprache in den Prompt auf; bestehende Tags anderer Sprache behalten Vorrang vor neuen. `titlesuggest` übernahm die Sprache bereits als Parameter (CLS-06). + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Dokumentsprache erkannt oder per Parameter vorgegeben | **Erfüllt.** `TestDetect_GermanAndEnglish`, `TestResolve_ExplicitOverridesDetectionAndUnknownFails` (unbekannter Code ergibt `ErrUnknownLanguage`). Unklare Texte ergeben keine Sprache statt einer Fehlzuordnung (`TestDetect_UnclearYieldsNothing`). | +| 2 | Ausgabesprache je Mandant konfigurierbar | **Erfüllt.** `TestStore_PerTenantAndLive` (echte DB, Mandantentrennung), `TestResolve_OutputFollowsTenantOtherwiseDocument`. | +| 3 | Bestehende Tag-Taxonomie wird bei abweichender Dokumentsprache respektiert | **Teilweise belegt.** `TestSuggest_LanguageInPromptAndTaxonomyKept`: Prompt enthält Sprachvorgabe und Vorrang bestehender Tags; englisches Dokument gegen deutschen Tag ergibt die bestehende Schreibweise. Mit Stub-Provider, nicht mit echtem Modell. | +| 4 | Deutsch und Englisch belegt, weitere Sprachen per Konfiguration | **Erfüllt.** `TestRegistry_ExtensibleByJSONWithoutCodeChange` (Französisch per JSON), `TestRegistryFromEnv`, `TestLoadJSON_RejectsBadCode`. | + +## Prüfungen vor der Abnahme + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Testset deutsch/englisch gemessen | **Bestanden, 20/20** (10 de, 10 en), Zielwert 90 %. `TestDetect_SyntheticTestsetAccuracy`. | +| 2 | Wechsel der Ausgabesprache wirkt ohne Neustart | **Bestanden.** In `TestStore_PerTenantAndLive` wird die Sprache gewechselt und sofort neu gelesen. | +| 3 | Regressionstest bestehender CLS-Funktionen | **Bestanden.** Alle bestehenden `tagsuggest`-Tests grün (Unit; die zwei Realtests wurden ohne `TEST_OLLAMA_*` übersprungen). Neues Feld `Language` ist optional, leer ergibt unveränderten Prompt (`TestSuggest_NoLanguageNoInstruction`). | + +## Einschränkungen + +- Das Testset hat derselbe Autor wie die Stoppwortlisten geschrieben; 20/20 ist daher optimistisch. Kurze Texte, Texte mit vielen Zahlen/Namen und gemischtsprachige Dokumente sind nicht gemessen und liefern bei zu wenigen Treffern keine Sprache. +- AK3 ist nur mit Stub-Provider belegt, nicht gegen ein echtes Modell. +- `classify`, `extract` und `correspondentsuggest` nutzen die Sprache noch nicht; keine Verdrahtung in `cmd/ai-app` und kein API-Endpunkt zum Setzen der Mandantensprache. +- Keine Übersetzung von Dokumentinhalten (ausdrücklich nicht Teil der Kachel). diff --git a/ai/internal/doclang/doclang.go b/ai/internal/doclang/doclang.go new file mode 100644 index 00000000..25fb206a --- /dev/null +++ b/ai/internal/doclang/doclang.go @@ -0,0 +1,206 @@ +package doclang + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "regexp" + "strings" + "unicode" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +const EnvLanguagesFile = "NEXARCH_AI_LANGUAGES_FILE" + +const ( + minTokens = 5 + minBestHits = 2 + codePatternRe = `^[a-z]{2,3}$` +) + +var ( + ErrUnknownLanguage = errors.New("doclang: sprache nicht registriert") + ErrInvalidCode = errors.New("doclang: sprachcode muss 2 bis 3 kleinbuchstaben haben") + codePattern = regexp.MustCompile(codePatternRe) +) + +type Language struct { + Code string `json:"code"` + Name string `json:"name"` + Stopwords []string `json:"stopwords"` +} + +type Registry struct { + langs map[string]Language + words map[string]map[string]bool +} + +func DefaultRegistry() *Registry { + r := &Registry{langs: map[string]Language{}, words: map[string]map[string]bool{}} + _ = r.Register(Language{Code: "de", Name: "Deutsch", Stopwords: strings.Fields( + "der die das und ist nicht ein eine mit von zu den für auf dem des auch sich wir sie ich bei nach wird sind oder aus werden wurde über sehr nur noch")}) + _ = r.Register(Language{Code: "en", Name: "English", Stopwords: strings.Fields( + "the and is of to that for with on are this by or from at it not have was were will would which their been you your has had")}) + return r +} + +func (r *Registry) Register(l Language) error { + l.Code = strings.ToLower(strings.TrimSpace(l.Code)) + if !codePattern.MatchString(l.Code) { + return ErrInvalidCode + } + if strings.TrimSpace(l.Name) == "" { + l.Name = l.Code + } + set := make(map[string]bool, len(l.Stopwords)) + for _, w := range l.Stopwords { + if w = strings.ToLower(strings.TrimSpace(w)); w != "" { + set[w] = true + } + } + r.langs[l.Code] = l + r.words[l.Code] = set + return nil +} + +func (r *Registry) Lookup(code string) (Language, bool) { + l, ok := r.langs[strings.ToLower(strings.TrimSpace(code))] + return l, ok +} + +func (r *Registry) LoadJSON(src io.Reader) error { + var langs []Language + if err := json.NewDecoder(src).Decode(&langs); err != nil { + return fmt.Errorf("doclang: sprachdatei lesen: %w", err) + } + for _, l := range langs { + if err := r.Register(l); err != nil { + return fmt.Errorf("doclang: sprache %q: %w", l.Code, err) + } + } + return nil +} + +func RegistryFromEnv() (*Registry, error) { + r := DefaultRegistry() + path := os.Getenv(EnvLanguagesFile) + if path == "" { + return r, nil + } + f, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("doclang: %s: %w", EnvLanguagesFile, err) + } + defer func() { _ = f.Close() }() + if err := r.LoadJSON(f); err != nil { + return nil, err + } + return r, nil +} + +// Detect liefert die wahrscheinlichste registrierte Sprache oder "" bei unklarem Text. +func (r *Registry) Detect(text string) (Language, bool) { + tokens := strings.FieldsFunc(strings.ToLower(text), func(c rune) bool { return !unicode.IsLetter(c) }) + if len(tokens) < minTokens { + return Language{}, false + } + best, second, bestCode := 0, 0, "" + for code, words := range r.words { + hits := 0 + for _, t := range tokens { + if words[t] { + hits++ + } + } + switch { + case hits > best || (hits == best && code < bestCode): + second, best, bestCode = best, hits, code + case hits > second: + second = hits + } + } + if best < minBestHits || best == second { + return Language{}, false + } + return r.langs[bestCode], true +} + +type TenantLanguages interface { + Get(ctx context.Context, tenantSlug string) (string, error) +} + +type Resolved struct { + Document Language + Output Language +} + +type Resolver struct { + Registry *Registry + Tenants TenantLanguages +} + +// Resolve ermittelt Dokument- und Ausgabesprache. Dokumentsprache: explizit, sonst erkannt. +// Ausgabesprache: Mandantenkonfiguration, sonst Dokumentsprache. Leer, wenn beides unbekannt. +func (r *Resolver) Resolve(ctx context.Context, tenantSlug, explicitDocLang, text string) (Resolved, error) { + var out Resolved + if explicitDocLang != "" { + l, ok := r.Registry.Lookup(explicitDocLang) + if !ok { + return Resolved{}, fmt.Errorf("%w: %q", ErrUnknownLanguage, explicitDocLang) + } + out.Document = l + } else if l, ok := r.Registry.Detect(text); ok { + out.Document = l + } + out.Output = out.Document + code, err := r.Tenants.Get(ctx, tenantSlug) + if err != nil { + return Resolved{}, err + } + if l, ok := r.Registry.Lookup(code); ok { + out.Output = l + } + return out, nil +} + +type Store struct { + Pool *pgxpool.Pool +} + +func (s *Store) Set(ctx context.Context, tenantSlug, code string) error { + tenantSlug = strings.TrimSpace(tenantSlug) + code = strings.ToLower(strings.TrimSpace(code)) + if tenantSlug == "" { + return fmt.Errorf("doclang: tenantSlug darf nicht leer sein") + } + if !codePattern.MatchString(code) { + return ErrInvalidCode + } + _, err := s.Pool.Exec(ctx, ` + INSERT INTO ai_tenant_languages (tenant_slug, language, updated_at) + VALUES ($1, $2, now()) + ON CONFLICT (tenant_slug) DO UPDATE SET language = EXCLUDED.language, updated_at = now() + `, tenantSlug, code) + if err != nil { + return fmt.Errorf("doclang: speichern: %w", err) + } + return nil +} + +// Get liefert "" ohne Konfiguration; liest live, Aenderungen wirken ohne Neustart. +func (s *Store) Get(ctx context.Context, tenantSlug string) (string, error) { + var code string + err := s.Pool.QueryRow(ctx, `SELECT language FROM ai_tenant_languages WHERE tenant_slug = $1`, tenantSlug).Scan(&code) + if errors.Is(err, pgx.ErrNoRows) { + return "", nil + } + if err != nil { + return "", fmt.Errorf("doclang: lesen: %w", err) + } + return code, nil +} diff --git a/ai/internal/doclang/doclang_test.go b/ai/internal/doclang/doclang_test.go new file mode 100644 index 00000000..55f9cc97 --- /dev/null +++ b/ai/internal/doclang/doclang_test.go @@ -0,0 +1,202 @@ +package doclang + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/common/testdb" +) + +const ( + deText = "Sehr geehrte Damen und Herren, die Rechnung ist nicht bezahlt und wird von uns mit der Mahnung an Sie gesendet." + enText = "Dear Sirs, the invoice is not paid and will be sent to you by the reminder that we have prepared for your account." + frJSON = `[{"code":"fr","name":"Français","stopwords":["le","la","les","et","est","des","une","pour","dans","avec"]}]` +) + +type fakeTenants map[string]string + +func (f fakeTenants) Get(_ context.Context, tenant string) (string, error) { return f[tenant], nil } + +func TestDetect_GermanAndEnglish(t *testing.T) { + r := DefaultRegistry() + if l, ok := r.Detect(deText); !ok || l.Code != "de" { + t.Fatalf("de: %+v %v", l, ok) + } + if l, ok := r.Detect(enText); !ok || l.Code != "en" { + t.Fatalf("en: %+v %v", l, ok) + } +} + +func TestDetect_UnclearYieldsNothing(t *testing.T) { + r := DefaultRegistry() + for _, txt := range []string{"", "Rechnung 2026", "xyz abc qwe rty uio pas dfg hjk"} { + if l, ok := r.Detect(txt); ok { + t.Errorf("%q: unerwartet %+v", txt, l) + } + } +} + +func TestRegistry_ExtensibleByJSONWithoutCodeChange(t *testing.T) { + r := DefaultRegistry() + if err := r.LoadJSON(strings.NewReader(frJSON)); err != nil { + t.Fatal(err) + } + l, ok := r.Detect("Le contrat est signé et les parties des deux côtés sont dans une situation pour avec") + if !ok || l.Code != "fr" || l.Name != "Français" { + t.Fatalf("fr: %+v %v", l, ok) + } + if l, ok := r.Detect(deText); !ok || l.Code != "de" { + t.Fatalf("de nach erweiterung: %+v %v", l, ok) + } +} + +func TestRegistryFromEnv(t *testing.T) { + path := filepath.Join(t.TempDir(), "langs.json") + if err := os.WriteFile(path, []byte(frJSON), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv(EnvLanguagesFile, path) + r, err := RegistryFromEnv() + if err != nil { + t.Fatal(err) + } + if _, ok := r.Lookup("fr"); !ok { + t.Fatal("fr fehlt") + } + t.Setenv(EnvLanguagesFile, filepath.Join(t.TempDir(), "fehlt.json")) + if _, err := RegistryFromEnv(); err == nil { + t.Fatal("fehler erwartet") + } +} + +func TestLoadJSON_RejectsBadCode(t *testing.T) { + r := DefaultRegistry() + if err := r.LoadJSON(strings.NewReader(`[{"code":"Deutsch","stopwords":[]}]`)); !errors.Is(err, ErrInvalidCode) { + t.Fatalf("err=%v", err) + } +} + +func TestResolve_OutputFollowsTenantOtherwiseDocument(t *testing.T) { + r := &Resolver{Registry: DefaultRegistry(), Tenants: fakeTenants{"acme": "de"}} + got, err := r.Resolve(context.Background(), "acme", "", enText) + if err != nil || got.Document.Code != "en" || got.Output.Code != "de" { + t.Fatalf("%+v %v", got, err) + } + got, _ = r.Resolve(context.Background(), "other", "", enText) + if got.Output.Code != "en" { + t.Fatalf("ohne konfiguration: %+v", got) + } +} + +func TestResolve_ExplicitOverridesDetectionAndUnknownFails(t *testing.T) { + r := &Resolver{Registry: DefaultRegistry(), Tenants: fakeTenants{}} + got, err := r.Resolve(context.Background(), "acme", "de", enText) + if err != nil || got.Document.Code != "de" { + t.Fatalf("%+v %v", got, err) + } + if _, err := r.Resolve(context.Background(), "acme", "xx", enText); !errors.Is(err, ErrUnknownLanguage) { + t.Fatalf("err=%v", err) + } +} + +func TestResolve_UnknownTenantCodeFallsBackToDocument(t *testing.T) { + r := &Resolver{Registry: DefaultRegistry(), Tenants: fakeTenants{"acme": "fr"}} + got, _ := r.Resolve(context.Background(), "acme", "", enText) + if got.Output.Code != "en" { + t.Fatalf("%+v", got) + } +} + +func newStore(t *testing.T) *Store { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen") + } + pool := testdb.Pool(t, dsn) + dir, err := filepath.Abs("../../migrations") + if err != nil { + t.Fatal(err) + } + migrations, err := migrate.Load(dir) + if err != nil { + t.Fatal(err) + } + if _, err := migrate.Up(context.Background(), pool, migrations); err != nil { + t.Fatal(err) + } + return &Store{Pool: pool} +} + +func TestStore_PerTenantAndLive(t *testing.T) { + s := newStore(t) + ctx := context.Background() + if got, err := s.Get(ctx, "acme"); err != nil || got != "" { + t.Fatalf("%q %v", got, err) + } + if err := s.Set(ctx, "acme", " DE "); err != nil { + t.Fatal(err) + } + if err := s.Set(ctx, "other", "en"); err != nil { + t.Fatal(err) + } + if got, _ := s.Get(ctx, "acme"); got != "de" { + t.Fatalf("acme: %q", got) + } + if err := s.Set(ctx, "acme", "en"); err != nil { + t.Fatal(err) + } + if got, _ := s.Get(ctx, "acme"); got != "en" { + t.Fatalf("wechsel ohne neustart: %q", got) + } + if got, _ := s.Get(ctx, "other"); got != "en" { + t.Fatalf("other: %q", got) + } + if err := s.Set(ctx, "acme", "Deutsch"); !errors.Is(err, ErrInvalidCode) { + t.Fatalf("err=%v", err) + } +} + +var testset = []struct{ code, text string }{ + {"de", "Hiermit kündigen wir den Vertrag zum Ende des Quartals und bitten um eine schriftliche Bestätigung."}, + {"de", "Die Rechnung wurde am 15. November versendet und ist innerhalb von 14 Tagen zu bezahlen."}, + {"de", "Wir bedanken uns für Ihre Bestellung und senden Ihnen die Ware in den nächsten Tagen zu."}, + {"de", "Der Mieter ist verpflichtet, die Wohnung bei Auszug in einem sauberen Zustand zu übergeben."}, + {"de", "Sehr geehrte Frau Muster, anbei erhalten Sie die Unterlagen, die Sie angefordert haben."}, + {"de", "Das Angebot gilt nur für Kunden, die sich bis zum Jahresende für den Vertrag entscheiden."}, + {"de", "Aus technischen Gründen wird die Lieferung erst nach dem Feiertag bei Ihnen eintreffen."}, + {"de", "Ich bitte Sie, mir den Termin für die Besichtigung der Räume noch heute mitzuteilen."}, + {"de", "Die Gesellschafter haben in der Versammlung beschlossen, den Gewinn auf neue Rechnung vorzutragen."}, + {"de", "Eine Kopie des Bescheids ist diesem Schreiben beigefügt und wird auch an Ihren Steuerberater gesendet."}, + {"en", "We hereby terminate the contract at the end of the quarter and ask for a written confirmation."}, + {"en", "The invoice has been sent on the fifteenth of November and is payable within fourteen days."}, + {"en", "Thank you for your order, the goods will be shipped to you within the next few days."}, + {"en", "The tenant is obliged to hand over the apartment in a clean condition when moving out."}, + {"en", "Dear Ms Sample, please find attached the documents that you have requested from us."}, + {"en", "This offer is only valid for customers who decide on the contract before the end of the year."}, + {"en", "Due to technical reasons the delivery will not arrive before the public holiday."}, + {"en", "I would like to ask you to let me know the date for the inspection of the premises today."}, + {"en", "The shareholders have resolved at the meeting that the profit will be carried forward to the next year."}, + {"en", "A copy of the decision is attached to this letter and will also be sent to your tax advisor."}, +} + +func TestDetect_SyntheticTestsetAccuracy(t *testing.T) { + r := DefaultRegistry() + hits := 0 + for _, c := range testset { + if l, ok := r.Detect(c.text); ok && l.Code == c.code { + hits++ + } else { + t.Logf("fehlklassifiziert/unklar (%s): %q -> %+v ok=%v", c.code, c.text, l, ok) + } + } + t.Logf("Erkennungsquote: %d/%d", hits, len(testset)) + if hits < len(testset)*9/10 { + t.Errorf("erkennungsquote %d/%d unter 90%%", hits, len(testset)) + } +} diff --git a/ai/internal/tagsuggest/tagsuggest.go b/ai/internal/tagsuggest/tagsuggest.go index 3faf8615..6f89d466 100644 --- a/ai/internal/tagsuggest/tagsuggest.go +++ b/ai/internal/tagsuggest/tagsuggest.go @@ -26,6 +26,8 @@ type Suggestion struct { type Suggester struct { Provider provider.Provider Model string + // Language ist der Anzeigename der Ausgabesprache; leer = keine Vorgabe. + Language string } type modelSuggestion struct { @@ -47,7 +49,7 @@ func (s *Suggester) Suggest(ctx context.Context, existingTags []string, moduleCo return nil, errors.New("tagsuggest: kein provider konfiguriert") } - prompt := buildPrompt(existingTags, moduleContext, documentText) + prompt := buildPrompt(existingTags, moduleContext, s.Language, documentText) resp, err := s.Provider.Chat(ctx, provider.ChatRequest{ Model: s.Model, Messages: []provider.Message{ @@ -63,7 +65,7 @@ func (s *Suggester) Suggest(ctx context.Context, existingTags []string, moduleCo return parseAndDedup(existingTags, resp.Message.Content), nil } -func buildPrompt(existingTags []string, moduleContext, documentText string) string { +func buildPrompt(existingTags []string, moduleContext, language, documentText string) string { var b strings.Builder b.WriteString("Schlage passende Schlagworte (Tags) für das folgende Dokument vor.\n") if moduleContext != "" { @@ -74,6 +76,9 @@ func buildPrompt(existingTags []string, moduleContext, documentText string) stri b.WriteString(strings.Join(existingTags, ", ")) b.WriteString("\n") } + if language != "" { + b.WriteString(fmt.Sprintf("Neue Tags schreibst du in der Sprache %q. Bestehende Tags dürfen auch in einer anderen Sprache vorliegen als das Dokument; ein bedeutungsgleicher bestehender Tag hat immer Vorrang vor einem neuen Tag.\n", language)) + } b.WriteString("Antworte NUR mit einem JSON-Array in diesem Format: ") b.WriteString(`[{"tag": string, "confidence": number zwischen 0 und 1, "reasoning": string}, ...].`) b.WriteString(" Schlage höchstens 8 Tags vor, keine Dopplungen.\n\n") diff --git a/ai/internal/tagsuggest/tagsuggest_test.go b/ai/internal/tagsuggest/tagsuggest_test.go index ac4242f0..268e6b07 100644 --- a/ai/internal/tagsuggest/tagsuggest_test.go +++ b/ai/internal/tagsuggest/tagsuggest_test.go @@ -2,6 +2,7 @@ package tagsuggest import ( "context" + "strings" "testing" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" @@ -125,3 +126,40 @@ func TestSuggest_NoProvider_ReturnsError(t *testing.T) { t.Fatal("erwartet Fehler ohne konfigurierten Provider") } } + +type capturingProvider struct { + stubProvider + prompt string +} + +func (c *capturingProvider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) { + c.prompt = req.Messages[len(req.Messages)-1].Content + return c.stubProvider.Chat(ctx, req) +} + +// CLS-08: Ausgabesprache wird in den Prompt aufgenommen, bestehende Tags in anderer Sprache behalten Vorrang. +func TestSuggest_LanguageInPromptAndTaxonomyKept(t *testing.T) { + p := &capturingProvider{stubProvider: stubProvider{response: `[{"tag":"rechnung","confidence":0.9}]`}} + s := &Suggester{Provider: p, Model: "test", Language: "Deutsch"} + + got, err := s.Suggest(context.Background(), []string{"Rechnung"}, "dms", "Invoice for consulting services") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Tag != "Rechnung" { + t.Fatalf("taxonomie nicht respektiert: %+v", got) + } + if !strings.Contains(p.prompt, `"Deutsch"`) || !strings.Contains(p.prompt, "Vorrang") { + t.Fatalf("sprachvorgabe fehlt im prompt: %s", p.prompt) + } +} + +func TestSuggest_NoLanguageNoInstruction(t *testing.T) { + p := &capturingProvider{stubProvider: stubProvider{response: `[]`}} + if _, err := (&Suggester{Provider: p}).Suggest(context.Background(), nil, "", "x"); err != nil { + t.Fatal(err) + } + if strings.Contains(p.prompt, "Sprache") { + t.Fatalf("unerwartete sprachvorgabe: %s", p.prompt) + } +} diff --git a/ai/migrations/0013_ai_tenant_language.down.sql b/ai/migrations/0013_ai_tenant_language.down.sql new file mode 100644 index 00000000..cf563052 --- /dev/null +++ b/ai/migrations/0013_ai_tenant_language.down.sql @@ -0,0 +1 @@ +DROP TABLE ai_tenant_languages; diff --git a/ai/migrations/0013_ai_tenant_language.up.sql b/ai/migrations/0013_ai_tenant_language.up.sql new file mode 100644 index 00000000..71caa470 --- /dev/null +++ b/ai/migrations/0013_ai_tenant_language.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE ai_tenant_languages ( + tenant_slug TEXT PRIMARY KEY, + language TEXT NOT NULL CHECK (language ~ '^[a-z]{2,3}$'), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +);