Add LLM-assisted NPC normalization
This commit is contained in:
@@ -3,8 +3,10 @@ package npcs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -18,14 +20,18 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "dnd/npcs"
|
||||
normalizationPolicy = "dnd.npcs.normalize.v2"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
Key = "dnd/npcs"
|
||||
normalizationPolicy = "dnd.npcs.normalize.v3"
|
||||
semanticContextPolicy = "dnd.npcs.semantic_context.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
||||
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
|
||||
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
|
||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
||||
ReasonCodeSourceReferencesNormalized = "source_references_normalized"
|
||||
ReasonCodeDuplicateNPCCollapsed = "duplicate_npc_collapsed"
|
||||
ReasonCodeNPCSemanticProposalInvalid = "npc_semantic_proposal_invalid"
|
||||
ReasonCodeNPCSemanticReconciliationExhausted = "npc_semantic_reconciliation_exhausted"
|
||||
ReasonCodeNPCNormalizationWarningsOmitted = "npc_normalization_warnings_omitted"
|
||||
)
|
||||
|
||||
var requiredCapabilities = []string{"merged"}
|
||||
@@ -36,9 +42,28 @@ var _ contracts.ManifestMetadataProvider = (*Normalizer)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Normalizer)(nil)
|
||||
|
||||
type Options struct{}
|
||||
type Normalizer struct{}
|
||||
|
||||
func New(Options) *Normalizer { return &Normalizer{} }
|
||||
type Normalizer struct {
|
||||
llm contracts.StructuredLLMClient
|
||||
promptSHA string
|
||||
responseSchemaSHA string
|
||||
}
|
||||
|
||||
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Normalizer, error) {
|
||||
if llmClient == nil {
|
||||
return nil, normalizerErrorf("LLM client must not be nil")
|
||||
}
|
||||
promptSHA, err := scriptoriumPromptMetadata()
|
||||
if err != nil {
|
||||
return nil, normalizerErrorf("load prompt metadata: %w", err)
|
||||
}
|
||||
responseSchema, err := loadResponseSchema()
|
||||
if err != nil {
|
||||
return nil, normalizerErrorf("load response schema: %w", err)
|
||||
}
|
||||
return &Normalizer{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
|
||||
}
|
||||
|
||||
func (n *Normalizer) Key() string { return Key }
|
||||
func (n *Normalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
|
||||
|
||||
@@ -46,7 +71,20 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
if n == nil {
|
||||
return nil
|
||||
}
|
||||
return map[string]any{"identity_policy": identity.Policy, "normalization_policy": normalizationPolicy}
|
||||
return map[string]any{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": SchemaVersion,
|
||||
"prompt_sha256": n.promptSHA,
|
||||
"response_schema_key": string(ResponseSchemaKey),
|
||||
"response_schema_id": ResponseSchemaID,
|
||||
"response_schema_name": ResponseSchemaName,
|
||||
"response_schema_version": SchemaVersion,
|
||||
"response_schema_sha256": n.responseSchemaSHA,
|
||||
"identity_policy": identity.Policy,
|
||||
"normalization_policy": normalizationPolicy,
|
||||
"semantic_context_policy": semanticContextPolicy,
|
||||
"semantic_context_radius": semanticContextRadius,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
@@ -54,8 +92,11 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return nil
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "prompt", Value: n.promptSHA},
|
||||
{Name: "response_schema", Value: n.responseSchemaSHA},
|
||||
{Name: "identity_policy", Value: identity.Policy},
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
{Name: "semantic_context_policy", Value: fmt.Sprintf("%s:%d", semanticContextPolicy, semanticContextRadius)},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,30 +104,114 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
||||
if n == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("normalizer must not be nil")
|
||||
}
|
||||
if n.llm == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("LLM client must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context must not be nil")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||
}
|
||||
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
value, warnings := normalizeList(req.MergeOutput.Value, order)
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
|
||||
records, warnings := preprocessRecords(req.MergeOutput.Value, order)
|
||||
deterministic := recordList(records)
|
||||
materials, ready, err := buildDefaultNormalizeContextMaterials(req.Source, recordValues(records))
|
||||
if err != nil {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("build semantic context: %w", err)
|
||||
}
|
||||
if !ready {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: deterministic, Warnings: limitWarnings(warnings)}, nil
|
||||
}
|
||||
|
||||
var response normalizeProposalResponse
|
||||
if _, err := n.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
|
||||
ProfileID: req.LLMProfile, SessionID: req.SessionID,
|
||||
Inputs: contracts.LLMInputSet{"candidates": materials.Candidates, "transcript": materials.Transcript},
|
||||
}, &response); err != nil {
|
||||
if errors.Is(err, contracts.ErrInvalidStructuredOutput) {
|
||||
return n.invalidStructuredResult(deterministic, warnings), nil
|
||||
}
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("complete structured output: %w", err)
|
||||
}
|
||||
|
||||
assessment := assessProposal(response, records, materials.candidatePositions)
|
||||
applied, semanticWarnings := applySafeGroups(records, assessment.safeGroups, order)
|
||||
warnings = append(warnings, semanticWarnings...)
|
||||
if assessment.discardedGroups == 0 {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: recordList(applied), Warnings: limitWarnings(warnings)}, nil
|
||||
}
|
||||
return retryResult(recordList(applied), warnings, assessment), nil
|
||||
}
|
||||
|
||||
func (n *Normalizer) invalidStructuredResult(value dnd.NPCList, warnings []contracts.Warning) contracts.TypedNormalizeResult[dnd.NPCList] {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{
|
||||
Value: value,
|
||||
Warnings: limitWarningsForRetry(warnings),
|
||||
Retry: &contracts.NormalizeRetry{
|
||||
ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
|
||||
Message: "semantic proposal requires retry: invalid structured output",
|
||||
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(-1)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func retryResult(value dnd.NPCList, warnings []contracts.Warning, assessment proposalAssessment) contracts.TypedNormalizeResult[dnd.NPCList] {
|
||||
return contracts.TypedNormalizeResult[dnd.NPCList]{
|
||||
Value: value,
|
||||
Warnings: limitWarningsForRetry(warnings),
|
||||
Retry: &contracts.NormalizeRetry{
|
||||
ReasonCode: ReasonCodeNPCSemanticProposalInvalid,
|
||||
Message: diagnostics.Aggregate("semantic proposal requires retry", assessment.issues),
|
||||
FallbackWarnings: []contracts.Warning{semanticFallbackWarning(assessment.discardedGroups)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func semanticFallbackWarning(discardedGroups int) contracts.Warning {
|
||||
message := "semantic proposal could not be applied"
|
||||
if discardedGroups >= 0 {
|
||||
message = fmt.Sprintf("%d proposal group(s) omitted after semantic proposal retry exhaustion", discardedGroups)
|
||||
}
|
||||
return contracts.Warning{Scope: "npcs", ReasonCode: ReasonCodeNPCSemanticReconciliationExhausted, Message: message}
|
||||
}
|
||||
|
||||
func limitWarnings(warnings []contracts.Warning) []contracts.Warning {
|
||||
return diagnostics.LimitWarnings(warnings, "npcs", ReasonCodeNPCNormalizationWarningsOmitted)
|
||||
}
|
||||
|
||||
func limitWarningsForRetry(warnings []contracts.Warning) []contracts.Warning {
|
||||
if warnings == nil {
|
||||
return nil
|
||||
}
|
||||
if len(warnings) < diagnostics.MaxWarnings {
|
||||
return append([]contracts.Warning(nil), warnings...)
|
||||
}
|
||||
displayed := diagnostics.MaxWarnings - 2
|
||||
bounded := append([]contracts.Warning(nil), warnings[:displayed]...)
|
||||
return append(bounded, contracts.Warning{
|
||||
Scope: "npcs", ReasonCode: ReasonCodeNPCNormalizationWarningsOmitted,
|
||||
Message: fmt.Sprintf("%d additional warning(s) omitted", len(warnings)-displayed),
|
||||
})
|
||||
}
|
||||
|
||||
type normalizedRecord struct {
|
||||
npc dnd.NPC
|
||||
npc dnd.NPC
|
||||
inputIndexes []int
|
||||
earliest int
|
||||
}
|
||||
|
||||
func normalizeList(input dnd.NPCList, order shared.SourceRefOrder) (dnd.NPCList, []contracts.Warning) {
|
||||
func preprocessRecords(input dnd.NPCList, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) {
|
||||
if input.NPCs == nil {
|
||||
return dnd.NPCList{}, nil
|
||||
return nil, nil
|
||||
}
|
||||
records := make([]normalizedRecord, len(input.NPCs))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, inputNPC := range input.NPCs {
|
||||
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order)
|
||||
records[index] = normalizedRecord{npc: npc}
|
||||
records[index] = normalizedRecord{npc: npc, inputIndexes: []int{index}, earliest: index}
|
||||
if fieldsChanged {
|
||||
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))})
|
||||
}
|
||||
@@ -99,16 +224,16 @@ func normalizeList(input dnd.NPCList, order shared.SourceRefOrder) (dnd.NPCList,
|
||||
}
|
||||
|
||||
groups := canonicalNameGroups(records)
|
||||
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
|
||||
output := make([]normalizedRecord, 0, len(groups))
|
||||
for _, members := range groups {
|
||||
consolidated, referencesChanged := consolidate(records, members, order)
|
||||
retainedIndex := members[0]
|
||||
output.NPCs = append(output.NPCs, consolidated)
|
||||
output = append(output, consolidated)
|
||||
retainedIndex := consolidated.earliest
|
||||
if referencesChanged {
|
||||
warnings = append(warnings, contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.SourceRefs))})
|
||||
warnings = append(warnings, contracts.Warning{Scope: npcScope(retainedIndex), ReasonCode: ReasonCodeSourceReferencesNormalized, Message: fmt.Sprintf("input index %d: source references normalized during identity consolidation (final count %d)", retainedIndex, len(consolidated.npc.SourceRefs))})
|
||||
}
|
||||
if len(members) > 1 {
|
||||
warnings = append(warnings, duplicateWarning(retainedIndex, members[1:]))
|
||||
warnings = append(warnings, duplicateWarning(retainedIndex, memberInputIndexes(records, members[1:])))
|
||||
}
|
||||
}
|
||||
return output, warnings
|
||||
@@ -144,15 +269,68 @@ func canonicalNameGroups(records []normalizedRecord) [][]int {
|
||||
return groups
|
||||
}
|
||||
|
||||
func consolidate(records []normalizedRecord, members []int, order shared.SourceRefOrder) (dnd.NPC, bool) {
|
||||
output := cloneNPC(records[members[0]].npc)
|
||||
originalRefs := cloneSourceRefs(output.SourceRefs)
|
||||
func consolidate(records []normalizedRecord, members []int, order shared.SourceRefOrder) (normalizedRecord, bool) {
|
||||
output := cloneRecord(records[members[0]])
|
||||
originalRefs := cloneSourceRefs(output.npc.SourceRefs)
|
||||
for _, member := range members[1:] {
|
||||
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
|
||||
output.npc.SourceRefs = append(output.npc.SourceRefs, records[member].npc.SourceRefs...)
|
||||
output.inputIndexes = append(output.inputIndexes, records[member].inputIndexes...)
|
||||
if records[member].earliest < output.earliest {
|
||||
output.earliest = records[member].earliest
|
||||
}
|
||||
}
|
||||
output.SourceRefs = order.Canonicalize(output.SourceRefs)
|
||||
output.ID = identity.DeriveID(output.Name)
|
||||
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
|
||||
output.inputIndexes = sortedUniqueIndexes(output.inputIndexes)
|
||||
output.npc.SourceRefs = order.Canonicalize(output.npc.SourceRefs)
|
||||
output.npc.ID = identity.DeriveID(output.npc.Name)
|
||||
return output, !reflect.DeepEqual(originalRefs, output.npc.SourceRefs)
|
||||
}
|
||||
|
||||
func cloneRecord(input normalizedRecord) normalizedRecord {
|
||||
input.npc = cloneNPC(input.npc)
|
||||
input.inputIndexes = append([]int(nil), input.inputIndexes...)
|
||||
return input
|
||||
}
|
||||
|
||||
func memberInputIndexes(records []normalizedRecord, members []int) []int {
|
||||
indexes := make([]int, 0, len(members))
|
||||
for _, member := range members {
|
||||
indexes = append(indexes, records[member].inputIndexes...)
|
||||
}
|
||||
return sortedUniqueIndexes(indexes)
|
||||
}
|
||||
|
||||
func sortedUniqueIndexes(indexes []int) []int {
|
||||
if len(indexes) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := append([]int(nil), indexes...)
|
||||
sort.Ints(out)
|
||||
write := 1
|
||||
for _, index := range out[1:] {
|
||||
if index != out[write-1] {
|
||||
out[write] = index
|
||||
write++
|
||||
}
|
||||
}
|
||||
return out[:write]
|
||||
}
|
||||
|
||||
func recordValues(records []normalizedRecord) []dnd.NPC {
|
||||
if records == nil {
|
||||
return nil
|
||||
}
|
||||
values := make([]dnd.NPC, len(records))
|
||||
for index, record := range records {
|
||||
values[index] = cloneNPC(record.npc)
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func recordList(records []normalizedRecord) dnd.NPCList {
|
||||
if records == nil {
|
||||
return dnd.NPCList{}
|
||||
}
|
||||
return dnd.NPCList{NPCs: recordValues(records)}
|
||||
}
|
||||
|
||||
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
|
||||
@@ -191,7 +369,7 @@ func Register(registry *pipeline.NormalizerRegistry) error {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return New(options), nil
|
||||
return New(request.Dependencies.LLM, options)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user