Compare commits
16 Commits
6e21c83fd8
...
d752c51aec
| Author | SHA1 | Date | |
|---|---|---|---|
| d752c51aec | |||
| 8199d95dc1 | |||
| 97cdb01357 | |||
| 7a66095912 | |||
| 9d1356a20e | |||
| e4471fc300 | |||
| 84a2854b5e | |||
| a1b76093ce | |||
| 1aa30a73db | |||
| dc7c0e2f9e | |||
| e2cb0d901a | |||
| 8e0b029f5f | |||
| 9bbf2535dd | |||
| 83fde83a58 | |||
| bef3d1359d | |||
| 1ff449435f |
@@ -53,7 +53,8 @@ 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/citation 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. The
|
`internal/modules/dnd/shared`, which also owns document-aware source-reference
|
||||||
|
ordering and canonicalization plus bounded D&D diagnostics. The
|
||||||
D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and scene-description extractors use ordered
|
D&D scene chunker and spell, NPC, combat-turn, NPC-interaction, and scene-description extractors use ordered
|
||||||
package-local prompt manifests for both rendering and prompt fingerprinting, so
|
package-local prompt manifests for both rendering and prompt fingerprinting, so
|
||||||
only the shared fragments each prompt actually renders participate in either
|
only the shared fragments each prompt actually renders participate in either
|
||||||
@@ -64,12 +65,13 @@ lanes. The canonical ordering and cache-boundary policy is documented in
|
|||||||
contracts expose only Notarius structured-completion types, not Scriptorium
|
contracts expose only Notarius structured-completion types, not Scriptorium
|
||||||
public types.
|
public types.
|
||||||
|
|
||||||
The shared `ChunkPromptMaterial` helper owns common transcript material
|
The shared `PrepareChunkExtraction` helper owns common extraction preflight and
|
||||||
preparation for the spell, NPC, combat-turn, and NPC-interaction extractors. It clones supplied
|
transcript material preparation for the spell, NPC, combat-turn,
|
||||||
source metadata, falls back to the materialized chunk when content is absent,
|
NPC-interaction, and scene-description extractors. It validates common request
|
||||||
checks that content remains chunk-identical, and fills only the common default
|
state, clones supplied source metadata, falls back to the materialized chunk
|
||||||
fields. Extractors retain their request validation and wrap helper errors with
|
when content is absent, checks that content remains chunk-identical, and fills
|
||||||
their module context.
|
only the common default fields. Extractors retain receiver, dependency, and
|
||||||
|
lane-specific checks locally 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
|
||||||
@@ -201,7 +203,7 @@ The spell extractor prepares a structured request from one chunk, the
|
|||||||
chunk-scoped source input, the session, and optional D&D reference inputs. It
|
chunk-scoped source input, the session, and optional D&D reference inputs. It
|
||||||
decodes the model response, assigns the generic source identity to every source
|
decodes the model response, assigns the generic source identity to every source
|
||||||
reference, canonicalizes duplicate references, orders spell casts by their
|
reference, canonicalizes duplicate references, orders spell casts by their
|
||||||
earliest cited unit, and returns `dnd.SpellList`.
|
earliest valid source-document position, and returns `dnd.SpellList`.
|
||||||
|
|
||||||
Its private response schema admits only the structural transport envelope:
|
Its private response schema admits only the structural transport envelope:
|
||||||
required fields, JSON types, array and object shapes, and unknown-field
|
required fields, JSON types, array and object shapes, and unknown-field
|
||||||
@@ -322,12 +324,13 @@ mapping, and the names-only registry projection
|
|||||||
participate in checkpoint identity, while generated producer identity remains
|
participate in checkpoint identity, while generated producer identity remains
|
||||||
framework provenance.
|
framework provenance.
|
||||||
|
|
||||||
The domain-owned `internal/modules/dnd/npcinteractions` package defines
|
The shared D&D source-reference order defines canonical evidence ordering. The
|
||||||
canonical source-reference and occurrence ordering, valid-evidence eligibility,
|
domain-owned `internal/modules/dnd/npcinteractions` package defines occurrence
|
||||||
and collision-safe exact identity. The interaction normalizer and normalized
|
ordering, valid-evidence eligibility, and collision-safe exact identity. The
|
||||||
invariants validator both consume those rules, so their production and checking
|
interaction normalizer and normalized invariants validator consume those
|
||||||
paths cannot drift. Normalizer and relatedness warning lists use the shared D&D
|
rules, so their production and checking paths cannot drift. Normalizer and
|
||||||
diagnostic cap and emit a final omission-summary warning when truncated.
|
relatedness warning lists use the shared D&D diagnostic cap and emit a final
|
||||||
|
omission-summary warning when truncated.
|
||||||
|
|
||||||
### `internal/modules/dnd/normalize/npcs`
|
### `internal/modules/dnd/normalize/npcs`
|
||||||
|
|
||||||
@@ -356,9 +359,9 @@ The typed spell normalizer resolves the optional `spell_catalog` reference into
|
|||||||
the same immutable SRD-plus-overlay effective catalog used by spell extraction
|
the same immutable SRD-plus-overlay effective catalog used by spell extraction
|
||||||
and catalog validation. It performs no LLM calls. For each spell cast it
|
and catalog validation. It performs no LLM calls. For each spell cast it
|
||||||
canonicalizes recognized names using the catalog's case, whitespace,
|
canonicalizes recognized names using the catalog's case, whitespace,
|
||||||
apostrophe, and alias rules; sorts source references by source identity and
|
apostrophe, and alias rules; canonicalizes source references with the shared
|
||||||
unit boundaries; removes only exact reference duplicates; and emits bounded,
|
document-aware order; removes only exact reference duplicates; and emits
|
||||||
scoped warnings for each mutation or unresolved name.
|
bounded, scoped warnings for each mutation or unresolved name.
|
||||||
|
|
||||||
After those per-cast changes, it collapses only casts with the same canonical
|
After those per-cast changes, it collapses only casts with the same canonical
|
||||||
spell, case-folded and whitespace-normalized caster, and complete non-empty
|
spell, case-folded and whitespace-normalized caster, and complete non-empty
|
||||||
@@ -575,6 +578,37 @@ When adding a production module or validator:
|
|||||||
Do not add the extension to `docs/development.md`; that file routes by task and
|
Do not add the extension to `docs/development.md`; that file routes by task and
|
||||||
does not inventory implementations.
|
does not inventory implementations.
|
||||||
|
|
||||||
|
### D&D Extractor Contract
|
||||||
|
|
||||||
|
New D&D extractors preserve these package-owned responsibilities:
|
||||||
|
|
||||||
|
- Reject unknown options unless an option namespace is intentionally
|
||||||
|
extensible, and use shared common preflight while retaining receiver,
|
||||||
|
dependency, and lane-specific checks locally.
|
||||||
|
- Return independently owned results and exposed metadata that callers may
|
||||||
|
safely mutate.
|
||||||
|
- Keep the private response DTO, structural response schema and its identity,
|
||||||
|
provider-response mapping, durable artifact conversion, and lane diagnostics
|
||||||
|
in the owning package.
|
||||||
|
- Include every stable semantic input that can change durable output in
|
||||||
|
checkpoint identity. Consider prompt, schema, mapping, canonicalization,
|
||||||
|
prepared reference projections, identity, normalization, and trimming where
|
||||||
|
applicable.
|
||||||
|
- Add focused behavioral coverage where the lane's risks warrant it, including
|
||||||
|
construction and registration, option rejection, preflight, provider
|
||||||
|
failures, structured decoding, mapping and ownership, prompt
|
||||||
|
role/input/cache order, and checkpoint invalidation.
|
||||||
|
|
||||||
|
Prompt ordering, shared-asset ownership, cache boundaries, and private-schema
|
||||||
|
rules are defined in [LLM Runtime](llm.md#dd-extraction-prompt-ordering-and-cache-boundaries).
|
||||||
|
[Pipeline Internals](pipeline.md#reference-materialization) owns reference
|
||||||
|
materialization, and its [checkpoint hooks](pipeline.md#checkpoint-and-debug-hooks)
|
||||||
|
define checkpoint behavior. Follow [Architecture](../policy/architecture.md#source-and-domain-boundaries)
|
||||||
|
for ownership boundaries and the [Testing Policy](../policy/testing.md) when
|
||||||
|
selecting durable coverage. This contract intentionally does not prescribe
|
||||||
|
prompt prose or length, hashes, test counts, filenames, fixture layouts, or
|
||||||
|
generic implementation builders.
|
||||||
|
|
||||||
## Tests To Inspect
|
## Tests To Inspect
|
||||||
|
|
||||||
- Package-local `*_test.go` files under the module or validator being changed.
|
- Package-local `*_test.go` files under the module or validator being changed.
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ Configuration. The implemented module packages are:
|
|||||||
| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. |
|
| `internal/modules/dnd/extract/combatturns` | Maps private structured model output to source-grounded D&D combat-turn candidates and preserves chronology and invalid candidate values for validators. |
|
||||||
| `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. |
|
| `internal/modules/dnd/extract/npcinteractions` | Maps private structured model output to current-source NPC interaction candidates grounded by a required registry. |
|
||||||
| `internal/modules/dnd/extract/scenedescriptions` | Maps one private scene description to the current accepted chunk's ID and exact range. |
|
| `internal/modules/dnd/extract/scenedescriptions` | Maps one private scene description to the current accepted chunk's ID and exact range. |
|
||||||
| `internal/modules/dnd/npcinteractions` | Owns canonical source-reference ordering, occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
|
| `internal/modules/dnd/npcinteractions` | Owns interaction occurrence ordering, valid-evidence checks, and exact interaction identity shared by normalization and invariant validation. |
|
||||||
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
|
| `internal/modules/dnd/normalize/combatturns` | Canonicalizes and orders merged combat turns, applies exact NPC identity matches, and collapses only exact valid-evidence duplicates. |
|
||||||
| `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. |
|
| `internal/modules/dnd/normalize/npcinteractions` | Canonicalizes required-registry names, orders interaction occurrences, and collapses only exact valid-evidence duplicates. |
|
||||||
| `internal/modules/dnd/normalize/scenedescriptions` | Trims, source-orders, and removes only exactly identical scene descriptions while rejecting ID and range conflicts. |
|
| `internal/modules/dnd/normalize/scenedescriptions` | Trims, source-orders, and removes only exactly identical scene descriptions while rejecting ID and range conflicts. |
|
||||||
@@ -118,8 +118,9 @@ Configuration. The implemented module packages are:
|
|||||||
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, rejections, and an explicitly enabled accepted chunk map as logical JSON files. |
|
| `internal/modules/generic/output/json` | Encodes manifests, lane payloads, warnings, rejections, and an explicitly enabled accepted chunk map as logical JSON files. |
|
||||||
|
|
||||||
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
|
`internal/modules/dnd/shared` owns reusable D&D prompt fragments,
|
||||||
reference declarations, prompt input assembly, source-unit reference helpers,
|
reference declarations, prompt input assembly, document-aware source-reference
|
||||||
and bounded diagnostics under `internal/modules/dnd/shared/diagnostics`.
|
ordering and canonicalization, and bounded diagnostics under
|
||||||
|
`internal/modules/dnd/shared/diagnostics`.
|
||||||
The shared NPC grounding fragment is mounted for D&D prompts and is owned by
|
The shared NPC grounding fragment is mounted for D&D prompts and is owned by
|
||||||
this package. Domain-neutral prompt filesystem composition lives in
|
this package. Domain-neutral prompt filesystem composition lives in
|
||||||
`internal/framework/promptfs`.
|
`internal/framework/promptfs`.
|
||||||
|
|||||||
@@ -1,826 +0,0 @@
|
|||||||
# D&D Extraction Module Refactoring Audit Strategy
|
|
||||||
|
|
||||||
Status: Strategy complete; final audit results recorded below
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Define a disciplined audit of the five production D&D extraction modules:
|
|
||||||
|
|
||||||
- `dnd/spells`;
|
|
||||||
- `dnd/npcs`;
|
|
||||||
- `dnd/combat-turns`;
|
|
||||||
- `dnd/npc-interactions`; and
|
|
||||||
- `dnd/scene-descriptions`.
|
|
||||||
|
|
||||||
The audit will determine whether these modules follow a coherent set of
|
|
||||||
conventions, whether repeated implementation can be replaced by appropriately
|
|
||||||
scoped shared code or assets, and whether the accumulated design suggests other
|
|
||||||
maintainability improvements.
|
|
||||||
|
|
||||||
This document defines how to perform the audit. It does not contain audit
|
|
||||||
findings and does not authorize production changes.
|
|
||||||
|
|
||||||
## Audit Principles
|
|
||||||
|
|
||||||
The audit must distinguish consistency from uniformity. The five modules should
|
|
||||||
use the same conventions where they perform the same responsibility, but a
|
|
||||||
module should remain different when its artifact semantics, evidence model,
|
|
||||||
reference requirements, or normalization policy require it.
|
|
||||||
|
|
||||||
Recommendations must follow these principles:
|
|
||||||
|
|
||||||
- Prefer evidence from current code, tests, prompts, schemas, configuration,
|
|
||||||
and documentation over naming or visual similarity.
|
|
||||||
- Treat a divergence as a finding only when it is unexplained, increases
|
|
||||||
maintenance or correctness risk, or violates an intended convention.
|
|
||||||
- Do not recommend an abstraction solely to reduce line count. A shared helper
|
|
||||||
must own one coherent invariant and make future correct changes easier.
|
|
||||||
- Preserve package ownership of artifact semantics, private response DTOs,
|
|
||||||
private structured-output schemas, and lane-specific prompts.
|
|
||||||
- Keep D&D-specific behavior in D&D packages. Move behavior into a generic
|
|
||||||
framework package only when the contract is demonstrably domain-neutral and
|
|
||||||
has a non-D&D consumer or a clear framework-owned responsibility.
|
|
||||||
- Evaluate prompt sharing by byte identity and semantic ownership. Prompt
|
|
||||||
caching benefits only when repeated message content and ordering are exactly
|
|
||||||
identical.
|
|
||||||
- Apply the testing policy to proposed refactors. Prefer behavioral protection
|
|
||||||
at stable boundaries and do not add change-detector tests for helper usage,
|
|
||||||
prompt length, exact hashes, or private file layout.
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
### Primary scope
|
|
||||||
|
|
||||||
Inspect the complete package-owned implementation beneath:
|
|
||||||
|
|
||||||
- `internal/modules/dnd/extract/spells`;
|
|
||||||
- `internal/modules/dnd/extract/npcs`;
|
|
||||||
- `internal/modules/dnd/extract/combatturns`;
|
|
||||||
- `internal/modules/dnd/extract/npcinteractions`; and
|
|
||||||
- `internal/modules/dnd/extract/scenedescriptions`.
|
|
||||||
|
|
||||||
For each package, include:
|
|
||||||
|
|
||||||
- module identity, capabilities, construction, options, registration, and
|
|
||||||
execution class;
|
|
||||||
- reference-slot declarations and construction-time or operation-time
|
|
||||||
reference handling;
|
|
||||||
- checkpoint fingerprints and manifest metadata;
|
|
||||||
- request validation and structured LLM request preparation;
|
|
||||||
- private response DTOs, response-schema loading, and response mapping;
|
|
||||||
- source-reference resolution, evidence attachment, canonicalization,
|
|
||||||
ordering, and exact deduplication;
|
|
||||||
- embedded prompt manifests, shared and local assets, message order, cache
|
|
||||||
boundaries, and schema assets;
|
|
||||||
- errors, warnings, diagnostics, cloning, and mutation safety; and
|
|
||||||
- package-local tests and test support.
|
|
||||||
|
|
||||||
### Contextual scope
|
|
||||||
|
|
||||||
Inspect a neighboring component only when needed to determine ownership,
|
|
||||||
duplication, or compatibility:
|
|
||||||
|
|
||||||
- `internal/modules/dnd/shared` and focused D&D subpackages used by more than
|
|
||||||
one extractor;
|
|
||||||
- the five artifact model and codec contracts;
|
|
||||||
- corresponding merge, normalize, and validate variants;
|
|
||||||
- production registration and default validator composition;
|
|
||||||
- pipeline reference, fingerprint, and LLM contracts;
|
|
||||||
- canonical current-behavior documentation and integration contracts; and
|
|
||||||
- representative production and integration tests.
|
|
||||||
|
|
||||||
Contextual inspection is not a request to redesign every lane stage. Findings
|
|
||||||
outside extraction should be reported only when they directly explain an
|
|
||||||
extractor inconsistency or reveal a misplaced responsibility.
|
|
||||||
|
|
||||||
### Exclusions
|
|
||||||
|
|
||||||
Do not use this audit to:
|
|
||||||
|
|
||||||
- change durable artifact schemas or extraction policy;
|
|
||||||
- redesign the fixed pipeline shape or ordered-step model;
|
|
||||||
- combine distinct artifacts into a larger D&D result;
|
|
||||||
- evaluate live-model output quality;
|
|
||||||
- introduce schema generation, a dependency-injection framework, or a general
|
|
||||||
module superclass;
|
|
||||||
- move domain rules into the generic framework;
|
|
||||||
- rewrite tests merely to make their file layout look alike; or
|
|
||||||
- implement any recommended refactor.
|
|
||||||
|
|
||||||
If the audit exposes a product-contract concern, record it separately from
|
|
||||||
refactoring recommendations and identify the additional decision required.
|
|
||||||
|
|
||||||
## Comparison Method
|
|
||||||
|
|
||||||
### 1. Establish a module inventory
|
|
||||||
|
|
||||||
Create one row per module in a working comparison matrix. Record exact current
|
|
||||||
facts rather than inferred conventions:
|
|
||||||
|
|
||||||
| Dimension | Facts to record |
|
|
||||||
| --- | --- |
|
|
||||||
| Identity | Module key, artifact kind, capabilities, execution class |
|
|
||||||
| Files | Production files, embedded assets, focused test files |
|
|
||||||
| Construction | Dependencies, options, reference decoding, immutable prepared state |
|
|
||||||
| Registration | `ModuleSpec`, builder, option validation, declared slots |
|
|
||||||
| Provenance | Manifest metadata and checkpoint fingerprint keys and values |
|
|
||||||
| Prompt | Prompt ID/version, manifest messages, inputs, shared assets, cache boundaries |
|
|
||||||
| Schema | Private schema identity, strictness, loader, diagnostics behavior |
|
|
||||||
| Execution | Request validation, LLM call, response mapping, errors and warnings |
|
|
||||||
| Evidence | Source identity, range resolution, canonicalization, ordering, deduplication |
|
|
||||||
| Tests | Contract owner, malformed cases, integration coverage, test-only helpers |
|
|
||||||
|
|
||||||
Use the matrix to identify exact agreement, intentional variation, and
|
|
||||||
unexplained variation. Do not infer a preferred convention from whichever
|
|
||||||
module was implemented first. Determine the preferred shape from architecture,
|
|
||||||
current documentation, shared contracts, and the clearest implementation.
|
|
||||||
|
|
||||||
### 2. Classify every divergence
|
|
||||||
|
|
||||||
Assign each observed difference one classification:
|
|
||||||
|
|
||||||
- **Required specialization:** the artifact or reference contract requires the
|
|
||||||
difference. No harmonization is recommended.
|
|
||||||
- **Permitted variation:** implementations differ without meaningful
|
|
||||||
maintenance or correctness cost.
|
|
||||||
- **Convention drift:** equivalent responsibilities use different names,
|
|
||||||
layouts, error behavior, metadata, validation, or tests without a reason.
|
|
||||||
- **Architectural divergence:** responsibility is placed in the wrong layer or
|
|
||||||
bypasses a shared contract.
|
|
||||||
- **Undetermined:** more evidence or a policy decision is needed.
|
|
||||||
|
|
||||||
For required specialization, document the reason briefly so a future audit
|
|
||||||
does not repeatedly flag it. For drift or architectural divergence, identify
|
|
||||||
the preferred convention and why it is preferable.
|
|
||||||
|
|
||||||
### 3. Build a duplication inventory
|
|
||||||
|
|
||||||
Search for three kinds of repetition:
|
|
||||||
|
|
||||||
1. **Exact duplication:** identical Go logic, prompt text, schema fragments, or
|
|
||||||
test support.
|
|
||||||
2. **Structural duplication:** the same algorithm or lifecycle expressed with
|
|
||||||
renamed domain types.
|
|
||||||
3. **Policy duplication:** the same invariant is independently encoded in
|
|
||||||
several production or test layers.
|
|
||||||
|
|
||||||
Trace callers and consumers before recommending extraction. Record:
|
|
||||||
|
|
||||||
- the repeated responsibility;
|
|
||||||
- participating modules;
|
|
||||||
- meaningful semantic differences;
|
|
||||||
- change history or likely change cadence when discoverable;
|
|
||||||
- defect risk if copies drift;
|
|
||||||
- proposed owner and API shape; and
|
|
||||||
- code or assets that would remain module-owned.
|
|
||||||
|
|
||||||
Similar code is not sufficient evidence. Prefer a shared abstraction when at
|
|
||||||
least one of the following is true:
|
|
||||||
|
|
||||||
- three or more modules independently implement the same nontrivial invariant;
|
|
||||||
- two modules share correctness-sensitive behavior that must evolve together;
|
|
||||||
- existing duplicated code has already drifted or caused a defect; or
|
|
||||||
- an existing shared contract is being reimplemented locally.
|
|
||||||
|
|
||||||
Avoid extraction when the shared API would need artifact-specific callbacks,
|
|
||||||
large configuration objects, type erasure, or branching on module identity.
|
|
||||||
Those are signs that visual similarity is masking separate responsibilities.
|
|
||||||
|
|
||||||
## Convention Review
|
|
||||||
|
|
||||||
Evaluate the following conventions across all five modules.
|
|
||||||
|
|
||||||
### Package organization
|
|
||||||
|
|
||||||
- Comparable responsibilities use predictable filenames and package-local
|
|
||||||
ownership.
|
|
||||||
- Optional specialized files, such as catalog or registry wiring, are present
|
|
||||||
only where the module has that responsibility.
|
|
||||||
- Exported identifiers are limited to framework and registration contracts.
|
|
||||||
- Test helpers remain local unless sharing them improves test clarity without
|
|
||||||
coupling independent suites.
|
|
||||||
|
|
||||||
### Construction and registration
|
|
||||||
|
|
||||||
- Required dependencies fail during construction.
|
|
||||||
- Options are decoded and unknown options rejected consistently.
|
|
||||||
- `ModuleSpec`, reference slots, artifact kind, execution class, and builder
|
|
||||||
behavior agree with runtime behavior.
|
|
||||||
- Construction resolves stable reference-derived state where possible, while
|
|
||||||
operation requests own genuinely run- or chunk-specific inputs.
|
|
||||||
- Returned specs, metadata, fingerprints, and byte slices have consistent
|
|
||||||
defensive-copy behavior.
|
|
||||||
|
|
||||||
### Prompt and schema boundary
|
|
||||||
|
|
||||||
- Shared prompt messages come from canonical shared assets; package assets
|
|
||||||
contain only lane-specific wording.
|
|
||||||
- Shared content is exactly identical across manifests and appears in the
|
|
||||||
documented cache-friendly order.
|
|
||||||
- Stable messages and cache boundaries precede the variable transcript.
|
|
||||||
- Declared prompt inputs match reference slots and generated projections.
|
|
||||||
- Private response schemas are strict structural envelopes and reject unknown
|
|
||||||
fields.
|
|
||||||
- Semantic validation remains in deterministic code at the intended boundary.
|
|
||||||
- Schema identity, version, digest, and diagnostics are exposed consistently
|
|
||||||
without leaking schema or prompt content.
|
|
||||||
|
|
||||||
### Extraction and evidence
|
|
||||||
|
|
||||||
- Request validation and contextual error wrapping follow one recognizable
|
|
||||||
pattern.
|
|
||||||
- Reference material aids disambiguation but never becomes transcript evidence.
|
|
||||||
- Source unit IDs are resolved against document order rather than numeric
|
|
||||||
assumptions.
|
|
||||||
- Mapping performs only the deterministic transformations owned by extraction.
|
|
||||||
- Canonical source references, stable ordering, and exact deduplication use
|
|
||||||
consistent policies where artifact semantics agree.
|
|
||||||
- Whole-chunk evidence in scene descriptions is treated as an intentional
|
|
||||||
specialization rather than forced through citation-oriented helpers.
|
|
||||||
- Results do not share mutable state with model responses, references, or
|
|
||||||
requests.
|
|
||||||
|
|
||||||
### Provenance and diagnostics
|
|
||||||
|
|
||||||
- Prompt and response-schema identities and hashes appear consistently in
|
|
||||||
manifest metadata.
|
|
||||||
- Checkpoint fingerprints cover every stable semantic input that could change
|
|
||||||
accepted output, without including credentials, paths, timestamps, or source
|
|
||||||
content.
|
|
||||||
- Registry and catalog projections use canonical bytes and retain bounded
|
|
||||||
provenance.
|
|
||||||
- Errors and diagnostics are contextual, bounded, and free of raw reference or
|
|
||||||
secret content.
|
|
||||||
|
|
||||||
### Tests
|
|
||||||
|
|
||||||
- Each important contract has one clear test owner.
|
|
||||||
- Equivalent risks receive comparable coverage without requiring identical test
|
|
||||||
file layouts.
|
|
||||||
- Schema tests cover required structure and unknown-field rejection without
|
|
||||||
duplicating every semantic validator case.
|
|
||||||
- Extractor tests cover request validation, response mapping, evidence,
|
|
||||||
ordering, references, and provider failures at the narrowest stable boundary.
|
|
||||||
- Prompt tests prepare real embedded assets and protect input placement,
|
|
||||||
ordering, cache boundaries, and content-safety properties without freezing
|
|
||||||
exact prompt text.
|
|
||||||
- Registration or production tests prove assembly once and do not repeat
|
|
||||||
package-local behavior unnecessarily.
|
|
||||||
- Obsolete, redundant, representation-specific, or impossible fixtures are
|
|
||||||
identified for deletion or simplification.
|
|
||||||
|
|
||||||
## Shared-Code Decision Framework
|
|
||||||
|
|
||||||
Recommend the narrowest owner that matches the repeated responsibility:
|
|
||||||
|
|
||||||
1. Keep artifact semantics in the module that owns the artifact.
|
|
||||||
2. Use `internal/modules/dnd/shared` for D&D-wide mechanics with identical
|
|
||||||
semantics, such as prompt inputs or source-unit reference handling.
|
|
||||||
3. Use a focused D&D subpackage for behavior shared by a subset of lanes, such
|
|
||||||
as NPC registry projection, when it has a coherent domain contract.
|
|
||||||
4. Use a framework package only for transport-neutral or domain-neutral
|
|
||||||
behavior owned by the framework.
|
|
||||||
|
|
||||||
Evaluate these candidate categories without assuming they should be extracted:
|
|
||||||
|
|
||||||
- extractor request precondition validation;
|
|
||||||
- private response-schema loading and metadata assembly;
|
|
||||||
- prompt asset registration and hashing;
|
|
||||||
- source-reference conversion, canonical ordering, and exact deduplication;
|
|
||||||
- manifest metadata and checkpoint fingerprint assembly;
|
|
||||||
- NPC registry resolution and names-only projection;
|
|
||||||
- strict option decoding and module registration;
|
|
||||||
- diagnostic redaction and bounded error context; and
|
|
||||||
- repeated test fixtures or schema-validation utilities.
|
|
||||||
|
|
||||||
For prompts and schemas:
|
|
||||||
|
|
||||||
- Factor prompt wording into a shared asset only when every consumer needs the
|
|
||||||
exact same text and should receive future changes atomically.
|
|
||||||
- Prefer an existing shared asset over a new near-duplicate.
|
|
||||||
- Do not create shared prompt fragments solely because prose is similar.
|
|
||||||
- Keep private response schemas package-owned unless a genuine shared wire
|
|
||||||
contract exists.
|
|
||||||
- Do not introduce shared JSON Schema fragments or generation unless the audit
|
|
||||||
demonstrates a maintenance problem that outweighs tooling and indirection.
|
|
||||||
|
|
||||||
## Additional Quality Review
|
|
||||||
|
|
||||||
Beyond consistency and duplication, inspect:
|
|
||||||
|
|
||||||
- functions with high cognitive complexity or responsibilities that can be
|
|
||||||
separated without obscuring the extraction flow;
|
|
||||||
- repeated linear scans inside loops, avoidable serialization, unnecessary
|
|
||||||
allocations, or per-chunk reconstruction of stable state;
|
|
||||||
- hidden mutation, aliasing, or inconsistent clone boundaries;
|
|
||||||
- fingerprint omissions that could permit stale checkpoint reuse;
|
|
||||||
- prompt inputs or references repeated unnecessarily across messages;
|
|
||||||
- unreachable defensive checks or validation performed redundantly at several
|
|
||||||
layers;
|
|
||||||
- errors that lose module, field, source, or stage context;
|
|
||||||
- stale documentation, fixtures, names, compatibility aliases, or comments;
|
|
||||||
- public or package abstractions that have only one artificial consumer; and
|
|
||||||
- opportunities to delete code after a shared helper replaces it.
|
|
||||||
|
|
||||||
Performance recommendations must identify a plausible workload and complexity
|
|
||||||
impact. Do not recommend micro-optimization without evidence.
|
|
||||||
|
|
||||||
## Evidence Collection
|
|
||||||
|
|
||||||
Perform the audit in this order:
|
|
||||||
|
|
||||||
1. Read the architecture, testing, documentation, module, LLM, pipeline, and
|
|
||||||
relevant integration contracts.
|
|
||||||
2. Build the five-module comparison matrix from definitions and assets.
|
|
||||||
3. Use graph similarity only to identify candidates; read the complete
|
|
||||||
functions and trace their callers before classifying them.
|
|
||||||
4. Compare prompt manifests and shared assets byte-for-byte, then compare local
|
|
||||||
prompt semantics.
|
|
||||||
5. Compare private schemas structurally and map each field to its DTO, mapper,
|
|
||||||
durable artifact, and validator owner.
|
|
||||||
6. Trace reference and fingerprint data from construction through the LLM
|
|
||||||
request and checkpoint identity.
|
|
||||||
7. Review focused tests alongside the behavior they own.
|
|
||||||
8. Run the existing focused and repository-wide validation commands to
|
|
||||||
distinguish current failures from maintainability observations.
|
|
||||||
|
|
||||||
Do not modify production code, tests, prompts, schemas, examples, or
|
|
||||||
current-behavior documentation during the audit.
|
|
||||||
|
|
||||||
## Finding Standard
|
|
||||||
|
|
||||||
Every reported finding must contain:
|
|
||||||
|
|
||||||
- severity: high, medium, or low;
|
|
||||||
- category: convention drift, duplication, architecture, correctness,
|
|
||||||
performance, testing, or documentation;
|
|
||||||
- affected modules and exact file or symbol references;
|
|
||||||
- observed behavior and the convention or invariant it is compared against;
|
|
||||||
- concrete maintenance, correctness, cost, or security impact;
|
|
||||||
- recommended target state and ownership;
|
|
||||||
- why the recommendation is preferable to leaving the code separate; and
|
|
||||||
- validation or migration considerations.
|
|
||||||
|
|
||||||
Order findings by severity and impact, not by module. Separate confirmed
|
|
||||||
findings from optional improvements. State explicitly when no issue is found in
|
|
||||||
a comparison area.
|
|
||||||
|
|
||||||
For each apparent duplication, the audit must choose one outcome:
|
|
||||||
|
|
||||||
- extract now;
|
|
||||||
- harmonize without sharing;
|
|
||||||
- retain intentionally separate; or
|
|
||||||
- defer pending a named missing requirement.
|
|
||||||
|
|
||||||
Do not report speculative abstractions as findings. Record them, if useful, as
|
|
||||||
rejected or deferred candidates with the reason.
|
|
||||||
|
|
||||||
## Audit Deliverable
|
|
||||||
|
|
||||||
The completed audit should provide:
|
|
||||||
|
|
||||||
1. an executive conclusion addressing convention consistency, shared-code
|
|
||||||
opportunities, and overall code quality;
|
|
||||||
2. the completed five-module comparison matrix;
|
|
||||||
3. prioritized findings with evidence and recommendations;
|
|
||||||
4. intentional differences that should be preserved;
|
|
||||||
5. rejected or deferred sharing candidates and rationale;
|
|
||||||
6. a proposed refactoring sequence grouped into independently safe changes;
|
|
||||||
and
|
|
||||||
7. validation commands and any residual risks.
|
|
||||||
|
|
||||||
The audit should be actionable enough to support a later decision-complete
|
|
||||||
implementation plan, but it must not implement or silently commit any
|
|
||||||
recommendation.
|
|
||||||
|
|
||||||
## Completion Criteria
|
|
||||||
|
|
||||||
The strategy has been followed when:
|
|
||||||
|
|
||||||
- all five primary packages and their prompts, schemas, registration,
|
|
||||||
provenance, reference handling, mapping, and tests have been compared;
|
|
||||||
- every divergence has a classification;
|
|
||||||
- every repeated candidate has an ownership and keep/share decision;
|
|
||||||
- architecture and testing-policy constraints are applied explicitly;
|
|
||||||
- findings cite exact evidence and explain impact;
|
|
||||||
- intentional specialization is documented alongside drift;
|
|
||||||
- repository validation results are recorded; and
|
|
||||||
- no production changes were made as part of the audit.
|
|
||||||
|
|
||||||
## Audit Results
|
|
||||||
|
|
||||||
Status: Complete
|
|
||||||
|
|
||||||
### Executive Conclusion
|
|
||||||
|
|
||||||
The five D&D extraction modules follow a coherent overall convention: all are
|
|
||||||
typed, production-registered extractors with strict private response schemas,
|
|
||||||
package-owned artifact mapping, bounded provenance, shared canonical prompt
|
|
||||||
assets, defensive result ownership, and deterministic downstream validation.
|
|
||||||
Their differences in catalog/registry state, reference slots, local prompt
|
|
||||||
assets, whole-chunk versus cited evidence, and scene prose cleanup are explained
|
|
||||||
by artifact semantics and should remain.
|
|
||||||
|
|
||||||
The consistency question is therefore **mostly yes, with two material
|
|
||||||
exceptions**. Spell artifact ordering and all four citation extractors'
|
|
||||||
reference ordering assume numeric unit IDs instead of source-document order,
|
|
||||||
and spell/NPC checkpoint identities omit stable mapping policies. Both can
|
|
||||||
change durable results or reuse results produced under different semantics.
|
|
||||||
Smaller drift exists in common request preflight, scene semantic-validation
|
|
||||||
ownership, prompt cache-boundary coverage, package surface conventions, and
|
|
||||||
the documentation of the common extractor contract for future lanes.
|
|
||||||
|
|
||||||
The shared-code question is **yes, but only for two narrow D&D-wide
|
|
||||||
responsibilities**: common chunk-extraction preflight and document-aware
|
|
||||||
source-reference ordering/canonicalization. They belong in
|
|
||||||
`internal/modules/dnd/shared`, not in the framework. Private response DTOs,
|
|
||||||
artifact mapping, structured LLM calls, prompt prose, response schemas,
|
|
||||||
metadata assembly, error prefixes, and typed test fakes should remain
|
|
||||||
package-owned; sharing them would require callbacks, type erasure, module
|
|
||||||
branching, or broad configuration.
|
|
||||||
|
|
||||||
Overall code quality is **good**. Construction, registration, cloning,
|
|
||||||
diagnostics, schema loading, prompt composition, and validator composition are
|
|
||||||
clear and consistently tested. The repository passes all current validation.
|
|
||||||
The recommended work is targeted correction and consolidation, not a redesign
|
|
||||||
of the extractor family.
|
|
||||||
|
|
||||||
### Final Module Comparison Matrix
|
|
||||||
|
|
||||||
| Module | Common contract | Required specialization | Prompt/schema boundary | Evidence and ordering | Provenance and tests |
|
|
||||||
| --- | --- | --- | --- | --- | --- |
|
|
||||||
| Spells | `dnd/spells` -> `dnd/spell-list`; typed LLM extractor; strict private DTO/schema; append merge and typed normalize/validate ([spec](../../internal/modules/dnd/extract/spells/extractor.go#L207)) | Prepared spell catalog and optional NPC registry projection; catalog prompt input ([constructor](../../internal/modules/dnd/extract/spells/extractor.go#L73)) | Shared evidence/input messages plus local task, instructions, catalog, and transcript; semantic catalog validation remains deterministic ([manifest](../../internal/modules/dnd/extract/spells/assets/prompts/dnd.spells.yaml#L23)) | Attaches current transcript ID and preserves invalid candidates, but sorts artifacts/references by numeric unit ID rather than document position ([canonicalization](../../internal/modules/dnd/extract/spells/canonicalize.go#L10)) | Prompt/schema/catalog/NPC projection fingerprints, but no mapping-policy fingerprint; broad extractor tests, incomplete full prompt cache-order assertion ([fingerprints](../../internal/modules/dnd/extract/spells/extractor.go#L144)) |
|
|
||||||
| NPCs | `dnd/npcs` -> `dnd/npc-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/npcs/extractor.go#L159)) | Deterministic NPC identity derivation; campaign references remain request context ([mapping](../../internal/modules/dnd/extract/npcs/canonicalize.go#L94)) | Shared evidence/input messages plus NPC-local task/instructions/transcript; private schema owns transport only ([manifest](../../internal/modules/dnd/extract/npcs/assets/prompts/dnd.npcs.yaml#L17)) | Artifact order resolves document positions; reference list still sorts numeric IDs before exact deduplication ([canonicalization](../../internal/modules/dnd/extract/npcs/canonicalize.go#L11)) | Prompt/schema/identity fingerprints, but no mapping-policy fingerprint; weakest focused preflight matrix and partial prompt ordering coverage ([fingerprints](../../internal/modules/dnd/extract/npcs/extractor.go#L91)) |
|
|
||||||
| Combat turns | `dnd/combat-turns` -> `dnd/combat-turn-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/combatturns/extractor.go#L201)) | Optional prepared NPC registry; all combat kinds remain raw validator candidates ([constructor](../../internal/modules/dnd/extract/combatturns/extractor.go#L68)) | Shared evidence/NPC/input messages plus combat-local task/instructions/transcript ([manifest](../../internal/modules/dnd/extract/combatturns/assets/prompts/dnd.combat_turns.yaml#L20)) | Artifact order uses valid source positions; extractor, normalizer, and invariant validator duplicate differing reference-order mechanics ([extractor](../../internal/modules/dnd/extract/combatturns/canonicalize.go#L10), [normalizer](../../internal/modules/dnd/normalize/combatturns/normalizer.go#L228)) | Mapping policy is fingerprinted; complete request preconditions, mapping, registry, and provider tests, but incomplete full prompt cache-order assertion ([fingerprints](../../internal/modules/dnd/extract/combatturns/extractor.go#L126)) |
|
|
||||||
| NPC interactions | `dnd/npc-interactions` -> `dnd/npc-interaction-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L203)) | Required NPC registry and names-only projection; exact identity remains in focused domain package ([constructor](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L68)) | Shared evidence/NPC/input messages plus interaction-local task/instructions/transcript ([manifest](../../internal/modules/dnd/extract/npcinteractions/assets/prompts/dnd.npc_interactions.yaml#L20)) | Extractor references sort numeric IDs, while the interaction model already owns correct document-aware canonicalization at too-narrow a layer ([extractor](../../internal/modules/dnd/extract/npcinteractions/canonicalize.go#L10), [model helper](../../internal/modules/dnd/npcinteractions/canonical.go#L17)) | Mapping, identity, prompt, schema, and registry projection are fingerprinted; focused suite omits nil-receiver and full prompt cache-order cases ([fingerprints](../../internal/modules/dnd/extract/npcinteractions/extractor.go#L126)) |
|
|
||||||
| Scene descriptions | `dnd/scene-descriptions` -> `dnd/scene-description-list`; same typed lifecycle ([spec](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L174)) | One summary per chunk, whole-chunk evidence, and fingerprinted prose trimming; no catalog/NPC projection ([mapping](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L150)) | Local task/instructions plus shared input/transcript; schema currently duplicates semantic enum/non-empty policy owned by shape validation ([schema](../../internal/modules/dnd/extract/scenedescriptions/assets/schemas/dnd_scene_descriptions_llm.v1.json#L8), [validator](../../internal/modules/dnd/validate/scenedescriptions/shape/validator.go#L49)) | Whole materialized chunk becomes one source range; citation ordering is not applicable | Mapping policy is fingerprinted; strongest full prompt order/cache test, but preflight omits nil receiver/context cases ([fingerprints](../../internal/modules/dnd/extract/scenedescriptions/extractor.go#L98), [prompt test](../../internal/modules/dnd/extract/scenedescriptions/scriptorium_assets_test.go#L15)) |
|
|
||||||
|
|
||||||
All five reject unknown options, register through the production D&D registrar,
|
|
||||||
return independently owned results, and use package-local structured response
|
|
||||||
types. No inconsistent secret handling, raw prompt/schema diagnostic exposure,
|
|
||||||
hidden result aliasing, or unregistered audited extractor was found.
|
|
||||||
|
|
||||||
### Prioritized Findings
|
|
||||||
|
|
||||||
#### High
|
|
||||||
|
|
||||||
1. **Source references and spell artifacts can be durably ordered contrary to
|
|
||||||
transcript order.**
|
|
||||||
- **Category:** correctness and architecture.
|
|
||||||
- **Affected modules:** spell, NPC, combat-turn, and NPC-interaction
|
|
||||||
extractors; related spell/combat/NPC normalizers and combat invariants.
|
|
||||||
- **Evidence:** spell selects the smallest positive numeric unit ID
|
|
||||||
([`earliestSourceUnit`](../../internal/modules/dnd/extract/spells/canonicalize.go#L62));
|
|
||||||
all four citation extractors sort reference endpoints numerically
|
|
||||||
([spell](../../internal/modules/dnd/extract/spells/canonicalize.go#L30),
|
|
||||||
[NPC](../../internal/modules/dnd/extract/npcs/canonicalize.go#L31),
|
|
||||||
[combat](../../internal/modules/dnd/extract/combatturns/canonicalize.go#L30),
|
|
||||||
[interaction](../../internal/modules/dnd/extract/npcinteractions/canonicalize.go#L30)).
|
|
||||||
Valid source documents require unique positive IDs, not monotonically
|
|
||||||
increasing IDs
|
|
||||||
([`ValidateDocument`](../../internal/core/source/validation.go#L8)).
|
|
||||||
The interaction model demonstrates the correct document-aware comparison
|
|
||||||
([`SourceRefLess`](../../internal/modules/dnd/npcinteractions/canonical.go#L51)).
|
|
||||||
- **Impact:** valid evidence can be reordered away from transcript order;
|
|
||||||
spell casts with invalid or later evidence can precede earlier valid
|
|
||||||
casts. This changes durable list order, evidence presentation, merge input,
|
|
||||||
and checkpointed results.
|
|
||||||
- **Target state and owner:** move a document-backed `SourceRefOrder` to
|
|
||||||
`internal/modules/dnd/shared`, with `EarliestValid` and `Canonicalize`
|
|
||||||
operations. Preserve invalid candidates, exact deduplication, stable ties,
|
|
||||||
nil/empty distinction, cloning, and deterministic invalid fallback.
|
|
||||||
Artifact comparison, DTO conversion, source-ID attachment, and repair
|
|
||||||
accounting remain local.
|
|
||||||
- **Why shared:** the rule is a D&D-wide evidence invariant already
|
|
||||||
implemented by extract, normalize, and validate consumers; continued
|
|
||||||
copies have already diverged. A shared position index also reduces repeated
|
|
||||||
`UnitIndex` scans from approximately `O(A log A * R * U)` ordering work to
|
|
||||||
`O(U + A*R + A log A)` for `A` artifacts, `R` references, and `U` units.
|
|
||||||
- **Migration/validation:** add non-monotonic, invalid, duplicate, nil/empty,
|
|
||||||
aliasing, stable-tie, and repair-count tests; migrate the interaction model
|
|
||||||
and combat normalize/invariant pair before extractors; add/bump mapping
|
|
||||||
policy fingerprints so old checkpoints miss intentionally.
|
|
||||||
|
|
||||||
2. **Spell and NPC checkpoint identities omit stable mapping policies.**
|
|
||||||
- **Category:** correctness and data integrity.
|
|
||||||
- **Affected modules:** spell and NPC extractors.
|
|
||||||
- **Evidence:** both extractors perform deterministic ordering,
|
|
||||||
canonicalization, source attachment, and mapping
|
|
||||||
([spell mapping](../../internal/modules/dnd/extract/spells/canonicalize.go#L10),
|
|
||||||
[NPC mapping](../../internal/modules/dnd/extract/npcs/canonicalize.go#L11)),
|
|
||||||
but their fingerprint providers name catalog/projection or identity
|
|
||||||
policies without a mapping policy
|
|
||||||
([spell fingerprints](../../internal/modules/dnd/extract/spells/extractor.go#L144),
|
|
||||||
[NPC fingerprints](../../internal/modules/dnd/extract/npcs/extractor.go#L91)).
|
|
||||||
Prepared fingerprints are lane-scoped
|
|
||||||
([collector](../../internal/framework/pipeline/prepared_fingerprints.go#L23))
|
|
||||||
and restore requires exact normalized equality
|
|
||||||
([comparison](../../internal/framework/checkpoint/loader.go#L301)).
|
|
||||||
- **Impact:** a mapping-policy code change can reuse a checkpoint produced
|
|
||||||
under older artifact/evidence semantics when prompt and schema bytes are
|
|
||||||
unchanged.
|
|
||||||
- **Target state and owner:** each extractor owns an explicit stable
|
|
||||||
mapping/canonicalization policy fingerprint. Metadata assembly stays
|
|
||||||
package-local.
|
|
||||||
- **Why preferable:** a local named semantic fingerprint directly closes
|
|
||||||
the reuse gap; a generic metadata builder would only hide module-specific
|
|
||||||
omissions behind configuration.
|
|
||||||
- **Migration/validation:** add provider and prepared-checkpoint restore
|
|
||||||
tests. Adding a fingerprint safely invalidates existing identities by full
|
|
||||||
list mismatch; no artifact payload migration is required.
|
|
||||||
|
|
||||||
#### Medium
|
|
||||||
|
|
||||||
3. **Common extraction preflight is copied across five production callers and
|
|
||||||
has already drifted in test protection.**
|
|
||||||
- **Category:** duplication and testing.
|
|
||||||
- **Affected modules:** all five extractors.
|
|
||||||
- **Evidence:** every `Extract` validates the same context/source/chunk/unit
|
|
||||||
prerequisites before calling
|
|
||||||
[`ChunkPromptMaterial`](../../internal/modules/dnd/shared/extraction_inputs.go#L12),
|
|
||||||
but focused coverage ranges from a complete spell/combat matrix to only
|
|
||||||
cancellation and source mismatch for NPCs
|
|
||||||
([spell tests](../../internal/modules/dnd/extract/spells/extractor_test.go#L235),
|
|
||||||
[NPC tests](../../internal/modules/dnd/extract/npcs/extractor_test.go#L139)).
|
|
||||||
- **Impact:** validation order, error behavior, or a newly required common
|
|
||||||
precondition can diverge silently among modules.
|
|
||||||
- **Target state and owner:** add
|
|
||||||
`shared.PrepareChunkExtraction(ctx, req) (contracts.LLMInputMaterial,
|
|
||||||
error)` for cancellation, non-nil source/chunk, non-empty units, and
|
|
||||||
matching cloned material. Receiver/client checks, specialized references,
|
|
||||||
provider calls, typed results, and contextual wrapping remain local.
|
|
||||||
- **Why shared:** this extends the existing common material boundary with
|
|
||||||
one coherent invariant and serves five real callers without callbacks or
|
|
||||||
module configuration.
|
|
||||||
- **Migration/validation:** shared table tests own common inputs; package
|
|
||||||
tests retain nil receiver/dependency, specialized reference, wrapped
|
|
||||||
error, and provider cases. Preserve current validation order and useful
|
|
||||||
package context.
|
|
||||||
|
|
||||||
4. **Scene semantic validity has two production owners.**
|
|
||||||
- **Category:** architecture.
|
|
||||||
- **Affected module:** scene descriptions.
|
|
||||||
- **Evidence:** the private response schema enforces the scene-kind enum and
|
|
||||||
non-empty title/summary
|
|
||||||
([schema](../../internal/modules/dnd/extract/scenedescriptions/assets/schemas/dnd_scene_descriptions_llm.v1.json#L8));
|
|
||||||
deterministic shape validation independently enforces the same policy
|
|
||||||
([validator](../../internal/modules/dnd/validate/scenedescriptions/shape/validator.go#L49)).
|
|
||||||
The documented schema boundary assigns semantic enum/non-empty rules to
|
|
||||||
deterministic validators
|
|
||||||
([LLM internals](../internal/llm.md#L169)).
|
|
||||||
- **Impact:** the two policies can drift and produce provider-dependent
|
|
||||||
rejection before typed validation, while non-LLM artifacts see only the
|
|
||||||
validator.
|
|
||||||
- **Target state and owner:** retain JSON type, required/nullability, and
|
|
||||||
unknown-field constraints in the package-private schema; make the scene
|
|
||||||
shape validator the sole semantic owner.
|
|
||||||
- **Why harmonize without sharing:** only scene has this duplicated policy;
|
|
||||||
moving it to the existing validator removes an owner without inventing an
|
|
||||||
abstraction.
|
|
||||||
- **Migration/validation:** update schema structural tests and validator
|
|
||||||
semantic tests, then expect the schema digest/checkpoint identity to
|
|
||||||
change. Verify provider-decoded invalid candidates reach deterministic
|
|
||||||
validation.
|
|
||||||
|
|
||||||
#### Low
|
|
||||||
|
|
||||||
5. **Four prompt suites do not fully protect the documented message-order and
|
|
||||||
cache-boundary contract.**
|
|
||||||
- **Category:** testing.
|
|
||||||
- **Affected modules:** spells, NPCs, combat turns, and NPC interactions.
|
|
||||||
- **Evidence:** scene descriptions asserts the complete prepared role order
|
|
||||||
and ephemeral/no-cache placement
|
|
||||||
([test](../../internal/modules/dnd/extract/scenedescriptions/scriptorium_assets_test.go#L15));
|
|
||||||
the four citation suites cover registration and selected inputs but not
|
|
||||||
the full documented sequence
|
|
||||||
([spell tests](../../internal/modules/dnd/extract/spells/scriptorium_assets_test.go),
|
|
||||||
[NPC tests](../../internal/modules/dnd/extract/npcs/scriptorium_assets_test.go),
|
|
||||||
[combat tests](../../internal/modules/dnd/extract/combatturns/scriptorium_assets_test.go),
|
|
||||||
[interaction tests](../../internal/modules/dnd/extract/npcinteractions/scriptorium_assets_test.go)).
|
|
||||||
- **Impact:** manifest edits can move variable content into a cacheable
|
|
||||||
prefix or reorder stable grounding without a focused failure, increasing
|
|
||||||
request cost or reducing prompt quality.
|
|
||||||
- **Target state and owner:** each package test should assert its complete
|
|
||||||
documented prepared sequence and cache flags using the real registry.
|
|
||||||
- **Why local:** lane inputs differ and the behavior belongs to each prompt
|
|
||||||
manifest; shared test setup would obscure the boundary.
|
|
||||||
- **Migration/validation:** assert roles/input identities/cache flags, not
|
|
||||||
exact prompt text, byte counts, or hashes.
|
|
||||||
|
|
||||||
6. **Small exported-surface and defensive-behavior drift remains.**
|
|
||||||
- **Category:** convention drift.
|
|
||||||
- **Affected modules:** spells, combat turns, and D&D shared.
|
|
||||||
- **Evidence:** spell and combat export unused singular `ArtifactType`
|
|
||||||
constants while production uses typed `ArtifactKind`
|
|
||||||
([spell](../../internal/modules/dnd/extract/spells/extractor.go#L17),
|
|
||||||
[combat](../../internal/modules/dnd/extract/combatturns/extractor.go#L17));
|
|
||||||
spell `ManifestMetadata` lacks the nil guard used by the other four
|
|
||||||
([spell](../../internal/modules/dnd/extract/spells/extractor.go#L122),
|
|
||||||
[NPC example](../../internal/modules/dnd/extract/npcs/extractor.go#L74));
|
|
||||||
[`SourceRefCandidate`](../../internal/modules/dnd/shared/unit_refs.go#L90)
|
|
||||||
has only a test caller, ignores its document parameter, and is unsafe for
|
|
||||||
extractor provenance because it trusts model-supplied source identity.
|
|
||||||
- **Impact:** the package surface presents competing artifact vocabulary,
|
|
||||||
zero-value behavior is inconsistent, and an artificial shared API invites
|
|
||||||
incorrect reuse. Immediate runtime impact is limited.
|
|
||||||
- **Target state and owner:** delete the unused constants; align spell nil
|
|
||||||
metadata behavior locally; delete `SourceRefCandidate` when the correct
|
|
||||||
shared reference API lands.
|
|
||||||
- **Why preferable:** deletion and local harmonization clarify existing
|
|
||||||
contracts without adding a helper.
|
|
||||||
- **Migration/validation:** graph search found no production consumers;
|
|
||||||
compile all internal packages and add one spell zero-value metadata test.
|
|
||||||
|
|
||||||
7. **The common D&D extractor contract is observable but only partially
|
|
||||||
documented as a requirement for future lanes.**
|
|
||||||
- **Category:** documentation and convention drift.
|
|
||||||
- **Affected modules:** all five extractors and future D&D extraction
|
|
||||||
modules.
|
|
||||||
- **Evidence:** all five reject unknown options, perform common request
|
|
||||||
preflight, return independently owned results, use package-local private
|
|
||||||
response types, and expose prompt/schema provenance plus checkpoint
|
|
||||||
fingerprints. The extension checklist in
|
|
||||||
[module internals](../internal/modules.md#adding-an-extension) covers
|
|
||||||
registration, package-owned assets, prompt ordering, and general option
|
|
||||||
and validation coverage, while
|
|
||||||
[LLM internals](../internal/llm.md#prompt-and-schema-assets) documents the
|
|
||||||
private-schema boundary. Neither location consolidates the remaining
|
|
||||||
behaviors into a normative D&D extractor contract. The missing spell/NPC
|
|
||||||
mapping fingerprints and uneven preflight and prompt-contract coverage
|
|
||||||
demonstrate that conventions discoverable from current packages can
|
|
||||||
still drift.
|
|
||||||
- **Impact:** an additional extractor can appear locally consistent while
|
|
||||||
silently accepting misspelled options, omitting a stable semantic input
|
|
||||||
from checkpoint identity, returning aliased mutable data, implementing
|
|
||||||
incomplete preflight, or missing focused contract coverage.
|
|
||||||
- **Target state and owner:** add a compact **D&D extractor contract**
|
|
||||||
subsection to `docs/internal/modules.md`, incorporated into or placed
|
|
||||||
immediately after **Adding An Extension**. Specify behavioral
|
|
||||||
responsibilities rather than filenames or boilerplate:
|
|
||||||
- reject unknown options unless the option namespace is intentionally
|
|
||||||
extensible;
|
|
||||||
- use the shared request preflight contract while retaining receiver,
|
|
||||||
dependency, and lane-specific checks locally;
|
|
||||||
- return results and exposed metadata that are independently owned and
|
|
||||||
safe for caller mutation;
|
|
||||||
- keep the private response DTO, structural schema, schema identity,
|
|
||||||
provider-response mapping, durable artifact conversion, and
|
|
||||||
lane-specific diagnostics package-owned;
|
|
||||||
- include every stable semantic input that can change durable results in
|
|
||||||
checkpoint identity, including prompt, schema, mapping,
|
|
||||||
canonicalization, prepared reference projection, identity,
|
|
||||||
normalization, and trimming policies when applicable; and
|
|
||||||
- consider focused behavioral coverage for construction and
|
|
||||||
registration, option rejection, preflight, provider failures,
|
|
||||||
structured-output decoding, mapping and ownership, prompt
|
|
||||||
role/input/cache order, and checkpoint invalidation.
|
|
||||||
- **Why documentation rather than another abstraction:** these are shared
|
|
||||||
obligations, not one shared implementation. A checklist makes omissions
|
|
||||||
visible without introducing a configurable metadata builder, generic
|
|
||||||
mapper, shared test fixture, mandatory file layout, or exact-output
|
|
||||||
change-detector tests. Detailed prompt, schema, pipeline, and testing
|
|
||||||
policies should remain linked rather than duplicated.
|
|
||||||
- **Migration/validation:** update the extension checklist and relevant
|
|
||||||
cross-links when the shared preflight helper is documented. Review the
|
|
||||||
text against all five lanes and the completed audit matrix. Do not require
|
|
||||||
exact prompt text, hashes, prefix lengths, test counts, filenames, or
|
|
||||||
fixture layouts.
|
|
||||||
|
|
||||||
#### Optional improvement
|
|
||||||
|
|
||||||
The current module documentation lists only four production consumers of
|
|
||||||
`ChunkPromptMaterial`, omitting scene descriptions
|
|
||||||
([documentation](../internal/modules.md#L67)). Correct that list when the shared
|
|
||||||
preflight boundary is documented. This is localized documentation maintenance,
|
|
||||||
not a separate production design finding.
|
|
||||||
|
|
||||||
No actionable issue was found in option strictness, production registration,
|
|
||||||
typed codec selection, result cloning, prompt/schema content redaction, local
|
|
||||||
prompt semantic ownership, catalog/NPC source-evidence separation, provider
|
|
||||||
error wrapping, or secret handling.
|
|
||||||
|
|
||||||
### Intentional Differences To Preserve
|
|
||||||
|
|
||||||
- **Prepared specialized state:** spells retains a catalog and optional NPC
|
|
||||||
resolver; combat retains an optional NPC resolver; interactions requires one;
|
|
||||||
NPC and scene extraction need only request material. Construction-time
|
|
||||||
identity belongs only where the artifact uses it.
|
|
||||||
- **Reference slots:** catalog and NPC registry slots follow grounding needs.
|
|
||||||
The interaction registry is required; combat/spell registry use is optional.
|
|
||||||
Campaign references remain prompt context and never become evidence.
|
|
||||||
- **Evidence model:** scene descriptions cites the whole materialized chunk;
|
|
||||||
forcing it through citation DTO/canonicalization machinery would weaken its
|
|
||||||
one-summary-per-chunk contract.
|
|
||||||
- **Prompt assets:** only byte-identical canonical messages are shared. Local
|
|
||||||
task, instruction, catalog, identity, and evidence wording changes with the
|
|
||||||
artifact and must not be coupled.
|
|
||||||
- **Private schemas and DTOs:** similar `source_refs` fragments do not form a
|
|
||||||
separately versioned wire contract. Package ownership keeps response changes
|
|
||||||
aligned with mapping and diagnostics.
|
|
||||||
- **Prose handling:** scene title/summary trimming is a deliberate fingerprinted
|
|
||||||
mapping policy; citation-lane names and enum candidates remain raw for their
|
|
||||||
normalizers and validators.
|
|
||||||
- **Test organization:** file layout, typed fakes, schema compilation helpers,
|
|
||||||
and real-registry prompt setup remain package-local. Equivalent risks need
|
|
||||||
comparable coverage, not identical fixtures or filenames.
|
|
||||||
|
|
||||||
### Shared-Code Decisions
|
|
||||||
|
|
||||||
| Decision | Owner and scope | Why this is the narrow correct boundary |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Extract common preflight | `internal/modules/dnd/shared`; validate common typed extraction request state and return cloned matching `LLMInputMaterial` | Five current callers repeat one prerequisite to the existing `ChunkPromptMaterial` boundary. Receiver/dependency/specialized checks remain readable and local. |
|
|
||||||
| Extract source-reference order | `internal/modules/dnd/shared`; document-backed `SourceRefOrder.EarliestValid` and `.Canonicalize` over durable `[]source.SourceRef` | Multiple extract, normalize, model, and validate consumers must evolve together; the rule is D&D-wide but has no demonstrated non-D&D/framework consumer. |
|
|
||||||
| Harmonize mapping fingerprints | Spell and NPC extractor packages | The missing values are module-semantic; local named fingerprints are safer than a configurable metadata builder. |
|
|
||||||
| Harmonize scene validation ownership | Private scene schema plus `validate/scenedescriptions/shape` | Remove semantic keywords from the transport schema and keep the already registered durable validator as sole owner. No sharing is needed. |
|
|
||||||
| Harmonize prompt contract tests | Four citation extractor test suites | Each manifest owns its role/input/cache sequence; consistent assertions should stay beside distinct assets. |
|
|
||||||
| Harmonize preflight coverage | All five package suites after shared preflight | Shared tests own common branches; local tests retain package-visible context and specializations. |
|
|
||||||
| Harmonize package hygiene | Spell/combat extractors and D&D shared | Delete unused exports/artificial API and align spell metadata nil behavior without introducing a new abstraction. |
|
|
||||||
| Document the D&D extractor contract | `docs/internal/modules.md`, linked to existing LLM and testing policies | The five lanes share behavioral obligations that should guide future extensions, but they do not justify a generic implementation or rigid package template. |
|
|
||||||
|
|
||||||
### Rejected Or Deferred Candidates
|
|
||||||
|
|
||||||
| Candidate | Decision and rationale |
|
|
||||||
| --- | --- |
|
|
||||||
| Deprecated `roster` slot asymmetry | **Deferred pending a product compatibility decision.** Configuration documents `roster` as a deprecated `party` alias ([config](../config.md#L526)), while scene removes it. Choose a removal release or uniform alias lifetime before changing slots and migration guidance. |
|
|
||||||
| Spell response-schema key/ID naming | **Deferred pending a provenance migration decision.** The spell names differ from the `_llm`/`.llm` convention, but schema identity is persisted metadata. Renaming needs an alias or an explicit manifest/checkpoint compatibility break. |
|
|
||||||
| Prompt registration/hash wrapper | **Rejected.** `shared.PromptAssetManifest` already owns coherent composition/hashing. Hiding filesystems, registries, `sync.Once`, and diagnostic nouns would require broad configuration. |
|
|
||||||
| Manifest/fingerprint builder | **Rejected.** Stable inputs are module-specific; a generic builder would accept the same keys/policies as arguments and could conceal omissions such as the current spell/NPC gap. |
|
|
||||||
| Generic artifact mapper | **Rejected.** Private DTO fields, identity, enums, and artifact types require callbacks, type erasure, or module branching. Only reference ordering is genuinely common. |
|
|
||||||
| Generic structured-LLM call wrapper | **Rejected.** Generic output and request configuration would hide an already clear framework client boundary without removing semantic work. |
|
|
||||||
| Shared error-prefix helper | **Rejected.** It would add a module-name parameter to replace three transparent lines and weaken local diagnostic ownership. |
|
|
||||||
| Shared response schemas or prompt prose | **Retain intentionally separate.** Similar structure/text is not an atomic shared contract; private schema and lane prompt changes should not propagate together. |
|
|
||||||
| Shared schema-test or provider-fake utilities | **Retain intentionally separate.** Typed DTOs and package assets are the behavior under test; central fixtures would couple suites and obscure failures. |
|
|
||||||
| Framework-level preflight/reference API | **Rejected.** No non-D&D consumer or framework-owned invariant was found. The D&D shared layer preserves dependency direction and domain ownership. |
|
|
||||||
|
|
||||||
### Recommended Refactoring Sequence
|
|
||||||
|
|
||||||
1. **Land checkpoint identity protection independently.** Add spell and NPC
|
|
||||||
mapping-policy fingerprints with provider and checkpoint-restore tests.
|
|
||||||
This is a small package-local change and protects all later semantic
|
|
||||||
migrations from stale reuse.
|
|
||||||
2. **Consolidate source-reference mechanics.** Add and exhaustively test the
|
|
||||||
D&D shared document-position index/API. Migrate the existing interaction
|
|
||||||
model helper first, then combat normalize plus invariants, then NPC/spell
|
|
||||||
normalizers, and finally the four citation extractors. Keep package mapping
|
|
||||||
and repair accounting local; remove numeric comparators and the unused
|
|
||||||
`SourceRefCandidate` only after all consumers move.
|
|
||||||
3. **Consolidate common extraction preflight.** Extend the existing shared
|
|
||||||
chunk-material boundary, migrate one extractor to establish error/validation
|
|
||||||
compatibility, then migrate the remaining four. Move common branch tables
|
|
||||||
to shared tests and retain package-context smoke tests.
|
|
||||||
4. **Resolve scene validation ownership independently.** Adjust schema tests,
|
|
||||||
remove semantic enum/non-empty constraints from the private schema, retain
|
|
||||||
validator cases, and verify the expected schema fingerprint/checkpoint miss.
|
|
||||||
5. **Close prompt contract coverage.** Add full role/input/cache-boundary
|
|
||||||
assertions to each citation prompt suite without shared fixtures or exact
|
|
||||||
text/hash assertions.
|
|
||||||
6. **Apply low-risk package cleanup.** Remove unused `ArtifactType` constants
|
|
||||||
and add the spell metadata nil guard/test.
|
|
||||||
7. **Document the common extractor contract.** Update module documentation for
|
|
||||||
the shared helper consumers and boundaries, then add the D&D extractor
|
|
||||||
contract checklist described in Finding 7. Link to existing prompt, schema,
|
|
||||||
pipeline, and testing policies rather than copying them.
|
|
||||||
|
|
||||||
Each scope can be reviewed and reverted independently. The sequence is not a
|
|
||||||
decision-complete implementation plan; implementation should still pin exact
|
|
||||||
fingerprint values, exported names, error compatibility, and per-package test
|
|
||||||
cases.
|
|
||||||
|
|
||||||
### Validation And Residual Risks
|
|
||||||
|
|
||||||
Final validation on 2026-07-24:
|
|
||||||
|
|
||||||
```text
|
|
||||||
go test -count=1 ./... PASS
|
|
||||||
go vet ./... PASS
|
|
||||||
go build ./cmd/notarius PASS
|
|
||||||
gofmt -l . PASS (no files listed)
|
|
||||||
git diff --check PASS
|
|
||||||
git diff --no-index --check /dev/null docs/roadmap/audit.md
|
|
||||||
PASS (no whitespace errors; exit 1 denotes differences)
|
|
||||||
```
|
|
||||||
|
|
||||||
The audit also checked that cited relative paths exist in the current working
|
|
||||||
tree. The audit changed no production code, tests, prompts, schemas, examples,
|
|
||||||
or current-behavior documentation.
|
|
||||||
|
|
||||||
Residual risks and limits:
|
|
||||||
|
|
||||||
- This was a static and deterministic-test audit; it did not evaluate live
|
|
||||||
model extraction quality, prompt effectiveness, provider cache-hit rates, or
|
|
||||||
token cost. Prompt changes still need representative human/model evaluation.
|
|
||||||
- Existing tests use mostly monotonic unit IDs, so the ordering defect is not a
|
|
||||||
current failing test. The proposed non-monotonic fixtures are required before
|
|
||||||
changing behavior.
|
|
||||||
- The exact durable order expected for invalid references must remain
|
|
||||||
deterministic and diagnostics-friendly during API design; invalid references
|
|
||||||
must not be discarded merely because default validators usually reject them
|
|
||||||
later.
|
|
||||||
- Mapping and schema fingerprint additions intentionally invalidate prior
|
|
||||||
checkpoint identities. Operators should be told to expect recomputation; no
|
|
||||||
serialized artifact migration is otherwise indicated.
|
|
||||||
- The `roster` alias lifetime and spell schema-identity migration remain human
|
|
||||||
product/compatibility decisions.
|
|
||||||
- Passing repository checks establishes current deterministic correctness, not
|
|
||||||
absence of model-quality regressions or correctness under unrepresented
|
|
||||||
source-document shapes.
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# D&D NPC Interactions
|
|
||||||
|
|
||||||
Status: Implemented
|
|
||||||
|
|
||||||
The current durable and configuration contract is documented in the
|
|
||||||
[D&D NPC interaction artifact](../integrations/dnd-npc-interaction-artifacts.md).
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
The normalized NPC registry intentionally answers only who was identified in a
|
|
||||||
session. It does not answer whether an NPC was merely mentioned, participated
|
|
||||||
in dialogue, or fought alongside or against the party. Add a separate,
|
|
||||||
ordered NPC-interaction artifact for that occurrence-level information rather
|
|
||||||
than expanding the identity registry.
|
|
||||||
|
|
||||||
This feature preserves the minimal-extractor policy: the model identifies one
|
|
||||||
bounded kind of interaction and its supporting transcript evidence. It does
|
|
||||||
not summarize the interaction, infer relationships, or maintain NPC state.
|
|
||||||
|
|
||||||
## Desired End State
|
|
||||||
|
|
||||||
A D&D pipeline can run NPC extraction first and supply its accepted normalized
|
|
||||||
registry to a later `dnd/npc-interactions` lane. The later lane emits an ordered
|
|
||||||
list of evidenced interaction occurrences involving registry NPCs.
|
|
||||||
|
|
||||||
The production identities should be:
|
|
||||||
|
|
||||||
- extractor and normalizer key: `dnd/npc-interactions`;
|
|
||||||
- artifact kind: `dnd/npc-interaction-list`;
|
|
||||||
- durable schema ID: `notarius.dnd.npc_interactions`;
|
|
||||||
- durable schema name: `notarius_dnd_npc_interactions_v1`;
|
|
||||||
- durable schema version: `v1`; and
|
|
||||||
- media type: `application/json`.
|
|
||||||
|
|
||||||
The lane must use the existing D&D module organization, shared prompt assets,
|
|
||||||
typed artifact pipeline, codec boundary, registration pattern, and default
|
|
||||||
validator composition.
|
|
||||||
|
|
||||||
## Artifact Contract
|
|
||||||
|
|
||||||
The durable payload is an object containing an `interactions` array. The array
|
|
||||||
may be empty. Each interaction contains exactly:
|
|
||||||
|
|
||||||
- `name`: the canonical NPC name from the supplied registry;
|
|
||||||
- `kind`: one value from the bounded interaction vocabulary; and
|
|
||||||
- `source_refs`: one or more current-transcript ranges supporting both the NPC
|
|
||||||
identity and the classified interaction.
|
|
||||||
|
|
||||||
Every object rejects unknown fields. The model-facing response should contain
|
|
||||||
only the corresponding name, kind, and source-unit range candidates. Notarius
|
|
||||||
attaches the current source identity deterministically; the model must not
|
|
||||||
reproduce it.
|
|
||||||
|
|
||||||
Do not add a separate interaction ID in the durable contract. Stable ordering,
|
|
||||||
the canonical NPC name, the bounded kind, and exact evidence ranges are enough
|
|
||||||
to identify and audit an occurrence for the present use cases. Revisit durable
|
|
||||||
cross-artifact identity only with a concrete consumer requirement.
|
|
||||||
|
|
||||||
## Interaction Vocabulary
|
|
||||||
|
|
||||||
Use this closed vocabulary:
|
|
||||||
|
|
||||||
| Kind | Meaning |
|
|
||||||
| --- | --- |
|
|
||||||
| `mentioned` | The NPC is referred to, but is not established as present or communicating in the evidenced passage. |
|
|
||||||
| `noncombat_presence` | The NPC is present and relevant to the passage but does not meaningfully participate in dialogue or combat. |
|
|
||||||
| `dialogue` | The NPC speaks, responds, or is directly engaged in a meaningful non-combat exchange. |
|
|
||||||
| `combat_ally` | The NPC actively participates in combat on the party's side. |
|
|
||||||
| `combat_opponent` | The NPC actively participates in combat against the party. |
|
|
||||||
| `other` | The transcript clearly establishes a direct NPC occurrence that fits none of the preceding kinds. |
|
|
||||||
|
|
||||||
`other` is a residual category for positively evidenced activity, not an
|
|
||||||
escape hatch for uncertain classification. Omit a candidate when the
|
|
||||||
transcript does not support one category.
|
|
||||||
|
|
||||||
When activities overlap within one occurrence, apply this precedence:
|
|
||||||
|
|
||||||
1. active combat participation outranks dialogue, presence, and mention;
|
|
||||||
2. dialogue outranks non-combat presence and mention;
|
|
||||||
3. non-combat presence outranks mention; and
|
|
||||||
4. `other` applies only when none of the defined categories describes the
|
|
||||||
evidenced activity.
|
|
||||||
|
|
||||||
Combat alignment is not resolved by precedence. An NPC cannot be both a combat
|
|
||||||
ally and combat opponent in one occurrence; split the record when its alignment
|
|
||||||
meaningfully changes.
|
|
||||||
|
|
||||||
## Occurrence Boundaries And Ordering
|
|
||||||
|
|
||||||
An occurrence represents one NPC, one interaction kind, and one locally
|
|
||||||
coherent transcript passage. Combine repeated evidence only while it supports
|
|
||||||
the same uninterrupted activity. Create separate occurrences when:
|
|
||||||
|
|
||||||
- the interaction kind changes;
|
|
||||||
- combat alignment changes;
|
|
||||||
- a scene or meaningful absence separates repeated activity; or
|
|
||||||
- the NPC is first mentioned and later becomes present.
|
|
||||||
|
|
||||||
Extraction remains chunk-scoped, so an occurrence must not span accepted chunk
|
|
||||||
boundaries. Merge and normalization must not semantically combine occurrences
|
|
||||||
from different chunks.
|
|
||||||
|
|
||||||
Order the durable list by the earliest valid source position. Use canonical
|
|
||||||
name, interaction kind, and canonical source ranges as deterministic
|
|
||||||
tie-breakers. Remove only exact duplicate records; do not use model judgment to
|
|
||||||
collapse nearby occurrences.
|
|
||||||
|
|
||||||
## NPC Grounding And Evidence
|
|
||||||
|
|
||||||
The extractor declares the existing `npcs` reference slot and requires a bound,
|
|
||||||
accepted NPC registry. The normal same-run configuration binds the normalized
|
|
||||||
output of an earlier NPC lane through an ordered generated reference. Existing
|
|
||||||
framework support for a compatible external registry may remain available, but
|
|
||||||
must not weaken the artifact contract.
|
|
||||||
|
|
||||||
Present the registry's names-only projection to the model. An emitted name must
|
|
||||||
match one canonical registry name exactly after the registry's established
|
|
||||||
lookup rules are applied, and the durable artifact retains that canonical
|
|
||||||
display name. Do not copy NPC IDs or registry source references into an
|
|
||||||
interaction.
|
|
||||||
|
|
||||||
The registry establishes available identity, not occurrence. Every interaction
|
|
||||||
must cite current transcript ranges that independently support both the NPC and
|
|
||||||
the selected kind. Campaign references and generated artifacts may disambiguate
|
|
||||||
a name, but they never become interaction evidence.
|
|
||||||
|
|
||||||
If no registry NPC has an evidenced interaction, the correct artifact is an
|
|
||||||
empty list. A missing, rejected, or incompatible required registry handoff is a
|
|
||||||
pipeline dependency failure rather than a request to extract ungrounded names.
|
|
||||||
|
|
||||||
## Prompt And Model Boundary
|
|
||||||
|
|
||||||
Follow the established D&D prompt ordering and cache-boundary policy. Stable
|
|
||||||
shared instructions, lane instructions, campaign references, and the NPC
|
|
||||||
names-only projection precede the variable transcript. Factor wording shared
|
|
||||||
with the spell and combat lanes into the existing shared asset pattern rather
|
|
||||||
than creating nearly identical package-local messages.
|
|
||||||
|
|
||||||
The prompt must:
|
|
||||||
|
|
||||||
- define the closed vocabulary and precedence rules;
|
|
||||||
- ask for occurrences involving only supplied registry NPCs;
|
|
||||||
- distinguish mention from presence and dialogue;
|
|
||||||
- require current-transcript evidence for identity and kind;
|
|
||||||
- forbid summaries, relationship inference, sentiment, aliases, and invented
|
|
||||||
names; and
|
|
||||||
- permit an empty result.
|
|
||||||
|
|
||||||
Keep the private JSON Schema structural. Deterministic code owns canonical-name
|
|
||||||
resolution, enum enforcement at the durable boundary, source-range validation,
|
|
||||||
ordering, exact deduplication, and evidence invariants.
|
|
||||||
|
|
||||||
## Validation And Quality
|
|
||||||
|
|
||||||
Provide production validators and default chains at extraction and
|
|
||||||
normalization boundaries consistent with the existing D&D artifacts. The
|
|
||||||
append-only merge does not require a separate default validator chain.
|
|
||||||
Deterministic validation must reject:
|
|
||||||
|
|
||||||
- missing or extra fields;
|
|
||||||
- empty or unrecognized NPC names;
|
|
||||||
- names absent from the supplied registry;
|
|
||||||
- unknown interaction kinds;
|
|
||||||
- empty, malformed, out-of-source, or reversed evidence ranges; and
|
|
||||||
- records whose evidence comes from a reference rather than the current
|
|
||||||
transcript.
|
|
||||||
|
|
||||||
Any relatedness validator should remain warning-only unless evaluation
|
|
||||||
demonstrates a reliable deterministic rejection rule. Diagnostics must be
|
|
||||||
bounded and must not leak reference contents.
|
|
||||||
|
|
||||||
Evaluate the lane on a small human-reviewed transcript set that includes every
|
|
||||||
category, transitions between categories, multiple occurrences for one NPC,
|
|
||||||
mentions followed by appearances, alignment changes, repeated evidence across
|
|
||||||
chunks, and empty output. Review category agreement, evidence sufficiency,
|
|
||||||
ordering, duplicate behavior, and reliability on the smaller models the
|
|
||||||
application is intended to support. Treat model-output evaluation as a human
|
|
||||||
development aid, not a brittle deterministic test oracle.
|
|
||||||
|
|
||||||
## Documentation Outcomes
|
|
||||||
|
|
||||||
When implemented, document the durable artifact in `docs/integrations/`, add
|
|
||||||
the selectable module and validator contracts to configuration documentation,
|
|
||||||
update the current module and pipeline internals, and provide a maintained
|
|
||||||
ordered-pipeline example showing NPC extraction followed by interaction
|
|
||||||
extraction. Future behavior must remain in this roadmap until it exists.
|
|
||||||
|
|
||||||
## Non-Goals
|
|
||||||
|
|
||||||
This scope does not:
|
|
||||||
|
|
||||||
- add occurrence fields to the normalized NPC registry;
|
|
||||||
- summarize dialogue, combat, or NPC behavior;
|
|
||||||
- infer disposition, relationships, factions, motives, or persistent state;
|
|
||||||
- identify player characters or anonymous groups as NPCs;
|
|
||||||
- add scene participants or duplicate scene-description responsibilities;
|
|
||||||
- reconcile NPC aliases or perform LLM-assisted semantic deduplication;
|
|
||||||
- derive interaction records from registry evidence; or
|
|
||||||
- introduce a DAG, concurrent cross-lane reconciliation, or a new reference
|
|
||||||
mechanism.
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -263,12 +263,25 @@ func TestChangedSemanticSpellCatalogFingerprintCannotResumeRecordedCheckpoint(t
|
|||||||
if _, decision := changedLoader.Normalize("spells", spellnormalize.Key, normalizeDependencies); decision.Reused {
|
if _, decision := changedLoader.Normalize("spells", spellnormalize.Key, normalizeDependencies); decision.Reused {
|
||||||
t.Fatalf("changed normalize fingerprint decision = %#v, want normalize checkpoint cold miss", decision)
|
t.Fatalf("changed normalize fingerprint decision = %#v, want normalize checkpoint cold miss", decision)
|
||||||
}
|
}
|
||||||
|
changedMapping := replaceCheckpointFingerprintValue(t, fingerprints, extractSpellMappingFingerprintName(), "dnd.spells.extract_mapping.v3")
|
||||||
|
assertOnlyCheckpointFingerprintChanged(t, fingerprints, changedMapping, extractSpellMappingFingerprintName())
|
||||||
|
_, mappingLoader, err := checkpointHandlersForRun(settings, Options{}, materialized, changedMapping, []byte("same input"), nil, nil, "", "", true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, decision := mappingLoader.Source(materialized.Input.Module); decision.Reused {
|
||||||
|
t.Fatalf("changed mapping policy decision = %#v, want cold miss", decision)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSpellCatalogFingerprintName() string {
|
func normalizeSpellCatalogFingerprintName() string {
|
||||||
return "normalize:spells:" + spellnormalize.Key + ":effective_catalog"
|
return "normalize:spells:" + spellnormalize.Key + ":effective_catalog"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractSpellMappingFingerprintName() string {
|
||||||
|
return "extract:spells:" + spells.Key + ":mapping_policy"
|
||||||
|
}
|
||||||
|
|
||||||
func replaceCheckpointFingerprintValue(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name, value string) []pipeline.CheckpointFingerprint {
|
func replaceCheckpointFingerprintValue(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name, value string) []pipeline.CheckpointFingerprint {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
changed := append([]pipeline.CheckpointFingerprint(nil), fingerprints...)
|
changed := append([]pipeline.CheckpointFingerprint(nil), fingerprints...)
|
||||||
|
|||||||
@@ -249,6 +249,9 @@ func TestValidateRefValid(t *testing.T) {
|
|||||||
if err := ValidateRef(doc, ref); err != nil {
|
if err := ValidateRef(doc, ref); err != nil {
|
||||||
t.Fatalf("ValidateRef() error = %v, want nil", err)
|
t.Fatalf("ValidateRef() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
|
if err := NewDocumentIndex(doc).ValidateRef(ref); err != nil {
|
||||||
|
t.Fatalf("DocumentIndex.ValidateRef() error = %v, want nil", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateRefRejectsMalformedReferences(t *testing.T) {
|
func TestValidateRefRejectsMalformedReferences(t *testing.T) {
|
||||||
@@ -301,11 +304,54 @@ func TestValidateRefRejectsMalformedReferences(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
err := ValidateRef(validDocument(), tt.ref)
|
doc := validDocument()
|
||||||
|
validators := []struct {
|
||||||
requireErrorFragments(t, err, tt.fragments...)
|
name string
|
||||||
|
validate func(SourceRef) error
|
||||||
|
}{
|
||||||
|
{name: "document", validate: func(ref SourceRef) error { return ValidateRef(doc, ref) }},
|
||||||
|
{name: "index", validate: NewDocumentIndex(doc).ValidateRef},
|
||||||
|
}
|
||||||
|
for _, validator := range validators {
|
||||||
|
t.Run(validator.name, func(t *testing.T) {
|
||||||
|
requireErrorFragments(t, validator.validate(tt.ref), tt.fragments...)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDocumentIndexSnapshotsIdentityAndUnitPositions(t *testing.T) {
|
||||||
|
doc := &SourceDocument{
|
||||||
|
ID: "source-1",
|
||||||
|
Units: []SourceUnit{
|
||||||
|
{ID: 30},
|
||||||
|
{ID: 10},
|
||||||
|
{ID: 30},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
index := NewDocumentIndex(doc)
|
||||||
|
doc.ID = "changed"
|
||||||
|
doc.Units[0].ID = 99
|
||||||
|
|
||||||
|
if position, ok := index.Position(30); !ok || position != 0 {
|
||||||
|
t.Fatalf("Position(30) = %d, %t, want 0, true", position, ok)
|
||||||
|
}
|
||||||
|
if position, ok := index.Position(10); !ok || position != 1 {
|
||||||
|
t.Fatalf("Position(10) = %d, %t, want 1, true", position, ok)
|
||||||
|
}
|
||||||
|
ref := SourceRef{SourceID: "source-1", StartUnitID: 30, EndUnitID: 10}
|
||||||
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
|
t.Fatalf("ValidateRef() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestZeroDocumentIndexIsSafe(t *testing.T) {
|
||||||
|
var index DocumentIndex
|
||||||
|
if position, ok := index.Position(1); ok || position != 0 {
|
||||||
|
t.Fatalf("Position(1) = %d, %t, want 0, false", position, ok)
|
||||||
|
}
|
||||||
|
requireErrorFragments(t, index.ValidateRef(SourceRef{}), "source document must not be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnitIndex(t *testing.T) {
|
func TestUnitIndex(t *testing.T) {
|
||||||
|
|||||||
@@ -5,6 +5,46 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DocumentIndex is an immutable snapshot of a source document's identity and
|
||||||
|
// unit positions for repeated source-reference operations.
|
||||||
|
type DocumentIndex struct {
|
||||||
|
documentID string
|
||||||
|
positions map[int]int
|
||||||
|
hasDocument bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDocumentIndex snapshots doc without retaining or mutating it.
|
||||||
|
func NewDocumentIndex(doc *SourceDocument) DocumentIndex {
|
||||||
|
if doc == nil {
|
||||||
|
return DocumentIndex{}
|
||||||
|
}
|
||||||
|
positions := make(map[int]int, len(doc.Units))
|
||||||
|
for position, unit := range doc.Units {
|
||||||
|
if _, exists := positions[unit.ID]; !exists {
|
||||||
|
positions[unit.ID] = position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return DocumentIndex{
|
||||||
|
documentID: doc.ID,
|
||||||
|
positions: positions,
|
||||||
|
hasDocument: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Position returns the indexed document position for unitID.
|
||||||
|
func (i DocumentIndex) Position(unitID int) (int, bool) {
|
||||||
|
position, ok := i.positions[unitID]
|
||||||
|
return position, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateRef validates ref against the indexed document snapshot.
|
||||||
|
func (i DocumentIndex) ValidateRef(ref SourceRef) error {
|
||||||
|
if !i.hasDocument {
|
||||||
|
return fmt.Errorf("source document must not be nil")
|
||||||
|
}
|
||||||
|
return validateRef(i.documentID, i.Position, ref)
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateDocument(doc *SourceDocument) error {
|
func ValidateDocument(doc *SourceDocument) error {
|
||||||
if doc == nil {
|
if doc == nil {
|
||||||
return fmt.Errorf("source document must not be nil")
|
return fmt.Errorf("source document must not be nil")
|
||||||
@@ -60,6 +100,12 @@ func ValidateRef(doc *SourceDocument, ref SourceRef) error {
|
|||||||
if doc == nil {
|
if doc == nil {
|
||||||
return fmt.Errorf("source document must not be nil")
|
return fmt.Errorf("source document must not be nil")
|
||||||
}
|
}
|
||||||
|
return validateRef(doc.ID, func(unitID int) (int, bool) {
|
||||||
|
return UnitIndex(doc, unitID)
|
||||||
|
}, ref)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRef(documentID string, position func(int) (int, bool), ref SourceRef) error {
|
||||||
if isBlank(ref.SourceID) {
|
if isBlank(ref.SourceID) {
|
||||||
return fmt.Errorf("source ref source_id must not be empty")
|
return fmt.Errorf("source ref source_id must not be empty")
|
||||||
}
|
}
|
||||||
@@ -72,15 +118,15 @@ func ValidateRef(doc *SourceDocument, ref SourceRef) error {
|
|||||||
if ref.EndUnitID <= 0 {
|
if ref.EndUnitID <= 0 {
|
||||||
return fmt.Errorf("source ref end_unit_id must be positive")
|
return fmt.Errorf("source ref end_unit_id must be positive")
|
||||||
}
|
}
|
||||||
if ref.SourceID != doc.ID {
|
if ref.SourceID != documentID {
|
||||||
return fmt.Errorf("source ref source_id %q does not match document id %q", ref.SourceID, doc.ID)
|
return fmt.Errorf("source ref source_id %q does not match document id %q", ref.SourceID, documentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
startIndex, ok := UnitIndex(doc, ref.StartUnitID)
|
startIndex, ok := position(ref.StartUnitID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("source ref start_unit_id %d was not found", ref.StartUnitID)
|
return fmt.Errorf("source ref start_unit_id %d was not found", ref.StartUnitID)
|
||||||
}
|
}
|
||||||
endIndex, ok := UnitIndex(doc, ref.EndUnitID)
|
endIndex, ok := position(ref.EndUnitID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("source ref end_unit_id %d was not found", ref.EndUnitID)
|
return fmt.Errorf("source ref end_unit_id %d was not found", ref.EndUnitID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,28 +156,25 @@ func planFromResponse(doc *source.SourceDocument, response chunkResponse) (sourc
|
|||||||
return source.ChunkPlan{}, fmt.Errorf("scenes must not be empty")
|
return source.ChunkPlan{}, fmt.Errorf("scenes must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
unitIndexes := make(map[int]int, len(doc.Units))
|
index := source.NewDocumentIndex(doc)
|
||||||
for i, unit := range doc.Units {
|
|
||||||
unitIndexes[unit.ID] = i
|
|
||||||
}
|
|
||||||
|
|
||||||
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
|
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
|
||||||
previousEnd := -1
|
previousEnd := -1
|
||||||
for i, scene := range response.Scenes {
|
for i, scene := range response.Scenes {
|
||||||
startUnitID, err := shared.ResolveUnitID(doc, "start_unit_id", scene.StartUnitID)
|
startUnitID, err := shared.ResolveUnitID(index, "start_unit_id", scene.StartUnitID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
|
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
|
||||||
}
|
}
|
||||||
endUnitID, err := shared.ResolveUnitID(doc, "end_unit_id", scene.EndUnitID)
|
endUnitID, err := shared.ResolveUnitID(index, "end_unit_id", scene.EndUnitID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
|
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
startIndex, ok := unitIndexes[startUnitID]
|
startIndex, ok := index.Position(startUnitID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID)
|
return source.ChunkPlan{}, fmt.Errorf("scene[%d] start_unit_id %d was not found", i, startUnitID)
|
||||||
}
|
}
|
||||||
endIndex, ok := unitIndexes[endUnitID]
|
endIndex, ok := index.Position(endUnitID)
|
||||||
if !ok {
|
if !ok {
|
||||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID)
|
return source.ChunkPlan{}, fmt.Errorf("scene[%d] end_unit_id %d was not found", i, endUnitID)
|
||||||
}
|
}
|
||||||
|
|||||||
38
internal/modules/dnd/codec/candidatejson/candidate_json.go
Normal file
38
internal/modules/dnd/codec/candidatejson/candidate_json.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
// Package candidatejson provides strict JSON mechanics for D&D artifact
|
||||||
|
// candidates before artifact-specific validation.
|
||||||
|
package candidatejson
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EncodeCandidate encodes a typed candidate with an artifact-specific error
|
||||||
|
// label.
|
||||||
|
func EncodeCandidate[T any](label string, value T) ([]byte, error) {
|
||||||
|
content, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode %s: %w", label, err)
|
||||||
|
}
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeCandidate decodes exactly one typed JSON value while rejecting unknown
|
||||||
|
// fields and trailing values with an artifact-specific error label.
|
||||||
|
func DecodeCandidate[T any](label string, content []byte) (T, error) {
|
||||||
|
decoder := json.NewDecoder(bytes.NewReader(content))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
var value T
|
||||||
|
if err := decoder.Decode(&value); err != nil {
|
||||||
|
var zero T
|
||||||
|
return zero, fmt.Errorf("decode %s: %w", label, err)
|
||||||
|
}
|
||||||
|
var trailing any
|
||||||
|
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||||
|
var zero T
|
||||||
|
return zero, fmt.Errorf("decode %s: multiple JSON values", label)
|
||||||
|
}
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package candidatejson
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testCandidate struct {
|
||||||
|
Items []string `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCandidateJSON(t *testing.T) {
|
||||||
|
input := testCandidate{Items: []string{"one", "two"}}
|
||||||
|
content, err := EncodeCandidate("test candidate", input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("EncodeCandidate() error = %v", err)
|
||||||
|
}
|
||||||
|
if !json.Valid(content) {
|
||||||
|
t.Fatalf("EncodeCandidate() = %q, want JSON", content)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
content []byte
|
||||||
|
want testCandidate
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{name: "typed round trip", content: content, want: input},
|
||||||
|
{name: "unknown field", content: []byte(`{"items":[],"unexpected":true}`), wantErr: "unknown field"},
|
||||||
|
{name: "malformed JSON", content: []byte(`{"items":`), wantErr: "decode test candidate"},
|
||||||
|
{name: "trailing value", content: []byte(`{"items":[]} {}`), wantErr: "multiple JSON values"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
got, err := DecodeCandidate[testCandidate]("test candidate", test.content)
|
||||||
|
if test.wantErr != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||||
|
t.Fatalf("DecodeCandidate() error = %v, want %q", err, test.wantErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || !reflect.DeepEqual(got, test.want) {
|
||||||
|
t.Fatalf("DecodeCandidate() = %#v, %v; want %#v", got, err, test.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
package combatturns
|
package combatturns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -59,11 +57,7 @@ func (c *Codec) Encode(value dnd.CombatTurnList) ([]byte, error) {
|
|||||||
// EncodeCandidate provides the durable representation before typed validators
|
// EncodeCandidate provides the durable representation before typed validators
|
||||||
// have approved a value.
|
// have approved a value.
|
||||||
func (c *Codec) EncodeCandidate(value dnd.CombatTurnList) ([]byte, error) {
|
func (c *Codec) EncodeCandidate(value dnd.CombatTurnList) ([]byte, error) {
|
||||||
content, err := json.Marshal(value)
|
return candidatejson.EncodeCandidate("dnd combat turn list", value)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode dnd combat turn list: %w", err)
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
|
func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
|
||||||
@@ -80,17 +74,7 @@ func (c *Codec) Decode(content []byte) (dnd.CombatTurnList, error) {
|
|||||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||||
// validators have approved it.
|
// validators have approved it.
|
||||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
|
||||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
return candidatejson.DecodeCandidate[dnd.CombatTurnList]("dnd combat turn list", content)
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
var value dnd.CombatTurnList
|
|
||||||
if err := decoder.Decode(&value); err != nil {
|
|
||||||
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: %w", err)
|
|
||||||
}
|
|
||||||
var trailing any
|
|
||||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
||||||
return dnd.CombatTurnList{}, fmt.Errorf("decode dnd combat turn list: multiple JSON values")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate(value dnd.CombatTurnList) error {
|
func validate(value dnd.CombatTurnList) error {
|
||||||
|
|||||||
@@ -2,15 +2,13 @@
|
|||||||
package npcinteractions
|
package npcinteractions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -60,11 +58,7 @@ func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) {
|
|||||||
// EncodeCandidate provides the durable representation before semantic
|
// EncodeCandidate provides the durable representation before semantic
|
||||||
// validators have approved a value.
|
// validators have approved a value.
|
||||||
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
|
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
|
||||||
content, err := json.Marshal(value)
|
return candidatejson.EncodeCandidate("dnd npc interaction list", value)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
|
func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
|
||||||
@@ -81,17 +75,7 @@ func (c *Codec) Decode(content []byte) (dnd.NPCInteractionList, error) {
|
|||||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||||
// validators have approved it.
|
// validators have approved it.
|
||||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
|
||||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content)
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
var value dnd.NPCInteractionList
|
|
||||||
if err := decoder.Decode(&value); err != nil {
|
|
||||||
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: %w", err)
|
|
||||||
}
|
|
||||||
var trailing any
|
|
||||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
||||||
return dnd.NPCInteractionList{}, fmt.Errorf("decode dnd npc interaction list: multiple JSON values")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate(value dnd.NPCInteractionList) error {
|
func validate(value dnd.NPCInteractionList) error {
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
package npcs
|
package npcs
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -60,11 +58,7 @@ func (c *Codec) Encode(value dnd.NPCList) ([]byte, error) {
|
|||||||
// EncodeCandidate provides the durable representation before semantic
|
// EncodeCandidate provides the durable representation before semantic
|
||||||
// validators have approved a value.
|
// validators have approved a value.
|
||||||
func (c *Codec) EncodeCandidate(value dnd.NPCList) ([]byte, error) {
|
func (c *Codec) EncodeCandidate(value dnd.NPCList) ([]byte, error) {
|
||||||
content, err := json.Marshal(value)
|
return candidatejson.EncodeCandidate("dnd npc list", value)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode dnd npc list: %w", err)
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Decode(content []byte) (dnd.NPCList, error) {
|
func (c *Codec) Decode(content []byte) (dnd.NPCList, error) {
|
||||||
@@ -81,17 +75,7 @@ func (c *Codec) Decode(content []byte) (dnd.NPCList, error) {
|
|||||||
// DecodeCandidate reads one strict durable JSON value before semantic
|
// DecodeCandidate reads one strict durable JSON value before semantic
|
||||||
// validators have approved it.
|
// validators have approved it.
|
||||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCList, error) {
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCList, error) {
|
||||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
return candidatejson.DecodeCandidate[dnd.NPCList]("dnd npc list", content)
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
var value dnd.NPCList
|
|
||||||
if err := decoder.Decode(&value); err != nil {
|
|
||||||
return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: %w", err)
|
|
||||||
}
|
|
||||||
var trailing any
|
|
||||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
||||||
return dnd.NPCList{}, fmt.Errorf("decode dnd npc list: multiple JSON values")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate(value dnd.NPCList) error {
|
func validate(value dnd.NPCList) error {
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
package scenedescriptions
|
package scenedescriptions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -59,11 +57,7 @@ func (c *Codec) Encode(value dnd.SceneDescriptionList) ([]byte, error) {
|
|||||||
// EncodeCandidate provides the durable representation before typed validators
|
// EncodeCandidate provides the durable representation before typed validators
|
||||||
// have approved a value.
|
// have approved a value.
|
||||||
func (c *Codec) EncodeCandidate(value dnd.SceneDescriptionList) ([]byte, error) {
|
func (c *Codec) EncodeCandidate(value dnd.SceneDescriptionList) ([]byte, error) {
|
||||||
content, err := json.Marshal(value)
|
return candidatejson.EncodeCandidate("dnd scene description list", value)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode dnd scene description list: %w", err)
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Decode(content []byte) (dnd.SceneDescriptionList, error) {
|
func (c *Codec) Decode(content []byte) (dnd.SceneDescriptionList, error) {
|
||||||
@@ -80,17 +74,7 @@ func (c *Codec) Decode(content []byte) (dnd.SceneDescriptionList, error) {
|
|||||||
// DecodeCandidate reads one strict durable JSON value before typed validators
|
// DecodeCandidate reads one strict durable JSON value before typed validators
|
||||||
// have approved it.
|
// have approved it.
|
||||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.SceneDescriptionList, error) {
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.SceneDescriptionList, error) {
|
||||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
return candidatejson.DecodeCandidate[dnd.SceneDescriptionList]("dnd scene description list", content)
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
var value dnd.SceneDescriptionList
|
|
||||||
if err := decoder.Decode(&value); err != nil {
|
|
||||||
return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: %w", err)
|
|
||||||
}
|
|
||||||
var trailing any
|
|
||||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
||||||
return dnd.SceneDescriptionList{}, fmt.Errorf("decode dnd scene description list: multiple JSON values")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate(value dnd.SceneDescriptionList) error {
|
func validate(value dnd.SceneDescriptionList) error {
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
package spells
|
package spells
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
|
||||||
"embed"
|
"embed"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -54,11 +52,7 @@ func (c *Codec) Encode(value dnd.SpellList) ([]byte, error) {
|
|||||||
// EncodeCandidate provides the same stable representation before typed
|
// EncodeCandidate provides the same stable representation before typed
|
||||||
// validators have approved a value.
|
// validators have approved a value.
|
||||||
func (c *Codec) EncodeCandidate(value dnd.SpellList) ([]byte, error) {
|
func (c *Codec) EncodeCandidate(value dnd.SpellList) ([]byte, error) {
|
||||||
content, err := json.Marshal(value)
|
return candidatejson.EncodeCandidate("dnd spell list", value)
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode dnd spell list: %w", err)
|
|
||||||
}
|
|
||||||
return content, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Codec) Decode(content []byte) (dnd.SpellList, error) {
|
func (c *Codec) Decode(content []byte) (dnd.SpellList, error) {
|
||||||
@@ -75,17 +69,7 @@ func (c *Codec) Decode(content []byte) (dnd.SpellList, error) {
|
|||||||
// DecodeCandidate reads the durable representation before semantic validators
|
// DecodeCandidate reads the durable representation before semantic validators
|
||||||
// have approved it.
|
// have approved it.
|
||||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.SpellList, error) {
|
func (c *Codec) DecodeCandidate(content []byte) (dnd.SpellList, error) {
|
||||||
decoder := json.NewDecoder(bytes.NewReader(content))
|
return candidatejson.DecodeCandidate[dnd.SpellList]("dnd spell list", content)
|
||||||
decoder.DisallowUnknownFields()
|
|
||||||
var value dnd.SpellList
|
|
||||||
if err := decoder.Decode(&value); err != nil {
|
|
||||||
return dnd.SpellList{}, fmt.Errorf("decode dnd spell list: %w", err)
|
|
||||||
}
|
|
||||||
var trailing any
|
|
||||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
|
||||||
return dnd.SpellList{}, fmt.Errorf("decode dnd spell list: multiple JSON values")
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validate(value dnd.SpellList) error {
|
func validate(value dnd.SpellList) error {
|
||||||
|
|||||||
@@ -5,89 +5,49 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
|
type orderedCombatTurnResponse struct {
|
||||||
|
value combatTurnResponse
|
||||||
|
earliest int
|
||||||
|
hasEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
|
||||||
if response == nil {
|
if response == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ordered := make([]orderedCombatTurnResponse, len(response.CombatTurns))
|
||||||
for index := range response.CombatTurns {
|
for index := range response.CombatTurns {
|
||||||
canonicalizeCombatTurn(&response.CombatTurns[index])
|
earliest, hasEvidence := canonicalizeCombatTurn(&response.CombatTurns[index], order, sourceID)
|
||||||
|
ordered[index] = orderedCombatTurnResponse{
|
||||||
|
value: response.CombatTurns[index],
|
||||||
|
earliest: earliest,
|
||||||
|
hasEvidence: hasEvidence,
|
||||||
}
|
}
|
||||||
sort.SliceStable(response.CombatTurns, func(i, j int) bool {
|
|
||||||
left, leftOK := earliestSourcePosition(doc, response.CombatTurns[i])
|
|
||||||
right, rightOK := earliestSourcePosition(doc, response.CombatTurns[j])
|
|
||||||
if leftOK != rightOK {
|
|
||||||
return leftOK
|
|
||||||
}
|
}
|
||||||
if !leftOK {
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
if ordered[i].hasEvidence != ordered[j].hasEvidence {
|
||||||
|
return ordered[i].hasEvidence
|
||||||
|
}
|
||||||
|
if !ordered[i].hasEvidence {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return left < right
|
return ordered[i].earliest < ordered[j].earliest
|
||||||
})
|
})
|
||||||
|
for index := range ordered {
|
||||||
|
response.CombatTurns[index] = ordered[index].value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeCombatTurn(turn *combatTurnResponse) {
|
func canonicalizeCombatTurn(turn *combatTurnResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||||
if turn == nil {
|
if turn == nil {
|
||||||
return
|
|
||||||
}
|
|
||||||
sort.SliceStable(turn.SourceRefs, func(i, j int) bool {
|
|
||||||
left := turn.SourceRefs[i]
|
|
||||||
right := turn.SourceRefs[j]
|
|
||||||
if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) {
|
|
||||||
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
|
||||||
}
|
|
||||||
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
|
||||||
})
|
|
||||||
turn.SourceRefs = dedupeSourceRefs(turn.SourceRefs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dedupeSourceRefs(refs []combatSourceRefResponse) []combatSourceRefResponse {
|
|
||||||
if len(refs) < 2 {
|
|
||||||
return refs
|
|
||||||
}
|
|
||||||
out := refs[:0]
|
|
||||||
var previous combatSourceRefResponse
|
|
||||||
for index, ref := range refs {
|
|
||||||
if index > 0 && sameSourceRef(previous, ref) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, ref)
|
|
||||||
previous = ref
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameSourceRef(left combatSourceRefResponse, right combatSourceRefResponse) bool {
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func earliestSourcePosition(doc *source.SourceDocument, turn combatTurnResponse) (int, bool) {
|
|
||||||
if doc == nil {
|
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
earliest := 0
|
refs := order.Canonicalize(canonicalSourceRefs(turn.SourceRefs, sourceID))
|
||||||
found := false
|
turn.SourceRefs = combatResponseRefs(refs)
|
||||||
for _, ref := range turn.SourceRefs {
|
return order.EarliestValid(refs)
|
||||||
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
|
||||||
if err := source.ValidateRef(doc, candidate); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
index, ok := source.UnitIndex(doc, candidate.StartUnitID)
|
|
||||||
if !ok || (found && index >= earliest) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
earliest = index
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
return earliest, found
|
|
||||||
}
|
|
||||||
|
|
||||||
func unitSortValue(value int) int {
|
|
||||||
if value <= 0 {
|
|
||||||
return int(^uint(0) >> 1)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.CombatTurnList {
|
func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.CombatTurnList {
|
||||||
@@ -115,3 +75,14 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func combatResponseRefs(refs []source.SourceRef) []combatSourceRefResponse {
|
||||||
|
if refs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values := make([]combatSourceRefResponse, len(refs))
|
||||||
|
for index, ref := range refs {
|
||||||
|
values[index] = combatSourceRefResponse{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,8 +14,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/combat-turns"
|
Key = "dnd/combat-turns"
|
||||||
ArtifactType = "dnd.combat_turn"
|
mappingPolicy = "dnd.combat_turns.extract_mapping.v2"
|
||||||
mappingPolicy = "dnd.combat_turns.extract_mapping.v1"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -144,25 +143,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if e.llm == nil {
|
if e.llm == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("LLM client must not be nil")
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("LLM client must not be nil")
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("context must not be nil")
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("context error before extraction: %w", err)
|
|
||||||
}
|
|
||||||
if req.Source == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("source must not be nil")
|
|
||||||
}
|
|
||||||
if req.Chunk == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk must not be nil")
|
|
||||||
}
|
|
||||||
if len(req.Chunk.Units) == 0 {
|
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
|
||||||
}
|
|
||||||
sourceInput, err := shared.ChunkPromptMaterial(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
order := shared.NewSourceRefOrder(req.Source)
|
||||||
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("resolve NPC registry: %w", err)
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("resolve NPC registry: %w", err)
|
||||||
@@ -181,7 +166,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
canonicalizeResponse(&response, req.Source)
|
canonicalizeResponse(&response, order, req.Source.ID)
|
||||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: canonicalCombatTurnList(response, req.Source.ID)}, nil
|
return contracts.TypedExtractionResult[dnd.CombatTurnList]{Value: canonicalCombatTurnList(response, req.Source.ID)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -82,6 +82,43 @@ func TestExtractPreservesInvalidCandidatesForValidators(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractUsesDocumentOrderForReferencesAndTurns(t *testing.T) {
|
||||||
|
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{
|
||||||
|
{Actor: "Later", TurnKind: "turn", SourceRefs: []combatSourceRefResponse{{StartUnitID: 10, EndUnitID: 10}}},
|
||||||
|
{Actor: "First", TurnKind: "reaction", SourceRefs: []combatSourceRefResponse{
|
||||||
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{StartUnitID: 999, EndUnitID: 0},
|
||||||
|
}},
|
||||||
|
{Actor: "Second", TurnKind: "other", SourceRefs: []combatSourceRefResponse{{StartUnitID: 30, EndUnitID: 30}}},
|
||||||
|
}}}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
|
||||||
|
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
||||||
|
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
|
||||||
|
|
||||||
|
result, err := newExtractor(t, client).Extract(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := []string{result.Value.CombatTurns[0].Actor, result.Value.CombatTurns[1].Actor, result.Value.CombatTurns[2].Actor}; !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
|
||||||
|
t.Fatalf("turn order = %#v, want document chronology with stable equal-evidence ties", got)
|
||||||
|
}
|
||||||
|
refs := result.Value.CombatTurns[0].SourceRefs
|
||||||
|
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
|
||||||
|
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
|
||||||
|
}
|
||||||
|
refs[0].StartUnitID = 777
|
||||||
|
for _, turn := range client.response.CombatTurns {
|
||||||
|
for _, ref := range turn.SourceRefs {
|
||||||
|
if ref.StartUnitID == 777 {
|
||||||
|
t.Fatal("result source references alias the model response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractPassesReferencesAndNPCGroundingWithoutUsingItAsEvidence(t *testing.T) {
|
func TestExtractPassesReferencesAndNPCGroundingWithoutUsingItAsEvidence(t *testing.T) {
|
||||||
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}}
|
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}}
|
||||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||||
@@ -185,9 +222,8 @@ func TestNewRejectsMalformedNPCRegistryBeforeLLMCallWithoutContent(t *testing.T)
|
|||||||
|
|
||||||
func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) {
|
func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) {
|
||||||
validReq := extractionRequest()
|
validReq := extractionRequest()
|
||||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
validExtractor := newExtractor(t, &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}})
|
validExtractor := newExtractor(t, &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}})
|
||||||
|
var nilExtractor *Extractor
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
extractor *Extractor
|
extractor *Extractor
|
||||||
@@ -195,13 +231,9 @@ func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) {
|
|||||||
req contracts.TypedExtractionRequest
|
req contracts.TypedExtractionRequest
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "nil extractor", ctx: context.Background(), req: validReq, want: "extractor"},
|
{name: "nil extractor", extractor: nilExtractor, ctx: context.Background(), req: validReq, want: "extractor"},
|
||||||
{name: "nil context", extractor: validExtractor, req: validReq, want: "context"},
|
{name: "nil LLM client", extractor: &Extractor{}, ctx: context.Background(), req: validReq, want: "LLM client"},
|
||||||
{name: "canceled context", extractor: validExtractor, ctx: canceledCtx, req: validReq, want: "context"},
|
{name: "wrapped preflight failure", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
|
||||||
{name: "nil source", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Chunk: validReq.Chunk}, want: "source"},
|
|
||||||
{name: "nil chunk", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Source: validReq.Source}, want: "chunk"},
|
|
||||||
{name: "empty chunk units", extractor: validExtractor, ctx: context.Background(), req: emptyChunkRequest(validReq), want: "units"},
|
|
||||||
{name: "source input mismatch", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
|
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -302,11 +334,6 @@ func combatSourceDocument() *source.SourceDocument {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
|
||||||
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
||||||
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"other":true}`), "sha256:other", "")
|
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"other":true}`), "sha256:other", "")
|
||||||
return req
|
return req
|
||||||
|
|||||||
@@ -62,16 +62,48 @@ func TestScriptoriumPromptPreparesRequiredInputs(t *testing.T) {
|
|||||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_combat_turns_llm.v1.json" {
|
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)
|
t.Fatalf("prepared prompt = %#v, want combat prompt identity and schema", prepared)
|
||||||
}
|
}
|
||||||
for _, want := range []string{transcript, "Dana: Mira", "Mira: ranger", "Greencloak: title", `{"npcs":[]}`} {
|
for index, want := range []struct {
|
||||||
found := false
|
role string
|
||||||
for _, message := range prepared.Messages {
|
cached bool
|
||||||
if strings.Contains(message.Content, want) {
|
marker string
|
||||||
found = true
|
}{
|
||||||
break
|
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
|
||||||
|
{role: "user", marker: "Transcript units are the only evidence"},
|
||||||
|
{role: "user", cached: true, marker: "most specific supported in-world"},
|
||||||
|
{role: "user", cached: true},
|
||||||
|
{role: "user", cached: true},
|
||||||
|
{role: "user", marker: "combat-turn artifacts"},
|
||||||
|
{role: "user", cached: true, marker: "turn_kind"},
|
||||||
|
{role: "user"},
|
||||||
|
} {
|
||||||
|
if index >= len(prepared.Messages) {
|
||||||
|
t.Fatalf("prepared prompt has %d messages, want at least %d", len(prepared.Messages), index+1)
|
||||||
|
}
|
||||||
|
message := prepared.Messages[index]
|
||||||
|
if message.Role != want.role {
|
||||||
|
t.Errorf("message %d role = %q, want %q", index, message.Role, want.role)
|
||||||
|
}
|
||||||
|
if want.cached {
|
||||||
|
if message.CacheControl == nil || message.CacheControl.Type != scriptorium.CacheControlEphemeral {
|
||||||
|
t.Errorf("message %d cache control = %#v, want ephemeral", index, message.CacheControl)
|
||||||
|
}
|
||||||
|
} else if message.CacheControl != nil {
|
||||||
|
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
|
||||||
|
}
|
||||||
|
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
|
||||||
|
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if len(prepared.Messages) != 8 {
|
||||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
t.Fatalf("prepared prompt has %d messages, want 8", len(prepared.Messages))
|
||||||
}
|
}
|
||||||
|
if references := prepared.Messages[3].Content; !strings.Contains(references, "Dana: Mira") || !strings.Contains(references, "Mira: ranger") || !strings.Contains(references, "Greencloak: title") {
|
||||||
|
t.Fatalf("campaign references message = %q, want rendered reference inputs", references)
|
||||||
|
}
|
||||||
|
if registry := prepared.Messages[4].Content; !strings.Contains(registry, `{"npcs":[]}`) {
|
||||||
|
t.Fatalf("NPC registry message = %q, want registry input", registry)
|
||||||
|
}
|
||||||
|
if final := prepared.Messages[7].Content; !strings.Contains(final, transcript) {
|
||||||
|
t.Fatalf("final message = %q, want transcript", final)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,85 +5,49 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
|
type orderedInteractionResponse struct {
|
||||||
|
value interactionResponse
|
||||||
|
earliest int
|
||||||
|
hasEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
|
||||||
if response == nil {
|
if response == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ordered := make([]orderedInteractionResponse, len(response.Interactions))
|
||||||
for index := range response.Interactions {
|
for index := range response.Interactions {
|
||||||
canonicalizeInteraction(&response.Interactions[index])
|
earliest, hasEvidence := canonicalizeInteraction(&response.Interactions[index], order, sourceID)
|
||||||
|
ordered[index] = orderedInteractionResponse{
|
||||||
|
value: response.Interactions[index],
|
||||||
|
earliest: earliest,
|
||||||
|
hasEvidence: hasEvidence,
|
||||||
}
|
}
|
||||||
sort.SliceStable(response.Interactions, func(i, j int) bool {
|
|
||||||
left, leftOK := earliestSourcePosition(doc, response.Interactions[i])
|
|
||||||
right, rightOK := earliestSourcePosition(doc, response.Interactions[j])
|
|
||||||
if leftOK != rightOK {
|
|
||||||
return leftOK
|
|
||||||
}
|
}
|
||||||
if !leftOK {
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
if ordered[i].hasEvidence != ordered[j].hasEvidence {
|
||||||
|
return ordered[i].hasEvidence
|
||||||
|
}
|
||||||
|
if !ordered[i].hasEvidence {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return left < right
|
return ordered[i].earliest < ordered[j].earliest
|
||||||
})
|
})
|
||||||
|
for index := range ordered {
|
||||||
|
response.Interactions[index] = ordered[index].value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeInteraction(interaction *interactionResponse) {
|
func canonicalizeInteraction(interaction *interactionResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||||
if interaction == nil {
|
if interaction == nil {
|
||||||
return
|
|
||||||
}
|
|
||||||
sort.SliceStable(interaction.SourceRefs, func(i, j int) bool {
|
|
||||||
left := interaction.SourceRefs[i]
|
|
||||||
right := interaction.SourceRefs[j]
|
|
||||||
if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) {
|
|
||||||
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
|
||||||
}
|
|
||||||
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
|
||||||
})
|
|
||||||
interaction.SourceRefs = dedupeSourceRefs(interaction.SourceRefs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dedupeSourceRefs(refs []interactionSourceRefResponse) []interactionSourceRefResponse {
|
|
||||||
if len(refs) < 2 {
|
|
||||||
return refs
|
|
||||||
}
|
|
||||||
out := refs[:0]
|
|
||||||
var previous interactionSourceRefResponse
|
|
||||||
for index, ref := range refs {
|
|
||||||
if index > 0 && previous == ref {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, ref)
|
|
||||||
previous = ref
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func earliestSourcePosition(doc *source.SourceDocument, interaction interactionResponse) (int, bool) {
|
|
||||||
if doc == nil {
|
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
found := false
|
refs := order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID))
|
||||||
earliest := 0
|
interaction.SourceRefs = interactionResponseRefs(refs)
|
||||||
for _, ref := range interaction.SourceRefs {
|
return order.EarliestValid(refs)
|
||||||
candidate := source.SourceRef{SourceID: doc.ID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
|
||||||
if err := source.ValidateRef(doc, candidate); err != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
index, ok := source.UnitIndex(doc, candidate.StartUnitID)
|
|
||||||
if !ok || (found && index >= earliest) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
earliest = index
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
return earliest, found
|
|
||||||
}
|
|
||||||
|
|
||||||
func unitSortValue(value int) int {
|
|
||||||
if value <= 0 {
|
|
||||||
return int(^uint(0) >> 1)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList {
|
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList {
|
||||||
@@ -111,3 +75,14 @@ func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) [
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func interactionResponseRefs(refs []source.SourceRef) []interactionSourceRefResponse {
|
||||||
|
if refs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values := make([]interactionSourceRefResponse, len(refs))
|
||||||
|
for index, ref := range refs {
|
||||||
|
values[index] = interactionSourceRefResponse{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/npc-interactions"
|
Key = "dnd/npc-interactions"
|
||||||
mappingPolicy = "dnd.npc_interactions.extract_mapping.v1"
|
mappingPolicy = "dnd.npc_interactions.extract_mapping.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -143,25 +143,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if e.llm == nil {
|
if e.llm == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("LLM client must not be nil")
|
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("LLM client must not be nil")
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("context must not be nil")
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("context error before extraction: %w", err)
|
|
||||||
}
|
|
||||||
if req.Source == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("source must not be nil")
|
|
||||||
}
|
|
||||||
if req.Chunk == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("chunk must not be nil")
|
|
||||||
}
|
|
||||||
if len(req.Chunk.Units) == 0 {
|
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
|
||||||
}
|
|
||||||
sourceInput, err := shared.ChunkPromptMaterial(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err)
|
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
order := shared.NewSourceRefOrder(req.Source)
|
||||||
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("resolve NPC registry: %w", err)
|
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("resolve NPC registry: %w", err)
|
||||||
@@ -183,7 +169,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
canonicalizeResponse(&response, req.Source)
|
canonicalizeResponse(&response, order, req.Source.ID)
|
||||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{Value: canonicalInteractionList(response, req.Source.ID)}, nil
|
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{Value: canonicalInteractionList(response, req.Source.ID)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,45 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractUsesDocumentOrderForReferencesAndInteractions(t *testing.T) {
|
||||||
|
client := &fakeInteractionsLLMClient{response: extractionResponse{Interactions: []interactionResponse{
|
||||||
|
{Name: "Later", Kind: "dialogue", SourceRefs: interactionRefs(10, 10)},
|
||||||
|
{Name: "First", Kind: "mentioned", SourceRefs: []interactionSourceRefResponse{
|
||||||
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{StartUnitID: 999, EndUnitID: 0},
|
||||||
|
}},
|
||||||
|
{Name: "Second", Kind: "other", SourceRefs: interactionRefs(30, 30)},
|
||||||
|
}}}
|
||||||
|
references := requiredRegistryReferences(t, "Later", "First", "Second")
|
||||||
|
req := extractionRequest()
|
||||||
|
req.References = references
|
||||||
|
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
|
||||||
|
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
||||||
|
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
|
||||||
|
|
||||||
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := interactionNames(result.Value); !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
|
||||||
|
t.Fatalf("interaction order = %#v, want document chronology with stable equal-evidence ties", got)
|
||||||
|
}
|
||||||
|
refs := result.Value.Interactions[0].SourceRefs
|
||||||
|
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
|
||||||
|
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
|
||||||
|
}
|
||||||
|
refs[0].StartUnitID = 777
|
||||||
|
for _, interaction := range client.response.Interactions {
|
||||||
|
for _, ref := range interaction.SourceRefs {
|
||||||
|
if ref.StartUnitID == 777 {
|
||||||
|
t.Fatal("result source references alias the model response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
|
func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
|
||||||
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
|
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
|
||||||
t.Fatalf("New(nil) error = %v", err)
|
t.Fatalf("New(nil) error = %v", err)
|
||||||
@@ -165,24 +204,21 @@ func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
|||||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||||
valid := extractionRequest()
|
valid := extractionRequest()
|
||||||
valid.References = references
|
valid.References = references
|
||||||
canceled, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
extractor := newExtractor(t, &fakeInteractionsLLMClient{}, references)
|
extractor := newExtractor(t, &fakeInteractionsLLMClient{}, references)
|
||||||
|
var nilExtractor *Extractor
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
extractor *Extractor
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
req contracts.TypedExtractionRequest
|
req contracts.TypedExtractionRequest
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{"nil context", nil, valid, "context"},
|
{"nil extractor", nilExtractor, context.Background(), valid, "extractor"},
|
||||||
{"canceled context", canceled, valid, "context"},
|
{"nil LLM client", &Extractor{}, context.Background(), valid, "LLM client"},
|
||||||
{"nil source", context.Background(), contracts.TypedExtractionRequest{Chunk: valid.Chunk, References: references}, "source"},
|
{"wrapped preflight failure", extractor, context.Background(), mismatchedSourceInputRequest(valid), "must match chunk"},
|
||||||
{"nil chunk", context.Background(), contracts.TypedExtractionRequest{Source: valid.Source, References: references}, "chunk"},
|
|
||||||
{"empty chunk", context.Background(), emptyChunkRequest(valid), "units"},
|
|
||||||
{"source input mismatch", context.Background(), mismatchedSourceInputRequest(valid), "must match chunk"},
|
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if _, err := extractor.Extract(test.ctx, test.req); err == nil || !strings.Contains(err.Error(), test.want) {
|
if _, err := test.extractor.Extract(test.ctx, test.req); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||||
t.Fatalf("Extract() error = %v, want %q", err, test.want)
|
t.Fatalf("Extract() error = %v, want %q", err, test.want)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -322,11 +358,6 @@ func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind {
|
|||||||
return kinds
|
return kinds
|
||||||
}
|
}
|
||||||
|
|
||||||
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
|
||||||
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
||||||
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "")
|
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "")
|
||||||
return req
|
return req
|
||||||
|
|||||||
@@ -51,22 +51,49 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
|
|||||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" {
|
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" {
|
||||||
t.Fatalf("prepared prompt = %#v", prepared)
|
t.Fatalf("prepared prompt = %#v", prepared)
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for index, want := range []struct {
|
||||||
"Mira Thorn", "mentioned", "combat_opponent", "Registry content is context, not event evidence", transcript,
|
role string
|
||||||
|
cached bool
|
||||||
|
marker string
|
||||||
}{
|
}{
|
||||||
found := false
|
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
|
||||||
for _, message := range prepared.Messages {
|
{role: "user", marker: "Transcript units are the only evidence"},
|
||||||
if strings.Contains(message.Content, want) {
|
{role: "user", cached: true, marker: "most specific supported in-world"},
|
||||||
found = true
|
{role: "user", cached: true},
|
||||||
break
|
{role: "user", cached: true},
|
||||||
|
{role: "user", marker: "interaction occurrences"},
|
||||||
|
{role: "user", cached: true, marker: "Use exactly one kind per occurrence"},
|
||||||
|
{role: "user"},
|
||||||
|
} {
|
||||||
|
if index >= len(prepared.Messages) {
|
||||||
|
t.Fatalf("prepared prompt has %d messages, want at least %d", len(prepared.Messages), index+1)
|
||||||
|
}
|
||||||
|
message := prepared.Messages[index]
|
||||||
|
if message.Role != want.role {
|
||||||
|
t.Errorf("message %d role = %q, want %q", index, message.Role, want.role)
|
||||||
|
}
|
||||||
|
if want.cached {
|
||||||
|
if message.CacheControl == nil || message.CacheControl.Type != scriptorium.CacheControlEphemeral {
|
||||||
|
t.Errorf("message %d cache control = %#v, want ephemeral", index, message.CacheControl)
|
||||||
|
}
|
||||||
|
} else if message.CacheControl != nil {
|
||||||
|
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
|
||||||
|
}
|
||||||
|
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
|
||||||
|
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if len(prepared.Messages) != 8 {
|
||||||
t.Fatalf("prepared prompt did not include %q", want)
|
t.Fatalf("prepared prompt has %d messages, want 8", len(prepared.Messages))
|
||||||
}
|
}
|
||||||
|
if references := prepared.Messages[3].Content; !strings.Contains(references, "Dana: Mira") || !strings.Contains(references, "Mira: ranger") || !strings.Contains(references, "Greencloak: title") {
|
||||||
|
t.Fatalf("campaign references message = %q, want rendered reference inputs", references)
|
||||||
}
|
}
|
||||||
if last := prepared.Messages[len(prepared.Messages)-1]; !strings.Contains(last.Content, transcript) {
|
if registry := prepared.Messages[4].Content; !strings.Contains(registry, `{"npcs":[{"name":"Mira Thorn"}]}`) {
|
||||||
t.Fatalf("last prompt message = %q, want transcript", last.Content)
|
t.Fatalf("NPC registry message = %q, want names-only registry input", registry)
|
||||||
|
}
|
||||||
|
if final := prepared.Messages[7].Content; !strings.Contains(final, transcript) {
|
||||||
|
t.Fatalf("final message = %q, want transcript", final)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,89 +6,49 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"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/npcs/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
func canonicalizeResponse(response *extractionResponse, doc *source.SourceDocument) {
|
type orderedNPCResponse struct {
|
||||||
|
value npcResponse
|
||||||
|
earliest int
|
||||||
|
hasEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
|
||||||
if response == nil {
|
if response == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ordered := make([]orderedNPCResponse, len(response.NPCs))
|
||||||
for index := range response.NPCs {
|
for index := range response.NPCs {
|
||||||
canonicalizeNPC(&response.NPCs[index])
|
earliest, hasEvidence := canonicalizeNPC(&response.NPCs[index], order, sourceID)
|
||||||
|
ordered[index] = orderedNPCResponse{
|
||||||
|
value: response.NPCs[index],
|
||||||
|
earliest: earliest,
|
||||||
|
hasEvidence: hasEvidence,
|
||||||
}
|
}
|
||||||
sort.SliceStable(response.NPCs, func(i, j int) bool {
|
|
||||||
left, leftOK := earliestSourceIndex(doc, response.NPCs[i])
|
|
||||||
right, rightOK := earliestSourceIndex(doc, response.NPCs[j])
|
|
||||||
if leftOK != rightOK {
|
|
||||||
return leftOK
|
|
||||||
}
|
}
|
||||||
if !leftOK {
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
if ordered[i].hasEvidence != ordered[j].hasEvidence {
|
||||||
|
return ordered[i].hasEvidence
|
||||||
|
}
|
||||||
|
if !ordered[i].hasEvidence {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return left < right
|
return ordered[i].earliest < ordered[j].earliest
|
||||||
})
|
})
|
||||||
|
for index := range ordered {
|
||||||
|
response.NPCs[index] = ordered[index].value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeNPC(npc *npcResponse) {
|
func canonicalizeNPC(npc *npcResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||||
if npc == nil {
|
if npc == nil {
|
||||||
return
|
return 0, false
|
||||||
}
|
}
|
||||||
sort.SliceStable(npc.SourceRefs, func(i, j int) bool {
|
refs := order.Canonicalize(canonicalSourceRefs(npc.SourceRefs, sourceID))
|
||||||
left := npc.SourceRefs[i]
|
npc.SourceRefs = npcResponseRefs(refs)
|
||||||
right := npc.SourceRefs[j]
|
return order.EarliestValid(refs)
|
||||||
if unitSortValue(left.StartUnitID) != unitSortValue(right.StartUnitID) {
|
|
||||||
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
|
||||||
}
|
|
||||||
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
|
||||||
})
|
|
||||||
npc.SourceRefs = dedupeSourceRefs(npc.SourceRefs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dedupeSourceRefs(refs []npcSourceRefResponse) []npcSourceRefResponse {
|
|
||||||
if len(refs) < 2 {
|
|
||||||
return refs
|
|
||||||
}
|
|
||||||
out := refs[:0]
|
|
||||||
var previous npcSourceRefResponse
|
|
||||||
for index, ref := range refs {
|
|
||||||
if index > 0 && sameSourceRef(previous, ref) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, ref)
|
|
||||||
previous = ref
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameSourceRef(left npcSourceRefResponse, right npcSourceRefResponse) bool {
|
|
||||||
return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
|
|
||||||
}
|
|
||||||
|
|
||||||
func earliestSourceIndex(doc *source.SourceDocument, npc npcResponse) (int, bool) {
|
|
||||||
earliest := 0
|
|
||||||
found := false
|
|
||||||
for _, ref := range npc.SourceRefs {
|
|
||||||
start := ref.StartUnitID
|
|
||||||
end := ref.EndUnitID
|
|
||||||
if start > 0 && end > 0 {
|
|
||||||
startIndex, startOK := source.UnitIndex(doc, start)
|
|
||||||
endIndex, endOK := source.UnitIndex(doc, end)
|
|
||||||
if !startOK || !endOK || startIndex > endIndex {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if !found || startIndex < earliest {
|
|
||||||
earliest = startIndex
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return earliest, found
|
|
||||||
}
|
|
||||||
|
|
||||||
func unitSortValue(value int) int {
|
|
||||||
if value <= 0 {
|
|
||||||
return int(^uint(0) >> 1)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList {
|
func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList {
|
||||||
@@ -120,3 +80,14 @@ func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []sourc
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func npcResponseRefs(values []source.SourceRef) []npcSourceRefResponse {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]npcSourceRefResponse, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
out[index] = npcSourceRefResponse{StartUnitID: value.StartUnitID, EndUnitID: value.EndUnitID}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import (
|
|||||||
|
|
||||||
const Key = "dnd/npcs"
|
const Key = "dnd/npcs"
|
||||||
|
|
||||||
|
const mappingPolicy = "dnd.npcs.extract_mapping.v2"
|
||||||
|
|
||||||
var requiredCapabilities = []string{
|
var requiredCapabilities = []string{
|
||||||
"chunks",
|
"chunks",
|
||||||
"source.transcript",
|
"source.transcript",
|
||||||
@@ -85,6 +87,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
|||||||
"response_schema_version": SchemaVersion,
|
"response_schema_version": SchemaVersion,
|
||||||
"response_schema_sha256": e.responseSchemaSHA,
|
"response_schema_sha256": e.responseSchemaSHA,
|
||||||
"identity_policy": identity.Policy,
|
"identity_policy": identity.Policy,
|
||||||
|
"mapping_policy": mappingPolicy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +99,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
{Name: "prompt", Value: e.promptSHA},
|
{Name: "prompt", Value: e.promptSHA},
|
||||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||||
{Name: "identity_policy", Value: identity.Policy},
|
{Name: "identity_policy", Value: identity.Policy},
|
||||||
|
{Name: "mapping_policy", Value: mappingPolicy},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,25 +110,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if e.llm == nil {
|
if e.llm == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("LLM client must not be nil")
|
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("LLM client must not be nil")
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("context must not be nil")
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("context error before extraction: %w", err)
|
|
||||||
}
|
|
||||||
if req.Source == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("source must not be nil")
|
|
||||||
}
|
|
||||||
if req.Chunk == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk must not be nil")
|
|
||||||
}
|
|
||||||
if len(req.Chunk.Units) == 0 {
|
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
|
||||||
}
|
|
||||||
sourceInput, err := shared.ChunkPromptMaterial(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
|
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
order := shared.NewSourceRefOrder(req.Source)
|
||||||
|
|
||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
@@ -137,7 +127,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
canonicalizeResponse(&response, req.Source)
|
canonicalizeResponse(&response, order, req.Source.ID)
|
||||||
return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(response, req.Source.ID)}, nil
|
return contracts.TypedExtractionResult[dnd.NPCList]{Value: canonicalNPCList(response, req.Source.ID)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,43 @@ func TestExtractOrdersNPCsBySourcePositionRatherThanUnitID(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractUsesDocumentOrderForNPCReferencesAndStableTies(t *testing.T) {
|
||||||
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{
|
||||||
|
{Name: "Later", SourceRefs: responseSourceRefs(10, 10)},
|
||||||
|
{Name: "First", SourceRefs: []npcSourceRefResponse{
|
||||||
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{StartUnitID: 999, EndUnitID: 0},
|
||||||
|
}},
|
||||||
|
{Name: "Second", SourceRefs: responseSourceRefs(30, 30)},
|
||||||
|
}}}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
|
||||||
|
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
||||||
|
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
|
||||||
|
|
||||||
|
result, err := newExtractor(t, client).Extract(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := []string{result.Value.NPCs[0].Name, result.Value.NPCs[1].Name, result.Value.NPCs[2].Name}; !reflect.DeepEqual(got, []string{"First", "Second", "Later"}) {
|
||||||
|
t.Fatalf("NPC order = %#v, want document chronology with stable equal-evidence ties", got)
|
||||||
|
}
|
||||||
|
refs := result.Value.NPCs[0].SourceRefs
|
||||||
|
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
|
||||||
|
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
|
||||||
|
}
|
||||||
|
refs[0].StartUnitID = 777
|
||||||
|
for _, npc := range client.response.NPCs {
|
||||||
|
for _, ref := range npc.SourceRefs {
|
||||||
|
if ref.StartUnitID == 777 {
|
||||||
|
t.Fatal("result source references alias the model response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
|
func TestExtractPassesCampaignReferencesAsPromptInputs(t *testing.T) {
|
||||||
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}
|
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -136,19 +173,27 @@ func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
func TestExtractRetainsLocalErrorContextAndProviderFailures(t *testing.T) {
|
||||||
request := extractionRequest()
|
request := extractionRequest()
|
||||||
extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
|
extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
|
||||||
canceled, cancel := context.WithCancel(context.Background())
|
var nilExtractor *Extractor
|
||||||
cancel()
|
for _, test := range []struct {
|
||||||
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
|
name string
|
||||||
t.Fatalf("canceled Extract() error = %v, want context error", err)
|
extractor *Extractor
|
||||||
|
req contracts.TypedExtractionRequest
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "nil extractor", extractor: nilExtractor, req: request, want: "extractor"},
|
||||||
|
{name: "nil LLM client", extractor: &Extractor{}, req: request, want: "LLM client"},
|
||||||
|
{name: "wrapped preflight failure", extractor: extractor, req: mismatchedSourceInputRequest(request), want: "must match chunk"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd npcs") || !strings.Contains(err.Error(), test.want) {
|
||||||
|
t.Fatalf("Extract() error = %v, want contextual local error", err)
|
||||||
}
|
}
|
||||||
_, 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)
|
_, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) {
|
|||||||
"response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
|
"response_schema_key": string(ResponseSchemaKey), "response_schema_id": ResponseSchemaID,
|
||||||
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
|
"response_schema_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
|
||||||
"identity_policy": "dnd.npcs.identity.v1",
|
"identity_policy": "dnd.npcs.identity.v1",
|
||||||
|
"mapping_policy": mappingPolicy,
|
||||||
} {
|
} {
|
||||||
if metadata[key] != want {
|
if metadata[key] != want {
|
||||||
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
|
t.Fatalf("metadata[%q] = %#v, want %q", key, metadata[key], want)
|
||||||
@@ -88,7 +89,7 @@ func TestExtractorMetadataAndCheckpointIdentity(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fingerprints := extractor.CheckpointFingerprints()
|
fingerprints := extractor.CheckpointFingerprints()
|
||||||
want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npcs.identity.v1"}
|
want := map[string]string{"prompt": metadata["prompt_sha256"].(string), "response_schema": metadata["response_schema_sha256"].(string), "identity_policy": "dnd.npcs.identity.v1", "mapping_policy": mappingPolicy}
|
||||||
if len(fingerprints) != len(want) {
|
if len(fingerprints) != len(want) {
|
||||||
t.Fatalf("CheckpointFingerprints() = %#v, want %d entries", fingerprints, len(want))
|
t.Fatalf("CheckpointFingerprints() = %#v, want %d entries", fingerprints, len(want))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,17 +42,45 @@ func TestRegisterPromptAssetsAndPrepareNPCPrompt(t *testing.T) {
|
|||||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npcs_llm.v1.json" {
|
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)
|
||||||
}
|
}
|
||||||
for _, want := range []string{`{"units":[1]}`, "Dana: Mira", "Mira: ranger", "Greencloak: title"} {
|
for index, want := range []struct {
|
||||||
found := false
|
role string
|
||||||
for _, message := range prepared.Messages {
|
cached bool
|
||||||
if strings.Contains(message.Content, want) {
|
marker string
|
||||||
found = true
|
}{
|
||||||
break
|
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
|
||||||
|
{role: "user", marker: "Transcript units are the only evidence"},
|
||||||
|
{role: "user", cached: true, marker: "most specific supported in-world"},
|
||||||
|
{role: "user", cached: true},
|
||||||
|
{role: "user", marker: "individually identifiable"},
|
||||||
|
{role: "user", cached: true, marker: "observed display name"},
|
||||||
|
{role: "user"},
|
||||||
|
} {
|
||||||
|
if index >= len(prepared.Messages) {
|
||||||
|
t.Fatalf("prepared prompt has %d messages, want at least %d", len(prepared.Messages), index+1)
|
||||||
|
}
|
||||||
|
message := prepared.Messages[index]
|
||||||
|
if message.Role != want.role {
|
||||||
|
t.Errorf("message %d role = %q, want %q", index, message.Role, want.role)
|
||||||
|
}
|
||||||
|
if want.cached {
|
||||||
|
if message.CacheControl == nil || message.CacheControl.Type != scriptorium.CacheControlEphemeral {
|
||||||
|
t.Errorf("message %d cache control = %#v, want ephemeral", index, message.CacheControl)
|
||||||
|
}
|
||||||
|
} else if message.CacheControl != nil {
|
||||||
|
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
|
||||||
|
}
|
||||||
|
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
|
||||||
|
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if len(prepared.Messages) != 7 {
|
||||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
t.Fatalf("prepared prompt has %d messages, want 7", len(prepared.Messages))
|
||||||
}
|
}
|
||||||
|
if references := prepared.Messages[3].Content; !strings.Contains(references, "Dana: Mira") || !strings.Contains(references, "Mira: ranger") || !strings.Contains(references, "Greencloak: title") {
|
||||||
|
t.Fatalf("campaign references message = %q, want rendered reference inputs", references)
|
||||||
|
}
|
||||||
|
if transcript := prepared.Messages[6].Content; !strings.Contains(transcript, `{"units":[1]}`) {
|
||||||
|
t.Fatalf("final message = %q, want transcript", transcript)
|
||||||
}
|
}
|
||||||
transcriptMessages := 0
|
transcriptMessages := 0
|
||||||
for _, message := range prepared.Messages {
|
for _, message := range prepared.Messages {
|
||||||
|
|||||||
@@ -58,11 +58,6 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
|
|||||||
return extractor
|
return extractor
|
||||||
}
|
}
|
||||||
|
|
||||||
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
|
||||||
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
||||||
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
|
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
|
||||||
return req
|
return req
|
||||||
|
|||||||
@@ -6,16 +6,13 @@
|
|||||||
"required": ["kind", "title", "summary"],
|
"required": ["kind", "title", "summary"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"kind": {
|
"kind": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"enum": ["combat", "narrative", "recap", "meta"]
|
|
||||||
},
|
},
|
||||||
"title": {
|
"title": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"type": "string",
|
"type": "string"
|
||||||
"minLength": 1
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,22 +113,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if e.llm == nil {
|
if e.llm == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("LLM client must not be nil")
|
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("LLM client must not be nil")
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||||
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("context must not be nil")
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("context error before extraction: %w", err)
|
|
||||||
}
|
|
||||||
if req.Source == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("source must not be nil")
|
|
||||||
}
|
|
||||||
if req.Chunk == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("chunk must not be nil")
|
|
||||||
}
|
|
||||||
if len(req.Chunk.Units) == 0 {
|
|
||||||
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
|
||||||
}
|
|
||||||
sourceInput, err := shared.ChunkPromptMaterial(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("%w", err)
|
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,43 +70,47 @@ func TestExtractPassesOptionalReferencesAndUsesEmptyPlaceholders(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractPreservesTheModelKindWithoutRepair(t *testing.T) {
|
func TestExtractReturnsSemanticallyInvalidResponseForDeterministicValidation(t *testing.T) {
|
||||||
|
schema, err := loadResponseSchema()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadResponseSchema() error = %v", err)
|
||||||
|
}
|
||||||
|
if err := validateJSONSchema(t, map[string]any{"kind": "unrecognized", "title": " ", "summary": ""}, schema.JSONSchema); err != nil {
|
||||||
|
t.Fatalf("semantic candidate rejected by private schema: %v", err)
|
||||||
|
}
|
||||||
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{
|
client := &fakeSceneDescriptionsLLMClient{response: extractionResponse{
|
||||||
Kind: dnd.SceneKind("unrecognized"), Title: " Untitled scene ", Summary: " Summary ",
|
Kind: dnd.SceneKind("unrecognized"), Title: " ", Summary: "",
|
||||||
}}
|
}}
|
||||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Extract() error = %v, want nil", err)
|
t.Fatalf("Extract() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
scene := result.Value.Scenes[0]
|
scene := result.Value.Scenes[0]
|
||||||
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "Untitled scene" || scene.Summary != "Summary" {
|
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "" || scene.Summary != "" {
|
||||||
t.Fatalf("scene = %#v, want model kind preserved and textual fields trimmed", scene)
|
t.Fatalf("scene = %#v, want semantic candidates returned for deterministic validation", scene)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractValidatesRequestsAndSurfacesProviderFailures(t *testing.T) {
|
func TestExtractValidatesRequestsAndSurfacesProviderFailures(t *testing.T) {
|
||||||
request := extractionRequest()
|
request := extractionRequest()
|
||||||
extractor := newExtractor(t, &fakeSceneDescriptionsLLMClient{response: extractionResponse{Kind: dnd.SceneKindMeta, Title: "Table talk", Summary: "The group discusses rules."}})
|
extractor := newExtractor(t, &fakeSceneDescriptionsLLMClient{response: extractionResponse{Kind: dnd.SceneKindMeta, Title: "Table talk", Summary: "The group discusses rules."}})
|
||||||
|
var nilExtractor *Extractor
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
|
extractor *Extractor
|
||||||
req contracts.TypedExtractionRequest
|
req contracts.TypedExtractionRequest
|
||||||
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "nil source", req: func() contracts.TypedExtractionRequest { r := request; r.Source = nil; return r }()},
|
{name: "nil extractor", extractor: nilExtractor, req: request, want: "extractor"},
|
||||||
{name: "nil chunk", req: func() contracts.TypedExtractionRequest { r := request; r.Chunk = nil; return r }()},
|
{name: "nil LLM client", extractor: &Extractor{}, req: request, want: "LLM client"},
|
||||||
{name: "empty chunk", req: emptyChunkRequest(request)},
|
{name: "wrapped preflight failure", extractor: extractor, req: mismatchedSourceInputRequest(request), want: "must match chunk"},
|
||||||
{name: "mismatched source input", req: mismatchedSourceInputRequest(request)},
|
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
if _, err := extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") {
|
if _, err := test.extractor.Extract(context.Background(), test.req); err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") || !strings.Contains(err.Error(), test.want) {
|
||||||
t.Fatalf("Extract() error = %v, want contextual validation error", err)
|
t.Fatalf("Extract() error = %v, want contextual validation error", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
canceled, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
if _, err := extractor.Extract(canceled, request); err == nil || !strings.Contains(err.Error(), "context") {
|
|
||||||
t.Fatalf("canceled Extract() error = %v, want context error", err)
|
|
||||||
}
|
|
||||||
_, err := newExtractor(t, &fakeSceneDescriptionsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
|
_, err := newExtractor(t, &fakeSceneDescriptionsLLMClient{err: errors.New("provider unavailable")}).Extract(context.Background(), request)
|
||||||
if err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") || !strings.Contains(err.Error(), "provider unavailable") {
|
if err == nil || !strings.Contains(err.Error(), "dnd scene descriptions") || !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)
|
||||||
|
|||||||
@@ -30,10 +30,12 @@ func TestLoadResponseSchemaUsesStrictPrivateSceneDescriptionContract(t *testing.
|
|||||||
{name: "unknown framework field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "id": "assigned-later"}},
|
{name: "unknown framework field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "id": "assigned-later"}},
|
||||||
{name: "unknown application field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "source_ref": map[string]any{}}},
|
{name: "unknown application field", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "source_ref": map[string]any{}}},
|
||||||
{name: "collection is not allowed", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "scenes": []any{}}},
|
{name: "collection is not allowed", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": "Bandits strike.", "scenes": []any{}}},
|
||||||
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}},
|
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}, valid: true},
|
||||||
{name: "wrong field type", response: map[string]any{"kind": "combat", "title": 7, "summary": "Bandits strike."}},
|
{name: "empty title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}, valid: true},
|
||||||
{name: "blank title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}},
|
{name: "empty summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}, valid: true},
|
||||||
{name: "blank summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}},
|
{name: "wrong kind type", response: map[string]any{"kind": 7, "title": "Ambush", "summary": "Bandits strike."}},
|
||||||
|
{name: "wrong title type", response: map[string]any{"kind": "combat", "title": 7, "summary": "Bandits strike."}},
|
||||||
|
{name: "wrong summary type", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": 7}},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
err := validateJSONSchema(t, test.response, schema.JSONSchema)
|
err := validateJSONSchema(t, test.response, schema.JSONSchema)
|
||||||
@@ -54,7 +56,7 @@ func TestResponseSchemaIsMutationSafeAndDiagnosticsRedactContent(t *testing.T) {
|
|||||||
}
|
}
|
||||||
first.JSONSchema[0] = '['
|
first.JSONSchema[0] = '['
|
||||||
second, err := loadResponseSchema()
|
second, err := loadResponseSchema()
|
||||||
if err != nil || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) {
|
if err != nil || !strings.HasPrefix(second.SHA256, "sha256:") || !json.Valid(second.JSONSchema) || bytes.Equal(first.JSONSchema, second.JSONSchema) {
|
||||||
t.Fatalf("second schema = %s, %v; want defensive copy", second.JSONSchema, err)
|
t.Fatalf("second schema = %s, %v; want defensive copy", second.JSONSchema, err)
|
||||||
}
|
}
|
||||||
if diagnostics := second.DiagnosticsMap(); diagnostics["json_schema"] != nil {
|
if diagnostics := second.DiagnosticsMap(); diagnostics["json_schema"] != nil {
|
||||||
|
|||||||
@@ -54,11 +54,6 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
|
|||||||
return extractor
|
return extractor
|
||||||
}
|
}
|
||||||
|
|
||||||
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
|
||||||
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
||||||
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
|
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
|
||||||
return req
|
return req
|
||||||
|
|||||||
@@ -5,75 +5,71 @@ import (
|
|||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
func canonicalizeResponse(response *extractionResponse) {
|
type orderedSpellResponse struct {
|
||||||
|
value spellCastResponse
|
||||||
|
earliest int
|
||||||
|
hasEvidence bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string) {
|
||||||
if response == nil {
|
if response == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
ordered := make([]orderedSpellResponse, len(response.SpellCasts))
|
||||||
for index := range response.SpellCasts {
|
for index := range response.SpellCasts {
|
||||||
canonicalizeSpellCast(&response.SpellCasts[index])
|
earliest, hasEvidence := canonicalizeSpellCast(&response.SpellCasts[index], order, sourceID)
|
||||||
|
ordered[index] = orderedSpellResponse{
|
||||||
|
value: response.SpellCasts[index],
|
||||||
|
earliest: earliest,
|
||||||
|
hasEvidence: hasEvidence,
|
||||||
}
|
}
|
||||||
sort.SliceStable(response.SpellCasts, func(i, j int) bool {
|
|
||||||
left, leftOK := earliestSourceUnit(response.SpellCasts[i])
|
|
||||||
right, rightOK := earliestSourceUnit(response.SpellCasts[j])
|
|
||||||
if leftOK != rightOK {
|
|
||||||
return leftOK
|
|
||||||
}
|
}
|
||||||
if !leftOK {
|
sort.SliceStable(ordered, func(i, j int) bool {
|
||||||
|
if ordered[i].hasEvidence != ordered[j].hasEvidence {
|
||||||
|
return ordered[i].hasEvidence
|
||||||
|
}
|
||||||
|
if !ordered[i].hasEvidence {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return left < right
|
return ordered[i].earliest < ordered[j].earliest
|
||||||
})
|
})
|
||||||
|
for index := range ordered {
|
||||||
|
response.SpellCasts[index] = ordered[index].value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeSpellCast(spell *spellCastResponse) {
|
func canonicalizeSpellCast(spell *spellCastResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||||
sort.SliceStable(spell.SourceRefs, func(i, j int) bool {
|
if spell == nil {
|
||||||
left := spell.SourceRefs[i]
|
|
||||||
right := spell.SourceRefs[j]
|
|
||||||
if left.StartUnitID != right.StartUnitID {
|
|
||||||
return unitSortValue(left.StartUnitID) < unitSortValue(right.StartUnitID)
|
|
||||||
}
|
|
||||||
return unitSortValue(left.EndUnitID) < unitSortValue(right.EndUnitID)
|
|
||||||
})
|
|
||||||
spell.SourceRefs = dedupeSourceRefs(spell.SourceRefs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func dedupeSourceRefs(refs []spellSourceRefResponse) []spellSourceRefResponse {
|
|
||||||
if len(refs) < 2 {
|
|
||||||
return refs
|
|
||||||
}
|
|
||||||
out := refs[:0]
|
|
||||||
var previous spellSourceRefResponse
|
|
||||||
for index, ref := range refs {
|
|
||||||
if index > 0 && sameSourceRef(previous, ref) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
out = append(out, ref)
|
|
||||||
previous = ref
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameSourceRef(left spellSourceRefResponse, right spellSourceRefResponse) bool {
|
|
||||||
return left.StartUnitID == right.StartUnitID && left.EndUnitID == right.EndUnitID
|
|
||||||
}
|
|
||||||
|
|
||||||
func earliestSourceUnit(spell spellCastResponse) (int, bool) {
|
|
||||||
for _, ref := range spell.SourceRefs {
|
|
||||||
start := ref.StartUnitID
|
|
||||||
if start > 0 {
|
|
||||||
return start, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
|
refs := order.Canonicalize(spellSourceRefs(spell.SourceRefs, sourceID))
|
||||||
func unitSortValue(value int) int {
|
spell.SourceRefs = spellResponseRefs(refs)
|
||||||
if value <= 0 {
|
return order.EarliestValid(refs)
|
||||||
return int(^uint(0) >> 1)
|
|
||||||
}
|
}
|
||||||
return value
|
|
||||||
|
func spellSourceRefs(refs []spellSourceRefResponse, sourceID string) []source.SourceRef {
|
||||||
|
if refs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values := make([]source.SourceRef, len(refs))
|
||||||
|
for index, ref := range refs {
|
||||||
|
values[index] = source.SourceRef{SourceID: sourceID, StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func spellResponseRefs(refs []source.SourceRef) []spellSourceRefResponse {
|
||||||
|
if refs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values := make([]spellSourceRefResponse, len(refs))
|
||||||
|
for index, ref := range refs {
|
||||||
|
values[index] = spellSourceRefResponse{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||||
|
}
|
||||||
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalSpellList(response extractionResponse, sourceID string) dnd.SpellList {
|
func canonicalSpellList(response extractionResponse, sourceID string) dnd.SpellList {
|
||||||
|
|||||||
@@ -14,9 +14,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const Key = "dnd/spells"
|
const Key = "dnd/spells"
|
||||||
const ArtifactType = "dnd.spell_cast"
|
|
||||||
const SchemaVersion = "v1"
|
const SchemaVersion = "v1"
|
||||||
|
|
||||||
|
const mappingPolicy = "dnd.spells.extract_mapping.v2"
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
|
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
|
||||||
NPCRegistryMaxBytes = npcregistry.MaxBytes
|
NPCRegistryMaxBytes = npcregistry.MaxBytes
|
||||||
@@ -120,6 +121,9 @@ func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *Extractor) ManifestMetadata() map[string]any {
|
func (e *Extractor) ManifestMetadata() map[string]any {
|
||||||
|
if e == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
metadata := map[string]any{
|
metadata := map[string]any{
|
||||||
"prompt_id": PromptID,
|
"prompt_id": PromptID,
|
||||||
"prompt_version": SchemaVersion,
|
"prompt_version": SchemaVersion,
|
||||||
@@ -127,6 +131,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
|||||||
"catalog_base_id": e.effectiveCatalog.BaseID(),
|
"catalog_base_id": e.effectiveCatalog.BaseID(),
|
||||||
"catalog_digest": e.effectiveCatalog.Digest(),
|
"catalog_digest": e.effectiveCatalog.Digest(),
|
||||||
"catalog_overlay_ids": e.effectiveCatalog.OverlayIDs(),
|
"catalog_overlay_ids": e.effectiveCatalog.OverlayIDs(),
|
||||||
|
"mapping_policy": mappingPolicy,
|
||||||
"response_schema_key": string(ResponseSchemaKey),
|
"response_schema_key": string(ResponseSchemaKey),
|
||||||
"response_schema_id": ResponseSchemaID,
|
"response_schema_id": ResponseSchemaID,
|
||||||
"response_schema_name": ResponseSchemaName,
|
"response_schema_name": ResponseSchemaName,
|
||||||
@@ -147,6 +152,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
fingerprints := []pipeline.CheckpointFingerprint{
|
fingerprints := []pipeline.CheckpointFingerprint{
|
||||||
{Name: "effective_catalog", Value: e.effectiveCatalog.Digest()},
|
{Name: "effective_catalog", Value: e.effectiveCatalog.Digest()},
|
||||||
|
{Name: "mapping_policy", Value: mappingPolicy},
|
||||||
{Name: "prompt", Value: e.promptSHA},
|
{Name: "prompt", Value: e.promptSHA},
|
||||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||||
}
|
}
|
||||||
@@ -162,25 +168,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if e.llm == nil {
|
if e.llm == nil {
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("LLM client must not be nil")
|
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("LLM client must not be nil")
|
||||||
}
|
}
|
||||||
if ctx == nil {
|
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("context must not be nil")
|
|
||||||
}
|
|
||||||
if err := ctx.Err(); err != nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("context error before extraction: %w", err)
|
|
||||||
}
|
|
||||||
if req.Source == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("source must not be nil")
|
|
||||||
}
|
|
||||||
if req.Chunk == nil {
|
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk must not be nil")
|
|
||||||
}
|
|
||||||
if len(req.Chunk.Units) == 0 {
|
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("chunk %q units must not be empty", req.Chunk.ID)
|
|
||||||
}
|
|
||||||
sourceInput, err := shared.ChunkPromptMaterial(req)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err)
|
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err)
|
||||||
}
|
}
|
||||||
|
order := shared.NewSourceRefOrder(req.Source)
|
||||||
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("resolve NPC registry: %w", err)
|
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("resolve NPC registry: %w", err)
|
||||||
@@ -200,7 +192,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
canonicalizeResponse(&response)
|
canonicalizeResponse(&response, order, req.Source.ID)
|
||||||
return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID)}, nil
|
return contracts.TypedExtractionResult[dnd.SpellList]{Value: canonicalSpellList(response, req.Source.ID)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
|
|||||||
}
|
}
|
||||||
|
|
||||||
metadata := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).ManifestMetadata()
|
metadata := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).ManifestMetadata()
|
||||||
|
if metadata["mapping_policy"] != mappingPolicy {
|
||||||
|
t.Fatalf("mapping policy metadata = %#v, want %q", metadata["mapping_policy"], mappingPolicy)
|
||||||
|
}
|
||||||
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID {
|
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID {
|
||||||
t.Fatalf("catalog base metadata = %#v", metadata["catalog_base_id"])
|
t.Fatalf("catalog base metadata = %#v", metadata["catalog_base_id"])
|
||||||
}
|
}
|
||||||
@@ -116,6 +119,7 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
|
|||||||
fingerprints := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).CheckpointFingerprints()
|
fingerprints := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).CheckpointFingerprints()
|
||||||
wantFingerprints := map[string]any{
|
wantFingerprints := map[string]any{
|
||||||
"effective_catalog": metadata["catalog_digest"],
|
"effective_catalog": metadata["catalog_digest"],
|
||||||
|
"mapping_policy": mappingPolicy,
|
||||||
"prompt": metadata["prompt_sha256"],
|
"prompt": metadata["prompt_sha256"],
|
||||||
"response_schema": metadata["response_schema_sha256"],
|
"response_schema": metadata["response_schema_sha256"],
|
||||||
"npc_registry": checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints())["npc_registry"],
|
"npc_registry": checkpointFingerprintMap(newExtractor(t, &fakeSpellsLLMClient{}).CheckpointFingerprints())["npc_registry"],
|
||||||
@@ -170,6 +174,13 @@ func TestExtractorManifestMetadataIncludesLLMSchemaProvenance(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNilExtractorManifestMetadata(t *testing.T) {
|
||||||
|
var extractor *Extractor
|
||||||
|
if metadata := extractor.ManifestMetadata(); metadata != nil {
|
||||||
|
t.Fatalf("nil extractor metadata = %#v, want nil", metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
|
func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
|
||||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -234,9 +245,8 @@ func TestExtractWrapsLLMClientError(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtractRejectsInvalidRequests(t *testing.T) {
|
func TestExtractRejectsInvalidRequests(t *testing.T) {
|
||||||
validReq := extractionRequest()
|
validReq := extractionRequest()
|
||||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
|
||||||
cancel()
|
|
||||||
validExtractor := newExtractor(t, &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}})
|
validExtractor := newExtractor(t, &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}})
|
||||||
|
var nilExtractor *Extractor
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
extractor *Extractor
|
extractor *Extractor
|
||||||
@@ -244,13 +254,9 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
|
|||||||
req contracts.TypedExtractionRequest
|
req contracts.TypedExtractionRequest
|
||||||
want string
|
want string
|
||||||
}{
|
}{
|
||||||
{name: "nil extractor", ctx: context.Background(), req: validReq, want: "extractor"},
|
{name: "nil extractor", extractor: nilExtractor, ctx: context.Background(), req: validReq, want: "extractor"},
|
||||||
{name: "nil context", extractor: validExtractor, req: validReq, want: "context"},
|
{name: "nil LLM client", extractor: &Extractor{}, ctx: context.Background(), req: validReq, want: "LLM client"},
|
||||||
{name: "canceled context", extractor: validExtractor, ctx: canceledCtx, req: validReq, want: "context"},
|
{name: "wrapped preflight failure", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
|
||||||
{name: "nil source", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Chunk: validReq.Chunk}, want: "source"},
|
|
||||||
{name: "nil chunk", extractor: validExtractor, ctx: context.Background(), req: contracts.TypedExtractionRequest{Source: validReq.Source}, want: "chunk"},
|
|
||||||
{name: "empty chunk units", extractor: validExtractor, ctx: context.Background(), req: emptyChunkRequest(validReq), want: "units"},
|
|
||||||
{name: "source input mismatches chunk", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
|
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -280,6 +286,57 @@ func TestExtractOrdersAndDeduplicatesEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractUsesDocumentOrderForReferencesAndSpellCasts(t *testing.T) {
|
||||||
|
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
|
||||||
|
{Caster: "Later", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(10, 10)},
|
||||||
|
{Caster: "Earlier", Spell: "Cure Wounds", SourceRefs: []spellSourceRefResponse{
|
||||||
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{StartUnitID: 999, EndUnitID: 0},
|
||||||
|
}},
|
||||||
|
{Caster: "Unavailable", Spell: "Healing Word", SourceRefs: []spellSourceRefResponse{{StartUnitID: 999, EndUnitID: 0}}},
|
||||||
|
}}}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.Source.Units = []source.SourceUnit{{ID: 30}, {ID: 10}}
|
||||||
|
req.Chunk.Units = append([]source.SourceUnit(nil), req.Source.Units...)
|
||||||
|
req.Chunk.Ref = source.SourceRef{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 10}
|
||||||
|
|
||||||
|
result, err := newExtractor(t, client).Extract(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := []string{result.Value.SpellCasts[0].Spell, result.Value.SpellCasts[1].Spell, result.Value.SpellCasts[2].Spell}; !reflect.DeepEqual(got, []string{"Cure Wounds", "Fire Bolt", "Healing Word"}) {
|
||||||
|
t.Fatalf("spell order = %#v, want document chronology followed by invalid evidence", got)
|
||||||
|
}
|
||||||
|
refs := result.Value.SpellCasts[0].SourceRefs
|
||||||
|
if got := []int{refs[0].StartUnitID, refs[1].StartUnitID, refs[2].StartUnitID}; !reflect.DeepEqual(got, []int{30, 10, 999}) {
|
||||||
|
t.Fatalf("source refs = %#v, want document order with exact duplicate removed", refs)
|
||||||
|
}
|
||||||
|
refs[0].StartUnitID = 777
|
||||||
|
for _, spell := range client.response.SpellCasts {
|
||||||
|
for _, ref := range spell.SourceRefs {
|
||||||
|
if ref.StartUnitID == 777 {
|
||||||
|
t.Fatal("result source references alias the model response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractPreservesStableSpellOrderForEqualEvidence(t *testing.T) {
|
||||||
|
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{
|
||||||
|
{Caster: "First", Spell: "Cure Wounds", SourceRefs: responseSourceRefs(2, 2)},
|
||||||
|
{Caster: "Second", Spell: "Fire Bolt", SourceRefs: responseSourceRefs(2, 2)},
|
||||||
|
}}}
|
||||||
|
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := []string{result.Value.SpellCasts[0].Caster, result.Value.SpellCasts[1].Caster}; !reflect.DeepEqual(got, []string{"First", "Second"}) {
|
||||||
|
t.Fatalf("equal-evidence order = %#v, want stable response order", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
|
func TestExtractPreservesInvalidEvidenceForValidators(t *testing.T) {
|
||||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
|
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
|
||||||
Caster: "Aria", Spell: "Cure Wounds",
|
Caster: "Aria", Spell: "Cure Wounds",
|
||||||
|
|||||||
@@ -21,24 +21,53 @@ 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)
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for index, want := range []struct {
|
||||||
string(transcript),
|
role string
|
||||||
"Dana: Mira",
|
cached bool
|
||||||
"Mira: wizard",
|
marker string
|
||||||
"Shield: abjuration",
|
|
||||||
`{"spell_names":["Cure Wounds"]}`,
|
|
||||||
`{"npcs":[]}`,
|
|
||||||
}{
|
}{
|
||||||
found := false
|
{role: "system", marker: "Dungeons & Dragons gameplay transcripts"},
|
||||||
for _, message := range prepared.Messages {
|
{role: "user", marker: "Transcript units are the only evidence"},
|
||||||
if strings.Contains(message.Content, want) {
|
{role: "user", cached: true, marker: "most specific supported in-world"},
|
||||||
found = true
|
{role: "user", cached: true},
|
||||||
break
|
{role: "user", cached: true},
|
||||||
|
{role: "user"},
|
||||||
|
{role: "user", marker: "spell-cast artifacts"},
|
||||||
|
{role: "user", cached: true, marker: "source references must collectively support"},
|
||||||
|
{role: "user"},
|
||||||
|
} {
|
||||||
|
if index >= len(prepared.Messages) {
|
||||||
|
t.Fatalf("prepared prompt has %d messages, want at least %d", len(prepared.Messages), index+1)
|
||||||
|
}
|
||||||
|
message := prepared.Messages[index]
|
||||||
|
if message.Role != want.role {
|
||||||
|
t.Errorf("message %d role = %q, want %q", index, message.Role, want.role)
|
||||||
|
}
|
||||||
|
if want.cached {
|
||||||
|
if message.CacheControl == nil || message.CacheControl.Type != scriptorium.CacheControlEphemeral {
|
||||||
|
t.Errorf("message %d cache control = %#v, want ephemeral", index, message.CacheControl)
|
||||||
|
}
|
||||||
|
} else if message.CacheControl != nil {
|
||||||
|
t.Errorf("message %d cache control = %#v, want nil", index, message.CacheControl)
|
||||||
|
}
|
||||||
|
if want.marker != "" && !strings.Contains(message.Content, want.marker) {
|
||||||
|
t.Errorf("message %d content does not contain purpose marker %q", index, want.marker)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !found {
|
if len(prepared.Messages) != 9 {
|
||||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
t.Fatalf("prepared prompt has %d messages, want 9", len(prepared.Messages))
|
||||||
}
|
}
|
||||||
|
if references := prepared.Messages[3].Content; !strings.Contains(references, "Dana: Mira") || !strings.Contains(references, "Mira: wizard") || !strings.Contains(references, "Shield: abjuration") {
|
||||||
|
t.Fatalf("campaign references message = %q, want rendered reference inputs", references)
|
||||||
|
}
|
||||||
|
if registry := prepared.Messages[4].Content; !strings.Contains(registry, `{"npcs":[]}`) {
|
||||||
|
t.Fatalf("NPC registry message = %q, want registry input", registry)
|
||||||
|
}
|
||||||
|
if catalog := prepared.Messages[5].Content; !strings.Contains(catalog, `{"spell_names":["Cure Wounds"]}`) {
|
||||||
|
t.Fatalf("spell catalog message = %q, want catalog input", catalog)
|
||||||
|
}
|
||||||
|
if final := prepared.Messages[8].Content; !strings.Contains(final, string(transcript)) {
|
||||||
|
t.Fatalf("final message = %q, want transcript", final)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,11 +99,6 @@ func extractionRequest() contracts.TypedExtractionRequest {
|
|||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
func emptyChunkRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
|
||||||
req.Chunk = &source.Chunk{ID: req.Chunk.ID, SourceID: req.Chunk.SourceID, Index: req.Chunk.Index}
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
func mismatchedSourceInputRequest(req contracts.TypedExtractionRequest) contracts.TypedExtractionRequest {
|
||||||
req.SourceInput = spellSourceInput()
|
req.SourceInput = spellSourceInput()
|
||||||
return req
|
return req
|
||||||
|
|||||||
@@ -14,12 +14,13 @@ import (
|
|||||||
"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/npcs/identity"
|
||||||
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||||
|
"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"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/combat-turns"
|
Key = "dnd/combat-turns"
|
||||||
normalizationPolicy = "dnd.combat_turns.normalize.v1"
|
normalizationPolicy = "dnd.combat_turns.normalize.v2"
|
||||||
NormalizationPolicy = normalizationPolicy
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|
||||||
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
||||||
@@ -109,7 +110,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("resolve NPC registry: %w", err)
|
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{}, normalizerErrorf("resolve NPC registry: %w", err)
|
||||||
}
|
}
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, npcRegistry)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
|
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
|
||||||
|
value, warnings := normalizeList(req.MergeOutput.Value, index, order, npcRegistry)
|
||||||
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil
|
return contracts.TypedNormalizeResult[dnd.CombatTurnList]{Value: value, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +128,7 @@ type actorCanonicalization struct {
|
|||||||
to string
|
to string
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
|
func normalizeList(input dnd.CombatTurnList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurnList, []contracts.Warning) {
|
||||||
if input.CombatTurns == nil {
|
if input.CombatTurns == nil {
|
||||||
return dnd.CombatTurnList{}, nil
|
return dnd.CombatTurnList{}, nil
|
||||||
}
|
}
|
||||||
@@ -133,8 +136,8 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
|
|||||||
records := make([]normalizedRecord, len(input.CombatTurns))
|
records := make([]normalizedRecord, len(input.CombatTurns))
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for index, inputTurn := range input.CombatTurns {
|
for index, inputTurn := range input.CombatTurns {
|
||||||
turn, actorChange, refsChanged := normalizeTurn(inputTurn, registry)
|
turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry)
|
||||||
earliest, hasEvidence := earliestSourcePosition(doc, turn)
|
earliest, hasEvidence := order.EarliestValid(turn.SourceRefs)
|
||||||
records[index] = normalizedRecord{
|
records[index] = normalizedRecord{
|
||||||
turn: turn,
|
turn: turn,
|
||||||
inputIndex: index,
|
inputIndex: index,
|
||||||
@@ -180,12 +183,12 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
output, duplicateWarnings := collapseDuplicates(records, doc)
|
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
warnings = append(warnings, duplicateWarnings...)
|
||||||
return dnd.CombatTurnList{CombatTurns: output}, warnings
|
return dnd.CombatTurnList{CombatTurns: output}, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
|
func normalizeTurn(input dnd.CombatTurn, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.CombatTurn, *actorCanonicalization, bool) {
|
||||||
output := cloneCombatTurn(input)
|
output := cloneCombatTurn(input)
|
||||||
output.Actor = identity.NormalizeDisplay(input.Actor)
|
output.Actor = identity.NormalizeDisplay(input.Actor)
|
||||||
|
|
||||||
@@ -198,8 +201,7 @@ func normalizeTurn(input dnd.CombatTurn, registry *npcregistry.Registry) (dnd.Co
|
|||||||
actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor}
|
actorChange = &actorCanonicalization{from: input.Actor, to: output.Actor}
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
|
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||||
output.SourceRefs = canonicalRefs
|
|
||||||
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||||
return output, actorChange, refsChanged
|
return output, actorChange, refsChanged
|
||||||
}
|
}
|
||||||
@@ -225,70 +227,12 @@ func sourceRefsEqual(left, right []source.SourceRef) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
|
||||||
if input == nil {
|
|
||||||
return nil, false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
canonical := make([]source.SourceRef, len(input))
|
|
||||||
copy(canonical, input)
|
|
||||||
sort.SliceStable(canonical, func(left, right int) bool {
|
|
||||||
return sourceRefLess(canonical[left], canonical[right])
|
|
||||||
})
|
|
||||||
|
|
||||||
orderChanged := false
|
|
||||||
for index := range input {
|
|
||||||
if input[index] != canonical[index] {
|
|
||||||
orderChanged = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
unique := make([]source.SourceRef, 0, len(canonical))
|
|
||||||
for _, ref := range canonical {
|
|
||||||
if len(unique) == 0 || unique[len(unique)-1] != ref {
|
|
||||||
unique = append(unique, ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return unique, orderChanged, len(input) - len(unique)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceRefLess(left, right source.SourceRef) bool {
|
|
||||||
if left.SourceID != right.SourceID {
|
|
||||||
return left.SourceID < right.SourceID
|
|
||||||
}
|
|
||||||
if left.StartUnitID != right.StartUnitID {
|
|
||||||
return left.StartUnitID < right.StartUnitID
|
|
||||||
}
|
|
||||||
return left.EndUnitID < right.EndUnitID
|
|
||||||
}
|
|
||||||
|
|
||||||
func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) {
|
|
||||||
if doc == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
earliest := 0
|
|
||||||
found := false
|
|
||||||
for _, ref := range turn.SourceRefs {
|
|
||||||
if source.ValidateRef(doc, ref) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
index, ok := source.UnitIndex(doc, ref.StartUnitID)
|
|
||||||
if !ok || (found && index >= earliest) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
earliest = index
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
return earliest, found
|
|
||||||
}
|
|
||||||
|
|
||||||
type duplicateGroup struct {
|
type duplicateGroup struct {
|
||||||
retainedIndex int
|
retainedIndex int
|
||||||
removed []int
|
removed []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.CombatTurn, []contracts.Warning) {
|
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.CombatTurn, []contracts.Warning) {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return make([]dnd.CombatTurn, 0), nil
|
return make([]dnd.CombatTurn, 0), nil
|
||||||
}
|
}
|
||||||
@@ -297,7 +241,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
|
|||||||
groups := make([]duplicateGroup, 0)
|
groups := make([]duplicateGroup, 0)
|
||||||
groupByKey := make(map[string]int)
|
groupByKey := make(map[string]int)
|
||||||
for index, record := range records {
|
for index, record := range records {
|
||||||
key, eligible := duplicateKey(record.turn, doc)
|
key, eligible := duplicateKey(record.turn, documentIndex)
|
||||||
if !eligible {
|
if !eligible {
|
||||||
keep[index] = true
|
keep[index] = true
|
||||||
continue
|
continue
|
||||||
@@ -329,12 +273,12 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
|
|||||||
return output, warnings
|
return output, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateKey(turn dnd.CombatTurn, doc *source.SourceDocument) (string, bool) {
|
func duplicateKey(turn dnd.CombatTurn, documentIndex source.DocumentIndex) (string, bool) {
|
||||||
if len(turn.SourceRefs) == 0 {
|
if len(turn.SourceRefs) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
for _, ref := range turn.SourceRefs {
|
for _, ref := range turn.SourceRefs {
|
||||||
if source.ValidateRef(doc, ref) != nil {
|
if documentIndex.ValidateRef(ref) != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,34 @@ func TestNormalizeOrdersBySourcePositionAndCollapsesExactDuplicates(t *testing.T
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeUsesDocumentOrderForReferencesAndChronology(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||||
|
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
|
||||||
|
validTurn("Borin", source.SourceRef{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}),
|
||||||
|
validTurn("Aria", source.SourceRef{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}),
|
||||||
|
{Actor: "Goblin", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{
|
||||||
|
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||||
|
}},
|
||||||
|
}}
|
||||||
|
normalizer, err := New(Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("New() error = %v", err)
|
||||||
|
}
|
||||||
|
result, err := normalizer.Normalize(context.Background(), contracts.TypedNormalizeRequest[dnd.CombatTurnList]{Source: doc, MergeOutput: contracts.MergeArtifact[dnd.CombatTurnList]{Value: input}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
if got := result.Value.CombatTurns; got[0].Actor != "Aria" || got[1].Actor != "Goblin" || got[2].Actor != "Borin" {
|
||||||
|
t.Fatalf("turn order = %#v, want source chronology", got)
|
||||||
|
}
|
||||||
|
refs := result.Value.CombatTurns[1].SourceRefs
|
||||||
|
if refs[0].StartUnitID != 30 || refs[1].StartUnitID != 10 || refs[2].StartUnitID != 999 {
|
||||||
|
t.Fatalf("turn source refs = %#v, want source-document order and invalid fallback", refs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
|
func TestNormalizePreservesStableOrderForEqualEvidencePositions(t *testing.T) {
|
||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}}
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}}
|
||||||
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
|
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
|
||||||
|
|||||||
@@ -118,7 +118,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
if !registry.Bound() {
|
if !registry.Bound() {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required")
|
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{}, normalizerErrorf("NPC registry reference is required")
|
||||||
}
|
}
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value, req.Source, registry)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
|
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
|
||||||
|
value, warnings := normalizeList(req.MergeOutput.Value, index, order, registry)
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
|
return contracts.TypedNormalizeResult[dnd.NPCInteractionList]{Value: value, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,7 +134,7 @@ type nameCanonicalization struct {
|
|||||||
to string
|
to string
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
|
func normalizeList(input dnd.NPCInteractionList, documentIndex source.DocumentIndex, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteractionList, []contracts.Warning) {
|
||||||
if input.Interactions == nil {
|
if input.Interactions == nil {
|
||||||
return dnd.NPCInteractionList{}, nil
|
return dnd.NPCInteractionList{}, nil
|
||||||
}
|
}
|
||||||
@@ -140,7 +142,7 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
|
|||||||
records := make([]normalizedRecord, len(input.Interactions))
|
records := make([]normalizedRecord, len(input.Interactions))
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for index, inputInteraction := range input.Interactions {
|
for index, inputInteraction := range input.Interactions {
|
||||||
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, doc, registry)
|
interaction, nameChange, refsChanged := normalizeInteraction(inputInteraction, order, registry)
|
||||||
records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
|
records[index] = normalizedRecord{interaction: interaction, inputIndex: index}
|
||||||
if nameChange != nil {
|
if nameChange != nil {
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
@@ -161,7 +163,7 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
|
|||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(records, func(left, right int) bool {
|
sort.SliceStable(records, func(left, right int) bool {
|
||||||
return interactionmodel.Less(doc, records[left].interaction, records[right].interaction)
|
return interactionmodel.Less(order, records[left].interaction, records[right].interaction)
|
||||||
})
|
})
|
||||||
for position, record := range records {
|
for position, record := range records {
|
||||||
if position == record.inputIndex {
|
if position == record.inputIndex {
|
||||||
@@ -174,13 +176,13 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
output, duplicateWarnings := collapseDuplicates(records, doc)
|
output, duplicateWarnings := collapseDuplicates(records, documentIndex)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
warnings = append(warnings, duplicateWarnings...)
|
||||||
return dnd.NPCInteractionList{Interactions: output},
|
return dnd.NPCInteractionList{Interactions: output},
|
||||||
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
|
diagnostics.LimitWarnings(warnings, "npc_interactions", ReasonCodeWarningsOmitted)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
|
func normalizeInteraction(input dnd.NPCInteraction, order shared.SourceRefOrder, registry *npcregistry.Registry) (dnd.NPCInteraction, *nameCanonicalization, bool) {
|
||||||
output := cloneInteraction(input)
|
output := cloneInteraction(input)
|
||||||
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok {
|
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok {
|
||||||
output.Name = canonical.Name
|
output.Name = canonical.Name
|
||||||
@@ -189,7 +191,7 @@ func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument,
|
|||||||
if input.Name != output.Name {
|
if input.Name != output.Name {
|
||||||
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
|
nameChange = &nameCanonicalization{from: input.Name, to: output.Name}
|
||||||
}
|
}
|
||||||
output.SourceRefs = interactionmodel.CanonicalizeSourceRefs(doc, input.SourceRefs)
|
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||||
return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
return output, nameChange, !interactionmodel.SourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +208,7 @@ type duplicateGroup struct {
|
|||||||
removed []int
|
removed []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument) ([]dnd.NPCInteraction, []contracts.Warning) {
|
func collapseDuplicates(records []normalizedRecord, documentIndex source.DocumentIndex) ([]dnd.NPCInteraction, []contracts.Warning) {
|
||||||
if len(records) == 0 {
|
if len(records) == 0 {
|
||||||
return make([]dnd.NPCInteraction, 0), nil
|
return make([]dnd.NPCInteraction, 0), nil
|
||||||
}
|
}
|
||||||
@@ -214,7 +216,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
|
|||||||
groups := make([]duplicateGroup, 0)
|
groups := make([]duplicateGroup, 0)
|
||||||
groupByKey := make(map[string]int)
|
groupByKey := make(map[string]int)
|
||||||
for index, record := range records {
|
for index, record := range records {
|
||||||
if !interactionmodel.ValidSourceRefs(doc, record.interaction.SourceRefs) {
|
if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) {
|
||||||
keep[index] = true
|
keep[index] = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestNormalizeCanonicalizesAndClones(t *testing.T) {
|
func TestNormalizeCanonicalizesAndClones(t *testing.T) {
|
||||||
doc := testDocument()
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||||
normalizer, err := New(Options{}, npcReferences(t))
|
normalizer, err := New(Options{}, npcReferences(t))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("New() error = %v", err)
|
t.Fatalf("New() error = %v", err)
|
||||||
@@ -30,7 +30,7 @@ func TestNormalizeCanonicalizesAndClones(t *testing.T) {
|
|||||||
t.Fatalf("Normalize() error = %v", err)
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
}
|
}
|
||||||
got := result.Value.Interactions[0]
|
got := result.Value.Interactions[0]
|
||||||
if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}) {
|
if got.Name != "Ária" || !reflect.DeepEqual(got.SourceRefs, []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}) {
|
||||||
t.Fatalf("normalized interaction = %#v", got)
|
t.Fatalf("normalized interaction = %#v", got)
|
||||||
}
|
}
|
||||||
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -14,12 +13,13 @@ import (
|
|||||||
"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/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"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/npcs"
|
Key = "dnd/npcs"
|
||||||
normalizationPolicy = "dnd.npcs.normalize.v1"
|
normalizationPolicy = "dnd.npcs.normalize.v2"
|
||||||
NormalizationPolicy = normalizationPolicy
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|
||||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||||
@@ -69,7 +69,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
if err := ctx.Err(); err != nil {
|
if err := ctx.Err(); err != nil {
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
|
return contracts.TypedNormalizeResult[dnd.NPCList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||||
}
|
}
|
||||||
value, warnings := normalizeList(req.MergeOutput.Value)
|
order := shared.NewSourceRefOrder(req.Source)
|
||||||
|
value, warnings := normalizeList(req.MergeOutput.Value, order)
|
||||||
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
|
return contracts.TypedNormalizeResult[dnd.NPCList]{Value: value, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,14 +78,14 @@ type normalizedRecord struct {
|
|||||||
npc dnd.NPC
|
npc dnd.NPC
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
|
func normalizeList(input dnd.NPCList, order shared.SourceRefOrder) (dnd.NPCList, []contracts.Warning) {
|
||||||
if input.NPCs == nil {
|
if input.NPCs == nil {
|
||||||
return dnd.NPCList{}, nil
|
return dnd.NPCList{}, nil
|
||||||
}
|
}
|
||||||
records := make([]normalizedRecord, len(input.NPCs))
|
records := make([]normalizedRecord, len(input.NPCs))
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for index, inputNPC := range input.NPCs {
|
for index, inputNPC := range input.NPCs {
|
||||||
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC)
|
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order)
|
||||||
records[index] = normalizedRecord{npc: npc}
|
records[index] = normalizedRecord{npc: npc}
|
||||||
if fieldsChanged {
|
if fieldsChanged {
|
||||||
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))})
|
warnings = append(warnings, contracts.Warning{Scope: npcScope(index), ReasonCode: ReasonCodeNPCFieldsNormalized, Message: fmt.Sprintf("input index %d: NPC name normalized for %s", index, diagnostics.Quote(inputNPC.Name))})
|
||||||
@@ -100,7 +101,7 @@ func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
|
|||||||
groups := canonicalNameGroups(records)
|
groups := canonicalNameGroups(records)
|
||||||
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
|
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
|
||||||
for _, members := range groups {
|
for _, members := range groups {
|
||||||
consolidated, referencesChanged := consolidate(records, members)
|
consolidated, referencesChanged := consolidate(records, members, order)
|
||||||
retainedIndex := members[0]
|
retainedIndex := members[0]
|
||||||
output.NPCs = append(output.NPCs, consolidated)
|
output.NPCs = append(output.NPCs, consolidated)
|
||||||
if referencesChanged {
|
if referencesChanged {
|
||||||
@@ -113,10 +114,10 @@ func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
|
|||||||
return output, warnings
|
return output, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeRecord(input dnd.NPC) (dnd.NPC, bool, bool) {
|
func normalizeRecord(input dnd.NPC, order shared.SourceRefOrder) (dnd.NPC, bool, bool) {
|
||||||
output := cloneNPC(input)
|
output := cloneNPC(input)
|
||||||
output.Name = identity.NormalizeDisplay(input.Name)
|
output.Name = identity.NormalizeDisplay(input.Name)
|
||||||
output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs)
|
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||||
output.ID = identity.DeriveID(output.Name)
|
output.ID = identity.DeriveID(output.Name)
|
||||||
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
|
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
|
||||||
}
|
}
|
||||||
@@ -143,41 +144,17 @@ func canonicalNameGroups(records []normalizedRecord) [][]int {
|
|||||||
return groups
|
return groups
|
||||||
}
|
}
|
||||||
|
|
||||||
func consolidate(records []normalizedRecord, members []int) (dnd.NPC, bool) {
|
func consolidate(records []normalizedRecord, members []int, order shared.SourceRefOrder) (dnd.NPC, bool) {
|
||||||
output := cloneNPC(records[members[0]].npc)
|
output := cloneNPC(records[members[0]].npc)
|
||||||
originalRefs := cloneSourceRefs(output.SourceRefs)
|
originalRefs := cloneSourceRefs(output.SourceRefs)
|
||||||
for _, member := range members[1:] {
|
for _, member := range members[1:] {
|
||||||
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
|
output.SourceRefs = append(output.SourceRefs, records[member].npc.SourceRefs...)
|
||||||
}
|
}
|
||||||
output.SourceRefs, _, _ = canonicalizeSourceRefs(output.SourceRefs)
|
output.SourceRefs = order.Canonicalize(output.SourceRefs)
|
||||||
output.ID = identity.DeriveID(output.Name)
|
output.ID = identity.DeriveID(output.Name)
|
||||||
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
|
return output, !reflect.DeepEqual(originalRefs, output.SourceRefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
|
||||||
if input == nil {
|
|
||||||
return nil, false, 0
|
|
||||||
}
|
|
||||||
canonical := cloneSourceRefs(input)
|
|
||||||
sort.SliceStable(canonical, func(left, right int) bool {
|
|
||||||
if canonical[left].SourceID != canonical[right].SourceID {
|
|
||||||
return canonical[left].SourceID < canonical[right].SourceID
|
|
||||||
}
|
|
||||||
if canonical[left].StartUnitID != canonical[right].StartUnitID {
|
|
||||||
return canonical[left].StartUnitID < canonical[right].StartUnitID
|
|
||||||
}
|
|
||||||
return canonical[left].EndUnitID < canonical[right].EndUnitID
|
|
||||||
})
|
|
||||||
orderChanged := !reflect.DeepEqual(input, canonical)
|
|
||||||
unique := make([]source.SourceRef, 0, len(canonical))
|
|
||||||
for _, ref := range canonical {
|
|
||||||
if len(unique) == 0 || unique[len(unique)-1] != ref {
|
|
||||||
unique = append(unique, ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return unique, orderChanged, len(input) - len(unique)
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
|
func cloneSourceRefs(input []source.SourceRef) []source.SourceRef {
|
||||||
if input == nil {
|
if input == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -61,6 +61,23 @@ func TestNormalizeNamesEvidenceAndIDs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeOrdersEvidenceBySourceDocumentPosition(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||||
|
input := dnd.NPCList{NPCs: []dnd.NPC{{Name: "Lady Ash", SourceRefs: []source.SourceRef{
|
||||||
|
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||||
|
}}}}
|
||||||
|
result, err := New(Options{}).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v", err)
|
||||||
|
}
|
||||||
|
got := result.Value.NPCs[0].SourceRefs
|
||||||
|
if got[0].StartUnitID != 30 || got[1].StartUnitID != 10 || got[2].StartUnitID != 999 {
|
||||||
|
t.Fatalf("source refs = %#v, want source-document order followed by invalid reference", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) {
|
func TestNormalizeConsolidatesCanonicalNamesOnlyAndUnionsEvidence(t *testing.T) {
|
||||||
input := dnd.NPCList{NPCs: []dnd.NPC{
|
input := dnd.NPCList{NPCs: []dnd.NPC{
|
||||||
{Name: " Captain Vale ", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}},
|
{Name: " Captain Vale ", SourceRefs: []source.SourceRef{{SourceID: "a", StartUnitID: 1, EndUnitID: 1}}},
|
||||||
@@ -115,6 +132,12 @@ func normalizeRequest(value dnd.NPCList) contracts.TypedNormalizeRequest[dnd.NPC
|
|||||||
return contracts.TypedNormalizeRequest[dnd.NPCList]{MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}}
|
return contracts.TypedNormalizeRequest[dnd.NPCList]{MergeOutput: contracts.MergeArtifact[dnd.NPCList]{Value: value}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeRequestWithSource(value dnd.NPCList, doc *source.SourceDocument) contracts.TypedNormalizeRequest[dnd.NPCList] {
|
||||||
|
request := normalizeRequest(value)
|
||||||
|
request.Source = doc
|
||||||
|
return request
|
||||||
|
}
|
||||||
|
|
||||||
func hasWarning(warnings []contracts.Warning, reason, scope string) bool {
|
func hasWarning(warnings []contracts.Warning, reason, scope string) bool {
|
||||||
for _, warning := range warnings {
|
for _, warning := range warnings {
|
||||||
if warning.ReasonCode == reason && warning.Scope == scope {
|
if warning.ReasonCode == reason && warning.Scope == scope {
|
||||||
|
|||||||
@@ -78,26 +78,23 @@ func normalizeList(input dnd.SceneDescriptionList, doc *source.SourceDocument) (
|
|||||||
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must not be empty")
|
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes must not be empty")
|
||||||
}
|
}
|
||||||
|
|
||||||
unitPositions := make(map[int]int, len(doc.Units))
|
documentIndex := source.NewDocumentIndex(doc)
|
||||||
for index, unit := range doc.Units {
|
|
||||||
unitPositions[unit.ID] = index
|
|
||||||
}
|
|
||||||
output := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, len(input.Scenes))}
|
output := dnd.SceneDescriptionList{Scenes: make([]dnd.SceneDescription, len(input.Scenes))}
|
||||||
for index, scene := range input.Scenes {
|
for sceneIndex, scene := range input.Scenes {
|
||||||
scene.Title = strings.TrimSpace(scene.Title)
|
scene.Title = strings.TrimSpace(scene.Title)
|
||||||
scene.Summary = strings.TrimSpace(scene.Summary)
|
scene.Summary = strings.TrimSpace(scene.Summary)
|
||||||
if err := shape.Validate(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene}}); err != nil {
|
if err := shape.Validate(dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{scene}}); err != nil {
|
||||||
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d]: %w", index, err)
|
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d]: %w", sceneIndex, err)
|
||||||
}
|
}
|
||||||
if err := source.ValidateRef(doc, scene.SourceRef); err != nil {
|
if err := documentIndex.ValidateRef(scene.SourceRef); err != nil {
|
||||||
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", index, diagnostics.Truncate(err.Error()))
|
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", sceneIndex, diagnostics.Truncate(err.Error()))
|
||||||
}
|
}
|
||||||
output.Scenes[index] = scene
|
output.Scenes[sceneIndex] = scene
|
||||||
}
|
}
|
||||||
|
|
||||||
sort.SliceStable(output.Scenes, func(left, right int) bool {
|
sort.SliceStable(output.Scenes, func(left, right int) bool {
|
||||||
leftStart := unitPositions[output.Scenes[left].SourceRef.StartUnitID]
|
leftStart, _ := documentIndex.Position(output.Scenes[left].SourceRef.StartUnitID)
|
||||||
rightStart := unitPositions[output.Scenes[right].SourceRef.StartUnitID]
|
rightStart, _ := documentIndex.Position(output.Scenes[right].SourceRef.StartUnitID)
|
||||||
if leftStart != rightStart {
|
if leftStart != rightStart {
|
||||||
return leftStart < rightStart
|
return leftStart < rightStart
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package spells
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -11,11 +10,16 @@ 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"
|
||||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||||
"golang.org/x/text/cases"
|
"golang.org/x/text/cases"
|
||||||
)
|
)
|
||||||
|
|
||||||
const Key = "dnd/spells"
|
const (
|
||||||
|
Key = "dnd/spells"
|
||||||
|
normalizationPolicy = "dnd.spells.normalize.v2"
|
||||||
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
ReasonCodeSpellNameCanonicalized = "spell_name_canonicalized"
|
ReasonCodeSpellNameCanonicalized = "spell_name_canonicalized"
|
||||||
@@ -68,6 +72,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
|||||||
"catalog_base_id": n.effectiveCatalog.BaseID(),
|
"catalog_base_id": n.effectiveCatalog.BaseID(),
|
||||||
"catalog_digest": n.effectiveCatalog.Digest(),
|
"catalog_digest": n.effectiveCatalog.Digest(),
|
||||||
"catalog_overlay_ids": append([]string(nil), n.effectiveCatalog.OverlayIDs()...),
|
"catalog_overlay_ids": append([]string(nil), n.effectiveCatalog.OverlayIDs()...),
|
||||||
|
"normalization_policy": normalizationPolicy,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +80,10 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
if n == nil {
|
if n == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{{Name: "effective_catalog", Value: n.effectiveCatalog.Digest()}}
|
return []pipeline.CheckpointFingerprint{
|
||||||
|
{Name: "effective_catalog", Value: n.effectiveCatalog.Digest()},
|
||||||
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SpellList]) (contracts.TypedNormalizeResult[dnd.SpellList], error) {
|
func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[dnd.SpellList]) (contracts.TypedNormalizeResult[dnd.SpellList], error) {
|
||||||
@@ -89,13 +97,15 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
|||||||
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context error before normalize: %w", err)
|
return contracts.TypedNormalizeResult[dnd.SpellList]{}, normalizerErrorf("context error before normalize: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
value, duplicateWarnings := collapseDuplicateSpellCasts(value, req.Source, n.effectiveCatalog)
|
order := shared.NewSourceRefOrderWithIndex(req.Source, index)
|
||||||
|
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog, order)
|
||||||
|
value, duplicateWarnings := collapseDuplicateSpellCasts(value, index, n.effectiveCatalog)
|
||||||
warnings = append(warnings, duplicateWarnings...)
|
warnings = append(warnings, duplicateWarnings...)
|
||||||
return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil
|
return contracts.TypedNormalizeResult[dnd.SpellList]{Value: value, Warnings: warnings}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
|
func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatalog, order shared.SourceRefOrder) (dnd.SpellList, []contracts.Warning) {
|
||||||
var warnings []contracts.Warning
|
var warnings []contracts.Warning
|
||||||
if input.SpellCasts == nil {
|
if input.SpellCasts == nil {
|
||||||
return dnd.SpellList{}, nil
|
return dnd.SpellList{}, nil
|
||||||
@@ -123,7 +133,7 @@ func normalizeSpellList(input dnd.SpellList, catalog spellcatalog.EffectiveCatal
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(inputCast.SourceRefs)
|
canonicalRefs, orderChanged, duplicateCount := canonicalizeSourceRefs(order, inputCast.SourceRefs)
|
||||||
cast.SourceRefs = canonicalRefs
|
cast.SourceRefs = canonicalRefs
|
||||||
if orderChanged || duplicateCount > 0 {
|
if orderChanged || duplicateCount > 0 {
|
||||||
warnings = append(warnings, contracts.Warning{
|
warnings = append(warnings, contracts.Warning{
|
||||||
@@ -147,42 +157,16 @@ func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast {
|
|||||||
return output
|
return output
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeSourceRefs(input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
func canonicalizeSourceRefs(order shared.SourceRefOrder, input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
||||||
if input == nil {
|
|
||||||
return nil, false, 0
|
|
||||||
}
|
|
||||||
|
|
||||||
canonical := make([]source.SourceRef, len(input))
|
|
||||||
copy(canonical, input)
|
|
||||||
sort.SliceStable(canonical, func(left, right int) bool {
|
|
||||||
return sourceRefLess(canonical[left], canonical[right])
|
|
||||||
})
|
|
||||||
|
|
||||||
orderChanged := false
|
orderChanged := false
|
||||||
for index := range input {
|
for index := 1; index < len(input); index++ {
|
||||||
if input[index] != canonical[index] {
|
if order.Less(input[index], input[index-1]) {
|
||||||
orderChanged = true
|
orderChanged = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
canonical := order.Canonicalize(input)
|
||||||
unique := make([]source.SourceRef, 0, len(canonical))
|
return canonical, orderChanged, len(input) - len(canonical)
|
||||||
for _, ref := range canonical {
|
|
||||||
if len(unique) == 0 || unique[len(unique)-1] != ref {
|
|
||||||
unique = append(unique, ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return unique, orderChanged, len(input) - len(unique)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sourceRefLess(left, right source.SourceRef) bool {
|
|
||||||
if left.SourceID != right.SourceID {
|
|
||||||
return left.SourceID < right.SourceID
|
|
||||||
}
|
|
||||||
if left.StartUnitID != right.StartUnitID {
|
|
||||||
return left.StartUnitID < right.StartUnitID
|
|
||||||
}
|
|
||||||
return left.EndUnitID < right.EndUnitID
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type duplicateGroup struct {
|
type duplicateGroup struct {
|
||||||
@@ -190,7 +174,7 @@ type duplicateGroup struct {
|
|||||||
removed []int
|
removed []int
|
||||||
}
|
}
|
||||||
|
|
||||||
func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
|
func collapseDuplicateSpellCasts(input dnd.SpellList, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (dnd.SpellList, []contracts.Warning) {
|
||||||
if len(input.SpellCasts) == 0 {
|
if len(input.SpellCasts) == 0 {
|
||||||
return input, nil
|
return input, nil
|
||||||
}
|
}
|
||||||
@@ -199,7 +183,7 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
|
|||||||
groups := make([]duplicateGroup, 0)
|
groups := make([]duplicateGroup, 0)
|
||||||
groupByKey := make(map[string]int)
|
groupByKey := make(map[string]int)
|
||||||
for index, cast := range input.SpellCasts {
|
for index, cast := range input.SpellCasts {
|
||||||
key, eligible := duplicateKey(cast, doc, catalog)
|
key, eligible := duplicateKey(cast, documentIndex, catalog)
|
||||||
if !eligible {
|
if !eligible {
|
||||||
keep[index] = true
|
keep[index] = true
|
||||||
continue
|
continue
|
||||||
@@ -242,13 +226,13 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
|
|||||||
return output, warnings
|
return output, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
func duplicateKey(cast dnd.SpellCast, doc *source.SourceDocument, catalog spellcatalog.EffectiveCatalog) (string, bool) {
|
func duplicateKey(cast dnd.SpellCast, documentIndex source.DocumentIndex, catalog spellcatalog.EffectiveCatalog) (string, bool) {
|
||||||
canonicalName, resolved := catalog.Lookup(cast.Spell)
|
canonicalName, resolved := catalog.Lookup(cast.Spell)
|
||||||
if !resolved || len(cast.SourceRefs) == 0 {
|
if !resolved || len(cast.SourceRefs) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
for _, ref := range cast.SourceRefs {
|
for _, ref := range cast.SourceRefs {
|
||||||
if source.ValidateRef(doc, ref) != nil {
|
if documentIndex.ValidateRef(ref) != nil {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,19 +104,22 @@ func TestIdentityAndMetadataAreDefensive(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if NormalizationPolicy != "dnd.spells.normalize.v2" {
|
||||||
|
t.Fatalf("NormalizationPolicy = %q, want v2 policy", NormalizationPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
fingerprints := normalizer.CheckpointFingerprints()
|
fingerprints := normalizer.CheckpointFingerprints()
|
||||||
if len(fingerprints) != 1 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() {
|
if len(fingerprints) != 2 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() || fingerprints[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
|
||||||
t.Fatalf("fingerprints = %#v, want effective catalog fingerprint", fingerprints)
|
t.Fatalf("fingerprints = %#v, want catalog and normalization policy fingerprints", fingerprints)
|
||||||
}
|
}
|
||||||
fingerprints[0].Name = "changed"
|
fingerprints[0].Name = "changed"
|
||||||
fingerprints[0].Value = "changed"
|
fingerprints[0].Value = "changed"
|
||||||
if got := normalizer.CheckpointFingerprints(); len(got) != 1 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() {
|
if got := normalizer.CheckpointFingerprints(); len(got) != 2 || got[0].Name != "effective_catalog" || got[0].Value != normalizer.effectiveCatalog.Digest() || got[1] != (pipeline.CheckpointFingerprint{Name: "normalization_policy", Value: normalizationPolicy}) {
|
||||||
t.Fatalf("fingerprints were not defensive: %#v", got)
|
t.Fatalf("fingerprints were not defensive: %#v", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
metadata := normalizer.ManifestMetadata()
|
metadata := normalizer.ManifestMetadata()
|
||||||
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() {
|
if metadata["catalog_base_id"] != spellcatalog.SRD5E2014ID || metadata["catalog_digest"] != normalizer.effectiveCatalog.Digest() || metadata["normalization_policy"] != normalizationPolicy {
|
||||||
t.Fatalf("metadata = %#v, want catalog identity", metadata)
|
t.Fatalf("metadata = %#v, want catalog identity", metadata)
|
||||||
}
|
}
|
||||||
metadata["catalog_overlay_ids"].([]string)[0] = "changed"
|
metadata["catalog_overlay_ids"].([]string)[0] = "changed"
|
||||||
@@ -205,11 +208,52 @@ func TestNormalizeSortsAndDeduplicatesExactSourceReferences(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(result.Value.SpellCasts[0].SourceRefs, wantRefs) {
|
if !reflect.DeepEqual(result.Value.SpellCasts[0].SourceRefs, wantRefs) {
|
||||||
t.Fatalf("source refs = %#v, want %#v", result.Value.SpellCasts[0].SourceRefs, wantRefs)
|
t.Fatalf("source refs = %#v, want %#v", result.Value.SpellCasts[0].SourceRefs, wantRefs)
|
||||||
}
|
}
|
||||||
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized || !strings.Contains(result.Warnings[0].Message, "original count 6") || !strings.Contains(result.Warnings[0].Message, "final count 5") || !strings.Contains(result.Warnings[0].Message, "duplicates removed 1") {
|
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized || !strings.Contains(result.Warnings[0].Message, "original count 6") || !strings.Contains(result.Warnings[0].Message, "final count 5") || !strings.Contains(result.Warnings[0].Message, "order changed true") || !strings.Contains(result.Warnings[0].Message, "duplicates removed 1") {
|
||||||
t.Fatalf("warnings = %#v, want source normalization warning", result.Warnings)
|
t.Fatalf("warnings = %#v, want source normalization warning", result.Warnings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNormalizeReportsDuplicateRemovalWithoutOrderChange(t *testing.T) {
|
||||||
|
ref1 := source.SourceRef{SourceID: "source", StartUnitID: 1, EndUnitID: 1}
|
||||||
|
ref2 := source.SourceRef{SourceID: "source", StartUnitID: 2, EndUnitID: 2}
|
||||||
|
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||||
|
Spell: "Cure Wounds",
|
||||||
|
SourceRefs: []source.SourceRef{ref1, ref1, ref2},
|
||||||
|
}}}
|
||||||
|
|
||||||
|
result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequest(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
if len(result.Warnings) != 1 || result.Warnings[0].ReasonCode != ReasonCodeSourceReferencesNormalized {
|
||||||
|
t.Fatalf("warnings = %#v, want one source normalization warning", result.Warnings)
|
||||||
|
}
|
||||||
|
message := result.Warnings[0].Message
|
||||||
|
if !strings.Contains(message, "order changed false") || !strings.Contains(message, "duplicates removed 1") {
|
||||||
|
t.Fatalf("warning = %q, want duplicate-only repair without order change", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeOrdersReferencesBySourceDocumentPosition(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "source", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||||
|
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{{
|
||||||
|
Spell: "Cure Wounds",
|
||||||
|
SourceRefs: []source.SourceRef{
|
||||||
|
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||||
|
},
|
||||||
|
}}}
|
||||||
|
result, err := newNormalizer(t).Normalize(context.Background(), normalizeRequestWithSource(input, doc))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize() error = %v, want nil", err)
|
||||||
|
}
|
||||||
|
got := result.Value.SpellCasts[0].SourceRefs
|
||||||
|
if got[0].StartUnitID != 30 || got[1].StartUnitID != 10 || got[2].StartUnitID != 999 {
|
||||||
|
t.Fatalf("normalized refs = %#v, want source-document order followed by invalid reference", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizePreservesNilEmptyAndAdjacentOrOverlappingReferences(t *testing.T) {
|
func TestNormalizePreservesNilEmptyAndAdjacentOrOverlappingReferences(t *testing.T) {
|
||||||
normalizer := newNormalizer(t)
|
normalizer := newNormalizer(t)
|
||||||
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
||||||
|
|||||||
@@ -3,34 +3,15 @@
|
|||||||
package npcinteractions
|
package npcinteractions
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"sort"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"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/npcs/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CanonicalizeSourceRefs returns a cloned, document-ordered, de-duplicated
|
|
||||||
// source-reference list.
|
|
||||||
func CanonicalizeSourceRefs(doc *source.SourceDocument, input []source.SourceRef) []source.SourceRef {
|
|
||||||
if input == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
canonical := append([]source.SourceRef(nil), input...)
|
|
||||||
sort.SliceStable(canonical, func(left, right int) bool {
|
|
||||||
return SourceRefLess(doc, canonical[left], canonical[right])
|
|
||||||
})
|
|
||||||
unique := make([]source.SourceRef, 0, len(canonical))
|
|
||||||
for _, ref := range canonical {
|
|
||||||
if len(unique) == 0 || unique[len(unique)-1] != ref {
|
|
||||||
unique = append(unique, ref)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return unique
|
|
||||||
}
|
|
||||||
|
|
||||||
// SourceRefsEqual reports whether two source-reference lists have identical
|
// SourceRefsEqual reports whether two source-reference lists have identical
|
||||||
// representations and values.
|
// representations and values.
|
||||||
func SourceRefsEqual(left, right []source.SourceRef) bool {
|
func SourceRefsEqual(left, right []source.SourceRef) bool {
|
||||||
@@ -45,39 +26,10 @@ func SourceRefsEqual(left, right []source.SourceRef) bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// SourceRefLess orders references by source identity and then by the source
|
|
||||||
// document positions of their endpoints. Invalid endpoints sort after valid
|
|
||||||
// endpoints and fall back to their literal IDs for deterministic diagnostics.
|
|
||||||
func SourceRefLess(doc *source.SourceDocument, left, right source.SourceRef) bool {
|
|
||||||
if left.SourceID != right.SourceID {
|
|
||||||
return left.SourceID < right.SourceID
|
|
||||||
}
|
|
||||||
leftStart, leftStartOK := source.UnitIndex(doc, left.StartUnitID)
|
|
||||||
rightStart, rightStartOK := source.UnitIndex(doc, right.StartUnitID)
|
|
||||||
if leftStartOK != rightStartOK {
|
|
||||||
return leftStartOK
|
|
||||||
}
|
|
||||||
if leftStartOK && leftStart != rightStart {
|
|
||||||
return leftStart < rightStart
|
|
||||||
}
|
|
||||||
if left.StartUnitID != right.StartUnitID {
|
|
||||||
return left.StartUnitID < right.StartUnitID
|
|
||||||
}
|
|
||||||
leftEnd, leftEndOK := source.UnitIndex(doc, left.EndUnitID)
|
|
||||||
rightEnd, rightEndOK := source.UnitIndex(doc, right.EndUnitID)
|
|
||||||
if leftEndOK != rightEndOK {
|
|
||||||
return leftEndOK
|
|
||||||
}
|
|
||||||
if leftEndOK && leftEnd != rightEnd {
|
|
||||||
return leftEnd < rightEnd
|
|
||||||
}
|
|
||||||
return left.EndUnitID < right.EndUnitID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Less defines the canonical order for NPC interaction occurrences.
|
// Less defines the canonical order for NPC interaction occurrences.
|
||||||
func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
|
func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
|
||||||
leftPosition, leftHasEvidence := EarliestSourcePosition(doc, left)
|
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
|
||||||
rightPosition, rightHasEvidence := EarliestSourcePosition(doc, right)
|
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
|
||||||
if leftHasEvidence != rightHasEvidence {
|
if leftHasEvidence != rightHasEvidence {
|
||||||
return leftHasEvidence
|
return leftHasEvidence
|
||||||
}
|
}
|
||||||
@@ -95,35 +47,17 @@ func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
|
|||||||
if left.Kind != right.Kind {
|
if left.Kind != right.Kind {
|
||||||
return left.Kind < right.Kind
|
return left.Kind < right.Kind
|
||||||
}
|
}
|
||||||
return sourceRefsLess(doc, left.SourceRefs, right.SourceRefs)
|
return sourceRefsLess(order, left.SourceRefs, right.SourceRefs)
|
||||||
}
|
|
||||||
|
|
||||||
// EarliestSourcePosition returns the earliest valid cited position.
|
|
||||||
func EarliestSourcePosition(doc *source.SourceDocument, interaction dnd.NPCInteraction) (int, bool) {
|
|
||||||
found := false
|
|
||||||
earliest := 0
|
|
||||||
for _, ref := range interaction.SourceRefs {
|
|
||||||
if source.ValidateRef(doc, ref) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
position, ok := source.UnitIndex(doc, ref.StartUnitID)
|
|
||||||
if !ok || (found && position >= earliest) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
earliest = position
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
return earliest, found
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidSourceRefs reports whether an interaction has non-empty, valid
|
// ValidSourceRefs reports whether an interaction has non-empty, valid
|
||||||
// current-document evidence.
|
// current-document evidence.
|
||||||
func ValidSourceRefs(doc *source.SourceDocument, refs []source.SourceRef) bool {
|
func ValidSourceRefs(index source.DocumentIndex, refs []source.SourceRef) bool {
|
||||||
if len(refs) == 0 {
|
if len(refs) == 0 {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
if source.ValidateRef(doc, ref) != nil {
|
if index.ValidateRef(ref) != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,12 +78,12 @@ func ExactIdentity(interaction dnd.NPCInteraction) string {
|
|||||||
return key.String()
|
return key.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceRefsLess(doc *source.SourceDocument, left, right []source.SourceRef) bool {
|
func sourceRefsLess(order shared.SourceRefOrder, left, right []source.SourceRef) bool {
|
||||||
for index := 0; index < len(left) && index < len(right); index++ {
|
for index := 0; index < len(left) && index < len(right); index++ {
|
||||||
if left[index] == right[index] {
|
if left[index] == right[index] {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return SourceRefLess(doc, left[index], right[index])
|
return order.Less(left[index], right[index])
|
||||||
}
|
}
|
||||||
return len(left) < len(right)
|
return len(left) < len(right)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,21 @@ import (
|
|||||||
|
|
||||||
func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) {
|
func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) {
|
||||||
count := 0
|
count := 0
|
||||||
|
present := false
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
|
if value.SpellCasts != nil {
|
||||||
|
present = true
|
||||||
|
}
|
||||||
count += len(value.SpellCasts)
|
count += len(value.SpellCasts)
|
||||||
}
|
}
|
||||||
|
if !present {
|
||||||
|
return dnd.SpellList{}, nil
|
||||||
|
}
|
||||||
combined := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, count)}
|
combined := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, count)}
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
combined.SpellCasts = append(combined.SpellCasts, value.SpellCasts...)
|
for _, spellCast := range value.SpellCasts {
|
||||||
|
combined.SpellCasts = append(combined.SpellCasts, cloneSpellCast(spellCast))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return combined, nil
|
return combined, nil
|
||||||
}
|
}
|
||||||
@@ -31,7 +40,9 @@ func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) {
|
|||||||
}
|
}
|
||||||
combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)}
|
combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)}
|
||||||
for _, value := range values {
|
for _, value := range values {
|
||||||
combined.NPCs = append(combined.NPCs, value.NPCs...)
|
for _, npc := range value.NPCs {
|
||||||
|
combined.NPCs = append(combined.NPCs, cloneNPC(npc))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return combined, nil
|
return combined, nil
|
||||||
}
|
}
|
||||||
@@ -105,6 +116,22 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
|||||||
return clone
|
return clone
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneSpellCast(value dnd.SpellCast) dnd.SpellCast {
|
||||||
|
clone := value
|
||||||
|
if value.SourceRefs != nil {
|
||||||
|
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneNPC(value dnd.NPC) dnd.NPC {
|
||||||
|
clone := value
|
||||||
|
if value.SourceRefs != nil {
|
||||||
|
clone.SourceRefs = append([]source.SourceRef(nil), value.SourceRefs...)
|
||||||
|
}
|
||||||
|
return clone
|
||||||
|
}
|
||||||
|
|
||||||
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction {
|
func cloneNPCInteraction(value dnd.NPCInteraction) dnd.NPCInteraction {
|
||||||
clone := value
|
clone := value
|
||||||
if value.SourceRefs != nil {
|
if value.SourceRefs != nil {
|
||||||
|
|||||||
@@ -285,6 +285,47 @@ func TestAppendNPCListsPreservesOrderAndArrayPresence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input := []dnd.NPCList{{NPCs: []dnd.NPC{{Name: "first", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}}
|
||||||
|
merged, err := appendNPCLists(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("appendNPCLists() error = %v", err)
|
||||||
|
}
|
||||||
|
merged.NPCs[0].SourceRefs[0].StartUnitID = 999
|
||||||
|
if input[0].NPCs[0].SourceRefs[0].StartUnitID == 999 {
|
||||||
|
t.Fatal("merged NPCs share source reference storage")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAppendSpellListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
in []dnd.SpellList
|
||||||
|
want dnd.SpellList
|
||||||
|
}{
|
||||||
|
{name: "no values", in: nil, want: dnd.SpellList{}},
|
||||||
|
{name: "nil values", in: []dnd.SpellList{{}, {}}, want: dnd.SpellList{}},
|
||||||
|
{name: "present empty", in: []dnd.SpellList{{SpellCasts: []dnd.SpellCast{}}}, want: dnd.SpellList{SpellCasts: []dnd.SpellCast{}}},
|
||||||
|
{name: "ordered values", in: []dnd.SpellList{{SpellCasts: []dnd.SpellCast{{Spell: "first"}}}, {SpellCasts: []dnd.SpellCast{{Spell: "second"}}}}, want: dnd.SpellList{SpellCasts: []dnd.SpellCast{{Spell: "first"}, {Spell: "second"}}}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := appendSpellLists(tt.in)
|
||||||
|
if err != nil || !reflect.DeepEqual(got, tt.want) {
|
||||||
|
t.Fatalf("appendSpellLists() = %#v, error = %v, want %#v", got, err, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
input := []dnd.SpellList{{SpellCasts: []dnd.SpellCast{{Spell: "Shield", SourceRefs: []source.SourceRef{{SourceID: "session", StartUnitID: 10, EndUnitID: 10}}}}}}
|
||||||
|
merged, err := appendSpellLists(input)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("appendSpellLists() error = %v", err)
|
||||||
|
}
|
||||||
|
merged.SpellCasts[0].SourceRefs[0].StartUnitID = 999
|
||||||
|
if input[0].SpellCasts[0].SourceRefs[0].StartUnitID == 999 {
|
||||||
|
t.Fatal("merged spell casts share source reference storage")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
func TestAppendNPCInteractionListsPreservesOrderPresenceAndOwnership(t *testing.T) {
|
||||||
|
|||||||
@@ -7,27 +7,37 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CitedText resolves cited source ranges in document order, including each
|
// CitationResolver resolves cited ranges against one source document.
|
||||||
// source unit once, and joins the resulting text with newlines.
|
type CitationResolver struct {
|
||||||
func CitedText(doc *source.SourceDocument, refs []source.SourceRef) (string, error) {
|
doc *source.SourceDocument
|
||||||
if doc == nil {
|
index source.DocumentIndex
|
||||||
return "", fmt.Errorf("source document must not be nil")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
included := make([]bool, len(doc.Units))
|
// NewCitationResolver prepares citation resolution for doc.
|
||||||
|
func NewCitationResolver(doc *source.SourceDocument) (*CitationResolver, error) {
|
||||||
|
if doc == nil {
|
||||||
|
return nil, fmt.Errorf("source document must not be nil")
|
||||||
|
}
|
||||||
|
return &CitationResolver{doc: doc, index: source.NewDocumentIndex(doc)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CitedText resolves cited source ranges in document order, including each
|
||||||
|
// source unit once, and joins the resulting text with newlines.
|
||||||
|
func (r *CitationResolver) CitedText(refs []source.SourceRef) (string, error) {
|
||||||
|
included := make([]bool, len(r.doc.Units))
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
if err := source.ValidateRef(doc, ref); err != nil {
|
if err := r.index.ValidateRef(ref); err != nil {
|
||||||
return "", fmt.Errorf("resolve cited source range: %w", err)
|
return "", fmt.Errorf("resolve cited source range: %w", err)
|
||||||
}
|
}
|
||||||
start, _ := source.UnitIndex(doc, ref.StartUnitID)
|
start, _ := r.index.Position(ref.StartUnitID)
|
||||||
end, _ := source.UnitIndex(doc, ref.EndUnitID)
|
end, _ := r.index.Position(ref.EndUnitID)
|
||||||
for index := start; index <= end; index++ {
|
for index := start; index <= end; index++ {
|
||||||
included[index] = true
|
included[index] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
parts := make([]string, 0, len(doc.Units))
|
parts := make([]string, 0, len(r.doc.Units))
|
||||||
for index, unit := range doc.Units {
|
for index, unit := range r.doc.Units {
|
||||||
if included[index] {
|
if included[index] {
|
||||||
parts = append(parts, unit.Text)
|
parts = append(parts, unit.Text)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,12 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCitedText(t *testing.T) {
|
func TestCitationResolver(t *testing.T) {
|
||||||
doc := citationDocument()
|
doc := citationDocument()
|
||||||
|
resolver, err := NewCitationResolver(doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewCitationResolver() error = %v", err)
|
||||||
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
refs []source.SourceRef
|
refs []source.SourceRef
|
||||||
@@ -51,7 +55,7 @@ func TestCitedText(t *testing.T) {
|
|||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
beforeUnits := append([]source.SourceUnit(nil), doc.Units...)
|
beforeUnits := append([]source.SourceUnit(nil), doc.Units...)
|
||||||
refs := append([]source.SourceRef(nil), test.refs...)
|
refs := append([]source.SourceRef(nil), test.refs...)
|
||||||
got, err := CitedText(doc, refs)
|
got, err := resolver.CitedText(refs)
|
||||||
if (err != nil) != test.wantErr {
|
if (err != nil) != test.wantErr {
|
||||||
t.Fatalf("CitedText() error = %v, want error = %t", err, test.wantErr)
|
t.Fatalf("CitedText() error = %v, want error = %t", err, test.wantErr)
|
||||||
}
|
}
|
||||||
@@ -65,9 +69,9 @@ func TestCitedText(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCitedTextRejectsNilDocument(t *testing.T) {
|
func TestNewCitationResolverRejectsNilDocument(t *testing.T) {
|
||||||
if _, err := CitedText(nil, nil); err == nil {
|
if _, err := NewCitationResolver(nil); err == nil {
|
||||||
t.Fatal("CitedText() error = nil, want nil-document error")
|
t.Fatal("NewCitationResolver() error = nil, want nil-document error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,14 +2,30 @@ package shared
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ChunkPromptMaterial prepares the chunk-scoped source material used by D&D
|
// PrepareChunkExtraction validates common D&D extraction prerequisites and
|
||||||
// extractors when constructing their prompt inputs.
|
// prepares owned chunk-scoped source material for prompt inputs.
|
||||||
func ChunkPromptMaterial(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
func PrepareChunkExtraction(ctx context.Context, req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||||
|
if ctx == nil {
|
||||||
|
return contracts.LLMInputMaterial{}, fmt.Errorf("context must not be nil")
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return contracts.LLMInputMaterial{}, fmt.Errorf("context error before extraction: %w", err)
|
||||||
|
}
|
||||||
|
if req.Source == nil {
|
||||||
|
return contracts.LLMInputMaterial{}, fmt.Errorf("source must not be nil")
|
||||||
|
}
|
||||||
|
if req.Chunk == nil {
|
||||||
|
return contracts.LLMInputMaterial{}, fmt.Errorf("chunk must not be nil")
|
||||||
|
}
|
||||||
|
if len(req.Chunk.Units) == 0 {
|
||||||
|
return contracts.LLMInputMaterial{}, fmt.Errorf("chunk %q units must not be empty", req.Chunk.ID)
|
||||||
|
}
|
||||||
material := req.SourceInput.Clone()
|
material := req.SourceInput.Clone()
|
||||||
if len(material.Content) == 0 {
|
if len(material.Content) == 0 {
|
||||||
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package shared
|
package shared
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"reflect"
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -9,48 +10,78 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestChunkPromptMaterial(t *testing.T) {
|
func TestPrepareChunkExtraction(t *testing.T) {
|
||||||
chunk := &source.Chunk{
|
canceled, cancel := context.WithCancel(context.Background())
|
||||||
ID: "session-alpha:chunk:0",
|
cancel()
|
||||||
Content: []byte(`{"units":[1,2]}`),
|
|
||||||
MediaType: "application/json",
|
|
||||||
}
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
ctx context.Context
|
||||||
|
configure func(*contracts.TypedExtractionRequest)
|
||||||
sourceInput contracts.LLMInputMaterial
|
sourceInput contracts.LLMInputMaterial
|
||||||
want contracts.LLMInputMaterial
|
want contracts.LLMInputMaterial
|
||||||
wantErr string
|
wantErr string
|
||||||
mutateOutput bool
|
mutateOutput bool
|
||||||
}{
|
}{
|
||||||
|
{
|
||||||
|
name: "nil context",
|
||||||
|
wantErr: "context must not be nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "canceled context",
|
||||||
|
ctx: canceled,
|
||||||
|
wantErr: "context error before extraction",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil source",
|
||||||
|
configure: func(req *contracts.TypedExtractionRequest) {
|
||||||
|
req.Source = nil
|
||||||
|
},
|
||||||
|
wantErr: "source must not be nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil chunk",
|
||||||
|
configure: func(req *contracts.TypedExtractionRequest) {
|
||||||
|
req.Chunk = nil
|
||||||
|
},
|
||||||
|
wantErr: "chunk must not be nil",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty chunk units",
|
||||||
|
configure: func(req *contracts.TypedExtractionRequest) {
|
||||||
|
req.Chunk = &source.Chunk{ID: req.Chunk.ID}
|
||||||
|
},
|
||||||
|
wantErr: "units must not be empty",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "fallback to chunk content",
|
name: "fallback to chunk content",
|
||||||
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "", ""),
|
want: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "", ""),
|
||||||
|
mutateOutput: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "clone isolation",
|
name: "clone isolation",
|
||||||
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
|
sourceInput: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "sha256:source", "file:///source.json"),
|
||||||
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
|
want: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "sha256:source", "file:///source.json"),
|
||||||
mutateOutput: true,
|
mutateOutput: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "mismatched content",
|
name: "mismatched content",
|
||||||
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, []byte(`{"units":[9]}`), "sha256:other", "file:///other.json"),
|
sourceInput: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[9]}`), "sha256:other", "file:///other.json"),
|
||||||
wantErr: "source input must match chunk",
|
wantErr: "source input must match chunk",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "default fields",
|
name: "default fields",
|
||||||
sourceInput: contracts.LLMInputMaterial{
|
sourceInput: contracts.LLMInputMaterial{
|
||||||
Content: append([]byte(nil), chunk.Content...),
|
Content: []byte(`{"units":[1,2]}`),
|
||||||
Digest: "sha256:source",
|
Digest: "sha256:source",
|
||||||
OriginURI: "file:///source.json",
|
OriginURI: "file:///source.json",
|
||||||
},
|
},
|
||||||
want: contracts.LLMInputMaterial{
|
want: contracts.LLMInputMaterial{
|
||||||
Name: "source",
|
Name: "source",
|
||||||
MediaType: chunk.MediaType,
|
MediaType: "application/json",
|
||||||
Content: append([]byte(nil), chunk.Content...),
|
Content: []byte(`{"units":[1,2]}`),
|
||||||
Digest: "sha256:source",
|
Digest: "sha256:source",
|
||||||
OriginURI: "file:///source.json",
|
OriginURI: "file:///source.json",
|
||||||
SizeBytes: int64(len(chunk.Content)),
|
SizeBytes: int64(len(`{"units":[1,2]}`)),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,7 +89,7 @@ func TestChunkPromptMaterial(t *testing.T) {
|
|||||||
sourceInput: contracts.LLMInputMaterial{
|
sourceInput: contracts.LLMInputMaterial{
|
||||||
Name: "transcript",
|
Name: "transcript",
|
||||||
MediaType: "text/plain",
|
MediaType: "text/plain",
|
||||||
Content: append([]byte(nil), chunk.Content...),
|
Content: []byte(`{"units":[1,2]}`),
|
||||||
Digest: "sha256:explicit",
|
Digest: "sha256:explicit",
|
||||||
OriginURI: "file:///explicit.txt",
|
OriginURI: "file:///explicit.txt",
|
||||||
SizeBytes: 42,
|
SizeBytes: 42,
|
||||||
@@ -66,7 +97,7 @@ func TestChunkPromptMaterial(t *testing.T) {
|
|||||||
want: contracts.LLMInputMaterial{
|
want: contracts.LLMInputMaterial{
|
||||||
Name: "transcript",
|
Name: "transcript",
|
||||||
MediaType: "text/plain",
|
MediaType: "text/plain",
|
||||||
Content: append([]byte(nil), chunk.Content...),
|
Content: []byte(`{"units":[1,2]}`),
|
||||||
Digest: "sha256:explicit",
|
Digest: "sha256:explicit",
|
||||||
OriginURI: "file:///explicit.txt",
|
OriginURI: "file:///explicit.txt",
|
||||||
SizeBytes: 42,
|
SizeBytes: 42,
|
||||||
@@ -76,26 +107,46 @@ func TestChunkPromptMaterial(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
req := contracts.TypedExtractionRequest{Chunk: chunk, SourceInput: test.sourceInput}
|
req := extractionRequest()
|
||||||
got, err := ChunkPromptMaterial(req)
|
req.SourceInput = test.sourceInput
|
||||||
|
if test.configure != nil {
|
||||||
|
test.configure(&req)
|
||||||
|
}
|
||||||
|
ctx := test.ctx
|
||||||
|
if ctx == nil && test.wantErr != "context must not be nil" {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
got, err := PrepareChunkExtraction(ctx, req)
|
||||||
if test.wantErr != "" {
|
if test.wantErr != "" {
|
||||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||||
t.Fatalf("ChunkPromptMaterial() error = %v, want %q", err, test.wantErr)
|
t.Fatalf("PrepareChunkExtraction() error = %v, want %q", err, test.wantErr)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ChunkPromptMaterial() error = %v, want nil", err)
|
t.Fatalf("PrepareChunkExtraction() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
if !reflect.DeepEqual(got, test.want) {
|
if !reflect.DeepEqual(got, test.want) {
|
||||||
t.Fatalf("ChunkPromptMaterial() = %#v, want %#v", got, test.want)
|
t.Fatalf("PrepareChunkExtraction() = %#v, want %#v", got, test.want)
|
||||||
}
|
}
|
||||||
if test.mutateOutput {
|
if test.mutateOutput {
|
||||||
got.Content[0] = 'x'
|
got.Content[0] = 'x'
|
||||||
if string(test.sourceInput.Content) != string(chunk.Content) {
|
if string(req.SourceInput.Content) != string(test.sourceInput.Content) || string(req.Chunk.Content) != `{"units":[1,2]}` {
|
||||||
t.Fatalf("ChunkPromptMaterial() output shares content with source input")
|
t.Fatal("PrepareChunkExtraction() output shares request content")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractionRequest() contracts.TypedExtractionRequest {
|
||||||
|
doc := &source.SourceDocument{ID: "session-alpha"}
|
||||||
|
chunk := &source.Chunk{
|
||||||
|
ID: "session-alpha:chunk:0",
|
||||||
|
SourceID: doc.ID,
|
||||||
|
Content: []byte(`{"units":[1,2]}`),
|
||||||
|
MediaType: "application/json",
|
||||||
|
Units: []source.SourceUnit{{ID: 1}},
|
||||||
|
}
|
||||||
|
return contracts.TypedExtractionRequest{Source: doc, Chunk: chunk}
|
||||||
|
}
|
||||||
|
|||||||
101
internal/modules/dnd/shared/source_ref_order.go
Normal file
101
internal/modules/dnd/shared/source_ref_order.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SourceRefOrder provides a stable snapshot of a source document's unit
|
||||||
|
// ordering for source-reference comparison and canonicalization.
|
||||||
|
type SourceRefOrder struct {
|
||||||
|
sourceID string
|
||||||
|
index source.DocumentIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSourceRefOrder captures the source identity and unit positions from doc.
|
||||||
|
func NewSourceRefOrder(doc *source.SourceDocument) SourceRefOrder {
|
||||||
|
return NewSourceRefOrderWithIndex(doc, source.NewDocumentIndex(doc))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSourceRefOrderWithIndex captures source identity with a supplied
|
||||||
|
// document index for source-reference comparison and canonicalization.
|
||||||
|
func NewSourceRefOrderWithIndex(doc *source.SourceDocument, index source.DocumentIndex) SourceRefOrder {
|
||||||
|
if doc == nil {
|
||||||
|
return SourceRefOrder{}
|
||||||
|
}
|
||||||
|
return SourceRefOrder{sourceID: doc.ID, index: index}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Less orders references by source identity, then document positions when
|
||||||
|
// available, and finally literal endpoint IDs.
|
||||||
|
func (o SourceRefOrder) Less(left, right source.SourceRef) bool {
|
||||||
|
if left.SourceID != right.SourceID {
|
||||||
|
return left.SourceID < right.SourceID
|
||||||
|
}
|
||||||
|
if o.lessEndpoint(left.SourceID, left.StartUnitID, right.StartUnitID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if o.lessEndpoint(left.SourceID, right.StartUnitID, left.StartUnitID) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return o.lessEndpoint(left.SourceID, left.EndUnitID, right.EndUnitID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EarliestValid returns the earliest document position among valid refs.
|
||||||
|
func (o SourceRefOrder) EarliestValid(refs []source.SourceRef) (int, bool) {
|
||||||
|
if len(refs) == 0 || o.sourceID == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
earliest := 0
|
||||||
|
for _, ref := range refs {
|
||||||
|
if o.index.ValidateRef(ref) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
start, _ := o.index.Position(ref.StartUnitID)
|
||||||
|
if !found || start < earliest {
|
||||||
|
earliest = start
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return earliest, found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Canonicalize returns an owned, stable-sorted, exactly de-duplicated copy of
|
||||||
|
// refs. It deliberately preserves invalid references for diagnostics.
|
||||||
|
func (o SourceRefOrder) Canonicalize(refs []source.SourceRef) []source.SourceRef {
|
||||||
|
if refs == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
canonical := append([]source.SourceRef{}, refs...)
|
||||||
|
sort.SliceStable(canonical, func(left, right int) bool {
|
||||||
|
return o.Less(canonical[left], canonical[right])
|
||||||
|
})
|
||||||
|
unique := make([]source.SourceRef, 0, len(canonical))
|
||||||
|
for _, ref := range canonical {
|
||||||
|
if len(unique) == 0 || unique[len(unique)-1] != ref {
|
||||||
|
unique = append(unique, ref)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unique
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o SourceRefOrder) lessEndpoint(sourceID string, left, right int) bool {
|
||||||
|
leftPosition, leftOK := o.position(sourceID, left)
|
||||||
|
rightPosition, rightOK := o.position(sourceID, right)
|
||||||
|
if leftOK != rightOK {
|
||||||
|
return leftOK
|
||||||
|
}
|
||||||
|
if leftOK && leftPosition != rightPosition {
|
||||||
|
return leftPosition < rightPosition
|
||||||
|
}
|
||||||
|
return left < right
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o SourceRefOrder) position(sourceID string, unitID int) (int, bool) {
|
||||||
|
if o.sourceID == "" || sourceID != o.sourceID {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
return o.index.Position(unitID)
|
||||||
|
}
|
||||||
81
internal/modules/dnd/shared/source_ref_order_test.go
Normal file
81
internal/modules/dnd/shared/source_ref_order_test.go
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
package shared
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSourceRefOrderUsesDocumentOrderAndLiteralFallbacks(t *testing.T) {
|
||||||
|
doc := unitRefSourceDocument(30, 10, 20)
|
||||||
|
order := NewSourceRefOrder(doc)
|
||||||
|
|
||||||
|
refs := []source.SourceRef{
|
||||||
|
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{SourceID: "other", StartUnitID: 30, EndUnitID: 1},
|
||||||
|
{SourceID: "other", StartUnitID: 10, EndUnitID: 2},
|
||||||
|
}
|
||||||
|
got := order.Canonicalize(refs)
|
||||||
|
want := []source.SourceRef{
|
||||||
|
{SourceID: "other", StartUnitID: 10, EndUnitID: 2},
|
||||||
|
{SourceID: "other", StartUnitID: 30, EndUnitID: 1},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("Canonicalize() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
if order.Less(want[0], want[0]) {
|
||||||
|
t.Fatal("Less(ref, ref) = true, want false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceRefOrderCanonicalizePreservesRepresentationAndOwnership(t *testing.T) {
|
||||||
|
order := NewSourceRefOrder(unitRefSourceDocument(3, 1, 2))
|
||||||
|
if got := order.Canonicalize(nil); got != nil {
|
||||||
|
t.Fatalf("Canonicalize(nil) = %#v, want nil", got)
|
||||||
|
}
|
||||||
|
empty := []source.SourceRef{}
|
||||||
|
if got := order.Canonicalize(empty); got == nil || len(got) != 0 {
|
||||||
|
t.Fatalf("Canonicalize(empty) = %#v, want owned empty slice", got)
|
||||||
|
}
|
||||||
|
input := []source.SourceRef{
|
||||||
|
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2},
|
||||||
|
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2},
|
||||||
|
{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 3},
|
||||||
|
}
|
||||||
|
got := order.Canonicalize(input)
|
||||||
|
if len(got) != 2 || got[0] == got[1] {
|
||||||
|
t.Fatalf("Canonicalize() = %#v, want exact duplicate removed but distinct range retained", got)
|
||||||
|
}
|
||||||
|
got[0].StartUnitID = 99
|
||||||
|
if input[0].StartUnitID == 99 {
|
||||||
|
t.Fatal("Canonicalize() output aliases input")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSourceRefOrderSnapshotAndEarliestValid(t *testing.T) {
|
||||||
|
doc := unitRefSourceDocument(30, 10, 20)
|
||||||
|
order := NewSourceRefOrder(doc)
|
||||||
|
doc.Units[0].ID, doc.Units[1].ID = 10, 30
|
||||||
|
refs := []source.SourceRef{
|
||||||
|
{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10},
|
||||||
|
{SourceID: "other", StartUnitID: 1, EndUnitID: 1},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999},
|
||||||
|
{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 30},
|
||||||
|
}
|
||||||
|
if position, ok := order.EarliestValid(refs); !ok || position != 1 {
|
||||||
|
t.Fatalf("EarliestValid() = %d, %t, want 1, true", position, ok)
|
||||||
|
}
|
||||||
|
if position, ok := order.EarliestValid([]source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 30}}); ok || position != 0 {
|
||||||
|
t.Fatalf("EarliestValid(invalid) = %d, %t, want 0, false", position, ok)
|
||||||
|
}
|
||||||
|
if position, ok := (SourceRefOrder{}).EarliestValid(refs); ok || position != 0 {
|
||||||
|
t.Fatalf("zero EarliestValid() = %d, %t, want 0, false", position, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,28 +77,16 @@ func (ref UnitRef) MarshalJSON() ([]byte, error) {
|
|||||||
return json.Marshal(ref.String())
|
return json.Marshal(ref.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
func ResolveUnitID(doc *source.SourceDocument, field string, ref UnitRef) (int, error) {
|
func ResolveUnitID(index source.DocumentIndex, field string, ref UnitRef) (int, error) {
|
||||||
if ref.value <= 0 {
|
if ref.value <= 0 {
|
||||||
return 0, fmt.Errorf("%s must be positive", field)
|
return 0, fmt.Errorf("%s must be positive", field)
|
||||||
}
|
}
|
||||||
if _, ok := source.UnitIndex(doc, ref.value); !ok {
|
if _, ok := index.Position(ref.value); !ok {
|
||||||
return 0, fmt.Errorf("%s %d was not found", field, ref.value)
|
return 0, fmt.Errorf("%s %d was not found", field, ref.value)
|
||||||
}
|
}
|
||||||
return ref.value, nil
|
return ref.value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func SourceRefCandidate(doc *source.SourceDocument, ref SourceRefResponse) source.SourceRef {
|
|
||||||
return source.SourceRef{
|
|
||||||
SourceID: strings.TrimSpace(ref.SourceID),
|
|
||||||
StartUnitID: unitIDCandidate(ref.StartUnitID),
|
|
||||||
EndUnitID: unitIDCandidate(ref.EndUnitID),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func unitIDCandidate(ref UnitRef) int {
|
|
||||||
return ref.value
|
|
||||||
}
|
|
||||||
|
|
||||||
func parseUnitRefNumber(value string) (int, error) {
|
func parseUnitRefNumber(value string) (int, error) {
|
||||||
trimmed := strings.TrimSpace(value)
|
trimmed := strings.TrimSpace(value)
|
||||||
if trimmed == "" {
|
if trimmed == "" {
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ func TestUnitRefUnmarshalRejectsNonIntegerValues(t *testing.T) {
|
|||||||
func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
|
func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
|
||||||
doc := unitRefSourceDocument(2, 10)
|
doc := unitRefSourceDocument(2, 10)
|
||||||
|
|
||||||
got, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(2))
|
got, err := ResolveUnitID(source.NewDocumentIndex(doc), "start_unit_id", UnitRefFromInt(2))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ResolveUnitID() error = %v, want nil", err)
|
t.Fatalf("ResolveUnitID() error = %v, want nil", err)
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
|
|||||||
func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
|
func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
|
||||||
doc := unitRefSourceDocument(10, 20)
|
doc := unitRefSourceDocument(10, 20)
|
||||||
|
|
||||||
_, err := ResolveUnitID(doc, "end_unit_id", UnitRefFromInt(2))
|
_, err := ResolveUnitID(source.NewDocumentIndex(doc), "end_unit_id", UnitRefFromInt(2))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ResolveUnitID() error = nil, want missing source-unit ID")
|
t.Fatal("ResolveUnitID() error = nil, want missing source-unit ID")
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
|
|||||||
func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
|
func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
|
||||||
doc := unitRefSourceDocument(1)
|
doc := unitRefSourceDocument(1)
|
||||||
|
|
||||||
_, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(9))
|
_, err := ResolveUnitID(source.NewDocumentIndex(doc), "start_unit_id", UnitRefFromInt(9))
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("ResolveUnitID() error = nil, want error")
|
t.Fatal("ResolveUnitID() error = nil, want error")
|
||||||
}
|
}
|
||||||
@@ -74,28 +74,6 @@ func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSourceRefCandidateCanonicalizesValidRefsAndPreservesInvalidRefs(t *testing.T) {
|
|
||||||
doc := unitRefSourceDocument(1, 2)
|
|
||||||
|
|
||||||
valid := SourceRefCandidate(doc, SourceRefResponse{
|
|
||||||
SourceID: " session-alpha ",
|
|
||||||
StartUnitID: UnitRefFromInt(1),
|
|
||||||
EndUnitID: UnitRefFromInt(2),
|
|
||||||
})
|
|
||||||
if valid != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 2}) {
|
|
||||||
t.Fatalf("valid candidate = %#v, want canonical source ref", valid)
|
|
||||||
}
|
|
||||||
|
|
||||||
invalid := SourceRefCandidate(doc, SourceRefResponse{
|
|
||||||
SourceID: "session-alpha",
|
|
||||||
StartUnitID: UnitRefFromInt(9),
|
|
||||||
EndUnitID: UnitRefFromString("missing"),
|
|
||||||
})
|
|
||||||
if invalid != (source.SourceRef{SourceID: "session-alpha", StartUnitID: 9, EndUnitID: 0}) {
|
|
||||||
t.Fatalf("invalid candidate = %#v, want unresolved values for validator", invalid)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func unitRefSourceDocument(ids ...int) *source.SourceDocument {
|
func unitRefSourceDocument(ids ...int) *source.SourceDocument {
|
||||||
doc := &source.SourceDocument{
|
doc := &source.SourceDocument{
|
||||||
ID: "session-alpha",
|
ID: "session-alpha",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"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/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"
|
||||||
)
|
)
|
||||||
@@ -19,7 +20,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "normalize/dnd/combat-turns/invariants"
|
Key = "normalize/dnd/combat-turns/invariants"
|
||||||
ReasonCode = "invalid_combat_turn_normalization"
|
ReasonCode = "invalid_combat_turn_normalization"
|
||||||
policy = "dnd.combat_turns.validator.normalized.v1"
|
policy = "dnd.combat_turns.validator.normalized.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Options struct{}
|
type Options struct{}
|
||||||
@@ -47,17 +48,21 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
// Validate checks only invariants owned by normalized combat-turn output. A
|
// Validate checks only invariants owned by normalized combat-turn output. A
|
||||||
// shape or source-reference failure is deliberately deferred to its owner.
|
// shape or source-reference failure is deliberately deferred to its owner.
|
||||||
func Validate(doc *source.SourceDocument, value dnd.CombatTurnList) error {
|
func Validate(doc *source.SourceDocument, value dnd.CombatTurnList) error {
|
||||||
if combatshape.Validate(value) != nil || !sourceRefsValid(doc, value) {
|
if combatshape.Validate(value) != nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
issues := issuesFor(doc, value)
|
index := source.NewDocumentIndex(doc)
|
||||||
|
if !sourceRefsValid(index, value) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
issues := issuesFor(shared.NewSourceRefOrderWithIndex(doc, index), value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn normalization", issues))
|
return fmt.Errorf("%s", diagnostics.Aggregate("invalid combat turn normalization", issues))
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
func issuesFor(order shared.SourceRefOrder, value dnd.CombatTurnList) []string {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
seenIdentity := make(map[string]int)
|
seenIdentity := make(map[string]int)
|
||||||
previousPosition := -1
|
previousPosition := -1
|
||||||
@@ -69,14 +74,14 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
|||||||
for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
|
for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
|
||||||
previous := turn.SourceRefs[refIndex-1]
|
previous := turn.SourceRefs[refIndex-1]
|
||||||
current := turn.SourceRefs[refIndex]
|
current := turn.SourceRefs[refIndex]
|
||||||
if sourceRefLess(current, previous) {
|
if order.Less(current, previous) {
|
||||||
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
|
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
|
||||||
} else if current == previous {
|
} else if current == previous {
|
||||||
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
|
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
position, ok := earliestSourcePosition(doc, turn)
|
position, ok := order.EarliestValid(turn.SourceRefs)
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -96,10 +101,10 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
|||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool {
|
func sourceRefsValid(index source.DocumentIndex, value dnd.CombatTurnList) bool {
|
||||||
for _, turn := range value.CombatTurns {
|
for _, turn := range value.CombatTurns {
|
||||||
for _, ref := range turn.SourceRefs {
|
for _, ref := range turn.SourceRefs {
|
||||||
if source.ValidateRef(doc, ref) != nil {
|
if index.ValidateRef(ref) != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,36 +112,6 @@ func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceRefLess(left, right source.SourceRef) bool {
|
|
||||||
if left.SourceID != right.SourceID {
|
|
||||||
return left.SourceID < right.SourceID
|
|
||||||
}
|
|
||||||
if left.StartUnitID != right.StartUnitID {
|
|
||||||
return left.StartUnitID < right.StartUnitID
|
|
||||||
}
|
|
||||||
return left.EndUnitID < right.EndUnitID
|
|
||||||
}
|
|
||||||
|
|
||||||
func earliestSourcePosition(doc *source.SourceDocument, turn dnd.CombatTurn) (int, bool) {
|
|
||||||
if doc == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
earliest := 0
|
|
||||||
found := false
|
|
||||||
for _, ref := range turn.SourceRefs {
|
|
||||||
if source.ValidateRef(doc, ref) != nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
index, ok := source.UnitIndex(doc, ref.StartUnitID)
|
|
||||||
if !ok || (found && index >= earliest) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
earliest = index
|
|
||||||
found = true
|
|
||||||
}
|
|
||||||
return earliest, found
|
|
||||||
}
|
|
||||||
|
|
||||||
func duplicateKey(turn dnd.CombatTurn) (string, bool) {
|
func duplicateKey(turn dnd.CombatTurn) (string, bool) {
|
||||||
if len(turn.SourceRefs) == 0 {
|
if len(turn.SourceRefs) == 0 {
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
@@ -57,6 +57,24 @@ func TestValidateRejectsOwnedNormalizedInvariants(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestValidateUsesSourceDocumentOrder(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 30}, {ID: 10}}}
|
||||||
|
value := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
|
||||||
|
{Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
||||||
|
}}
|
||||||
|
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "not in canonical order") {
|
||||||
|
t.Fatalf("Validate() error = %v, want document-order reference rejection", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value = dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
|
||||||
|
{Actor: "Aria", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
||||||
|
{Actor: "Borin", TurnKind: dnd.CombatTurnKindTurn, SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
||||||
|
}}
|
||||||
|
if err := Validate(doc, value); err == nil || !strings.Contains(err.Error(), "out of chronological order") {
|
||||||
|
t.Fatalf("Validate() error = %v, want document-order chronology rejection", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidatorDefersShapeAndSourceReferenceFailures(t *testing.T) {
|
func TestValidatorDefersShapeAndSourceReferenceFailures(t *testing.T) {
|
||||||
doc := invariantDocument()
|
doc := invariantDocument()
|
||||||
shapeInvalid := normalizedList()
|
shapeInvalid := normalizedList()
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if err := combatshape.Validate(req.Value); err != nil {
|
if err := combatshape.Validate(req.Value); err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
issues := sourceRefIssues(req.Source, req.Value)
|
issues := sourceRefIssues(source.NewDocumentIndex(req.Source), req.Value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -48,11 +48,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceRefIssues(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
func sourceRefIssues(index source.DocumentIndex, value dnd.CombatTurnList) []string {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for turnIndex, turn := range value.CombatTurns {
|
for turnIndex, turn := range value.CombatTurns {
|
||||||
for refIndex, ref := range turn.SourceRefs {
|
for refIndex, ref := range turn.SourceRefs {
|
||||||
if err := source.ValidateRef(doc, ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: %s", turnIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("combat_turns[%d].source_refs[%d]: %s", turnIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if combatshape.Validate(req.Value) != nil {
|
if combatshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
resolver, err := shared.NewCitationResolver(req.Source)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
|
||||||
citedTexts := make([]string, len(req.Value.CombatTurns))
|
citedTexts := make([]string, len(req.Value.CombatTurns))
|
||||||
for turnIndex, turn := range req.Value.CombatTurns {
|
for turnIndex, turn := range req.Value.CombatTurns {
|
||||||
citedText, err := shared.CitedText(req.Source, turn.SourceRefs)
|
citedText, err := resolver.CitedText(turn.SourceRefs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
|
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
|
||||||
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
npcregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/registry"
|
||||||
|
"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"
|
||||||
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
|
interactionshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/npcinteractions/shape"
|
||||||
)
|
)
|
||||||
@@ -76,7 +77,11 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.NPCInteractionList]) (contracts.ValidationResult, error) {
|
||||||
if interactionshape.Validate(req.Value) != nil || !allSourceRefsValid(req.Source, req.Value) {
|
if interactionshape.Validate(req.Value) != nil {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
index := source.NewDocumentIndex(req.Source)
|
||||||
|
if !allSourceRefsValid(index, req.Value) {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
if v == nil || v.npcResolver == nil {
|
if v == nil || v.npcResolver == nil {
|
||||||
@@ -89,7 +94,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if !npcRegistry.Bound() {
|
if !npcRegistry.Bound() {
|
||||||
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid NPC interaction normalization: NPC registry reference is required"}, nil
|
return contracts.ValidationResult{Approved: false, ReasonCode: ReasonCode, Message: "invalid NPC interaction normalization: NPC registry reference is required"}, nil
|
||||||
}
|
}
|
||||||
issues := issuesFor(req.Source, req.Value, npcRegistry)
|
issues := issuesFor(shared.NewSourceRefOrderWithIndex(req.Source, index), req.Value, npcRegistry)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -100,16 +105,16 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func allSourceRefsValid(doc *source.SourceDocument, value dnd.NPCInteractionList) bool {
|
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool {
|
||||||
for _, interaction := range value.Interactions {
|
for _, interaction := range value.Interactions {
|
||||||
if !interactionmodel.ValidSourceRefs(doc, interaction.SourceRefs) {
|
if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string {
|
func issuesFor(order shared.SourceRefOrder, value dnd.NPCInteractionList, npcRegistry *npcregistry.Registry) []string {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for index, interaction := range value.Interactions {
|
for index, interaction := range value.Interactions {
|
||||||
prefix := fmt.Sprintf("interactions[%d]", index)
|
prefix := fmt.Sprintf("interactions[%d]", index)
|
||||||
@@ -119,7 +124,7 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
|
|||||||
for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ {
|
for refIndex := 1; refIndex < len(interaction.SourceRefs); refIndex++ {
|
||||||
previous := interaction.SourceRefs[refIndex-1]
|
previous := interaction.SourceRefs[refIndex-1]
|
||||||
current := interaction.SourceRefs[refIndex]
|
current := interaction.SourceRefs[refIndex]
|
||||||
if interactionmodel.SourceRefLess(doc, current, previous) {
|
if order.Less(current, previous) {
|
||||||
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
|
issues = append(issues, fmt.Sprintf("%s.source_refs are not in canonical order at index %d", prefix, refIndex))
|
||||||
} else if current == previous {
|
} else if current == previous {
|
||||||
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
|
issues = append(issues, fmt.Sprintf("%s.source_refs[%d] duplicates the previous reference", prefix, refIndex))
|
||||||
@@ -128,7 +133,7 @@ func issuesFor(doc *source.SourceDocument, value dnd.NPCInteractionList, npcRegi
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool {
|
if !sort.SliceIsSorted(value.Interactions, func(left, right int) bool {
|
||||||
return interactionmodel.Less(doc, value.Interactions[left], value.Interactions[right])
|
return interactionmodel.Less(order, value.Interactions[left], value.Interactions[right])
|
||||||
}) {
|
}) {
|
||||||
issues = append(issues, "interactions are not in canonical order")
|
issues = append(issues, "interactions are not in canonical order")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,10 +41,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if interactionshape.Validate(req.Value) != nil {
|
if interactionshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
index := source.NewDocumentIndex(req.Source)
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for interactionIndex, interaction := range req.Value.Interactions {
|
for interactionIndex, interaction := range req.Value.Interactions {
|
||||||
for refIndex, ref := range interaction.SourceRefs {
|
for refIndex, ref := range interaction.SourceRefs {
|
||||||
if err := source.ValidateRef(req.Source, ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("interactions[%d].source_refs[%d]: %s", interactionIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("interactions[%d].source_refs[%d]: %s", interactionIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,9 +39,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if interactionshape.Validate(req.Value) != nil {
|
if interactionshape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
resolver, err := shared.NewCitationResolver(req.Source)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
citedTexts := make([]string, len(req.Value.Interactions))
|
citedTexts := make([]string, len(req.Value.Interactions))
|
||||||
for index, interaction := range req.Value.Interactions {
|
for index, interaction := range req.Value.Interactions {
|
||||||
citedText, err := shared.CitedText(req.Source, interaction.SourceRefs)
|
citedText, err := resolver.CitedText(interaction.SourceRefs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,10 +37,11 @@ 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
|
||||||
}
|
}
|
||||||
|
index := source.NewDocumentIndex(req.Source)
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
for npcIndex, npc := range req.Value.NPCs {
|
for npcIndex, npc := range req.Value.NPCs {
|
||||||
for refIndex, ref := range npc.SourceRefs {
|
for refIndex, ref := range npc.SourceRefs {
|
||||||
if err := source.ValidateRef(req.Source, ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: %s", npcIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("npcs[%d].source_refs[%d]: %s", npcIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ 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
|
||||||
}
|
}
|
||||||
|
resolver, err := shared.NewCitationResolver(req.Source)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
citedTexts := make([]string, len(req.Value.NPCs))
|
citedTexts := make([]string, len(req.Value.NPCs))
|
||||||
for npcIndex, npc := range req.Value.NPCs {
|
for npcIndex, npc := range req.Value.NPCs {
|
||||||
citedText, err := shared.CitedText(req.Source, npc.SourceRefs)
|
citedText, err := resolver.CitedText(npc.SourceRefs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,14 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.SceneDescriptionList]) (contracts.ValidationResult, error) {
|
||||||
if shape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
|
if shape.Validate(req.Value) != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
issues := issuesFor(req.Source, req.Value)
|
index := source.NewDocumentIndex(req.Source)
|
||||||
|
if !sourceRefsValid(index, req.Value) {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
|
issues := issuesFor(index, req.Value)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
@@ -49,20 +53,20 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceRefsValid(doc *source.SourceDocument, value dnd.SceneDescriptionList) bool {
|
func sourceRefsValid(index source.DocumentIndex, value dnd.SceneDescriptionList) bool {
|
||||||
for _, scene := range value.Scenes {
|
for _, scene := range value.Scenes {
|
||||||
if source.ValidateRef(doc, scene.SourceRef) != nil {
|
if index.ValidateRef(scene.SourceRef) != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func issuesFor(doc *source.SourceDocument, value dnd.SceneDescriptionList) []string {
|
func issuesFor(index source.DocumentIndex, value dnd.SceneDescriptionList) []string {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
if !sort.SliceIsSorted(value.Scenes, func(left, right int) bool {
|
if !sort.SliceIsSorted(value.Scenes, func(left, right int) bool {
|
||||||
leftStart, _ := source.UnitIndex(doc, value.Scenes[left].SourceRef.StartUnitID)
|
leftStart, _ := index.Position(value.Scenes[left].SourceRef.StartUnitID)
|
||||||
rightStart, _ := source.UnitIndex(doc, value.Scenes[right].SourceRef.StartUnitID)
|
rightStart, _ := index.Position(value.Scenes[right].SourceRef.StartUnitID)
|
||||||
if leftStart != rightStart {
|
if leftStart != rightStart {
|
||||||
return leftStart < rightStart
|
return leftStart < rightStart
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ func TestValidatorRequiresSceneShapeAndExactlyOneExtractionRecord(t *testing.T)
|
|||||||
{name: "empty list", value: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}},
|
{name: "empty list", value: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}},
|
||||||
{name: "blank ID", value: list(scene(" ", dnd.SceneKindNarrative, "Arrival", "The party arrives.", 1, 1))},
|
{name: "blank ID", value: list(scene(" ", dnd.SceneKindNarrative, "Arrival", "The party arrives.", 1, 1))},
|
||||||
{name: "unsupported kind", value: list(scene("one", "other", "Arrival", "The party arrives.", 1, 1))},
|
{name: "unsupported kind", value: list(scene("one", "other", "Arrival", "The party arrives.", 1, 1))},
|
||||||
|
{name: "empty title", value: list(scene("one", dnd.SceneKindNarrative, "", "The party arrives.", 1, 1))},
|
||||||
|
{name: "whitespace-only title", value: list(scene("one", dnd.SceneKindNarrative, " ", "The party arrives.", 1, 1))},
|
||||||
|
{name: "empty summary", value: list(scene("one", dnd.SceneKindNarrative, "Arrival", "", 1, 1))},
|
||||||
|
{name: "whitespace-only summary", value: list(scene("one", dnd.SceneKindNarrative, "Arrival", " ", 1, 1))},
|
||||||
{name: "untrimmed prose", value: list(scene("one", dnd.SceneKindNarrative, " Arrival ", "The party arrives.", 1, 1))},
|
{name: "untrimmed prose", value: list(scene("one", dnd.SceneKindNarrative, " Arrival ", "The party arrives.", 1, 1))},
|
||||||
{name: "empty source reference", value: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ID: "one", Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives."}}}},
|
{name: "empty source reference", value: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{{ID: "one", Kind: dnd.SceneKindNarrative, Title: "Arrival", Summary: "The party arrives."}}}},
|
||||||
} {
|
} {
|
||||||
|
|||||||
@@ -42,9 +42,10 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
|
if req.Stage == string(pipeline.StageExtract) && req.Chunk == nil {
|
||||||
issues = append(issues, "current extraction chunk must not be nil")
|
issues = append(issues, "current extraction chunk must not be nil")
|
||||||
}
|
}
|
||||||
for index, scene := range req.Value.Scenes {
|
index := source.NewDocumentIndex(req.Source)
|
||||||
prefix := fmt.Sprintf("scenes[%d]", index)
|
for sceneIndex, scene := range req.Value.Scenes {
|
||||||
if err := source.ValidateRef(req.Source, scene.SourceRef); err != nil {
|
prefix := fmt.Sprintf("scenes[%d]", sceneIndex)
|
||||||
|
if err := index.ValidateRef(scene.SourceRef); err != nil {
|
||||||
issues = append(issues, prefix+".source_ref: "+diagnostics.Truncate(err.Error()))
|
issues = append(issues, prefix+".source_ref: "+diagnostics.Truncate(err.Error()))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,9 +48,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
if shape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
|
if shape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
resolver, err := shared.NewCitationResolver(req.Source)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
warnings := make([]contracts.Warning, 0)
|
warnings := make([]contracts.Warning, 0)
|
||||||
for index, scene := range req.Value.Scenes {
|
for index, scene := range req.Value.Scenes {
|
||||||
citedText, err := shared.CitedText(req.Source, []source.SourceRef{scene.SourceRef})
|
citedText, err := resolver.CitedText([]source.SourceRef{scene.SourceRef})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,10 +36,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
index := source.NewDocumentIndex(req.Source)
|
||||||
issues := make([]string, 0)
|
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 := index.ValidateRef(ref); err != nil {
|
||||||
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
|
issues = append(issues, fmt.Sprintf("spell_casts[%d].source_refs[%d]: %s", spellIndex, refIndex, diagnostics.Truncate(err.Error())))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
resolver, err := shared.NewCitationResolver(req.Source)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
|
}
|
||||||
citedTexts := make([]string, len(req.Value.SpellCasts))
|
citedTexts := make([]string, len(req.Value.SpellCasts))
|
||||||
for spellIndex, spell := range req.Value.SpellCasts {
|
for spellIndex, spell := range req.Value.SpellCasts {
|
||||||
citedText, err := shared.CitedText(req.Source, spell.SourceRefs)
|
citedText, err := resolver.CitedText(spell.SourceRefs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return contracts.ValidationResult{Approved: true}, nil
|
return contracts.ValidationResult{Approved: true}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Prepare() error = %v", err)
|
t.Fatalf("Prepare() error = %v", err)
|
||||||
}
|
}
|
||||||
|
for name, value := range map[string]string{
|
||||||
|
"extract:npcs:dnd/npcs:mapping_policy": "dnd.npcs.extract_mapping.v2",
|
||||||
|
"extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2",
|
||||||
|
} {
|
||||||
|
assertFingerprintValue(t, prepared.CheckpointFingerprints(), name, value)
|
||||||
|
}
|
||||||
for _, name := range []string{
|
for _, name := range []string{
|
||||||
"extract:npcs:dnd/npcs:prompt",
|
"extract:npcs:dnd/npcs:prompt",
|
||||||
"extract:npcs:dnd/npcs:response_schema",
|
"extract:npcs:dnd/npcs:response_schema",
|
||||||
@@ -173,6 +179,16 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
|||||||
assertCurrentEvidence(t, combatValue.CombatTurns[0].SourceRefs)
|
assertCurrentEvidence(t, combatValue.CombatTurns[0].SourceRefs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func assertFingerprintValue(t *testing.T, fingerprints []pipeline.CheckpointFingerprint, name, want string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, fingerprint := range fingerprints {
|
||||||
|
if fingerprint.Name == name && fingerprint.Value == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("fingerprints = %#v, want %q = %q", fingerprints, name, want)
|
||||||
|
}
|
||||||
|
|
||||||
func assertCurrentEvidence(t *testing.T, references []source.SourceRef) {
|
func assertCurrentEvidence(t *testing.T, references []source.SourceRef) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
for _, reference := range references {
|
for _, reference := range references {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
|
|||||||
t.Fatalf("manifest lane = %#v, want NPC production composition", lane)
|
t.Fatalf("manifest lane = %#v, want NPC production composition", lane)
|
||||||
}
|
}
|
||||||
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
|
normalizerMetadata, ok := lane.Metadata["normalizer"].(map[string]any)
|
||||||
if !ok || normalizerMetadata["identity_policy"] != identity.Policy || normalizerMetadata["normalization_policy"] != "dnd.npcs.normalize.v1" {
|
if !ok || normalizerMetadata["identity_policy"] != identity.Policy || normalizerMetadata["normalization_policy"] != "dnd.npcs.normalize.v2" {
|
||||||
t.Fatalf("normalizer metadata = %#v, want identity and normalization policies", lane.Metadata)
|
t.Fatalf("normalizer metadata = %#v, want identity and normalization policies", lane.Metadata)
|
||||||
}
|
}
|
||||||
var npcOutputFile *contracts.OutputFile
|
var npcOutputFile *contracts.OutputFile
|
||||||
|
|||||||
Reference in New Issue
Block a user