2026-07-21 05:08:02 +00:00
package combatturns
import (
"context"
"fmt"
"sort"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"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"
2026-07-25 19:23:19 +00:00
sceneregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/scenedescriptions/registry"
2026-07-21 05:08:02 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
const (
2026-07-25 19:23:19 +00:00
Key = "dnd/combat-turns"
2026-08-28 13:45:21 +00:00
mappingPolicy = "dnd.combat_turns.extract_mapping.v3"
2026-07-25 19:23:19 +00:00
sceneGatePolicy = "dnd.combat_turns.scene_gate.v1"
2026-07-21 05:08:02 +00:00
)
const (
2026-07-25 19:23:19 +00:00
NPCRegistryReferenceSlot = npcregistry . ReferenceSlot
NPCRegistryMaxBytes = npcregistry . MaxBytes
SceneDescriptionReferenceSlot = sceneregistry . ReferenceSlot
SceneDescriptionMaxBytes = sceneregistry . MaxBytes
2026-07-21 05:08:02 +00:00
)
var requiredCapabilities = [ ] string {
"chunks" ,
"source.transcript" ,
}
var providedCapabilities = [ ] string {
"dnd.combat_turns" ,
}
var referenceSlotDescriptions = shared . ReferenceSlotDescriptions {
Glossary : "Optional campaign glossary reference material used only for disambiguation." ,
Party : "Optional party roster reference material used only for disambiguation." ,
Players : "Optional player list reference material used only for disambiguation." ,
Roster : "Deprecated alias for party roster reference material used only for disambiguation." ,
}
func referenceSlots ( ) [ ] contracts . ReferenceSlot {
slots := shared . ReferenceSlots ( referenceSlotDescriptions )
slots = append ( slots , contracts . ReferenceSlot {
2026-07-21 21:05:28 +00:00
Name : NPCRegistryReferenceSlot ,
2026-07-22 19:14:14 +00:00
Description : "Optional normalized NPC registry used for canonical actor grounding." ,
2026-07-21 21:05:28 +00:00
AcceptedMediaTypes : [ ] string { "application/json" } ,
2026-08-05 18:25:04 +00:00
AcceptedArtifactKinds : [ ] contracts . ArtifactKind { dnd . NPCRegistryKind } ,
2026-07-21 21:05:28 +00:00
MaxBytes : NPCRegistryMaxBytes ,
2026-07-21 05:08:02 +00:00
} )
2026-07-25 19:23:19 +00:00
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 ,
} )
2026-07-21 05:08:02 +00:00
sort . Slice ( slots , func ( i , j int ) bool { return slots [ i ] . Name < slots [ j ] . Name } )
return slots
}
var _ contracts . Extractor [ dnd . CombatTurnList ] = ( * Extractor ) ( nil )
var _ contracts . ManifestMetadataProvider = ( * Extractor ) ( nil )
var _ pipeline . CheckpointFingerprintProvider = ( * Extractor ) ( nil )
type Options struct { }
type Extractor struct {
llm contracts . StructuredLLMClient
2026-07-21 22:12:01 +00:00
npcResolver * npcregistry . Resolver
2026-07-25 19:23:19 +00:00
sceneResolver * sceneregistry . Resolver
2026-07-21 05:08:02 +00:00
promptSHA string
responseSchemaSHA string
}
func New ( llmClient contracts . StructuredLLMClient , _ Options , references ... contracts . ReferenceSet ) ( * Extractor , error ) {
if llmClient == nil {
return nil , extractorErrorf ( "LLM client must not be nil" )
}
if len ( references ) > 1 {
return nil , extractorErrorf ( "at most one reference set may be supplied" )
}
var referenceSet contracts . ReferenceSet
if len ( references ) == 1 {
referenceSet = references [ 0 ]
}
2026-07-21 22:12:01 +00:00
npcResolver , err := npcregistry . NewResolver ( referenceSet )
2026-07-21 05:08:02 +00:00
if err != nil {
return nil , extractorErrorf ( "prepare NPC registry prompt input: %w" , err )
}
2026-07-25 19:23:19 +00:00
sceneResolver , err := sceneregistry . NewResolver ( referenceSet )
if err != nil {
return nil , extractorErrorf ( "prepare scene eligibility: %w" , err )
}
2026-07-28 16:42:41 +00:00
promptSHA , err := promptAssetMetadata ( )
2026-07-21 05:08:02 +00:00
if err != nil {
return nil , extractorErrorf ( "load prompt metadata: %w" , err )
}
responseSchema , err := loadResponseSchema ( )
if err != nil {
return nil , extractorErrorf ( "load response schema: %w" , err )
}
return & Extractor {
llm : llmClient ,
2026-07-21 22:12:01 +00:00
npcResolver : npcResolver ,
2026-07-25 19:23:19 +00:00
sceneResolver : sceneResolver ,
2026-07-21 05:08:02 +00:00
promptSHA : promptSHA ,
responseSchemaSHA : responseSchema . SHA256 ,
} , nil
}
func ( e * Extractor ) Key ( ) string { return Key }
func ( e * Extractor ) ReferenceSlots ( ) [ ] contracts . ReferenceSlot { return referenceSlots ( ) }
func ( e * Extractor ) ManifestMetadata ( ) map [ string ] any {
if e == nil {
return nil
}
metadata := map [ string ] any {
"prompt_id" : PromptID ,
"prompt_version" : SchemaVersion ,
"prompt_sha256" : e . promptSHA ,
"mapping_policy" : mappingPolicy ,
2026-07-25 19:23:19 +00:00
"scene_gate_policy" : sceneGatePolicy ,
2026-07-21 05:08:02 +00:00
"response_schema_key" : string ( ResponseSchemaKey ) ,
"response_schema_id" : ResponseSchemaID ,
"response_schema_name" : ResponseSchemaName ,
"response_schema_version" : SchemaVersion ,
"response_schema_sha256" : e . responseSchemaSHA ,
}
2026-07-21 22:12:01 +00:00
seeded := e . npcResolver . Seeded ( )
if seeded . Bound ( ) {
metadata [ "npc_registry_digest" ] = seeded . Digest ( )
metadata [ "npc_count" ] = seeded . Count ( )
2026-07-21 05:08:02 +00:00
}
2026-07-25 19:23:19 +00:00
seededScenes := e . sceneResolver . Seeded ( )
if seededScenes . Bound ( ) {
metadata [ "scene_eligibility_digest" ] = seededScenes . EligibilityDigest ( )
metadata [ "scene_description_count" ] = seededScenes . Count ( )
}
2026-07-21 05:08:02 +00:00
return metadata
}
func ( e * Extractor ) CheckpointFingerprints ( ) [ ] pipeline . CheckpointFingerprint {
if e == nil {
return nil
}
fingerprints := [ ] pipeline . CheckpointFingerprint {
{ Name : "prompt" , Value : e . promptSHA } ,
{ Name : "response_schema" , Value : e . responseSchemaSHA } ,
{ Name : "mapping_policy" , Value : mappingPolicy } ,
2026-07-25 19:23:19 +00:00
{ Name : "scene_gate_policy" , Value : sceneGatePolicy } ,
2026-07-21 05:08:02 +00:00
}
2026-07-21 22:12:01 +00:00
seeded := e . npcResolver . Seeded ( )
2026-07-22 18:51:26 +00:00
fingerprints = append ( fingerprints , pipeline . CheckpointFingerprint { Name : "npc_registry" , Value : seeded . ProjectionDigest ( ) } )
2026-07-25 19:23:19 +00:00
seededScenes := e . sceneResolver . Seeded ( )
fingerprints = append ( fingerprints , pipeline . CheckpointFingerprint { Name : "scene_eligibility" , Value : seededScenes . EligibilityDigest ( ) } )
2026-07-21 05:08:02 +00:00
return fingerprints
}
func ( e * Extractor ) Extract ( ctx context . Context , req contracts . TypedExtractionRequest ) ( contracts . TypedExtractionResult [ dnd . CombatTurnList ] , error ) {
if e == nil {
return contracts . TypedExtractionResult [ dnd . CombatTurnList ] { } , extractorErrorf ( "extractor must not be nil" )
}
if e . llm == nil {
return contracts . TypedExtractionResult [ dnd . CombatTurnList ] { } , extractorErrorf ( "LLM client must not be nil" )
}
2026-07-24 14:38:42 +00:00
sourceInput , err := shared . PrepareChunkExtraction ( ctx , req )
2026-07-21 05:08:02 +00:00
if err != nil {
2026-07-21 14:27:39 +00:00
return contracts . TypedExtractionResult [ dnd . CombatTurnList ] { } , extractorErrorf ( "%w" , err )
2026-07-21 05:08:02 +00:00
}
2026-07-25 19:23:19 +00:00
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 { } } ,
2026-08-27 16:04:32 +00:00
Diagnostics : [ ] contracts . ProducerDiagnostic { {
Disposition : contracts . DiagnosticDispositionWarning ,
Category : contracts . DiagnosticCategoryDegradation ,
ReasonCode : "scene_classification_unavailable" ,
OccurrenceCount : 1 ,
Samples : [ ] contracts . DiagnosticSample { { Scope : SceneDescriptionReferenceSlot , Message : "No exact scene classification was available; combat extraction was skipped." } } ,
2026-07-25 19:23:19 +00:00
} } ,
} , nil
2026-07-25 21:08:24 +00:00
default :
return contracts . TypedExtractionResult [ dnd . CombatTurnList ] { } , extractorErrorf ( "unsupported scene eligibility match state %q" , match . State )
2026-07-25 19:23:19 +00:00
}
2026-07-24 14:33:34 +00:00
order := shared . NewSourceRefOrder ( req . Source )
2026-07-21 22:12:01 +00:00
npcRegistry , err := e . npcResolver . Resolve ( req . References )
if err != nil {
return contracts . TypedExtractionResult [ dnd . CombatTurnList ] { } , extractorErrorf ( "resolve NPC registry: %w" , err )
}
2026-07-21 05:08:02 +00:00
var response extractionResponse
inputs := shared . PromptInputs ( sourceInput , req . References )
2026-07-21 22:12:01 +00:00
inputs [ NPCRegistryReferenceSlot ] = npcRegistry . PromptInput ( )
2026-08-26 23:59:05 +00:00
completion , err := e . llm . CompleteStructured ( ctx , contracts . StructuredCompletionRequest {
2026-08-25 19:40:44 +00:00
StageName : Key ,
PromptID : PromptID ,
PromptVersion : SchemaVersion ,
ProfileID : req . LLMProfile ,
SessionID : req . SessionID ,
StructuredOutputRepairAttempts : req . StructuredOutputRepairAttempts ,
2026-08-26 23:59:05 +00:00
Correction : req . Correction ,
2026-08-25 19:40:44 +00:00
Inputs : inputs ,
2026-08-26 23:59:05 +00:00
} , & response )
if err != nil {
2026-07-21 05:08:02 +00:00
return contracts . TypedExtractionResult [ dnd . CombatTurnList ] { } , extractorErrorf ( "complete structured output: %w" , err )
}
2026-08-26 23:59:05 +00:00
candidate , err := shared . ModelCandidateFromResponse ( completion )
if err != nil {
return contracts . TypedExtractionResult [ dnd . CombatTurnList ] { } , extractorErrorf ( "capture model candidate: %w" , err )
}
2026-07-24 14:33:34 +00:00
canonicalizeResponse ( & response , order , req . Source . ID )
2026-08-26 23:59:05 +00:00
return contracts . TypedExtractionResult [ dnd . CombatTurnList ] { Value : canonicalCombatTurnList ( response , req . Source . ID ) , ModelCandidate : candidate } , nil
2026-07-21 05:08:02 +00:00
}
func ModuleSpec ( ) pipeline . ModuleSpec {
return pipeline . ModuleSpec {
2026-08-26 23:59:05 +00:00
Key : Key ,
Stage : pipeline . StageExtract ,
ExecutionClass : contracts . ExecutionClassLLMBacked ,
CorrectionProtocol : contracts . CorrectionProtocolSingleResponseV1 ,
Requires : append ( [ ] string ( nil ) , requiredCapabilities ... ) ,
Provides : append ( [ ] string ( nil ) , providedCapabilities ... ) ,
ArtifactKind : dnd . CombatTurnListKind ,
ReferenceSlots : referenceSlots ( ) ,
2026-07-21 05:08:02 +00:00
}
}
func Register ( registry * pipeline . ExtractorRegistry ) error {
return pipeline . RegisterExtractorBuilder ( registry , ModuleSpec ( ) , validateOptions , func ( request pipeline . BuildRequest ) ( contracts . Extractor [ dnd . CombatTurnList ] , error ) {
options , err := DecodeOptions ( request . Options )
if err != nil {
return nil , err
}
return New ( request . Dependencies . LLM , options , request . References )
} )
}
func validateOptions ( options map [ string ] any ) error {
_ , err := DecodeOptions ( options )
return err
}
func DecodeOptions ( options map [ string ] any ) ( Options , error ) {
if err := pipeline . RejectUnknownOptions ( options ) ; err != nil {
return Options { } , extractorErrorf ( "%w" , err )
}
return Options { } , nil
}
func extractorErrorf ( format string , args ... any ) error {
return fmt . Errorf ( "dnd combat turns extractor: " + format , args ... )
}