AI-Label hinzugefügt
All checks were successful
release-tag / release-image (push) Successful in 1m41s

This commit is contained in:
2026-07-29 14:53:12 +02:00
parent 827c348e69
commit a10225d74c
10 changed files with 129 additions and 18 deletions

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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) {

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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, "&lt;script&gt;") {
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)
}
}

View File

@@ -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")
}

View File

@@ -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)
}
}

View File

@@ -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