mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-25 15:44:56 -04:00
* feat(vllm-cpp): add GLiNER2.5 NER via TokenClassify Wire the vllm-cpp backend to the C ABI NER surface (vllm_gliner_ner, ABI v27) so LocalAI can serve zero-shot named entity recognition through the existing TokenClassify gRPC method. backend.go: TokenClassify method on *VllmCpp calls vllm_gliner_ner with the text and labels, copies the C-owned entity array into protobuf TokenClassifyEntity messages, and frees the result. govllmcpp.go: cNerEntity and cNerResult Go POD mirrors matching the C structs; vllmGlinerNer and vllmNerResultFree purego bindings; abiVersion bumped 26 -> 27. options.go: ner_labels, ner_threshold, ner_max_width parsed from engine_args. pkg/grpc: ClassifyModel interface and TokenClassify server handler (follows the Embedding locking pattern). core/config: vllm-cpp backend declares MethodTokenClassify and UsecaseTokenClassify. docs/content/features/vllm-cpp.md: NER section documenting the engine_args keys and the host-forward contract. Assisted-by: MAKI:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): correct NER pointer lint directive Use the govet directive for the C-owned NER array, matching the other purego pointer conversions. The array remains valid until its deferred free; the misspelled directive caused CI to flag this conversion. Assisted-by: Codex:gpt-6 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(vllm-cpp): add kev-compatible SystemOne API endpoints Add POST /v1/systemone, /v1/systemone/permute, and /v1/systemone/separate to LocalAI, mirroring the kev project's structured-extraction API. Each endpoint runs zero-shot NER over the rendered state text and builds kev-compatible answers for three question types: noul (binary entity presence), choice (pick one option), and score (pick one level). The TokenClassifyRequest proto gains a `repeated string labels` field so each question can supply its own labels at inference time, and TokenClassifier gains TokenClassifyWithLabels for per-call label selection. The vllm-cpp backend uses request labels when non-empty, falling back to configured ner_labels then the built-in defaults. Helpers (renderState, softmax, choiceConfidence, scoreConfidence, r2) are ported from kev/api.py and mirrored in vllm.cpp's api_server.cpp so both servers produce the same answer shape. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): suppress gosec G404 on seeded permutation RNG The SystemOne permute endpoint uses math/rand with a caller-supplied seed for reproducible option permutations, matching kev's random.seed. gosec flags this as G404 (weak RNG). Add #nosec with a comment naming the intent: this is reproducibility, not cryptography. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(vllm-cpp): bump vllm.cpp pin to GLiNER2.5 merge commit Advance VLLM_CPP_VERSION from f3cd97e to 5058268d, the commit that landed GLiNER2.5 zero-shot NER support (PR #3224) in vllm.cpp. This brings the DeBERTa v2 encoder, GLiNER2 boundary head, C ABI NER functions, and server endpoints into the LocalAI vllm-cpp backend. The ABI version (27) and Go struct mirrors already match. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(vllm-cpp): use instruction text as NER label in SystemOne handler The SystemOne handler was passing question IDs as NER labels for noul questions and bare key names for choice questions, so the model never matched any entities. Port the label mapping from vllm.cpp's ParseSystemOneBody: - noul: use the rendered instructions field (with instr alias) as the NER label, not the question ID - choice: use optionText(name, desc) — "name: description" or "name" when the description is null/empty — not the bare key - score: already correct (rendered criteria text) - permute: shuffle indices and build parallel key/label arrays so the NER call uses the optionText labels while the response is keyed by the original option names Also add the instructions field to the SystemOneQuestion schema struct (accepted alongside the instr backward-compat alias). Verified end-to-end against the real GLiNER2.5 model: noul questions now find "Apple Inc. is" (organization, 0.999) and "Tim Cook is" (person, 0.852) where they previously returned zero entities. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:regolo/glm5.2 [maki] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: localai-org-maint-bot <306269227+localai-org-maint-bot@users.noreply.github.com>
184 lines
6.9 KiB
Go
184 lines
6.9 KiB
Go
package backend
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/core/config"
|
|
"github.com/mudler/LocalAI/core/trace"
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
model "github.com/mudler/LocalAI/pkg/model"
|
|
)
|
|
|
|
// TokenEntity is one detected span from a token-classification (NER)
|
|
// model. Mirrors pb.TokenClassifyEntity but keeps the proto type out of
|
|
// consumers. Start/End are BYTE offsets into the classified text,
|
|
// half-open (addressing text[Start:End]) — the proto contract. Group is
|
|
// the model's entity label (e.g. "private_person", "EMAIL").
|
|
type TokenEntity struct {
|
|
Group string `json:"group"`
|
|
Start int `json:"start"`
|
|
End int `json:"end"`
|
|
Score float32 `json:"score"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
// TokenClassifyOptions controls a single TokenClassify request.
|
|
type TokenClassifyOptions struct {
|
|
// Threshold drops entities the backend scores below this value at
|
|
// the source. 0 returns everything the model emits; downstream
|
|
// callers (e.g. the PII redactor's MinScore) can still filter
|
|
// further once they know the per-request policy.
|
|
Threshold float32
|
|
// Labels overrides the backend's configured entity labels for this
|
|
// request. Empty means "use the model's configured labels" (the PII
|
|
// default). Non-empty enables zero-shot per-request label selection
|
|
// (kev / SystemOne questions).
|
|
Labels []string
|
|
}
|
|
|
|
// TokenClassifier runs a token-classification model over text and
|
|
// returns the detected entity spans. Implemented by NewTokenClassifier
|
|
// over a model-loaded backend; the PII redactor's encoder/NER tier
|
|
// consumes this via a pii.NERDetector adapter (see
|
|
// core/services/routing/piidetector).
|
|
type TokenClassifier interface {
|
|
TokenClassify(ctx context.Context, text string) ([]TokenEntity, error)
|
|
// TokenClassifyWithLabels runs NER with the given labels, overriding
|
|
// the model's configured labels for this call.
|
|
TokenClassifyWithLabels(ctx context.Context, text string, labels []string) ([]TokenEntity, error)
|
|
}
|
|
|
|
// NewTokenClassifier binds (loader, modelConfig, appConfig) into a
|
|
// TokenClassifier. The underlying backend is resolved lazily on the
|
|
// first call, mirroring NewScorer.
|
|
func NewTokenClassifier(loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig, opts TokenClassifyOptions) TokenClassifier {
|
|
return &modelTokenClassifier{loader: loader, modelConfig: modelConfig, appConfig: appConfig, opts: opts}
|
|
}
|
|
|
|
type modelTokenClassifier struct {
|
|
loader *model.ModelLoader
|
|
modelConfig config.ModelConfig
|
|
appConfig *config.ApplicationConfig
|
|
opts TokenClassifyOptions
|
|
}
|
|
|
|
func (m *modelTokenClassifier) TokenClassify(ctx context.Context, text string) ([]TokenEntity, error) {
|
|
fn, err := ModelTokenClassify(text, m.opts, m.loader, m.modelConfig, m.appConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return fn(ctx)
|
|
}
|
|
|
|
// TokenClassifyWithLabels runs NER with the given labels, overriding the
|
|
// model's configured labels for this call. Used by the SystemOne endpoints
|
|
// where each question supplies its own labels.
|
|
func (m *modelTokenClassifier) TokenClassifyWithLabels(ctx context.Context, text string, labels []string) ([]TokenEntity, error) {
|
|
opts := m.opts
|
|
opts.Labels = labels
|
|
fn, err := ModelTokenClassify(text, opts, m.loader, m.modelConfig, m.appConfig)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return fn(ctx)
|
|
}
|
|
|
|
// ModelTokenClassify loads the backend for modelConfig and returns a
|
|
// closure that classifies `text`. Mirrors ModelScore: the closure is
|
|
// bound to the loaded model so a caller can reuse it within a request
|
|
// without re-resolving the backend.
|
|
//
|
|
// When tracing is enabled it records a BackendTraceTokenClassify row so the
|
|
// detector's output — every entity's group, byte range, confidence and the
|
|
// matched substring — shows in the Traces UI alongside the request it gated.
|
|
// This is the technical view for debugging false positives (e.g. a phone
|
|
// number scored as SSN); the persisted PIIEvent keeps only a hash.
|
|
func ModelTokenClassify(text string, opts TokenClassifyOptions, loader *model.ModelLoader, modelConfig config.ModelConfig, appConfig *config.ApplicationConfig) (func(ctx context.Context) ([]TokenEntity, error), error) {
|
|
modelOpts := ModelOptions(modelConfig, appConfig)
|
|
inferenceModel, err := loader.Load(modelOpts...)
|
|
if err != nil {
|
|
recordModelLoadFailure(appConfig, modelConfig.Name, modelConfig.Backend, err, nil)
|
|
return nil, err
|
|
}
|
|
return func(ctx context.Context) ([]TokenEntity, error) {
|
|
release, err := AcquireGlobalBackendSlot()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer release()
|
|
var startTime time.Time
|
|
var traceID string
|
|
if appConfig.EnableTracing {
|
|
trace.InitBackendTracingIfEnabled(appConfig.TracingMaxItems, appConfig.TracingMaxBodyBytes)
|
|
startTime = time.Now()
|
|
traceID = trace.BeginBackendTrace(trace.BackendTrace{Timestamp: startTime, Type: trace.BackendTraceTokenClassify, ModelName: modelConfig.Name, Backend: modelConfig.Backend, Summary: trace.TruncateString(text, 200)})
|
|
}
|
|
defer trace.CancelBackendTrace(traceID)
|
|
resp, err := inferenceModel.TokenClassify(ctx, &pb.TokenClassifyRequest{
|
|
ModelIdentity: modelConfig.Model,
|
|
Text: text,
|
|
Threshold: opts.Threshold,
|
|
Labels: opts.Labels,
|
|
})
|
|
entities := tokenClassifyResponseToEntities(resp)
|
|
if appConfig.EnableTracing {
|
|
bt := tokenClassifyTrace(modelConfig, text, opts.Threshold, entities, startTime, err)
|
|
bt.ID = traceID
|
|
trace.RecordBackendTrace(bt)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return entities, nil
|
|
}, nil
|
|
}
|
|
|
|
// tokenClassifyTrace assembles the Traces-UI row for one NER call: the input
|
|
// preview, the threshold, and every detected entity (group, byte range,
|
|
// confidence, matched text). Split out from the closure so the Data assembly
|
|
// is unit-testable without a live backend.
|
|
func tokenClassifyTrace(modelConfig config.ModelConfig, text string, threshold float32, entities []TokenEntity, start time.Time, callErr error) trace.BackendTrace {
|
|
errStr := ""
|
|
if callErr != nil {
|
|
errStr = callErr.Error()
|
|
}
|
|
return trace.BackendTrace{
|
|
Timestamp: start,
|
|
Duration: time.Since(start),
|
|
Type: trace.BackendTraceTokenClassify,
|
|
ModelName: modelConfig.Name,
|
|
Backend: modelConfig.Backend,
|
|
Summary: trace.TruncateString(text, 200),
|
|
Error: errStr,
|
|
Data: map[string]any{
|
|
"input_chars": len(text),
|
|
"threshold": threshold,
|
|
"entities": entities,
|
|
},
|
|
}
|
|
}
|
|
|
|
// tokenClassifyResponseToEntities converts the wire-format response into
|
|
// the value type consumed by callers. Extracted so the conversion can be
|
|
// unit-tested without a real backend (see token_classify_test.go).
|
|
func tokenClassifyResponseToEntities(resp *pb.TokenClassifyResponse) []TokenEntity {
|
|
if resp == nil {
|
|
return nil
|
|
}
|
|
out := make([]TokenEntity, 0, len(resp.Entities))
|
|
for _, e := range resp.Entities {
|
|
if e == nil {
|
|
continue
|
|
}
|
|
out = append(out, TokenEntity{
|
|
Group: e.EntityGroup,
|
|
Start: int(e.Start),
|
|
End: int(e.End),
|
|
Score: e.Score,
|
|
Text: e.Text,
|
|
})
|
|
}
|
|
return out
|
|
}
|