Organize D&D extensions by domain

This commit is contained in:
2026-07-17 05:05:48 +00:00
parent a81b9f1e1f
commit 15c369c509
64 changed files with 469 additions and 194 deletions

View File

@@ -80,7 +80,9 @@ configuration facts in [Configuration](../config.md#defaults).
`AssetRegistry` combines caller-owned prompt filesystems under stable prefixes `AssetRegistry` combines caller-owned prompt filesystems under stable prefixes
and rejects invalid or conflicting registrations. Production module packages and rejects invalid or conflicting registrations. Production module packages
register their own prompt and schema assets; generic framework code contains no register their own prompt and schema assets; generic framework code contains no
D&D prompt content. D&D prompt content. `internal/framework/promptfs` provides the domain-neutral
filesystem composition helper used to combine module-owned files with shared
domain prompt fragments.
Schema helpers load embedded JSON Schema with identity and digest metadata, Schema helpers load embedded JSON Schema with identity and digest metadata,
return defensive copies, and expose a diagnostics map that omits schema bytes. return defensive copies, and expose a diagnostics map that omits schema bytes.

View File

@@ -1,7 +1,7 @@
# Module And Validator Internals # Module And Validator Internals
Production module implementations and generic validators live under Production module and validator implementations live under their domain-first
`internal/modules`; the current D&D validators live under `internal/validators`. trees in `internal/modules`.
The selectable keys, configuration options, reference slots, and default The selectable keys, configuration options, reference slots, and default
validator chain are canonical in the validator chain are canonical in the
[module](../config.md#implemented-production-modules) and [module](../config.md#implemented-production-modules) and
@@ -26,10 +26,10 @@ that agreement. Runtime delivery uses the corresponding stage request's
LLM-backed extensions own their prompt definitions and response schemas under LLM-backed extensions own their prompt definitions and response schemas under
package-local embedded assets. Shared filesystem composition belongs in package-local embedded assets. Shared filesystem composition belongs in
`internal/modules/sharedassets`; reusable D&D prompt fragments, reference `internal/framework/promptfs`; reusable D&D prompt fragments, reference
declarations, prompt-input assembly, and source-unit helpers belong in declarations, prompt-input assembly, and source-unit helpers belong in
`internal/modules/sharedassets/dnd`. Stage contracts expose only Notarius `internal/modules/dnd/shared`. Stage contracts expose only Notarius structured-
structured-completion types, not Scriptorium public types. completion types, not Scriptorium public types.
Reference material may inform a module or prompt but must not become source Reference material may inform a module or prompt but must not become source
evidence. The resolver and materializer behavior is described in evidence. The resolver and materializer behavior is described in
@@ -62,7 +62,7 @@ The accepted options and defaults are defined in
[Configuration](../config.md#implemented-production-modules). Generic [Configuration](../config.md#implemented-production-modules). Generic
framework validation canonicalizes the returned unit slices before extraction. framework validation canonicalizes the returned unit slices before extraction.
### `internal/modules/chunk/dnd/scenes` ### `internal/modules/dnd/chunk/scenes`
The scene chunker prepares a structured Scriptorium request from the full The scene chunker prepares a structured Scriptorium request from the full
transcript, session, and optional D&D reference inputs. It validates the model's transcript, session, and optional D&D reference inputs. It validates the model's
@@ -82,7 +82,7 @@ file types remain canonical in [Configuration](../config.md).
## Extractor ## Extractor
### `internal/modules/extract/dnd/spells` ### `internal/modules/dnd/extract/spells`
The spell extractor prepares a structured request from one chunk, the The spell extractor prepares a structured request from one chunk, the
chunk-scoped source input, the session, and optional D&D reference inputs. It chunk-scoped source input, the session, and optional D&D reference inputs. It
@@ -141,7 +141,7 @@ from schema loading or compilation errors. Neither validator calls the LLM.
## D&D Spell Validators ## D&D Spell Validators
`internal/validators/extract/dnd/spells/spellpayload` provides strict decoding, `internal/modules/dnd/validate/spells/spellpayload` provides strict decoding,
shape checks, source-reference candidates, and cited-text lookup shared by the shape checks, source-reference candidates, and cited-text lookup shared by the
three validators. three validators.
@@ -199,5 +199,7 @@ does not inventory implementations.
defaults. defaults.
- `internal/cli/run_test.go`: production catalog, config resolution, and - `internal/cli/run_test.go`: production catalog, config resolution, and
end-to-end CLI composition. end-to-end CLI composition.
- `internal/modules/sharedassets/**/*_test.go`: shared prompt and reference - `internal/framework/promptfs/*_test.go` and
assembly. `internal/modules/dnd/shared/*_test.go`: shared prompt and reference assembly.
- `internal/modules/integration/*_test.go`: black-box composition across
production extension domains.

View File

@@ -42,6 +42,7 @@ a sorted set of artifact lanes before the runner constructs any stage module.
| `internal/framework/pipeline` | Registries, profile resolution, capability checks, reference materialization, validator-chain resolution, retries, orchestration, warnings, and manifest population. | | `internal/framework/pipeline` | Registries, profile resolution, capability checks, reference materialization, validator-chain resolution, retries, orchestration, warnings, and manifest population. |
| `internal/framework/validate` | Shared validator decision and cardinality helpers. | | `internal/framework/validate` | Shared validator decision and cardinality helpers. |
| `internal/framework/llm` | Scriptorium-backed structured completions, prompt/schema registration, scheduling, profile recording, and secret redaction. | | `internal/framework/llm` | Scriptorium-backed structured completions, prompt/schema registration, scheduling, profile recording, and secret redaction. |
| `internal/framework/promptfs` | Builds module prompt filesystems from module-owned and caller-provided shared prompt assets. |
| `internal/framework/checkpoint` | Workspace-backed checkpoint loading, recording, and payload serialization. | | `internal/framework/checkpoint` | Workspace-backed checkpoint loading, recording, and payload serialization. |
| `internal/framework/debug` | Workspace-backed framework and LLM debug recording. | | `internal/framework/debug` | Workspace-backed framework and LLM debug recording. |
@@ -60,26 +61,25 @@ Configuration. The implemented module packages are:
| --- | --- | | --- | --- |
| `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. | | `internal/modules/seriatim/input/transcript` | Parses the supported Seriatim transcript format into the generic source model. |
| `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. | | `internal/modules/generic/chunk/units` | Splits ordered source units by unit count and overlap. |
| `internal/modules/chunk/dnd/scenes` | Produces contiguous D&D scene chunks from structured model output. | | `internal/modules/dnd/chunk/scenes` | Produces contiguous D&D scene chunks from structured model output. |
| `internal/modules/extract/dnd/spells` | Produces source-grounded D&D spell-cast raw output. | | `internal/modules/dnd/extract/spells` | Produces source-grounded D&D spell-cast raw output. |
| `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. | | `internal/modules/generic/merge/appendorder` | Combines accepted extraction results in chunk order. |
| `internal/modules/generic/normalize/noop` | Preserves accepted merged output. | | `internal/modules/generic/normalize/noop` | Preserves accepted merged output. |
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. | | `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, and rejections as logical JSON files. |
`internal/modules/sharedassets` composes shared prompt filesystems. `internal/modules/dnd/shared` owns reusable D&D prompt fragments,
`internal/modules/sharedassets/dnd` owns reusable D&D prompt fragments,
reference declarations, prompt input assembly, and source-unit reference reference declarations, prompt input assembly, and source-unit reference
helpers. helpers. Domain-neutral prompt filesystem composition lives in
`internal/framework/promptfs`.
Generic validators under `internal/modules/generic/validate` provide Generic validators under `internal/modules/generic/validate` provide
unconditional test decisions, JSON syntax validation, and JSON Schema unconditional test decisions, JSON syntax validation, and JSON Schema
validation. D&D spell validators remain under `internal/validators` and provide validation. D&D spell validators under `internal/modules/dnd/validate/spells`
shape, source-reference, and source-relatedness decisions, with `spellpayload` provide shape, source-reference, and source-relatedness decisions, with
holding their shared parser and lookup helpers. `spellpayload` holding their shared parser and lookup helpers.
Production composition is grouped behind package-family registrars. Generic Production composition is grouped behind package-family registrars, and every
and Seriatim implementations use their domain-first trees; D&D implementations implemented production extension uses its domain-first tree:
remain in their current stage-oriented packages:
| Package | Implemented responsibility | | Package | Implemented responsibility |
| --- | --- | | --- | --- |

View File

@@ -18,13 +18,13 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
frameworkllm "gitea.maximumdirect.net/eric/notarius/internal/framework/llm" frameworkllm "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json" validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json"
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema" validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_relatedness"
) )
func TestProductionCompatibilitySnapshot(t *testing.T) { func TestProductionCompatibilitySnapshot(t *testing.T) {

View File

@@ -22,8 +22,11 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/chunk/units" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/chunk/units"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
@@ -33,9 +36,6 @@ import (
validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json" validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json"
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema" validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema"
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript" "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_relatedness"
"gitea.maximumdirect.net/eric/scriptorium" "gitea.maximumdirect.net/eric/scriptorium"
) )

View File

@@ -1,4 +1,4 @@
package sharedassets package promptfs
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package sharedassets package promptfs
import ( import (
"io/fs" "io/fs"

View File

@@ -1,30 +0,0 @@
package scenes
import "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
type chunkResponse struct {
Scenes []sceneResponse `json:"scenes"`
BoundaryCaveats []string `json:"boundary_caveats"`
}
type sceneResponse struct {
StartUnitID dnd.UnitRef `json:"start_unit_id"`
EndUnitID dnd.UnitRef `json:"end_unit_id"`
ShortTitle string `json:"short_title"`
PrimaryMode string `json:"primary_mode"`
MainParticipants []string `json:"main_participants"`
Summary string `json:"summary"`
BoundaryNote string `json:"boundary_note"`
BoundaryConfidence string `json:"boundary_confidence"`
}
type normalizedScene struct {
StartUnitID int
EndUnitID int
ShortTitle string
PrimaryMode string
MainParticipants []string
Summary string
BoundaryNote string
BoundaryConfidence string
}

View File

@@ -9,7 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const Key = "dnd/scenes" const Key = "dnd/scenes"
@@ -23,7 +23,7 @@ var providedCapabilities = []string{
"chunks.scenes", "chunks.scenes",
} }
var referenceSlotDescriptions = dnd.ReferenceSlotDescriptions{ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for scene disambiguation.", Glossary: "Optional campaign glossary reference material used only for scene disambiguation.",
Party: "Optional party roster 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.", Players: "Optional player list reference material used only for scene disambiguation.",
@@ -44,7 +44,7 @@ func (c *Chunker) Key() string {
} }
func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot { func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot {
return dnd.ReferenceSlots(referenceSlotDescriptions) return shared.ReferenceSlots(referenceSlotDescriptions)
} }
func (c *Chunker) ManifestMetadata() map[string]any { func (c *Chunker) ManifestMetadata() map[string]any {
@@ -100,7 +100,7 @@ func (c *Chunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contra
PromptVersion: ResponseSchemaVersion, PromptVersion: ResponseSchemaVersion,
ProfileID: req.LLMProfile, ProfileID: req.LLMProfile,
SessionID: req.SessionID, SessionID: req.SessionID,
Inputs: dnd.PromptInputs(req.SourceInput, req.References), Inputs: shared.PromptInputs(req.SourceInput, req.References),
}, &response); err != nil { }, &response); err != nil {
return contracts.ChunkResult{}, chunkerErrorf("complete structured output: %w", err) return contracts.ChunkResult{}, chunkerErrorf("complete structured output: %w", err)
} }
@@ -125,7 +125,7 @@ func ModuleSpec() pipeline.ModuleSpec {
Stage: pipeline.StageChunk, Stage: pipeline.StageChunk,
Requires: append([]string(nil), requiredCapabilities...), Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...), Provides: append([]string(nil), providedCapabilities...),
ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions), ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions),
} }
} }
@@ -228,11 +228,11 @@ func chunkContent(units []source.SourceUnit) ([]byte, error) {
} }
func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) { func normalizeScene(doc *source.SourceDocument, index int, scene sceneResponse) (normalizedScene, error) {
startUnitID, err := dnd.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID) startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
if err != nil { if err != nil {
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err) return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
} }
endUnitID, err := dnd.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID) endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
if err != nil { if err != nil {
return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err) return normalizedScene{}, fmt.Errorf("scene[%d] %w", index, err)
} }

