sync: migrate ai-operator to Gitea (2026-08-10)

This commit is contained in:
konturai-ops
2026-08-10 15:26:52 +00:00
commit 53652b95ad
173 changed files with 16676 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
package kb
import "unicode/utf8"
type ChunkerConfig struct{ Target, Min, Max, Overlap int }
func DefaultChunkerConfig() ChunkerConfig {
return ChunkerConfig{Target: 1200, Min: 200, Max: 2000, Overlap: 150}
}
func ChunkDocument(doc Document, cfg ChunkerConfig) []Chunk {
text := BuildChunkText(doc)
if cfg.Target <= 0 {
cfg = DefaultChunkerConfig()
}
rs := []rune(text)
if len(rs) == 0 {
return nil
}
var chunks []Chunk
start := 0
idx := 0
for start < len(rs) {
end := start + cfg.Target
if end > len(rs) {
end = len(rs)
}
if end-start > cfg.Max {
end = start + cfg.Max
}
content := string(rs[start:end])
if !utf8.ValidString(content) {
content = string([]rune(content))
}
chunks = append(chunks, Chunk{ChunkIndex: idx, Language: doc.Language, RegionCode: doc.RegionCode, Title: doc.Title, Content: content, ContentHash: ContentHash(content), CharCount: len([]rune(content)), TokenEstimate: len([]rune(content)) / 4, Metadata: map[string]any{"external_id": doc.ExternalID, "source_uri": doc.SourceURI}})
idx++
if end == len(rs) {
break
}
start = end - cfg.Overlap
if start < 0 {
start = 0
}
}
return chunks
}
+5
View File
@@ -0,0 +1,5 @@
package kb
func citationFor(r SearchResult) Citation {
return Citation{DocumentTitle: r.Title, SourceURI: r.SourceURI, ChunkID: r.ChunkID, RegionCode: r.RegionCode, Language: r.Language, Source: r.Citation.Source}
}
+1
View File
@@ -0,0 +1 @@
package kb
+174
View File
@@ -0,0 +1,174 @@
package kb
import (
"bufio"
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"ai-operator/internal/dialogue/region"
)
var requiredFields = []string{"external_id", "title", "language", "region_code", "status", "content", "source_hash"}
func ParseJSONLFile(path string, allowDisabled bool) ([]Document, []string, error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, err
}
defer f.Close()
return ParseJSONL(f, path, allowDisabled)
}
func ParseJSONL(r io.Reader, sourcePath string, allowDisabled bool) ([]Document, []string, error) {
resolver := region.NewDefaultResolver()
sc := bufio.NewScanner(r)
sc.Buffer(make([]byte, 1024), 10*1024*1024)
var docs []Document
var errs []string
line := 0
for sc.Scan() {
line++
raw := bytes.TrimSpace(sc.Bytes())
if len(raw) == 0 {
continue
}
var m map[string]any
if err := json.Unmarshal(raw, &m); err != nil {
errs = append(errs, fmt.Sprintf("%s:%d invalid json", sourcePath, line))
continue
}
missing := ""
for _, k := range requiredFields {
if strings.TrimSpace(asString(m[k])) == "" {
missing = k
break
}
}
if missing != "" {
errs = append(errs, fmt.Sprintf("%s:%d missing %s", sourcePath, line, missing))
continue
}
lang := asString(m["language"])
if lang != "ru" && lang != "kk" {
errs = append(errs, fmt.Sprintf("%s:%d invalid language", sourcePath, line))
continue
}
regCode := asString(m["region_code"])
if regCode != "global" {
reg, ok := resolver.GetByCode(regCode)
if !ok || (!reg.Enabled && !allowDisabled) {
errs = append(errs, fmt.Sprintf("%s:%d invalid region_code", sourcePath, line))
continue
}
}
status := asString(m["status"])
if status != "draft" && status != "published" && status != "archived" {
errs = append(errs, fmt.Sprintf("%s:%d invalid status", sourcePath, line))
continue
}
metadata := map[string]any{}
if mm, ok := m["metadata"].(map[string]any); ok {
for k, v := range mm {
metadata[k] = v
}
}
known := map[string]bool{"external_id": true, "title": true, "language": true, "region_code": true, "category": true, "status": true, "content": true, "question": true, "short_answer": true, "full_answer": true, "keywords": true, "source": true, "source_type": true, "source_uri": true, "source_hash": true, "metadata": true}
for k, v := range m {
if !known[k] {
metadata[k] = v
}
}
docs = append(docs, Document{ExternalID: asString(m["external_id"]), Title: asString(m["title"]), Language: lang, RegionCode: regCode, Category: defaultString(asString(m["category"]), "general"), SourceType: defaultString(asString(m["source_type"]), "jsonl"), SourceURI: defaultString(asString(m["source_uri"]), filepath.Base(sourcePath)), SourceHash: asString(m["source_hash"]), Status: status, Content: asString(m["content"]), Question: asString(m["question"]), ShortAnswer: asString(m["short_answer"]), FullAnswer: asString(m["full_answer"]), Source: asString(m["source"]), Keywords: nonNilStrings(asStringSlice(m["keywords"])), Metadata: metadata})
}
return docs, errs, sc.Err()
}
func LoadJSONLDocuments(ctx context.Context, root string, allowDisabled bool) ([]Document, []string, error) {
var docs []Document
var errs []string
err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || !strings.HasSuffix(path, ".jsonl") {
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
default:
}
dd, ee, err := ParseJSONLFile(path, allowDisabled)
if err != nil {
return err
}
docs = append(docs, dd...)
errs = append(errs, ee...)
return nil
})
return docs, errs, err
}
func BuildChunkText(doc Document) string {
parts := []string{doc.Title}
if doc.Question != "" {
parts = append(parts, "Вопрос: "+doc.Question)
}
if doc.ShortAnswer != "" {
parts = append(parts, "Краткий ответ: "+doc.ShortAnswer)
}
if doc.FullAnswer != "" {
parts = append(parts, "Расширенное пояснение: "+doc.FullAnswer)
}
if len(doc.Keywords) > 0 {
parts = append(parts, "Ключевые слова: "+strings.Join(doc.Keywords, ", "))
}
if doc.Source != "" {
parts = append(parts, "Источник: "+doc.Source)
}
if doc.Content != "" {
parts = append(parts, doc.Content)
}
return strings.Join(parts, "\n\n")
}
func ContentHash(s string) string { h := sha256.Sum256([]byte(s)); return hex.EncodeToString(h[:]) }
func asString(v any) string {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
return ""
}
func defaultString(v, d string) string {
if v == "" {
return d
}
return v
}
func asStringSlice(v any) []string {
arr, ok := v.([]any)
if !ok {
return nil
}
out := []string{}
for _, x := range arr {
if s := asString(x); s != "" {
out = append(out, s)
}
}
return out
}
func nonNilStrings(v []string) []string {
if v == nil {
return []string{}
}
return v
}
+73
View File
@@ -0,0 +1,73 @@
package kb
import (
"context"
"strings"
"testing"
"ai-operator/internal/config"
"ai-operator/internal/embedding"
)
func TestParseJSONLTolerant(t *testing.T) {
line := `{"external_id":"id1","title":"T","language":"ru","region_code":"global","status":"published","content":"hello","source_hash":"abc","keywords":["a"],"extra":"x","metadata":{"m":1}}`
docs, errs, err := ParseJSONL(strings.NewReader(line), "test.jsonl", false)
if err != nil || len(errs) != 0 || len(docs) != 1 {
t.Fatalf("docs=%d errs=%v err=%v", len(docs), errs, err)
}
if docs[0].Metadata["extra"] != "x" || len(docs[0].Keywords) != 1 {
t.Fatalf("metadata/keywords not parsed: %+v", docs[0])
}
}
func TestParseJSONLMissingRequiredSkipped(t *testing.T) {
docs, errs, err := ParseJSONL(strings.NewReader(`{"title":"T"}`), "bad.jsonl", false)
if err != nil || len(docs) != 0 || len(errs) != 1 {
t.Fatalf("docs=%d errs=%v err=%v", len(docs), errs, err)
}
}
func TestChunkDocumentUTF8(t *testing.T) {
doc := Document{ExternalID: "x", Title: "Заголовок", Language: "ru", RegionCode: "global", Content: strings.Repeat("Қазақша текст. ", 200)}
chunks := ChunkDocument(doc, DefaultChunkerConfig())
if len(chunks) < 2 {
t.Fatalf("expected multiple chunks")
}
for _, ch := range chunks {
if ch.Content == "" || ch.CharCount == 0 || !strings.Contains(ch.Title, "Заголовок") {
t.Fatalf("bad chunk %+v", ch)
}
}
}
func TestServiceValidationNoAnswer(t *testing.T) {
s := NewService(fakeRepo{}, fakeEmbed{}, testKBConfig())
resp, _ := s.Search(context.Background(), SearchRequest{Query: "", Language: "ru", RegionCode: "global"})
if resp.ReasonCode != "query_too_short" {
t.Fatalf("bad empty response %+v", resp)
}
resp, _ = s.Search(context.Background(), SearchRequest{Query: "none", Language: "ru", RegionCode: "global"})
if resp.ReasonCode != "no_relevant_knowledge" {
t.Fatalf("bad no answer %+v", resp)
}
}
type fakeRepo struct{}
func (fakeRepo) Health(context.Context) (Health, error) { return Health{}, nil }
func (fakeRepo) UpsertDocument(context.Context, Document, []Chunk) error { return nil }
func (fakeRepo) Search(context.Context, SearchRequest, []float32) ([]SearchResult, error) {
return nil, nil
}
type fakeEmbed struct{}
func (fakeEmbed) Embed(context.Context, []string) ([]embedding.Vector, error) {
return []embedding.Vector{make(embedding.Vector, 1536)}, nil
}
func (fakeEmbed) Dimensions() int { return 1536 }
func (fakeEmbed) Model() string { return "fake" }
func (fakeEmbed) ProviderName() string { return "fake" }
func testKBConfig() config.KBConfig {
return config.KBConfig{DefaultLimit: 5, MaxLimit: 10, MinScore: 0.2, QueryMaxChars: 1000, CrossLanguageFallback: true}
}
+55
View File
@@ -0,0 +1,55 @@
package kb
import (
"context"
"time"
)
type Document struct {
ID, ExternalID, Title, Language, RegionCode, Category, SourceType, SourceURI, SourceHash, Status string
Content, Question, ShortAnswer, FullAnswer, Source string
Keywords []string
Metadata map[string]any
ValidFrom, ValidTo *time.Time
Priority int
}
type Chunk struct {
ID, DocumentID string
ChunkIndex int
Language, RegionCode, Title, Content, ContentHash string
TokenEstimate, CharCount int
Embedding []float32
EmbeddingModel, EmbeddingProvider string
Metadata map[string]any
}
type SearchRequest struct {
Query, Language, RegionCode, CallID string
Limit int
MinScore float64
IncludeGlobal bool
CrossLanguageFallback bool
}
type Citation struct{ DocumentTitle, SourceURI, ChunkID, RegionCode, Language, Source string }
type SearchResult struct {
DocumentID, ChunkID, Title, Content, Language, SourceLanguage, RegionCode, Category, SourceURI string
Score, VectorScore, KeywordScore float64
CrossLanguageFallback bool
Metadata map[string]any
Citation Citation
}
type IngestResult struct {
DocsSeen, DocsIngested, ChunksCreated, Skipped int
Errors []string
}
type Health struct {
DBReachable, VectorExtension, MigrationsApplied bool
Documents, Chunks int
}
type Repository interface {
Health(ctx context.Context) (Health, error)
UpsertDocument(ctx context.Context, doc Document, chunks []Chunk) error
Search(ctx context.Context, req SearchRequest, queryEmbedding []float32) ([]SearchResult, error)
}
+143
View File
@@ -0,0 +1,143 @@
package kb
import (
"context"
"encoding/json"
"fmt"
"strings"
"ai-operator/internal/embedding"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type PostgresRepository struct{ pool *pgxpool.Pool }
func NewPostgresRepository(pool *pgxpool.Pool) *PostgresRepository {
return &PostgresRepository{pool: pool}
}
func (r *PostgresRepository) Health(ctx context.Context) (Health, error) {
h := Health{}
if err := r.pool.Ping(ctx); err != nil {
return h, err
}
h.DBReachable = true
_ = r.pool.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM pg_extension WHERE extname='vector')`).Scan(&h.VectorExtension)
var mig int
_ = r.pool.QueryRow(ctx, `SELECT count(*) FROM information_schema.tables WHERE table_name IN ('schema_migrations','knowledge_documents','knowledge_chunks','knowledge_ingest_runs','knowledge_search_logs')`).Scan(&mig)
h.MigrationsApplied = mig == 5
_ = r.pool.QueryRow(ctx, `SELECT count(*) FROM knowledge_documents`).Scan(&h.Documents)
_ = r.pool.QueryRow(ctx, `SELECT count(*) FROM knowledge_chunks`).Scan(&h.Chunks)
return h, nil
}
func (r *PostgresRepository) UpsertDocument(ctx context.Context, doc Document, chunks []Chunk) error {
tx, err := r.pool.Begin(ctx)
if err != nil {
return err
}
defer tx.Rollback(ctx)
docID := stableUUID(doc.ExternalID)
meta, _ := json.Marshal(doc.Metadata)
_, err = tx.Exec(ctx, `INSERT INTO knowledge_documents(id,external_id,title,language,region_code,category,source_type,source_uri,source_hash,status,content,question,short_answer,full_answer,keywords,source,metadata,priority,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,now()) ON CONFLICT(external_id) DO UPDATE SET title=EXCLUDED.title, language=EXCLUDED.language, region_code=EXCLUDED.region_code, category=EXCLUDED.category, source_type=EXCLUDED.source_type, source_uri=EXCLUDED.source_uri, source_hash=EXCLUDED.source_hash, status=EXCLUDED.status, content=EXCLUDED.content, question=EXCLUDED.question, short_answer=EXCLUDED.short_answer, full_answer=EXCLUDED.full_answer, keywords=EXCLUDED.keywords, source=EXCLUDED.source, metadata=EXCLUDED.metadata, priority=EXCLUDED.priority, updated_at=now()`, docID, doc.ExternalID, doc.Title, doc.Language, doc.RegionCode, doc.Category, doc.SourceType, doc.SourceURI, doc.SourceHash, doc.Status, doc.Content, nullable(doc.Question), nullable(doc.ShortAnswer), nullable(doc.FullAnswer), nonNilKeywords(doc.Keywords), nullable(doc.Source), meta, doc.Priority)
if err != nil {
return err
}
if _, err = tx.Exec(ctx, `DELETE FROM knowledge_chunks WHERE document_id=$1`, docID); err != nil {
return err
}
for _, ch := range chunks {
chID := stableUUID(fmt.Sprintf("%s:%d:%s", doc.ExternalID, ch.ChunkIndex, ch.ContentHash))
cm, _ := json.Marshal(ch.Metadata)
_, err = tx.Exec(ctx, `INSERT INTO knowledge_chunks(id,document_id,chunk_index,language,region_code,title,content,content_hash,token_estimate,char_count,embedding,embedding_model,embedding_provider,metadata,tsv) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::vector,$12,$13,$14,to_tsvector('simple',$7))`, chID, docID, ch.ChunkIndex, ch.Language, ch.RegionCode, ch.Title, ch.Content, ch.ContentHash, ch.TokenEstimate, ch.CharCount, embedding.VectorLiteral(ch.Embedding), ch.EmbeddingModel, ch.EmbeddingProvider, cm)
if err != nil {
return err
}
}
return tx.Commit(ctx)
}
func (r *PostgresRepository) Search(ctx context.Context, req SearchRequest, queryEmbedding []float32) ([]SearchResult, error) {
limit := req.Limit
if limit <= 0 {
limit = 5
}
if limit > 10 {
limit = 10
}
langs := []string{req.Language}
regions := []string{req.RegionCode}
if req.IncludeGlobal {
regions = append(regions, "global")
}
vec := embedding.VectorLiteral(queryEmbedding)
rows, err := r.pool.Query(ctx, `WITH q AS (SELECT plainto_tsquery('simple', $1) query) SELECT d.id::text, c.id::text, c.title, c.content, c.language, c.region_code, d.category, coalesce(d.source_uri,''), coalesce(d.source,''), (1 - ((c.embedding <=> $2::vector)/2.0))::float8 AS vector_score, ts_rank_cd(c.tsv, q.query)::float8 AS keyword_score, CASE WHEN c.region_code=$3 THEN 1.0 ELSE 0.6 END AS region_boost FROM knowledge_chunks c JOIN knowledge_documents d ON d.id=c.document_id, q WHERE d.status='published' AND c.language=ANY($4) AND c.region_code=ANY($5) AND (d.valid_from IS NULL OR d.valid_from<=now()) AND (d.valid_to IS NULL OR d.valid_to>=now()) AND (c.tsv @@ q.query OR c.content ILIKE '%' || $1 || '%' OR c.embedding IS NOT NULL) ORDER BY ((0.65*(1-((c.embedding <=> $2::vector)/2.0))) + (0.25*ts_rank_cd(c.tsv,q.query)) + (0.10*CASE WHEN c.region_code=$3 THEN 1.0 ELSE 0.6 END)) DESC LIMIT $6`, sanitizeQuery(req.Query), vec, req.RegionCode, langs, regions, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []SearchResult
for rows.Next() {
var r0 SearchResult
var source string
var v, k, boost float64
if err := rows.Scan(&r0.DocumentID, &r0.ChunkID, &r0.Title, &r0.Content, &r0.Language, &r0.RegionCode, &r0.Category, &r0.SourceURI, &source, &v, &k, &boost); err != nil {
return nil, err
}
r0.SourceLanguage = r0.Language
r0.VectorScore = v
r0.KeywordScore = k
r0.Score = 0.65*v + 0.25*k + 0.10*boost
r0.Citation = Citation{DocumentTitle: r0.Title, SourceURI: r0.SourceURI, ChunkID: r0.ChunkID, RegionCode: r0.RegionCode, Language: r0.Language, Source: source}
if r0.Score >= req.MinScore {
out = append(out, r0)
}
}
_ = r.logSearch(ctx, req, out, false)
return out, rows.Err()
}
func (r *PostgresRepository) logSearch(ctx context.Context, req SearchRequest, results []SearchResult, fallback bool) error {
top := 0.0
if len(results) > 0 {
top = results[0].Score
}
_, err := r.pool.Exec(ctx, `INSERT INTO knowledge_search_logs(id,call_id,query,language,region_code,result_count,top_score,cross_language_fallback_used) VALUES($1,$2,$3,$4,$5,$6,$7,$8)`, stableUUID(fmt.Sprintf("%s:%s:%d", req.CallID, req.Query, len(results))), nullable(req.CallID), truncate(req.Query, 1000), req.Language, req.RegionCode, len(results), top, fallback)
return err
}
func nullable(s string) any {
if strings.TrimSpace(s) == "" {
return nil
}
return s
}
func sanitizeQuery(s string) string {
return strings.Map(func(r rune) rune {
if r < 32 {
return -1
}
return r
}, truncate(strings.TrimSpace(s), 1000))
}
func truncate(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
func stableUUID(s string) string {
h := ContentHash(s)
return fmt.Sprintf("%s-%s-%s-%s-%s", h[0:8], h[8:12], h[12:16], h[16:20], h[20:32])
}
var _ pgx.Tx
func nonNilKeywords(v []string) []string {
if v == nil {
return []string{}
}
return v
}
+1
View File
@@ -0,0 +1 @@
package kb
+1
View File
@@ -0,0 +1 @@
package kb
+126
View File
@@ -0,0 +1,126 @@
package kb
import (
"context"
"fmt"
"strings"
"ai-operator/internal/config"
"ai-operator/internal/embedding"
)
type Service struct {
repo Repository
embed embedding.Provider
cfg config.KBConfig
}
func NewService(repo Repository, embed embedding.Provider, cfg config.KBConfig) *Service {
return &Service{repo: repo, embed: embed, cfg: cfg}
}
type SearchResponse struct {
OK bool
ReasonCode, MessageKey string
Results []SearchResult
Citations []Citation
CrossLanguageFallbackUsed bool
}
func (s *Service) Search(ctx context.Context, req SearchRequest) (SearchResponse, error) {
q := strings.TrimSpace(req.Query)
if q == "" {
return SearchResponse{OK: false, ReasonCode: "query_too_short", MessageKey: "knowledge.query_too_short"}, nil
}
if s.cfg.QueryMaxChars > 0 && len([]rune(q)) > s.cfg.QueryMaxChars {
return SearchResponse{OK: false, ReasonCode: "query_too_long", MessageKey: "knowledge.query_too_long"}, nil
}
if req.Language != "ru" && req.Language != "kk" {
return SearchResponse{OK: false, ReasonCode: "language_required", MessageKey: "knowledge.search_denied_language"}, nil
}
if req.RegionCode == "" {
return SearchResponse{OK: false, ReasonCode: "region_required", MessageKey: "knowledge.search_denied_region"}, nil
}
if req.Limit <= 0 {
req.Limit = s.cfg.DefaultLimit
}
if req.Limit <= 0 {
req.Limit = 5
}
if s.cfg.MaxLimit > 0 && req.Limit > s.cfg.MaxLimit {
req.Limit = s.cfg.MaxLimit
}
if req.MinScore == 0 {
req.MinScore = s.cfg.MinScore
}
emb, err := s.embed.Embed(ctx, []string{q})
if err != nil {
return SearchResponse{OK: false, ReasonCode: "knowledge_base_unavailable", MessageKey: "knowledge.unavailable"}, err
}
req.Query = q
req.IncludeGlobal = true
results, err := s.repo.Search(ctx, req, emb[0])
if err != nil {
return SearchResponse{OK: false, ReasonCode: "knowledge_base_unavailable", MessageKey: "knowledge.unavailable"}, err
}
fallback := false
if len(results) == 0 && req.Language == "kk" && (req.CrossLanguageFallback || s.cfg.CrossLanguageFallback) {
ruReq := req
ruReq.Language = "ru"
results, err = s.repo.Search(ctx, ruReq, emb[0])
if err != nil {
return SearchResponse{OK: false, ReasonCode: "knowledge_base_unavailable", MessageKey: "knowledge.unavailable"}, err
}
if len(results) > 0 {
fallback = true
for i := range results {
results[i].CrossLanguageFallback = true
results[i].SourceLanguage = "ru"
}
}
}
if len(results) == 0 {
return SearchResponse{OK: false, ReasonCode: "no_relevant_knowledge", MessageKey: "knowledge.no_answer"}, nil
}
cites := make([]Citation, 0, len(results))
for _, r := range results {
cites = append(cites, r.Citation)
}
return SearchResponse{OK: true, ReasonCode: "ok", MessageKey: "knowledge.results_found", Results: results, Citations: cites, CrossLanguageFallbackUsed: fallback}, nil
}
func (s *Service) Health(ctx context.Context) (Health, error) { return s.repo.Health(ctx) }
func (s *Service) IngestJSONL(ctx context.Context, path string, allowDisabled bool) (IngestResult, error) {
docs, parseErrs, err := LoadJSONLDocuments(ctx, path, allowDisabled)
if err != nil {
return IngestResult{}, err
}
res := IngestResult{DocsSeen: len(docs) + len(parseErrs), Errors: parseErrs}
for _, doc := range docs {
chunks := ChunkDocument(doc, DefaultChunkerConfig())
texts := make([]string, len(chunks))
for i, ch := range chunks {
texts[i] = ch.Content
}
vecs, err := s.embed.Embed(ctx, texts)
if err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: embedding failed", doc.ExternalID))
res.Skipped++
continue
}
for i := range chunks {
chunks[i].Embedding = vecs[i]
chunks[i].EmbeddingModel = s.embed.Model()
chunks[i].EmbeddingProvider = s.embed.ProviderName()
}
if err := s.repo.UpsertDocument(ctx, doc, chunks); err != nil {
res.Errors = append(res.Errors, fmt.Sprintf("%s: %v", doc.ExternalID, err))
res.Skipped++
continue
}
res.DocsIngested++
res.ChunksCreated += len(chunks)
}
return res, nil
}