Gate combat extraction on scene descriptions

This commit is contained in:
2026-07-25 19:23:19 +00:00
parent 7e35915b3e
commit 989f2c220b
8 changed files with 433 additions and 49 deletions

View File

@@ -9,17 +9,21 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const (
Key = "dnd/combat-turns"
mappingPolicy = "dnd.combat_turns.extract_mapping.v2"
Key = "dnd/combat-turns"
mappingPolicy = "dnd.combat_turns.extract_mapping.v2"
sceneGatePolicy = "dnd.combat_turns.scene_gate.v1"
)
const (
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
NPCRegistryMaxBytes = npcregistry.MaxBytes
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
NPCRegistryMaxBytes = npcregistry.MaxBytes
SceneDescriptionReferenceSlot = sceneregistry.ReferenceSlot
SceneDescriptionMaxBytes = sceneregistry.MaxBytes
)
var requiredCapabilities = []string{
@@ -47,6 +51,14 @@ func referenceSlots() []contracts.ReferenceSlot {
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.NPCListKind},
MaxBytes: NPCRegistryMaxBytes,
})
slots = append(slots, contracts.ReferenceSlot{
Name: SceneDescriptionReferenceSlot,
Description: "Required scene descriptions that gate combat eligibility and are not combat evidence.",
Required: true,
AcceptedMediaTypes: []string{"application/json"},
AcceptedArtifactKinds: []contracts.ArtifactKind{dnd.SceneDescriptionListKind},
MaxBytes: SceneDescriptionMaxBytes,
})
sort.Slice(slots, func(i, j int) bool { return slots[i].Name < slots[j].Name })
return slots
}
@@ -60,6 +72,7 @@ type Options struct{}
type Extractor struct {
llm contracts.StructuredLLMClient
npcResolver *npcregistry.Resolver
sceneResolver *sceneregistry.Resolver
promptSHA string
responseSchemaSHA string
}
@@ -79,6 +92,10 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
if err != nil {
return nil, extractorErrorf("prepare NPC registry prompt input: %w", err)
}
sceneResolver, err := sceneregistry.NewResolver(referenceSet)
if err != nil {
return nil, extractorErrorf("prepare scene eligibility: %w", err)
}
promptSHA, err := scriptoriumPromptMetadata()
if err != nil {
return nil, extractorErrorf("load prompt metadata: %w", err)
@@ -90,6 +107,7 @@ func New(llmClient contracts.StructuredLLMClient, _ Options, references ...contr
return &Extractor{
llm: llmClient,
npcResolver: npcResolver,
sceneResolver: sceneResolver,
promptSHA: promptSHA,
responseSchemaSHA: responseSchema.SHA256,
}, nil
@@ -108,6 +126,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
"prompt_version": SchemaVersion,
"prompt_sha256": e.promptSHA,
"mapping_policy": mappingPolicy,
"scene_gate_policy": sceneGatePolicy,
"response_schema_key": string(ResponseSchemaKey),
"response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName,
@@ -119,6 +138,11 @@ func (e *Extractor) ManifestMetadata() map[string]any {
metadata["npc_registry_digest"] = seeded.Digest()
metadata["npc_count"] = seeded.Count()
}
seededScenes := e.sceneResolver.Seeded()
if seededScenes.Bound() {
metadata["scene_eligibility_digest"] = seededScenes.EligibilityDigest()
metadata["scene_description_count"] = seededScenes.Count()
}
return metadata
}
@@ -130,9 +154,12 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
{Name: "prompt", Value: e.promptSHA},
{Name: "response_schema", Value: e.responseSchemaSHA},
{Name: "mapping_policy", Value: mappingPolicy},
{Name: "scene_gate_policy", Value: sceneGatePolicy},
}
seeded := e.npcResolver.Seeded()
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "npc_registry", Value: seeded.ProjectionDigest()})
seededScenes := e.sceneResolver.Seeded()
fingerprints = append(fingerprints, pipeline.CheckpointFingerprint{Name: "scene_eligibility", Value: seededScenes.EligibilityDigest()})
return fingerprints
}
@@ -147,6 +174,29 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
if err != nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
}
if _, ok := req.References.Slots[SceneDescriptionReferenceSlot]; !ok {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("required scene descriptions reference is missing")
}
scenes, err := e.sceneResolver.Resolve(req.References)
if err != nil {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("resolve scene eligibility: %w", err)
}
match := scenes.Match(req.Chunk)
switch match.State {
case sceneregistry.MatchExact:
if match.Kind != dnd.SceneKindCombat {
return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{}}}, nil
}
case sceneregistry.MatchMissing, sceneregistry.MatchMismatched:
return contracts.TypedExtractionResult[dnd.CombatTurnList]{
Value: dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{}},
Warnings: []contracts.Warning{{
Scope: SceneDescriptionReferenceSlot,
ReasonCode: "scene_classification_unavailable",
Message: "No exact scene classification was available; combat extraction was skipped.",
}},
}, nil
}
order := shared.NewSourceRefOrder(req.Source)
npcRegistry, err := e.npcResolver.Resolve(req.References)
if err != nil {