Compare commits
7 Commits
7861d040df
...
671ff6d132
| Author | SHA1 | Date | |
|---|---|---|---|
| 671ff6d132 | |||
| 9b2d0297b7 | |||
| f91e643932 | |||
| 35fe405448 | |||
| 524f2ffb8e | |||
| 68b426cdb0 | |||
| 223f3751e8 |
@@ -42,11 +42,13 @@ rendered reference bytes.
|
||||
|
||||
LLM-backed modules own Scriptorium prompt definitions and response schemas in
|
||||
their embedded assets. Module-owned prompts live under each module's shallow
|
||||
`assets/prompts` tree and schemas live under `assets/schemas`. Shared reusable
|
||||
prompt fragments live under `internal/modules/sharedassets`. Module contracts
|
||||
should expose prompt IDs, versions, input material names, and non-secret
|
||||
prompt/schema hashes through manifest metadata; they should not expose
|
||||
Scriptorium public types through chunk, extract, or normalize contracts.
|
||||
`assets/prompts` tree and schemas live under `assets/schemas`. Generic shared
|
||||
prompt filesystem composition lives under `internal/modules/sharedassets`.
|
||||
Common D&D prompt fragments, reference slot helpers, prompt input assembly, and
|
||||
reference rendering live under `internal/modules/sharedassets/dnd`. Module
|
||||
contracts should expose prompt IDs, versions, input material names, and
|
||||
non-secret prompt/schema hashes through manifest metadata; they should not
|
||||
expose Scriptorium public types through chunk, extract, or normalize contracts.
|
||||
|
||||
Chunk modules receive the structured LLM client, configured Scriptorium profile
|
||||
ID, prompt session ID, and raw source input material through
|
||||
@@ -107,8 +109,8 @@ session ID to the runtime; validates model-authored source-unit boundaries; and
|
||||
converts each scene into a deterministic source chunk.
|
||||
|
||||
Its prompt definition lives under `assets/prompts` and its schema under
|
||||
`assets/schemas`. Shared reusable prompt fragments are provided by
|
||||
`internal/modules/sharedassets` and referenced from prompt definitions under
|
||||
`assets/schemas`. Shared reusable D&D prompt fragments are provided by
|
||||
`internal/modules/sharedassets/dnd` and referenced from prompt definitions under
|
||||
`./sharedassets/`.
|
||||
|
||||
Requires:
|
||||
@@ -146,8 +148,8 @@ spell-cast responses into artifact candidates; and supplies deterministic
|
||||
validators.
|
||||
|
||||
Its prompt definition lives under `assets/prompts` and its schema under
|
||||
`assets/schemas`. Shared reusable prompt fragments are provided by
|
||||
`internal/modules/sharedassets` and referenced from prompt definitions under
|
||||
`assets/schemas`. Shared reusable D&D prompt fragments are provided by
|
||||
`internal/modules/sharedassets/dnd` and referenced from prompt definitions under
|
||||
`./sharedassets/`.
|
||||
|
||||
Requires:
|
||||
|
||||
@@ -65,10 +65,12 @@ Each module package owns its contract implementation, module spec,
|
||||
registration, options, focused tests, and module-specific errors.
|
||||
|
||||
Module-owned prompts and schemas live in each module's shallow `assets/prompts`
|
||||
and `assets/schemas` directories. Shared reusable D&D prompt fragments live in
|
||||
`internal/modules/sharedassets`.
|
||||
and `assets/schemas` directories. Generic shared prompt filesystem composition
|
||||
lives in `internal/modules/sharedassets`; shared D&D prompt fragments and
|
||||
reference helpers live in `internal/modules/sharedassets/dnd`.
|
||||
|
||||
Shared asset package: `internal/modules/sharedassets`
|
||||
Shared D&D helper package: `internal/modules/sharedassets/dnd`
|
||||
|
||||
## Fixtures And Tests
|
||||
|
||||
@@ -88,7 +90,9 @@ servers.
|
||||
|
||||
- Source-format details stay in input modules and integration docs.
|
||||
- Extraction-domain details stay in extract modules and artifact docs.
|
||||
- Shared prompt fragments stay in `internal/modules/sharedassets`.
|
||||
- Generic shared prompt plumbing stays in `internal/modules/sharedassets`;
|
||||
domain-specific shared prompt behavior stays with the relevant module helper
|
||||
package.
|
||||
- Provider wire details stay in the LLM runtime and provider integration docs.
|
||||
- Durable output contracts belong in integration docs.
|
||||
- Operator procedures belong in `docs/operations.md`, not internal docs.
|
||||
|
||||
@@ -1,157 +1,22 @@
|
||||
# D&D Shared Module Helper Roadmap
|
||||
# D&D Shared Module Helper Refactor
|
||||
|
||||
This roadmap defines the target state for consolidating Dungeons & Dragons
|
||||
helper code that is currently duplicated across the implemented D&D modules.
|
||||
The refactor should preserve runtime behavior: module keys, prompt IDs, prompt
|
||||
versions, Scriptorium-visible prompt files, message order, response schemas,
|
||||
reference slot compatibility, manifests, diagnostics policy, and CLI/config
|
||||
semantics should not change.
|
||||
The D&D shared module helper refactor is complete.
|
||||
|
||||
## Motivation
|
||||
The implemented package split is:
|
||||
|
||||
The `dnd/scenes` chunker and `dnd/spells` extractor now share several D&D prompt
|
||||
and reference conventions:
|
||||
- `internal/modules/sharedassets`: generic shared prompt filesystem composition
|
||||
and domain-neutral shared asset plumbing.
|
||||
- `internal/modules/sharedassets/dnd`: D&D shared prompt fragments, accepted
|
||||
reference media types, `players`/`party`/`glossary` reference slot helpers,
|
||||
deprecated `roster` alias handling, prompt input assembly, deterministic
|
||||
reference rendering, and D&D shared prompt hash parts.
|
||||
- Concrete D&D modules: stage contracts and registration, module keys,
|
||||
provided/required capabilities, prompt IDs and versions, module-local prompt
|
||||
definitions, response schemas, validators, artifact or scene interpretation,
|
||||
and manifest metadata.
|
||||
|
||||
- prompt inputs named `transcript`, `players`, `party`, and `glossary`;
|
||||
- deprecated `roster` reference bindings mapped to the `party` prompt input;
|
||||
- D&D reference media-type declarations;
|
||||
- deterministic rendering of one or more reference files into prompt input
|
||||
materials;
|
||||
- shared D&D prompt fragments currently under `internal/modules/sharedassets`;
|
||||
- prompt hash inclusion for shared D&D prompt fragments.
|
||||
|
||||
Keeping this logic duplicated makes future D&D modules more likely to drift. It
|
||||
also makes small prompt-contract changes expensive because each module must
|
||||
update the same reference-slot, prompt-input, and test helper behavior.
|
||||
|
||||
## Target Package Boundary
|
||||
|
||||
D&D-specific shared helpers should live under:
|
||||
|
||||
```text
|
||||
internal/modules/sharedassets/dnd
|
||||
```
|
||||
|
||||
The package name should be `dnd`. This keeps generic shared asset plumbing in
|
||||
`internal/modules/sharedassets` while giving D&D-specific prompt and reference
|
||||
policy a clear home.
|
||||
|
||||
`internal/modules/sharedassets` should continue to own:
|
||||
|
||||
- generic shared prompt asset registration;
|
||||
- `ModulePromptFS` and related prompt filesystem layout helpers;
|
||||
- non-domain-specific embedded asset plumbing;
|
||||
- future prompt assets only when they are truly domain-neutral.
|
||||
|
||||
`internal/modules/sharedassets/dnd` should own D&D-specific reusable helpers:
|
||||
|
||||
- D&D shared prompt fragments;
|
||||
- accepted reference media types for D&D reference slots;
|
||||
- reference slot definitions for `players`, `party`, `glossary`, and the
|
||||
deprecated `roster` alias;
|
||||
- prompt input assembly for D&D modules that use the shared prompt fragments;
|
||||
- deterministic reference material rendering;
|
||||
- shared D&D prompt hash parts.
|
||||
|
||||
Concrete D&D modules should continue to own:
|
||||
|
||||
- stage contracts and module registration;
|
||||
- module keys and provided/required capabilities;
|
||||
- prompt IDs and prompt versions;
|
||||
- module-local prompt definitions, task prompts, and instruction prompts;
|
||||
- response schema definitions and schema metadata;
|
||||
- artifact interpretation, scene interpretation, validators, and output
|
||||
conversion.
|
||||
|
||||
Framework, core, and CLI packages must not import
|
||||
`internal/modules/sharedassets/dnd`.
|
||||
|
||||
The intended asset layout is:
|
||||
|
||||
```text
|
||||
internal/modules/sharedassets/
|
||||
assets.go
|
||||
prompt_fs.go
|
||||
|
||||
internal/modules/sharedassets/dnd/
|
||||
assets.go
|
||||
prompt_inputs.go
|
||||
references.go
|
||||
assets/
|
||||
prompts/
|
||||
common-dnd-system.md
|
||||
common-dnd-transcript.md
|
||||
common-dnd-references.md
|
||||
```
|
||||
|
||||
The parent `sharedassets` package should have no hard-coded knowledge of
|
||||
`common-dnd-*` files. It should provide generic composition primitives that a
|
||||
domain package such as `sharedassets/dnd` can use.
|
||||
|
||||
## Desired End State
|
||||
|
||||
The `dnd/scenes` and `dnd/spells` modules should remain small owners of their
|
||||
stage-specific behavior. Shared D&D helpers should remove duplicated mechanics
|
||||
without hiding module semantics.
|
||||
|
||||
After the refactor:
|
||||
|
||||
- both modules should call one shared helper to build the D&D prompt input set
|
||||
from source input and resolved references;
|
||||
- both modules should use one shared source of truth for D&D reference slots and
|
||||
accepted media types;
|
||||
- D&D shared prompt files should live in `internal/modules/sharedassets/dnd`
|
||||
beside the code that owns the D&D prompt contract;
|
||||
- generic shared asset plumbing should compose caller-provided shared prompt
|
||||
files without knowing which domain owns them;
|
||||
- deprecated `roster` bindings should continue to work as an alias for `party`;
|
||||
- reference rendering behavior should remain deterministic and covered by tests
|
||||
in the shared D&D helper package;
|
||||
- real prompt asset tests should remain decoupled from exact embedded prompt
|
||||
prose;
|
||||
- D&D module tests should focus on module contracts, request construction,
|
||||
response interpretation, validators, diagnostics, and manifest metadata rather
|
||||
than duplicate reference rendering details.
|
||||
|
||||
## Documentation Outcome
|
||||
|
||||
Current-behavior docs should describe the D&D shared helper package only where
|
||||
it helps future maintainers understand ownership:
|
||||
|
||||
- [Internal Modules](../internal/modules.md) should mention that common D&D
|
||||
prompt/reference helper behavior lives under
|
||||
`internal/modules/sharedassets/dnd`.
|
||||
- [Internal Overview](../internal/overview.md) may mention the package if it
|
||||
lists shared module-support packages.
|
||||
|
||||
User-facing docs should not describe internal helper placement unless behavior
|
||||
visible to users changes. This refactor should not change visible behavior.
|
||||
|
||||
## Resolved Decisions
|
||||
|
||||
### Should generic reference-slot cloning move to `contracts`?
|
||||
|
||||
Decision: move generic cloning to `internal/framework/contracts`, for example
|
||||
as `CloneReferenceSlots`. The operation is not D&D-specific and exists because
|
||||
`contracts.ReferenceSlot` contains mutable slices. Keeping the clone helper with
|
||||
the contract type makes defensive copying easier to reuse in future modules
|
||||
without introducing domain imports.
|
||||
|
||||
### Should shared D&D prompt hash parts move into `sharedassets/dnd`?
|
||||
|
||||
Decision: move D&D shared prompt hash helpers into
|
||||
`internal/modules/sharedassets/dnd`. The hash parts are D&D-specific prompt
|
||||
provenance, and placing them beside the D&D prompt contract reduces drift.
|
||||
|
||||
### Should D&D shared prompt assets move into `sharedassets/dnd`?
|
||||
|
||||
Decision: move D&D shared prompt assets into
|
||||
`internal/modules/sharedassets/dnd/assets/prompts`. The current
|
||||
`common-dnd-system.md`, `common-dnd-transcript.md`, and
|
||||
`common-dnd-references.md` files are not truly generic; they are part of the
|
||||
D&D prompt contract. Keeping the files beside D&D reference slots, prompt input
|
||||
assembly, reference rendering, and prompt hash helpers gives the package clear
|
||||
ownership of reusable D&D prompt behavior.
|
||||
|
||||
The parent `internal/modules/sharedassets` package may still contain prompt
|
||||
assets in the future, but only when they are genuinely domain-neutral.
|
||||
The `dnd/scenes` chunker and `dnd/spells` extractor now use the shared D&D
|
||||
helper package for common prompt and reference behavior while keeping
|
||||
stage-specific semantics in their concrete module packages. D&D shared prompt
|
||||
fragments are exposed to Scriptorium through each module's local
|
||||
`./sharedassets/` prompt paths.
|
||||
|
||||
@@ -1,277 +1,19 @@
|
||||
# D&D Shared Helper Refactor Implementation Plan
|
||||
# D&D Shared Helper Refactor Completed
|
||||
|
||||
## Summary
|
||||
The D&D shared helper refactor is implemented.
|
||||
|
||||
Implement the target state defined in
|
||||
[D&D Shared Module Helper Roadmap](dnd.md). This is an internal refactor: do not
|
||||
change module keys, prompt IDs, prompt versions, Scriptorium-visible message
|
||||
order, response schema IDs or names, CLI/config semantics, manifest shape,
|
||||
diagnostics redaction policy, or D&D scene/spell interpretation behavior.
|
||||
|
||||
The intended package split is:
|
||||
Implemented package ownership:
|
||||
|
||||
- `internal/modules/sharedassets`: generic prompt filesystem composition and
|
||||
non-domain-specific asset plumbing only;
|
||||
- `internal/modules/sharedassets/dnd`: reusable D&D prompt assets, prompt input
|
||||
helpers, reference slot helpers, reference rendering, and D&D shared prompt
|
||||
hash parts;
|
||||
- concrete D&D modules: stage contracts, module registration, module-local
|
||||
prompt definitions, response schemas, validators, and response
|
||||
interpretation.
|
||||
domain-neutral shared asset plumbing.
|
||||
- `internal/modules/sharedassets/dnd`: reusable D&D prompt fragments, prompt
|
||||
filesystem composition for D&D modules, D&D reference slot helpers, prompt
|
||||
input assembly, reference rendering, and D&D shared prompt hash parts.
|
||||
- Concrete D&D modules: stage contracts, module registration, module-local
|
||||
prompt definitions, response schemas, validators, request validation,
|
||||
response conversion, and manifest metadata ownership.
|
||||
|
||||
## Stage 1: Add Generic Defensive-Copy Helper
|
||||
|
||||
- Add `CloneReferenceSlots` to `internal/framework/contracts`.
|
||||
- The helper must:
|
||||
- return `nil` for an empty input slice;
|
||||
- allocate a new slot slice;
|
||||
- deep-copy each `AcceptedMediaTypes` slice;
|
||||
- preserve all other `ReferenceSlot` fields exactly.
|
||||
- Add focused tests in `internal/framework/contracts` proving nil/empty
|
||||
behavior, deep-copy behavior, and full field preservation.
|
||||
- Do not import any concrete module package from `contracts`.
|
||||
|
||||
## Stage 2: Make Shared Prompt FS Composition Domain-Neutral
|
||||
|
||||
- Refactor `internal/modules/sharedassets.ModulePromptFS` so the parent package
|
||||
no longer knows about D&D prompt filenames or embedded D&D assets.
|
||||
- Add a generic shared prompt file descriptor:
|
||||
|
||||
```go
|
||||
type SharedPromptFile struct {
|
||||
Name string
|
||||
FS fs.FS
|
||||
Path string
|
||||
}
|
||||
```
|
||||
|
||||
- Change `ModulePromptFS` to this shape:
|
||||
|
||||
```go
|
||||
func ModulePromptFS(
|
||||
moduleDir string,
|
||||
moduleFS fs.FS,
|
||||
files []ModulePromptFile,
|
||||
sharedFiles ...SharedPromptFile,
|
||||
) (fs.FS, error)
|
||||
```
|
||||
|
||||
- Keep existing `ModulePromptFile` semantics:
|
||||
- `Name` is mounted directly under `assets/prompts/<moduleDir>/`;
|
||||
- `Path` is read from the module embedded filesystem.
|
||||
- Mount each `SharedPromptFile` under
|
||||
`assets/prompts/<moduleDir>/sharedassets/<Name>`.
|
||||
- Validate all names and paths with contextual errors:
|
||||
- `moduleDir` must be a valid non-root fs path;
|
||||
- module prompt `Name` must be a single filename with no path separators;
|
||||
- shared prompt `Name` must be a single filename with no path separators;
|
||||
- `moduleFS` and each shared file `FS` must be non-nil;
|
||||
- missing module or shared files must return errors naming the source path.
|
||||
- Preserve the in-memory FS behavior currently covered by tests, including
|
||||
readable intermediate directories and root `.` support.
|
||||
- Update `internal/modules/sharedassets` tests so they use test-owned shared
|
||||
prompt files from `fstest.MapFS`, not production D&D prompt assets.
|
||||
- Remove D&D-specific helpers, constants, and embedded asset dependencies from
|
||||
the parent `sharedassets` package.
|
||||
|
||||
## Stage 3: Create `sharedassets/dnd`
|
||||
|
||||
- Create `internal/modules/sharedassets/dnd` with package name `dnd`.
|
||||
- Move the current D&D shared prompt files into:
|
||||
|
||||
```text
|
||||
internal/modules/sharedassets/dnd/assets/prompts/common-dnd-system.md
|
||||
internal/modules/sharedassets/dnd/assets/prompts/common-dnd-transcript.md
|
||||
internal/modules/sharedassets/dnd/assets/prompts/common-dnd-references.md
|
||||
```
|
||||
|
||||
- Add `assets.go` in the D&D package with `go:embed assets/prompts/*.md`.
|
||||
- Expose D&D shared asset helpers:
|
||||
|
||||
```go
|
||||
func SharedPromptFiles() []sharedassets.SharedPromptFile
|
||||
func CommonHashParts() []llm.AssetHashPart
|
||||
func ReferenceHashParts() []llm.AssetHashPart
|
||||
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []sharedassets.ModulePromptFile) (fs.FS, error)
|
||||
```
|
||||
|
||||
- `SharedPromptFiles` must return a new slice each call.
|
||||
- `CommonHashParts` must include `common-dnd-system.md` and
|
||||
`common-dnd-transcript.md`.
|
||||
- `ReferenceHashParts` must include `common-dnd-references.md`.
|
||||
- `ModulePromptFS` must call `sharedassets.ModulePromptFS` with
|
||||
`SharedPromptFiles()...`.
|
||||
- Do not expose a D&D package registration function unless a concrete caller
|
||||
needs top-level D&D shared prompt files outside module-local prompt
|
||||
composition. The D&D prompt definitions should continue to load shared files
|
||||
through `./sharedassets/...` inside each module prompt directory.
|
||||
|
||||
## Stage 4: Add D&D Reference And Prompt Input Helpers
|
||||
|
||||
- In `internal/modules/sharedassets/dnd`, add D&D reference helpers:
|
||||
|
||||
```go
|
||||
type ReferenceSlotDescriptions struct {
|
||||
Glossary string
|
||||
Party string
|
||||
Players string
|
||||
Roster string
|
||||
}
|
||||
|
||||
func ReferenceMediaTypes() []string
|
||||
func ReferenceSlots(descriptions ReferenceSlotDescriptions) []contracts.ReferenceSlot
|
||||
```
|
||||
|
||||
- `ReferenceMediaTypes` must return a defensive copy of:
|
||||
- `application/json`
|
||||
- `application/x-yaml`
|
||||
- `application/yaml`
|
||||
- `text/markdown`
|
||||
- `text/plain`
|
||||
- `ReferenceSlots` must return slots in the same order currently exposed by
|
||||
both D&D modules: `glossary`, `party`, `players`, `roster`.
|
||||
- Slot names and alias semantics must remain unchanged.
|
||||
- Slot descriptions should be supplied by each concrete module through
|
||||
`ReferenceSlotDescriptions` so current module metadata text can be preserved.
|
||||
- `ReferenceSlots` must use `contracts.CloneReferenceSlots` or equivalent
|
||||
defensive-copy behavior before returning.
|
||||
|
||||
- Add prompt input helpers:
|
||||
|
||||
```go
|
||||
func PromptInputs(sourceInput contracts.LLMInputMaterial, references contracts.ReferenceSet) contracts.LLMInputSet
|
||||
func TranscriptPromptMaterial(material contracts.LLMInputMaterial) contracts.LLMInputMaterial
|
||||
func ReferencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot) contracts.LLMInputMaterial
|
||||
func ReferencePromptInput(slot contracts.ResolvedReferenceSlot) []byte
|
||||
```
|
||||
|
||||
- `PromptInputs` must return inputs named `transcript`, `players`, `party`, and
|
||||
`glossary`.
|
||||
- If `party` has no items and `roster` has items, `PromptInputs` must use the
|
||||
`roster` slot content for the `party` input.
|
||||
- `PromptInputs` must not include a `roster` prompt input.
|
||||
- `TranscriptPromptMaterial` must clone the source input and set `Name` to
|
||||
`transcript`.
|
||||
- `ReferencePromptMaterial` must use media type `text/plain`; for a single
|
||||
reference item it must propagate that item's digest and origin URI.
|
||||
- `ReferencePromptInput` behavior must match current D&D module behavior:
|
||||
- empty slot renders as a single space;
|
||||
- one item renders as raw item content;
|
||||
- multiple items are copied, sorted deterministically by origin URI, digest,
|
||||
then content, and rendered with the same heading/metadata format currently
|
||||
used by `dnd/scenes` and `dnd/spells`.
|
||||
|
||||
- Add focused tests under `internal/modules/sharedassets/dnd` for all helper
|
||||
behavior. These tests may assert rendering of helper-owned fixture inputs, but
|
||||
must not assert exact production embedded prompt prose.
|
||||
|
||||
## Stage 5: Update D&D Modules To Use Shared Helpers
|
||||
|
||||
- Update `internal/modules/chunk/dnd/scenes`:
|
||||
- import `internal/modules/sharedassets/dnd`;
|
||||
- replace local accepted media type and slot cloning logic with
|
||||
`dnd.ReferenceSlots`;
|
||||
- preserve existing scene-specific slot descriptions;
|
||||
- replace local prompt input/reference rendering helpers with
|
||||
`dnd.PromptInputs`;
|
||||
- replace `sharedassets.ModulePromptFS` with `dnd.ModulePromptFS`;
|
||||
- replace `sharedassets.CommonHashParts` and `ReferenceHashParts` with the
|
||||
D&D package equivalents.
|
||||
- Update `internal/modules/extract/dnd/spells` the same way, preserving current
|
||||
extractor-specific slot descriptions.
|
||||
- Delete local duplicated helper functions that become unused:
|
||||
- `acceptedReferenceMediaTypes`;
|
||||
- `cloneReferenceSlots`;
|
||||
- `transcriptPromptInput`;
|
||||
- `referencePromptMaterial`;
|
||||
- `referencePromptInput`.
|
||||
- Keep module-local schema loading, prompt IDs, prompt versions, validators,
|
||||
request validation, response conversion, and manifest metadata ownership in
|
||||
each concrete module.
|
||||
|
||||
## Stage 6: Update Asset Registration And Tests
|
||||
|
||||
- Update production prompt asset registration in `internal/cli` as needed:
|
||||
- remove any call that registers D&D shared prompt files from the generic
|
||||
`sharedassets` package;
|
||||
- ensure `dnd/scenes` and `dnd/spells` prompt registration still makes their
|
||||
module-local `./sharedassets/common-dnd-*.md` files available.
|
||||
- Update production prompt asset tests so they assert module-local shared asset
|
||||
paths such as:
|
||||
- `dnd.scenes/sharedassets/common-dnd-system.md`
|
||||
- `dnd.spells/sharedassets/common-dnd-system.md`
|
||||
- Remove expectations for top-level `common-dnd-*.md` prompt files unless a
|
||||
genuine top-level registration remains necessary.
|
||||
- Move reference rendering tests from concrete D&D modules into
|
||||
`internal/modules/sharedassets/dnd`.
|
||||
- Keep concrete D&D module tests focused on:
|
||||
- module specs and registry behavior;
|
||||
- LLM request prompt ID/version/profile/session fields;
|
||||
- transcript and reference input propagation;
|
||||
- Scriptorium prompt preparation;
|
||||
- diagnostics redaction;
|
||||
- manifest metadata;
|
||||
- scene/spell response interpretation.
|
||||
- Follow the prompt asset testing policy in `docs/policy/development.md`: do
|
||||
not assert exact production embedded prompt prose.
|
||||
|
||||
## Stage 7: Update Documentation
|
||||
|
||||
- Update canonical internal docs for implemented behavior:
|
||||
- `docs/internal/modules.md`: mention that common D&D prompt/reference helper
|
||||
behavior lives under `internal/modules/sharedassets/dnd`;
|
||||
- `docs/internal/overview.md`: mention the package only if the overview lists
|
||||
shared module-support packages.
|
||||
- Do not add user-facing documentation unless visible behavior changes.
|
||||
- Update `docs/roadmap/dnd.md` after implementation so it no longer presents
|
||||
this refactor as future work.
|
||||
- Replace this file with a concise completed-note document once the work is
|
||||
implemented.
|
||||
|
||||
## Validation
|
||||
|
||||
Run focused tests after each meaningful stage:
|
||||
|
||||
```sh
|
||||
go test ./internal/framework/contracts
|
||||
go test ./internal/modules/sharedassets
|
||||
go test ./internal/modules/sharedassets/dnd
|
||||
go test ./internal/modules/chunk/dnd/scenes
|
||||
go test ./internal/modules/extract/dnd/spells
|
||||
go test ./internal/cli
|
||||
```
|
||||
|
||||
Run full validation before completion:
|
||||
|
||||
```sh
|
||||
go test ./...
|
||||
go vet ./...
|
||||
go build ./cmd/notarius
|
||||
```
|
||||
|
||||
Run inspection searches:
|
||||
|
||||
```sh
|
||||
rg -n "common-dnd|CommonHashParts|ReferenceHashParts|referencePromptInput|cloneReferenceSlots|acceptedReferenceMediaTypes" internal/modules internal/framework
|
||||
rg -n "Divide the provided transcript|Extract Dungeons & Dragons spell-cast artifacts|A transcript of a Dungeons & Dragons gameplay session|Roster reference|Glossary reference" internal/**/*_test.go
|
||||
```
|
||||
|
||||
Expected inspection outcomes:
|
||||
|
||||
- D&D shared prompt files and hash helpers are owned by
|
||||
`internal/modules/sharedassets/dnd`;
|
||||
- parent `sharedassets` has no hard-coded `common-dnd-*` knowledge;
|
||||
- concrete D&D modules no longer carry duplicated reference rendering logic;
|
||||
- tests do not assert exact production embedded prompt prose.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change D&D prompt wording except for file moves required by this
|
||||
refactor.
|
||||
- Do not change prompt IDs, prompt versions, profile defaults, Scriptorium
|
||||
message order, schema files, schema metadata, module keys, capabilities,
|
||||
manifest shapes, or diagnostics policy.
|
||||
- Do not move scene boundary logic, spell artifact logic, validators, or schema
|
||||
loading into the shared D&D package.
|
||||
- Do not make framework, core, or CLI packages depend on
|
||||
`internal/modules/sharedassets/dnd`.
|
||||
The refactor preserved module keys, prompt IDs, prompt versions,
|
||||
Scriptorium-visible message order, response schema IDs and names, CLI/config
|
||||
semantics, manifest shape, diagnostics redaction policy, and D&D scene/spell
|
||||
interpretation behavior.
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/merge/appendorder"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/normalize/noop"
|
||||
jsonoutput "gitea.maximumdirect.net/eric/notarius/internal/modules/output/json"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
)
|
||||
|
||||
func productionRegistries() (pipeline.Registries, error) {
|
||||
@@ -63,9 +62,6 @@ func productionCatalog() (pipeline.ModuleCatalog, error) {
|
||||
|
||||
func productionPromptAssets() (*llm.AssetRegistry, error) {
|
||||
registry := llm.NewAssetRegistry()
|
||||
if err := sharedassets.Register(registry); err != nil {
|
||||
return nil, fmt.Errorf("register shared dnd prompt assets: %w", err)
|
||||
}
|
||||
if err := scenes.RegisterPromptAssets(registry); err != nil {
|
||||
return nil, fmt.Errorf("register dnd scenes prompt assets: %w", err)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -191,9 +192,6 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) {
|
||||
t.Fatalf("PromptFS() error = %v, want nil", err)
|
||||
}
|
||||
for _, name := range []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"dnd.scenes/dnd.scenes.yaml",
|
||||
"dnd.scenes/task.md",
|
||||
"dnd.scenes/instructions.md",
|
||||
@@ -211,6 +209,15 @@ func TestProductionPromptAssetsRegisterAndPrepareDndPrompts(t *testing.T) {
|
||||
t.Fatalf("PromptFS().Open(%q) error = %v, want nil", name, err)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
} {
|
||||
if _, err := promptFS.Open(name); !errors.Is(err, fs.ErrNotExist) {
|
||||
t.Fatalf("PromptFS().Open(%q) error = %v, want not exist", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
options, err := registry.ScriptoriumOptions()
|
||||
if err != nil {
|
||||
|
||||
@@ -132,6 +132,18 @@ type ReferenceSlot struct {
|
||||
MaxBytes int64 `json:"max_bytes,omitempty"`
|
||||
}
|
||||
|
||||
func CloneReferenceSlots(slots []ReferenceSlot) []ReferenceSlot {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]ReferenceSlot, len(slots))
|
||||
for i, slot := range slots {
|
||||
slot.AcceptedMediaTypes = append([]string(nil), slot.AcceptedMediaTypes...)
|
||||
out[i] = slot
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
type ReferenceOrigin struct {
|
||||
Type string `json:"type"`
|
||||
URI string `json:"uri,omitempty"`
|
||||
|
||||
@@ -3,6 +3,7 @@ package contracts
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/artifacts"
|
||||
@@ -225,6 +226,59 @@ func TestReferenceSetDataTypes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneReferenceSlotsEmptyInputReturnsNil(t *testing.T) {
|
||||
if got := CloneReferenceSlots(nil); got != nil {
|
||||
t.Fatalf("CloneReferenceSlots(nil) = %#v, want nil", got)
|
||||
}
|
||||
if got := CloneReferenceSlots([]ReferenceSlot{}); got != nil {
|
||||
t.Fatalf("CloneReferenceSlots(empty) = %#v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneReferenceSlotsPreservesFields(t *testing.T) {
|
||||
slots := []ReferenceSlot{
|
||||
{
|
||||
Name: "roster",
|
||||
Description: "Known characters",
|
||||
Required: true,
|
||||
AcceptedMediaTypes: []string{"text/plain", "text/markdown"},
|
||||
Multiple: true,
|
||||
MaxBytes: 4096,
|
||||
},
|
||||
{
|
||||
Name: "glossary",
|
||||
Description: "Campaign terms",
|
||||
MaxBytes: 2048,
|
||||
},
|
||||
}
|
||||
|
||||
got := CloneReferenceSlots(slots)
|
||||
|
||||
if !reflect.DeepEqual(got, slots) {
|
||||
t.Fatalf("CloneReferenceSlots() = %#v, want %#v", got, slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCloneReferenceSlotsCopiesAcceptedMediaTypes(t *testing.T) {
|
||||
slots := []ReferenceSlot{
|
||||
{
|
||||
Name: "party",
|
||||
AcceptedMediaTypes: []string{"application/json", "text/plain"},
|
||||
},
|
||||
}
|
||||
|
||||
got := CloneReferenceSlots(slots)
|
||||
got[0].Name = "changed"
|
||||
got[0].AcceptedMediaTypes[0] = "text/markdown"
|
||||
|
||||
if slots[0].Name != "party" {
|
||||
t.Fatalf("source slot name = %q, want unchanged", slots[0].Name)
|
||||
}
|
||||
if slots[0].AcceptedMediaTypes[0] != "application/json" {
|
||||
t.Fatalf("source AcceptedMediaTypes aliased clone: %#v", slots[0].AcceptedMediaTypes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceItemJSONOmitsContent(t *testing.T) {
|
||||
item := ReferenceItem{
|
||||
SlotName: "roster",
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"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/sharedassets/dnd"
|
||||
)
|
||||
|
||||
const Key = "dnd/scenes"
|
||||
@@ -21,35 +22,11 @@ var providedCapabilities = []string{
|
||||
"chunks.scenes",
|
||||
}
|
||||
|
||||
var acceptedReferenceMediaTypes = []string{
|
||||
"application/json",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
}
|
||||
|
||||
var referenceSlots = []contracts.ReferenceSlot{
|
||||
{
|
||||
Name: "glossary",
|
||||
Description: "Optional campaign glossary reference material used only for scene disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||
},
|
||||
{
|
||||
Name: "party",
|
||||
Description: "Optional party roster reference material used only for scene disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||
},
|
||||
{
|
||||
Name: "players",
|
||||
Description: "Optional player list reference material used only for scene disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||
},
|
||||
{
|
||||
Name: "roster",
|
||||
Description: "Deprecated alias for party roster reference material used only for scene disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||
},
|
||||
var referenceSlotDescriptions = dnd.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.",
|
||||
}
|
||||
|
||||
var _ contracts.Chunker = (*Chunker)(nil)
|
||||
@@ -66,7 +43,7 @@ func (c *Chunker) Key() string {
|
||||
}
|
||||
|
||||
func (c *Chunker) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return cloneReferenceSlots(referenceSlots)
|
||||
return dnd.ReferenceSlots(referenceSlotDescriptions)
|
||||
}
|
||||
|
||||
func (c *Chunker) ManifestMetadata() map[string]any {
|
||||
@@ -122,7 +99,7 @@ func (c *Chunker) Chunk(ctx context.Context, req contracts.ChunkRequest) (contra
|
||||
PromptVersion: ResponseSchemaVersion,
|
||||
ProfileID: req.LLMProfile,
|
||||
SessionID: req.SessionID,
|
||||
Inputs: promptInputs(req),
|
||||
Inputs: dnd.PromptInputs(req.SourceInput, req.References),
|
||||
}, &response); err != nil {
|
||||
return contracts.ChunkResult{}, chunkerErrorf("complete structured output: %w", err)
|
||||
}
|
||||
@@ -147,7 +124,7 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
Stage: pipeline.StageChunk,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ReferenceSlots: cloneReferenceSlots(referenceSlots),
|
||||
ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -338,15 +315,3 @@ func cloneMetadata(metadata map[string]any) map[string]any {
|
||||
func chunkerErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd scenes chunker: "+format, args...)
|
||||
}
|
||||
|
||||
func cloneReferenceSlots(slots []contracts.ReferenceSlot) []contracts.ReferenceSlot {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]contracts.ReferenceSlot, len(slots))
|
||||
for i, slot := range slots {
|
||||
slot.AcceptedMediaTypes = append([]string(nil), slot.AcceptedMediaTypes...)
|
||||
out[i] = slot
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"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/sharedassets/dnd"
|
||||
)
|
||||
|
||||
func TestNewModuleSpecAndRegister(t *testing.T) {
|
||||
@@ -257,9 +258,7 @@ func TestChunkPassesReferencesAsPromptInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
inputs := promptInputs(contracts.ChunkRequest{
|
||||
SourceInput: sceneSourceInput(),
|
||||
References: contracts.ReferenceSet{
|
||||
inputs := dnd.PromptInputs(sceneSourceInput(), contracts.ReferenceSet{
|
||||
Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"roster": {
|
||||
Slot: contracts.ReferenceSlot{Name: "roster"},
|
||||
@@ -268,7 +267,6 @@ func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if got := string(inputs["party"].Content); got != "Legacy roster text" {
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
package scenes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||
)
|
||||
|
||||
const scriptoriumPromptRoot = "assets/prompts"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := sharedassets.ModulePromptFS("dnd.scenes", embeddedAssets, []sharedassets.ModulePromptFile{
|
||||
promptFS, err := dnd.ModulePromptFS("dnd.scenes", embeddedAssets, []sharedassets.ModulePromptFile{
|
||||
{Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
@@ -28,93 +26,18 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
|
||||
}
|
||||
|
||||
func promptInputs(req contracts.ChunkRequest) contracts.LLMInputSet {
|
||||
partySlot := req.References.Slots["party"]
|
||||
if len(partySlot.Items) == 0 {
|
||||
partySlot = req.References.Slots["roster"]
|
||||
}
|
||||
return contracts.LLMInputSet{
|
||||
"transcript": transcriptPromptInput(req.SourceInput),
|
||||
"players": referencePromptMaterial("players", req.References.Slots["players"]),
|
||||
"party": referencePromptMaterial("party", partySlot),
|
||||
"glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]),
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptPromptInput(material contracts.LLMInputMaterial) contracts.LLMInputMaterial {
|
||||
out := material.Clone()
|
||||
out.Name = "transcript"
|
||||
return out
|
||||
}
|
||||
|
||||
func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot) contracts.LLMInputMaterial {
|
||||
body := referencePromptInput(slot)
|
||||
digest := ""
|
||||
originURI := ""
|
||||
if len(slot.Items) == 1 {
|
||||
digest = slot.Items[0].Digest
|
||||
originURI = slot.Items[0].Origin.URI
|
||||
}
|
||||
return contracts.NewLLMInputMaterial(name, "text/plain", body, digest, originURI)
|
||||
}
|
||||
|
||||
func scriptoriumPromptMetadata() (string, error) {
|
||||
scriptoriumPromptHashOnce.Do(func() {
|
||||
parts := append([]llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.scenes.yaml"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||
}, append(sharedassets.CommonHashParts(), sharedassets.ReferenceHashParts()...)...)
|
||||
}, append(dnd.CommonHashParts(), dnd.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
}
|
||||
|
||||
func referencePromptInput(slot contracts.ResolvedReferenceSlot) []byte {
|
||||
if len(slot.Items) == 0 {
|
||||
return []byte(" ")
|
||||
}
|
||||
items := append([]contracts.ReferenceItem(nil), slot.Items...)
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Origin.URI != items[j].Origin.URI {
|
||||
return items[i].Origin.URI < items[j].Origin.URI
|
||||
}
|
||||
if items[i].Digest != items[j].Digest {
|
||||
return items[i].Digest < items[j].Digest
|
||||
}
|
||||
return string(items[i].Content) < string(items[j].Content)
|
||||
})
|
||||
if len(items) == 1 {
|
||||
return append([]byte(nil), items[0].Content...)
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
for i, item := range items {
|
||||
if i > 0 {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "Reference %d\n", i+1)
|
||||
if item.Origin.Type != "" {
|
||||
fmt.Fprintf(&b, "Origin-Type: %s\n", item.Origin.Type)
|
||||
}
|
||||
if item.Origin.URI != "" {
|
||||
fmt.Fprintf(&b, "Origin-URI: %s\n", item.Origin.URI)
|
||||
}
|
||||
if item.Digest != "" {
|
||||
fmt.Fprintf(&b, "Digest: %s\n", item.Digest)
|
||||
}
|
||||
if item.MediaType != "" {
|
||||
fmt.Fprintf(&b, "Media-Type: %s\n", item.MediaType)
|
||||
}
|
||||
if item.SizeBytes > 0 {
|
||||
fmt.Fprintf(&b, "Size-Bytes: %d\n", item.SizeBytes)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.Write(item.Content)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
var (
|
||||
scriptoriumPromptHashOnce sync.Once
|
||||
scriptoriumPromptHash string
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
"gitea.maximumdirect.net/eric/scriptorium"
|
||||
)
|
||||
|
||||
@@ -90,9 +89,6 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
||||
func prepareScenesPrompt(t *testing.T, transcript []byte, players string, party string, glossary string) *scriptorium.PreparedRun {
|
||||
t.Helper()
|
||||
registry := llm.NewAssetRegistry()
|
||||
if err := sharedassets.Register(registry); err != nil {
|
||||
t.Fatalf("register shared prompt assets: %v", err)
|
||||
}
|
||||
if err := RegisterPromptAssets(registry); err != nil {
|
||||
t.Fatalf("register scene prompt assets: %v", err)
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"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/sharedassets/dnd"
|
||||
)
|
||||
|
||||
const Key = "dnd/spells"
|
||||
@@ -25,35 +26,11 @@ var providedCapabilities = []string{
|
||||
"dnd.spell_casts",
|
||||
}
|
||||
|
||||
var acceptedReferenceMediaTypes = []string{
|
||||
"application/json",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
}
|
||||
|
||||
var referenceSlots = []contracts.ReferenceSlot{
|
||||
{
|
||||
Name: "glossary",
|
||||
Description: "Optional campaign glossary reference material used only for disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||
},
|
||||
{
|
||||
Name: "party",
|
||||
Description: "Optional party roster reference material used only for disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||
},
|
||||
{
|
||||
Name: "players",
|
||||
Description: "Optional player list reference material used only for disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||
},
|
||||
{
|
||||
Name: "roster",
|
||||
Description: "Deprecated alias for party roster reference material used only for disambiguation.",
|
||||
AcceptedMediaTypes: append([]string(nil), acceptedReferenceMediaTypes...),
|
||||
},
|
||||
var referenceSlotDescriptions = dnd.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.",
|
||||
}
|
||||
|
||||
var _ contracts.Extractor = (*Extractor)(nil)
|
||||
@@ -77,7 +54,7 @@ func (e *Extractor) SchemaVersion() string {
|
||||
}
|
||||
|
||||
func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
return cloneReferenceSlots(referenceSlots)
|
||||
return dnd.ReferenceSlots(referenceSlotDescriptions)
|
||||
}
|
||||
|
||||
func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
@@ -137,7 +114,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.ExtractionRequest
|
||||
PromptVersion: SchemaVersion,
|
||||
ProfileID: req.LLMProfile,
|
||||
SessionID: req.SessionID,
|
||||
Inputs: promptInputs(req),
|
||||
Inputs: dnd.PromptInputs(req.SourceInput, req.References),
|
||||
}, &response); err != nil {
|
||||
return contracts.ExtractionResult{}, extractorErrorf("complete structured output: %w", err)
|
||||
}
|
||||
@@ -168,7 +145,7 @@ func ModuleSpec() pipeline.ModuleSpec {
|
||||
Stage: pipeline.StageExtract,
|
||||
Requires: append([]string(nil), requiredCapabilities...),
|
||||
Provides: append([]string(nil), providedCapabilities...),
|
||||
ReferenceSlots: cloneReferenceSlots(referenceSlots),
|
||||
ReferenceSlots: dnd.ReferenceSlots(referenceSlotDescriptions),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,15 +167,3 @@ func spellCastPayload(spellCast spellCastResponse) (json.RawMessage, error) {
|
||||
func extractorErrorf(format string, args ...any) error {
|
||||
return fmt.Errorf("dnd spells extractor: "+format, args...)
|
||||
}
|
||||
|
||||
func cloneReferenceSlots(slots []contracts.ReferenceSlot) []contracts.ReferenceSlot {
|
||||
if len(slots) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]contracts.ReferenceSlot, len(slots))
|
||||
for i, slot := range slots {
|
||||
slot.AcceptedMediaTypes = append([]string(nil), slot.AcceptedMediaTypes...)
|
||||
out[i] = slot
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||
)
|
||||
|
||||
func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) {
|
||||
@@ -156,9 +157,7 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
inputs := promptInputs(contracts.ExtractionRequest{
|
||||
SourceInput: spellSourceInput(),
|
||||
References: contracts.ReferenceSet{
|
||||
inputs := dnd.PromptInputs(spellSourceInput(), contracts.ReferenceSet{
|
||||
Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"roster": {
|
||||
Slot: contracts.ReferenceSlot{Name: "roster"},
|
||||
@@ -167,7 +166,6 @@ func TestPromptInputsMapLegacyRosterReferenceToParty(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if got := string(inputs["party"].Content); got != "Legacy roster text" {
|
||||
|
||||
@@ -1,20 +1,18 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets/dnd"
|
||||
)
|
||||
|
||||
const scriptoriumPromptRoot = "assets/prompts"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := sharedassets.ModulePromptFS("dnd.spells", embeddedAssets, []sharedassets.ModulePromptFile{
|
||||
promptFS, err := dnd.ModulePromptFS("dnd.spells", embeddedAssets, []sharedassets.ModulePromptFile{
|
||||
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
@@ -28,93 +26,18 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
return registry.RegisterSchemaFS(embeddedAssets, "assets/schemas")
|
||||
}
|
||||
|
||||
func promptInputs(req contracts.ExtractionRequest) contracts.LLMInputSet {
|
||||
partySlot := req.References.Slots["party"]
|
||||
if len(partySlot.Items) == 0 {
|
||||
partySlot = req.References.Slots["roster"]
|
||||
}
|
||||
return contracts.LLMInputSet{
|
||||
"transcript": transcriptPromptInput(req.SourceInput),
|
||||
"players": referencePromptMaterial("players", req.References.Slots["players"]),
|
||||
"party": referencePromptMaterial("party", partySlot),
|
||||
"glossary": referencePromptMaterial("glossary", req.References.Slots["glossary"]),
|
||||
}
|
||||
}
|
||||
|
||||
func transcriptPromptInput(material contracts.LLMInputMaterial) contracts.LLMInputMaterial {
|
||||
out := material.Clone()
|
||||
out.Name = "transcript"
|
||||
return out
|
||||
}
|
||||
|
||||
func referencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot) contracts.LLMInputMaterial {
|
||||
body := referencePromptInput(slot)
|
||||
digest := ""
|
||||
originURI := ""
|
||||
if len(slot.Items) == 1 {
|
||||
digest = slot.Items[0].Digest
|
||||
originURI = slot.Items[0].Origin.URI
|
||||
}
|
||||
return contracts.NewLLMInputMaterial(name, "text/plain", body, digest, originURI)
|
||||
}
|
||||
|
||||
func scriptoriumPromptMetadata() (string, error) {
|
||||
scriptoriumPromptHashOnce.Do(func() {
|
||||
parts := append([]llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||
}, append(sharedassets.CommonHashParts(), sharedassets.ReferenceHashParts()...)...)
|
||||
}, append(dnd.CommonHashParts(), dnd.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
}
|
||||
|
||||
func referencePromptInput(slot contracts.ResolvedReferenceSlot) []byte {
|
||||
if len(slot.Items) == 0 {
|
||||
return []byte(" ")
|
||||
}
|
||||
items := append([]contracts.ReferenceItem(nil), slot.Items...)
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Origin.URI != items[j].Origin.URI {
|
||||
return items[i].Origin.URI < items[j].Origin.URI
|
||||
}
|
||||
if items[i].Digest != items[j].Digest {
|
||||
return items[i].Digest < items[j].Digest
|
||||
}
|
||||
return string(items[i].Content) < string(items[j].Content)
|
||||
})
|
||||
if len(items) == 1 {
|
||||
return append([]byte(nil), items[0].Content...)
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
for i, item := range items {
|
||||
if i > 0 {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "Reference %d\n", i+1)
|
||||
if item.Origin.Type != "" {
|
||||
fmt.Fprintf(&b, "Origin-Type: %s\n", item.Origin.Type)
|
||||
}
|
||||
if item.Origin.URI != "" {
|
||||
fmt.Fprintf(&b, "Origin-URI: %s\n", item.Origin.URI)
|
||||
}
|
||||
if item.Digest != "" {
|
||||
fmt.Fprintf(&b, "Digest: %s\n", item.Digest)
|
||||
}
|
||||
if item.MediaType != "" {
|
||||
fmt.Fprintf(&b, "Media-Type: %s\n", item.MediaType)
|
||||
}
|
||||
if item.SizeBytes > 0 {
|
||||
fmt.Fprintf(&b, "Size-Bytes: %d\n", item.SizeBytes)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.Write(item.Content)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
var (
|
||||
scriptoriumPromptHashOnce sync.Once
|
||||
scriptoriumPromptHash string
|
||||
|
||||
@@ -7,9 +7,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
"gitea.maximumdirect.net/eric/scriptorium"
|
||||
)
|
||||
|
||||
@@ -52,49 +50,6 @@ func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencePromptInputRenderingIsDeterministic(t *testing.T) {
|
||||
slot := contracts.ResolvedReferenceSlot{
|
||||
Items: []contracts.ReferenceItem{
|
||||
{
|
||||
SlotName: "roster",
|
||||
MediaType: "text/plain",
|
||||
Content: []byte("second"),
|
||||
Digest: "sha256:bbb",
|
||||
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///b.txt"},
|
||||
SizeBytes: 6,
|
||||
},
|
||||
{
|
||||
SlotName: "roster",
|
||||
MediaType: "text/plain",
|
||||
Content: []byte("first"),
|
||||
Digest: "sha256:aaa",
|
||||
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///a.txt"},
|
||||
SizeBytes: 5,
|
||||
},
|
||||
},
|
||||
}
|
||||
first := string(referencePromptInput(slot))
|
||||
second := string(referencePromptInput(slot))
|
||||
if first != second {
|
||||
t.Fatalf("reference rendering was not deterministic:\nfirst=%q\nsecond=%q", first, second)
|
||||
}
|
||||
if !strings.Contains(first, "Reference 1\nOrigin-Type: file\nOrigin-URI: file:///a.txt\nDigest: sha256:aaa") {
|
||||
t.Fatalf("first reference heading was not stable: %q", first)
|
||||
}
|
||||
if strings.Index(first, "first") > strings.Index(first, "second") {
|
||||
t.Fatalf("references were not sorted deterministically: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleReferencePromptInputKeepsContentOnly(t *testing.T) {
|
||||
got := string(referencePromptInput(contracts.ResolvedReferenceSlot{
|
||||
Items: []contracts.ReferenceItem{{Content: []byte("single reference")}},
|
||||
}))
|
||||
if got != "single reference" {
|
||||
t.Fatalf("single reference rendering = %q, want raw content only", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
||||
transcript := []byte(`{"secret":"source text"}`)
|
||||
reference := "private party note"
|
||||
@@ -140,9 +95,6 @@ func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
||||
func prepareSpellsPrompt(t *testing.T, transcript []byte, players string, party string, glossary string) *scriptorium.PreparedRun {
|
||||
t.Helper()
|
||||
registry := llm.NewAssetRegistry()
|
||||
if err := sharedassets.Register(registry); err != nil {
|
||||
t.Fatalf("register shared prompt assets: %v", err)
|
||||
}
|
||||
if err := RegisterPromptAssets(registry); err != nil {
|
||||
t.Fatalf("register spell prompt assets: %v", err)
|
||||
}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
package sharedassets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
)
|
||||
|
||||
//go:embed assets/prompts/*.md
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
func Register(registry *llm.AssetRegistry) error {
|
||||
return registry.RegisterPromptFS(embeddedAssets, "assets/prompts")
|
||||
}
|
||||
|
||||
func CommonHashParts() []llm.AssetHashPart {
|
||||
return []llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"},
|
||||
}
|
||||
}
|
||||
|
||||
func ReferenceHashParts() []llm.AssetHashPart {
|
||||
return []llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-references.md"},
|
||||
}
|
||||
}
|
||||
47
internal/modules/sharedassets/dnd/assets.go
Normal file
47
internal/modules/sharedassets/dnd/assets.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package dnd
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
)
|
||||
|
||||
//go:embed assets/prompts/*.md
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
var sharedPromptFiles = []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
}
|
||||
|
||||
func SharedPromptFiles() []sharedassets.SharedPromptFile {
|
||||
files := make([]sharedassets.SharedPromptFile, 0, len(sharedPromptFiles))
|
||||
for _, name := range sharedPromptFiles {
|
||||
files = append(files, sharedassets.SharedPromptFile{
|
||||
Name: name,
|
||||
FS: embeddedAssets,
|
||||
Path: "assets/prompts/" + name,
|
||||
})
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func CommonHashParts() []llm.AssetHashPart {
|
||||
return []llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"},
|
||||
}
|
||||
}
|
||||
|
||||
func ReferenceHashParts() []llm.AssetHashPart {
|
||||
return []llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-references.md"},
|
||||
}
|
||||
}
|
||||
|
||||
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []sharedassets.ModulePromptFile) (fs.FS, error) {
|
||||
return sharedassets.ModulePromptFS(moduleDir, moduleFS, files, SharedPromptFiles()...)
|
||||
}
|
||||
87
internal/modules/sharedassets/dnd/assets_test.go
Normal file
87
internal/modules/sharedassets/dnd/assets_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package dnd
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/sharedassets"
|
||||
)
|
||||
|
||||
func TestSharedPromptFilesReturnsNewSlice(t *testing.T) {
|
||||
first := SharedPromptFiles()
|
||||
second := SharedPromptFiles()
|
||||
|
||||
if len(first) != 3 || len(second) != 3 {
|
||||
t.Fatalf("SharedPromptFiles() lengths = %d and %d, want 3", len(first), len(second))
|
||||
}
|
||||
first[0].Name = "changed.md"
|
||||
if second[0].Name != "common-dnd-system.md" {
|
||||
t.Fatalf("SharedPromptFiles() reused descriptor slice: %#v", second)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPromptFilesReferenceEmbeddedAssets(t *testing.T) {
|
||||
for _, file := range SharedPromptFiles() {
|
||||
if file.FS == nil {
|
||||
t.Fatalf("SharedPromptFiles() descriptor %q has nil FS", file.Name)
|
||||
}
|
||||
if _, err := fs.ReadFile(file.FS, file.Path); err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v, want nil", file.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashPartsReferenceSharedPrompts(t *testing.T) {
|
||||
assertHashParts(t, "common", CommonHashParts(), []string{
|
||||
"assets/prompts/common-dnd-system.md",
|
||||
"assets/prompts/common-dnd-transcript.md",
|
||||
})
|
||||
assertHashParts(t, "reference", ReferenceHashParts(), []string{
|
||||
"assets/prompts/common-dnd-references.md",
|
||||
})
|
||||
|
||||
for _, part := range append(CommonHashParts(), ReferenceHashParts()...) {
|
||||
if _, err := fs.ReadFile(part.FS, part.Path); err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v, want nil", part.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertHashParts(t *testing.T, name string, parts []llm.AssetHashPart, want []string) {
|
||||
t.Helper()
|
||||
if len(parts) != len(want) {
|
||||
t.Fatalf("%s hash parts length = %d, want %d", name, len(parts), len(want))
|
||||
}
|
||||
for i, part := range parts {
|
||||
if part.Path != want[i] {
|
||||
t.Fatalf("%s hash part %d path = %q, want %q", name, i, part.Path, want[i])
|
||||
}
|
||||
if part.FS == nil {
|
||||
t.Fatalf("%s hash part %d has nil FS", name, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSMountsDNDSharedPrompts(t *testing.T) {
|
||||
fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{
|
||||
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
||||
}, []sharedassets.ModulePromptFile{
|
||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
for _, path := range []string{
|
||||
"assets/prompts/dnd.test/dnd.test.yaml",
|
||||
"assets/prompts/dnd.test/sharedassets/common-dnd-system.md",
|
||||
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md",
|
||||
"assets/prompts/dnd.test/sharedassets/common-dnd-references.md",
|
||||
} {
|
||||
if _, err := fs.ReadFile(fsys, path); err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
84
internal/modules/sharedassets/dnd/prompt_inputs.go
Normal file
84
internal/modules/sharedassets/dnd/prompt_inputs.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package dnd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func PromptInputs(sourceInput contracts.LLMInputMaterial, references contracts.ReferenceSet) contracts.LLMInputSet {
|
||||
partySlot := references.Slots["party"]
|
||||
if len(partySlot.Items) == 0 {
|
||||
partySlot = references.Slots["roster"]
|
||||
}
|
||||
return contracts.LLMInputSet{
|
||||
"transcript": TranscriptPromptMaterial(sourceInput),
|
||||
"players": ReferencePromptMaterial("players", references.Slots["players"]),
|
||||
"party": ReferencePromptMaterial("party", partySlot),
|
||||
"glossary": ReferencePromptMaterial("glossary", references.Slots["glossary"]),
|
||||
}
|
||||
}
|
||||
|
||||
func TranscriptPromptMaterial(material contracts.LLMInputMaterial) contracts.LLMInputMaterial {
|
||||
out := material.Clone()
|
||||
out.Name = "transcript"
|
||||
return out
|
||||
}
|
||||
|
||||
func ReferencePromptMaterial(name string, slot contracts.ResolvedReferenceSlot) contracts.LLMInputMaterial {
|
||||
body := ReferencePromptInput(slot)
|
||||
digest := ""
|
||||
originURI := ""
|
||||
if len(slot.Items) == 1 {
|
||||
digest = slot.Items[0].Digest
|
||||
originURI = slot.Items[0].Origin.URI
|
||||
}
|
||||
return contracts.NewLLMInputMaterial(name, "text/plain", body, digest, originURI)
|
||||
}
|
||||
|
||||
func ReferencePromptInput(slot contracts.ResolvedReferenceSlot) []byte {
|
||||
if len(slot.Items) == 0 {
|
||||
return []byte(" ")
|
||||
}
|
||||
items := append([]contracts.ReferenceItem(nil), slot.Items...)
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
if items[i].Origin.URI != items[j].Origin.URI {
|
||||
return items[i].Origin.URI < items[j].Origin.URI
|
||||
}
|
||||
if items[i].Digest != items[j].Digest {
|
||||
return items[i].Digest < items[j].Digest
|
||||
}
|
||||
return string(items[i].Content) < string(items[j].Content)
|
||||
})
|
||||
if len(items) == 1 {
|
||||
return append([]byte(nil), items[0].Content...)
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
for i, item := range items {
|
||||
if i > 0 {
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
fmt.Fprintf(&b, "Reference %d\n", i+1)
|
||||
if item.Origin.Type != "" {
|
||||
fmt.Fprintf(&b, "Origin-Type: %s\n", item.Origin.Type)
|
||||
}
|
||||
if item.Origin.URI != "" {
|
||||
fmt.Fprintf(&b, "Origin-URI: %s\n", item.Origin.URI)
|
||||
}
|
||||
if item.Digest != "" {
|
||||
fmt.Fprintf(&b, "Digest: %s\n", item.Digest)
|
||||
}
|
||||
if item.MediaType != "" {
|
||||
fmt.Fprintf(&b, "Media-Type: %s\n", item.MediaType)
|
||||
}
|
||||
if item.SizeBytes > 0 {
|
||||
fmt.Fprintf(&b, "Size-Bytes: %d\n", item.SizeBytes)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.Write(item.Content)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
151
internal/modules/sharedassets/dnd/prompt_inputs_test.go
Normal file
151
internal/modules/sharedassets/dnd/prompt_inputs_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package dnd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestPromptInputsBuildExpectedInputs(t *testing.T) {
|
||||
source := contracts.NewLLMInputMaterial("source", "application/json", []byte("source text"), "sha256:source", "file:///source.json")
|
||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"players": slotWithContent("players", "Alice: Aria"),
|
||||
"party": slotWithContent("party", "Aria: cleric"),
|
||||
"glossary": slotWithContent("glossary", "Brightmantle: temple"),
|
||||
}}
|
||||
|
||||
inputs := PromptInputs(source, references)
|
||||
for _, name := range []string{"transcript", "players", "party", "glossary"} {
|
||||
if _, ok := inputs[name]; !ok {
|
||||
t.Fatalf("PromptInputs() missing %q: %#v", name, inputs)
|
||||
}
|
||||
}
|
||||
if _, ok := inputs["roster"]; ok {
|
||||
t.Fatalf("PromptInputs() included roster input: %#v", inputs)
|
||||
}
|
||||
if got := inputs["transcript"].Name; got != "transcript" {
|
||||
t.Fatalf("transcript name = %q, want transcript", got)
|
||||
}
|
||||
if got := string(inputs["transcript"].Content); got != "source text" {
|
||||
t.Fatalf("transcript content = %q, want source text", got)
|
||||
}
|
||||
if got := string(inputs["players"].Content); got != "Alice: Aria" {
|
||||
t.Fatalf("players content = %q, want player reference", got)
|
||||
}
|
||||
if got := string(inputs["party"].Content); got != "Aria: cleric" {
|
||||
t.Fatalf("party content = %q, want party reference", got)
|
||||
}
|
||||
if got := string(inputs["glossary"].Content); got != "Brightmantle: temple" {
|
||||
t.Fatalf("glossary content = %q, want glossary reference", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptInputsUseRosterWhenPartyIsEmpty(t *testing.T) {
|
||||
inputs := PromptInputs(contracts.LLMInputMaterial{}, contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"party": {},
|
||||
"roster": slotWithContent("roster", "Legacy roster text"),
|
||||
}})
|
||||
|
||||
if got := string(inputs["party"].Content); got != "Legacy roster text" {
|
||||
t.Fatalf("party content = %q, want roster fallback content", got)
|
||||
}
|
||||
if _, ok := inputs["roster"]; ok {
|
||||
t.Fatalf("PromptInputs() included roster input: %#v", inputs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTranscriptPromptMaterialClonesSource(t *testing.T) {
|
||||
source := contracts.NewLLMInputMaterial("source", "text/plain", []byte("source text"), "sha256:source", "file:///source.txt")
|
||||
got := TranscriptPromptMaterial(source)
|
||||
|
||||
if got.Name != "transcript" {
|
||||
t.Fatalf("Name = %q, want transcript", got.Name)
|
||||
}
|
||||
if got.MediaType != source.MediaType || got.Digest != source.Digest || got.OriginURI != source.OriginURI || got.SizeBytes != source.SizeBytes {
|
||||
t.Fatalf("TranscriptPromptMaterial() = %#v, want cloned metadata from %#v", got, source)
|
||||
}
|
||||
source.Content[0] = 'X'
|
||||
if string(got.Content) != "source text" {
|
||||
t.Fatalf("TranscriptPromptMaterial() reused content slice: %q", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencePromptMaterialUsesTextPlainAndSingleReferenceMetadata(t *testing.T) {
|
||||
slot := contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{{
|
||||
Content: []byte("single reference"),
|
||||
Digest: "sha256:reference",
|
||||
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///reference.md"},
|
||||
}}}
|
||||
|
||||
got := ReferencePromptMaterial("party", slot)
|
||||
if got.Name != "party" || got.MediaType != "text/plain" {
|
||||
t.Fatalf("ReferencePromptMaterial() name/media = %q/%q, want party/text/plain", got.Name, got.MediaType)
|
||||
}
|
||||
if got.Digest != "sha256:reference" || got.OriginURI != "file:///reference.md" {
|
||||
t.Fatalf("ReferencePromptMaterial() digest/origin = %q/%q, want single reference metadata", got.Digest, got.OriginURI)
|
||||
}
|
||||
if string(got.Content) != "single reference" {
|
||||
t.Fatalf("ReferencePromptMaterial() content = %q, want raw single reference", got.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencePromptMaterialOmitsAggregateMetadata(t *testing.T) {
|
||||
got := ReferencePromptMaterial("party", contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{
|
||||
{Content: []byte("one"), Digest: "sha256:one", Origin: contracts.ReferenceOrigin{URI: "file:///one.md"}},
|
||||
{Content: []byte("two"), Digest: "sha256:two", Origin: contracts.ReferenceOrigin{URI: "file:///two.md"}},
|
||||
}})
|
||||
|
||||
if got.Digest != "" || got.OriginURI != "" {
|
||||
t.Fatalf("ReferencePromptMaterial() digest/origin = %q/%q, want empty aggregate metadata", got.Digest, got.OriginURI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferencePromptInputRendering(t *testing.T) {
|
||||
if got := string(ReferencePromptInput(contracts.ResolvedReferenceSlot{})); got != " " {
|
||||
t.Fatalf("empty rendering = %q, want single space", got)
|
||||
}
|
||||
if got := string(ReferencePromptInput(slotWithContent("party", "single reference"))); got != "single reference" {
|
||||
t.Fatalf("single rendering = %q, want raw content", got)
|
||||
}
|
||||
|
||||
slot := contracts.ResolvedReferenceSlot{Items: []contracts.ReferenceItem{
|
||||
{
|
||||
SlotName: "party",
|
||||
MediaType: "text/plain",
|
||||
Content: []byte("second"),
|
||||
Digest: "sha256:bbb",
|
||||
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///b.txt"},
|
||||
SizeBytes: 6,
|
||||
},
|
||||
{
|
||||
SlotName: "party",
|
||||
MediaType: "text/plain",
|
||||
Content: []byte("first"),
|
||||
Digest: "sha256:aaa",
|
||||
Origin: contracts.ReferenceOrigin{Type: "file", URI: "file:///a.txt"},
|
||||
SizeBytes: 5,
|
||||
},
|
||||
}}
|
||||
first := string(ReferencePromptInput(slot))
|
||||
second := string(ReferencePromptInput(slot))
|
||||
if first != second {
|
||||
t.Fatalf("ReferencePromptInput() was not deterministic:\nfirst=%q\nsecond=%q", first, second)
|
||||
}
|
||||
if !strings.Contains(first, "Reference 1\nOrigin-Type: file\nOrigin-URI: file:///a.txt\nDigest: sha256:aaa\nMedia-Type: text/plain\nSize-Bytes: 5\n\nfirst") {
|
||||
t.Fatalf("first reference block = %q, want sorted first reference metadata", first)
|
||||
}
|
||||
if strings.Index(first, "first") > strings.Index(first, "second") {
|
||||
t.Fatalf("references were not sorted deterministically: %q", first)
|
||||
}
|
||||
}
|
||||
|
||||
func slotWithContent(name string, content string) contracts.ResolvedReferenceSlot {
|
||||
return contracts.ResolvedReferenceSlot{
|
||||
Slot: contracts.ReferenceSlot{Name: name},
|
||||
Items: []contracts.ReferenceItem{{
|
||||
SlotName: name,
|
||||
Content: []byte(content),
|
||||
}},
|
||||
}
|
||||
}
|
||||
47
internal/modules/sharedassets/dnd/references.go
Normal file
47
internal/modules/sharedassets/dnd/references.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package dnd
|
||||
|
||||
import "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
|
||||
type ReferenceSlotDescriptions struct {
|
||||
Glossary string
|
||||
Party string
|
||||
Players string
|
||||
Roster string
|
||||
}
|
||||
|
||||
var referenceMediaTypes = []string{
|
||||
"application/json",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
}
|
||||
|
||||
func ReferenceMediaTypes() []string {
|
||||
return append([]string(nil), referenceMediaTypes...)
|
||||
}
|
||||
|
||||
func ReferenceSlots(descriptions ReferenceSlotDescriptions) []contracts.ReferenceSlot {
|
||||
return contracts.CloneReferenceSlots([]contracts.ReferenceSlot{
|
||||
{
|
||||
Name: "glossary",
|
||||
Description: descriptions.Glossary,
|
||||
AcceptedMediaTypes: ReferenceMediaTypes(),
|
||||
},
|
||||
{
|
||||
Name: "party",
|
||||
Description: descriptions.Party,
|
||||
AcceptedMediaTypes: ReferenceMediaTypes(),
|
||||
},
|
||||
{
|
||||
Name: "players",
|
||||
Description: descriptions.Players,
|
||||
AcceptedMediaTypes: ReferenceMediaTypes(),
|
||||
},
|
||||
{
|
||||
Name: "roster",
|
||||
Description: descriptions.Roster,
|
||||
AcceptedMediaTypes: ReferenceMediaTypes(),
|
||||
},
|
||||
})
|
||||
}
|
||||
60
internal/modules/sharedassets/dnd/references_test.go
Normal file
60
internal/modules/sharedassets/dnd/references_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package dnd
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestReferenceMediaTypesReturnsDefensiveCopy(t *testing.T) {
|
||||
want := []string{
|
||||
"application/json",
|
||||
"application/x-yaml",
|
||||
"application/yaml",
|
||||
"text/markdown",
|
||||
"text/plain",
|
||||
}
|
||||
got := ReferenceMediaTypes()
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ReferenceMediaTypes() = %#v, want %#v", got, want)
|
||||
}
|
||||
got[0] = "changed"
|
||||
if again := ReferenceMediaTypes(); again[0] != "application/json" {
|
||||
t.Fatalf("ReferenceMediaTypes() reused backing storage: %#v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSlotsUseDescriptionsAndExpectedOrder(t *testing.T) {
|
||||
descriptions := ReferenceSlotDescriptions{
|
||||
Glossary: "Glossary reference",
|
||||
Party: "Party reference",
|
||||
Players: "Players reference",
|
||||
Roster: "Roster reference",
|
||||
}
|
||||
got := ReferenceSlots(descriptions)
|
||||
want := []contracts.ReferenceSlot{
|
||||
{Name: "glossary", Description: descriptions.Glossary, AcceptedMediaTypes: ReferenceMediaTypes()},
|
||||
{Name: "party", Description: descriptions.Party, AcceptedMediaTypes: ReferenceMediaTypes()},
|
||||
{Name: "players", Description: descriptions.Players, AcceptedMediaTypes: ReferenceMediaTypes()},
|
||||
{Name: "roster", Description: descriptions.Roster, AcceptedMediaTypes: ReferenceMediaTypes()},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("ReferenceSlots() = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReferenceSlotsReturnDefensiveCopies(t *testing.T) {
|
||||
first := ReferenceSlots(ReferenceSlotDescriptions{})
|
||||
second := ReferenceSlots(ReferenceSlotDescriptions{})
|
||||
|
||||
first[0].Name = "changed"
|
||||
first[0].AcceptedMediaTypes[0] = "changed"
|
||||
|
||||
if second[0].Name != "glossary" {
|
||||
t.Fatalf("ReferenceSlots() reused slot slice: %#v", second)
|
||||
}
|
||||
if second[0].AcceptedMediaTypes[0] != "application/json" {
|
||||
t.Fatalf("ReferenceSlots() reused media type slice: %#v", second)
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,18 @@ type ModulePromptFile struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
// ModulePromptFS builds a prompt filesystem for a module directory from
|
||||
// module-owned prompt files plus common D&D shared prompt files under the
|
||||
// module's sharedassets subdirectory.
|
||||
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile) (fs.FS, error) {
|
||||
return modulePromptFS(moduleDir, moduleFS, files, embeddedAssets)
|
||||
// SharedPromptFile maps a caller-owned shared prompt file into a module's
|
||||
// Scriptorium-visible sharedassets prompt subdirectory.
|
||||
type SharedPromptFile struct {
|
||||
Name string
|
||||
FS fs.FS
|
||||
Path string
|
||||
}
|
||||
|
||||
func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, sharedFS fs.FS) (fs.FS, error) {
|
||||
// ModulePromptFS builds a prompt filesystem for a module directory from
|
||||
// module-owned prompt files plus caller-provided shared prompt files under the
|
||||
// module's sharedassets subdirectory.
|
||||
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile, sharedFiles ...SharedPromptFile) (fs.FS, error) {
|
||||
cleanModuleDir, err := cleanPromptPath(moduleDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("module prompt directory: %w", err)
|
||||
@@ -33,10 +37,7 @@ func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
||||
if moduleFS == nil {
|
||||
return nil, fmt.Errorf("module prompt filesystem must not be nil")
|
||||
}
|
||||
if sharedFS == nil {
|
||||
return nil, fmt.Errorf("shared prompt filesystem must not be nil")
|
||||
}
|
||||
assets := make(promptMapFS, len(files)+len(commonDNDPromptFiles))
|
||||
assets := make(promptMapFS, len(files)+len(sharedFiles))
|
||||
for _, file := range files {
|
||||
name, err := cleanPromptPath(file.Name)
|
||||
if err != nil {
|
||||
@@ -56,22 +57,30 @@ func modulePromptFS(moduleDir string, moduleFS fs.FS, files []ModulePromptFile,
|
||||
assets["assets/prompts/"+cleanModuleDir+"/"+name] = append([]byte(nil), data...)
|
||||
}
|
||||
|
||||
for _, name := range commonDNDPromptFiles {
|
||||
data, err := fs.ReadFile(sharedFS, "assets/prompts/"+name)
|
||||
for _, file := range sharedFiles {
|
||||
name, err := cleanPromptPath(file.Name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read shared prompt asset %s: %w", name, err)
|
||||
return nil, fmt.Errorf("shared prompt file name %q: %w", file.Name, err)
|
||||
}
|
||||
if strings.Contains(name, "/") {
|
||||
return nil, fmt.Errorf("shared prompt file name %q must not contain path separators", file.Name)
|
||||
}
|
||||
if file.FS == nil {
|
||||
return nil, fmt.Errorf("shared prompt file %q filesystem must not be nil", file.Name)
|
||||
}
|
||||
filePath, err := cleanPromptPath(file.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("shared prompt file path %q: %w", file.Path, err)
|
||||
}
|
||||
data, err := fs.ReadFile(file.FS, filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read shared prompt asset %s: %w", filePath, err)
|
||||
}
|
||||
assets["assets/prompts/"+cleanModuleDir+"/sharedassets/"+name] = append([]byte(nil), data...)
|
||||
}
|
||||
return assets, nil
|
||||
}
|
||||
|
||||
var commonDNDPromptFiles = []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
}
|
||||
|
||||
type promptMapFS map[string][]byte
|
||||
|
||||
func (m promptMapFS) Open(name string) (fs.File, error) {
|
||||
|
||||
@@ -8,34 +8,40 @@ import (
|
||||
)
|
||||
|
||||
func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) {
|
||||
fsys, err := ModulePromptFS("dnd.test", fstest.MapFS{
|
||||
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
||||
sharedFS := fstest.MapFS{
|
||||
"shared/system.md": {Data: []byte("system")},
|
||||
"shared/reference.md": {Data: []byte("reference")},
|
||||
"nested/transcript.md": {Data: []byte("transcript")},
|
||||
}
|
||||
fsys, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/module.test.yaml": {Data: []byte("id: module.test")},
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
||||
{Name: "module.test.yaml", Path: "assets/prompts/module.test.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
})
|
||||
},
|
||||
SharedPromptFile{Name: "system.md", FS: sharedFS, Path: "shared/system.md"},
|
||||
SharedPromptFile{Name: "reference.md", FS: sharedFS, Path: "shared/reference.md"},
|
||||
SharedPromptFile{Name: "transcript.md", FS: sharedFS, Path: "nested/transcript.md"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
tests := map[string]string{
|
||||
"assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test",
|
||||
"assets/prompts/dnd.test/task.md": "task",
|
||||
"assets/prompts/dnd.test/sharedassets/common-dnd-system.md": "",
|
||||
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript.md": "",
|
||||
"assets/prompts/dnd.test/sharedassets/common-dnd-references.md": "",
|
||||
"assets/prompts/module.test/module.test.yaml": "id: module.test",
|
||||
"assets/prompts/module.test/task.md": "task",
|
||||
"assets/prompts/module.test/sharedassets/system.md": "system",
|
||||
"assets/prompts/module.test/sharedassets/reference.md": "reference",
|
||||
"assets/prompts/module.test/sharedassets/transcript.md": "transcript",
|
||||
}
|
||||
for path, want := range tests {
|
||||
data, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
||||
}
|
||||
if len(data) == 0 {
|
||||
t.Fatalf("ReadFile(%q) returned empty content", path)
|
||||
}
|
||||
if want != "" && !strings.Contains(string(data), want) {
|
||||
t.Fatalf("ReadFile(%q) = %q, want substring %q", path, data, want)
|
||||
if string(data) != want {
|
||||
t.Fatalf("ReadFile(%q) = %q, want %q", path, data, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,10 +52,17 @@ func TestModulePromptFSCombinesModuleAndSharedPrompts(t *testing.T) {
|
||||
if len(entries) != 1 || entries[0].Name() != "assets" || !entries[0].IsDir() {
|
||||
t.Fatalf("ReadDir(.) = %#v, want assets directory", entries)
|
||||
}
|
||||
entries, err = fs.ReadDir(fsys, "assets/prompts/module.test/sharedassets")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDir(sharedassets) error = %v, want nil", err)
|
||||
}
|
||||
if len(entries) != 3 || entries[0].Name() != "reference.md" || entries[1].Name() != "system.md" || entries[2].Name() != "transcript.md" {
|
||||
t.Fatalf("ReadDir(sharedassets) = %#v, want sorted shared prompt entries", entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsMissingModuleFile(t *testing.T) {
|
||||
_, err := ModulePromptFS("dnd.test", fstest.MapFS{}, []ModulePromptFile{
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{}, []ModulePromptFile{
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/task.md") {
|
||||
@@ -58,18 +71,18 @@ func TestModulePromptFSRejectsMissingModuleFile(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsMissingSharedFile(t *testing.T) {
|
||||
_, err := modulePromptFS("dnd.test", fstest.MapFS{
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
}, fstest.MapFS{})
|
||||
if err == nil || !strings.Contains(err.Error(), "read shared prompt asset common-dnd-system.md") {
|
||||
t.Fatalf("modulePromptFS() error = %v, want missing shared asset context", err)
|
||||
}, SharedPromptFile{Name: "system.md", FS: fstest.MapFS{}, Path: "shared/system.md"})
|
||||
if err == nil || !strings.Contains(err.Error(), "read shared prompt asset shared/system.md") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want missing shared asset context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsNestedVirtualFileName(t *testing.T) {
|
||||
_, err := ModulePromptFS("dnd.test", fstest.MapFS{
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "nested/task.md", Path: "assets/prompts/task.md"},
|
||||
@@ -78,3 +91,33 @@ func TestModulePromptFSRejectsNestedVirtualFileName(t *testing.T) {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nested file name error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsNestedSharedFileName(t *testing.T) {
|
||||
_, err := ModulePromptFS("module.test", fstest.MapFS{
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}, []ModulePromptFile{
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
}, SharedPromptFile{Name: "nested/system.md", FS: fstest.MapFS{}, Path: "shared/system.md"})
|
||||
if err == nil || !strings.Contains(err.Error(), "shared prompt file name") || !strings.Contains(err.Error(), "must not contain path separators") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nested shared file name error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsInvalidModuleDir(t *testing.T) {
|
||||
_, err := ModulePromptFS(".", fstest.MapFS{}, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "module prompt directory") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want module directory context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModulePromptFSRejectsNilFilesystems(t *testing.T) {
|
||||
_, err := ModulePromptFS("module.test", nil, nil)
|
||||
if err == nil || !strings.Contains(err.Error(), "module prompt filesystem must not be nil") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nil module filesystem error", err)
|
||||
}
|
||||
|
||||
_, err = ModulePromptFS("module.test", fstest.MapFS{}, nil, SharedPromptFile{Name: "system.md", Path: "shared/system.md"})
|
||||
if err == nil || !strings.Contains(err.Error(), "shared prompt file \"system.md\" filesystem must not be nil") {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nil shared filesystem error", err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user