AI-Label hinzugefügt
All checks were successful
release-tag / release-image (push) Successful in 1m41s
All checks were successful
release-tag / release-image (push) Successful in 1m41s
This commit is contained in:
@@ -225,6 +225,7 @@ COMMUNICATION_STYLE=formal
|
||||
COMMUNICATION_SALUTATION=Guten Tag,
|
||||
COMMUNICATION_CLOSING=Mit freundlichen Grüßen
|
||||
COMMUNICATION_SIGNATURE=IT-Service
|
||||
AI_CONTENT_LABEL_ENABLED=true
|
||||
```
|
||||
|
||||
Ein Auto-Reply ist nur erlaubt, wenn `language` und `communication_style` des freigegebenen Knowledge-Dokuments exakt zur aktiven Policy passen. Das Feld `answer` enthält nur den fachlich freigegebenen Nachrichtentext; Anrede, Grußformel und Signatur werden von der Go-Policy zentral ergänzt. Damit kann das Modell diese Kommunikationsvorgaben nicht überschreiben.
|
||||
@@ -538,3 +539,8 @@ Index-Modi:
|
||||
`/api/status` und das Dashboard zeigen unter anderem Snapshot-Zeitpunkt, letzten Delta-Scan, geänderte/gelöschte Dateien, wiederverwendete Vektoren, Embedding-Batchgröße und Scanintervall. Das komplette Vektorindex-Map wird bei einer Suche nicht mehr pro Ticket kopiert; Suchläufe lesen den warmen Index direkt unter einem Read-Lock.
|
||||
|
||||
Beim allerersten Aufbau ohne Snapshot startet das Dashboard weiterhin sofort und zeigt Scan-/Embedding-Fortschritt. Die Ticketverarbeitung wartet in diesem Fall, bis der erste konsistente Index fertig ist.
|
||||
|
||||
|
||||
### KI-Kennzeichnung automatischer Antworten
|
||||
|
||||
Mit `AI_CONTENT_LABEL_ENABLED=true` wird der konfigurierte TrustedNet-Kennzeichnungsblock unveraendert an den Anfang jeder automatisch vom Agenten ausgewaehlten Antwort gesetzt. Da der Badge HTML verwendet, werden auch Plaintext-KB-Antworten fuer den Versand sicher nach HTML escaped.
|
||||
|
||||
@@ -182,3 +182,8 @@ Normaler Neustart in `incremental`:
|
||||
6. Geänderten Snapshot atomar ersetzen.
|
||||
|
||||
`KNOWLEDGE_INDEX_MODE=rebuild` erzwingt einen vollständigen Quellen-Scan. `readonly` verwendet ausschließlich den vorhandenen Snapshot und führt keine lokalen Delta-Scans aus.
|
||||
|
||||
|
||||
## KI-Kennzeichnung
|
||||
|
||||
Automatische Antworten tragen standardmaessig den TrustedNet-Kennzeichnungsblock am Anfang. Zum expliziten Aktivieren/Deaktivieren: `AI_CONTENT_LABEL_ENABLED=true|false`.
|
||||
|
||||
@@ -56,7 +56,7 @@ type Service struct {
|
||||
}
|
||||
|
||||
func New(cfg config.Config, g GLPI, ai AI, k *knowledge.Store, l *learning.Store, s *state.Store, q *queue.Queue, m *metrics.Metrics, contextCollector ContextCollector) *Service {
|
||||
return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, learning: l, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeRetrievalFloor, cfg.KnowledgeEvidenceRetrievalWeight, cfg.KnowledgeEvidenceAIWeight, cfg.KnowledgeEvidenceCategoryWeight, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)}
|
||||
return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, learning: l, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeRetrievalFloor, cfg.KnowledgeEvidenceRetrievalWeight, cfg.KnowledgeEvidenceAIWeight, cfg.KnowledgeEvidenceCategoryWeight, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.AIContentLabelEnabled, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)}
|
||||
}
|
||||
func (s *Service) Queue() *queue.Queue { return s.q }
|
||||
func (s *Service) Start(ctx context.Context) {
|
||||
|
||||
@@ -81,7 +81,7 @@ func newTestService(t *testing.T, g *fakeGLPI, d model.Decision, autoReply bool)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := config.Config{DryRun: false, AutoCategory: true, AutoReply: autoReply, CategoryConfidence: .9, ReplyConfidence: .9, KnowledgeMinScore: 0, KnowledgeTopK: 1, CategoryPromptLimit: 20, Workers: 1, GLPIAllowedStatusIDs: []int64{1}, KnowledgeAllowedSources: []string{"internal-kb"}, KnowledgeAutoReplySources: []string{"internal-kb"}, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", CommunicationSalutation: "Guten Tag,", CommunicationClosing: "Mit freundlichen Grüßen", CommunicationSignature: "IT-Service"}
|
||||
cfg := config.Config{DryRun: false, AutoCategory: true, AutoReply: autoReply, CategoryConfidence: .9, ReplyConfidence: .9, KnowledgeMinScore: 0, KnowledgeTopK: 1, CategoryPromptLimit: 20, Workers: 1, GLPIAllowedStatusIDs: []int64{1}, KnowledgeAllowedSources: []string{"internal-kb"}, KnowledgeAutoReplySources: []string{"internal-kb"}, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", CommunicationSalutation: "Guten Tag,", CommunicationClosing: "Mit freundlichen Grüßen", CommunicationSignature: "IT-Service", AIContentLabelEnabled: true}
|
||||
return New(cfg, g, fakeAI{d: d}, k, nil, st, queue.New(8), metrics.New(), nil)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"github.com/example/glpi-ai-agent/internal/model"
|
||||
)
|
||||
|
||||
const AIContentLabelHTML = `<a href="https://ai.trustednet.eu/declaration?preset=full&component=text&extent=full&activities=generation&review=expert&lang=de&assurance=selfDeclared&subject=https%3A%2F%2Fglpi.hilden.de&substantialHumanReview=true&editorialResponsibilityConfirmed=true&responsible=Stadt+Hilden&responsibleUrl=https%3A%2F%2Fwww.hilden.de%2Fde%2Fservice%2Fimpressum-datenschutz&customDescription=Die+KI+hat+den+Inhalt+auf+Basis+des+Ticket-Inhaltes+ausgew%C3%A4hlt.+Der+Inhalt+wurde+sachlich+und+fachlich+von+einem+Menschen+erzeugt.&badgeLabel=KI-Beitrag&badgeMessage=Die+KI+hat+die+Antwort+ausgew%C3%A4hlt%2C+jedoch+nicht+verfasst.&leftColor=%23036715&rightColor=%23535454"><img src="https://ai.trustednet.eu/v1/badge.svg?preset=full&component=text&extent=full&activities=generation&review=expert&lang=de&assurance=selfDeclared&subject=https%3A%2F%2Fglpi.hilden.de&substantialHumanReview=true&editorialResponsibilityConfirmed=true&responsible=Stadt+Hilden&responsibleUrl=https%3A%2F%2Fwww.hilden.de%2Fde%2Fservice%2Fimpressum-datenschutz&customDescription=Die+KI+hat+den+Inhalt+auf+Basis+des+Ticket-Inhaltes+ausgew%C3%A4hlt.+Der+Inhalt+wurde+sachlich+und+fachlich+von+einem+Menschen+erzeugt.&badgeLabel=KI-Beitrag&badgeMessage=Die+KI+hat+die+Antwort+ausgew%C3%A4hlt%2C+jedoch+nicht+verfasst.&leftColor=%23036715&rightColor=%23535454&theme=color&link=https%3A%2F%2Fai.trustednet.eu%2Fdeclaration%3Fpreset%3Dfull%26component%3Dtext%26extent%3Dfull%26activities%3Dgeneration%26review%3Dexpert%26lang%3Dde%26assurance%3DselfDeclared%26subject%3Dhttps%253A%252F%252Fglpi.hilden.de%26substantialHumanReview%3Dtrue%26editorialResponsibilityConfirmed%3Dtrue%26responsible%3DStadt%2BHilden%26responsibleUrl%3Dhttps%253A%252F%252Fwww.hilden.de%252Fde%252Fservice%252Fimpressum-datenschutz%26customDescription%3DDie%2BKI%2Bhat%2Bden%2BInhalt%2Bauf%2BBasis%2Bdes%2BTicket-Inhaltes%2Bausgew%25C3%25A4hlt.%2BDer%2BInhalt%2Bwurde%2Bsachlich%2Bund%2Bfachlich%2Bvon%2Beinem%2BMenschen%2Berzeugt.%26badgeLabel%3DKI-Beitrag%26badgeMessage%3DDie%2BKI%2Bhat%2Bdie%2BAntwort%2Bausgew%25C3%25A4hlt%252C%2Bjedoch%2Bnicht%2Bverfasst.%26leftColor%3D%2523036715%26rightColor%3D%2523535454" alt="UCNG"></a>`
|
||||
|
||||
type Policy struct {
|
||||
AutoCategory, AutoReply bool
|
||||
CategoryConfidence, ReplyConfidence, KnowledgeMinScore float64
|
||||
@@ -18,9 +20,10 @@ type Policy struct {
|
||||
CommunicationSignature string
|
||||
BlockReplyOnContextError, BlockReplyOnIncident bool
|
||||
ContextRelevanceMinScore float64
|
||||
AIContentLabelEnabled bool
|
||||
}
|
||||
|
||||
func NewPolicy(autoCategory, autoReply bool, categoryConfidence, replyConfidence, knowledgeMinScore, knowledgeRetrievalFloor, evidenceRetrievalWeight, evidenceAIWeight, evidenceCategoryWeight float64, allowedSources, autoReplySources []string, language, style, salutation, closing, signature string, blockReplyOnContextError, blockReplyOnIncident bool, contextRelevanceMinScore float64) Policy {
|
||||
func NewPolicy(autoCategory, autoReply bool, categoryConfidence, replyConfidence, knowledgeMinScore, knowledgeRetrievalFloor, evidenceRetrievalWeight, evidenceAIWeight, evidenceCategoryWeight float64, allowedSources, autoReplySources []string, language, style, salutation, closing, signature string, aiContentLabelEnabled, blockReplyOnContextError, blockReplyOnIncident bool, contextRelevanceMinScore float64) Policy {
|
||||
if evidenceRetrievalWeight+evidenceAIWeight+evidenceCategoryWeight <= 0 {
|
||||
evidenceRetrievalWeight, evidenceAIWeight, evidenceCategoryWeight = .45, .35, .20
|
||||
}
|
||||
@@ -42,6 +45,7 @@ func NewPolicy(autoCategory, autoReply bool, categoryConfidence, replyConfidence
|
||||
CommunicationSalutation: strings.TrimSpace(salutation),
|
||||
CommunicationClosing: strings.TrimSpace(closing),
|
||||
CommunicationSignature: strings.TrimSpace(signature),
|
||||
AIContentLabelEnabled: aiContentLabelEnabled,
|
||||
BlockReplyOnContextError: blockReplyOnContextError,
|
||||
BlockReplyOnIncident: blockReplyOnIncident,
|
||||
ContextRelevanceMinScore: contextRelevanceMinScore,
|
||||
@@ -202,7 +206,16 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
|
||||
}
|
||||
}
|
||||
res.Reply = true
|
||||
if strings.TrimSpace(hit.Doc.AnswerHTML) != "" {
|
||||
if p.AIContentLabelEnabled {
|
||||
// The disclosure is HTML, therefore every AI-selected automatic reply is
|
||||
// sent as HTML. Plain-text KB answers are escaped before wrapping.
|
||||
if strings.TrimSpace(hit.Doc.AnswerHTML) != "" {
|
||||
res.ReplyText = p.formatRichReply(hit.Doc.AnswerHTML)
|
||||
} else {
|
||||
res.ReplyText = p.formatRichReply(p.plainTextToHTML(hit.Doc.Answer))
|
||||
}
|
||||
res.ReplyIsHTML = true
|
||||
} else if strings.TrimSpace(hit.Doc.AnswerHTML) != "" {
|
||||
res.ReplyText = p.formatRichReply(hit.Doc.AnswerHTML)
|
||||
res.ReplyIsHTML = true
|
||||
} else {
|
||||
@@ -244,7 +257,12 @@ func (p Policy) formatReply(body string) string {
|
||||
}
|
||||
|
||||
func (p Policy) formatRichReply(bodyHTML string) string {
|
||||
parts := make([]string, 0, 3)
|
||||
parts := make([]string, 0, 4)
|
||||
if p.AIContentLabelEnabled {
|
||||
// Keep this block byte-for-byte unchanged. It is the externally defined
|
||||
// declaration that must be the first content in every AI-selected reply.
|
||||
parts = append(parts, AIContentLabelHTML)
|
||||
}
|
||||
if strings.TrimSpace(p.CommunicationSalutation) != "" {
|
||||
parts = append(parts, "<p>"+html.EscapeString(strings.TrimSpace(p.CommunicationSalutation))+"</p>")
|
||||
}
|
||||
@@ -260,6 +278,25 @@ func (p Policy) formatRichReply(bodyHTML string) string {
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func (p Policy) plainTextToHTML(body string) string {
|
||||
normalized := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(body), "\r\n", "\n"), "\r", "\n")
|
||||
if normalized == "" {
|
||||
return ""
|
||||
}
|
||||
paragraphs := strings.Split(normalized, "\\n\\n")
|
||||
out := make([]string, 0, len(paragraphs))
|
||||
for _, paragraph := range paragraphs {
|
||||
paragraph = strings.TrimSpace(paragraph)
|
||||
if paragraph == "" {
|
||||
continue
|
||||
}
|
||||
escaped := html.EscapeString(paragraph)
|
||||
escaped = strings.ReplaceAll(escaped, "\n", "<br>")
|
||||
out = append(out, "<p>"+escaped+"</p>")
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
func sourceSet(values []string) map[string]struct{} {
|
||||
out := make(map[string]struct{}, len(values))
|
||||
for _, v := range values {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
func productionTestPolicy() Policy {
|
||||
return NewPolicy(true, true, .9, .97, .88, .30, .45, .35, .20, []string{"internal-kb", "vendor-docs"}, []string{"internal-kb"}, "de-DE", "formal", "Guten Tag,", "Mit freundlichen Grüßen", "IT-Service", true, true, .2)
|
||||
return NewPolicy(true, true, .9, .97, .88, .30, .45, .35, .20, []string{"internal-kb", "vendor-docs"}, []string{"internal-kb"}, "de-DE", "formal", "Guten Tag,", "Mit freundlichen Grüßen", "IT-Service", true, true, true, .2)
|
||||
}
|
||||
|
||||
func approvedHit(source, language, style string) []model.KnowledgeHit {
|
||||
@@ -33,6 +33,12 @@ func TestPolicyAutoReplyUsesApprovedKnowledge(t *testing.T) {
|
||||
if !r.Reply || !r.ChangeCategory {
|
||||
t.Fatalf("unexpected result: %+v", r)
|
||||
}
|
||||
if !r.ReplyIsHTML {
|
||||
t.Fatalf("AI-labelled reply must be HTML: %+v", r)
|
||||
}
|
||||
if !strings.HasPrefix(r.ReplyText, AIContentLabelHTML) {
|
||||
t.Fatalf("AI content label is not the exact first content: %s", r.ReplyText)
|
||||
}
|
||||
for _, expected := range []string{"Guten Tag,", "Bitte starten Sie", "Mit freundlichen Grüßen", "IT-Service"} {
|
||||
if !strings.Contains(r.ReplyText, expected) {
|
||||
t.Fatalf("reply missing %q: %q", expected, r.ReplyText)
|
||||
@@ -92,7 +98,7 @@ func TestPolicyRejectsUnknownCategoryWithoutFailingRun(t *testing.T) {
|
||||
var d model.Decision
|
||||
d.Category.ID = 99
|
||||
d.Category.Confidence = 1
|
||||
p := NewPolicy(true, false, .9, .9, .8, .30, .45, .35, .20, []string{"internal-kb"}, nil, "de-DE", "formal", "", "", "", true, true, .2)
|
||||
p := NewPolicy(true, false, .9, .9, .8, .30, .45, .35, .20, []string{"internal-kb"}, nil, "de-DE", "formal", "", "", "", true, true, true, .2)
|
||||
r, err := p.Evaluate(model.Ticket{CategoryID: 1}, d, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -103,7 +109,7 @@ func TestPolicyRejectsUnknownCategoryWithoutFailingRun(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPolicyCategoryDecisionIsDeterministic(t *testing.T) {
|
||||
p := NewPolicy(true, false, .9, .9, .8, .30, .45, .35, .20, []string{"internal-kb"}, nil, "de-DE", "formal", "", "", "", true, true, .2)
|
||||
p := NewPolicy(true, false, .9, .9, .8, .30, .45, .35, .20, []string{"internal-kb"}, nil, "de-DE", "formal", "", "", "", true, true, true, .2)
|
||||
var d model.Decision
|
||||
d.Category.ID = 2
|
||||
d.Category.Confidence = .89
|
||||
@@ -148,7 +154,7 @@ func TestPolicyBlocksAutoReplyOnIncompleteContext(t *testing.T) {
|
||||
|
||||
func TestPolicyUsesTwoStageEvidenceForShortButUnambiguousTicket(t *testing.T) {
|
||||
p := NewPolicy(true, true, .70, .70, .70, .30, .45, .35, .20,
|
||||
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "", "", "", true, true, .2)
|
||||
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "", "", "", true, true, true, .2)
|
||||
d := replyDecision()
|
||||
d.Reply.Confidence = .95
|
||||
hits := []model.KnowledgeHit{{
|
||||
@@ -170,7 +176,7 @@ func TestPolicyUsesTwoStageEvidenceForShortButUnambiguousTicket(t *testing.T) {
|
||||
|
||||
func TestPolicyStillRejectsWeakRetrievalEvenWithHighAIConfidence(t *testing.T) {
|
||||
p := NewPolicy(true, true, .70, .70, .70, .30, .45, .35, .20,
|
||||
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "", "", "", true, true, .2)
|
||||
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "", "", "", true, true, true, .2)
|
||||
d := replyDecision()
|
||||
d.Reply.Confidence = .99
|
||||
hits := []model.KnowledgeHit{{
|
||||
@@ -185,3 +191,32 @@ func TestPolicyStillRejectsWeakRetrievalEvenWithHighAIConfidence(t *testing.T) {
|
||||
t.Fatalf("weak retrieval must remain blocked: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyAIContentLabelEscapesPlainTextKnowledge(t *testing.T) {
|
||||
p := productionTestPolicy()
|
||||
d := replyDecision()
|
||||
hits := approvedHit("internal-kb", "de-DE", "formal")
|
||||
hits[0].Doc.Answer = "Bitte <script>alert('x')</script> prüfen.\\nZweite Zeile."
|
||||
r, err := p.Evaluate(model.Ticket{CategoryID: 2}, d, []model.Category{{ID: 2}}, hits, model.ContextSnapshot{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !r.Reply || !r.ReplyIsHTML || !strings.HasPrefix(r.ReplyText, AIContentLabelHTML) {
|
||||
t.Fatalf("unexpected labelled reply: %+v", r)
|
||||
}
|
||||
if strings.Contains(r.ReplyText, "<script>") || !strings.Contains(r.ReplyText, "<script>") {
|
||||
t.Fatalf("plain-text KB was not safely escaped: %s", r.ReplyText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyCanDisableAIContentLabel(t *testing.T) {
|
||||
p := NewPolicy(true, true, .9, .97, .88, .30, .45, .35, .20,
|
||||
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "Guten Tag,", "Mit freundlichen Grüßen", "IT-Service", false, true, true, .2)
|
||||
r, err := p.Evaluate(model.Ticket{CategoryID: 1}, replyDecision(), []model.Category{{ID: 1}, {ID: 2}}, approvedHit("internal-kb", "de-DE", "formal"), model.ContextSnapshot{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.ReplyIsHTML || strings.Contains(r.ReplyText, AIContentLabelHTML) {
|
||||
t.Fatalf("disabled AI content label must preserve plain reply behaviour: %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,7 @@ type Config struct {
|
||||
CommunicationSalutation string
|
||||
CommunicationClosing string
|
||||
CommunicationSignature string
|
||||
AIContentLabelEnabled bool
|
||||
|
||||
AutoCategory bool
|
||||
AutoReply bool
|
||||
@@ -172,7 +173,7 @@ func Load() (Config, error) {
|
||||
KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"),
|
||||
KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"),
|
||||
KnowledgeWebEditEnabled: envBool("KNOWLEDGE_WEB_EDIT_ENABLED", false),
|
||||
KnowledgeCategoryMode: strings.ToLower(env("KNOWLEDGE_CATEGORY_MODE", "unscoped")),
|
||||
KnowledgeCategoryMode: envNormalizedLower("KNOWLEDGE_CATEGORY_MODE", "unscoped"),
|
||||
KnowledgeCategoryMapFile: strings.TrimSpace(os.Getenv("KNOWLEDGE_CATEGORY_MAP_FILE")),
|
||||
KnowledgeIgnoreGlobs: envStringListPreserveCase("KNOWLEDGE_IGNORE_GLOBS", ""),
|
||||
KnowledgeSemanticWeight: envFloat("KNOWLEDGE_WEIGHT_SEMANTIC", 0.45),
|
||||
@@ -180,12 +181,12 @@ func Load() (Config, error) {
|
||||
KnowledgeLexicalWeight: envFloat("KNOWLEDGE_WEIGHT_LEXICAL", 0.20),
|
||||
KnowledgeKeywordWeight: envFloat("KNOWLEDGE_WEIGHT_KEYWORDS", 0.075),
|
||||
KnowledgeCategoryWeight: envFloat("KNOWLEDGE_WEIGHT_CATEGORY", 0.075),
|
||||
KnowledgeEmbeddingProfile: strings.ToLower(env("KNOWLEDGE_EMBEDDING_PROFILE", "auto")),
|
||||
KnowledgeEmbeddingProfile: envNormalizedLower("KNOWLEDGE_EMBEDDING_PROFILE", "auto"),
|
||||
KnowledgeChunkWords: envInt("KNOWLEDGE_CHUNK_WORDS", 160),
|
||||
KnowledgeChunkOverlapWords: envInt("KNOWLEDGE_CHUNK_OVERLAP_WORDS", 30),
|
||||
KnowledgeMaxChunksPerDoc: envInt("KNOWLEDGE_MAX_CHUNKS_PER_DOC", 24),
|
||||
KnowledgeMaxQueryChunks: envInt("KNOWLEDGE_MAX_QUERY_CHUNKS", 64),
|
||||
KnowledgeIndexMode: strings.ToLower(env("KNOWLEDGE_INDEX_MODE", "incremental")),
|
||||
KnowledgeIndexMode: envNormalizedLower("KNOWLEDGE_INDEX_MODE", "incremental"),
|
||||
KnowledgeEmbedBatchSize: envInt("KNOWLEDGE_EMBED_BATCH_SIZE", 64),
|
||||
KnowledgeIndexScanInterval: envDuration("KNOWLEDGE_INDEX_SCAN_INTERVAL", 5*time.Minute),
|
||||
GLPIKBEnabled: envBool("GLPI_KB_ENABLED", false),
|
||||
@@ -234,7 +235,7 @@ func Load() (Config, error) {
|
||||
GLPIUserDeviceLimit: envInt("GLPI_USER_DEVICE_LIMIT", 20),
|
||||
UptimeKumaEnabled: envBool("UPTIME_KUMA_ENABLED", false),
|
||||
UptimeKumaURL: strings.TrimRight(os.Getenv("UPTIME_KUMA_URL"), "/"),
|
||||
UptimeKumaMode: strings.ToLower(env("UPTIME_KUMA_MODE", "metrics")),
|
||||
UptimeKumaMode: envNormalizedLower("UPTIME_KUMA_MODE", "metrics"),
|
||||
UptimeKumaAPIKey: os.Getenv("UPTIME_KUMA_API_KEY"),
|
||||
UptimeKumaStatusPages: envStringListPreserveCase("UPTIME_KUMA_STATUS_PAGES", ""),
|
||||
UptimeKumaTimeout: envDuration("UPTIME_KUMA_TIMEOUT", 10*time.Second),
|
||||
@@ -311,10 +312,10 @@ func (c Config) Validate() error {
|
||||
if c.KnowledgeWebEditEnabled && c.WebAllowAnonymous {
|
||||
return errors.New("KNOWLEDGE_WEB_EDIT_ENABLED requires authenticated dashboard access; WEB_ALLOW_ANONYMOUS must be false")
|
||||
}
|
||||
switch c.KnowledgeCategoryMode {
|
||||
switch strings.ToLower(strings.TrimSpace(c.KnowledgeCategoryMode)) {
|
||||
case "", "unscoped", "skip", "strict":
|
||||
default:
|
||||
return errors.New("KNOWLEDGE_CATEGORY_MODE must be one of: unscoped, skip, strict")
|
||||
return fmt.Errorf("KNOWLEDGE_CATEGORY_MODE must be one of: unscoped, skip, strict (got %q)", c.KnowledgeCategoryMode)
|
||||
}
|
||||
for _, pattern := range c.KnowledgeIgnoreGlobs {
|
||||
if _, err := filepath.Match(pattern, "probe.json"); err != nil {
|
||||
@@ -530,6 +531,14 @@ func env(key, def string) string {
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
func envNormalizedLower(key, def string) string {
|
||||
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
|
||||
if v == "" {
|
||||
return strings.ToLower(strings.TrimSpace(def))
|
||||
}
|
||||
return v
|
||||
}
|
||||
func isPlaceholder(v string) bool {
|
||||
return strings.Contains(strings.ToUpper(strings.TrimSpace(v)), "CHANGE_ME")
|
||||
}
|
||||
|
||||
@@ -173,3 +173,22 @@ func TestValidateRejectsInvalidKnowledgeScoringConfig(t *testing.T) {
|
||||
t.Fatal("expected overlap >= chunk size to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateKnowledgeCategoryModeTrimsWhitespace(t *testing.T) {
|
||||
c := validConfig()
|
||||
c.KnowledgeCategoryMode = " UNSCOPED "
|
||||
if err := c.Validate(); err != nil {
|
||||
t.Fatalf("expected whitespace/case normalized category mode to validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvNormalizedLowerDefaultsBlankOrWhitespace(t *testing.T) {
|
||||
t.Setenv("KNOWLEDGE_CATEGORY_MODE", " ")
|
||||
if got := envNormalizedLower("KNOWLEDGE_CATEGORY_MODE", "unscoped"); got != "unscoped" {
|
||||
t.Fatalf("expected unscoped default, got %q", got)
|
||||
}
|
||||
t.Setenv("KNOWLEDGE_CATEGORY_MODE", " STRICT ")
|
||||
if got := envNormalizedLower("KNOWLEDGE_CATEGORY_MODE", "unscoped"); got != "strict" {
|
||||
t.Fatalf("expected strict, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
|
||||
"glpi_ok": g, "ollama_ok": o, "knowledge_docs": s.metrics.KnowledgeDocs(), "last_poll": s.metrics.LastPoll(),
|
||||
"knowledge_ready": s.knowledge.Ready(), "knowledge_init_state": initStatus.State, "knowledge_init_phase": initStatus.Phase, "knowledge_init_total_files": initStatus.TotalFiles, "knowledge_init_processed_files": initStatus.ProcessedFiles, "knowledge_init_loaded_docs": initStatus.LoadedDocs, "knowledge_init_indexed_docs": initStatus.IndexedDocs, "knowledge_init_cache_hits": initStatus.CacheHits, "knowledge_init_pending_embeddings": initStatus.PendingEmbeddings, "knowledge_init_started_at": initStatus.StartedAt, "knowledge_init_finished_at": initStatus.FinishedAt, "knowledge_init_error": initStatus.LastError,
|
||||
"knowledge_index_mode": s.cfg.KnowledgeIndexMode, "knowledge_embed_batch_size": s.cfg.KnowledgeEmbedBatchSize, "knowledge_index_scan_interval": s.cfg.KnowledgeIndexScanInterval.String(), "knowledge_snapshot_loaded": initStatus.SnapshotLoaded, "knowledge_snapshot_path": initStatus.SnapshotPath, "knowledge_snapshot_saved_at": initStatus.SnapshotSavedAt, "knowledge_last_scan_at": initStatus.LastScanAt, "knowledge_last_scan_error": initStatus.LastScanError, "knowledge_changed_files": initStatus.ChangedFiles, "knowledge_deleted_files": initStatus.DeletedFiles, "knowledge_reused_files": initStatus.ReusedFiles,
|
||||
"communication_language": s.cfg.CommunicationLanguage, "communication_style": s.cfg.CommunicationStyle, "knowledge_allowed_sources": s.cfg.KnowledgeAllowedSources, "knowledge_auto_reply_sources": s.cfg.KnowledgeAutoReplySources, "knowledge_category_mode": s.cfg.KnowledgeCategoryMode, "knowledge_category_map_configured": strings.TrimSpace(s.cfg.KnowledgeCategoryMapFile) != "", "knowledge_ignore_globs": s.cfg.KnowledgeIgnoreGlobs, "knowledge_ignored_files": loadStats.IgnoredFiles, "knowledge_unmapped_category_files": loadStats.UnmappedCategoryFiles, "knowledge_unmapped_categories": loadStats.UnmappedCategories,
|
||||
"communication_language": s.cfg.CommunicationLanguage, "communication_style": s.cfg.CommunicationStyle, "ai_content_label_enabled": s.cfg.AIContentLabelEnabled, "knowledge_allowed_sources": s.cfg.KnowledgeAllowedSources, "knowledge_auto_reply_sources": s.cfg.KnowledgeAutoReplySources, "knowledge_category_mode": s.cfg.KnowledgeCategoryMode, "knowledge_category_map_configured": strings.TrimSpace(s.cfg.KnowledgeCategoryMapFile) != "", "knowledge_ignore_globs": s.cfg.KnowledgeIgnoreGlobs, "knowledge_ignored_files": loadStats.IgnoredFiles, "knowledge_unmapped_category_files": loadStats.UnmappedCategoryFiles, "knowledge_unmapped_categories": loadStats.UnmappedCategories,
|
||||
"category_confidence": s.cfg.CategoryConfidence, "reply_confidence": s.cfg.ReplyConfidence, "knowledge_min_score": s.cfg.KnowledgeMinScore, "knowledge_retrieval_floor": s.cfg.KnowledgeRetrievalFloor, "knowledge_evidence_weight_retrieval": s.cfg.KnowledgeEvidenceRetrievalWeight, "knowledge_evidence_weight_ai": s.cfg.KnowledgeEvidenceAIWeight, "knowledge_evidence_weight_category": s.cfg.KnowledgeEvidenceCategoryWeight,
|
||||
"knowledge_weight_semantic": s.cfg.KnowledgeSemanticWeight, "knowledge_weight_title": s.cfg.KnowledgeTitleWeight, "knowledge_weight_lexical": s.cfg.KnowledgeLexicalWeight, "knowledge_weight_keywords": s.cfg.KnowledgeKeywordWeight, "knowledge_weight_category": s.cfg.KnowledgeCategoryWeight, "knowledge_embedding_profile": s.cfg.KnowledgeEmbeddingProfile,
|
||||
"knowledge_chunk_words": s.cfg.KnowledgeChunkWords, "knowledge_chunk_overlap_words": s.cfg.KnowledgeChunkOverlapWords, "knowledge_max_chunks_per_doc": s.cfg.KnowledgeMaxChunksPerDoc,
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user