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
|
filesystem composition helper used to combine module-owned files with shared
|
||||||
domain prompt fragments.
|
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,
|
Schema helpers load embedded JSON Schema with identity and digest metadata,
|
||||||
return defensive copies, and expose a diagnostics map that omits schema bytes.
|
return defensive copies, and expose a diagnostics map that omits schema bytes.
|
||||||
The small framework registry contains only generic test schemas; production
|
The small framework registry contains only generic test schemas; production
|
||||||
schemas remain package-owned.
|
schemas remain package-owned.
|
||||||
|
|
||||||
The spell and combat extractors' package-owned prompts declare their structured
|
The spell, NPC, and combat extractors' package-owned prompts declare their
|
||||||
JSON inputs and private response schemas. The combat private schema owns the
|
structured JSON inputs and private response schemas. Each private response
|
||||||
transport envelope—required fields, JSON types, nullability, and unknown-field
|
schema remains separate from its durable artifact codec schema; this work does
|
||||||
rejection—while its deterministic validators own semantic constraints such as
|
not use shared schema fragments or schema generation. The combat private schema
|
||||||
enum membership, non-empty values and collections, and positive numbers. The
|
owns the transport envelope—required fields, JSON types, nullability, and
|
||||||
spell extractor's prompt declares a required `application/json` `spell_catalog`
|
unknown-field rejection—while its deterministic validators own semantic
|
||||||
input and an optional `application/json` `npcs` input. The extractor generates
|
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
|
the catalog input from its prepared
|
||||||
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
effective catalog as `{"spell_names":[...]}` using sorted canonical names only.
|
||||||
The shared D&D prompt assets include a generic NPC grounding fragment directly
|
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
|
after the campaign reference message for spell and combat prompts. When an NPC
|
||||||
is bound, the
|
registry is bound, the
|
||||||
domain registry boundary strictly decodes and identity-validates one durable
|
domain registry boundary strictly decodes and identity-validates one durable
|
||||||
artifact, re-encodes canonical JSON, and generates a semantic digest over
|
artifact, re-encodes canonical JSON, and generates a semantic digest over
|
||||||
those bytes. The unbound input is exactly `{"npcs":[]}`. Input digests cover
|
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
|
LLM-backed extensions own their prompt definitions and response schemas under
|
||||||
package-local embedded assets. Shared filesystem composition belongs in
|
package-local embedded assets. Shared filesystem composition belongs in
|
||||||
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
||||||
declarations, prompt-input assembly, and source-unit helpers belong in
|
declarations, prompt-input assembly, and source-unit/citation helpers belong in
|
||||||
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. Stage
|
`internal/modules/dnd/shared`, which also owns bounded D&D diagnostics. The
|
||||||
contracts expose only Notarius structured-
|
D&D scene chunker and spell, NPC, and combat-turn extractors use ordered
|
||||||
completion types, not Scriptorium public types.
|
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
|
Reference material may inform a module or prompt but must not become source
|
||||||
evidence. The resolver and materializer behavior is described in
|
evidence. The resolver and materializer behavior is described in
|
||||||
@@ -182,15 +199,14 @@ source evidence.
|
|||||||
|
|
||||||
The prompt limits each cast to its declaration and immediate resolution; it
|
The prompt limits each cast to its declaration and immediate resolution; it
|
||||||
does not follow summoned creatures, persistent effects, or other downstream
|
does not follow summoned creatures, persistent effects, or other downstream
|
||||||
consequences through the scene. Source references must collectively support all
|
consequences through the scene. Shared extraction-evidence and identity rules
|
||||||
reported facts, using separate narrow ranges when immediate evidence is
|
require transcript-supported factual claims and the most specific in-world
|
||||||
non-contiguous. The prompt grounds caster names in in-world identities, using
|
caster identity, while campaign references only disambiguate source text.
|
||||||
the player and party references to disambiguate transcript speakers without
|
Effects describe the session as played: model rules knowledge cannot supplement
|
||||||
treating those references as event evidence. Effects describe the session as
|
or correct the transcript, and nonstandard adjudication is attributed to the GM
|
||||||
played: model rules knowledge cannot supplement or correct the transcript, and
|
or table rather than stated as a universal rule. Structural source validation
|
||||||
nonstandard adjudication is attributed to the GM or table rather than stated as
|
remains deterministic; semantic claim completeness is enforced through
|
||||||
a universal rule. Structural source validation remains deterministic; semantic
|
extraction policy and evaluation.
|
||||||
claim completeness is enforced through extraction policy and evaluation.
|
|
||||||
|
|
||||||
Both the extractor and deterministic catalog validator expose
|
Both the extractor and deterministic catalog validator expose
|
||||||
the effective base-plus-overlay semantic digest as scoped prepared-component
|
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
|
assigns source identity and deterministic NPC IDs, and preserves source
|
||||||
references for deterministic validation. It uses the shared campaign
|
references for deterministic validation. It uses the shared campaign
|
||||||
references only for disambiguation and does not consume the optional NPC
|
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`
|
### `internal/modules/dnd/extract/combatturns`
|
||||||
|
|
||||||
The combat extractor prepares one structured request per supplied chunk using
|
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
|
maps the private response to `dnd.CombatTurnList`, assigns the current source
|
||||||
identity, removes exact duplicate source ranges, and orders turns by valid
|
identity, removes exact duplicate source ranges, and orders turns by valid
|
||||||
source-document position while preserving malformed candidate fields for
|
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
|
only the structural JSON envelope; semantic artifact constraints remain with
|
||||||
the validator chain. Its prepared metadata and checkpoint fingerprints contain
|
the validator chain. Its prepared metadata and checkpoint fingerprints contain
|
||||||
only prompt/schema/mapping identities plus an optional NPC registry digest.
|
only prompt/schema/mapping identities plus an optional NPC registry digest.
|
||||||
The package exposes typed registration and is included in the production D&D
|
The prompt renders immediate resolution and the NPC registry before the
|
||||||
registrar with the default combat extraction chain.
|
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.
|
The combat normalizer accepts only the optional structured NPC registry.
|
||||||
Campaign references remain extractor-only LLM context and are not materialized
|
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
|
against the immutable effective SRD and overlay catalog. It accepts normalized
|
||||||
canonical names and aliases without rewriting the artifact; unknown names
|
canonical names and aliases without rewriting the artifact; unknown names
|
||||||
reject the complete result with bounded, stable index/name diagnostics. The
|
reject the complete result with bounded, stable index/name diagnostics. The
|
||||||
source-reference validator applies generic source-reference validation to every
|
source-reference validator defers malformed shapes, validates every cited
|
||||||
cited range. The relatedness validator warns when a case-insensitive spell name
|
range, and reports all range defects through a bounded aggregate while
|
||||||
is absent from all cited source text.
|
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
|
These validators are deterministic. Shape, source-reference, and relatedness
|
||||||
are defined in
|
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
|
[Configuration](../config.md#implemented-production-validators); their durable
|
||||||
payload rules are defined in the
|
payload rules are defined in the
|
||||||
[artifact contract](../integrations/dnd-spell-artifacts.md).
|
[artifact contract](../integrations/dnd-spell-artifacts.md).
|
||||||
@@ -351,10 +382,13 @@ payload rules are defined in the
|
|||||||
## D&D NPC Validators
|
## D&D NPC Validators
|
||||||
|
|
||||||
NPC shape validation checks required strings, arrays, and source-reference
|
NPC shape validation checks required strings, arrays, and source-reference
|
||||||
shape. The source-reference validator checks current-document identity, unit
|
shape. The source-reference validator defers malformed shapes, checks
|
||||||
existence, and range ordering; source relatedness emits at most one bounded
|
current-document identity, unit existence, and range ordering, and reports all
|
||||||
warning per record when neither the canonical name nor an alias occurs near
|
defects through bounded aggregates. Source relatedness uses the shared
|
||||||
its cited text. Normalize identity validation checks deterministic IDs,
|
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.
|
canonical names, aliases, and cross-record ownership or canonical collisions.
|
||||||
All are deterministic and expose the policy fingerprints used by the
|
All are deterministic and expose the policy fingerprints used by the
|
||||||
production chains.
|
production chains.
|
||||||
@@ -363,10 +397,13 @@ production chains.
|
|||||||
|
|
||||||
Combat shape validation owns required arrays, strings, nullable values, positive
|
Combat shape validation owns required arrays, strings, nullable values, positive
|
||||||
rounds, and supported enums. Combat source-reference validation defers invalid
|
rounds, and supported enums. Combat source-reference validation defers invalid
|
||||||
shape and checks source identity, unit existence, and range order. Combat
|
shape, checks source identity, unit existence, and range order, and reports all
|
||||||
source-relatedness defers invalid shape or ranges, combines overlapping cited
|
defects through bounded aggregates. Combat source-relatedness defers invalid
|
||||||
units in document order, and emits at most one bounded advisory warning per
|
shape or ranges, uses the shared traversal to combine overlapping cited units
|
||||||
turn for unrelated actor or declaration text. The normalized-invariants
|
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
|
validator owns display normalization, comparison-unique targets, canonical
|
||||||
source-reference order, chronology, and exact duplicate identity; it defers
|
source-reference order, chronology, and exact duplicate identity; it defers
|
||||||
shape and source-reference failures. All four validators are deterministic and
|
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
|
Seriatim registrars own their production leaf registrations. The D&D registrar
|
||||||
owns D&D leaf registrations, typed spell, NPC, and combat default-validator
|
owns D&D leaf registrations, typed spell, NPC, and combat default-validator
|
||||||
chains, typed append-order specializations, and D&D prompt/schema asset
|
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
|
Concrete implementation packages do not import generic implementation
|
||||||
packages directly. A concrete family's `register` package is its composition
|
packages directly. A concrete family's `register` package is its composition
|
||||||
|
|||||||
@@ -1,180 +1,35 @@
|
|||||||
# D&D Module Harmonization And Prompt Reuse
|
# D&D Module Harmonization And Prompt Reuse
|
||||||
|
|
||||||
This roadmap records proposed improvements to the spell, NPC, and combat-turn
|
This roadmap tracked harmonization of the spell, NPC, and combat-turn lanes.
|
||||||
pipeline lanes. Current implemented behavior remains documented in the
|
The implemented behavior is now owned by [Module Internals](../internal/modules.md)
|
||||||
[module internals](../internal/modules.md) and
|
and [LLM Runtime Internals](../internal/llm.md); this document records status
|
||||||
[LLM runtime internals](../internal/llm.md). The work below is not yet an
|
and the one remaining external prerequisite rather than duplicating those
|
||||||
implemented contract.
|
current-behavior references.
|
||||||
|
|
||||||
The three lanes already share the same overall decomposition: a typed
|
## Implementation Status
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
## Deferred evaluation
|
||||||
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.
|
|
||||||
|
|
||||||
The target prompt layout has the longest valid common prefix before any
|
An optional live before/after provider comparison remains deferred because it
|
||||||
module-specific message:
|
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
|
||||||
- Put shared static system, extraction-evidence, and in-world-identity policy
|
prerequisites become available, record only aggregate extraction-review
|
||||||
before dynamic transcript and campaign-reference messages so reuse does not
|
results, prompt token counts, cache-hit/cache-write metrics, and non-secret
|
||||||
depend on identical transcript content.
|
prompt hashes. Never commit transcript content, rendered prompts, credentials,
|
||||||
- Keep the shared transcript and campaign-reference messages in the same roles
|
endpoints, or private reference material.
|
||||||
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.
|
|
||||||
|
|||||||
@@ -11,12 +11,22 @@ import (
|
|||||||
|
|
||||||
const scriptoriumPromptRoot = "assets/prompts"
|
const scriptoriumPromptRoot = "assets/prompts"
|
||||||
|
|
||||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
var promptAssetManifest = shared.PromptAssetManifest{
|
||||||
promptFS, err := shared.ModulePromptFS("dnd.scenes", embeddedAssets, []promptfs.ModulePromptFile{
|
ModuleDir: "dnd.scenes",
|
||||||
|
ModuleFiles: []promptfs.ModulePromptFile{
|
||||||
{Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"},
|
{Name: "dnd.scenes.yaml", Path: "assets/prompts/dnd.scenes.yaml"},
|
||||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||||
})
|
},
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("prepare scene prompt assets: %w", err)
|
return fmt.Errorf("prepare scene prompt assets: %w", err)
|
||||||
}
|
}
|
||||||
@@ -28,12 +38,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
|||||||
|
|
||||||
func scriptoriumPromptMetadata() (string, error) {
|
func scriptoriumPromptMetadata() (string, error) {
|
||||||
scriptoriumPromptHashOnce.Do(func() {
|
scriptoriumPromptHashOnce.Do(func() {
|
||||||
parts := append([]llm.AssetHashPart{
|
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||||
{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)
|
|
||||||
})
|
})
|
||||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,29 +18,29 @@ func TestScriptoriumPromptPreparesTranscriptAndTaskMessages(t *testing.T) {
|
|||||||
if prepared.PromptID != PromptID {
|
if prepared.PromptID != PromptID {
|
||||||
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
|
t.Fatalf("prompt id = %q, want %q", prepared.PromptID, PromptID)
|
||||||
}
|
}
|
||||||
if got := len(prepared.Messages); got != 5 {
|
transcriptMessages := 0
|
||||||
t.Fatalf("message count = %d, want 5", got)
|
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 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 !strings.Contains(prepared.Messages[1].Content, string(transcript)) {
|
if strings.Contains(message.Content, "Alice: Aria") &&
|
||||||
t.Fatalf("transcript message did not include source input")
|
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[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") {
|
if transcriptMessages != 1 {
|
||||||
t.Fatalf("reference message missing party content")
|
t.Fatalf("raw transcript rendered in %d messages, want exactly one", transcriptMessages)
|
||||||
}
|
}
|
||||||
if !strings.Contains(prepared.Messages[2].Content, "Brightmantle: temple") {
|
if !referenceMessageFound {
|
||||||
t.Fatalf("reference message missing glossary content")
|
t.Fatalf("reference message did not render all supplied reference material")
|
||||||
}
|
|
||||||
if strings.Contains(prepared.Messages[3].Content, string(transcript)) {
|
|
||||||
t.Fatalf("task message leaked transcript bytes")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ inputs:
|
|||||||
messages:
|
messages:
|
||||||
- role: system
|
- role: system
|
||||||
content_file: ./sharedassets/common-dnd-system.md
|
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
|
- role: user
|
||||||
content_file: ./sharedassets/common-dnd-transcript.md
|
content_file: ./sharedassets/common-dnd-transcript.md
|
||||||
cache_control:
|
cache_control:
|
||||||
@@ -28,8 +34,12 @@ messages:
|
|||||||
content_file: ./sharedassets/common-dnd-references.md
|
content_file: ./sharedassets/common-dnd-references.md
|
||||||
cache_control:
|
cache_control:
|
||||||
type: ephemeral
|
type: ephemeral
|
||||||
|
- role: user
|
||||||
|
content_file: ./sharedassets/common-dnd-immediate-resolution.md
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./sharedassets/common-dnd-npcs.md
|
content_file: ./sharedassets/common-dnd-npcs.md
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./task.md
|
content_file: ./task.md
|
||||||
- role: user
|
- 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
|
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
|
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
|
action category values. Set round to null when the transcript does not state an
|
||||||
an explicit or unambiguous positive round number. Set resolution to null when
|
explicit or unambiguous positive round number. Set resolution to null when the
|
||||||
the transcript establishes the declaration but not an immediate resolution.
|
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.
|
|
||||||
|
|||||||
@@ -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
|
event. Reactions, legendary actions, lair actions, and other out-of-turn events
|
||||||
belong at the point where they occur in transcript chronology.
|
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,
|
Exclude initiative setup without a turn or combat event, tactical planning,
|
||||||
table talk, rules lookup, hypothetical actions, abandoned declarations, recap
|
table talk, rules lookup, hypothetical actions, abandoned declarations, recap
|
||||||
of combat outside the current passage, and downstream consequences.
|
of combat outside the current passage, and downstream consequences.
|
||||||
|
|
||||||
Use only the supplied transcript as evidence. Do not infer a round, target,
|
Do not infer a round, target, roll, amount, condition, outcome, or action
|
||||||
roll, amount, condition, outcome, or action classification from D&D rules
|
classification from D&D rules knowledge. Preserve the session as played;
|
||||||
knowledge. Preserve the session as played; attribute relevant nonstandard
|
attribute relevant nonstandard rulings to the GM or table.
|
||||||
rulings to the GM or table.
|
|
||||||
|
|
||||||
The actor must be the in-world character or creature, not a player, transcript
|
Unmatched actors and targets remain permitted.
|
||||||
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.
|
|
||||||
|
|
||||||
For every factual detail in a turn, cite all supporting transcript units in the
|
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
|
turn-level source_refs collection.
|
||||||
non-contiguous. Numeric source-unit IDs identify transcript units; they do not
|
|
||||||
establish chronology outside the supplied transcript.
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package combatturns
|
package combatturns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -159,9 +158,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if len(req.Chunk.Units) == 0 {
|
if len(req.Chunk.Units) == 0 {
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
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 {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, err
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
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
|
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 {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
|
|||||||
@@ -11,12 +11,26 @@ import (
|
|||||||
|
|
||||||
const scriptoriumPromptRoot = "assets/prompts"
|
const scriptoriumPromptRoot = "assets/prompts"
|
||||||
|
|
||||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
var promptAssetManifest = shared.PromptAssetManifest{
|
||||||
promptFS, err := shared.ModulePromptFS("dnd.combat_turns", embeddedAssets, []promptfs.ModulePromptFile{
|
ModuleDir: "dnd.combat_turns",
|
||||||
|
ModuleFiles: []promptfs.ModulePromptFile{
|
||||||
{Name: "dnd.combat_turns.yaml", Path: "assets/prompts/dnd.combat_turns.yaml"},
|
{Name: "dnd.combat_turns.yaml", Path: "assets/prompts/dnd.combat_turns.yaml"},
|
||||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||||
})
|
},
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("prepare combat-turn prompt assets: %w", err)
|
return fmt.Errorf("prepare combat-turn prompt assets: %w", err)
|
||||||
}
|
}
|
||||||
@@ -28,12 +42,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
|||||||
|
|
||||||
func scriptoriumPromptMetadata() (string, error) {
|
func scriptoriumPromptMetadata() (string, error) {
|
||||||
scriptoriumPromptHashOnce.Do(func() {
|
scriptoriumPromptHashOnce.Do(func() {
|
||||||
parts := append([]llm.AssetHashPart{
|
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||||
{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)
|
|
||||||
})
|
})
|
||||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
package combatturns
|
package combatturns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
|
"gitea.maximumdirect.net/eric/scriptorium"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
|
func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
|
||||||
@@ -13,23 +16,6 @@ func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
|
|||||||
if err := RegisterPromptAssets(registry); err != nil {
|
if err := RegisterPromptAssets(registry); err != nil {
|
||||||
t.Fatalf("RegisterPromptAssets() error = %v", err)
|
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()
|
schemaFS, err := registry.SchemaFS()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -42,3 +28,50 @@ func TestRegisterPromptAssetsAndPrepareCombatPrompt(t *testing.T) {
|
|||||||
t.Fatalf("scriptoriumPromptMetadata() = %q, %v; want digest", hash, err)
|
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:
|
messages:
|
||||||
- role: system
|
- role: system
|
||||||
content_file: ./sharedassets/common-dnd-system.md
|
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
|
- role: user
|
||||||
content_file: ./sharedassets/common-dnd-transcript.md
|
content_file: ./sharedassets/common-dnd-transcript.md
|
||||||
cache_control:
|
cache_control:
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
Return exactly one JSON object and no explanatory text.
|
For every NPC record, cite transcript units that support the canonical name,
|
||||||
|
every alias, the description, and every relationship.
|
||||||
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.
|
|
||||||
|
|
||||||
Descriptions must be short session records, not biographies, statistics,
|
Descriptions must be short session records, not biographies, statistics,
|
||||||
alignment, motivations, or lore inferred from general D&D knowledge. Do not
|
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,
|
appears, acts, speaks, or is materially discussed and gives it a proper name,
|
||||||
a stable alias or title, or an individually useful distinguishing description.
|
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
|
player characters identified by the player or party references, incidental or
|
||||||
hypothetical name drops, corrected transcription mistakes, characters mentioned
|
hypothetical name drops, corrected transcription mistakes, characters mentioned
|
||||||
only by reference material, indistinguishable crowds or groups, and temporary
|
only by reference material, indistinguishable crowds or groups, and temporary
|
||||||
summoned creatures or spell effects without a persistent individual identity.
|
summoned creatures or spell effects without a persistent individual identity.
|
||||||
|
|
||||||
Return canonical in-world names rather than player names or transcript speaker
|
Keep each description concise and limited to facts established by the
|
||||||
names. Keep each description concise and limited to facts established by the
|
|
||||||
transcript. Include only explicitly supported aliases and relationships.
|
transcript. Include only explicitly supported aliases and relationships.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package npcs
|
package npcs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
@@ -122,9 +121,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if len(req.Chunk.Units) == 0 {
|
if len(req.Chunk.Units) == 0 {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
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 {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, err
|
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
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
|
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 {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
|
|||||||
@@ -133,7 +133,11 @@ func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
|||||||
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
|
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
|
||||||
t.Fatalf("canceled Extract() error = %v, want context error", err)
|
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") {
|
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)
|
t.Fatalf("provider Extract() error = %v, want contextual provider error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,24 @@ import (
|
|||||||
|
|
||||||
const scriptoriumPromptRoot = "assets/prompts"
|
const scriptoriumPromptRoot = "assets/prompts"
|
||||||
|
|
||||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
var promptAssetManifest = shared.PromptAssetManifest{
|
||||||
promptFS, err := shared.ModulePromptFS("dnd.npcs", embeddedAssets, []promptfs.ModulePromptFile{
|
ModuleDir: "dnd.npcs",
|
||||||
|
ModuleFiles: []promptfs.ModulePromptFile{
|
||||||
{Name: "dnd.npcs.yaml", Path: "assets/prompts/dnd.npcs.yaml"},
|
{Name: "dnd.npcs.yaml", Path: "assets/prompts/dnd.npcs.yaml"},
|
||||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||||
})
|
},
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("prepare NPC prompt assets: %w", err)
|
return fmt.Errorf("prepare NPC prompt assets: %w", err)
|
||||||
}
|
}
|
||||||
@@ -28,12 +40,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
|||||||
|
|
||||||
func scriptoriumPromptMetadata() (string, error) {
|
func scriptoriumPromptMetadata() (string, error) {
|
||||||
scriptoriumPromptHashOnce.Do(func() {
|
scriptoriumPromptHashOnce.Do(func() {
|
||||||
parts := append([]llm.AssetHashPart{
|
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||||
{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)
|
|
||||||
})
|
})
|
||||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,14 +39,29 @@ func TestRegisterPromptAssetsAndPrepareNPCPrompt(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Prepare() error = %v, want nil", err)
|
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)
|
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") {
|
for _, want := range []string{`{"units":[1]}`, "Dana: Mira", "Mira: ranger", "Greencloak: title"} {
|
||||||
t.Fatalf("prepared prompt inputs do not include transcript/references")
|
found := false
|
||||||
|
for _, message := range prepared.Messages {
|
||||||
|
if strings.Contains(message.Content, want) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
}
|
}
|
||||||
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")
|
if !found {
|
||||||
|
t.Fatalf("prepared prompt did not render required input %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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:
|
messages:
|
||||||
- role: system
|
- role: system
|
||||||
content_file: ./sharedassets/common-dnd-system.md
|
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
|
- role: user
|
||||||
content_file: ./sharedassets/common-dnd-transcript.md
|
content_file: ./sharedassets/common-dnd-transcript.md
|
||||||
cache_control:
|
cache_control:
|
||||||
@@ -31,8 +37,12 @@ messages:
|
|||||||
content_file: ./sharedassets/common-dnd-references.md
|
content_file: ./sharedassets/common-dnd-references.md
|
||||||
cache_control:
|
cache_control:
|
||||||
type: ephemeral
|
type: ephemeral
|
||||||
|
- role: user
|
||||||
|
content_file: ./sharedassets/common-dnd-immediate-resolution.md
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./sharedassets/common-dnd-npcs.md
|
content_file: ./sharedassets/common-dnd-npcs.md
|
||||||
|
cache_control:
|
||||||
|
type: ephemeral
|
||||||
- role: user
|
- role: user
|
||||||
content_file: ./catalog.md
|
content_file: ./catalog.md
|
||||||
- role: user
|
- role: user
|
||||||
|
|||||||
@@ -1,40 +1,29 @@
|
|||||||
Source references must use integer source-unit IDs from the transcript. Provide
|
For each spell cast, source references must collectively support the caster,
|
||||||
start_unit_id and end_unit_id for each source reference; the extractor assigns
|
spell, effect, and narrative_description. If a detail is not supported by the
|
||||||
source_id automatically.
|
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.
|
For spells, do not follow summoned creatures, persistent effects, or other
|
||||||
This may include an immediately resolved target, saving throw, damage, healing,
|
downstream consequences through the rest of the scene.
|
||||||
condition, interruption, or outcome. 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
|
Return only D&D spell-cast artifacts. For each spell cast, identify the
|
||||||
references for an artifact must collectively support every factual claim in
|
in-world caster, spell name, effect, narrative description, and source
|
||||||
caster, spell, effect, and narrative_description. When immediate supporting
|
references.
|
||||||
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
|
Use the player and party references together with transcript context to map
|
||||||
caster, spell name, effect, narrative description, and source references.
|
first-person player speech to the associated player character and use the
|
||||||
|
canonical character name from the references. Likewise, attribute a spell
|
||||||
The caster must be the canonical in-world character or creature, not the human
|
narrated by the GM to the in-world creature that casts it. If the caster cannot
|
||||||
player, transcript speaker, or GM. Use the player and party references together
|
be resolved, use only the most specific in-world identity supported by the
|
||||||
with transcript context to map first-person player speech to the associated
|
transcript; do not invent a name.
|
||||||
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 canonical spell-name catalog to select spell names. Do not return a
|
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
|
spell name absent from that catalog, even when it is suggested by general D&D
|
||||||
knowledge or reference material.
|
knowledge or reference material.
|
||||||
|
|
||||||
Use player, party, and glossary reference material only to clarify source text;
|
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,
|
Do not return spells, casters, or effects that are mentioned only in reference
|
||||||
casters, or effects that are mentioned only in reference material.
|
material.
|
||||||
|
|
||||||
Effects and narrative descriptions are session records, not rules summaries.
|
Effects and narrative descriptions are session records, not rules summaries.
|
||||||
Report only mechanics, explanations, and outcomes established by the cited
|
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
|
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
|
nonstandard rationale, use wording such as "the GM rules" or "the table
|
||||||
resolves" rather than asserting that rationale as a universal rule.
|
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
|
package spells
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
@@ -177,9 +176,9 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if len(req.Chunk.Units) == 0 {
|
if len(req.Chunk.Units) == 0 {
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
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 {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, err
|
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
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
|
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 {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
|
|||||||
@@ -11,13 +11,27 @@ import (
|
|||||||
|
|
||||||
const scriptoriumPromptRoot = "assets/prompts"
|
const scriptoriumPromptRoot = "assets/prompts"
|
||||||
|
|
||||||
func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
var promptAssetManifest = shared.PromptAssetManifest{
|
||||||
promptFS, err := shared.ModulePromptFS("dnd.spells", embeddedAssets, []promptfs.ModulePromptFile{
|
ModuleDir: "dnd.spells",
|
||||||
|
ModuleFiles: []promptfs.ModulePromptFile{
|
||||||
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
|
{Name: "dnd.spells.yaml", Path: "assets/prompts/dnd.spells.yaml"},
|
||||||
{Name: "catalog.md", Path: "assets/prompts/catalog.md"},
|
{Name: "catalog.md", Path: "assets/prompts/catalog.md"},
|
||||||
{Name: "task.md", Path: "assets/prompts/task.md"},
|
{Name: "task.md", Path: "assets/prompts/task.md"},
|
||||||
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
{Name: "instructions.md", Path: "assets/prompts/instructions.md"},
|
||||||
})
|
},
|
||||||
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("prepare spell prompt assets: %w", err)
|
return fmt.Errorf("prepare spell prompt assets: %w", err)
|
||||||
}
|
}
|
||||||
@@ -29,13 +43,7 @@ func RegisterPromptAssets(registry *llm.AssetRegistry) error {
|
|||||||
|
|
||||||
func scriptoriumPromptMetadata() (string, error) {
|
func scriptoriumPromptMetadata() (string, error) {
|
||||||
scriptoriumPromptHashOnce.Do(func() {
|
scriptoriumPromptHashOnce.Do(func() {
|
||||||
parts := append([]llm.AssetHashPart{
|
scriptoriumPromptHash, scriptoriumPromptHashErr = promptAssetManifest.Hash(embeddedAssets)
|
||||||
{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)
|
|
||||||
})
|
})
|
||||||
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
return scriptoriumPromptHash, scriptoriumPromptHashErr
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,32 +21,24 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
|
|||||||
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
|
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
|
||||||
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
|
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
|
||||||
}
|
}
|
||||||
if got := len(prepared.Messages); got != 7 {
|
for _, want := range []string{
|
||||||
t.Fatalf("message count = %d, want 7", got)
|
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 !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 {
|
if !found {
|
||||||
t.Fatalf("expected transcript and reference messages to be cacheable: %#v", prepared.Messages)
|
t.Fatalf("prepared prompt did not render required input %q", want)
|
||||||
}
|
}
|
||||||
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")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,9 +46,12 @@ func TestScriptoriumPromptPreparesWithMissingOptionalReferences(t *testing.T) {
|
|||||||
transcript := []byte(`{"id":"session-1","segments":[]}`)
|
transcript := []byte(`{"id":"session-1","segments":[]}`)
|
||||||
prepared := prepareSpellsPrompt(t, transcript, " ", " ", " ")
|
prepared := prepareSpellsPrompt(t, transcript, " ", " ", " ")
|
||||||
|
|
||||||
if !strings.Contains(prepared.Messages[2].Content, " ") {
|
for _, message := range prepared.Messages {
|
||||||
t.Fatalf("reference message did not include empty optional reference placeholders")
|
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) {
|
func TestScriptoriumPromptDiagnosticsOmitRawMaterials(t *testing.T) {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/combat-turns"
|
Key = "dnd/combat-turns"
|
||||||
NormalizationPolicy = "dnd.combat_turns.normalize.v1"
|
normalizationPolicy = "dnd.combat_turns.normalize.v1"
|
||||||
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|
||||||
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
|
ReasonCodeFieldsNormalized = "combat_turn_fields_normalized"
|
||||||
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
||||||
@@ -71,7 +72,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
metadata := map[string]any{
|
metadata := map[string]any{
|
||||||
"normalization_policy": NormalizationPolicy,
|
"normalization_policy": normalizationPolicy,
|
||||||
"identity_policy": identity.Policy,
|
"identity_policy": identity.Policy,
|
||||||
}
|
}
|
||||||
if n.npcRegistry.Bound() {
|
if n.npcRegistry.Bound() {
|
||||||
@@ -86,7 +87,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
fingerprints := []pipeline.CheckpointFingerprint{
|
fingerprints := []pipeline.CheckpointFingerprint{
|
||||||
{Name: "normalization_policy", Value: NormalizationPolicy},
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||||
{Name: "identity_policy", Value: identity.Policy},
|
{Name: "identity_policy", Value: identity.Policy},
|
||||||
}
|
}
|
||||||
if n.npcRegistry.Bound() {
|
if n.npcRegistry.Bound() {
|
||||||
|
|||||||
@@ -189,7 +189,7 @@ func TestNormalizerPreparationMetadataFingerprintsAndModuleContract(t *testing.T
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
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)
|
t.Fatalf("unbound metadata = %#v", metadata)
|
||||||
}
|
}
|
||||||
if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" {
|
if got := unbound.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "normalization_policy" || got[1].Name != "identity_policy" {
|
||||||
|
|||||||
@@ -19,7 +19,8 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/npcs"
|
Key = "dnd/npcs"
|
||||||
NormalizationPolicy = "dnd.npcs.normalize.v1"
|
normalizationPolicy = "dnd.npcs.normalize.v1"
|
||||||
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|
||||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||||
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
ReasonCodeNPCIDRecomputed = "npc_id_recomputed"
|
||||||
@@ -51,7 +52,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
|||||||
}
|
}
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"identity_policy": identity.Policy,
|
"identity_policy": identity.Policy,
|
||||||
"normalization_policy": NormalizationPolicy,
|
"normalization_policy": normalizationPolicy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "identity_policy", Value: identity.Policy},
|
{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/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"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) {
|
func TestModuleContractAndIdentity(t *testing.T) {
|
||||||
@@ -48,13 +48,13 @@ func TestModuleContractAndIdentity(t *testing.T) {
|
|||||||
|
|
||||||
normalizer := New(Options{})
|
normalizer := New(Options{})
|
||||||
metadata := normalizer.ManifestMetadata()
|
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)
|
t.Fatalf("metadata = %#v, want identity and normalization policies", metadata)
|
||||||
}
|
}
|
||||||
fingerprints := normalizer.CheckpointFingerprints()
|
fingerprints := normalizer.CheckpointFingerprints()
|
||||||
wantFingerprints := []pipeline.CheckpointFingerprint{
|
wantFingerprints := []pipeline.CheckpointFingerprint{
|
||||||
{Name: "identity_policy", Value: domainidentity.Policy},
|
{Name: "identity_policy", Value: identity.Policy},
|
||||||
{Name: "normalization_policy", Value: NormalizationPolicy},
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(fingerprints, wantFingerprints) {
|
if !reflect.DeepEqual(fingerprints, wantFingerprints) {
|
||||||
t.Fatalf("fingerprints = %#v, want %#v", 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) {
|
if !reflect.DeepEqual(got.SourceRefs, wantRefs) {
|
||||||
t.Fatalf("source refs = %#v, want %#v", 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)
|
t.Fatalf("ID = %q, want derived ID", got.ID)
|
||||||
}
|
}
|
||||||
if !hasWarning(result.Warnings, ReasonCodeNPCFieldsNormalized, "npcs[0]") ||
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/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.
|
// Register adds all production D&D modules, validators, policy, and assets.
|
||||||
func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
func Register(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
||||||
if err := validateRegistries(registries, assets); err != nil {
|
if err := validateRegistries(registries, assets); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
codec := spellcodec.New()
|
if err := registerModules(registries); err != nil {
|
||||||
registrations := []struct {
|
return err
|
||||||
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 := 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 {
|
for _, registration := range registrations {
|
||||||
if err := registration.register(); err != nil {
|
if err := registration.register(); err != nil {
|
||||||
return fmt.Errorf("register dnd %s: %w", registration.name, err)
|
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
|
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 {
|
func validateRegistries(registries pipeline.Registries, assets *llm.AssetRegistry) error {
|
||||||
switch {
|
switch {
|
||||||
case registries.Chunkers == nil:
|
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 (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/llm"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
||||||
@@ -11,39 +13,72 @@ import (
|
|||||||
//go:embed assets/prompts/*.md
|
//go:embed assets/prompts/*.md
|
||||||
var embeddedAssets embed.FS
|
var embeddedAssets embed.FS
|
||||||
|
|
||||||
var sharedPromptFiles = []string{
|
// PromptAssetManifest is the ordered set of assets that make up one prompt.
|
||||||
"common-dnd-system.md",
|
// Module files are addressed in the owning module filesystem; shared files use
|
||||||
"common-dnd-transcript.md",
|
// the names in sharedPromptPaths and are mounted beneath sharedassets.
|
||||||
"common-dnd-references.md",
|
type PromptAssetManifest struct {
|
||||||
"common-dnd-npcs.md",
|
ModuleDir string
|
||||||
|
ModuleFiles []promptfs.ModulePromptFile
|
||||||
|
SharedFiles []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func SharedPromptFiles() []promptfs.SharedPromptFile {
|
var sharedPromptPaths = map[string]string{
|
||||||
files := make([]promptfs.SharedPromptFile, 0, len(sharedPromptFiles))
|
"common-dnd-system.md": "assets/prompts/common-dnd-system.md",
|
||||||
for _, name := range sharedPromptFiles {
|
"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{
|
files = append(files, promptfs.SharedPromptFile{
|
||||||
Name: name,
|
Name: name,
|
||||||
FS: embeddedAssets,
|
FS: embeddedAssets,
|
||||||
Path: "assets/prompts/" + name,
|
Path: path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return files
|
return files, nil
|
||||||
}
|
|
||||||
|
|
||||||
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()...)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
@@ -9,81 +11,191 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/promptfs"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSharedPromptFilesReturnsNewSlice(t *testing.T) {
|
func TestPromptAssetManifestPromptFS(t *testing.T) {
|
||||||
first := SharedPromptFiles()
|
manifest := PromptAssetManifest{
|
||||||
second := SharedPromptFiles()
|
ModuleDir: "dnd.test",
|
||||||
|
ModuleFiles: []promptfs.ModulePromptFile{
|
||||||
if len(first) != 4 || len(second) != 4 {
|
|
||||||
t.Fatalf("SharedPromptFiles() lengths = %d and %d, want 4", len(first), len(second))
|
|
||||||
}
|
|
||||||
first[0].Name = "changed.md"
|
|
||||||
if second[0].Name != "common-dnd-system.md" {
|
|
||||||
t.Fatalf("SharedPromptFiles() reused descriptor slice: %#v", second)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestSharedPromptFilesReferenceEmbeddedAssets(t *testing.T) {
|
|
||||||
for _, file := range SharedPromptFiles() {
|
|
||||||
if file.FS == nil {
|
|
||||||
t.Fatalf("SharedPromptFiles() descriptor %q has nil FS", file.Name)
|
|
||||||
}
|
|
||||||
if _, err := fs.ReadFile(file.FS, file.Path); err != nil {
|
|
||||||
t.Fatalf("ReadFile(%q) error = %v, want nil", file.Path, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestHashPartsReferenceSharedPrompts(t *testing.T) {
|
|
||||||
assertHashParts(t, "common", CommonHashParts(), []string{
|
|
||||||
"assets/prompts/common-dnd-system.md",
|
|
||||||
"assets/prompts/common-dnd-transcript.md",
|
|
||||||
})
|
|
||||||
assertHashParts(t, "reference", ReferenceHashParts(), []string{
|
|
||||||
"assets/prompts/common-dnd-references.md",
|
|
||||||
"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{
|
|
||||||
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
|
||||||
}, []promptfs.ModulePromptFile{
|
|
||||||
{Name: "dnd.test.yaml", Path: "assets/prompts/dnd.test.yaml"},
|
{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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fsys, err := manifest.PromptFS(fstest.MapFS{
|
||||||
|
"assets/prompts/dnd.test.yaml": {Data: []byte("id: dnd.test")},
|
||||||
|
"assets/prompts/task.md": {Data: []byte("task")},
|
||||||
})
|
})
|
||||||
if err != nil {
|
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{
|
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-system.md",
|
||||||
"assets/prompts/dnd.test/sharedassets/common-dnd-transcript.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-references.md",
|
||||||
"assets/prompts/dnd.test/sharedassets/common-dnd-npcs.md",
|
"assets/prompts/dnd.test/sharedassets/common-dnd-npcs.md",
|
||||||
} {
|
} {
|
||||||
if _, err := fs.ReadFile(fsys, path); err != nil {
|
if _, err := fs.ReadFile(fsys, path); err == nil {
|
||||||
t.Fatalf("ReadFile(%q) error = %v, want nil", path, err)
|
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,58 +1,108 @@
|
|||||||
package shared
|
package shared
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"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")
|
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"),
|
"players": slotWithContent("players", "Alice: Aria"),
|
||||||
"party": slotWithContent("party", "Aria: cleric"),
|
"party": slotWithContent("party", "Aria: cleric"),
|
||||||
"glossary": slotWithContent("glossary", "Brightmantle: temple"),
|
"glossary": slotWithContent("glossary", "Brightmantle: temple"),
|
||||||
}}
|
}}
|
||||||
|
orderedReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
inputs := PromptInputs(source, references)
|
"players": {Items: []contracts.ReferenceItem{
|
||||||
for _, name := range []string{"transcript", "players", "party", "glossary"} {
|
referenceItem("players", "file:///b.txt", "sha256:bbb", "second"),
|
||||||
if _, ok := inputs[name]; !ok {
|
referenceItem("players", "file:///a.txt", "sha256:aaa", "first"),
|
||||||
t.Fatalf("PromptInputs() missing %q: %#v", name, inputs)
|
}},
|
||||||
}
|
}}
|
||||||
}
|
reversedReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
if _, ok := inputs["roster"]; ok {
|
"players": {Items: []contracts.ReferenceItem{
|
||||||
t.Fatalf("PromptInputs() included roster input: %#v", inputs)
|
referenceItem("players", "file:///a.txt", "sha256:aaa", "first"),
|
||||||
}
|
referenceItem("players", "file:///b.txt", "sha256:bbb", "second"),
|
||||||
if got := inputs["transcript"].Name; got != "transcript" {
|
}},
|
||||||
t.Fatalf("transcript name = %q, want transcript", got)
|
}}
|
||||||
}
|
rosterReferences := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
if got := string(inputs["transcript"].Content); got != "source text" {
|
|
||||||
t.Fatalf("transcript content = %q, want source text", got)
|
|
||||||
}
|
|
||||||
if got := string(inputs["players"].Content); got != "Alice: Aria" {
|
|
||||||
t.Fatalf("players content = %q, want player reference", got)
|
|
||||||
}
|
|
||||||
if got := string(inputs["party"].Content); got != "Aria: cleric" {
|
|
||||||
t.Fatalf("party content = %q, want party reference", got)
|
|
||||||
}
|
|
||||||
if got := string(inputs["glossary"].Content); got != "Brightmantle: temple" {
|
|
||||||
t.Fatalf("glossary content = %q, want glossary reference", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestPromptInputsUseRosterWhenPartyIsEmpty(t *testing.T) {
|
|
||||||
inputs := PromptInputs(contracts.LLMInputMaterial{}, contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
|
||||||
"party": {},
|
"party": {},
|
||||||
"roster": slotWithContent("roster", "Legacy roster text"),
|
"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" {
|
tests := []struct {
|
||||||
t.Fatalf("party content = %q, want roster fallback content", got)
|
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": " ",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
if _, ok := inputs["roster"]; ok {
|
||||||
t.Fatalf("PromptInputs() included roster input: %#v", inputs)
|
t.Fatalf("PromptInputs() included roster input: %#v", inputs)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTranscriptPromptMaterialClonesSource(t *testing.T) {
|
func TestTranscriptPromptMaterialClonesSource(t *testing.T) {
|
||||||
@@ -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"
|
"context"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
@@ -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) {
|
func TestSpecRegisterOptionsAndPolicy(t *testing.T) {
|
||||||
if got := New(Options{}).CheckpointFingerprints(); len(got) != 1 || got[0].Name != "policy" || got[0].Value != policy {
|
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)
|
t.Fatalf("CheckpointFingerprints() = %#v, want source-reference policy", got)
|
||||||
|
|||||||
@@ -3,15 +3,12 @@ package sourcerelatedness
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
|
||||||
"unicode"
|
|
||||||
"unicode/utf8"
|
"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/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"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"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
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) {
|
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
|
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)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for turnIndex, turn := range req.Value.CombatTurns {
|
for turnIndex, turn := range req.Value.CombatTurns {
|
||||||
citedText := citedTextKey(req.Source, turn.SourceRefs)
|
citedText := citedTexts[turnIndex]
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
if !actorAppearsInCitedText(citedText, turn.Actor) {
|
if !actorAppearsInCitedText(citedText, turn.Actor) {
|
||||||
issues = append(issues, fmt.Sprintf("actor %s was not found in cited source text", diagnostics.Quote(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
|
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 {
|
func actorAppearsInCitedText(citedText string, actor string) bool {
|
||||||
key := identity.ComparisonKey(actor)
|
return shared.ContainsTokenSequence(citedText, actor)
|
||||||
return key != "" && strings.Contains(citedText, key)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func declarationAppearsInCitedText(citedText string, declaration string) bool {
|
func declarationAppearsInCitedText(citedText string, declaration string) bool {
|
||||||
citedTokens := tokenSet(citedText)
|
citedTokens := tokenSet(citedText)
|
||||||
for _, token := range comparisonTokens(declaration) {
|
for _, token := range shared.NormalizedTokens(declaration) {
|
||||||
if utf8.RuneCountInString(token) >= 4 {
|
if utf8.RuneCountInString(token) >= 4 {
|
||||||
if _, ok := citedTokens[token]; ok {
|
if _, ok := citedTokens[token]; ok {
|
||||||
return true
|
return true
|
||||||
@@ -115,16 +86,8 @@ func declarationAppearsInCitedText(citedText string, declaration string) bool {
|
|||||||
return false
|
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{} {
|
func tokenSet(value string) map[string]struct{} {
|
||||||
tokens := comparisonTokens(value)
|
tokens := shared.NormalizedTokens(value)
|
||||||
set := make(map[string]struct{}, len(tokens))
|
set := make(map[string]struct{}, len(tokens))
|
||||||
for _, token := range tokens {
|
for _, token := range tokens {
|
||||||
set[token] = struct{}{}
|
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) {
|
func TestValidatorDefersMalformedShapeAndInvalidRanges(t *testing.T) {
|
||||||
invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
|
invalidShape := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{{Actor: "Aria"}}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.CombatTurnList]{Source: relatednessDocument(), Value: invalidShape})
|
||||||
|
|||||||
@@ -3,13 +3,11 @@ package sourcerelatedness
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"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/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"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"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||||
npcshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcs/shape"
|
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 {
|
if err := npcshape.Validate(req.Value); err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, 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
|
var warnings []contracts.Warning
|
||||||
for npcIndex, npc := range req.Value.NPCs {
|
for npcIndex, npc := range req.Value.NPCs {
|
||||||
if npcAppearsInCitedText(req.Source, npc) {
|
if npcAppearsInCitedText(citedTexts[npcIndex], npc) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
warnings = append(warnings, contracts.Warning{
|
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
|
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func npcAppearsInCitedText(doc *source.SourceDocument, npc dnd.NPC) bool {
|
func npcAppearsInCitedText(citedText string, npc dnd.NPC) bool {
|
||||||
cited := citedTextKey(doc, npc.SourceRefs)
|
if shared.ContainsTokenSequence(citedText, npc.Name) {
|
||||||
if cited == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if strings.Contains(cited, identity.ComparisonKey(npc.Name)) {
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, alias := range npc.Aliases {
|
for _, alias := range npc.Aliases {
|
||||||
if strings.Contains(cited, identity.ComparisonKey(alias)) {
|
if shared.ContainsTokenSequence(citedText, alias) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
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 {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ import (
|
|||||||
|
|
||||||
func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) {
|
func TestValidatorMatchesCanonicalNamesAndAliasesWithUnicodeVariants(t *testing.T) {
|
||||||
value := dnd.NPCList{NPCs: []dnd.NPC{
|
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}}},
|
{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{
|
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) {
|
func TestValidatorDefersMalformedShapeAndInvalidRangesDoNotPanic(t *testing.T) {
|
||||||
invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}}
|
invalidShape := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Mira Thorn"}}}
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.NPCList]{Source: relatednessDocument(), Value: invalidShape})
|
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}}}}}
|
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})
|
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 {
|
if err != nil || !result.Approved || len(result.Warnings) != 0 {
|
||||||
t.Fatalf("invalid-range relatedness = %#v, %v; want one warning", result, err)
|
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"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Key = "extract/dnd/spells/shape"
|
const (
|
||||||
const ReasonCode = "invalid_spell_shape"
|
Key = "extract/dnd/spells/shape"
|
||||||
|
ReasonCode = "invalid_spell_shape"
|
||||||
|
policy = "dnd.spells.validator.shape.v1"
|
||||||
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
||||||
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
func (v *Validator) Name() string { return Key }
|
func (v *Validator) Name() string { return Key }
|
||||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||||
return contracts.ExecutionClassDeterministic
|
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) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||||
if err := Validate(req.Value); err != nil {
|
if err := Validate(req.Value); err != nil {
|
||||||
return rejection(err.Error()), 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()
|
registry := pipeline.NewValidatorRegistry()
|
||||||
if err := Register(registry); err != nil {
|
if err := Register(registry); err != nil {
|
||||||
t.Fatalf("Register() error = %v, want nil", err)
|
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/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"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"
|
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Key = "extract/dnd/spells/source_refs"
|
const (
|
||||||
const ReasonCode = "invalid_source_refs"
|
Key = "extract/dnd/spells/source_refs"
|
||||||
|
ReasonCode = "invalid_source_refs"
|
||||||
|
policy = "dnd.spells.validator.source_refs.v1"
|
||||||
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
||||||
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
func (v *Validator) Name() string { return Key }
|
func (v *Validator) Name() string { return Key }
|
||||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||||
return contracts.ExecutionClassDeterministic
|
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) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||||
if err := spellshape.Validate(req.Value); err != nil {
|
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 spellIndex, spell := range req.Value.SpellCasts {
|
||||||
for refIndex, ref := range spell.SourceRefs {
|
for refIndex, ref := range spell.SourceRefs {
|
||||||
if err := source.ValidateRef(req.Source, ref); err != nil {
|
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
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package sourcerefs
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
@@ -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()
|
registry := pipeline.NewValidatorRegistry()
|
||||||
if err := Register(registry); err != nil {
|
if err := Register(registry); err != nil {
|
||||||
t.Fatalf("Register() error = %v, want nil", err)
|
t.Fatalf("Register() error = %v, want nil", err)
|
||||||
|
|||||||
@@ -5,70 +5,59 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"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"
|
spellshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/spells/shape"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Key = "extract/dnd/spells/source_relatedness"
|
const (
|
||||||
const WarningReasonCode = "spell_not_near_source"
|
Key = "extract/dnd/spells/source_relatedness"
|
||||||
|
WarningReasonCode = "spell_not_near_source"
|
||||||
|
policy = "dnd.spells.validator.source_relatedness.v1"
|
||||||
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
type Validator struct{}
|
type Validator struct{}
|
||||||
|
|
||||||
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
var _ contracts.TypedValidator[dnd.SpellList] = (*Validator)(nil)
|
||||||
|
var _ pipeline.CheckpointFingerprintProvider = (*Validator)(nil)
|
||||||
|
|
||||||
func New(Options) *Validator { return &Validator{} }
|
func New(Options) *Validator { return &Validator{} }
|
||||||
func (v *Validator) Name() string { return Key }
|
func (v *Validator) Name() string { return Key }
|
||||||
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
func (v *Validator) ExecutionClass() contracts.ExecutionClass {
|
||||||
return contracts.ExecutionClassDeterministic
|
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) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SpellList]) (contracts.ValidationResult, error) {
|
||||||
if err := spellshape.Validate(req.Value); err != nil {
|
if err := spellshape.Validate(req.Value); err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, 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
|
var warnings []contracts.Warning
|
||||||
for spellIndex, spell := range req.Value.SpellCasts {
|
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))})
|
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
|
return contracts.ValidationResult{Approved: true, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
func spellAppearsInCitedText(doc *source.SourceDocument, spell dnd.SpellCast) bool {
|
func spellAppearsInCitedText(citedText string, spell dnd.SpellCast) bool {
|
||||||
name := strings.ToLower(strings.TrimSpace(spell.Spell))
|
name := strings.TrimSpace(spell.Spell)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
for _, ref := range spell.SourceRefs {
|
return shared.ContainsTokenSequence(citedText, name)
|
||||||
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
|
|
||||||
}
|
}
|
||||||
func Spec() pipeline.ValidatorSpec {
|
func Spec() pipeline.ValidatorSpec {
|
||||||
return pipeline.ValidatorSpec{Key: Key, ExecutionClass: contracts.ExecutionClassDeterministic}
|
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) {
|
func TestValidatorApprovesEmptySpellListWithoutWarning(t *testing.T) {
|
||||||
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}})
|
result, err := New(Options{}).Validate(context.Background(), contracts.TypedValidationRequest[dnd.SpellList]{Source: validDocument(), Value: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}})
|
||||||
if err != nil {
|
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()
|
registry := pipeline.NewValidatorRegistry()
|
||||||
if err := Register(registry); err != nil {
|
if err := Register(registry); err != nil {
|
||||||
t.Fatalf("Register() error = %v, want nil", err)
|
t.Fatalf("Register() error = %v, want nil", err)
|
||||||
|
|||||||
Reference in New Issue
Block a user