View File

@@ -11,7 +11,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func TestNewModuleSpecAndRegister(t *testing.T) { func TestNewModuleSpecAndRegister(t *testing.T) {
@@ -105,8 +105,8 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
response: chunkResponse{ response: chunkResponse{
Scenes: []sceneResponse{ Scenes: []sceneResponse{
{ {
StartUnitID: dnd.UnitRefFromInt(1), StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: dnd.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2),
ShortTitle: " Goblin parley ", ShortTitle: " Goblin parley ",
PrimaryMode: "Discussion", PrimaryMode: "Discussion",
MainParticipants: []string{" Aria ", "Goblin scout"}, MainParticipants: []string{" Aria ", "Goblin scout"},
@@ -115,8 +115,8 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
BoundaryConfidence: "High", BoundaryConfidence: "High",
}, },
{ {
StartUnitID: dnd.UnitRefFromInt(3), StartUnitID: shared.UnitRefFromInt(3),
EndUnitID: dnd.UnitRefFromInt(4), EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Ambush at the gate", ShortTitle: "Ambush at the gate",
PrimaryMode: "Combat", PrimaryMode: "Combat",
MainParticipants: []string{"Aria", "Goblin ambushers"}, MainParticipants: []string{"Aria", "Goblin ambushers"},
@@ -210,8 +210,8 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
client := &fakeScenesLLMClient{response: chunkResponse{ client := &fakeScenesLLMClient{response: chunkResponse{
Scenes: []sceneResponse{ Scenes: []sceneResponse{
{ {
StartUnitID: dnd.UnitRefFromInt(1), StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: dnd.UnitRefFromInt(4), EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Ambush", ShortTitle: "Ambush",
PrimaryMode: "Combat", PrimaryMode: "Combat",
MainParticipants: []string{"Aria"}, MainParticipants: []string{"Aria"},
@@ -264,7 +264,7 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
} }
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) { func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
inputs := dnd.PromptInputs(sceneSourceInput(), contracts.ReferenceSet{ inputs := shared.PromptInputs(sceneSourceInput(), contracts.ReferenceSet{
Slots: map[string]contracts.ResolvedReferenceSlot{ Slots: map[string]contracts.ResolvedReferenceSlot{
"roster": { "roster": {
Slot: contracts.ReferenceSlot{Name: "roster"}, Slot: contracts.ReferenceSlot{Name: "roster"},
@@ -451,8 +451,8 @@ func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
name: "empty metadata field", name: "empty metadata field",
response: replaceScenes(validSceneResponse(), []sceneResponse{ response: replaceScenes(validSceneResponse(), []sceneResponse{
{ {
StartUnitID: dnd.UnitRefFromInt(1), StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: dnd.UnitRefFromInt(4), EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: " ", ShortTitle: " ",
PrimaryMode: "Narrative", PrimaryMode: "Narrative",
MainParticipants: []string{"Aria"}, MainParticipants: []string{"Aria"},
@@ -467,8 +467,8 @@ func TestChunkRejectsMalformedStructuredOutput(t *testing.T) {
name: "empty participant", name: "empty participant",
response: replaceScenes(validSceneResponse(), []sceneResponse{ response: replaceScenes(validSceneResponse(), []sceneResponse{
{ {
StartUnitID: dnd.UnitRefFromInt(1), StartUnitID: shared.UnitRefFromInt(1),
EndUnitID: dnd.UnitRefFromInt(4), EndUnitID: shared.UnitRefFromInt(4),
ShortTitle: "Title", ShortTitle: "Title",
PrimaryMode: "Narrative", PrimaryMode: "Narrative",
MainParticipants: []string{"Aria", " "}, MainParticipants: []string{"Aria", " "},
@@ -559,8 +559,8 @@ func replaceScenes(response chunkResponse, scenes []sceneResponse) chunkResponse
func scene(startUnitID int, endUnitID int) sceneResponse { func scene(startUnitID int, endUnitID int) sceneResponse {
return sceneResponse{ return sceneResponse{
StartUnitID: dnd.UnitRefFromInt(startUnitID), StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: dnd.UnitRefFromInt(endUnitID), EndUnitID: shared.UnitRefFromInt(endUnitID),
ShortTitle: "Scene title", ShortTitle: "Scene title",
PrimaryMode: "Narrative", PrimaryMode: "Narrative",
MainParticipants: []string{"Aria"}, MainParticipants: []string{"Aria"},

View File

@@ -0,0 +1,30 @@
package scenes
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
type chunkResponse struct {
Scenes []sceneResponse `json:"scenes"`
BoundaryCaveats []string `json:"boundary_caveats"`
}
type sceneResponse struct {
StartUnitID shared.UnitRef `json:"start_unit_id"`
EndUnitID shared.UnitRef `json:"end_unit_id"`
ShortTitle string `json:"short_title"`
PrimaryMode string `json:"primary_mode"`
MainParticipants []string `json:"main_participants"`
Summary string `json:"summary"`
BoundaryNote string `json:"boundary_note"`
BoundaryConfidence string `json:"boundary_confidence"`
}
type normalizedScene struct {
StartUnitID int
EndUnitID int
ShortTitle string
PrimaryMode string
MainParticipants []string
Summary string
BoundaryNote string
BoundaryConfidence string
}

View File

@@ -5,14 +5,14 @@ import (
"sync" "sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const scriptoriumPromptRoot = "assets/prompts" const scriptoriumPromptRoot = "assets/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error { func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := dnd.ModulePromptFS("dnd.scenes", embeddedAssets, []sharedassets.ModulePromptFile{ promptFS, err := shared.ModulePromptFS("dnd.scenes", embeddedAssets, []promptfs.ModulePromptFile{
{Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"}, {Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"},
@@ -32,7 +32,7 @@ func scriptoriumPromptMetadata() (string, error) {
{FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.yaml"}, {FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.yaml"},
{FS: embeddedAssets, Path: "assets/prompts/task.md"}, {FS: embeddedAssets, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, {FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
}, append(dnd.CommonHashParts(), dnd.ReferenceHashParts()...)...) }, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
}) })
return scriptoriumPromptHash, scriptoriumPromptHashErr return scriptoriumPromptHash, scriptoriumPromptHashErr

View File

@@ -3,7 +3,7 @@ package spells
import ( import (
"sort" "sort"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func canonicalizeResponse(response *extractionResponse, sourceID string) { func canonicalizeResponse(response *extractionResponse, sourceID string) {
@@ -43,20 +43,20 @@ func canonicalizeSpellCast(spell *spellCastResponse, sourceID string) {
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs) spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
} }
func canonicalUnitRef(ref dnd.UnitRef) dnd.UnitRef { func canonicalUnitRef(ref shared.UnitRef) shared.UnitRef {
value := ref.Int() value := ref.Int()
if value <= 0 { if value <= 0 {
return ref return ref
} }
return dnd.UnitRefFromInt(value) return shared.UnitRefFromInt(value)
} }
func dedupeSourceRefs(refs []dnd.SourceRefResponse) []dnd.SourceRefResponse { func dedupeSourceRefs(refs []shared.SourceRefResponse) []shared.SourceRefResponse {
if len(refs) < 2 { if len(refs) < 2 {
return refs return refs
} }
out := refs[:0] out := refs[:0]
var previous dnd.SourceRefResponse var previous shared.SourceRefResponse
for index, ref := range refs { for index, ref := range refs {
if index > 0 && sameSourceRef(previous, ref) { if index > 0 && sameSourceRef(previous, ref) {
continue continue
@@ -67,7 +67,7 @@ func dedupeSourceRefs(refs []dnd.SourceRefResponse) []dnd.SourceRefResponse {
return out return out
} }
func sameSourceRef(left dnd.SourceRefResponse, right dnd.SourceRefResponse) bool { func sameSourceRef(left shared.SourceRefResponse, right shared.SourceRefResponse) bool {
return left.SourceID == right.SourceID && return left.SourceID == right.SourceID &&
left.StartUnitID.Int() == right.StartUnitID.Int() && left.StartUnitID.Int() == right.StartUnitID.Int() &&
left.EndUnitID.Int() == right.EndUnitID.Int() left.EndUnitID.Int() == right.EndUnitID.Int()
@@ -83,7 +83,7 @@ func earliestSourceUnit(spell spellCastResponse) (int, bool) {
return 0, false return 0, false
} }
func unitSortValue(ref dnd.UnitRef) int { func unitSortValue(ref shared.UnitRef) int {
value := ref.Int() value := ref.Int()
if value <= 0 { if value <= 0 {
return int(^uint(0) >> 1) return int(^uint(0) >> 1)

View File

@@ -8,7 +8,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const Key = "dnd/spells" const Key = "dnd/spells"
@@ -24,7 +24,7 @@ var providedCapabilities = []string{
"dnd.spell_casts", "dnd.spell_casts",
} }
var referenceSlotDescriptions = dnd.ReferenceSlotDescriptions{ var referenceSlotDescriptions = shared.ReferenceSlotDescriptions{
Glossary: "Optional campaign glossary reference material used only for disambiguation.", Glossary: "Optional campaign glossary reference material used only for disambiguation.",
Party: "Optional party roster 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.", Players: "Optional player list reference material used only for disambiguation.",
@@ -44,7 +44,7 @@ func (e *Extractor) Key() string {
} }
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot { func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
return dnd.ReferenceSlots(referenceSlotDescriptions) return shared.ReferenceSlots(referenceSlotDescriptions)
} }
func (e *Extractor) ManifestMetadata() map[string]any { func (e *Extractor) ManifestMetadata() map[string]any {
@@ -101,7 +101,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
PromptVersion: SchemaVersion, PromptVersion: SchemaVersion,
ProfileID: req.LLMProfile, ProfileID: req.LLMProfile,
SessionID: req.SessionID, SessionID: req.SessionID,
Inputs: dnd.PromptInputs(sourceInput, req.References), Inputs: shared.PromptInputs(sourceInput, req.References),
}, &response); err != nil { }, &response); err != nil {
return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err) return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err)
} }
@@ -159,7 +159,7 @@ func ModuleSpec() pipeline.ModuleSpec {
Stage: pipeline.StageExtract, Stage: pipeline.StageExtract,
Requires: append([]string(nil), requiredCapabilities...), Requires: append([]string(nil), requiredCapabilities...),
Provides: append([]string(nil), providedCapabilities...), Provides: append([]string(nil), providedCapabilities...),
ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions), ReferenceSlots: shared.ReferenceSlots(referenceSlotDescriptions),
} }
} }

View File

@@ -8,7 +8,7 @@ import (
"testing" "testing"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func TestExtractReturnsCanonicalOutputFromStructuredResponse(t *testing.T) { func TestExtractReturnsCanonicalOutputFromStructuredResponse(t *testing.T) {
@@ -155,7 +155,7 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
} }
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) { func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
inputs := dnd.PromptInputs(spellSourceInput(), contracts.ReferenceSet{ inputs := shared.PromptInputs(spellSourceInput(), contracts.ReferenceSet{
Slots: map[string]contracts.ResolvedReferenceSlot{ Slots: map[string]contracts.ResolvedReferenceSlot{
"roster": { "roster": {
Slot: contracts.ReferenceSlot{Name: "roster"}, Slot: contracts.ReferenceSlot{Name: "roster"},
@@ -303,10 +303,10 @@ func TestExtractCanonicalizesSourceRefs(t *testing.T) {
Spell: "Cure Wounds", Spell: "Cure Wounds",
Effect: "Heals.", Effect: "Heals.",
NarrativeDescription: "Aria heals.", NarrativeDescription: "Aria heals.",
SourceRefs: []dnd.SourceRefResponse{ SourceRefs: []shared.SourceRefResponse{
{SourceID: "gameplay_transcript", StartUnitID: dnd.UnitRefFromInt(2), EndUnitID: dnd.UnitRefFromInt(2)}, {SourceID: "gameplay_transcript", StartUnitID: shared.UnitRefFromInt(2), EndUnitID: shared.UnitRefFromInt(2)},
{SourceID: "", StartUnitID: dnd.UnitRefFromInt(1), EndUnitID: dnd.UnitRefFromInt(2)}, {SourceID: "", StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)},
{SourceID: "transcript", StartUnitID: dnd.UnitRefFromInt(1), EndUnitID: dnd.UnitRefFromInt(2)}, {SourceID: "transcript", StartUnitID: shared.UnitRefFromInt(1), EndUnitID: shared.UnitRefFromInt(2)},
}, },
}, },
}, },
@@ -345,8 +345,8 @@ func TestExtractPreservesInvalidSourceRefsForValidators(t *testing.T) {
Spell: "Cure Wounds", Spell: "Cure Wounds",
Effect: "Heals.", Effect: "Heals.",
NarrativeDescription: "Aria heals.", NarrativeDescription: "Aria heals.",
SourceRefs: []dnd.SourceRefResponse{ SourceRefs: []shared.SourceRefResponse{
{SourceID: "transcript", StartUnitID: dnd.UnitRefFromInt(99), EndUnitID: dnd.UnitRefFromString("missing")}, {SourceID: "transcript", StartUnitID: shared.UnitRefFromInt(99), EndUnitID: shared.UnitRefFromString("missing")},
}, },
}, },
}, },
@@ -386,7 +386,7 @@ func TestExtractDefensivelyCopiesRawContent(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("Extract() error = %v, want nil", err) t.Fatalf("Extract() error = %v, want nil", err)
} }
client.response.SpellCasts[0].SourceRefs[0].StartUnitID = dnd.UnitRefFromInt(99) client.response.SpellCasts[0].SourceRefs[0].StartUnitID = shared.UnitRefFromInt(99)
var payload extractionResponse var payload extractionResponse
if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil { if err := json.Unmarshal(result.Output.Payload.Content, &payload); err != nil {

View File

@@ -0,0 +1,22 @@
package spells
import "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
type SpellCast struct {
Caster string `json:"caster"`
Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
}
type extractionResponse struct {
SpellCasts []spellCastResponse `json:"spell_casts"`
}
type spellCastResponse struct {
Caster string `json:"caster"`
Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []shared.SourceRefResponse `json:"source_refs"`
}

View File

@@ -5,14 +5,14 @@ import (
"sync" "sync"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
const scriptoriumPromptRoot = "assets/prompts" const scriptoriumPromptRoot = "assets/prompts"
func RegisterPromptAssets(registry *llm.AssetRegistry) error { func RegisterPromptAssets(registry *llm.AssetRegistry) error {
promptFS, err := dnd.ModulePromptFS("dnd.spells", embeddedAssets, []sharedassets.ModulePromptFile{ promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"}, {Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
{Name: "task.md", Path: "assets/prompts/task.md"}, {Name: "task.md", Path: "assets/prompts/task.md"},
{Name: "instructions.md", Path: "assets/prompts/instructions.md"}, {Name: "instructions.md", Path: "assets/prompts/instructions.md"},
@@ -32,7 +32,7 @@ func scriptoriumPromptMetadata() (string, error) {
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"}, {FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
{FS: embeddedAssets, Path: "assets/prompts/task.md"}, {FS: embeddedAssets, Path: "assets/prompts/task.md"},
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"}, {FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
}, append(dnd.CommonHashParts(), dnd.ReferenceHashParts()...)...) }, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts) scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
}) })
return scriptoriumPromptHash, scriptoriumPromptHashErr return scriptoriumPromptHash, scriptoriumPromptHashErr

View File

@@ -6,7 +6,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
func promptExtractionRequest() contracts.ExtractionRequest { func promptExtractionRequest() contracts.ExtractionRequest {
@@ -65,22 +65,22 @@ func mustJSON(t *testing.T, value any) string {
return string(encoded) return string(encoded)
} }
func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []dnd.SourceRefResponse { func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
return []dnd.SourceRefResponse{ return []shared.SourceRefResponse{
{ {
SourceID: sourceID, SourceID: sourceID,
StartUnitID: dnd.UnitRefFromInt(startUnitID), StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: dnd.UnitRefFromInt(endUnitID), EndUnitID: shared.UnitRefFromInt(endUnitID),
}, },
} }
} }
func responseSourceRefsInt(sourceID string, startUnitID int, endUnitID int) []dnd.SourceRefResponse { func responseSourceRefsInt(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
return []dnd.SourceRefResponse{ return []shared.SourceRefResponse{
{ {
SourceID: sourceID, SourceID: sourceID,
StartUnitID: dnd.UnitRefFromInt(startUnitID), StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: dnd.UnitRefFromInt(endUnitID), EndUnitID: shared.UnitRefFromInt(endUnitID),
}, },
} }
} }

View File

@@ -6,13 +6,13 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/chunk/dnd/scenes" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/source_relatedness"
validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json" validjson "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json"
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema" validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema"
spellshape "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/shape"
spellsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_refs"
spellrelatedness "gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/source_relatedness"
) )
// Register adds all production D&D modules, validators, policy, and assets. // Register adds all production D&D modules, validators, policy, and assets.

View File

@@ -9,7 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/extract/dnd/spells" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
) )
func TestRegisterAddsDNDFamily(t *testing.T) { func TestRegisterAddsDNDFamily(t *testing.T) {

View File

@@ -1,11 +1,11 @@
package dnd package shared
import ( import (
"embed" "embed"
"io/fs" "io/fs"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
) )
//go:embed assets/prompts/*.md //go:embed assets/prompts/*.md
@@ -17,10 +17,10 @@ var sharedPromptFiles = []string{
"common-dnd-references.md", "common-dnd-references.md",
} }
func SharedPromptFiles() []sharedassets.SharedPromptFile { func SharedPromptFiles() []promptfs.SharedPromptFile {
files := make([]sharedassets.SharedPromptFile, 0, len(sharedPromptFiles)) files := make([]promptfs.SharedPromptFile, 0, len(sharedPromptFiles))
for _, name := range sharedPromptFiles { for _, name := range sharedPromptFiles {
files = append(files, sharedassets.SharedPromptFile{ files = append(files, promptfs.SharedPromptFile{
Name: name, Name: name,
FS: embeddedAssets, FS: embeddedAssets,
Path: "assets/prompts/" + name, Path: "assets/prompts/" + name,
@@ -42,6 +42,6 @@ func ReferenceHashParts() []llm.AssetHashPart {
} }
} }
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []sharedassets.ModulePromptFile) (fs.FS, error) { func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []promptfs.ModulePromptFile) (fs.FS, error) {
return sharedassets.ModulePromptFS(moduleDir, moduleFS, files, SharedPromptFiles()...) return promptfs.ModulePromptFS(moduleDir, moduleFS, files, SharedPromptFiles()...)
} }

View File

@@ -1,4 +1,4 @@
package dnd package shared
import ( import (
"io/fs" "io/fs"
@@ -6,7 +6,7 @@ import (
"testing/fstest" "testing/fstest"
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm" "gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets" "gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
) )
func TestSharedPromptFilesReturnsNewSlice(t *testing.T) { func TestSharedPromptFilesReturnsNewSlice(t *testing.T) {
@@ -67,7 +67,7 @@ func assertHashParts(t *testing.T, name string, parts []llm.AssetHashPart, want
func TestModulePromptFSMountsDNDSharedPrompts(t *testing.T) { func TestModulePromptFSMountsDNDSharedPrompts(t *testing.T) {
fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{ fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")}, "assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
}, []sharedassets.ModulePromptFile{ }, []promptfs.ModulePromptFile{
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"}, {Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
}) })
if err != nil { if err != nil {

View File

@@ -1,4 +1,4 @@
package dnd package shared
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package dnd package shared
import ( import (
"strings" "strings"

View File

@@ -1,4 +1,4 @@
package dnd package shared
import "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" import "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"

View File

@@ -1,4 +1,4 @@
package dnd package shared
import ( import (
"reflect" "reflect"

View File

@@ -1,4 +1,4 @@
package dnd package shared
import ( import (
"bytes" "bytes"

View File

@@ -1,4 +1,4 @@
package dnd package shared
import ( import (
"encoding/json" "encoding/json"

View File

@@ -5,7 +5,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/spellpayload" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/spellpayload"
) )
const Key = "extract/dnd/spells/shape" const Key = "extract/dnd/spells/shape"

View File

@@ -7,7 +7,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/spellpayload" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/spellpayload"
) )
const Key = "extract/dnd/spells/source_refs" const Key = "extract/dnd/spells/source_refs"

View File

@@ -8,7 +8,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/validators/extract/dnd/spells/spellpayload" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/spellpayload"
) )
const Key = "extract/dnd/spells/source_relatedness" const Key = "extract/dnd/spells/source_relatedness"

View File

@@ -9,7 +9,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd" "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
) )
type Payload struct { type Payload struct {
@@ -21,7 +21,7 @@ type SpellCast struct {
Spell string `json:"spell"` Spell string `json:"spell"`
Effect string `json:"effect"` Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"` NarrativeDescription string `json:"narrative_description"`
SourceRefs []dnd.SourceRefResponse `json:"source_refs"` SourceRefs []shared.SourceRefResponse `json:"source_refs"`
} }
func Parse(raw []byte) (Payload, error) { func Parse(raw []byte) (Payload, error) {
@@ -66,7 +66,7 @@ func ValidateShape(payload Payload) error {
func SourceRefCandidates(doc *source.SourceDocument, spell SpellCast) []source.SourceRef { func SourceRefCandidates(doc *source.SourceDocument, spell SpellCast) []source.SourceRef {
refs := make([]source.SourceRef, 0, len(spell.SourceRefs)) refs := make([]source.SourceRef, 0, len(spell.SourceRefs))
for _, ref := range spell.SourceRefs { for _, ref := range spell.SourceRefs {
refs = append(refs, dnd.SourceRefCandidate(doc, ref)) refs = append(refs, shared.SourceRefCandidate(doc, ref))
} }
return refs return refs
} }

View File

@@ -1,22 +0,0 @@
package spells
import "gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
type SpellCast struct {
Caster string `json:"caster"`
Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
}
type extractionResponse struct {
SpellCasts []spellCastResponse `json:"spell_casts"`
}
type spellCastResponse struct {
Caster string `json:"caster"`
Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []dnd.SourceRefResponse `json:"source_refs"`
}

View File

@@ -0,0 +1,3 @@
package importboundaries
import _ "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"

View File

@@ -0,0 +1,203 @@
package modules_test
import (
"fmt"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
const moduleImportPrefix = "gitea.maximumdirect.net/eric/notarius/internal/modules/"
func TestProductionImportBoundaries(t *testing.T) {
repositoryRoot := testRepositoryRoot(t)
err := filepath.WalkDir(repositoryRoot, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if entry.Name() == ".git" || entry.Name() == "testdata" || entry.Name() == "vendor" {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
return checkImportBoundaries(repositoryRoot, path)
})
if err != nil {
t.Fatal(err)
}
}
func TestImportBoundaryFixtureIsRejected(t *testing.T) {
repositoryRoot := testRepositoryRoot(t)
fixture := filepath.Join(repositoryRoot, "internal", "modules", "generic", "testdata", "importboundaries", "imports_dnd.go")
err := checkImportBoundaries(repositoryRoot, fixture)
if err == nil {
t.Fatal("fixture import was accepted, want generic-to-D&D violation")
}
if !strings.Contains(err.Error(), "generic packages must not import D&D packages") {
t.Fatalf("fixture error = %q, want generic-to-D&D violation", err)
}
}
func TestImportBoundaryRules(t *testing.T) {
tests := []struct {
name string
filename string
importPath string
wantError bool
}{
{
name: "D&D implementation cannot import Seriatim",
filename: "internal/modules/dnd/extract/example/extractor.go",
importPath: moduleImportPrefix + "seriatim/input/transcript",
wantError: true,
},
{
name: "Seriatim implementation cannot import D&D",
filename: "internal/modules/seriatim/input/example/adapter.go",
importPath: moduleImportPrefix + "dnd/shared",
wantError: true,
},
{
name: "generic implementation cannot import D&D",
filename: "internal/modules/generic/merge/example/merger.go",
importPath: moduleImportPrefix + "dnd/shared",
wantError: true,
},
{
name: "domain root cannot import child",
filename: "internal/modules/dnd/types.go",
importPath: moduleImportPrefix + "dnd/extract/spells",
wantError: true,
},
{
name: "D&D implementation may import generic implementation",
filename: "internal/modules/dnd/extract/example/extractor.go",
importPath: moduleImportPrefix + "generic/normalize/noop",
},
{
name: "domain registrar may compose child packages",
filename: "internal/modules/dnd/register/register.go",
importPath: moduleImportPrefix + "dnd/extract/spells",
},
{
name: "CLI may compose registrars",
filename: "internal/cli/catalog.go",
importPath: moduleImportPrefix + "dnd/register",
},
{
name: "external integration test may compose domains",
filename: "internal/modules/integration/example_test.go",
importPath: moduleImportPrefix + "dnd/extract/spells",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateImport(tt.filename, tt.importPath)
if tt.wantError && err == nil {
t.Fatal("validateImport() error = nil, want boundary violation")
}
if !tt.wantError && err != nil {
t.Fatalf("validateImport() error = %v, want nil", err)
}
})
}
}
func checkImportBoundaries(repositoryRoot string, filename string) error {
parsed, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.ImportsOnly)
if err != nil {
return fmt.Errorf("parse %s: %w", filename, err)
}
relative, err := filepath.Rel(repositoryRoot, filename)
if err != nil {
return fmt.Errorf("resolve relative path for %s: %w", filename, err)
}
relative = filepath.ToSlash(relative)
for _, imported := range parsed.Imports {
importPath, err := strconv.Unquote(imported.Path.Value)
if err != nil {
return fmt.Errorf("parse import in %s: %w", relative, err)
}
if err := validateImport(relative, importPath); err != nil {
return fmt.Errorf("%s imports %s: %w", relative, importPath, err)
}
}
return nil
}
func validateImport(filename string, importPath string) error {
if isExternalIntegrationTest(filename) {
return nil
}
sourceDomain, sourceRoot := domainForFile(filename)
targetDomain, targetChild := domainForImport(importPath)
if sourceDomain == "" || targetDomain == "" {
return nil
}
if sourceRoot && sourceDomain == targetDomain && targetChild {
return fmt.Errorf("domain root packages must not import child implementations")
}
if sourceDomain == "generic" && targetDomain == "dnd" {
return fmt.Errorf("generic packages must not import D&D packages")
}
if sourceDomain == "dnd" && targetDomain == "seriatim" {
return fmt.Errorf("D&D packages must not import Seriatim packages")
}
if sourceDomain == "seriatim" && targetDomain == "dnd" {
return fmt.Errorf("Seriatim packages must not import D&D packages")
}
return nil
}
func domainForFile(filename string) (domain string, root bool) {
const prefix = "internal/modules/"
if !strings.HasPrefix(filename, prefix) {
return "", false
}
remainder := strings.TrimPrefix(filename, prefix)
parts := strings.Split(remainder, "/")
if len(parts) < 2 || !isDomain(parts[0]) {
return "", false
}
return parts[0], len(parts) == 2
}
func domainForImport(importPath string) (domain string, child bool) {
if !strings.HasPrefix(importPath, moduleImportPrefix) {
return "", false
}
remainder := strings.TrimPrefix(importPath, moduleImportPrefix)
parts := strings.Split(remainder, "/")
if len(parts) == 0 || !isDomain(parts[0]) {
return "", false
}
return parts[0], len(parts) > 1
}
func isDomain(name string) bool {
return name == "dnd" || name == "generic" || name == "seriatim"
}
func isExternalIntegrationTest(filename string) bool {
return strings.HasPrefix(filename, "internal/modules/integration/") && strings.HasSuffix(filename, "_test.go")
}
func testRepositoryRoot(t *testing.T) string {
t.Helper()
_, filename, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("resolve import-boundary test location")
}
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
}

View File

@@ -1,4 +1,4 @@
package spells package integration_test
import ( import (
"context" "context"
@@ -10,6 +10,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop" "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript" "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
@@ -45,8 +46,8 @@ func TestPipelineConfigLoadsAndResolvesWithDNDSpellsExtractor(t *testing.T) {
if lane.ID != "spells" { if lane.ID != "spells" {
t.Fatalf("lane ID = %q, want spells", lane.ID) t.Fatalf("lane ID = %q, want spells", lane.ID)
} }
if lane.Extract.Module != Key { if lane.Extract.Module != spells.Key {
t.Fatalf("extract module = %q, want %q", lane.Extract.Module, Key) t.Fatalf("extract module = %q, want %q", lane.Extract.Module, spells.Key)
} }
if resolved.ResolvedPipeline.Digest == "" { if resolved.ResolvedPipeline.Digest == "" {
t.Fatal("resolved digest is empty") t.Fatal("resolved digest is empty")
@@ -82,13 +83,13 @@ func TestPipelineConfigRejectsMissingTranscriptCapabilityForDNDSpells(t *testing
} }
if !strings.Contains(err.Error(), "missing capability") || if !strings.Contains(err.Error(), "missing capability") ||
!strings.Contains(err.Error(), "source.transcript") || !strings.Contains(err.Error(), "source.transcript") ||
!strings.Contains(err.Error(), Key) { !strings.Contains(err.Error(), spells.Key) {
t.Fatalf("Resolve() error = %q, want dnd/spells missing source.transcript capability", err.Error()) t.Fatalf("Resolve() error = %q, want dnd/spells missing source.transcript capability", err.Error())
} }
} }
func TestPipelineConfigRejectsMissingSpellCastsCapabilityForAppendOrder(t *testing.T) { func TestPipelineConfigRejectsMissingSpellCastsCapabilityForAppendOrder(t *testing.T) {
extractorSpec := ModuleSpec() extractorSpec := spells.ModuleSpec()
extractorSpec.Provides = withoutCapability(extractorSpec.Provides, "dnd.spell_casts") extractorSpec.Provides = withoutCapability(extractorSpec.Provides, "dnd.spell_casts")
_, err := loadDNDSpellsPipelineConfig(t).Resolve(config.ResolveInput{ _, err := loadDNDSpellsPipelineConfig(t).Resolve(config.ResolveInput{
@@ -176,11 +177,11 @@ func dndSpellsTestCatalog(t *testing.T, specs dndSpellsCatalogSpecs) pipeline.Mo
} }
if specs.extractor.Key == "" { if specs.extractor.Key == "" {
if err := Register(extractors); err != nil { if err := spells.Register(extractors); err != nil {
t.Fatalf("register dnd spells extractor: %v", err) t.Fatalf("register dnd spells extractor: %v", err)
} }
} else if err := extractors.RegisterWithSpec(specs.extractor, func() (contracts.Extractor, error) { } else if err := extractors.RegisterWithSpec(specs.extractor, func() (contracts.Extractor, error) {
return New(), nil return spells.New(), nil
}); err != nil { }); err != nil {
t.Fatalf("register dnd spells extractor override: %v", err) t.Fatalf("register dnd spells extractor override: %v", err)
} }

View File

@@ -0,0 +1,63 @@
package integration_test
import (
"context"
"encoding/json"
"fmt"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
)
type extractionResponse struct {
SpellCasts []spellCastResponse `json:"spell_casts"`
}
type spellCastResponse struct {
Caster string `json:"caster"`
Spell string `json:"spell"`
Effect string `json:"effect"`
NarrativeDescription string `json:"narrative_description"`
SourceRefs []shared.SourceRefResponse `json:"source_refs"`
}
type fakeSpellsLLMClient struct {
response extractionResponse
requests []contracts.StructuredCompletionRequest
}
func (client *fakeSpellsLLMClient) CompleteStructured(_ context.Context, req contracts.StructuredCompletionRequest, out any) (contracts.StructuredCompletionResponse, error) {
client.requests = append(client.requests, cloneStructuredCompletionRequest(req))
content, err := json.Marshal(client.response)
if err != nil {
return contracts.StructuredCompletionResponse{}, err
}
if err := json.Unmarshal(content, out); err != nil {
return contracts.StructuredCompletionResponse{}, fmt.Errorf("populate structured target: %w", err)
}
return contracts.StructuredCompletionResponse{Content: content}, nil
}
func responseSourceRefs(sourceID string, startUnitID int, endUnitID int) []shared.SourceRefResponse {
return []shared.SourceRefResponse{
{
SourceID: sourceID,
StartUnitID: shared.UnitRefFromInt(startUnitID),
EndUnitID: shared.UnitRefFromInt(endUnitID),
},
}
}
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
req.Inputs = req.Inputs.Clone()
if len(req.Vars) == 0 {
req.Vars = nil
return req
}
vars := make(map[string]any, len(req.Vars))
for key, value := range req.Vars {
vars[key] = value
}
req.Vars = vars
return req
}

View File

@@ -1,4 +1,4 @@
package spells package integration_test
import ( import (
"context" "context"
@@ -11,6 +11,7 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
"gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript" "gitea.maximumdirect.net/eric/notarius/internal/modules/seriatim/input/transcript"
) )
@@ -52,7 +53,7 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs)) t.Fatalf("len(NormalizeOutputs) = %d, want 1", len(output.NormalizeOutputs))
} }
rawOutput := output.NormalizeOutputs[0] rawOutput := output.NormalizeOutputs[0]
if rawOutput.LaneID != "spells" || rawOutput.Schema.ID != ResponseSchemaID || rawOutput.Schema.Version != SchemaVersion { if rawOutput.LaneID != "spells" || rawOutput.Schema.ID != spells.ResponseSchemaID || rawOutput.Schema.Version != spells.SchemaVersion {
t.Fatalf("raw output envelope = %#v, want dnd spells schema on spells lane", rawOutput) t.Fatalf("raw output envelope = %#v, want dnd spells schema on spells lane", rawOutput)
} }
response := decodeRunnerSpellResponse(t, rawOutput.Payload.Content) response := decodeRunnerSpellResponse(t, rawOutput.Payload.Content)
@@ -85,16 +86,16 @@ func TestRunnerProcessesSeriatimInputWithDNDSpellsExtractor(t *testing.T) {
t.Fatalf("len(ArtifactLanes) = %d, want 1", len(output.Manifest.ArtifactLanes)) t.Fatalf("len(ArtifactLanes) = %d, want 1", len(output.Manifest.ArtifactLanes))
} }
lane := output.Manifest.ArtifactLanes[0] lane := output.Manifest.ArtifactLanes[0]
if lane.ID != "spells" || lane.Extractor != Key { if lane.ID != "spells" || lane.Extractor != spells.Key {
t.Fatalf("manifest lane = %#v, want spells lane with dnd/spells extractor", lane) t.Fatalf("manifest lane = %#v, want spells lane with dnd/spells extractor", lane)
} }
extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any) extractorMetadata, ok := lane.Metadata["extractor"].(map[string]any)
if !ok { if !ok {
t.Fatalf("manifest lane metadata = %#v, want extractor metadata", lane.Metadata) t.Fatalf("manifest lane metadata = %#v, want extractor metadata", lane.Metadata)
} }
if extractorMetadata["prompt_id"] != PromptID || if extractorMetadata["prompt_id"] != spells.PromptID ||
extractorMetadata["response_schema_key"] != string(ResponseSchemaKey) || extractorMetadata["response_schema_key"] != string(spells.ResponseSchemaKey) ||
extractorMetadata["response_schema_name"] != ResponseSchemaName { extractorMetadata["response_schema_name"] != spells.ResponseSchemaName {
t.Fatalf("extractor metadata = %#v, want prompt/schema identifiers", extractorMetadata) t.Fatalf("extractor metadata = %#v, want prompt/schema identifiers", extractorMetadata)
} }
if len(output.OutputFiles) != 1 { if len(output.OutputFiles) != 1 {
@@ -146,8 +147,8 @@ func TestRunnerPassesPartyAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T) {
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests)) t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
} }
request := llmClient.requests[0] request := llmClient.requests[0]
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion { if request.PromptID != spells.PromptID || request.PromptVersion != spells.SchemaVersion {
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion) t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, spells.PromptID, spells.SchemaVersion)
} }
if got := string(request.Inputs["party"].Content); got != "Aria: party cleric\nBorin: fighter" { if got := string(request.Inputs["party"].Content); got != "Aria: party cleric\nBorin: fighter" {
t.Fatalf("party input = %q, want reference text", got) t.Fatalf("party input = %q, want reference text", got)
@@ -188,8 +189,8 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInPartyReference(t *testing.T) {
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests)) t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
} }
request := llmClient.requests[0] request := llmClient.requests[0]
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion { if request.PromptID != spells.PromptID || request.PromptVersion != spells.SchemaVersion {
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion) t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, spells.PromptID, spells.SchemaVersion)
} }
if got := string(request.Inputs["party"].Content); !strings.Contains(got, "Lightning Bolt") { if got := string(request.Inputs["party"].Content); !strings.Contains(got, "Lightning Bolt") {
t.Fatalf("party input = %q, want party-reference-only spell in reference input", got) t.Fatalf("party input = %q, want party-reference-only spell in reference input", got)