234 lines
9.3 KiB
Go
234 lines
9.3 KiB
Go
package combat_semantics
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"unicode/utf8"
|
|
|
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/scenedescriptions/shape"
|
|
)
|
|
|
|
const (
|
|
Key = "extract/dnd/scene-descriptions/combat_semantics"
|
|
ReasonCodeActiveCombatNotClassified = "scene_active_combat_not_classified"
|
|
ReasonCodeCombatClassificationUnsupported = "scene_combat_classification_unsupported"
|
|
combatPolicy = "dnd.scene_descriptions.combat_semantics.v1"
|
|
maximumExplanationRunes = 512
|
|
)
|
|
|
|
type Options struct{}
|
|
|
|
type completionResponse struct {
|
|
Verdict string `json:"verdict"`
|
|
Explanation string `json:"explanation"`
|
|
}
|
|
|
|
type Validator struct {
|
|
llm contracts.StructuredLLMClient
|
|
promptSHA string
|
|
responseSchemaSHA string
|
|
}
|
|
|
|
var _ contracts.TypedValidator[dnd.SceneDescriptionList] = (*Validator)(nil)
|
|
var _ contracts.ManifestMetadataProvider = (*Validator)(nil)
|
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
|
|
|
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Validator, error) {
|
|
if llmClient == nil {
|
|
return nil, validatorErrorf("LLM client must not be nil")
|
|
}
|
|
promptSHA, err := promptAssetMetadata()
|
|
if err != nil {
|
|
return nil, validatorErrorf("load prompt metadata: %w", err)
|
|
}
|
|
responseSchema, err := loadResponseSchema()
|
|
if err != nil {
|
|
return nil, validatorErrorf("load response schema: %w", err)
|
|
}
|
|
return &Validator{llm: llmClient, promptSHA: promptSHA, responseSchemaSHA: responseSchema.SHA256}, nil
|
|
}
|
|
|
|
func (v *Validator) Name() string { return Key }
|
|
|
|
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
|
return contracts.ExecutionClassLLMBacked
|
|
}
|
|
|
|
func (v *Validator) ManifestMetadata() map[string]any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
return map[string]any{
|
|
"prompt_id": PromptID,
|
|
"prompt_version": SchemaVersion,
|
|
"prompt_sha256": v.promptSHA,
|
|
"response_schema_key": string(ResponseSchemaKey),
|
|
"response_schema_id": ResponseSchemaID,
|
|
"response_schema_name": ResponseSchemaName,
|
|
"response_schema_version": SchemaVersion,
|
|
"response_schema_sha256": v.responseSchemaSHA,
|
|
"combat_policy": combatPolicy,
|
|
}
|
|
}
|
|
|
|
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
return []pipeline.CheckpointFingerprint{
|
|
{Name: "prompt", Value: v.promptSHA},
|
|
{Name: "response_schema", Value: v.responseSchemaSHA},
|
|
{Name: "combat_policy", Value: combatPolicy},
|
|
}
|
|
}
|
|
|
|
func (v *Validator) Validate(ctx context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
|
|
if v == nil {
|
|
return contracts.ValidationResult{}, validatorErrorf("validator must not be nil")
|
|
}
|
|
if v.llm == nil {
|
|
return contracts.ValidationResult{}, validatorErrorf("LLM client must not be nil")
|
|
}
|
|
sourceInput, scene, err := validateRequest(ctx, req)
|
|
if err != nil {
|
|
return contracts.ValidationResult{}, err
|
|
}
|
|
|
|
var response completionResponse
|
|
_, err = v.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
|
StageName: Key,
|
|
PromptID: PromptID,
|
|
PromptVersion: SchemaVersion,
|
|
ProfileID: req.LLMProfile,
|
|
SessionID: req.SessionID,
|
|
StructuredOutputRepairAttempts: req.StructuredOutputRepairAttempts,
|
|
Inputs: contracts.LLMInputSet{
|
|
"transcript": shared.TranscriptPromptMaterial(sourceInput),
|
|
"proposed_kind": contracts.NewLLMInputMaterial("proposed_kind", "text/plain", []byte(scene.Kind), "", ""),
|
|
},
|
|
}, &response)
|
|
if err != nil {
|
|
return contracts.ValidationResult{}, validatorErrorf("complete structured output: %w", err)
|
|
}
|
|
return interpretResponse(response, scene.Kind)
|
|
}
|
|
|
|
func validateRequest(ctx context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.LLMInputMaterial, dnd.SceneDescription, error) {
|
|
if ctx == nil {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("context must not be nil")
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("context error before validation: %w", err)
|
|
}
|
|
if req.Stage != string(pipeline.StageExtract) {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("validation stage must be extract, got %q", req.Stage)
|
|
}
|
|
sourceInput, err := shared.PrepareChunkExtraction(ctx, contracts.TypedExtractionRequest{Source: req.Source, Chunk: req.Chunk, SourceInput: req.SourceInput})
|
|
if err != nil {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("prepare chunk source input: %w", err)
|
|
}
|
|
if strings.TrimSpace(req.Chunk.ID) == "" || req.Chunk.ID != strings.TrimSpace(req.Chunk.ID) {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("current chunk ID must be nonblank and trimmed")
|
|
}
|
|
if req.Chunk.SourceID != req.Source.ID {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("current chunk source ID %q does not match source %q", req.Chunk.SourceID, req.Source.ID)
|
|
}
|
|
if err := source.NewDocumentIndex(req.Source).ValidateRef(req.Chunk.Ref); err != nil {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("validate current chunk source range: %w", err)
|
|
}
|
|
if err := shape.ValidateForStage(req.Value, req.Stage); err != nil {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("validate scene description shape: %w", err)
|
|
}
|
|
scene := req.Value.Scenes[0]
|
|
if scene.ID != req.Chunk.ID {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("scene ID must equal current chunk ID")
|
|
}
|
|
if scene.SourceRef != req.Chunk.Ref {
|
|
return contracts.LLMInputMaterial{}, dnd.SceneDescription{}, validatorErrorf("scene source range must equal current chunk range")
|
|
}
|
|
return sourceInput, scene, nil
|
|
}
|
|
|
|
func interpretResponse(response completionResponse, proposedKind dnd.SceneKind) (contracts.ValidationResult, error) {
|
|
explanation, err := validateExplanation(response.Explanation)
|
|
if err != nil {
|
|
return contracts.ValidationResult{}, err
|
|
}
|
|
switch response.Verdict {
|
|
case "approved":
|
|
return contracts.ValidationResult{Approved: true}, nil
|
|
case "combat_should_be_added":
|
|
if proposedKind == dnd.SceneKindCombat {
|
|
return contracts.ValidationResult{}, validatorErrorf("combat_should_be_added verdict is inconsistent with proposed combat kind")
|
|
}
|
|
return contracts.ValidationResult{
|
|
Approved: false,
|
|
ReasonCode: ReasonCodeActiveCombatNotClassified,
|
|
Message: "The current chunk contains substantive active combat that is not classified as combat.",
|
|
CorrectionGuidance: "Return kind: combat for this scene. " + explanation,
|
|
}, nil
|
|
case "combat_should_be_removed":
|
|
if proposedKind != dnd.SceneKindCombat {
|
|
return contracts.ValidationResult{}, validatorErrorf("combat_should_be_removed verdict is inconsistent with proposed non-combat kind")
|
|
}
|
|
return contracts.ValidationResult{
|
|
Approved: false,
|
|
ReasonCode: ReasonCodeCombatClassificationUnsupported,
|
|
Message: "The current chunk does not support a combat classification.",
|
|
CorrectionGuidance: "Choose the appropriate narrative, recap, or meta kind for this scene. " + explanation,
|
|
}, nil
|
|
default:
|
|
return contracts.ValidationResult{}, validatorErrorf("unsupported combat-semantics verdict %q", response.Verdict)
|
|
}
|
|
}
|
|
|
|
func validateExplanation(value string) (string, error) {
|
|
if !utf8.ValidString(value) {
|
|
return "", validatorErrorf("validator explanation must be valid UTF-8")
|
|
}
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", validatorErrorf("validator explanation must not be blank")
|
|
}
|
|
if trimmed != value {
|
|
return "", validatorErrorf("validator explanation must be trimmed")
|
|
}
|
|
if utf8.RuneCountInString(value) > maximumExplanationRunes {
|
|
return "", validatorErrorf("validator explanation exceeds %d Unicode code points", maximumExplanationRunes)
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
func Spec() pipeline.ValidatorSpec {
|
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassLLMBacked}
|
|
}
|
|
|
|
func Register(registry *pipeline.ValidatorRegistry) error {
|
|
return pipeline.RegisterTypedValidatorBuilder(registry, dnd.SceneDescriptionListKind, Spec(), validateOptions, func(request pipeline.BuildRequest) (contracts.TypedValidator[dnd.SceneDescriptionList], error) {
|
|
options, err := DecodeOptions(request.Options)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return New(request.Dependencies.LLM, options)
|
|
})
|
|
}
|
|
|
|
func DecodeOptions(options map[string]any) (Options, error) {
|
|
if err := pipeline.RejectUnknownOptions(options); err != nil {
|
|
return Options{}, err
|
|
}
|
|
return Options{}, nil
|
|
}
|
|
|
|
func validateOptions(options map[string]any) error { _, err := DecodeOptions(options); return err }
|
|
|
|
func validatorErrorf(format string, args ...any) error {
|
|
return fmt.Errorf("scene combat-semantics validator: "+format, args...)
|
|
}
|