Compare commits
8 Commits
e01b8d1b6d
...
3ba2bfd7f6
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ba2bfd7f6 | |||
| b344d16dc1 | |||
| 6e12c09952 | |||
| 07460341e3 | |||
| 732b13669f | |||
| 3a8a82ebc9 | |||
| 1c9819f08e | |||
| 447c4f73f9 |
@@ -98,23 +98,41 @@ 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.
|
||||
|
||||
The D&D scene chunker and spell, NPC, and combat-turn extractors each declare an
|
||||
ordered prompt asset manifest. The manifest lists the package-owned YAML and
|
||||
Markdown files, then the exact shared fragments rendered by that prompt; the
|
||||
same ordered list drives both filesystem mounting and the prompt fingerprint.
|
||||
The three extraction prompts share system, extraction evidence, in-world
|
||||
identity, transcript, and campaign-reference messages. Spell and combat prompts
|
||||
then render immediate resolution and the ephemeral NPC registry before their
|
||||
lane-specific material; the NPC prompt renders its task and instructions
|
||||
instead. The scene prompt uses system, transcript, and campaign-reference
|
||||
messages before its task and instructions. Identity, transcript, and
|
||||
campaign-reference messages are ephemeral in the extraction prompts; the scene
|
||||
prompt marks transcript and campaign references ephemeral, and the NPC registry
|
||||
is ephemeral where spell and combat prompts use it. Unused shared assets are
|
||||
neither mounted nor fingerprinted.
|
||||
|
||||
Schema helpers load embedded JSON Schema with identity and digest metadata,
|
||||
return defensive copies, and expose a diagnostics map that omits schema bytes.
|
||||
The small framework registry contains only generic test schemas; production
|
||||
schemas remain package-owned.
|
||||
|
||||
The spell and combat extractors' package-owned prompts declare their structured
|
||||
JSON inputs and private response schemas. The combat private schema owns the
|
||||
transport envelope—required fields, JSON types, nullability, and unknown-field
|
||||
rejection—while its deterministic validators own semantic constraints such as
|
||||
enum membership, non-empty values and collections, and positive numbers. The
|
||||
spell extractor's prompt declares a required `application/json` `spell_catalog`
|
||||
input and an optional `application/json` `npcs` input. The extractor generates
|
||||
The spell, NPC, and combat extractors' package-owned prompts declare their
|
||||
structured JSON inputs and private response schemas. Each private response
|
||||
schema remains separate from its durable artifact codec schema; this work does
|
||||
not use shared schema fragments or schema generation. The combat private schema
|
||||
owns the transport envelope—required fields, JSON types, nullability, and
|
||||
unknown-field rejection—while its deterministic validators own semantic
|
||||
constraints such as enum membership, non-empty values and collections, and
|
||||
positive numbers. The spell extractor's prompt declares a required
|
||||
`application/json` `spell_catalog` input and an optional `application/json`
|
||||
`npcs` input. The extractor generates
|
||||
the catalog input from its prepared
|
||||
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
||||
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
||||
after the campaign reference message for both extractors. When an NPC registry
|
||||
is bound, the
|
||||
after the campaign reference message for spell and combat prompts. When an NPC
|
||||
registry is bound, the
|
||||
domain registry boundary strictly decodes and identity-validates one durable
|
||||
artifact, re-encodes canonical JSON, and generates a semantic digest over
|
||||
those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover
|
||||
|
||||
@@ -46,10 +46,27 @@ that agreement. Runtime delivery uses the corresponding stage request's
|
||||
LLM-backed extensions own their prompt definitions and response schemas under
|
||||
package-local embedded assets. Shared filesystem composition belongs in
|
||||
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
||||
declarations, prompt-input assembly, and source-unit helpers belong in
|
||||
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. Stage
|
||||
contracts expose only Notarius structured-
|
||||
completion types, not Scriptorium public types.
|
||||
declarations, prompt-input assembly, and source-unit/citation helpers belong in
|
||||
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The
|
||||
D&D scene chunker and spell, NPC, and combat-turn extractors use ordered
|
||||
package-local prompt manifests for both rendering and prompt fingerprinting, so
|
||||
only the shared fragments each prompt actually renders participate in either
|
||||
operation. The three extraction prompts share the order system, extraction
|
||||
evidence, in-world identity, transcript, and campaign references, with
|
||||
ephemeral cache control on identity, transcript, and references. Spell and
|
||||
combat prompts append immediate resolution and an ephemeral NPC registry before
|
||||
lane-specific material; the NPC prompt appends its task and instructions. The
|
||||
scene prompt uses system, transcript, and campaign references before its
|
||||
lane-specific task and instructions, with ephemeral cache control on transcript
|
||||
and references. Stage contracts expose only Notarius structured-completion
|
||||
types, not Scriptorium public types.
|
||||
|
||||
The shared `ChunkPromptMaterial` helper owns common transcript material
|
||||
preparation for the spell, NPC, and combat-turn extractors. It clones supplied
|
||||
source metadata, falls back to the materialized chunk when content is absent,
|
||||
checks that content remains chunk-identical, and fills only the common default
|
||||
fields. Extractors retain their request validation and wrap helper errors with
|
||||
their module context.
|
||||
|
||||
Reference material may inform a module or prompt but must not become source
|
||||
evidence. The resolver and materializer behavior is described in
|
||||
@@ -182,15 +199,14 @@ source evidence.
|
||||
|
||||
The prompt limits each cast to its declaration and immediate resolution; it
|
||||
does not follow summoned creatures, persistent effects, or other downstream
|
||||
consequences through the scene. Source references must collectively support all
|
||||
reported facts, using separate narrow ranges when immediate evidence is
|
||||
non-contiguous. The prompt grounds caster names in in-world identities, using
|
||||
the player and party references to disambiguate transcript speakers without
|
||||
treating those references as event evidence. Effects describe the session as
|
||||
played: model rules knowledge cannot supplement or correct the transcript, and
|
||||
nonstandard adjudication is attributed to the GM or table rather than stated as
|
||||
a universal rule. Structural source validation remains deterministic; semantic
|
||||
claim completeness is enforced through extraction policy and evaluation.
|
||||
consequences through the scene. Shared extraction-evidence and identity rules
|
||||
require transcript-supported factual claims and the most specific in-world
|
||||
caster identity, while campaign references only disambiguate source text.
|
||||
Effects describe the session as played: model rules knowledge cannot supplement
|
||||
or correct the transcript, and nonstandard adjudication is attributed to the GM
|
||||
or table rather than stated as a universal rule. Structural source validation
|
||||
remains deterministic; semantic claim completeness is enforced through
|
||||
extraction policy and evaluation.
|
||||
|
||||
Both the extractor and deterministic catalog validator expose
|
||||
the effective base-plus-overlay semantic digest as scoped prepared-component
|
||||
@@ -223,12 +239,16 @@ The NPC extractor maps private model output to the canonical `dnd.NPCList`,
|
||||
assigns source identity and deterministic NPC IDs, and preserves source
|
||||
references for deterministic validation. It uses the shared campaign
|
||||
references only for disambiguation and does not consume the optional NPC
|
||||
registry slot. Its prompt and private response schema are package-owned.
|
||||
registry slot. Its prompt and private response schema are package-owned. The
|
||||
prompt uses the common evidence, identity, transcript, and campaign-reference
|
||||
messages, then the NPC-specific task and instructions; only the identity,
|
||||
transcript, and campaign-reference messages carry ephemeral cache control.
|
||||
|
||||
### `internal/modules/dnd/extract/combatturns`
|
||||
|
||||
The combat extractor prepares one structured request per supplied chunk using
|
||||
the shared transcript, campaign-reference, and NPC-grounding prompt inputs. It
|
||||
the shared extraction-evidence, identity, transcript, campaign-reference,
|
||||
immediate-resolution, and NPC-grounding prompt inputs. It
|
||||
maps the private response to `dnd.CombatTurnList`, assigns the current source
|
||||
identity, removes exact duplicate source ranges, and orders turns by valid
|
||||
source-document position while preserving malformed candidate fields for
|
||||
@@ -236,8 +256,11 @@ deterministic validators. Its package-owned private response schema enforces
|
||||
only the structural JSON envelope; semantic artifact constraints remain with
|
||||
the validator chain. Its prepared metadata and checkpoint fingerprints contain
|
||||
only prompt/schema/mapping identities plus an optional NPC registry digest.
|
||||
The package exposes typed registration and is included in the production D&D
|
||||
registrar with the default combat extraction chain.
|
||||
The prompt renders immediate resolution and the NPC registry before the
|
||||
combat-specific task and instructions; identity, transcript, campaign
|
||||
references, and the NPC registry use ephemeral cache control. The package
|
||||
exposes typed registration and is included in the production D&D registrar with
|
||||
the default combat extraction chain.
|
||||
|
||||
The combat normalizer accepts only the optional structured NPC registry.
|
||||
Campaign references remain extractor-only LLM context and are not materialized
|
||||
@@ -338,12 +361,20 @@ validator defers when shape is invalid, then checks every non-empty spell name
|
||||
against the immutable effective SRD and overlay catalog. It accepts normalized
|
||||
canonical names and aliases without rewriting the artifact; unknown names
|
||||
reject the complete result with bounded, stable index/name diagnostics. The
|
||||
source-reference validator applies generic source-reference validation to every
|
||||
cited range. The relatedness validator warns when a case-insensitive spell name
|
||||
is absent from all cited source text.
|
||||
source-reference validator defers malformed shapes, validates every cited
|
||||
range, and reports all range defects through a bounded aggregate while
|
||||
preserving `invalid_source_refs`. The relatedness validator resolves all cited
|
||||
ranges through the shared document-order traversal, then warns when a normalized
|
||||
consecutive spell-name token sequence is absent from the cited source text.
|
||||
Invalid shape
|
||||
or cited ranges produce no relatedness warnings; the shape and source-reference
|
||||
validators own those defects.
|
||||
|
||||
These validators are deterministic. Their selectable keys and production order
|
||||
are defined in
|
||||
These validators are deterministic. Shape, source-reference, and relatedness
|
||||
each expose a local semantic `policy` checkpoint fingerprint. The catalog
|
||||
validator instead exposes its effective catalog digest as its semantic
|
||||
checkpoint identity and does not add a separate policy fingerprint. Their
|
||||
selectable keys and production order are defined in
|
||||
[Configuration](../config.md#implemented-production-validators); their durable
|
||||
payload rules are defined in the
|
||||
[artifact contract](../integrations/dnd-spell-artifacts.md).
|
||||
@@ -351,10 +382,13 @@ payload rules are defined in the
|
||||
## D&D NPC Validators
|
||||
|
||||
NPC shape validation checks required strings, arrays, and source-reference
|
||||
shape. The source-reference validator checks current-document identity, unit
|
||||
existence, and range ordering; source relatedness emits at most one bounded
|
||||
warning per record when neither the canonical name nor an alias occurs near
|
||||
its cited text. Normalize identity validation checks deterministic IDs,
|
||||
shape. The source-reference validator defers malformed shapes, checks
|
||||
current-document identity, unit existence, and range ordering, and reports all
|
||||
defects through bounded aggregates. Source relatedness uses the shared
|
||||
document-order traversal and normalized consecutive-token matching, emitting at
|
||||
most one bounded warning per record when neither the canonical name nor an
|
||||
alias occurs near its cited text. Invalid shape or cited ranges produce no
|
||||
relatedness warnings. Normalize identity validation checks deterministic IDs,
|
||||
canonical names, aliases, and cross-record ownership or canonical collisions.
|
||||
All are deterministic and expose the policy fingerprints used by the
|
||||
production chains.
|
||||
@@ -363,10 +397,13 @@ production chains.
|
||||
|
||||
Combat shape validation owns required arrays, strings, nullable values, positive
|
||||
rounds, and supported enums. Combat source-reference validation defers invalid
|
||||
shape and checks source identity, unit existence, and range order. Combat
|
||||
source-relatedness defers invalid shape or ranges, combines overlapping cited
|
||||
units in document order, and emits at most one bounded advisory warning per
|
||||
turn for unrelated actor or declaration text. The normalized-invariants
|
||||
shape, checks source identity, unit existence, and range order, and reports all
|
||||
defects through bounded aggregates. Combat source-relatedness defers invalid
|
||||
shape or ranges, uses the shared traversal to combine overlapping cited units
|
||||
in document order, and emits at most one bounded advisory warning per turn for
|
||||
unrelated actors or declaration text.
|
||||
Actors use normalized consecutive-token matching; declarations retain the
|
||||
minimum four-rune token heuristic. The normalized-invariants
|
||||
validator owns display normalization, comparison-unique targets, canonical
|
||||
source-reference order, chronology, and exact duplicate identity; it defers
|
||||
shape and source-reference failures. All four validators are deterministic and
|
||||
@@ -383,7 +420,9 @@ that order, then exposes the matching catalog for resolution. The generic and
|
||||
Seriatim registrars own their production leaf registrations. The D&D registrar
|
||||
owns D&D leaf registrations, typed spell, NPC, and combat default-validator
|
||||
chains, typed append-order specializations, and D&D prompt/schema asset
|
||||
collection.
|
||||
collection. Its registration helpers group module, validator, prompt-asset, and
|
||||
chain composition while retaining artifact-specific merge and clone behavior in
|
||||
the registrar.
|
||||
|
||||
Concrete implementation packages do not import generic implementation
|
||||
packages directly. A concrete family's `register` package is its composition
|
||||
|
||||
@@ -1,180 +1,35 @@
|
||||
# D&D Module Harmonization And Prompt Reuse
|
||||
|
||||
This roadmap records proposed improvements to the spell, NPC, and combat-turn
|
||||
pipeline lanes. Current implemented behavior remains documented in the
|
||||
[module internals](../internal/modules.md) and
|
||||
[LLM runtime internals](../internal/llm.md). The work below is not yet an
|
||||
implemented contract.
|
||||
This roadmap tracked harmonization of the spell, NPC, and combat-turn lanes.
|
||||
The implemented behavior is now owned by [Module Internals](../internal/modules.md)
|
||||
and [LLM Runtime Internals](../internal/llm.md); this document records status
|
||||
and the one remaining external prerequisite rather than duplicating those
|
||||
current-behavior references.
|
||||
|
||||
The three lanes already share the same overall decomposition: a typed
|
||||
extractor, durable codec, merger, normalizer, shape and provenance validators,
|
||||
domain-specific validation, embedded prompt assets, and central production
|
||||
registration. Spell catalog resolution, NPC identity and registry support, and
|
||||
combat normalization invariants are intentional domain differences. The goal
|
||||
is to remove mechanical drift without hiding those differences behind a broad
|
||||
generic abstraction.
|
||||
## Implementation Status
|
||||
|
||||
## Target State
|
||||
The offline/runtime work is complete:
|
||||
|
||||
### Prompt Reuse Is A Rendered-Request Invariant
|
||||
- shared prompt assets have explicit per-prompt manifests that drive both
|
||||
mounting and prompt fingerprints;
|
||||
- shared prompt inputs, deterministic chunk material preparation, and cited
|
||||
source traversal are centralized;
|
||||
- prompt ordering and cache-control policy are aligned across the D&D
|
||||
extraction prompts, while scene chunking retains its distinct prompt shape;
|
||||
- validator prerequisite, bounded-diagnostic, and checkpoint policies are
|
||||
aligned without changing compatibility-sensitive identifiers;
|
||||
- production registration remains explicit and typed, with grouped composition,
|
||||
central default-chain ownership, and artifact-specific merge behavior;
|
||||
- private LLM response schemas remain package-owned and separate from durable
|
||||
codec schemas; and
|
||||
- focused tests and repository-wide test, vet, build, and diff checks pass.
|
||||
|
||||
Provider prompt caching depends on the request prefix being byte-for-byte
|
||||
identical. Similar prose, duplicated files, or equivalent structured values are
|
||||
not sufficient. Treat message role, content bytes, ordering, cache-control
|
||||
metadata, input rendering, and any provider-visible separators as part of the
|
||||
cache identity.
|
||||
## Deferred evaluation
|
||||
|
||||
The target prompt layout has the longest valid common prefix before any
|
||||
module-specific message:
|
||||
|
||||
- Put shared static system, extraction-evidence, and in-world-identity policy
|
||||
before dynamic transcript and campaign-reference messages so reuse does not
|
||||
depend on identical transcript content.
|
||||
- Keep the shared transcript and campaign-reference messages in the same roles
|
||||
and order in every D&D extractor.
|
||||
- Keep cache-control declarations identical on corresponding messages.
|
||||
- Define one canonical ordering and serialization for shared named inputs.
|
||||
Continue resolving the deprecated `roster` input into the canonical `party`
|
||||
input before prompt rendering.
|
||||
- Keep genuinely universal extraction rules in shared message assets. These
|
||||
rules include using only transcript units as evidence, treating references as
|
||||
disambiguation, returning schema-conforming JSON only, using integer
|
||||
`start_unit_id` and `end_unit_id` values, omitting `source_id` for the mapper
|
||||
to assign, citing all factual claims, and preferring narrow ranges over broad
|
||||
ranges that bridge unrelated conversation.
|
||||
- Use one shared in-world identity message for lanes whose artifacts name
|
||||
actors or participants. It contains only rules that spells, NPCs, and combat
|
||||
turns can follow verbatim; artifact-specific identity, alias, target, and
|
||||
relationship rules remain local.
|
||||
- Keep rules shared by only a subset in subset-specific shared assets. The
|
||||
immediate-declaration-and-resolution boundary and NPC-registry grounding are
|
||||
shared by spells and combat. The NPC extractor does not consume a prior NPC
|
||||
registry.
|
||||
- Place subset-specific and module-specific messages only after the longest
|
||||
useful all-lane prefix. Place schema-specific task instructions last.
|
||||
- Avoid inserting a nominally shared message when its rendered input or
|
||||
wording differs by lane. Factor the common bytes into one message and leave
|
||||
the differences in later messages instead.
|
||||
|
||||
Shared messages are embedded once and referenced by every applicable prompt.
|
||||
This makes reuse of the canonical asset the easiest way to preserve cache
|
||||
identity and avoids parallel module-specific copies that can drift.
|
||||
|
||||
### Shared Prompt Ownership And Input Behavior Are Protected
|
||||
|
||||
Focused tests protect shared asset composition and input behavior without
|
||||
snapshotting the length or exact boundary of the common message prefix.
|
||||
|
||||
- Verify that each applicable prompt mounts and renders the canonical shared
|
||||
assets rather than module-specific copies.
|
||||
- Verify common input material identity, including name, media type, content,
|
||||
digest, origin URI, size, empty-value representation, reference ordering,
|
||||
and `roster` fallback behavior.
|
||||
- Test that every prompt fingerprint includes exactly the assets actually
|
||||
rendered by that prompt. The current grouped reference hash helper should be
|
||||
replaced or refined so a module does not fingerprint an unused shared asset,
|
||||
while no used asset is omitted.
|
||||
- Document the expected shared message ordering and cache-control policy so
|
||||
later changes are reviewed against the caching intent.
|
||||
- Prefer behavioral and ownership assertions over message-count, prefix-length,
|
||||
or full-prompt snapshots.
|
||||
|
||||
### Stable Extraction Preparation Is Centralized
|
||||
|
||||
Move the three identical chunk-input preparation implementations into a D&D
|
||||
shared helper. The helper should clone the supplied material, fall back to the
|
||||
chunk content, verify byte equality, and fill the canonical name, media type,
|
||||
and size without retaining mutable request data.
|
||||
|
||||
Nil context, cancellation, source, chunk, and empty-unit checks remain local so
|
||||
typed result handling and module error context stay explicit. Do not wrap the
|
||||
complete LLM call, response DTO mapping, catalog preparation, registry
|
||||
preparation, or manifest metadata in a generic extractor framework.
|
||||
|
||||
Use the shared helper as the single source of the `transcript` prompt material
|
||||
so identical input requests cannot drift between lanes.
|
||||
|
||||
### Validator Checkpoint And Composition Policy Is Aligned
|
||||
|
||||
- Add explicit versioned policy checkpoint fingerprints to the spell shape,
|
||||
source-reference, and source-relatedness validators. Bump a policy version
|
||||
whenever acceptance, rejection, warning, or diagnostic-selection behavior
|
||||
changes.
|
||||
- Shape validators own malformed artifact shape; later validators defer when
|
||||
shape is invalid.
|
||||
- Make all source-reference validators collect bounded diagnostics through the
|
||||
shared D&D diagnostics package rather than mixing first-error and aggregate
|
||||
behavior.
|
||||
- Use artifact-qualified reason codes consistently unless a reason code is
|
||||
intentionally a stable cross-artifact contract.
|
||||
- Add a compact cross-lane validator contract test covering deterministic
|
||||
execution class, strict empty options, registration, policy fingerprinting,
|
||||
prerequisite behavior, and diagnostic bounds.
|
||||
|
||||
### Source-Evidence Traversal Is Shared
|
||||
|
||||
Extract a D&D helper that validates source ranges and returns cited units or
|
||||
text once, in source-document order, without repeating units covered by
|
||||
overlapping ranges. Use it from spell, NPC, and combat relatedness validators.
|
||||
|
||||
Keep matching policy artifact-specific:
|
||||
|
||||
- spell matching may use canonical catalog names and aliases;
|
||||
- NPC matching may use NPC comparison keys and aliases; and
|
||||
- combat matching may apply separate actor and declaration heuristics.
|
||||
|
||||
Review Unicode normalization, apostrophe handling, word boundaries, short-name
|
||||
false positives, multiword identities, and overlapping ranges with shared
|
||||
table-driven fixtures. Relatedness remains a warning heuristic and should not
|
||||
be presented as proof that every semantic claim is supported.
|
||||
|
||||
## Structural Cleanup
|
||||
|
||||
### Lane Registration
|
||||
|
||||
Reduce repetition in the central D&D registrar with focused registration
|
||||
helpers grouped by modules, validators, prompt assets, and default chains.
|
||||
Retain artifact-specific append and deep-clone behavior in the registrar,
|
||||
central ownership of validator ordering, and explicit typed registration.
|
||||
Avoid reflection and heterogeneous erased lane descriptors.
|
||||
|
||||
### Naming And Package Conventions
|
||||
|
||||
Adopt consistent conventions for package aliases, module keys, artifact kinds,
|
||||
prompt IDs, schema IDs, reason codes, policy IDs, metadata fields, and
|
||||
checkpoint fingerprint names. Compatibility-sensitive identifiers should
|
||||
change only through an explicit migration; internal aliases can be harmonized
|
||||
independently.
|
||||
|
||||
### Schemas And Assets
|
||||
|
||||
- Keep private LLM response schemas distinct from durable artifact codec
|
||||
schemas. They represent different trust and compatibility boundaries.
|
||||
- Keep source-reference schema definitions package-owned in this work; do not
|
||||
add schema composition or generation machinery solely to deduplicate them.
|
||||
- Prefer one embedded shared asset over synchronized copies whenever content
|
||||
must be identical for prompt caching.
|
||||
- Make prompt fingerprints derive from an explicit prompt asset manifest, or
|
||||
from the prepared prompt definition, so message composition and provenance
|
||||
cannot drift independently.
|
||||
|
||||
Prompt factoring must not be accepted solely because cache reuse improves.
|
||||
Retain or restore module-specific wording when evaluation shows a meaningful
|
||||
quality regression. Record cache observations using non-secret request and
|
||||
usage metadata rather than prompt or transcript payloads.
|
||||
|
||||
## Completion Criteria
|
||||
|
||||
This roadmap is complete when:
|
||||
|
||||
- the three lanes expose the same stable structural conventions while keeping
|
||||
documented domain differences local;
|
||||
- shared provider-visible messages are produced from canonical shared assets,
|
||||
and shared inputs have one deterministic implementation;
|
||||
- the common prompt ordering is deliberate, documented, and as cache-friendly
|
||||
as extraction quality permits;
|
||||
- every validator policy that affects reusable results participates in
|
||||
checkpoint identity;
|
||||
- source-reference traversal and diagnostics no longer drift between lanes;
|
||||
- registration remains explicit and type-safe; and
|
||||
- focused D&D tests plus repository-wide tests, vetting, and the CLI build pass.
|
||||
An optional live before/after provider comparison remains deferred because it
|
||||
requires credentials and a maintained human-reviewed transcript or fixture
|
||||
set. It is not part of the default test suite or merge gate. If those
|
||||
prerequisites become available, record only aggregate extraction-review
|
||||
results, prompt token counts, cache-hit/cache-write metrics, and non-secret
|
||||
prompt hashes. Never commit transcript content, rendered prompts, credentials,
|
||||
endpoints, or private reference material.
|
||||
|
||||
@@ -11,12 +11,22 @@ import (
|
||||
|
||||
const scriptoriumPromptRoot = "assets/prompts"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := shared.ModulePromptFS("dnd.scenes", embeddedAssets, []promptfs.ModulePromptFile{
|
||||
var promptAssetManifest = shared.PromptAssetManifest{
|
||||
ModuleDir: "dnd.scenes",
|
||||
ModuleFiles: []promptfs.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"},
|
||||
})
|
||||
},
|
||||
SharedFiles: []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
},
|
||||
}
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare scene prompt assets: %w", err)
|
||||
}
|
||||
@@ -28,12 +38,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
|
||||
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(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
}
|
||||
|
||||
@@ -18,29 +18,29 @@ func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) {
|
||||
if prepared.PromptID != PromptID {
|
||||
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
|
||||
}
|
||||
if got := len(prepared.Messages); got != 5 {
|
||||
t.Fatalf("message count = %d, want 5", got)
|
||||
transcriptMessages := 0
|
||||
referenceMessageFound := false
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, string(transcript)) {
|
||||
transcriptMessages++
|
||||
if message.Role != "user" || message.CacheControl == nil {
|
||||
t.Fatalf("transcript message did not render as cacheable user message: %#v", message)
|
||||
}
|
||||
}
|
||||
if strings.Contains(message.Content, "Alice: Aria") &&
|
||||
strings.Contains(message.Content, "Aria: cleric") &&
|
||||
strings.Contains(message.Content, "Brightmantle: temple") {
|
||||
referenceMessageFound = true
|
||||
if message.Role != "user" || message.CacheControl == nil {
|
||||
t.Fatalf("reference message did not render as cacheable user message: %#v", message)
|
||||
}
|
||||
}
|
||||
}
|
||||
if prepared.Messages[1].Role != "user" || prepared.Messages[1].CacheControl == nil {
|
||||
t.Fatalf("transcript message did not render as cacheable user message: %#v", prepared.Messages[1])
|
||||
if transcriptMessages != 1 {
|
||||
t.Fatalf("raw transcript rendered in %d messages, want exactly one", transcriptMessages)
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[1].Content, string(transcript)) {
|
||||
t.Fatalf("transcript message did not include source input")
|
||||
}
|
||||
if prepared.Messages[2].CacheControl == nil {
|
||||
t.Fatalf("reference message did not render as cacheable user message: %#v", prepared.Messages[2])
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Alice: Aria") {
|
||||
t.Fatalf("reference message missing player content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Aria: cleric") {
|
||||
t.Fatalf("reference message missing party content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Brightmantle: temple") {
|
||||
t.Fatalf("reference message missing glossary content")
|
||||
}
|
||||
if strings.Contains(prepared.Messages[3].Content, string(transcript)) {
|
||||
t.Fatalf("task message leaked transcript bytes")
|
||||
if !referenceMessageFound {
|
||||
t.Fatalf("reference message did not render all supplied reference material")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,12 @@ inputs:
|
||||
messages:
|
||||
- role: system
|
||||
content_file: ./sharedassets/common-dnd-system.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-extraction-evidence.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-identity.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-transcript.md
|
||||
cache_control:
|
||||
@@ -28,8 +34,12 @@ messages:
|
||||
content_file: ./sharedassets/common-dnd-references.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-immediate-resolution.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-npcs.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./task.md
|
||||
- role: user
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
Return exactly one JSON object and no explanatory text.
|
||||
|
||||
Return the combat_turns array even when no combat turn is established. Return
|
||||
one or more actions for every turn. Use one of the supported turn_kind and
|
||||
action category values. Set round to null when the transcript does not state
|
||||
an explicit or unambiguous positive round number. Set resolution to null when
|
||||
the transcript establishes the declaration but not an immediate resolution.
|
||||
|
||||
Every source reference must contain start_unit_id and end_unit_id from the
|
||||
provided transcript. Do not add source_id; the extraction mapper assigns the
|
||||
current source identity. Do not include fields not defined by the response
|
||||
schema.
|
||||
action category values. Set round to null when the transcript does not state an
|
||||
explicit or unambiguous positive round number. Set resolution to null when the
|
||||
transcript establishes the declaration but not an immediate resolution.
|
||||
|
||||
@@ -5,26 +5,15 @@ participant takes a combat turn or performs a discrete interrupting combat
|
||||
event. Reactions, legendary actions, lair actions, and other out-of-turn events
|
||||
belong at the point where they occur in transcript chronology.
|
||||
|
||||
Report only the declaration and its immediate observed resolution. Immediate
|
||||
resolution may include directly associated rolls, damage, healing, movement,
|
||||
conditions, target outcomes, or an interruption. Do not follow consequences
|
||||
that occur on later turns or elsewhere in the scene.
|
||||
|
||||
Exclude initiative setup without a turn or combat event, tactical planning,
|
||||
table talk, rules lookup, hypothetical actions, abandoned declarations, recap
|
||||
of combat outside the current passage, and downstream consequences.
|
||||
|
||||
Use only the supplied transcript as evidence. Do not infer a round, target,
|
||||
roll, amount, condition, outcome, or action classification from D&D rules
|
||||
knowledge. Preserve the session as played; attribute relevant nonstandard
|
||||
rulings to the GM or table.
|
||||
Do not infer a round, target, roll, amount, condition, outcome, or action
|
||||
classification from D&D rules knowledge. Preserve the session as played;
|
||||
attribute relevant nonstandard rulings to the GM or table.
|
||||
|
||||
The actor must be the in-world character or creature, not a player, transcript
|
||||
speaker, or GM. Use player, party, glossary, and NPC reference material only
|
||||
to disambiguate identities. Reference material is context, never combat
|
||||
evidence. Unmatched actors and targets remain permitted.
|
||||
Unmatched actors and targets remain permitted.
|
||||
|
||||
For every factual detail in a turn, cite all supporting transcript units in the
|
||||
turn-level source_refs collection. Use narrow ranges when evidence is
|
||||
non-contiguous. Numeric source-unit IDs identify transcript units; they do not
|
||||
establish chronology outside the supplied transcript.
|
||||
turn-level source_refs collection.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package combatturns
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -159,9 +158,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
if len(req.Chunk.Units) == 0 {
|
||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
||||
}
|
||||
sourceInput, err := chunkSourceInput(req)
|
||||
sourceInput, err := shared.ChunkPromptMaterial(req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, err
|
||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
|
||||
var response extractionResponse
|
||||
@@ -181,26 +180,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: canonicalCombatTurnList(response, req.Source.ID)}, nil
|
||||
}
|
||||
|
||||
func chunkSourceInput(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||
material := req.SourceInput.Clone()
|
||||
if len(material.Content) == 0 {
|
||||
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||
}
|
||||
if !bytes.Equal(material.Content, req.Chunk.Content) {
|
||||
return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID)
|
||||
}
|
||||
if material.Name == "" {
|
||||
material.Name = "source"
|
||||
}
|
||||
if material.MediaType == "" {
|
||||
material.MediaType = req.Chunk.MediaType
|
||||
}
|
||||
if material.SizeBytes == 0 {
|
||||
material.SizeBytes = int64(len(material.Content))
|
||||
}
|
||||
return material, nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
|
||||
@@ -11,12 +11,26 @@ import (
|
||||
|
||||
const scriptoriumPromptRoot = "assets/prompts"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := shared.ModulePromptFS("dnd.combat_turns", embeddedAssets, []promptfs.ModulePromptFile{
|
||||
var promptAssetManifest = shared.PromptAssetManifest{
|
||||
ModuleDir: "dnd.combat_turns",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.combat_turns.yaml", Path: "assets/prompts/dnd.combat_turns.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
})
|
||||
},
|
||||
SharedFiles: []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-extraction-evidence.md",
|
||||
"common-dnd-identity.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"common-dnd-immediate-resolution.md",
|
||||
"common-dnd-npcs.md",
|
||||
},
|
||||
}
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare combat-turn prompt assets: %w", err)
|
||||
}
|
||||
@@ -28,12 +42,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
|
||||
func scriptoriumPromptMetadata() (string, error) {
|
||||
scriptoriumPromptHashOnce.Do(func() {
|
||||
parts := append([]llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.combat_turns.yaml"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
}
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package combatturns
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/scriptorium"
|
||||
)
|
||||
|
||||
func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
|
||||
@@ -13,23 +16,6 @@ func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
|
||||
if err := RegisterPromptAssets(registry); err != nil {
|
||||
t.Fatalf("RegisterPromptAssets() error = %v", err)
|
||||
}
|
||||
promptFS, err := registry.PromptFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{
|
||||
"dnd.combat_turns/dnd.combat_turns.yaml",
|
||||
"dnd.combat_turns/task.md",
|
||||
"dnd.combat_turns/instructions.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-system.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-transcript.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-references.md",
|
||||
"dnd.combat_turns/sharedassets/common-dnd-npcs.md",
|
||||
} {
|
||||
if _, err := fs.ReadFile(promptFS, path); err != nil {
|
||||
t.Fatalf("prompt asset %q: %v", path, err)
|
||||
}
|
||||
}
|
||||
schemaFS, err := registry.SchemaFS()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -42,3 +28,50 @@ func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
|
||||
t.Fatalf("scriptoriumPromptMetadata() = %q, %v; want digest", hash, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScriptoriumPromptPreparesRequiredInputs(t *testing.T) {
|
||||
registry := llm.NewAssetRegistry()
|
||||
if err := RegisterPromptAssets(registry); err != nil {
|
||||
t.Fatalf("RegisterPromptAssets() error = %v, want nil", err)
|
||||
}
|
||||
options, err := registry.ScriptoriumOptions()
|
||||
if err != nil {
|
||||
t.Fatalf("ScriptoriumOptions() error = %v, want nil", err)
|
||||
}
|
||||
options = append(options, scriptorium.WithProfiles(scriptorium.OpenAICompatibleProfile(scriptorium.OpenAICompatibleProfileConfig{
|
||||
ID: "combat-test-profile", Endpoint: "http://127.0.0.1:1/v1", Model: "combat-test-model",
|
||||
})))
|
||||
engine, err := scriptorium.NewEngine(scriptorium.Config{Timeout: time.Second}, options...)
|
||||
if err != nil {
|
||||
t.Fatalf("NewEngine() error = %v, want nil", err)
|
||||
}
|
||||
transcript := `{"units":[1]}`
|
||||
prepared, err := engine.Prepare(context.Background(), scriptorium.RunRequest{
|
||||
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "combat-test-profile",
|
||||
Inputs: map[string]scriptorium.ArtifactRef{
|
||||
"transcript": scriptorium.InlineWithURI("file:///session.json", transcript),
|
||||
"players": scriptorium.Inline("Dana: Mira"),
|
||||
"party": scriptorium.Inline("Mira: ranger"),
|
||||
"glossary": scriptorium.Inline("Greencloak: title"),
|
||||
"npcs": scriptorium.Inline(`{"npcs":[]}`),
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||
}
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_combat_turns_llm.v1.json" {
|
||||
t.Fatalf("prepared prompt = %#v, want combat prompt identity and schema", prepared)
|
||||
}
|
||||
for _, want := range []string{transcript, "Dana: Mira", "Mira: ranger", "Greencloak: title", `{"npcs":[]}`} {
|
||||
found := false
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ inputs:
|
||||
messages:
|
||||
- role: system
|
||||
content_file: ./sharedassets/common-dnd-system.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-extraction-evidence.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-identity.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-transcript.md
|
||||
cache_control:
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
Return exactly one JSON object and no explanatory text.
|
||||
|
||||
For every NPC record, cite one or more transcript source-unit ranges that
|
||||
collectively support the canonical name, every alias, the description, and
|
||||
every relationship. Use integer start_unit_id and end_unit_id values from the
|
||||
transcript. Do not provide source_id; the extractor assigns it automatically.
|
||||
Use narrow ranges when evidence is not contiguous and do not bridge unrelated
|
||||
conversation with a broad range.
|
||||
|
||||
A canonical name must be the most specific in-world identity supported by the
|
||||
transcript. Never use a human player, transcript speaker, or GM name when an
|
||||
associated in-world character or creature is identified. Player, party, and
|
||||
glossary references may disambiguate identities, but they are not transcript
|
||||
evidence and cannot establish that an NPC appeared, acted, or was discussed.
|
||||
Do not return a person or entity mentioned only in those references.
|
||||
For every NPC record, cite transcript units that support the canonical name,
|
||||
every alias, the description, and every relationship.
|
||||
|
||||
Descriptions must be short session records, not biographies, statistics,
|
||||
alignment, motivations, or lore inferred from general D&D knowledge. Do not
|
||||
|
||||
@@ -5,12 +5,11 @@ Include an in-world non-PC participant when the transcript establishes that it
|
||||
appears, acts, speaks, or is materially discussed and gives it a proper name,
|
||||
a stable alias or title, or an individually useful distinguishing description.
|
||||
|
||||
Exclude human players, transcript speakers, the GM as an out-of-world person,
|
||||
Exclude human players, transcript speakers, and the GM as out-of-world people,
|
||||
player characters identified by the player or party references, incidental or
|
||||
hypothetical name drops, corrected transcription mistakes, characters mentioned
|
||||
only by reference material, indistinguishable crowds or groups, and temporary
|
||||
summoned creatures or spell effects without a persistent individual identity.
|
||||
|
||||
Return canonical in-world names rather than player names or transcript speaker
|
||||
names. Keep each description concise and limited to facts established by the
|
||||
Keep each description concise and limited to facts established by the
|
||||
transcript. Include only explicitly supported aliases and relationships.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package npcs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
@@ -122,9 +121,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
if len(req.Chunk.Units) == 0 {
|
||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
||||
}
|
||||
sourceInput, err := chunkSourceInput(req)
|
||||
sourceInput, err := shared.ChunkPromptMaterial(req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, err
|
||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
|
||||
var response extractionResponse
|
||||
@@ -142,26 +141,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(response, req.Source.ID)}, nil
|
||||
}
|
||||
|
||||
func chunkSourceInput(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||
material := req.SourceInput.Clone()
|
||||
if len(material.Content) == 0 {
|
||||
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||
}
|
||||
if !bytes.Equal(material.Content, req.Chunk.Content) {
|
||||
return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID)
|
||||
}
|
||||
if material.Name == "" {
|
||||
material.Name = "source"
|
||||
}
|
||||
if material.MediaType == "" {
|
||||
material.MediaType = req.Chunk.MediaType
|
||||
}
|
||||
if material.SizeBytes == 0 {
|
||||
material.SizeBytes = int64(len(material.Content))
|
||||
}
|
||||
return material, nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
|
||||
@@ -133,7 +133,11 @@ func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
||||
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
|
||||
t.Fatalf("canceled Extract() error = %v, want context error", err)
|
||||
}
|
||||
_, err := newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
|
||||
_, err := extractor.Extract(context.Background(), mismatchedSourceInputRequest(request))
|
||||
if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "must match chunk") {
|
||||
t.Fatalf("source input error = %v, want contextual source input error", err)
|
||||
}
|
||||
_, err = newExtractor(t, &fakeNPCsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
|
||||
if err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), "provider unavailable") {
|
||||
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
|
||||
}
|
||||
|
||||
@@ -11,12 +11,24 @@ import (
|
||||
|
||||
const scriptoriumPromptRoot = "assets/prompts"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := shared.ModulePromptFS("dnd.npcs", embeddedAssets, []promptfs.ModulePromptFile{
|
||||
var promptAssetManifest = shared.PromptAssetManifest{
|
||||
ModuleDir: "dnd.npcs",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.npcs.yaml", Path: "assets/prompts/dnd.npcs.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
})
|
||||
},
|
||||
SharedFiles: []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-extraction-evidence.md",
|
||||
"common-dnd-identity.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
},
|
||||
}
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare NPC prompt assets: %w", err)
|
||||
}
|
||||
@@ -28,12 +40,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
|
||||
func scriptoriumPromptMetadata() (string, error) {
|
||||
scriptoriumPromptHashOnce.Do(func() {
|
||||
parts := append([]llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.npcs.yaml"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
}
|
||||
|
||||
@@ -39,14 +39,29 @@ func TestRegisterPromptAssetsAndPrepareNPCPrompt(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
||||
}
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_llm.v1.json" || len(prepared.Messages) != 5 {
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_llm.v1.json" {
|
||||
t.Fatalf("prepared prompt = %#v, want NPC prompt identity and wiring", prepared)
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[1].Content, `{"units":[1]}`) || !strings.Contains(prepared.Messages[2].Content, "Dana: Mira") || !strings.Contains(prepared.Messages[2].Content, "Mira: ranger") {
|
||||
t.Fatalf("prepared prompt inputs do not include transcript/references")
|
||||
for _, want := range []string{`{"units":[1]}`, "Dana: Mira", "Mira: ranger", "Greencloak: title"} {
|
||||
found := false
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
||||
}
|
||||
}
|
||||
if strings.Contains(prepared.Messages[3].Content, `{"units":[1]}`) || strings.Contains(prepared.Messages[4].Content, `{"units":[1]}`) {
|
||||
t.Fatal("task or instruction prompt leaked raw transcript")
|
||||
transcriptMessages := 0
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, `{"units":[1]}`) {
|
||||
transcriptMessages++
|
||||
}
|
||||
}
|
||||
if transcriptMessages != 1 {
|
||||
t.Fatalf("raw transcript rendered in %d messages, want exactly one", transcriptMessages)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ inputs:
|
||||
messages:
|
||||
- role: system
|
||||
content_file: ./sharedassets/common-dnd-system.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-extraction-evidence.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-identity.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-transcript.md
|
||||
cache_control:
|
||||
@@ -31,8 +37,12 @@ messages:
|
||||
content_file: ./sharedassets/common-dnd-references.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-immediate-resolution.md
|
||||
- role: user
|
||||
content_file: ./sharedassets/common-dnd-npcs.md
|
||||
cache_control:
|
||||
type: ephemeral
|
||||
- role: user
|
||||
content_file: ./catalog.md
|
||||
- role: user
|
||||
|
||||
@@ -1,40 +1,29 @@
|
||||
Source references must use integer source-unit IDs from the transcript. Provide
|
||||
start_unit_id and end_unit_id for each source reference; the extractor assigns
|
||||
source_id automatically.
|
||||
For each spell cast, source references must collectively support the caster,
|
||||
spell, effect, and narrative_description. If a detail is not supported by the
|
||||
cited transcript units, omit that detail or describe only the supported attempt
|
||||
or declaration.
|
||||
|
||||
Report only the casting declaration or action and its immediate resolution.
|
||||
This may include an immediately resolved target, saving throw, damage, healing,
|
||||
condition, interruption, or outcome. Do not follow summoned creatures,
|
||||
persistent effects, or other downstream consequences through the rest of the
|
||||
scene.
|
||||
For spells, do not follow summoned creatures, persistent effects, or other
|
||||
downstream consequences through the rest of the scene.
|
||||
|
||||
For every detail you report, cite all supporting transcript units. The source
|
||||
references for an artifact must collectively support every factual claim in
|
||||
caster, spell, effect, and narrative_description. When immediate supporting
|
||||
evidence is non-contiguous, provide multiple narrow source references. Do not
|
||||
use one broad range merely to bridge unrelated table conversation. If a detail
|
||||
is not supported by the cited transcript units, omit that detail or describe
|
||||
only the supported attempt or declaration.
|
||||
Return only D&D spell-cast artifacts. For each spell cast, identify the
|
||||
in-world caster, spell name, effect, narrative description, and source
|
||||
references.
|
||||
|
||||
Return only D&D spell-cast artifacts. For each spell cast, identify the in-world
|
||||
caster, spell name, effect, narrative description, and source references.
|
||||
|
||||
The caster must be the canonical in-world character or creature, not the human
|
||||
player, transcript speaker, or GM. Use the player and party references together
|
||||
with transcript context to map first-person player speech to the associated
|
||||
player character and use the canonical character name from the references.
|
||||
Likewise, attribute a spell narrated by the GM to the in-world creature that
|
||||
casts it. Never return a player or GM name when the associated in-world caster
|
||||
can be identified. If the caster cannot be resolved, use only the most specific
|
||||
in-world identity supported by the transcript; do not invent a name.
|
||||
Use the player and party references together with transcript context to map
|
||||
first-person player speech to the associated player character and use the
|
||||
canonical character name from the references. Likewise, attribute a spell
|
||||
narrated by the GM to the in-world creature that casts it. If the caster cannot
|
||||
be resolved, use only the most specific in-world identity supported by the
|
||||
transcript; do not invent a name.
|
||||
|
||||
Use the canonical spell-name catalog to select spell names. Do not return a
|
||||
spell name absent from that catalog, even when it is suggested by general D&D
|
||||
knowledge or reference material.
|
||||
|
||||
Use player, party, and glossary reference material only to clarify source text;
|
||||
references are not source evidence for a spell cast. Do not return spells,
|
||||
casters, or effects that are mentioned only in reference material.
|
||||
Use player, party, and glossary reference material only to clarify source text.
|
||||
Do not return spells, casters, or effects that are mentioned only in reference
|
||||
material.
|
||||
|
||||
Effects and narrative descriptions are session records, not rules summaries.
|
||||
Report only mechanics, explanations, and outcomes established by the cited
|
||||
@@ -42,5 +31,3 @@ transcript units. Preserve the table's observed resolution without silently
|
||||
correcting it from general D&D knowledge. If the transcript gives a possibly
|
||||
nonstandard rationale, use wording such as "the GM rules" or "the table
|
||||
resolves" rather than asserting that rationale as a universal rule.
|
||||
|
||||
Return exactly one JSON object and no explanatory text.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
@@ -177,9 +176,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
if len(req.Chunk.Units) == 0 {
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
||||
}
|
||||
sourceInput, err := chunkSourceInput(req)
|
||||
sourceInput, err := shared.ChunkPromptMaterial(req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, err
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
|
||||
var response extractionResponse
|
||||
@@ -200,26 +199,6 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID)}, nil
|
||||
}
|
||||
|
||||
func chunkSourceInput(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||
material := req.SourceInput.Clone()
|
||||
if len(material.Content) == 0 {
|
||||
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||
}
|
||||
if !bytes.Equal(material.Content, req.Chunk.Content) {
|
||||
return contracts.LLMInputMaterial{}, extractorErrorf("source input must match chunk %q content", req.Chunk.ID)
|
||||
}
|
||||
if material.Name == "" {
|
||||
material.Name = "source"
|
||||
}
|
||||
if material.MediaType == "" {
|
||||
material.MediaType = req.Chunk.MediaType
|
||||
}
|
||||
if material.SizeBytes == 0 {
|
||||
material.SizeBytes = int64(len(material.Content))
|
||||
}
|
||||
return material, nil
|
||||
}
|
||||
|
||||
func ModuleSpec() pipeline.ModuleSpec {
|
||||
return pipeline.ModuleSpec{
|
||||
Key: Key,
|
||||
|
||||
@@ -11,13 +11,27 @@ import (
|
||||
|
||||
const scriptoriumPromptRoot = "assets/prompts"
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{
|
||||
var promptAssetManifest = shared.PromptAssetManifest{
|
||||
ModuleDir: "dnd.spells",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
|
||||
{Name: "catalog.md", Path: "assets/prompts/catalog.md"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||
})
|
||||
},
|
||||
SharedFiles: []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-extraction-evidence.md",
|
||||
"common-dnd-identity.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"common-dnd-immediate-resolution.md",
|
||||
"common-dnd-npcs.md",
|
||||
},
|
||||
}
|
||||
|
||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
promptFS, err := promptAssetManifest.PromptFS(embeddedAssets)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare spell prompt assets: %w", err)
|
||||
}
|
||||
@@ -29,13 +43,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
||||
|
||||
func scriptoriumPromptMetadata() (string, error) {
|
||||
scriptoriumPromptHashOnce.Do(func() {
|
||||
parts := append([]llm.AssetHashPart{
|
||||
{FS: embeddedAssets, Path: "assets/prompts/dnd.spells.yaml"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/catalog.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/instructions.md"},
|
||||
}, append(shared.CommonHashParts(), shared.ReferenceHashParts()...)...)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = llm.HashAssets(parts)
|
||||
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||
})
|
||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||
}
|
||||
|
||||
@@ -21,32 +21,24 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
|
||||
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
|
||||
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
|
||||
}
|
||||
if got := len(prepared.Messages); got != 7 {
|
||||
t.Fatalf("message count = %d, want 7", got)
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[1].Content, string(transcript)) {
|
||||
t.Fatalf("transcript message did not include source input")
|
||||
}
|
||||
if prepared.Messages[1].CacheControl == nil || prepared.Messages[2].CacheControl == nil {
|
||||
t.Fatalf("expected transcript and reference messages to be cacheable: %#v", prepared.Messages)
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Dana: Mira") {
|
||||
t.Fatalf("reference message missing player content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Mira: wizard") {
|
||||
t.Fatalf("reference message missing party content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[2].Content, "Shield: abjuration") {
|
||||
t.Fatalf("reference message missing glossary content")
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[4].Content, `{"spell_names":["Cure Wounds"]}`) {
|
||||
t.Fatalf("catalog message missing canonical spell-name input: %s", prepared.Messages[4].Content)
|
||||
}
|
||||
if !strings.Contains(prepared.Messages[3].Content, `{"npcs":[]}`) {
|
||||
t.Fatalf("NPC registry message missing empty registry input: %s", prepared.Messages[3].Content)
|
||||
}
|
||||
if strings.Contains(prepared.Messages[5].Content, string(transcript)) {
|
||||
t.Fatalf("task message leaked transcript bytes")
|
||||
for _, want := range []string{
|
||||
string(transcript),
|
||||
"Dana: Mira",
|
||||
"Mira: wizard",
|
||||
"Shield: abjuration",
|
||||
`{"spell_names":["Cure Wounds"]}`,
|
||||
`{"npcs":[]}`,
|
||||
} {
|
||||
found := false
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, want) {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,9 +46,12 @@ func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) {
|
||||
transcript := []byte(`{"id":"session-1","segments":[]}`)
|
||||
prepared := prepareSpellsPrompt(t, transcript, " ", " ", " ")
|
||||
|
||||
if !strings.Contains(prepared.Messages[2].Content, " ") {
|
||||
t.Fatalf("reference message did not include empty optional reference placeholders")
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, "Player list reference:") && strings.Contains(message.Content, "Party roster reference:") && strings.Contains(message.Content, "Glossary reference:") {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("reference message did not render empty optional reference placeholders")
|
||||
}
|
||||
|
||||
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
|
||||
const (
|
||||
Key = "dnd/combat-turns"
|
||||
NormalizationPolicy = "dnd.combat_turns.normalize.v1"
|
||||
normalizationPolicy = "dnd.combat_turns.normalize.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
|
||||
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
||||
@@ -71,7 +72,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
return nil
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"normalization_policy": NormalizationPolicy,
|
||||
"normalization_policy": normalizationPolicy,
|
||||
"identity_policy": identity.Policy,
|
||||
}
|
||||
if n.npcRegistry.Bound() {
|
||||
@@ -86,7 +87,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return nil
|
||||
}
|
||||
fingerprints := []pipeline.CheckpointFingerprint{
|
||||
{Name: "normalization_policy", Value: NormalizationPolicy},
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
{Name: "identity_policy", Value: identity.Policy},
|
||||
}
|
||||
if n.npcRegistry.Bound() {
|
||||
|
||||
@@ -189,7 +189,7 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
}
|
||||
if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != NormalizationPolicy || metadata["identity_policy"] != identity.Policy {
|
||||
if metadata := unbound.ManifestMetadata(); metadata["npc_registry_digest"] != nil || metadata["npc_count"] != nil || metadata["normalization_policy"] != normalizationPolicy || metadata["identity_policy"] != identity.Policy {
|
||||
t.Fatalf("unbound metadata = %#v", metadata)
|
||||
}
|
||||
if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" {
|
||||
|
||||
@@ -19,7 +19,8 @@ import (
|
||||
|
||||
const (
|
||||
Key = "dnd/npcs"
|
||||
NormalizationPolicy = "dnd.npcs.normalize.v1"
|
||||
normalizationPolicy = "dnd.npcs.normalize.v1"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
||||
@@ -51,7 +52,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
}
|
||||
return map[string]any{
|
||||
"identity_policy": identity.Policy,
|
||||
"normalization_policy": NormalizationPolicy,
|
||||
"normalization_policy": normalizationPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +62,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
return []pipeline.CheckpointFingerprint{
|
||||
{Name: "identity_policy", Value: identity.Policy},
|
||||
{Name: "normalization_policy", Value: NormalizationPolicy},
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
domainidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
identity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
)
|
||||
|
||||
func TestModuleContractAndIdentity(t *testing.T) {
|
||||
@@ -48,13 +48,13 @@ func TestModuleContractAndIdentity(t *testing.T) {
|
||||
|
||||
normalizer := New(Options{})
|
||||
metadata := normalizer.ManifestMetadata()
|
||||
if metadata["identity_policy"] != domainidentity.Policy || metadata["normalization_policy"] != NormalizationPolicy {
|
||||
if metadata["identity_policy"] != identity.Policy || metadata["normalization_policy"] != normalizationPolicy {
|
||||
t.Fatalf("metadata = %#v, want identity and normalization policies", metadata)
|
||||
}
|
||||
fingerprints := normalizer.CheckpointFingerprints()
|
||||
wantFingerprints := []pipeline.CheckpointFingerprint{
|
||||
{Name: "identity_policy", Value: domainidentity.Policy},
|
||||
{Name: "normalization_policy", Value: NormalizationPolicy},
|
||||
{Name: "identity_policy", Value: identity.Policy},
|
||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||
}
|
||||
if !reflect.DeepEqual(fingerprints, wantFingerprints) {
|
||||
t.Fatalf("fingerprints = %#v, want %#v", fingerprints, wantFingerprints)
|
||||
@@ -104,7 +104,7 @@ func TestNormalizePerRecordFieldsAndEvidence(t *testing.T) {
|
||||
if !reflect.DeepEqual(got.SourceRefs, wantRefs) {
|
||||
t.Fatalf("source refs = %#v, want %#v", got.SourceRefs, wantRefs)
|
||||
}
|
||||
if got.ID != domainidentity.DeriveID("Lady Ash") {
|
||||
if got.ID != identity.DeriveID("Lady Ash") {
|
||||
t.Fatalf("ID = %q, want derived ID", got.ID)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeNPCFieldsNormalized, "npcs[0]") ||
|
||||
|
||||
112
internal/modules/dnd/register/chains.go
Normal file
112
internal/modules/dnd/register/chains.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package register
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
|
||||
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
|
||||
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"
|
||||
validjsonschema "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/valid_json_schema"
|
||||
)
|
||||
|
||||
func registerDefaultChains(registry *pipeline.ValidatorChainRegistry) error {
|
||||
return runRegistrations([]registration{
|
||||
{name: "spells validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: spellextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellshape.Key),
|
||||
pipeline.Binding(spellcatalog.Key),
|
||||
pipeline.Binding(spellsourcerefs.Key),
|
||||
pipeline.Binding(spellrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "spells normalize validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: spellnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellshape.Key),
|
||||
pipeline.Binding(spellcatalog.Key),
|
||||
pipeline.Binding(spellsourcerefs.Key),
|
||||
pipeline.Binding(spellrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "npcs validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: npcextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(npcshape.Key),
|
||||
pipeline.Binding(npcsourcerefs.Key),
|
||||
pipeline.Binding(npcrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "npcs normalize validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: npcnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(npcshape.Key),
|
||||
pipeline.Binding(npcidentity.Key),
|
||||
pipeline.Binding(npcsourcerefs.Key),
|
||||
pipeline.Binding(npcrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "combat turns validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: combatextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(combatshape.Key),
|
||||
pipeline.Binding(combatsourcerefs.Key),
|
||||
pipeline.Binding(combatrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
{name: "combat turns normalize validator chain", register: func() error {
|
||||
return registry.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: combatnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(combatshape.Key),
|
||||
pipeline.Binding(combatinvariants.Key),
|
||||
pipeline.Binding(combatsourcerefs.Key),
|
||||
pipeline.Binding(combatrelatedness.Key),
|
||||
},
|
||||
})
|
||||
}},
|
||||
})
|
||||
}
|
||||
83
internal/modules/dnd/register/merge.go
Normal file
83
internal/modules/dnd/register/merge.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package register
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
count += len(value.SpellCasts)
|
||||
}
|
||||
combined := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, count)}
|
||||
for _, value := range values {
|
||||
combined.SpellCasts = append(combined.SpellCasts, value.SpellCasts...)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.NPCs != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.NPCs)
|
||||
}
|
||||
if !present {
|
||||
return dnd.NPCList{}, nil
|
||||
}
|
||||
combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)}
|
||||
for _, value := range values {
|
||||
combined.NPCs = append(combined.NPCs, value.NPCs...)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.CombatTurns != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.CombatTurns)
|
||||
}
|
||||
if !present {
|
||||
return dnd.CombatTurnList{}, nil
|
||||
}
|
||||
combined := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, 0, count)}
|
||||
for _, value := range values {
|
||||
for _, turn := range value.CombatTurns {
|
||||
combined.CombatTurns = append(combined.CombatTurns, cloneCombatTurn(turn))
|
||||
}
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||
clone := value
|
||||
if value.Round != nil {
|
||||
round := *value.Round
|
||||
clone.Round = &round
|
||||
}
|
||||
if value.Actions != nil {
|
||||
clone.Actions = make([]dnd.CombatAction, len(value.Actions))
|
||||
for index, action := range value.Actions {
|
||||
clone.Actions[index] = action
|
||||
if action.Targets != nil {
|
||||
clone.Actions[index].Targets = append([]string(nil), action.Targets...)
|
||||
}
|
||||
if action.Resolution != nil {
|
||||
resolution := *action.Resolution
|
||||
clone.Actions[index].Resolution = &resolution
|
||||
}
|
||||
}
|
||||
}
|
||||
if value.SourceRefs != nil {
|
||||
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
62
internal/modules/dnd/register/modules.go
Normal file
62
internal/modules/dnd/register/modules.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package register
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
spellextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||
)
|
||||
|
||||
func registerModules(registries pipeline.Registries) error {
|
||||
codec := spellcodec.New()
|
||||
return runRegistrations([]registration{
|
||||
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
|
||||
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
||||
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
|
||||
{name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
|
||||
{name: "spells extractor", register: func() error { return spellextract.Register(registries.Extractors) }},
|
||||
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
||||
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
|
||||
{name: "spell-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
|
||||
}},
|
||||
{name: "npc-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists)
|
||||
}},
|
||||
{name: "combat-turn-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
|
||||
}},
|
||||
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
|
||||
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
||||
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
|
||||
{name: "spell-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind)
|
||||
}},
|
||||
{name: "npc-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind)
|
||||
}},
|
||||
{name: "combat-turn-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
|
||||
}},
|
||||
})
|
||||
}
|
||||
|
||||
func registerPromptAssets(assets *llm.AssetRegistry) error {
|
||||
return runRegistrations([]registration{
|
||||
{name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
|
||||
{name: "spells prompt assets", register: func() error { return spellextract.RegisterPromptAssets(assets) }},
|
||||
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
|
||||
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
|
||||
})
|
||||
}
|
||||
@@ -4,276 +4,41 @@ package register
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/chunk/scenes"
|
||||
combatcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/combatturns"
|
||||
npccodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/npcs"
|
||||
spellcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/spells"
|
||||
combatextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/combatturns"
|
||||
npcextract "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/npcs"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/extract/spells"
|
||||
combatnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/combatturns"
|
||||
npcnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/npcs"
|
||||
spellnormalize "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/normalize/spells"
|
||||
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
|
||||
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
|
||||
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/merge/appendorder"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
|
||||
alwaysaccept "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_accept"
|
||||
alwaysreject "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_reject"
|
||||
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"
|
||||
)
|
||||
|
||||
type registration struct {
|
||||
name string
|
||||
register func() error
|
||||
}
|
||||
|
||||
// Register adds all production D&D modules, validators, policy, and assets.
|
||||
func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
||||
if err := validateRegistries(registries, assets); err != nil {
|
||||
return err
|
||||
}
|
||||
codec := spellcodec.New()
|
||||
registrations := []struct {
|
||||
name string
|
||||
register func() error
|
||||
}{
|
||||
{name: "spells codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, codec) }},
|
||||
{name: "npcs codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, npccodec.New()) }},
|
||||
{name: "combat turns codec", register: func() error { return pipeline.RegisterArtifactCodec(registries.ArtifactCodecs, combatcodec.New()) }},
|
||||
{name: "scenes chunker", register: func() error { return scenes.Register(registries.Chunkers) }},
|
||||
{name: "spells extractor", register: func() error { return spells.Register(registries.Extractors) }},
|
||||
{name: "npcs extractor", register: func() error { return npcextract.Register(registries.Extractors) }},
|
||||
{name: "combat turns extractor", register: func() error { return combatextract.Register(registries.Extractors) }},
|
||||
{name: "spell-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.SpellListKind, appendSpellLists)
|
||||
}},
|
||||
{name: "npc-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.NPCListKind, appendNPCLists)
|
||||
}},
|
||||
{name: "combat-turn-list appendorder merger", register: func() error {
|
||||
return appendorder.RegisterTyped(registries.Mergers, dnd.CombatTurnListKind, appendCombatTurnLists)
|
||||
}},
|
||||
{name: "spells normalizer", register: func() error { return spellnormalize.Register(registries.Normalizers) }},
|
||||
{name: "npcs normalizer", register: func() error { return npcnormalize.Register(registries.Normalizers) }},
|
||||
{name: "combat turns normalizer", register: func() error { return combatnormalize.Register(registries.Normalizers) }},
|
||||
{name: "spell-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.SpellList](registries.Normalizers, dnd.SpellListKind) }},
|
||||
{name: "npc-list noop normalizer", register: func() error { return noop.RegisterTyped[dnd.NPCList](registries.Normalizers, dnd.NPCListKind) }},
|
||||
{name: "combat-turn-list noop normalizer", register: func() error {
|
||||
return noop.RegisterTyped[dnd.CombatTurnList](registries.Normalizers, dnd.CombatTurnListKind)
|
||||
}},
|
||||
{name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }},
|
||||
{name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }},
|
||||
{name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }},
|
||||
{name: "spell source relatedness validator", register: func() error { return spellrelatedness.Register(registries.Validators) }},
|
||||
{name: "npc shape validator", register: func() error { return npcshape.Register(registries.Validators) }},
|
||||
{name: "npc identity validator", register: func() error { return npcidentity.Register(registries.Validators) }},
|
||||
{name: "npc source references validator", register: func() error { return npcsourcerefs.Register(registries.Validators) }},
|
||||
{name: "npc source relatedness validator", register: func() error { return npcrelatedness.Register(registries.Validators) }},
|
||||
{name: "combat shape validator", register: func() error { return combatshape.Register(registries.Validators) }},
|
||||
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
|
||||
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
|
||||
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
|
||||
{name: "spell-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
|
||||
}},
|
||||
{name: "spell-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
|
||||
}},
|
||||
{name: "npc-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
|
||||
}},
|
||||
{name: "npc-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
|
||||
}},
|
||||
{name: "combat-turn-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||
}},
|
||||
{name: "combat-turn-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||
}},
|
||||
{name: "scenes prompt assets", register: func() error { return scenes.RegisterPromptAssets(assets) }},
|
||||
{name: "spells prompt assets", register: func() error { return spells.RegisterPromptAssets(assets) }},
|
||||
{name: "npcs prompt assets", register: func() error { return npcextract.RegisterPromptAssets(assets) }},
|
||||
{name: "combat turns prompt assets", register: func() error { return combatextract.RegisterPromptAssets(assets) }},
|
||||
if err := registerModules(registries); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerValidators(registries); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := registerPromptAssets(assets); err != nil {
|
||||
return err
|
||||
}
|
||||
return registerDefaultChains(registries.ValidatorChains)
|
||||
}
|
||||
|
||||
func runRegistrations(registrations []registration) error {
|
||||
for _, registration := range registrations {
|
||||
if err := registration.register(); err != nil {
|
||||
return fmt.Errorf("register dnd %s: %w", registration.name, err)
|
||||
}
|
||||
}
|
||||
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: spells.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellshape.Key),
|
||||
pipeline.Binding(spellcatalog.Key),
|
||||
pipeline.Binding(spellsourcerefs.Key),
|
||||
pipeline.Binding(spellrelatedness.Key),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register dnd spells validator chain: %w", err)
|
||||
}
|
||||
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: spellnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(spellshape.Key),
|
||||
pipeline.Binding(spellcatalog.Key),
|
||||
pipeline.Binding(spellsourcerefs.Key),
|
||||
pipeline.Binding(spellrelatedness.Key),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register dnd spells normalize validator chain: %w", err)
|
||||
}
|
||||
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: npcextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(npcshape.Key),
|
||||
pipeline.Binding(npcsourcerefs.Key),
|
||||
pipeline.Binding(npcrelatedness.Key),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register dnd npcs validator chain: %w", err)
|
||||
}
|
||||
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: npcnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(npcshape.Key),
|
||||
pipeline.Binding(npcidentity.Key),
|
||||
pipeline.Binding(npcsourcerefs.Key),
|
||||
pipeline.Binding(npcrelatedness.Key),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register dnd npcs normalize validator chain: %w", err)
|
||||
}
|
||||
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageExtract,
|
||||
Module: combatextract.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(combatshape.Key),
|
||||
pipeline.Binding(combatsourcerefs.Key),
|
||||
pipeline.Binding(combatrelatedness.Key),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register dnd combat turns validator chain: %w", err)
|
||||
}
|
||||
if err := registries.ValidatorChains.Register(pipeline.ValidatorChainMapping{
|
||||
Stage: pipeline.StageNormalize,
|
||||
Module: combatnormalize.Key,
|
||||
Validators: []pipeline.ModuleBinding{
|
||||
pipeline.Binding(validjson.Key),
|
||||
pipeline.Binding(validjsonschema.Key),
|
||||
pipeline.Binding(combatshape.Key),
|
||||
pipeline.Binding(combatinvariants.Key),
|
||||
pipeline.Binding(combatsourcerefs.Key),
|
||||
pipeline.Binding(combatrelatedness.Key),
|
||||
},
|
||||
}); err != nil {
|
||||
return fmt.Errorf("register dnd combat turns normalize validator chain: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
count += len(value.SpellCasts)
|
||||
}
|
||||
combined := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, count)}
|
||||
for _, value := range values {
|
||||
combined.SpellCasts = append(combined.SpellCasts, value.SpellCasts...)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.NPCs != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.NPCs)
|
||||
}
|
||||
if !present {
|
||||
return dnd.NPCList{}, nil
|
||||
}
|
||||
combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)}
|
||||
for _, value := range values {
|
||||
combined.NPCs = append(combined.NPCs, value.NPCs...)
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func appendCombatTurnLists(values []dnd.CombatTurnList) (dnd.CombatTurnList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.CombatTurns != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.CombatTurns)
|
||||
}
|
||||
if !present {
|
||||
return dnd.CombatTurnList{}, nil
|
||||
}
|
||||
combined := dnd.CombatTurnList{CombatTurns: make([]dnd.CombatTurn, 0, count)}
|
||||
for _, value := range values {
|
||||
for _, turn := range value.CombatTurns {
|
||||
combined.CombatTurns = append(combined.CombatTurns, cloneCombatTurn(turn))
|
||||
}
|
||||
}
|
||||
return combined, nil
|
||||
}
|
||||
|
||||
func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||
clone := value
|
||||
if value.Round != nil {
|
||||
round := *value.Round
|
||||
clone.Round = &round
|
||||
}
|
||||
if value.Actions != nil {
|
||||
clone.Actions = make([]dnd.CombatAction, len(value.Actions))
|
||||
for index, action := range value.Actions {
|
||||
clone.Actions[index] = action
|
||||
if action.Targets != nil {
|
||||
clone.Actions[index].Targets = append([]string(nil), action.Targets...)
|
||||
}
|
||||
if action.Resolution != nil {
|
||||
resolution := *action.Resolution
|
||||
clone.Actions[index].Resolution = &resolution
|
||||
}
|
||||
}
|
||||
}
|
||||
if value.SourceRefs != nil {
|
||||
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||
}
|
||||
return clone
|
||||
}
|
||||
|
||||
func validateRegistries(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
||||
switch {
|
||||
case registries.Chunkers == nil:
|
||||
|
||||
55
internal/modules/dnd/register/validators.go
Normal file
55
internal/modules/dnd/register/validators.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package register
|
||||
|
||||
import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
combatinvariants "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/invariants"
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
combatsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_refs"
|
||||
combatrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/source_relatedness"
|
||||
npcidentity "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/identity"
|
||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||
npcsourcerefs "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_refs"
|
||||
npcrelatedness "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/source_relatedness"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/catalog"
|
||||
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"
|
||||
alwaysaccept "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_accept"
|
||||
alwaysreject "gitea.maximumdirect.net/eric/notarius/internal/modules/generic/validate/always_reject"
|
||||
)
|
||||
|
||||
func registerValidators(registries pipeline.Registries) error {
|
||||
return runRegistrations([]registration{
|
||||
{name: "spell shape validator", register: func() error { return spellshape.Register(registries.Validators) }},
|
||||
{name: "spell catalog validator", register: func() error { return spellcatalog.Register(registries.Validators) }},
|
||||
{name: "spell source references validator", register: func() error { return spellsourcerefs.Register(registries.Validators) }},
|
||||
{name: "spell source relatedness validator", register: func() error { return spellrelatedness.Register(registries.Validators) }},
|
||||
{name: "npc shape validator", register: func() error { return npcshape.Register(registries.Validators) }},
|
||||
{name: "npc identity validator", register: func() error { return npcidentity.Register(registries.Validators) }},
|
||||
{name: "npc source references validator", register: func() error { return npcsourcerefs.Register(registries.Validators) }},
|
||||
{name: "npc source relatedness validator", register: func() error { return npcrelatedness.Register(registries.Validators) }},
|
||||
{name: "combat shape validator", register: func() error { return combatshape.Register(registries.Validators) }},
|
||||
{name: "combat source references validator", register: func() error { return combatsourcerefs.Register(registries.Validators) }},
|
||||
{name: "combat source relatedness validator", register: func() error { return combatrelatedness.Register(registries.Validators) }},
|
||||
{name: "combat normalized invariants validator", register: func() error { return combatinvariants.Register(registries.Validators) }},
|
||||
{name: "spell-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
|
||||
}},
|
||||
{name: "spell-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.SpellList](registries.Validators, dnd.SpellListKind)
|
||||
}},
|
||||
{name: "npc-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
|
||||
}},
|
||||
{name: "npc-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.NPCList](registries.Validators, dnd.NPCListKind)
|
||||
}},
|
||||
{name: "combat-turn-list always accept validator", register: func() error {
|
||||
return alwaysaccept.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||
}},
|
||||
{name: "combat-turn-list always reject validator", register: func() error {
|
||||
return alwaysreject.RegisterTyped[dnd.CombatTurnList](registries.Validators, dnd.CombatTurnListKind)
|
||||
}},
|
||||
})
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package shared
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
||||
@@ -11,39 +13,72 @@ import (
|
||||
//go:embed assets/prompts/*.md
|
||||
var embeddedAssets embed.FS
|
||||
|
||||
var sharedPromptFiles = []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-references.md",
|
||||
"common-dnd-npcs.md",
|
||||
// PromptAssetManifest is the ordered set of assets that make up one prompt.
|
||||
// Module files are addressed in the owning module filesystem; shared files use
|
||||
// the names in sharedPromptPaths and are mounted beneath sharedassets.
|
||||
type PromptAssetManifest struct {
|
||||
ModuleDir string
|
||||
ModuleFiles []promptfs.ModulePromptFile
|
||||
SharedFiles []string
|
||||
}
|
||||
|
||||
func SharedPromptFiles() []promptfs.SharedPromptFile {
|
||||
files := make([]promptfs.SharedPromptFile, 0, len(sharedPromptFiles))
|
||||
for _, name := range sharedPromptFiles {
|
||||
var sharedPromptPaths = map[string]string{
|
||||
"common-dnd-system.md": "assets/prompts/common-dnd-system.md",
|
||||
"common-dnd-extraction-evidence.md": "assets/prompts/common-dnd-extraction-evidence.md",
|
||||
"common-dnd-identity.md": "assets/prompts/common-dnd-identity.md",
|
||||
"common-dnd-transcript.md": "assets/prompts/common-dnd-transcript.md",
|
||||
"common-dnd-references.md": "assets/prompts/common-dnd-references.md",
|
||||
"common-dnd-immediate-resolution.md": "assets/prompts/common-dnd-immediate-resolution.md",
|
||||
"common-dnd-npcs.md": "assets/prompts/common-dnd-npcs.md",
|
||||
}
|
||||
|
||||
func (manifest PromptAssetManifest) PromptFS(moduleFS fs.FS) (fs.FS, error) {
|
||||
sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
moduleFiles := append([]promptfs.ModulePromptFile(nil), manifest.ModuleFiles...)
|
||||
return promptfs.ModulePromptFS(manifest.ModuleDir, moduleFS, moduleFiles, sharedFiles...)
|
||||
}
|
||||
|
||||
func (manifest PromptAssetManifest) Hash(moduleFS fs.FS) (string, error) {
|
||||
sharedFiles, err := resolveSharedPromptFiles(manifest.SharedFiles)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
parts := make([]llm.AssetHashPart, 0, len(manifest.ModuleFiles)+len(sharedFiles))
|
||||
for _, file := range manifest.ModuleFiles {
|
||||
parts = append(parts, llm.AssetHashPart{FS: moduleFS, Path: file.Path})
|
||||
}
|
||||
for _, file := range sharedFiles {
|
||||
parts = append(parts, llm.AssetHashPart{FS: file.FS, Path: file.Path})
|
||||
}
|
||||
return llm.HashAssets(parts)
|
||||
}
|
||||
|
||||
func resolveSharedPromptFiles(names []string) ([]promptfs.SharedPromptFile, error) {
|
||||
files := make([]promptfs.SharedPromptFile, 0, len(names))
|
||||
seen := make(map[string]struct{}, len(names))
|
||||
for _, name := range names {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("shared prompt asset name must not be empty")
|
||||
}
|
||||
if strings.ContainsAny(name, `/\\`) {
|
||||
return nil, fmt.Errorf("shared prompt asset name %q must not contain path separators", name)
|
||||
}
|
||||
if _, ok := seen[name]; ok {
|
||||
return nil, fmt.Errorf("duplicate shared prompt asset name %q", name)
|
||||
}
|
||||
path, ok := sharedPromptPaths[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown shared prompt asset name %q", name)
|
||||
}
|
||||
seen[name] = struct{}{}
|
||||
files = append(files, promptfs.SharedPromptFile{
|
||||
Name: name,
|
||||
FS: embeddedAssets,
|
||||
Path: "assets/prompts/" + name,
|
||||
Path: path,
|
||||
})
|
||||
}
|
||||
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"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-npcs.md"},
|
||||
}
|
||||
}
|
||||
|
||||
func ModulePromptFS(moduleDir string, moduleFS fs.FS, files []promptfs.ModulePromptFile) (fs.FS, error) {
|
||||
return promptfs.ModulePromptFS(moduleDir, moduleFS, files, SharedPromptFiles()...)
|
||||
return files, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
Transcript units are the only evidence for extracted events and factual claims.
|
||||
Campaign and registry references may disambiguate names, aliases, speakers, or
|
||||
other identities, but they do not establish events, participation, effects, or
|
||||
source evidence.
|
||||
|
||||
Every reported factual claim must be supported by cited transcript units. Use
|
||||
integer `start_unit_id` and `end_unit_id` values from the transcript. Omit
|
||||
`source_id`; Notarius assigns the current source identity.
|
||||
|
||||
When supporting evidence is non-contiguous, use multiple narrow ranges rather
|
||||
than a broad range that bridges unrelated conversation.
|
||||
|
||||
Return exactly one JSON object and no explanatory text. Output only the
|
||||
configured JSON object and fields defined by its response schema.
|
||||
@@ -0,0 +1,7 @@
|
||||
Use the most specific supported in-world character or creature identity for
|
||||
each actor or participant. Do not identify a human player, transcript speaker,
|
||||
or the GM as an out-of-world person when an in-world identity is supported.
|
||||
|
||||
Player, party, glossary, campaign, and NPC registry references may disambiguate
|
||||
an identity, but a reference alone cannot establish that the identity
|
||||
participated in the transcript.
|
||||
@@ -0,0 +1,7 @@
|
||||
Report only a declaration or action and its immediate observed resolution.
|
||||
Immediate resolution may include directly associated rolls, damage, healing,
|
||||
movement, conditions, target outcomes, interruptions, or other outcomes shown
|
||||
with that declaration or action.
|
||||
|
||||
Do not follow consequences that occur on later turns or elsewhere in the
|
||||
scene.
|
||||
@@ -2,6 +2,8 @@ package shared
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
@@ -9,81 +11,191 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
||||
)
|
||||
|
||||
func TestSharedPromptFilesReturnsNewSlice(t *testing.T) {
|
||||
first := SharedPromptFiles()
|
||||
second := SharedPromptFiles()
|
||||
|
||||
if len(first) != 4 || len(second) != 4 {
|
||||
t.Fatalf("SharedPromptFiles() lengths = %d and %d, want 4", len(first), len(second))
|
||||
func TestPromptAssetManifestPromptFS(t *testing.T) {
|
||||
manifest := PromptAssetManifest{
|
||||
ModuleDir: "dnd.test",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
},
|
||||
SharedFiles: []string{
|
||||
"common-dnd-system.md",
|
||||
"common-dnd-transcript.md",
|
||||
},
|
||||
}
|
||||
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",
|
||||
"assets/prompts/common-dnd-npcs.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{
|
||||
fsys, err := manifest.PromptFS(fstest.MapFS{
|
||||
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
||||
}, []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ModulePromptFS() error = %v, want nil", err)
|
||||
t.Fatalf("PromptFS() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
wantModule := map[string]string{
|
||||
"assets/prompts/dnd.test/dnd.test.yaml": "id: dnd.test",
|
||||
"assets/prompts/dnd.test/task.md": "task",
|
||||
}
|
||||
for path, wantContent := range wantModule {
|
||||
content, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
||||
}
|
||||
if string(content) != wantContent {
|
||||
t.Fatalf("ReadFile(%q) = %q, want %q", path, content, wantContent)
|
||||
}
|
||||
}
|
||||
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",
|
||||
} {
|
||||
content, err := fs.ReadFile(fsys, path)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
||||
}
|
||||
if len(content) == 0 {
|
||||
t.Fatalf("ReadFile(%q) returned empty content, want mounted asset", path)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{
|
||||
"assets/prompts/dnd.test/sharedassets/common-dnd-references.md",
|
||||
"assets/prompts/dnd.test/sharedassets/common-dnd-npcs.md",
|
||||
} {
|
||||
if _, err := fs.ReadFile(fsys, path); err != nil {
|
||||
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
||||
if _, err := fs.ReadFile(fsys, path); err == nil {
|
||||
t.Fatalf("ReadFile(%q) succeeded, want unlisted shared asset to be absent", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptAssetManifestRejectsInvalidSharedNames(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sharedFiles []string
|
||||
wantError string
|
||||
}{
|
||||
{name: "unknown", sharedFiles: []string{"missing.md"}, wantError: "unknown shared prompt asset name"},
|
||||
{name: "duplicate", sharedFiles: []string{"common-dnd-system.md", "common-dnd-system.md"}, wantError: "duplicate shared prompt asset name"},
|
||||
{name: "empty", sharedFiles: []string{""}, wantError: "must not be empty"},
|
||||
{name: "slash path", sharedFiles: []string{"nested/common-dnd-system.md"}, wantError: "must not contain path separators"},
|
||||
{name: "backslash path", sharedFiles: []string{`nested\common-dnd-system.md`}, wantError: "must not contain path separators"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := (PromptAssetManifest{
|
||||
ModuleDir: "dnd.test",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
||||
},
|
||||
SharedFiles: test.sharedFiles,
|
||||
}).PromptFS(fstest.MapFS{
|
||||
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("PromptFS() error = %v, want %q", err, test.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptAssetManifestRejectsMissingModuleFile(t *testing.T) {
|
||||
_, err := (PromptAssetManifest{
|
||||
ModuleDir: "dnd.test",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
},
|
||||
}).PromptFS(fstest.MapFS{})
|
||||
if err == nil || !strings.Contains(err.Error(), "read module prompt asset assets/prompts/task.md") {
|
||||
t.Fatalf("PromptFS() error = %v, want missing module asset context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptAssetManifestRejectsMissingSharedFile(t *testing.T) {
|
||||
const name = "missing-for-test.md"
|
||||
const path = "assets/prompts/missing-for-test.md"
|
||||
previous, existed := sharedPromptPaths[name]
|
||||
sharedPromptPaths[name] = path
|
||||
t.Cleanup(func() {
|
||||
if existed {
|
||||
sharedPromptPaths[name] = previous
|
||||
} else {
|
||||
delete(sharedPromptPaths, name)
|
||||
}
|
||||
})
|
||||
|
||||
_, err := (PromptAssetManifest{
|
||||
ModuleDir: "dnd.test",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
||||
},
|
||||
SharedFiles: []string{name},
|
||||
}).PromptFS(fstest.MapFS{
|
||||
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "read shared prompt asset "+path) {
|
||||
t.Fatalf("PromptFS() error = %v, want missing shared asset context", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptAssetManifestHashMatchesManifestParts(t *testing.T) {
|
||||
moduleFS := fstest.MapFS{
|
||||
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
||||
"assets/prompts/task.md": {Data: []byte("task")},
|
||||
}
|
||||
manifest := PromptAssetManifest{
|
||||
ModuleDir: "dnd.test",
|
||||
ModuleFiles: []promptfs.ModulePromptFile{
|
||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||
},
|
||||
SharedFiles: []string{
|
||||
"common-dnd-transcript.md",
|
||||
"common-dnd-system.md",
|
||||
},
|
||||
}
|
||||
|
||||
got, err := manifest.Hash(moduleFS)
|
||||
if err != nil {
|
||||
t.Fatalf("Hash() error = %v, want nil", err)
|
||||
}
|
||||
want, err := llm.HashAssets([]llm.AssetHashPart{
|
||||
{FS: moduleFS, Path: "assets/prompts/dnd.test.yaml"},
|
||||
{FS: moduleFS, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HashAssets() error = %v, want nil", err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("Hash() = %q, want independently assembled manifest hash %q", got, want)
|
||||
}
|
||||
withUnused, err := llm.HashAssets([]llm.AssetHashPart{
|
||||
{FS: moduleFS, Path: "assets/prompts/dnd.test.yaml"},
|
||||
{FS: moduleFS, Path: "assets/prompts/task.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-transcript.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-system.md"},
|
||||
{FS: embeddedAssets, Path: "assets/prompts/common-dnd-npcs.md"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("HashAssets() with unused asset error = %v, want nil", err)
|
||||
}
|
||||
if got == withUnused {
|
||||
t.Fatalf("Hash() included an unlisted shared asset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedPromptDescriptorsReturnFreshCopies(t *testing.T) {
|
||||
first, err := resolveSharedPromptFiles([]string{"common-dnd-system.md"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSharedPromptFiles() error = %v, want nil", err)
|
||||
}
|
||||
second, err := resolveSharedPromptFiles([]string{"common-dnd-system.md"})
|
||||
if err != nil {
|
||||
t.Fatalf("resolveSharedPromptFiles() second error = %v, want nil", err)
|
||||
}
|
||||
first[0].Name = "changed.md"
|
||||
first[0].Path = "changed.md"
|
||||
if reflect.DeepEqual(first, second) || second[0].Name != "common-dnd-system.md" || second[0].Path != "assets/prompts/common-dnd-system.md" {
|
||||
t.Fatalf("resolveSharedPromptFiles() reused descriptor state: first=%#v second=%#v", first, second)
|
||||
}
|
||||
}
|
||||
|
||||
36
internal/modules/dnd/shared/citations.go
Normal file
36
internal/modules/dnd/shared/citations.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
// CitedText resolves cited source ranges in document order, including each
|
||||
// source unit once, and joins the resulting text with newlines.
|
||||
func CitedText(doc *source.SourceDocument, refs []source.SourceRef) (string, error) {
|
||||
if doc == nil {
|
||||
return "", fmt.Errorf("source document must not be nil")
|
||||
}
|
||||
|
||||
included := make([]bool, len(doc.Units))
|
||||
for _, ref := range refs {
|
||||
if err := source.ValidateRef(doc, ref); err != nil {
|
||||
return "", fmt.Errorf("resolve cited source range: %w", err)
|
||||
}
|
||||
start, _ := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, _ := source.UnitIndex(doc, ref.EndUnitID)
|
||||
for index := start; index <= end; index++ {
|
||||
included[index] = true
|
||||
}
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(doc.Units))
|
||||
for index, unit := range doc.Units {
|
||||
if included[index] {
|
||||
parts = append(parts, unit.Text)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n"), nil
|
||||
}
|
||||
87
internal/modules/dnd/shared/citations_test.go
Normal file
87
internal/modules/dnd/shared/citations_test.go
Normal file
@@ -0,0 +1,87 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestCitedText(t *testing.T) {
|
||||
doc := citationDocument()
|
||||
tests := []struct {
|
||||
name string
|
||||
refs []source.SourceRef
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "empty references", refs: nil, want: ""},
|
||||
{name: "invalid source id", refs: []source.SourceRef{{SourceID: "other", StartUnitID: 10, EndUnitID: 10}}, wantErr: true},
|
||||
{name: "unknown start unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 10}}, wantErr: true},
|
||||
{name: "unknown end unit", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 99}}, wantErr: true},
|
||||
{name: "reversed document range", refs: []source.SourceRef{{SourceID: "session", StartUnitID: 30, EndUnitID: 10}}, wantErr: true},
|
||||
{
|
||||
name: "disjoint ranges supplied out of order",
|
||||
refs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 30},
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 10},
|
||||
},
|
||||
want: "alpha\ngamma",
|
||||
},
|
||||
{
|
||||
name: "adjacent ranges",
|
||||
refs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 20},
|
||||
{SourceID: "session", StartUnitID: 30, EndUnitID: 40},
|
||||
},
|
||||
want: "alpha\nbeta\ngamma\ndelta",
|
||||
},
|
||||
{
|
||||
name: "overlapping and duplicate ranges",
|
||||
refs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 30},
|
||||
{SourceID: "session", StartUnitID: 20, EndUnitID: 40},
|
||||
{SourceID: "session", StartUnitID: 10, EndUnitID: 30},
|
||||
},
|
||||
want: "alpha\nbeta\ngamma\ndelta",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
beforeUnits := append([]source.SourceUnit(nil), doc.Units...)
|
||||
refs := append([]source.SourceRef(nil), test.refs...)
|
||||
got, err := CitedText(doc, refs)
|
||||
if (err != nil) != test.wantErr {
|
||||
t.Fatalf("CitedText() error = %v, want error = %t", err, test.wantErr)
|
||||
}
|
||||
if err == nil && got != test.want {
|
||||
t.Fatalf("CitedText() = %q, want %q", got, test.want)
|
||||
}
|
||||
if !reflect.DeepEqual(doc.Units, beforeUnits) || !reflect.DeepEqual(refs, test.refs) {
|
||||
t.Fatalf("CitedText() mutated document or references")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCitedTextRejectsNilDocument(t *testing.T) {
|
||||
if _, err := CitedText(nil, nil); err == nil {
|
||||
t.Fatal("CitedText() error = nil, want nil-document error")
|
||||
}
|
||||
}
|
||||
|
||||
func citationDocument() *source.SourceDocument {
|
||||
return &source.SourceDocument{
|
||||
ID: "session",
|
||||
Kind: "transcript",
|
||||
Format: "application/json",
|
||||
Digest: "sha256:session",
|
||||
Units: []source.SourceUnit{
|
||||
{ID: 10, Kind: "message", Text: "alpha"},
|
||||
{ID: 20, Kind: "message", Text: "beta"},
|
||||
{ID: 30, Kind: "message", Text: "gamma"},
|
||||
{ID: 40, Kind: "message", Text: "delta"},
|
||||
},
|
||||
}
|
||||
}
|
||||
30
internal/modules/dnd/shared/extraction_inputs.go
Normal file
30
internal/modules/dnd/shared/extraction_inputs.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
// ChunkPromptMaterial prepares the chunk-scoped source material used by D&D
|
||||
// extractors when constructing their prompt inputs.
|
||||
func ChunkPromptMaterial(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||
material := req.SourceInput.Clone()
|
||||
if len(material.Content) == 0 {
|
||||
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||
}
|
||||
if !bytes.Equal(material.Content, req.Chunk.Content) {
|
||||
return contracts.LLMInputMaterial{}, fmt.Errorf("source input must match chunk %q content", req.Chunk.ID)
|
||||
}
|
||||
if material.Name == "" {
|
||||
material.Name = "source"
|
||||
}
|
||||
if material.MediaType == "" {
|
||||
material.MediaType = req.Chunk.MediaType
|
||||
}
|
||||
if material.SizeBytes == 0 {
|
||||
material.SizeBytes = int64(len(material.Content))
|
||||
}
|
||||
return material, nil
|
||||
}
|
||||
101
internal/modules/dnd/shared/extraction_inputs_test.go
Normal file
101
internal/modules/dnd/shared/extraction_inputs_test.go
Normal file
@@ -0,0 +1,101 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestChunkPromptMaterial(t *testing.T) {
|
||||
chunk := &source.Chunk{
|
||||
ID: "session-alpha:chunk:0",
|
||||
Content: []byte(`{"units":[1,2]}`),
|
||||
MediaType: "application/json",
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
sourceInput contracts.LLMInputMaterial
|
||||
want contracts.LLMInputMaterial
|
||||
wantErr string
|
||||
mutateOutput bool
|
||||
}{
|
||||
{
|
||||
name: "fallback to chunk content",
|
||||
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "", ""),
|
||||
},
|
||||
{
|
||||
name: "clone isolation",
|
||||
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
|
||||
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
|
||||
mutateOutput: true,
|
||||
},
|
||||
{
|
||||
name: "mismatched content",
|
||||
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, []byte(`{"units":[9]}`), "sha256:other", "file:///other.json"),
|
||||
wantErr: "source input must match chunk",
|
||||
},
|
||||
{
|
||||
name: "default fields",
|
||||
sourceInput: contracts.LLMInputMaterial{
|
||||
Content: append([]byte(nil), chunk.Content...),
|
||||
Digest: "sha256:source",
|
||||
OriginURI: "file:///source.json",
|
||||
},
|
||||
want: contracts.LLMInputMaterial{
|
||||
Name: "source",
|
||||
MediaType: chunk.MediaType,
|
||||
Content: append([]byte(nil), chunk.Content...),
|
||||
Digest: "sha256:source",
|
||||
OriginURI: "file:///source.json",
|
||||
SizeBytes: int64(len(chunk.Content)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "preserve explicit metadata",
|
||||
sourceInput: contracts.LLMInputMaterial{
|
||||
Name: "transcript",
|
||||
MediaType: "text/plain",
|
||||
Content: append([]byte(nil), chunk.Content...),
|
||||
Digest: "sha256:explicit",
|
||||
OriginURI: "file:///explicit.txt",
|
||||
SizeBytes: 42,
|
||||
},
|
||||
want: contracts.LLMInputMaterial{
|
||||
Name: "transcript",
|
||||
MediaType: "text/plain",
|
||||
Content: append([]byte(nil), chunk.Content...),
|
||||
Digest: "sha256:explicit",
|
||||
OriginURI: "file:///explicit.txt",
|
||||
SizeBytes: 42,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := contracts.TypedExtractionRequest{Chunk: chunk, SourceInput: test.sourceInput}
|
||||
got, err := ChunkPromptMaterial(req)
|
||||
if test.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("ChunkPromptMaterial() error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ChunkPromptMaterial() error = %v, want nil", err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("ChunkPromptMaterial() = %#v, want %#v", got, test.want)
|
||||
}
|
||||
if test.mutateOutput {
|
||||
got.Content[0] = 'x'
|
||||
if string(test.sourceInput.Content) != string(chunk.Content) {
|
||||
t.Fatalf("ChunkPromptMaterial() output shares content with source input")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
43
internal/modules/dnd/shared/matching.go
Normal file
43
internal/modules/dnd/shared/matching.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
)
|
||||
|
||||
// NormalizedTokens returns identity-normalized alphanumeric tokens from a
|
||||
// D&D value.
|
||||
func NormalizedTokens(value string) []string {
|
||||
value = identity.ComparisonKey(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
|
||||
// ContainsTokenSequence reports whether value contains the complete normalized
|
||||
// query token sequence as consecutive tokens.
|
||||
func ContainsTokenSequence(value string, query string) bool {
|
||||
valueTokens := NormalizedTokens(value)
|
||||
queryTokens := NormalizedTokens(query)
|
||||
if len(queryTokens) == 0 || len(queryTokens) > len(valueTokens) {
|
||||
return false
|
||||
}
|
||||
for start := 0; start <= len(valueTokens)-len(queryTokens); start++ {
|
||||
matches := true
|
||||
for offset, token := range queryTokens {
|
||||
if valueTokens[start+offset] != token {
|
||||
matches = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matches {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
25
internal/modules/dnd/shared/matching_test.go
Normal file
25
internal/modules/dnd/shared/matching_test.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package shared
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestContainsTokenSequence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
query string
|
||||
want bool
|
||||
}{
|
||||
{name: "unicode apostrophe and case", value: "O’rIn\u2003ThOrN advances", query: "o'rin thorn", want: true},
|
||||
{name: "multiword sequence", value: "Mira Thorn watches", query: "mira thorn", want: true},
|
||||
{name: "nonconsecutive words", value: "Mira watches Thorn", query: "mira thorn", want: false},
|
||||
{name: "short name is not substring", value: "A cart rolls past", query: "art", want: false},
|
||||
{name: "empty query", value: "anything", query: " ", want: false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
if got := ContainsTokenSequence(test.value, test.query); got != test.want {
|
||||
t.Fatalf("ContainsTokenSequence(%q, %q) = %t, want %t", test.value, test.query, got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,57 +1,107 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestPromptInputsBuildExpectedInputs(t *testing.T) {
|
||||
func TestPromptInputsIdentity(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{
|
||||
baseReferences := 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{
|
||||
orderedReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"players": {Items: []contracts.ReferenceItem{
|
||||
referenceItem("players", "file:///b.txt", "sha256:bbb", "second"),
|
||||
referenceItem("players", "file:///a.txt", "sha256:aaa", "first"),
|
||||
}},
|
||||
}}
|
||||
reversedReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"players": {Items: []contracts.ReferenceItem{
|
||||
referenceItem("players", "file:///a.txt", "sha256:aaa", "first"),
|
||||
referenceItem("players", "file:///b.txt", "sha256:bbb", "second"),
|
||||
}},
|
||||
}}
|
||||
rosterReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"party": {},
|
||||
"roster": slotWithContent("roster", "Legacy roster text"),
|
||||
}})
|
||||
}}
|
||||
explicitPartyReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
"party": slotWithContent("party", "Current party text"),
|
||||
"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)
|
||||
tests := []struct {
|
||||
name string
|
||||
references contracts.ReferenceSet
|
||||
other *contracts.ReferenceSet
|
||||
want map[string]string
|
||||
}{
|
||||
{
|
||||
name: "identical materials produce deeply equal inputs",
|
||||
references: baseReferences,
|
||||
other: &baseReferences,
|
||||
want: map[string]string{
|
||||
"transcript": "source text",
|
||||
"players": "Alice: Aria",
|
||||
"party": "Aria: cleric",
|
||||
"glossary": "Brightmantle: temple",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "reference insertion order does not change bytes",
|
||||
references: orderedReferences,
|
||||
other: &reversedReferences,
|
||||
want: map[string]string{"players": "Reference 1\nOrigin-URI: file:///a.txt\nDigest: sha256:aaa\n\nfirst\n\nReference 2\nOrigin-URI: file:///b.txt\nDigest: sha256:bbb\n\nsecond"},
|
||||
},
|
||||
{
|
||||
name: "roster becomes canonical party input",
|
||||
references: rosterReferences,
|
||||
want: map[string]string{"party": "Legacy roster text"},
|
||||
},
|
||||
{
|
||||
name: "explicit party wins over roster",
|
||||
references: explicitPartyReferences,
|
||||
want: map[string]string{"party": "Current party text"},
|
||||
},
|
||||
{
|
||||
name: "missing optional slots use placeholders",
|
||||
references: contracts.ReferenceSet{},
|
||||
want: map[string]string{
|
||||
"players": " ",
|
||||
"party": " ",
|
||||
"glossary": " ",
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, ok := inputs["roster"]; ok {
|
||||
t.Fatalf("PromptInputs() included roster input: %#v", inputs)
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
inputs := PromptInputs(source, test.references)
|
||||
if test.other != nil {
|
||||
other := PromptInputs(source, *test.other)
|
||||
if !reflect.DeepEqual(inputs, other) {
|
||||
t.Fatalf("PromptInputs() differs for equivalent references:\nfirst=%#v\nsecond=%#v", inputs, other)
|
||||
}
|
||||
}
|
||||
for name, want := range test.want {
|
||||
got, ok := inputs[name]
|
||||
if !ok {
|
||||
t.Fatalf("PromptInputs() missing %q: %#v", name, inputs)
|
||||
}
|
||||
if got := string(got.Content); got != want {
|
||||
t.Fatalf("%s content = %q, want %q", name, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := inputs["roster"]; ok {
|
||||
t.Fatalf("PromptInputs() included roster input: %#v", inputs)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,3 +199,12 @@ func slotWithContent(name string, content string) contracts.ResolvedReferenceSlo
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func referenceItem(slotName, uri, digest, content string) contracts.ReferenceItem {
|
||||
return contracts.ReferenceItem{
|
||||
SlotName: slotName,
|
||||
Content: []byte(content),
|
||||
Digest: digest,
|
||||
Origin: contracts.ReferenceOrigin{URI: uri},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -53,6 +54,22 @@ func TestValidatorDefersMalformedShape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorBoundsAggregateDiagnostics(t *testing.T) {
|
||||
value := validCombatTurnList()
|
||||
value.CombatTurns[0].SourceRefs = make([]source.SourceRef, 24)
|
||||
for index := range value.CombatTurns[0].SourceRefs {
|
||||
value.CombatTurns[0].SourceRefs[index] = source.SourceRef{SourceID: "session", StartUnitID: 99 + index, EndUnitID: 99 + index}
|
||||
}
|
||||
value.CombatTurns[0].SourceRefs[0].SourceID = strings.Repeat("火", 220) + "\n\t"
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: validDocument(), Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) {
|
||||
t.Fatalf("Validate() = %#v, %v; want bounded aggregate rejection", result, err)
|
||||
}
|
||||
if strings.Count(result.Message, "combat_turns[0].source_refs[") != 20 || !strings.Contains(result.Message, "source_refs[19]") || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, "…") {
|
||||
t.Fatalf("message = %q, want all bounded aggregate diagnostics", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v, want source-reference policy", got)
|
||||
|
||||
@@ -3,15 +3,12 @@ package sourcerelatedness
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
)
|
||||
@@ -38,13 +35,21 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.CombatTurnList]) (contracts.ValidationResult, error) {
|
||||
if combatshape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
|
||||
if combatshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
citedTexts := make([]string, len(req.Value.CombatTurns))
|
||||
for turnIndex, turn := range req.Value.CombatTurns {
|
||||
citedText, err := shared.CitedText(req.Source, turn.SourceRefs)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
citedTexts[turnIndex] = citedText
|
||||
}
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for turnIndex, turn := range req.Value.CombatTurns {
|
||||
citedText := citedTextKey(req.Source, turn.SourceRefs)
|
||||
citedText := citedTexts[turnIndex]
|
||||
issues := make([]string, 0)
|
||||
if !actorAppearsInCitedText(citedText, turn.Actor) {
|
||||
issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(turn.Actor)))
|
||||
@@ -65,47 +70,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool {
|
||||
for _, turn := range value.CombatTurns {
|
||||
for _, ref := range turn.SourceRefs {
|
||||
if source.ValidateRef(doc, ref) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string {
|
||||
if doc == nil {
|
||||
return ""
|
||||
}
|
||||
included := make([]bool, len(doc.Units))
|
||||
for _, ref := range refs {
|
||||
start, _ := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, _ := source.UnitIndex(doc, ref.EndUnitID)
|
||||
for index := start; index <= end && index < len(included); index++ {
|
||||
included[index] = true
|
||||
}
|
||||
}
|
||||
parts := make([]string, 0)
|
||||
for index, unit := range doc.Units {
|
||||
if included[index] {
|
||||
parts = append(parts, unit.Text)
|
||||
}
|
||||
}
|
||||
return identity.ComparisonKey(strings.Join(parts, " "))
|
||||
}
|
||||
|
||||
func actorAppearsInCitedText(citedText string, actor string) bool {
|
||||
key := identity.ComparisonKey(actor)
|
||||
return key != "" && strings.Contains(citedText, key)
|
||||
return shared.ContainsTokenSequence(citedText, actor)
|
||||
}
|
||||
|
||||
func declarationAppearsInCitedText(citedText string, declaration string) bool {
|
||||
citedTokens := tokenSet(citedText)
|
||||
for _, token := range comparisonTokens(declaration) {
|
||||
for _, token := range shared.NormalizedTokens(declaration) {
|
||||
if utf8.RuneCountInString(token) >= 4 {
|
||||
if _, ok := citedTokens[token]; ok {
|
||||
return true
|
||||
@@ -115,16 +86,8 @@ func declarationAppearsInCitedText(citedText string, declaration string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func comparisonTokens(value string) []string {
|
||||
value = identity.ComparisonKey(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return strings.FieldsFunc(value, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) })
|
||||
}
|
||||
|
||||
func tokenSet(value string) map[string]struct{} {
|
||||
tokens := comparisonTokens(value)
|
||||
tokens := shared.NormalizedTokens(value)
|
||||
set := make(map[string]struct{}, len(tokens))
|
||||
for _, token := range tokens {
|
||||
set[token] = struct{}{}
|
||||
|
||||
@@ -51,6 +51,20 @@ func TestValidatorWarnsOncePerTurnForUnrelatedActorAndActions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDoesNotMatchShortActorSubstring(t *testing.T) {
|
||||
resolution := "The cart is struck."
|
||||
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{
|
||||
Actor: "Art", TurnKind: dnd.CombatTurnKindTurn,
|
||||
Actions: []dnd.CombatAction{{Category: dnd.CombatActionCategoryAttack, Declaration: "cart attacks", Targets: []string{}, Resolution: &resolution}},
|
||||
Summary: "The cart attacks.", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The cart attacks."}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: doc, Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 || !strings.Contains(result.Warnings[0].Message, "actor") {
|
||||
t.Fatalf("Validate() = %#v, %v; want short-actor boundary warning", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
||||
invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape})
|
||||
|
||||
@@ -3,13 +3,11 @@ package sourcerelatedness
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
||||
)
|
||||
@@ -39,9 +37,17 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if err := npcshape.Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
citedTexts := make([]string, len(req.Value.NPCs))
|
||||
for npcIndex, npc := range req.Value.NPCs {
|
||||
citedText, err := shared.CitedText(req.Source, npc.SourceRefs)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
citedTexts[npcIndex] = citedText
|
||||
}
|
||||
var warnings []contracts.Warning
|
||||
for npcIndex, npc := range req.Value.NPCs {
|
||||
if npcAppearsInCitedText(req.Source, npc) {
|
||||
if npcAppearsInCitedText(citedTexts[npcIndex], npc) {
|
||||
continue
|
||||
}
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
@@ -53,43 +59,18 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||
}
|
||||
|
||||
func npcAppearsInCitedText(doc *source.SourceDocument, npc dnd.NPC) bool {
|
||||
cited := citedTextKey(doc, npc.SourceRefs)
|
||||
if cited == "" {
|
||||
return false
|
||||
}
|
||||
if strings.Contains(cited, identity.ComparisonKey(npc.Name)) {
|
||||
func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
|
||||
if shared.ContainsTokenSequence(citedText, npc.Name) {
|
||||
return true
|
||||
}
|
||||
for _, alias := range npc.Aliases {
|
||||
if strings.Contains(cited, identity.ComparisonKey(alias)) {
|
||||
if shared.ContainsTokenSequence(citedText, alias) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func citedTextKey(doc *source.SourceDocument, refs []source.SourceRef) string {
|
||||
if doc == nil {
|
||||
return ""
|
||||
}
|
||||
var builder strings.Builder
|
||||
for _, ref := range refs {
|
||||
if err := source.ValidateRef(doc, ref); err != nil {
|
||||
continue
|
||||
}
|
||||
start, _ := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, _ := source.UnitIndex(doc, ref.EndUnitID)
|
||||
for index := start; index <= end; index++ {
|
||||
if builder.Len() > 0 {
|
||||
builder.WriteByte(' ')
|
||||
}
|
||||
builder.WriteString(doc.Units[index].Text)
|
||||
}
|
||||
}
|
||||
return identity.ComparisonKey(builder.String())
|
||||
}
|
||||
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,11 @@ import (
|
||||
|
||||
func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) {
|
||||
value := dnd.NPCList{NPCs: []dnd.NPC{
|
||||
{ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}}},
|
||||
{ID: "one", Name: "O'Rin Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
|
||||
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
|
||||
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
|
||||
}},
|
||||
{ID: "two", Name: "Missing Name", Aliases: []string{"The Greencloak"}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 2, EndUnitID: 2}}},
|
||||
}}
|
||||
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{
|
||||
@@ -41,6 +45,17 @@ func TestValidatorWarnsAtMostOncePerNPCForUnrelatedCitations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDoesNotMatchShortNameSubstring(t *testing.T) {
|
||||
value := dnd.NPCList{NPCs: []dnd.NPC{{
|
||||
ID: "one", Name: "Art", Aliases: []string{}, Description: "A guard.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 1, EndUnitID: 1}},
|
||||
}}}
|
||||
doc := &source.SourceDocument{ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session", Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "A cart rolls past."}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: doc, Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||
t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
|
||||
invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidShape})
|
||||
@@ -49,8 +64,8 @@ func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
|
||||
}
|
||||
invalidRange := dnd.NPCList{NPCs: []dnd.NPC{{ID: "one", Name: "Mira Thorn", Aliases: []string{}, Description: "A ranger.", Relationships: []dnd.NPCRelationship{}, SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 99, EndUnitID: 99}}}}}
|
||||
result, err = New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidRange})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != WarningReasonCode {
|
||||
t.Fatalf("invalid-range relatedness = %#v, %v; want one warning", result, err)
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
t.Fatalf("invalid-range relatedness = %#v, %v; want approval without warning", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,19 +10,26 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
)
|
||||
|
||||
const Key = "extract/dnd/spells/shape"
|
||||
const ReasonCode = "invalid_spell_shape"
|
||||
const (
|
||||
Key = "extract/dnd/spells/shape"
|
||||
ReasonCode = "invalid_spell_shape"
|
||||
policy = "dnd.spells.validator.shape.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
func (v *Validator) Name() string { return Key }
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||
if err := Validate(req.Value); err != nil {
|
||||
return rejection(err.Error()), nil
|
||||
|
||||
@@ -48,7 +48,13 @@ func TestValidatorRejectsMissingRequiredSpellFields(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAndRegister(t *testing.T) {
|
||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||
}
|
||||
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v, want deterministic shape validator", spec)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
|
||||
@@ -8,33 +8,45 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
)
|
||||
|
||||
const Key = "extract/dnd/spells/source_refs"
|
||||
const ReasonCode = "invalid_source_refs"
|
||||
const (
|
||||
Key = "extract/dnd/spells/source_refs"
|
||||
ReasonCode = "invalid_source_refs"
|
||||
policy = "dnd.spells.validator.source_refs.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
func (v *Validator) Name() string { return Key }
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||
if err := spellshape.Validate(req.Value); err != nil {
|
||||
return rejection(err.Error()), nil
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
issues := make([]string, 0)
|
||||
for spellIndex, spell := range req.Value.SpellCasts {
|
||||
for refIndex, ref := range spell.SourceRefs {
|
||||
if err := source.ValidateRef(req.Source, ref); err != nil {
|
||||
return rejection(fmt.Sprintf("spell_casts[%d].source_refs[%d]: %v", spellIndex, refIndex, err)), nil
|
||||
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(issues) > 0 {
|
||||
return rejection(diagnostics.Aggregate("invalid spell source references", issues)), nil
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
|
||||
@@ -2,7 +2,9 @@ package sourcerefs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
@@ -46,7 +48,37 @@ func TestValidatorRejectsMissingSourceDocument(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAndRegister(t *testing.T) {
|
||||
func TestValidatorDefersMalformedShape(t *testing.T) {
|
||||
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "Cure Wounds"}}}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: value})
|
||||
if err != nil || !result.Approved || result.ReasonCode != "" || result.Message != "" {
|
||||
t.Fatalf("Validate() = %#v, %v; want shape deferral", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorAggregatesAndBoundsInvalidSourceReferences(t *testing.T) {
|
||||
value := requestWithValue(validDocument(), source.SourceRef{SourceID: "session", StartUnitID: 1, EndUnitID: 1}).Value
|
||||
value.SpellCasts[0].SourceRefs = make([]source.SourceRef, 24)
|
||||
for index := range value.SpellCasts[0].SourceRefs {
|
||||
value.SpellCasts[0].SourceRefs[index] = source.SourceRef{SourceID: "session", StartUnitID: 99 + index, EndUnitID: 99 + index}
|
||||
}
|
||||
value.SpellCasts[0].SourceRefs[0].SourceID = strings.Repeat("火", 220) + "\n\t"
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: value})
|
||||
if err != nil || result.Approved || result.ReasonCode != ReasonCode || len([]byte(result.Message)) > 4096 || !utf8.ValidString(result.Message) {
|
||||
t.Fatalf("Validate() = %#v, %v; want bounded aggregate rejection", result, err)
|
||||
}
|
||||
if strings.Count(result.Message, "spell_casts[") != 20 || !strings.Contains(result.Message, "source_refs[19]") || !strings.Contains(result.Message, "additional issue(s) omitted") || !strings.Contains(result.Message, "…") {
|
||||
t.Fatalf("message = %q, want all bounded aggregate diagnostics", result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||
}
|
||||
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v, want deterministic source-reference validator", spec)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
|
||||
@@ -5,70 +5,59 @@ import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||
)
|
||||
|
||||
const Key = "extract/dnd/spells/source_relatedness"
|
||||
const WarningReasonCode = "spell_not_near_source"
|
||||
const (
|
||||
Key = "extract/dnd/spells/source_relatedness"
|
||||
WarningReasonCode = "spell_not_near_source"
|
||||
policy = "dnd.spells.validator.source_relatedness.v1"
|
||||
)
|
||||
|
||||
type Options struct{}
|
||||
type Validator struct{}
|
||||
|
||||
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
||||
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||
|
||||
func New(Options) *Validator { return &Validator{} }
|
||||
func (v *Validator) Name() string { return Key }
|
||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||
return contracts.ExecutionClassDeterministic
|
||||
}
|
||||
func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}}
|
||||
}
|
||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||
if err := spellshape.Validate(req.Value); err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
citedTexts := make([]string, len(req.Value.SpellCasts))
|
||||
for spellIndex, spell := range req.Value.SpellCasts {
|
||||
citedText, err := shared.CitedText(req.Source, spell.SourceRefs)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
citedTexts[spellIndex] = citedText
|
||||
}
|
||||
var warnings []contracts.Warning
|
||||
for spellIndex, spell := range req.Value.SpellCasts {
|
||||
if !spellAppearsInCitedText(req.Source, spell) {
|
||||
if !spellAppearsInCitedText(citedTexts[spellIndex], spell) {
|
||||
warnings = append(warnings, contracts.Warning{Scope: fmt.Sprintf("spell_casts[%d]", spellIndex), ReasonCode: WarningReasonCode, Message: fmt.Sprintf("spell %q was not found in cited source text", strings.TrimSpace(spell.Spell))})
|
||||
}
|
||||
}
|
||||
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||
}
|
||||
func spellAppearsInCitedText(doc *source.SourceDocument, spell dnd.SpellCast) bool {
|
||||
name := strings.ToLower(strings.TrimSpace(spell.Spell))
|
||||
func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool {
|
||||
name := strings.TrimSpace(spell.Spell)
|
||||
if name == "" {
|
||||
return true
|
||||
}
|
||||
for _, ref := range spell.SourceRefs {
|
||||
if text, ok := citedText(doc, ref); ok && strings.Contains(strings.ToLower(text), name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func citedText(doc *source.SourceDocument, ref source.SourceRef) (string, bool) {
|
||||
if doc == nil {
|
||||
return "", false
|
||||
}
|
||||
start, ok := source.UnitIndex(doc, ref.StartUnitID)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
end, ok := source.UnitIndex(doc, ref.EndUnitID)
|
||||
if !ok || start > end {
|
||||
return "", false
|
||||
}
|
||||
var b strings.Builder
|
||||
for i := start; i <= end; i++ {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
b.WriteString(doc.Units[i].Text)
|
||||
}
|
||||
return b.String(), true
|
||||
return shared.ContainsTokenSequence(citedText, name)
|
||||
}
|
||||
func Spec() pipeline.ValidatorSpec {
|
||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||
|
||||
@@ -39,6 +39,47 @@ func TestValidatorWarnsWhenSpellDoesNotAppearInCitedText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorMatchesCaseInsensitiveUnicodeMultiwordSpellAcrossCitations(t *testing.T) {
|
||||
value := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||
Caster: "Aria", Spell: "Tasha's Hideous Laughter", Effect: "effect", NarrativeDescription: "description",
|
||||
SourceRefs: []source.SourceRef{
|
||||
{SourceID: "session", StartUnitID: 2, EndUnitID: 2},
|
||||
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
|
||||
{SourceID: "session", StartUnitID: 1, EndUnitID: 2},
|
||||
},
|
||||
}}}
|
||||
doc := &source.SourceDocument{
|
||||
ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session",
|
||||
Units: []source.SourceUnit{
|
||||
{ID: 1, Kind: "message", Text: "Tasha’s"},
|
||||
{ID: 2, Kind: "message", Text: "hideous laughter fills the room."},
|
||||
},
|
||||
}
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: doc, Value: value})
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
t.Fatalf("Validate() = %#v, %v; want normalized multiword spell approval", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorDoesNotMatchShortSpellNameSubstring(t *testing.T) {
|
||||
result, err := New(Options{}).Validate(context.Background(), requestWithSpell(&source.SourceDocument{
|
||||
ID: "session", Kind: "transcript", Format: "application/json", Digest: "sha256:session",
|
||||
Units: []source.SourceUnit{{ID: 1, Kind: "message", Text: "The party said nothing."}},
|
||||
}, "Aid", 1))
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 1 {
|
||||
t.Fatalf("Validate() = %#v, %v; want short-name boundary warning", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorIgnoresInvalidCitations(t *testing.T) {
|
||||
request := requestWithSpell(validDocument(), "Cure Wounds", 2)
|
||||
request.Value.SpellCasts[0].SourceRefs[0].StartUnitID = 99
|
||||
result, err := New(Options{}).Validate(context.Background(), request)
|
||||
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||
t.Fatalf("Validate() = %#v, %v; want approval without relatedness warning", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) {
|
||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}})
|
||||
if err != nil {
|
||||
@@ -49,7 +90,13 @@ func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecAndRegister(t *testing.T) {
|
||||
func TestValidatorSpecCheckpointAndRegistration(t *testing.T) {
|
||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
||||
t.Fatalf("CheckpointFingerprints() = %#v, want local policy", got)
|
||||
}
|
||||
if spec := Spec(); spec.Key != Key || spec.ExecutionClass != contracts.ExecutionClassDeterministic {
|
||||
t.Fatalf("Spec() = %#v, want deterministic relatedness validator", spec)
|
||||
}
|
||||
registry := pipeline.NewValidatorRegistry()
|
||||
if err := Register(registry); err != nil {
|
||||
t.Fatalf("Register() error = %v, want nil", err)
|
||||
|
||||
Reference in New Issue
Block a user