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 }