175 lines
4.9 KiB
Go
175 lines
4.9 KiB
Go
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
|
|
}
|