Add LLM-assisted NPC normalization
This commit is contained in:
195
internal/modules/dnd/normalize/npcs/proposal.go
Normal file
195
internal/modules/dnd/normalize/npcs/proposal.go
Normal file
@@ -0,0 +1,195 @@
|
||||
package npcs
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
)
|
||||
|
||||
type proposalAssessment struct {
|
||||
safeGroups []safeProposalGroup
|
||||
discardedGroups int
|
||||
issues []string
|
||||
}
|
||||
|
||||
type safeProposalGroup struct {
|
||||
members []int
|
||||
canonical int
|
||||
}
|
||||
|
||||
type assessedProposalGroup struct {
|
||||
members []int
|
||||
canonical int
|
||||
locallyValid bool
|
||||
conflicting bool
|
||||
}
|
||||
|
||||
func assessProposal(response normalizeProposalResponse, records []normalizedRecord, candidatePositions []int) proposalAssessment {
|
||||
positionsByKey := make(map[string][]int, len(candidatePositions))
|
||||
for _, position := range candidatePositions {
|
||||
if position < 0 || position >= len(records) {
|
||||
continue
|
||||
}
|
||||
key := identity.ComparisonKey(records[position].npc.Name)
|
||||
if key != "" {
|
||||
positionsByKey[key] = append(positionsByKey[key], position)
|
||||
}
|
||||
}
|
||||
groups := make([]assessedProposalGroup, len(response.DuplicateGroups))
|
||||
issues := make([]string, 0)
|
||||
owners := make(map[int][]int)
|
||||
|
||||
for groupIndex, proposal := range response.DuplicateGroups {
|
||||
group, groupIssues := assessProposalGroup(proposal, positionsByKey)
|
||||
groups[groupIndex] = group
|
||||
for _, position := range group.members {
|
||||
owners[position] = append(owners[position], groupIndex)
|
||||
}
|
||||
for _, issue := range groupIssues {
|
||||
issues = append(issues, proposalIssue(groupIndex, issue))
|
||||
}
|
||||
}
|
||||
for groupIndex := range groups {
|
||||
for _, position := range groups[groupIndex].members {
|
||||
if len(owners[position]) > 1 {
|
||||
groups[groupIndex].conflicting = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if groups[groupIndex].conflicting {
|
||||
issues = append(issues, proposalIssue(groupIndex, "overlapping_member"))
|
||||
}
|
||||
}
|
||||
|
||||
assessment := proposalAssessment{issues: issues}
|
||||
for _, group := range groups {
|
||||
if !group.locallyValid || group.conflicting {
|
||||
assessment.discardedGroups++
|
||||
continue
|
||||
}
|
||||
assessment.safeGroups = append(assessment.safeGroups, safeProposalGroup{members: group.members, canonical: group.canonical})
|
||||
}
|
||||
return assessment
|
||||
}
|
||||
|
||||
func assessProposalGroup(proposal normalizeProposalGroup, positionsByKey map[string][]int) (assessedProposalGroup, []string) {
|
||||
issues := make([]string, 0)
|
||||
members := make([]int, 0, len(proposal.Members))
|
||||
seenMembers := make(map[int]struct{}, len(proposal.Members))
|
||||
for _, name := range proposal.Members {
|
||||
position, issue := resolveCandidate(name, positionsByKey)
|
||||
if issue != "" {
|
||||
issues = append(issues, "member_"+issue)
|
||||
continue
|
||||
}
|
||||
if _, exists := seenMembers[position]; exists {
|
||||
issues = append(issues, "repeated_member")
|
||||
continue
|
||||
}
|
||||
seenMembers[position] = struct{}{}
|
||||
members = append(members, position)
|
||||
}
|
||||
canonical, canonicalIssue := resolveCandidate(proposal.CanonicalName, positionsByKey)
|
||||
if canonicalIssue != "" {
|
||||
issues = append(issues, "canonical_"+canonicalIssue)
|
||||
}
|
||||
if len(members) < 2 {
|
||||
issues = append(issues, "fewer_than_two_members")
|
||||
}
|
||||
if canonicalIssue == "" && !containsPosition(members, canonical) {
|
||||
issues = append(issues, "canonical_not_member")
|
||||
}
|
||||
sort.Ints(members)
|
||||
return assessedProposalGroup{
|
||||
members: members, canonical: canonical, locallyValid: len(issues) == 0,
|
||||
}, issues
|
||||
}
|
||||
|
||||
func resolveCandidate(name string, positionsByKey map[string][]int) (position int, issue string) {
|
||||
key := identity.ComparisonKey(name)
|
||||
if key == "" {
|
||||
return 0, "blank"
|
||||
}
|
||||
positions := positionsByKey[key]
|
||||
if len(positions) == 0 {
|
||||
return 0, "unknown"
|
||||
}
|
||||
if len(positions) != 1 {
|
||||
return 0, "ambiguous"
|
||||
}
|
||||
return positions[0], ""
|
||||
}
|
||||
|
||||
func containsPosition(positions []int, want int) bool {
|
||||
for _, position := range positions {
|
||||
if position == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func proposalIssue(groupIndex int, category string) string {
|
||||
return "group " + strconv.Itoa(groupIndex) + ": " + category
|
||||
}
|
||||
|
||||
func applySafeGroups(records []normalizedRecord, groups []safeProposalGroup, order shared.SourceRefOrder) ([]normalizedRecord, []contracts.Warning) {
|
||||
byMember := make(map[int]safeProposalGroup, len(groups)*2)
|
||||
for _, group := range groups {
|
||||
for _, member := range group.members {
|
||||
byMember[member] = group
|
||||
}
|
||||
}
|
||||
output := make([]normalizedRecord, 0, len(records)-len(groups))
|
||||
warnings := make([]contracts.Warning, 0, len(groups))
|
||||
for index, record := range records {
|
||||
group, grouped := byMember[index]
|
||||
if !grouped {
|
||||
output = append(output, cloneRecord(record))
|
||||
continue
|
||||
}
|
||||
if group.members[0] != index {
|
||||
continue
|
||||
}
|
||||
consolidated := consolidateSemanticGroup(records, group, order)
|
||||
output = append(output, consolidated)
|
||||
warnings = append(warnings, semanticDuplicateWarning(consolidated, records[group.canonical]))
|
||||
}
|
||||
return output, warnings
|
||||
}
|
||||
|
||||
func consolidateSemanticGroup(records []normalizedRecord, group safeProposalGroup, order shared.SourceRefOrder) normalizedRecord {
|
||||
output := cloneRecord(records[group.members[0]])
|
||||
output.npc.Name = records[group.canonical].npc.Name
|
||||
for _, member := range group.members[1:] {
|
||||
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.inputIndexes = sortedUniqueIndexes(output.inputIndexes)
|
||||
output.npc.SourceRefs = order.Canonicalize(output.npc.SourceRefs)
|
||||
output.npc.ID = identity.DeriveID(output.npc.Name)
|
||||
return output
|
||||
}
|
||||
|
||||
func semanticDuplicateWarning(record normalizedRecord, canonical normalizedRecord) contracts.Warning {
|
||||
details := make([]string, 0, len(record.inputIndexes)+1)
|
||||
for _, inputIndex := range record.inputIndexes {
|
||||
details = append(details, fmt.Sprintf("input index %d", inputIndex))
|
||||
}
|
||||
if canonical.earliest != record.earliest {
|
||||
details = append(details, fmt.Sprintf("canonical display name from input index %d", canonical.earliest))
|
||||
}
|
||||
return contracts.Warning{
|
||||
Scope: npcScope(record.earliest),
|
||||
ReasonCode: ReasonCodeDuplicateNPCCollapsed,
|
||||
Message: diagnostics.Aggregate("semantic duplicate consolidation", details),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user