Skip to content

Commit 1c59a7c

Browse files
committed
feat(pii): observability for NER detections — backend trace, confidence, debug logs
There was no way to see what the NER detector actually produced, so a false-positive block (e.g. a phone number scored as SSN) was opaque. Add three views, all from data the TokenClassify gRPC already returns (no backend rebuild): - Backend trace: ModelTokenClassify now records a BackendTraceTokenClassify row (gated on tracing) with the input preview, threshold, and every entity's group, byte range, confidence and matched text. Wires up the long-standing TODO; shows in the Traces UI alongside the request it gated. - Confidence in the audit log: carry the detector score through rawHit -> Span -> PIIEvent, exposed as `score` on /api/pii/events. Metadata only — the event still stores a hash, never the value. - Per-detection DEBUG logs in the redactor: one line per raw hit with group, range, score, matched text and the policy decision (accepted / dropped below min_score / no action for group), so the masking/blocking rationale is visible in the backend logs. Also drop a redundant same-type assertion in ModelTokenClassify (Load already returns grpc.Backend) and give TokenEntity json tags for clean trace rendering. Assisted-by: claude-code:claude-opus-4-8 [Claude Code]
1 parent c2757f7 commit 1c59a7c

8 files changed

Lines changed: 120 additions & 24 deletions

File tree

core/backend/token_classify.go

Lines changed: 48 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ package backend
22

