Files

221 lines
7.3 KiB
Go
Raw Permalink Normal View History

2026-07-04 13:02:02 +00:00
package scenes
import (
"context"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
2026-07-17 05:05:48 +00:00
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
2026-07-04 13:02:02 +00:00
)
const Key = "dnd/scenes"
var requiredCapabilities = []string{
"source.transcript",
}
var providedCapabilities = []string{
"chunks",
}
2026-07-17 05:05:48 +00:00
var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
Party: "Optional party roster reference material used only for scene disambiguation.",
Players: "Optional player list reference material used only for scene disambiguation.",
Roster: "Deprecated alias for party roster reference material used only for scene disambiguation.",
}
2026-07-04 13:02:02 +00:00
var _ contracts.Chunker = (*Chunker)(nil)
var _ contracts.ManifestMetadataProvider = (*Chunker)(nil)
type Options struct{}
2026-07-04 13:02:02 +00:00
type Chunker struct {
llm contracts.StructuredLLMClient
}
func New(llmClient contracts.StructuredLLMClient, _ Options) (*Chunker, error) {
if llmClient == nil {
return nil, chunkerErrorf("LLM client must not be nil")
}
return &Chunker{llm: llmClient}, nil
2026-07-04 13:02:02 +00:00
}
func (c *Chunker) Key() string {
return Key
}
func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot {
2026-07-17 05:05:48 +00:00
return shared.ReferenceSlots(referenceSlotDescriptions)
}
2026-07-04 13:02:02 +00:00
func (c *Chunker) ManifestMetadata() map[string]any {
promptSHA, err := promptAssetMetadata()
2026-07-05 18:13:10 +00:00
if err != nil {
promptSHA = ""
2026-07-05 18:13:10 +00:00
}
2026-07-04 13:02:02 +00:00
metadata := map[string]any{
"prompt_id": PromptID,
2026-07-05 18:13:10 +00:00
"prompt_version": ResponseSchemaVersion,
"prompt_sha256": promptSHA,
2026-07-04 13:02:02 +00:00
"response_schema_key": string(ResponseSchemaKey),
"response_schema_id": ResponseSchemaID,
"response_schema_name": ResponseSchemaName,
}
if schema, err := loadResponseSchema(); err == nil {
metadata["response_schema_version"] = schema.Version
metadata["response_schema_sha256"] = schema.SHA256
}
return metadata
}
func (c *Chunker) Plan(ctx context.Context, req contracts.ChunkRequest) (contracts.ChunkPlanResult, error) {
2026-07-04 13:02:02 +00:00
if c == nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("chunker must not be nil")
2026-07-04 13:02:02 +00:00
}
if c.llm == nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("LLM client must not be nil")
}
2026-07-04 13:02:02 +00:00
if ctx == nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("context must not be nil")
2026-07-04 13:02:02 +00:00
}
if err := ctx.Err(); err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("context error before chunking: %w", err)
2026-07-04 13:02:02 +00:00
}
if req.Source == nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("source must not be nil")
2026-07-04 13:02:02 +00:00
}
if len(req.Source.Units) == 0 {
return contracts.ChunkPlanResult{}, chunkerErrorf("source units must not be empty")
2026-07-04 13:02:02 +00:00
}
if err := source.ValidateDocument(req.Source); err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("validate source document: %w", err)
2026-07-04 13:02:02 +00:00
}
var response chunkResponse
completion, err := c.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
2026-08-25 19:40:44 +00:00
StageName: Key,
PromptID: PromptID,
PromptVersion: ResponseSchemaVersion,
ProfileID: req.LLMProfile,
SessionID: req.SessionID,
StructuredOutputRepairAttempts: req.StructuredOutputRepairAttempts,
Correction: req.Correction,
2026-08-25 19:40:44 +00:00
Inputs: shared.PromptInputs(req.SourceInput, req.References),
}, &response)
if err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("complete structured output: %w", err)
2026-07-04 13:02:02 +00:00
}
candidate, err := shared.ModelCandidateFromResponse(completion)
if err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("capture model candidate: %w", err)
}
2026-07-04 13:02:02 +00:00
2026-07-24 00:35:10 +00:00
plan, err := planFromResponse(req.Source, response)
2026-07-04 13:02:02 +00:00
if err != nil {
return contracts.ChunkPlanResult{}, chunkerErrorf("malformed structured output: %w", err)
2026-07-04 13:02:02 +00:00
}
return contracts.ChunkPlanResult{Plan: plan, ModelCandidate: candidate}, nil
2026-07-04 13:02:02 +00:00
}
func ModuleSpec() pipeline.ModuleSpec {
return pipeline.ModuleSpec{
Key: Key,
Stage: pipeline.StageChunk,
ExecutionClass: contracts.ExecutionClassLLMBacked,
CorrectionProtocol: contracts.CorrectionProtocolSingleResponseV1,
Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...),
ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions),
2026-07-04 13:02:02 +00:00
}
}
func Register(registry *pipeline.ChunkerRegistry) error {
return registry.RegisterBuilderWithSpec(ModuleSpec(), validateOptions, func(request pipeline.BuildRequest) (contracts.Chunker, error) {
options, err := DecodeOptions(request.Options)
if err != nil {
return nil, err
}
return New(request.Dependencies.LLM, options)
2026-07-04 13:02:02 +00:00
})
}
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{}, chunkerErrorf("%w", err)
}
return Options{}, nil
}
2026-07-24 00:35:10 +00:00
func planFromResponse(doc *source.SourceDocument, response chunkResponse) (source.ChunkPlan, error) {
2026-07-04 13:02:02 +00:00
if response.Scenes == nil {
return source.ChunkPlan{}, fmt.Errorf("scenes must be present")
2026-07-04 13:02:02 +00:00
}
if len(response.Scenes) == 0 {
return source.ChunkPlan{}, fmt.Errorf("scenes must not be empty")
2026-07-04 13:02:02 +00:00
}
index := source.NewDocumentIndex(doc)
2026-07-04 13:02:02 +00:00
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
2026-07-04 13:02:02 +00:00
previousEnd := -1
for i, scene := range response.Scenes {
startUnitID, err := shared.ResolveUnitID(index, "start_unit_id", scene.StartUnitID)
2026-07-04 13:02:02 +00:00
if err != nil {
2026-07-24 00:35:10 +00:00
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
}
endUnitID, err := shared.ResolveUnitID(index, "end_unit_id", scene.EndUnitID)
2026-07-24 00:35:10 +00:00
if err != nil {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
2026-07-04 13:02:02 +00:00
}
startIndex, ok := index.Position(startUnitID)
2026-07-04 13:02:02 +00:00
if !ok {
2026-07-24 00:35:10 +00:00
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID)
2026-07-04 13:02:02 +00:00
}
endIndex, ok := index.Position(endUnitID)
2026-07-04 13:02:02 +00:00
if !ok {
2026-07-24 00:35:10 +00:00
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID)
2026-07-04 13:02:02 +00:00
}
if startIndex > endIndex {
2026-07-24 00:35:10 +00:00
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d appears after end_unit_id %d", i, startUnitID, endUnitID)
2026-07-04 13:02:02 +00:00
}
if i == 0 && startIndex != 0 {
return source.ChunkPlan{}, fmt.Errorf("first scene must start at first source unit %d", doc.Units[0].ID)
2026-07-04 13:02:02 +00:00
}
if i > 0 {
if startIndex <= previousEnd {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] overlaps previous scene", i)
2026-07-04 13:02:02 +00:00
}
if startIndex > previousEnd+1 {
return source.ChunkPlan{}, fmt.Errorf("scene[%d] leaves a gap after previous scene", i)
2026-07-04 13:02:02 +00:00
}
}
previousEnd = endIndex
ranges = append(ranges, source.ChunkRange{
2026-07-24 00:35:10 +00:00
StartUnitID: startUnitID,
EndUnitID: endUnitID,
2026-07-04 13:02:02 +00:00
})
}
if previousEnd != len(doc.Units)-1 {
return source.ChunkPlan{}, fmt.Errorf("final scene must end at final source unit %d", doc.Units[len(doc.Units)-1].ID)
2026-07-04 13:02:02 +00:00
}
return source.ChunkPlan{
SourceDigest: doc.Digest,
Ranges: ranges,
}, nil
}
2026-07-04 13:02:02 +00:00
func chunkerErrorf(format string, args ...any) error {
return fmt.Errorf("dnd scenes chunker: "+format, args...)
}