33
import (
44
"context"
5-
"fmt"
5+
"time"
66

77
"github.com/mudler/LocalAI/core/config"
8-
"github.com/mudler/LocalAI/pkg/grpc"
8+
"github.com/mudler/LocalAI/core/trace"
99
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
1010
model "github.com/mudler/LocalAI/pkg/model"
1111
)
@@ -16,11 +16,11 @@ import (
1616
// half-open (addressing text[Start:End]) — the proto contract. Group is
1717
// the model's entity label (e.g. "private_person", "EMAIL").
1818
type TokenEntity struct {
19-
Group string
20-
Start int
21-
End int
22-
Score float32
23-
Text string
19+
Group string `json:"group"`
20+
Start int `json:"start"`
21+
End int `json:"end"`
22+
Score float32 `json:"score"`
23+
Text string `json:"text"`
2424
}
2525

2626
// TokenClassifyOptions controls a single TokenClassify request.
@@ -68,32 +68,64 @@ func (m *modelTokenClassifier) TokenClassify(ctx context.Context, text string) (
6868
// bound to the loaded model so a caller can reuse it within a request
6969
// without re-resolving the backend.
7070
//
71-
// NOTE: unlike ModelScore this does not yet emit a Traces UI row — wire
72-
// a trace.BackendTrace (new trace type) here if/when NER calls should
73-
// show up alongside the requests they gate.
71+
// When tracing is enabled it records a BackendTraceTokenClassify row so the
72+
// detector's output — every entity's group, byte range, confidence and the
73+
// matched substring — shows in the Traces UI alongside the request it gated.
74+
// This is the technical view for debugging false positives (e.g. a phone
75+
// number scored as SSN); the persisted PIIEvent keeps only a hash.
7476
func ModelTokenClassify(text string, opts TokenClassifyOptions, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func(ctx context.Context) ([]TokenEntity, error), error) {
7577
modelOpts := ModelOptions(modelConfig, appConfig)
7678
inferenceModel, err := loader.Load(modelOpts...)
7779
if err != nil {
7880
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
7981
return nil, err
8082
}
81-
b, ok := inferenceModel.(grpc.Backend)
82-
if !ok {
83-
return nil, fmt.Errorf("token classification not supported by backend %q", modelConfig.Backend)
84-
}
8583
return func(ctx context.Context) ([]TokenEntity, error) {
86-
resp, err := b.TokenClassify(ctx, &pb.TokenClassifyRequest{
84+
var startTime time.Time
85+
if appConfig.EnableTracing {
86+
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
87+
startTime = time.Now()
88+
}
89+
resp, err := inferenceModel.TokenClassify(ctx, &pb.TokenClassifyRequest{
8790
Text: text,
8891
Threshold: opts.Threshold,
8992
})
93+
entities := tokenClassifyResponseToEntities(resp)
94+
if appConfig.EnableTracing {
95+
trace.RecordBackendTrace(tokenClassifyTrace(modelConfig, text, opts.Threshold, entities, startTime, err))
96+
}
9097
if err != nil {
9198
return nil, err
9299
}
93-
return tokenClassifyResponseToEntities(resp), nil
100+
return entities, nil
94101
}, nil
95102
}
96103

104+
// tokenClassifyTrace assembles the Traces-UI row for one NER call: the input
105+
// preview, the threshold, and every detected entity (group, byte range,
106+
// confidence, matched text). Split out from the closure so the Data assembly
107+
// is unit-testable without a live backend.
108+
func tokenClassifyTrace(modelConfig config.ModelConfig, text string, threshold float32, entities []TokenEntity, start time.Time, callErr error) trace.BackendTrace {
109+
errStr := ""
110+
if callErr != nil {
111+
errStr = callErr.Error()
112+
}
113+
return trace.BackendTrace{
114+
Timestamp: start,
115+
Duration: time.Since(start),
116+
Type: trace.BackendTraceTokenClassify,
117+
ModelName: modelConfig.Name,
118+
Backend: modelConfig.Backend,
119+
Summary: trace.TruncateString(text, 200),
120+
Error: errStr,
121+
Data: map[string]any{
122+
"input_chars": len(text),
123+
"threshold": threshold,
124+
"entities": entities,
125+
},
126+
}
127+
}
128+
97129
// tokenClassifyResponseToEntities converts the wire-format response into
98130
// the value type consumed by callers. Extracted so the conversion can be
99131
// unit-tested without a real backend (see token_classify_test.go).

core/backend/token_classify_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
package backend
22

33
import (
4+
"errors"
5+
"time"
6+
7+
"github.com/mudler/LocalAI/core/config"
8+
"github.com/mudler/LocalAI/core/trace"
49
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
510

611
. "github.com/onsi/ginkgo/v2"
@@ -32,3 +37,25 @@ var _ = Describe("tokenClassifyResponseToEntities", func() {
3237
Expect(out).To(BeEmpty())
3338
})
3439
})
40+
41+
var _ = Describe("tokenClassifyTrace", func() {
42+
cfg := config.ModelConfig{Name: "privacy-filter", Backend: "llama-cpp"}
43+
ents := []TokenEntity{{Group: "SSN", Start: 5, End: 16, Score: 0.62, Text: "123-45-6789"}}
44+
45+
It("captures model, input preview, threshold and per-entity detail", func() {
46+
tr := tokenClassifyTrace(cfg, "ssn is 123-45-6789", 0.5, ents, time.Now(), nil)
47+
Expect(tr.Type).To(Equal(trace.BackendTraceTokenClassify))
48+
Expect(tr.ModelName).To(Equal("privacy-filter"))
49+
Expect(tr.Backend).To(Equal("llama-cpp"))
50+
Expect(tr.Summary).To(ContainSubstring("ssn is"))
51+
Expect(tr.Error).To(BeEmpty())
52+
Expect(tr.Data["input_chars"]).To(Equal(len("ssn is 123-45-6789")))
53+
Expect(tr.Data["threshold"]).To(BeEquivalentTo(float32(0.5)))
54+
Expect(tr.Data["entities"]).To(Equal(ents))
55+
})
56+
57+
It("records the backend error string when the call failed", func() {
58+
tr := tokenClassifyTrace(cfg, "x", 0, nil, time.Now(), errors.New("boom"))
59+
Expect(tr.Error).To(Equal("boom"))
60+
})
61+
})

core/services/routing/pii/middleware.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,7 @@ func RequestMiddleware(redactor *Redactor, store EventStore, adapter Adapter, fa
229229
Length: span.End - span.Start,
230230
HashPrefix: span.HashPrefix,
231231
Action: span.Action,
232+
Score: span.Score,
232233
CreatedAt: time.Now().UTC(),
233234
}
234235
if firstEventID == "" {

core/services/routing/pii/ner.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ type NEREntity struct {
2828
Start int
2929
End int
3030
Score float32
31+
// Text is the matched substring as the detector saw it. Carried for
32+
// debug logging only (the persisted PIIEvent never stores the raw
33+
// value); the redactor re-slices the original text for masking.
34+
Text string
3135
}
3236

3337
// NERConfig configures the encoder tier for one redactor invocation.

core/services/routing/pii/redactor.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import (
66
"encoding/hex"
77
"sort"
88
"strings"
9+
10+
"github.com/mudler/xlog"
911
)
1012

1113
// rawHit is one detection before overlap-merging. Lifted to file scope so
@@ -15,6 +17,7 @@ type rawHit struct {
1517
action Action
1618
start int
1719
end int
20+
score float32
1821
}
1922

2023
// Redactor is a stateless handle for the PII subsystem. The regex tier
@@ -77,24 +80,42 @@ func collectNERHits(ctx context.Context, text string, cfg NERConfig) ([]rawHit,
7780
}
7881
var hits []rawHit
7982
for _, e := range entities {
83+
// One DEBUG line per raw detection with the model's confidence, the
84+
// byte range, the matched substring, and the policy decision. This is
85+
// the lowest-level view of why a request was masked/blocked — e.g. a
86+
// phone number scored as SSN — and answers "what was in that range and
87+
// how sure was the model" without re-running the detector. DEBUG-gated
88+
// because the matched value is sensitive.
8089
if e.Score < cfg.MinScore {
90+
xlog.Debug("pii/ner: detection dropped (below min score)",
91+
"group", e.Group, "score", e.Score, "min_score", cfg.MinScore,
92+
"start", e.Start, "end", e.End, "text", e.Text)
8193
continue
8294
}
8395
action, ok := cfg.ResolveAction(e.Group)
8496
if !ok {
97+
xlog.Debug("pii/ner: detection ignored (no action for group)",
98+
"group", e.Group, "score", e.Score,
99+
"start", e.Start, "end", e.End, "text", e.Text)
85100
continue
86101
}
87102
if e.Start < 0 || e.End <= e.Start || e.End > len(text) {
88103
// Defensive: the backend should return byte offsets into the
89104
// original text, but a misconfigured model could produce
90105
// garbage. Skip rather than panic on slice OOB.
106+
xlog.Warn("pii/ner: detection has out-of-range offsets; skipping",
107+
"group", e.Group, "start", e.Start, "end", e.End, "text_len", len(text))
91108
continue
92109
}
110+
xlog.Debug("pii/ner: detection accepted",
111+
"group", e.Group, "score", e.Score, "action", action,
112+
"start", e.Start, "end", e.End, "text", e.Text)
93113
hits = append(hits, rawHit{
94114
patternID: nerPatternID(e.Group),
95115
action: action,
96116
start: e.Start,
97117
end: e.End,
118+
score: e.Score,
98119
})
99120
}
100121
return hits, nil
@@ -125,6 +146,7 @@ func mergeAndEmit(text string, hits []rawHit) Result {
125146
if actionRank(h.action) > actionRank(last.action) {
126147
last.action = h.action
127148
last.patternID = h.patternID
149+
last.score = h.score
128150
}
129151
if h.end > last.end {
130152
last.end = h.end
@@ -147,6 +169,7 @@ func mergeAndEmit(text string, hits []rawHit) Result {
147169
Pattern: h.patternID,
148170
HashPrefix: hashPrefix(matched),
149171
Action: h.action,
172+
Score: h.score,
150173
}
151174
res.Spans = append(res.Spans, span)
152175

core/services/routing/pii/types.go

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,9 +64,10 @@ const (
6464
type Span struct {
6565
Start int
6666
End int
67-
Pattern string // synthetic detector id, "ner:<GROUP>"
68-
HashPrefix string // first 8 chars of sha256(matched value); audit-safe
69-
Action Action // the action that fired for this span (after merge)
67+
Pattern string // synthetic detector id, "ner:<GROUP>"
68+
HashPrefix string // first 8 chars of sha256(matched value); audit-safe
69+
Action Action // the action that fired for this span (after merge)
70+
Score float32 // detector confidence for the (winning) hit, 0..1
7071
}
7172

7273
// Result is what Redact returns. Redacted is the input string after
@@ -131,7 +132,11 @@ type PIIEvent struct {
131132
Length int `json:"length,omitempty"`
132133
HashPrefix string `json:"hash_prefix,omitempty"`
133134
Action Action `json:"action,omitempty"`
134-
CreatedAt time.Time `json:"created_at"`
135+
// Score is the detector confidence (0..1) for an NER PII hit. Metadata
136+
// only — never the matched value. Lets admins see how sure the model was
137+
// about a (possibly false-positive) detection without re-running it.
138+
Score float32 `json:"score,omitempty"`
139+
CreatedAt time.Time `json:"created_at"`
135140

136141
Host string `json:"host,omitempty"`
137142
Intercepted *bool `json:"intercepted,omitempty"`

core/services/routing/piidetector/detector.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ func (d *nerDetector) Detect(ctx context.Context, text string) ([]pii.NEREntity,
7979
Start: e.Start,
8080
End: e.End,
8181
Score: e.Score,
82+
Text: e.Text,
8283
})
8384
}
8485
return out, nil

core/trace/backend_trace.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const (
3333
BackendTraceAudioTransform BackendTraceType = "audio_transform"
3434
BackendTraceModelLoad BackendTraceType = "model_load"
3535
BackendTraceScore BackendTraceType = "score"
36+
BackendTraceTokenClassify BackendTraceType = "token_classify"
3637
BackendTraceVectorStore BackendTraceType = "vector_store"
3738
)
3839

@@ -58,10 +59,12 @@ type BackendTrace struct {
5859
// runaway buffer when a caller streams MB-scale payloads.
5960
const MaxTraceBodyBytes = 1 << 20
6061

61-
var backendTraceBuffer *circularbuffer.Queue[*BackendTrace]
62-
var backendMu sync.Mutex
63-
var backendLogChan = make(chan *BackendTrace, 100)
64-
var backendInitOnce sync.Once
62+
var (
63+
backendTraceBuffer *circularbuffer.Queue[*BackendTrace]
64+
backendMu sync.Mutex
65+
backendLogChan = make(chan *BackendTrace, 100)
66+
backendInitOnce sync.Once
67+
)
6568

6669
// backendMaxBodyBytes caps each captured string value in a BackendTrace.Data
6770
// field to keep the /api/backend-traces JSON small enough for the admin UI to

0 commit comments

Comments
 (0)