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
|
||||
`internal/framework/promptfs`; reusable D&D prompt fragments, reference
|
||||
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
|
||||
package-local prompt manifests for both rendering and prompt fingerprinting, so
|
||||
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
|
||||
public types.
|
||||
|
||||
The shared `ChunkPromptMaterial` helper owns common transcript material
|
||||
preparation for the spell, NPC, combat-turn, and NPC-interaction extractors. It clones supplied
|
||||
source metadata, falls back to the materialized chunk when content is absent,
|
||||
checks that content remains chunk-identical, and fills only the common default
|
||||
fields. Extractors retain their request validation and wrap helper errors with
|
||||
their module context.
|
||||
The shared `PrepareChunkExtraction` helper owns common extraction preflight and
|
||||
transcript material preparation for the spell, NPC, combat-turn,
|
||||
NPC-interaction, and scene-description extractors. It validates common request
|
||||
state, clones supplied source metadata, falls back to the materialized chunk
|
||||
when content is absent, checks that content remains chunk-identical, and fills
|
||||
only the common default fields. Extractors retain 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
|
||||
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
|
||||
decodes the model response, assigns the generic source identity to every source
|
||||
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:
|
||||
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
|
||||
framework provenance.
|
||||
|
||||
The domain-owned `internal/modules/dnd/npcinteractions` package defines
|
||||
canonical source-reference and occurrence ordering, valid-evidence eligibility,
|
||||
and collision-safe exact identity. The interaction normalizer and normalized
|
||||
invariants validator both consume those rules, so their production and checking
|
||||
paths cannot drift. Normalizer and relatedness warning lists use the shared D&D
|
||||
diagnostic cap and emit a final omission-summary warning when truncated.
|
||||
The shared D&D source-reference order defines canonical evidence ordering. The
|
||||
domain-owned `internal/modules/dnd/npcinteractions` package defines occurrence
|
||||
ordering, valid-evidence eligibility, and collision-safe exact identity. The
|
||||
interaction normalizer and normalized invariants validator consume those
|
||||
rules, so their production and checking paths cannot drift. Normalizer and
|
||||
relatedness warning lists use the shared D&D diagnostic cap and emit a final
|
||||
omission-summary warning when truncated.
|
||||
|
||||
### `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
|
||||
and catalog validation. It performs no LLM calls. For each spell cast it
|
||||
canonicalizes recognized names using the catalog's case, whitespace,
|
||||
apostrophe, and alias rules; sorts source references by source identity and
|
||||
unit boundaries; removes only exact reference duplicates; and emits bounded,
|
||||
scoped warnings for each mutation or unresolved name.
|
||||
apostrophe, and alias rules; canonicalizes source references with the shared
|
||||
document-aware order; removes only exact reference duplicates; and emits
|
||||
bounded, scoped warnings for each mutation or unresolved name.
|
||||
|
||||
After those per-cast changes, it collapses only casts with the same canonical
|
||||
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
|
||||
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
|
||||
|
||||
- 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/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/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/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. |
|
||||
@@ -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/dnd/shared` owns reusable D&D prompt fragments,
|
||||
reference declarations, prompt input assembly, source-unit reference helpers,
|
||||
and bounded diagnostics under `internal/modules/dnd/shared/diagnostics`.
|
||||
reference declarations, prompt input assembly, document-aware source-reference
|
||||
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
|
||||
this package. Domain-neutral prompt filesystem composition lives in
|
||||
`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 {
|
||||
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 {
|
||||
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 {
|
||||
t.Helper()
|
||||
changed := append([]pipeline.CheckpointFingerprint(nil), fingerprints...)
|
||||
|
||||
@@ -249,6 +249,9 @@ func TestValidateRefValid(t *testing.T) {
|
||||
if err := ValidateRef(doc, ref); err != nil {
|
||||
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) {
|
||||
@@ -301,11 +304,54 @@ func TestValidateRefRejectsMalformedReferences(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateRef(validDocument(), tt.ref)
|
||||
|
||||
requireErrorFragments(t, err, tt.fragments...)
|
||||
doc := validDocument()
|
||||
validators := []struct {
|
||||
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) {
|
||||
|
||||
@@ -5,6 +5,46 @@ import (
|
||||
"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 {
|
||||
if doc == nil {
|
||||
return fmt.Errorf("source document must not be nil")
|
||||
@@ -60,6 +100,12 @@ func ValidateRef(doc *SourceDocument, ref SourceRef) error {
|
||||
if doc == 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) {
|
||||
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 {
|
||||
return fmt.Errorf("source ref end_unit_id must be positive")
|
||||
}
|
||||
if ref.SourceID != doc.ID {
|
||||
return fmt.Errorf("source ref source_id %q does not match document id %q", ref.SourceID, doc.ID)
|
||||
if ref.SourceID != documentID {
|
||||
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 {
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
unitIndexes := make(map[int]int, len(doc.Units))
|
||||
for i, unit := range doc.Units {
|
||||
unitIndexes[unit.ID] = i
|
||||
}
|
||||
index := source.NewDocumentIndex(doc)
|
||||
|
||||
ranges := make([]source.ChunkRange, 0, len(response.Scenes))
|
||||
previousEnd := -1
|
||||
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 {
|
||||
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 {
|
||||
return source.ChunkPlan{}, fmt.Errorf("scene[%d] %w", i, err)
|
||||
}
|
||||
|
||||
startIndex, ok := unitIndexes[startUnitID]
|
||||
startIndex, ok := index.Position(startUnitID)
|
||||
if !ok {
|
||||
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 {
|
||||
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
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -59,11 +57,7 @@ func (c *Codec) Encode(value dnd.CombatTurnList) ([]byte, error) {
|
||||
// EncodeCandidate provides the durable representation before typed validators
|
||||
// have approved a value.
|
||||
func (c *Codec) EncodeCandidate(value dnd.CombatTurnList) ([]byte, error) {
|
||||
content, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode dnd combat turn list: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
return candidatejson.EncodeCandidate("dnd combat turn list", value)
|
||||
}
|
||||
|
||||
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
|
||||
// validators have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.CombatTurnList, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(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
|
||||
return candidatejson.DecodeCandidate[dnd.CombatTurnList]("dnd combat turn list", content)
|
||||
}
|
||||
|
||||
func validate(value dnd.CombatTurnList) error {
|
||||
|
||||
@@ -2,15 +2,13 @@
|
||||
package npcinteractions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -60,11 +58,7 @@ func (c *Codec) Encode(value dnd.NPCInteractionList) ([]byte, error) {
|
||||
// EncodeCandidate provides the durable representation before semantic
|
||||
// validators have approved a value.
|
||||
func (c *Codec) EncodeCandidate(value dnd.NPCInteractionList) ([]byte, error) {
|
||||
content, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode dnd npc interaction list: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
return candidatejson.EncodeCandidate("dnd npc interaction list", value)
|
||||
}
|
||||
|
||||
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
|
||||
// validators have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCInteractionList, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(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
|
||||
return candidatejson.DecodeCandidate[dnd.NPCInteractionList]("dnd npc interaction list", content)
|
||||
}
|
||||
|
||||
func validate(value dnd.NPCInteractionList) error {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package npcs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -60,11 +58,7 @@ func (c *Codec) Encode(value dnd.NPCList) ([]byte, error) {
|
||||
// EncodeCandidate provides the durable representation before semantic
|
||||
// validators have approved a value.
|
||||
func (c *Codec) EncodeCandidate(value dnd.NPCList) ([]byte, error) {
|
||||
content, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode dnd npc list: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
return candidatejson.EncodeCandidate("dnd npc list", value)
|
||||
}
|
||||
|
||||
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
|
||||
// validators have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.NPCList, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(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
|
||||
return candidatejson.DecodeCandidate[dnd.NPCList]("dnd npc list", content)
|
||||
}
|
||||
|
||||
func validate(value dnd.NPCList) error {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package scenedescriptions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -59,11 +57,7 @@ func (c *Codec) Encode(value dnd.SceneDescriptionList) ([]byte, error) {
|
||||
// EncodeCandidate provides the durable representation before typed validators
|
||||
// have approved a value.
|
||||
func (c *Codec) EncodeCandidate(value dnd.SceneDescriptionList) ([]byte, error) {
|
||||
content, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode dnd scene description list: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
return candidatejson.EncodeCandidate("dnd scene description list", value)
|
||||
}
|
||||
|
||||
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
|
||||
// have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.SceneDescriptionList, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(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
|
||||
return candidatejson.DecodeCandidate[dnd.SceneDescriptionList]("dnd scene description list", content)
|
||||
}
|
||||
|
||||
func validate(value dnd.SceneDescriptionList) error {
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
package spells
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/candidatejson"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -54,11 +52,7 @@ func (c *Codec) Encode(value dnd.SpellList) ([]byte, error) {
|
||||
// EncodeCandidate provides the same stable representation before typed
|
||||
// validators have approved a value.
|
||||
func (c *Codec) EncodeCandidate(value dnd.SpellList) ([]byte, error) {
|
||||
content, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode dnd spell list: %w", err)
|
||||
}
|
||||
return content, nil
|
||||
return candidatejson.EncodeCandidate("dnd spell list", value)
|
||||
}
|
||||
|
||||
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
|
||||
// have approved it.
|
||||
func (c *Codec) DecodeCandidate(content []byte) (dnd.SpellList, error) {
|
||||
decoder := json.NewDecoder(bytes.NewReader(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
|
||||
return candidatejson.DecodeCandidate[dnd.SpellList]("dnd spell list", content)
|
||||
}
|
||||
|
||||
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/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 {
|
||||
return
|
||||
}
|
||||
ordered := make([]orderedCombatTurnResponse, len(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 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 {
|
||||
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
|
||||
}
|
||||
earliest := 0
|
||||
found := false
|
||||
for _, ref := range turn.SourceRefs {
|
||||
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
|
||||
refs := order.Canonicalize(canonicalSourceRefs(turn.SourceRefs, sourceID))
|
||||
turn.SourceRefs = combatResponseRefs(refs)
|
||||
return order.EarliestValid(refs)
|
||||
}
|
||||
|
||||
func canonicalCombatTurnList(response extractionResponse, sourceID string) dnd.CombatTurnList {
|
||||
@@ -115,3 +75,14 @@ func canonicalSourceRefs(refs []combatSourceRefResponse, sourceID string) []sour
|
||||
}
|
||||
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 (
|
||||
Key = "dnd/combat-turns"
|
||||
ArtifactType = "dnd.combat_turn"
|
||||
mappingPolicy = "dnd.combat_turns.extract_mapping.v1"
|
||||
mappingPolicy = "dnd.combat_turns.extract_mapping.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -144,25 +143,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
if e.llm == nil {
|
||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
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)
|
||||
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.CombatTurnList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
client := &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}}
|
||||
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{
|
||||
@@ -185,9 +222,8 @@ func TestNewRejectsMalformedNPCRegistryBeforeLLMCallWithoutContent(t *testing.T)
|
||||
|
||||
func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) {
|
||||
validReq := extractionRequest()
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
validExtractor := newExtractor(t, &fakeCombatTurnsLLMClient{response: extractionResponse{CombatTurns: []combatTurnResponse{}}})
|
||||
var nilExtractor *Extractor
|
||||
tests := []struct {
|
||||
name string
|
||||
extractor *Extractor
|
||||
@@ -195,13 +231,9 @@ func TestExtractRejectsInvalidRequestsAndWrapsProviderFailures(t *testing.T) {
|
||||
req contracts.TypedExtractionRequest
|
||||
want string
|
||||
}{
|
||||
{name: "nil extractor", ctx: context.Background(), req: validReq, want: "extractor"},
|
||||
{name: "nil context", extractor: validExtractor, req: validReq, want: "context"},
|
||||
{name: "canceled context", extractor: validExtractor, ctx: canceledCtx, req: validReq, want: "context"},
|
||||
{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"},
|
||||
{name: "nil extractor", extractor: nilExtractor, ctx: context.Background(), req: validReq, want: "extractor"},
|
||||
{name: "nil LLM client", extractor: &Extractor{}, ctx: context.Background(), req: validReq, want: "LLM client"},
|
||||
{name: "wrapped preflight failure", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
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 {
|
||||
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"other":true}`), "sha256:other", "")
|
||||
return req
|
||||
|
||||
@@ -62,16 +62,48 @@ func TestScriptoriumPromptPreparesRequiredInputs(t *testing.T) {
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_combat_turns_llm.v1.json" {
|
||||
t.Fatalf("prepared prompt = %#v, want combat prompt identity and schema", prepared)
|
||||
}
|
||||
for _, want := range []string{transcript, "Dana: Mira", "Mira: ranger", "Greencloak: title", `{"npcs":[]}`} {
|
||||
found := false
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, want) {
|
||||
found = true
|
||||
break
|
||||
for index, want := range []struct {
|
||||
role string
|
||||
cached bool
|
||||
marker string
|
||||
}{
|
||||
{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 {
|
||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
||||
if len(prepared.Messages) != 8 {
|
||||
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/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 {
|
||||
return
|
||||
}
|
||||
ordered := make([]orderedInteractionResponse, len(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 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 {
|
||||
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
|
||||
}
|
||||
found := false
|
||||
earliest := 0
|
||||
for _, ref := range interaction.SourceRefs {
|
||||
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
|
||||
refs := order.Canonicalize(canonicalSourceRefs(interaction.SourceRefs, sourceID))
|
||||
interaction.SourceRefs = interactionResponseRefs(refs)
|
||||
return order.EarliestValid(refs)
|
||||
}
|
||||
|
||||
func canonicalInteractionList(response extractionResponse, sourceID string) dnd.NPCInteractionList {
|
||||
@@ -111,3 +75,14 @@ func canonicalSourceRefs(refs []interactionSourceRefResponse, sourceID string) [
|
||||
}
|
||||
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 (
|
||||
Key = "dnd/npc-interactions"
|
||||
mappingPolicy = "dnd.npc_interactions.extract_mapping.v1"
|
||||
mappingPolicy = "dnd.npc_interactions.extract_mapping.v2"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -143,25 +143,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
if e.llm == nil {
|
||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
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)
|
||||
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.NPCInteractionList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
if _, err := New(nil, Options{}); err == nil || !strings.Contains(err.Error(), "LLM client") {
|
||||
t.Fatalf("New(nil) error = %v", err)
|
||||
@@ -165,24 +204,21 @@ func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||
valid := extractionRequest()
|
||||
valid.References = references
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
extractor := newExtractor(t, &fakeInteractionsLLMClient{}, references)
|
||||
var nilExtractor *Extractor
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
extractor *Extractor
|
||||
ctx context.Context
|
||||
req contracts.TypedExtractionRequest
|
||||
want string
|
||||
}{
|
||||
{"nil context", nil, valid, "context"},
|
||||
{"canceled context", canceled, valid, "context"},
|
||||
{"nil source", context.Background(), contracts.TypedExtractionRequest{Chunk: valid.Chunk, References: references}, "source"},
|
||||
{"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"},
|
||||
{"nil extractor", nilExtractor, context.Background(), valid, "extractor"},
|
||||
{"nil LLM client", &Extractor{}, context.Background(), valid, "LLM client"},
|
||||
{"wrapped preflight failure", extractor, context.Background(), mismatchedSourceInputRequest(valid), "must match chunk"},
|
||||
} {
|
||||
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)
|
||||
}
|
||||
})
|
||||
@@ -322,11 +358,6 @@ func interactionKinds(value dnd.NPCInteractionList) []dnd.NPCInteractionKind {
|
||||
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 {
|
||||
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "")
|
||||
return req
|
||||
|
||||
@@ -51,22 +51,49 @@ func TestRegisterPromptAssetsAndPrepareInteractionPrompt(t *testing.T) {
|
||||
if prepared.PromptID != PromptID || prepared.OutputContract.SchemaPath != "dnd_npc_interactions_llm.v1.json" {
|
||||
t.Fatalf("prepared prompt = %#v", prepared)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Mira Thorn", "mentioned", "combat_opponent", "Registry content is context, not event evidence", transcript,
|
||||
for index, want := range []struct {
|
||||
role string
|
||||
cached bool
|
||||
marker string
|
||||
}{
|
||||
{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: "interaction occurrences"},
|
||||
{role: "user", cached: true, marker: "Use exactly one kind per occurrence"},
|
||||
{role: "user"},
|
||||
} {
|
||||
found := false
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, want) {
|
||||
found = true
|
||||
break
|
||||
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 {
|
||||
t.Fatalf("prepared prompt did not include %q", want)
|
||||
if len(prepared.Messages) != 8 {
|
||||
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) {
|
||||
t.Fatalf("last prompt message = %q, want transcript", last.Content)
|
||||
if registry := prepared.Messages[4].Content; !strings.Contains(registry, `{"npcs":[{"name":"Mira Thorn"}]}`) {
|
||||
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/modules/dnd"
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
ordered := make([]orderedNPCResponse, len(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 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 {
|
||||
return
|
||||
return 0, false
|
||||
}
|
||||
sort.SliceStable(npc.SourceRefs, func(i, j int) bool {
|
||||
left := npc.SourceRefs[i]
|
||||
right := npc.SourceRefs[j]
|
||||
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
|
||||
refs := order.Canonicalize(canonicalSourceRefs(npc.SourceRefs, sourceID))
|
||||
npc.SourceRefs = npcResponseRefs(refs)
|
||||
return order.EarliestValid(refs)
|
||||
}
|
||||
|
||||
func canonicalNPCList(response extractionResponse, sourceID string) dnd.NPCList {
|
||||
@@ -120,3 +80,14 @@ func canonicalSourceRefs(values []npcSourceRefResponse, sourceID string) []sourc
|
||||
}
|
||||
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 mappingPolicy = "dnd.npcs.extract_mapping.v2"
|
||||
|
||||
var requiredCapabilities = []string{
|
||||
"chunks",
|
||||
"source.transcript",
|
||||
@@ -85,6 +87,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
"response_schema_version": SchemaVersion,
|
||||
"response_schema_sha256": e.responseSchemaSHA,
|
||||
"identity_policy": identity.Policy,
|
||||
"mapping_policy": mappingPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +99,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
{Name: "prompt", Value: e.promptSHA},
|
||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||
{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 {
|
||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
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)
|
||||
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.NPCList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
|
||||
var response extractionResponse
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
client := &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}}
|
||||
req := extractionRequest()
|
||||
@@ -136,19 +173,27 @@ func TestExtractMapsRawSemanticCandidatesWithoutRepair(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractHandlesCancellationAndProviderErrors(t *testing.T) {
|
||||
func TestExtractRetainsLocalErrorContextAndProviderFailures(t *testing.T) {
|
||||
request := extractionRequest()
|
||||
extractor := newExtractor(t, &fakeNPCsLLMClient{response: extractionResponse{NPCs: []npcResponse{}}})
|
||||
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)
|
||||
var nilExtractor *Extractor
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
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") {
|
||||
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_name": ResponseSchemaName, "response_schema_version": SchemaVersion,
|
||||
"identity_policy": "dnd.npcs.identity.v1",
|
||||
"mapping_policy": mappingPolicy,
|
||||
} {
|
||||
if 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()
|
||||
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) {
|
||||
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" {
|
||||
t.Fatalf("prepared prompt = %#v, want NPC prompt identity and wiring", prepared)
|
||||
}
|
||||
for _, want := range []string{`{"units":[1]}`, "Dana: Mira", "Mira: ranger", "Greencloak: title"} {
|
||||
found := false
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, want) {
|
||||
found = true
|
||||
break
|
||||
for index, want := range []struct {
|
||||
role string
|
||||
cached bool
|
||||
marker string
|
||||
}{
|
||||
{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 {
|
||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
||||
if len(prepared.Messages) != 7 {
|
||||
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
|
||||
for _, message := range prepared.Messages {
|
||||
|
||||
@@ -58,11 +58,6 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
|
||||
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 {
|
||||
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
|
||||
return req
|
||||
|
||||
@@ -6,16 +6,13 @@
|
||||
"required": ["kind", "title", "summary"],
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["combat", "narrative", "recap", "meta"]
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"type": "string"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,22 +113,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
if e.llm == nil {
|
||||
return contracts.TypedExtractionResult[dnd.SceneDescriptionList]{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
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)
|
||||
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||
if err != nil {
|
||||
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{
|
||||
Kind: dnd.SceneKind("unrecognized"), Title: " Untitled scene ", Summary: " Summary ",
|
||||
Kind: dnd.SceneKind("unrecognized"), Title: " ", Summary: "",
|
||||
}}
|
||||
result, err := newExtractor(t, client).Extract(context.Background(), extractionRequest())
|
||||
if err != nil {
|
||||
t.Fatalf("Extract() error = %v, want nil", err)
|
||||
}
|
||||
scene := result.Value.Scenes[0]
|
||||
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "Untitled scene" || scene.Summary != "Summary" {
|
||||
t.Fatalf("scene = %#v, want model kind preserved and textual fields trimmed", scene)
|
||||
if scene.Kind != dnd.SceneKind("unrecognized") || scene.Title != "" || scene.Summary != "" {
|
||||
t.Fatalf("scene = %#v, want semantic candidates returned for deterministic validation", scene)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractValidatesRequestsAndSurfacesProviderFailures(t *testing.T) {
|
||||
request := extractionRequest()
|
||||
extractor := newExtractor(t, &fakeSceneDescriptionsLLMClient{response: extractionResponse{Kind: dnd.SceneKindMeta, Title: "Table talk", Summary: "The group discusses rules."}})
|
||||
var nilExtractor *Extractor
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
extractor *Extractor
|
||||
req contracts.TypedExtractionRequest
|
||||
want string
|
||||
}{
|
||||
{name: "nil source", req: func() contracts.TypedExtractionRequest { r := request; r.Source = nil; return r }()},
|
||||
{name: "nil chunk", req: func() contracts.TypedExtractionRequest { r := request; r.Chunk = nil; return r }()},
|
||||
{name: "empty chunk", req: emptyChunkRequest(request)},
|
||||
{name: "mismatched source input", req: mismatchedSourceInputRequest(request)},
|
||||
{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 := 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
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)
|
||||
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)
|
||||
|
||||
@@ -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 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: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}},
|
||||
{name: "wrong field type", response: map[string]any{"kind": "combat", "title": 7, "summary": "Bandits strike."}},
|
||||
{name: "blank title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}},
|
||||
{name: "blank summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}},
|
||||
{name: "unsupported kind", response: map[string]any{"kind": "interlude", "title": "Ambush", "summary": "Bandits strike."}, valid: true},
|
||||
{name: "empty title", response: map[string]any{"kind": "combat", "title": "", "summary": "Bandits strike."}, valid: true},
|
||||
{name: "empty summary", response: map[string]any{"kind": "combat", "title": "Ambush", "summary": ""}, valid: true},
|
||||
{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) {
|
||||
err := validateJSONSchema(t, test.response, schema.JSONSchema)
|
||||
@@ -54,7 +56,7 @@ func TestResponseSchemaIsMutationSafeAndDiagnosticsRedactContent(t *testing.T) {
|
||||
}
|
||||
first.JSONSchema[0] = '['
|
||||
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)
|
||||
}
|
||||
if diagnostics := second.DiagnosticsMap(); diagnostics["json_schema"] != nil {
|
||||
|
||||
@@ -54,11 +54,6 @@ func newExtractor(t *testing.T, client contracts.StructuredLLMClient, references
|
||||
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 {
|
||||
req.SourceInput = contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"different":true}`), "sha256:other", "file:///other.json")
|
||||
return req
|
||||
|
||||
@@ -5,75 +5,71 @@ import (
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"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 {
|
||||
return
|
||||
}
|
||||
ordered := make([]orderedSpellResponse, len(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 left < right
|
||||
return ordered[i].earliest < ordered[j].earliest
|
||||
})
|
||||
for index := range ordered {
|
||||
response.SpellCasts[index] = ordered[index].value
|
||||
}
|
||||
}
|
||||
|
||||
func canonicalizeSpellCast(spell *spellCastResponse) {
|
||||
sort.SliceStable(spell.SourceRefs, func(i, j int) bool {
|
||||
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
|
||||
}
|
||||
}
|
||||
func canonicalizeSpellCast(spell *spellCastResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
||||
if spell == nil {
|
||||
return 0, false
|
||||
}
|
||||
refs := order.Canonicalize(spellSourceRefs(spell.SourceRefs, sourceID))
|
||||
spell.SourceRefs = spellResponseRefs(refs)
|
||||
return order.EarliestValid(refs)
|
||||
}
|
||||
|
||||
func unitSortValue(value int) int {
|
||||
if value <= 0 {
|
||||
return int(^uint(0) >> 1)
|
||||
func spellSourceRefs(refs []spellSourceRefResponse, sourceID string) []source.SourceRef {
|
||||
if refs == nil {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
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 {
|
||||
|
||||
@@ -14,9 +14,10 @@ import (
|
||||
)
|
||||
|
||||
const Key = "dnd/spells"
|
||||
const ArtifactType = "dnd.spell_cast"
|
||||
const SchemaVersion = "v1"
|
||||
|
||||
const mappingPolicy = "dnd.spells.extract_mapping.v2"
|
||||
|
||||
const (
|
||||
NPCRegistryReferenceSlot = npcregistry.ReferenceSlot
|
||||
NPCRegistryMaxBytes = npcregistry.MaxBytes
|
||||
@@ -120,6 +121,9 @@ func (e *Extractor) ReferenceSlots() []contracts.ReferenceSlot {
|
||||
}
|
||||
|
||||
func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
if e == nil {
|
||||
return nil
|
||||
}
|
||||
metadata := map[string]any{
|
||||
"prompt_id": PromptID,
|
||||
"prompt_version": SchemaVersion,
|
||||
@@ -127,6 +131,7 @@ func (e *Extractor) ManifestMetadata() map[string]any {
|
||||
"catalog_base_id": e.effectiveCatalog.BaseID(),
|
||||
"catalog_digest": e.effectiveCatalog.Digest(),
|
||||
"catalog_overlay_ids": e.effectiveCatalog.OverlayIDs(),
|
||||
"mapping_policy": mappingPolicy,
|
||||
"response_schema_key": string(ResponseSchemaKey),
|
||||
"response_schema_id": ResponseSchemaID,
|
||||
"response_schema_name": ResponseSchemaName,
|
||||
@@ -147,6 +152,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
||||
}
|
||||
fingerprints := []pipeline.CheckpointFingerprint{
|
||||
{Name: "effective_catalog", Value: e.effectiveCatalog.Digest()},
|
||||
{Name: "mapping_policy", Value: mappingPolicy},
|
||||
{Name: "prompt", Value: e.promptSHA},
|
||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||
}
|
||||
@@ -162,25 +168,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
||||
if e.llm == nil {
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("LLM client must not be nil")
|
||||
}
|
||||
if ctx == nil {
|
||||
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)
|
||||
sourceInput, err := shared.PrepareChunkExtraction(ctx, req)
|
||||
if err != nil {
|
||||
return contracts.TypedExtractionResult[dnd.SpellList]{}, extractorErrorf("%w", err)
|
||||
}
|
||||
order := shared.NewSourceRefOrder(req.Source)
|
||||
npcRegistry, err := e.npcResolver.Resolve(req.References)
|
||||
if err != nil {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -104,6 +104,9 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
|
||||
}
|
||||
|
||||
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 {
|
||||
t.Fatalf("catalog base metadata = %#v", metadata["catalog_base_id"])
|
||||
}
|
||||
@@ -116,6 +119,7 @@ func TestExtractPromptUsesCanonicalOverlayNamesWithoutAliasesOrMetadata(t *testi
|
||||
fingerprints := newExtractor(t, &fakeSpellsLLMClient{}, overlaySpellCatalogReference()).CheckpointFingerprints()
|
||||
wantFingerprints := map[string]any{
|
||||
"effective_catalog": metadata["catalog_digest"],
|
||||
"mapping_policy": mappingPolicy,
|
||||
"prompt": metadata["prompt_sha256"],
|
||||
"response_schema": metadata["response_schema_sha256"],
|
||||
"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) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}}
|
||||
req := extractionRequest()
|
||||
@@ -234,9 +245,8 @@ func TestExtractWrapsLLMClientError(t *testing.T) {
|
||||
|
||||
func TestExtractRejectsInvalidRequests(t *testing.T) {
|
||||
validReq := extractionRequest()
|
||||
canceledCtx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
validExtractor := newExtractor(t, &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{}}})
|
||||
var nilExtractor *Extractor
|
||||
tests := []struct {
|
||||
name string
|
||||
extractor *Extractor
|
||||
@@ -244,13 +254,9 @@ func TestExtractRejectsInvalidRequests(t *testing.T) {
|
||||
req contracts.TypedExtractionRequest
|
||||
want string
|
||||
}{
|
||||
{name: "nil extractor", ctx: context.Background(), req: validReq, want: "extractor"},
|
||||
{name: "nil context", extractor: validExtractor, req: validReq, want: "context"},
|
||||
{name: "canceled context", extractor: validExtractor, ctx: canceledCtx, req: validReq, want: "context"},
|
||||
{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"},
|
||||
{name: "nil extractor", extractor: nilExtractor, ctx: context.Background(), req: validReq, want: "extractor"},
|
||||
{name: "nil LLM client", extractor: &Extractor{}, ctx: context.Background(), req: validReq, want: "LLM client"},
|
||||
{name: "wrapped preflight failure", extractor: validExtractor, ctx: context.Background(), req: mismatchedSourceInputRequest(validReq), want: "must match chunk"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
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) {
|
||||
client := &fakeSpellsLLMClient{response: extractionResponse{SpellCasts: []spellCastResponse{{
|
||||
Caster: "Aria", Spell: "Cure Wounds",
|
||||
|
||||
@@ -21,24 +21,53 @@ func TestScriptoriumPromptPreparesTranscriptReferencesAndTaskMessages(t *testing
|
||||
if prepared.OutputContract.SchemaPath != "dnd_spells_llm.v1.json" {
|
||||
t.Fatalf("schema path = %q, want LLM-only schema", prepared.OutputContract.SchemaPath)
|
||||
}
|
||||
for _, want := range []string{
|
||||
string(transcript),
|
||||
"Dana: Mira",
|
||||
"Mira: wizard",
|
||||
"Shield: abjuration",
|
||||
`{"spell_names":["Cure Wounds"]}`,
|
||||
`{"npcs":[]}`,
|
||||
for index, want := range []struct {
|
||||
role string
|
||||
cached bool
|
||||
marker string
|
||||
}{
|
||||
{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"},
|
||||
{role: "user", marker: "spell-cast artifacts"},
|
||||
{role: "user", cached: true, marker: "source references must collectively support"},
|
||||
{role: "user"},
|
||||
} {
|
||||
found := false
|
||||
for _, message := range prepared.Messages {
|
||||
if strings.Contains(message.Content, want) {
|
||||
found = true
|
||||
break
|
||||
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 {
|
||||
t.Fatalf("prepared prompt did not render required input %q", want)
|
||||
if len(prepared.Messages) != 9 {
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
req.SourceInput = spellSourceInput()
|
||||
return req
|
||||
|
||||
@@ -14,12 +14,13 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
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"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "dnd/combat-turns"
|
||||
normalizationPolicy = "dnd.combat_turns.normalize.v1"
|
||||
normalizationPolicy = "dnd.combat_turns.normalize.v2"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeActorCanonicalized = "combat_actor_canonicalized"
|
||||
@@ -109,7 +110,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -125,7 +128,7 @@ type actorCanonicalization struct {
|
||||
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 {
|
||||
return dnd.CombatTurnList{}, nil
|
||||
}
|
||||
@@ -133,8 +136,8 @@ func normalizeList(input dnd.CombatTurnList, doc *source.SourceDocument, registr
|
||||
records := make([]normalizedRecord, len(input.CombatTurns))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, inputTurn := range input.CombatTurns {
|
||||
turn, actorChange, refsChanged := normalizeTurn(inputTurn, registry)
|
||||
earliest, hasEvidence := earliestSourcePosition(doc, turn)
|
||||
turn, actorChange, refsChanged := normalizeTurn(inputTurn, order, registry)
|
||||
earliest, hasEvidence := order.EarliestValid(turn.SourceRefs)
|
||||
records[index] = normalizedRecord{
|
||||
turn: turn,
|
||||
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...)
|
||||
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.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}
|
||||
}
|
||||
|
||||
canonicalRefs, _, _ := canonicalizeSourceRefs(input.SourceRefs)
|
||||
output.SourceRefs = canonicalRefs
|
||||
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||
refsChanged := !sourceRefsEqual(input.SourceRefs, output.SourceRefs)
|
||||
return output, actorChange, refsChanged
|
||||
}
|
||||
@@ -225,70 +227,12 @@ func sourceRefsEqual(left, right []source.SourceRef) bool {
|
||||
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 {
|
||||
retainedIndex 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 {
|
||||
return make([]dnd.CombatTurn, 0), nil
|
||||
}
|
||||
@@ -297,7 +241,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
|
||||
groups := make([]duplicateGroup, 0)
|
||||
groupByKey := make(map[string]int)
|
||||
for index, record := range records {
|
||||
key, eligible := duplicateKey(record.turn, doc)
|
||||
key, eligible := duplicateKey(record.turn, documentIndex)
|
||||
if !eligible {
|
||||
keep[index] = true
|
||||
continue
|
||||
@@ -329,12 +273,12 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
|
||||
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 {
|
||||
return "", false
|
||||
}
|
||||
for _, ref := range turn.SourceRefs {
|
||||
if source.ValidateRef(doc, ref) != nil {
|
||||
if documentIndex.ValidateRef(ref) != nil {
|
||||
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) {
|
||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 50}}}
|
||||
input := dnd.CombatTurnList{CombatTurns: []dnd.CombatTurn{
|
||||
|
||||
@@ -118,7 +118,9 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
||||
if !registry.Bound() {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -132,7 +134,7 @@ type nameCanonicalization struct {
|
||||
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 {
|
||||
return dnd.NPCInteractionList{}, nil
|
||||
}
|
||||
@@ -140,7 +142,7 @@ func normalizeList(input dnd.NPCInteractionList, doc *source.SourceDocument, reg
|
||||
records := make([]normalizedRecord, len(input.Interactions))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
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}
|
||||
if nameChange != nil {
|
||||
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 {
|
||||
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 {
|
||||
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...)
|
||||
return dnd.NPCInteractionList{Interactions: output},
|
||||
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)
|
||||
if canonical, ok := registry.Lookup(identity.NormalizeDisplay(input.Name)); ok {
|
||||
output.Name = canonical.Name
|
||||
@@ -189,7 +191,7 @@ func normalizeInteraction(input dnd.NPCInteraction, doc *source.SourceDocument,
|
||||
if input.Name != 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)
|
||||
}
|
||||
|
||||
@@ -206,7 +208,7 @@ type duplicateGroup struct {
|
||||
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 {
|
||||
return make([]dnd.NPCInteraction, 0), nil
|
||||
}
|
||||
@@ -214,7 +216,7 @@ func collapseDuplicates(records []normalizedRecord, doc *source.SourceDocument)
|
||||
groups := make([]duplicateGroup, 0)
|
||||
groupByKey := make(map[string]int)
|
||||
for index, record := range records {
|
||||
if !interactionmodel.ValidSourceRefs(doc, record.interaction.SourceRefs) {
|
||||
if !interactionmodel.ValidSourceRefs(documentIndex, record.interaction.SourceRefs) {
|
||||
keep[index] = true
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
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))
|
||||
if err != nil {
|
||||
t.Fatalf("New() error = %v", err)
|
||||
@@ -30,7 +30,7 @@ func TestNormalizeCanonicalizesAndClones(t *testing.T) {
|
||||
t.Fatalf("Normalize() error = %v", err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
if !hasWarning(result.Warnings, ReasonCodeNameCanonicalized) || !hasWarning(result.Warnings, ReasonCodeSourceRefsNormalized) {
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -14,12 +13,13 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
)
|
||||
|
||||
const (
|
||||
Key = "dnd/npcs"
|
||||
normalizationPolicy = "dnd.npcs.normalize.v1"
|
||||
normalizationPolicy = "dnd.npcs.normalize.v2"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
|
||||
ReasonCodeNPCFieldsNormalized = "npc_fields_normalized"
|
||||
@@ -69,7 +69,8 @@ func (n *Normalizer) Normalize(ctx context.Context, req contracts.TypedNormalize
|
||||
if err := ctx.Err(); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -77,14 +78,14 @@ type normalizedRecord struct {
|
||||
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 {
|
||||
return dnd.NPCList{}, nil
|
||||
}
|
||||
records := make([]normalizedRecord, len(input.NPCs))
|
||||
warnings := make([]contracts.Warning, 0)
|
||||
for index, inputNPC := range input.NPCs {
|
||||
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC)
|
||||
npc, fieldsChanged, referencesChanged := normalizeRecord(inputNPC, order)
|
||||
records[index] = normalizedRecord{npc: npc}
|
||||
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))})
|
||||
@@ -100,7 +101,7 @@ func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
|
||||
groups := canonicalNameGroups(records)
|
||||
output := dnd.NPCList{NPCs: make([]dnd.NPC, 0, len(groups))}
|
||||
for _, members := range groups {
|
||||
consolidated, referencesChanged := consolidate(records, members)
|
||||
consolidated, referencesChanged := consolidate(records, members, order)
|
||||
retainedIndex := members[0]
|
||||
output.NPCs = append(output.NPCs, consolidated)
|
||||
if referencesChanged {
|
||||
@@ -113,10 +114,10 @@ func normalizeList(input dnd.NPCList) (dnd.NPCList, []contracts.Warning) {
|
||||
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.Name = identity.NormalizeDisplay(input.Name)
|
||||
output.SourceRefs, _, _ = canonicalizeSourceRefs(input.SourceRefs)
|
||||
output.SourceRefs = order.Canonicalize(input.SourceRefs)
|
||||
output.ID = identity.DeriveID(output.Name)
|
||||
return output, input.Name != output.Name, !reflect.DeepEqual(input.SourceRefs, output.SourceRefs)
|
||||
}
|
||||
@@ -143,41 +144,17 @@ func canonicalNameGroups(records []normalizedRecord) [][]int {
|
||||
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)
|
||||
originalRefs := cloneSourceRefs(output.SourceRefs)
|
||||
for _, member := range members[1:] {
|
||||
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)
|
||||
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 {
|
||||
if input == 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) {
|
||||
input := dnd.NPCList{NPCs: []dnd.NPC{
|
||||
{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}}
|
||||
}
|
||||
|
||||
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 {
|
||||
for _, warning := range warnings {
|
||||
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")
|
||||
}
|
||||
|
||||
unitPositions := make(map[int]int, len(doc.Units))
|
||||
for index, unit := range doc.Units {
|
||||
unitPositions[unit.ID] = index
|
||||
}
|
||||
documentIndex := source.NewDocumentIndex(doc)
|
||||
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.Summary = strings.TrimSpace(scene.Summary)
|
||||
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 {
|
||||
return dnd.SceneDescriptionList{}, fmt.Errorf("scenes[%d].source_ref: %s", index, diagnostics.Truncate(err.Error()))
|
||||
if err := documentIndex.ValidateRef(scene.SourceRef); err != nil {
|
||||
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 {
|
||||
leftStart := unitPositions[output.Scenes[left].SourceRef.StartUnitID]
|
||||
rightStart := unitPositions[output.Scenes[right].SourceRef.StartUnitID]
|
||||
leftStart, _ := documentIndex.Position(output.Scenes[left].SourceRef.StartUnitID)
|
||||
rightStart, _ := documentIndex.Position(output.Scenes[right].SourceRef.StartUnitID)
|
||||
if leftStart != rightStart {
|
||||
return leftStart < rightStart
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package spells
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -11,11 +10,16 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
spellcatalog "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/spells/catalog"
|
||||
"golang.org/x/text/cases"
|
||||
)
|
||||
|
||||
const Key = "dnd/spells"
|
||||
const (
|
||||
Key = "dnd/spells"
|
||||
normalizationPolicy = "dnd.spells.normalize.v2"
|
||||
NormalizationPolicy = normalizationPolicy
|
||||
)
|
||||
|
||||
const (
|
||||
ReasonCodeSpellNameCanonicalized = "spell_name_canonicalized"
|
||||
@@ -68,6 +72,7 @@ func (n *Normalizer) ManifestMetadata() map[string]any {
|
||||
"catalog_base_id": n.effectiveCatalog.BaseID(),
|
||||
"catalog_digest": n.effectiveCatalog.Digest(),
|
||||
"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 {
|
||||
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) {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
value, warnings := normalizeSpellList(req.MergeOutput.Value, n.effectiveCatalog)
|
||||
value, duplicateWarnings := collapseDuplicateSpellCasts(value, req.Source, n.effectiveCatalog)
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
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...)
|
||||
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
|
||||
if input.SpellCasts == 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
|
||||
if orderChanged || duplicateCount > 0 {
|
||||
warnings = append(warnings, contracts.Warning{
|
||||
@@ -147,42 +157,16 @@ func cloneSpellCast(input dnd.SpellCast) dnd.SpellCast {
|
||||
return output
|
||||
}
|
||||
|
||||
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])
|
||||
})
|
||||
|
||||
func canonicalizeSourceRefs(order shared.SourceRefOrder, input []source.SourceRef) ([]source.SourceRef, bool, int) {
|
||||
orderChanged := false
|
||||
for index := range input {
|
||||
if input[index] != canonical[index] {
|
||||
for index := 1; index < len(input); index++ {
|
||||
if order.Less(input[index], input[index-1]) {
|
||||
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
|
||||
canonical := order.Canonicalize(input)
|
||||
return canonical, orderChanged, len(input) - len(canonical)
|
||||
}
|
||||
|
||||
type duplicateGroup struct {
|
||||
@@ -190,7 +174,7 @@ type duplicateGroup struct {
|
||||
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 {
|
||||
return input, nil
|
||||
}
|
||||
@@ -199,7 +183,7 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
|
||||
groups := make([]duplicateGroup, 0)
|
||||
groupByKey := make(map[string]int)
|
||||
for index, cast := range input.SpellCasts {
|
||||
key, eligible := duplicateKey(cast, doc, catalog)
|
||||
key, eligible := duplicateKey(cast, documentIndex, catalog)
|
||||
if !eligible {
|
||||
keep[index] = true
|
||||
continue
|
||||
@@ -242,13 +226,13 @@ func collapseDuplicateSpellCasts(input dnd.SpellList, doc *source.SourceDocument
|
||||
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)
|
||||
if !resolved || len(cast.SourceRefs) == 0 {
|
||||
return "", false
|
||||
}
|
||||
for _, ref := range cast.SourceRefs {
|
||||
if source.ValidateRef(doc, ref) != nil {
|
||||
if documentIndex.ValidateRef(ref) != nil {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,19 +104,22 @@ func TestIdentityAndMetadataAreDefensive(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if NormalizationPolicy != "dnd.spells.normalize.v2" {
|
||||
t.Fatalf("NormalizationPolicy = %q, want v2 policy", NormalizationPolicy)
|
||||
}
|
||||
|
||||
fingerprints := normalizer.CheckpointFingerprints()
|
||||
if len(fingerprints) != 1 || fingerprints[0].Name != "effective_catalog" || fingerprints[0].Value != normalizer.effectiveCatalog.Digest() {
|
||||
t.Fatalf("fingerprints = %#v, want effective catalog fingerprint", fingerprints)
|
||||
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 catalog and normalization policy fingerprints", fingerprints)
|
||||
}
|
||||
fingerprints[0].Name = "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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
normalizer := newNormalizer(t)
|
||||
input := dnd.SpellList{SpellCasts: []dnd.SpellCast{
|
||||
|
||||
@@ -3,34 +3,15 @@
|
||||
package npcinteractions
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
)
|
||||
|
||||
// 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
|
||||
// representations and values.
|
||||
func SourceRefsEqual(left, right []source.SourceRef) bool {
|
||||
@@ -45,39 +26,10 @@ func SourceRefsEqual(left, right []source.SourceRef) bool {
|
||||
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.
|
||||
func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
|
||||
leftPosition, leftHasEvidence := EarliestSourcePosition(doc, left)
|
||||
rightPosition, rightHasEvidence := EarliestSourcePosition(doc, right)
|
||||
func Less(order shared.SourceRefOrder, left, right dnd.NPCInteraction) bool {
|
||||
leftPosition, leftHasEvidence := order.EarliestValid(left.SourceRefs)
|
||||
rightPosition, rightHasEvidence := order.EarliestValid(right.SourceRefs)
|
||||
if leftHasEvidence != rightHasEvidence {
|
||||
return leftHasEvidence
|
||||
}
|
||||
@@ -95,35 +47,17 @@ func Less(doc *source.SourceDocument, left, right dnd.NPCInteraction) bool {
|
||||
if left.Kind != right.Kind {
|
||||
return left.Kind < right.Kind
|
||||
}
|
||||
return sourceRefsLess(doc, 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
|
||||
return sourceRefsLess(order, left.SourceRefs, right.SourceRefs)
|
||||
}
|
||||
|
||||
// ValidSourceRefs reports whether an interaction has non-empty, valid
|
||||
// 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 {
|
||||
return false
|
||||
}
|
||||
for _, ref := range refs {
|
||||
if source.ValidateRef(doc, ref) != nil {
|
||||
if index.ValidateRef(ref) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -144,12 +78,12 @@ func ExactIdentity(interaction dnd.NPCInteraction) 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++ {
|
||||
if left[index] == right[index] {
|
||||
continue
|
||||
}
|
||||
return SourceRefLess(doc, left[index], right[index])
|
||||
return order.Less(left[index], right[index])
|
||||
}
|
||||
return len(left) < len(right)
|
||||
}
|
||||
|
||||
@@ -7,12 +7,21 @@ import (
|
||||
|
||||
func appendSpellLists(values []dnd.SpellList) (dnd.SpellList, error) {
|
||||
count := 0
|
||||
present := false
|
||||
for _, value := range values {
|
||||
if value.SpellCasts != nil {
|
||||
present = true
|
||||
}
|
||||
count += len(value.SpellCasts)
|
||||
}
|
||||
if !present {
|
||||
return dnd.SpellList{}, nil
|
||||
}
|
||||
combined := dnd.SpellList{SpellCasts: make([]dnd.SpellCast, 0, count)}
|
||||
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
|
||||
}
|
||||
@@ -31,7 +40,9 @@ func appendNPCLists(values []dnd.NPCList) (dnd.NPCList, error) {
|
||||
}
|
||||
combined := dnd.NPCList{NPCs: make([]dnd.NPC, 0, count)}
|
||||
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
|
||||
}
|
||||
@@ -105,6 +116,22 @@ func cloneCombatTurn(value dnd.CombatTurn) dnd.CombatTurn {
|
||||
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 {
|
||||
clone := value
|
||||
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) {
|
||||
|
||||
@@ -7,27 +7,37 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
// CitationResolver resolves cited ranges against one source document.
|
||||
type CitationResolver struct {
|
||||
doc *source.SourceDocument
|
||||
index source.DocumentIndex
|
||||
}
|
||||
|
||||
// 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 CitedText(doc *source.SourceDocument, refs []source.SourceRef) (string, error) {
|
||||
if doc == nil {
|
||||
return "", fmt.Errorf("source document must not be nil")
|
||||
}
|
||||
|
||||
included := make([]bool, len(doc.Units))
|
||||
func (r *CitationResolver) CitedText(refs []source.SourceRef) (string, error) {
|
||||
included := make([]bool, len(r.doc.Units))
|
||||
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)
|
||||
}
|
||||
start, _ := source.UnitIndex(doc, ref.StartUnitID)
|
||||
end, _ := source.UnitIndex(doc, ref.EndUnitID)
|
||||
start, _ := r.index.Position(ref.StartUnitID)
|
||||
end, _ := r.index.Position(ref.EndUnitID)
|
||||
for index := start; index <= end; index++ {
|
||||
included[index] = true
|
||||
}
|
||||
}
|
||||
|
||||
parts := make([]string, 0, len(doc.Units))
|
||||
for index, unit := range doc.Units {
|
||||
parts := make([]string, 0, len(r.doc.Units))
|
||||
for index, unit := range r.doc.Units {
|
||||
if included[index] {
|
||||
parts = append(parts, unit.Text)
|
||||
}
|
||||
|
||||
@@ -7,8 +7,12 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||
)
|
||||
|
||||
func TestCitedText(t *testing.T) {
|
||||
func TestCitationResolver(t *testing.T) {
|
||||
doc := citationDocument()
|
||||
resolver, err := NewCitationResolver(doc)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCitationResolver() error = %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
refs []source.SourceRef
|
||||
@@ -51,7 +55,7 @@ func TestCitedText(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
beforeUnits := append([]source.SourceUnit(nil), doc.Units...)
|
||||
refs := append([]source.SourceRef(nil), test.refs...)
|
||||
got, err := CitedText(doc, refs)
|
||||
got, err := resolver.CitedText(refs)
|
||||
if (err != nil) != 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) {
|
||||
if _, err := CitedText(nil, nil); err == nil {
|
||||
t.Fatal("CitedText() error = nil, want nil-document error")
|
||||
func TestNewCitationResolverRejectsNilDocument(t *testing.T) {
|
||||
if _, err := NewCitationResolver(nil); err == nil {
|
||||
t.Fatal("NewCitationResolver() error = nil, want nil-document error")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,30 @@ package shared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
// ChunkPromptMaterial prepares the chunk-scoped source material used by D&D
|
||||
// extractors when constructing their prompt inputs.
|
||||
func ChunkPromptMaterial(req contracts.TypedExtractionRequest) (contracts.LLMInputMaterial, error) {
|
||||
// PrepareChunkExtraction validates common D&D extraction prerequisites and
|
||||
// prepares owned chunk-scoped source material for prompt inputs.
|
||||
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()
|
||||
if len(material.Content) == 0 {
|
||||
material = contracts.NewLLMInputMaterial("source", req.Chunk.MediaType, req.Chunk.Content, "", "")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,48 +10,78 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||
)
|
||||
|
||||
func TestChunkPromptMaterial(t *testing.T) {
|
||||
chunk := &source.Chunk{
|
||||
ID: "session-alpha:chunk:0",
|
||||
Content: []byte(`{"units":[1,2]}`),
|
||||
MediaType: "application/json",
|
||||
}
|
||||
func TestPrepareChunkExtraction(t *testing.T) {
|
||||
canceled, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
tests := []struct {
|
||||
name string
|
||||
ctx context.Context
|
||||
configure func(*contracts.TypedExtractionRequest)
|
||||
sourceInput contracts.LLMInputMaterial
|
||||
want contracts.LLMInputMaterial
|
||||
wantErr string
|
||||
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",
|
||||
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "", ""),
|
||||
want: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "", ""),
|
||||
mutateOutput: true,
|
||||
},
|
||||
{
|
||||
name: "clone isolation",
|
||||
sourceInput: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
|
||||
want: contracts.NewLLMInputMaterial("source", chunk.MediaType, chunk.Content, "sha256:source", "file:///source.json"),
|
||||
sourceInput: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "sha256:source", "file:///source.json"),
|
||||
want: contracts.NewLLMInputMaterial("source", "application/json", []byte(`{"units":[1,2]}`), "sha256:source", "file:///source.json"),
|
||||
mutateOutput: true,
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
{
|
||||
name: "default fields",
|
||||
sourceInput: contracts.LLMInputMaterial{
|
||||
Content: append([]byte(nil), chunk.Content...),
|
||||
Content: []byte(`{"units":[1,2]}`),
|
||||
Digest: "sha256:source",
|
||||
OriginURI: "file:///source.json",
|
||||
},
|
||||
want: contracts.LLMInputMaterial{
|
||||
Name: "source",
|
||||
MediaType: chunk.MediaType,
|
||||
Content: append([]byte(nil), chunk.Content...),
|
||||
MediaType: "application/json",
|
||||
Content: []byte(`{"units":[1,2]}`),
|
||||
Digest: "sha256:source",
|
||||
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{
|
||||
Name: "transcript",
|
||||
MediaType: "text/plain",
|
||||
Content: append([]byte(nil), chunk.Content...),
|
||||
Content: []byte(`{"units":[1,2]}`),
|
||||
Digest: "sha256:explicit",
|
||||
OriginURI: "file:///explicit.txt",
|
||||
SizeBytes: 42,
|
||||
@@ -66,7 +97,7 @@ func TestChunkPromptMaterial(t *testing.T) {
|
||||
want: contracts.LLMInputMaterial{
|
||||
Name: "transcript",
|
||||
MediaType: "text/plain",
|
||||
Content: append([]byte(nil), chunk.Content...),
|
||||
Content: []byte(`{"units":[1,2]}`),
|
||||
Digest: "sha256:explicit",
|
||||
OriginURI: "file:///explicit.txt",
|
||||
SizeBytes: 42,
|
||||
@@ -76,26 +107,46 @@ func TestChunkPromptMaterial(t *testing.T) {
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
req := contracts.TypedExtractionRequest{Chunk: chunk, SourceInput: test.sourceInput}
|
||||
got, err := ChunkPromptMaterial(req)
|
||||
req := extractionRequest()
|
||||
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 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
|
||||
}
|
||||
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) {
|
||||
t.Fatalf("ChunkPromptMaterial() = %#v, want %#v", got, test.want)
|
||||
t.Fatalf("PrepareChunkExtraction() = %#v, want %#v", got, test.want)
|
||||
}
|
||||
if test.mutateOutput {
|
||||
got.Content[0] = 'x'
|
||||
if string(test.sourceInput.Content) != string(chunk.Content) {
|
||||
t.Fatalf("ChunkPromptMaterial() output shares content with source input")
|
||||
if string(req.SourceInput.Content) != string(test.sourceInput.Content) || string(req.Chunk.Content) != `{"units":[1,2]}` {
|
||||
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())
|
||||
}
|
||||
|
||||
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 {
|
||||
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 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) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestUnitRefUnmarshalRejectsNonIntegerValues(t *testing.T) {
|
||||
func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
|
||||
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 {
|
||||
t.Fatalf("ResolveUnitID() error = %v, want nil", err)
|
||||
}
|
||||
@@ -56,7 +56,7 @@ func TestResolveUnitIDReturnsExistingIntegerSourceUnitID(t *testing.T) {
|
||||
func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
|
||||
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 {
|
||||
t.Fatal("ResolveUnitID() error = nil, want missing source-unit ID")
|
||||
}
|
||||
@@ -65,7 +65,7 @@ func TestResolveUnitIDDoesNotFallbackToOneBasedUnitNumber(t *testing.T) {
|
||||
func TestResolveUnitIDRejectsMissingUnit(t *testing.T) {
|
||||
doc := unitRefSourceDocument(1)
|
||||
|
||||
_, err := ResolveUnitID(doc, "start_unit_id", UnitRefFromInt(9))
|
||||
_, err := ResolveUnitID(source.NewDocumentIndex(doc), "start_unit_id", UnitRefFromInt(9))
|
||||
if err == nil {
|
||||
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 {
|
||||
doc := &source.SourceDocument{
|
||||
ID: "session-alpha",
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcs/identity"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/diagnostics"
|
||||
combatshape "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/validate/combatturns/shape"
|
||||
)
|
||||
@@ -19,7 +20,7 @@ import (
|
||||
const (
|
||||
Key = "normalize/dnd/combat-turns/invariants"
|
||||
ReasonCode = "invalid_combat_turn_normalization"
|
||||
policy = "dnd.combat_turns.validator.normalized.v1"
|
||||
policy = "dnd.combat_turns.validator.normalized.v2"
|
||||
)
|
||||
|
||||
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
|
||||
// shape or source-reference failure is deliberately deferred to its owner.
|
||||
func Validate(doc *source.SourceDocument, value dnd.CombatTurnList) error {
|
||||
if combatshape.Validate(value) != nil || !sourceRefsValid(doc, value) {
|
||||
if combatshape.Validate(value) != 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 {
|
||||
return nil
|
||||
}
|
||||
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)
|
||||
seenIdentity := make(map[string]int)
|
||||
previousPosition := -1
|
||||
@@ -69,14 +74,14 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
||||
for refIndex := 1; refIndex < len(turn.SourceRefs); refIndex++ {
|
||||
previous := turn.SourceRefs[refIndex-1]
|
||||
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))
|
||||
} else if current == previous {
|
||||
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 {
|
||||
continue
|
||||
}
|
||||
@@ -96,10 +101,10 @@ func issuesFor(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
||||
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 _, ref := range turn.SourceRefs {
|
||||
if source.ValidateRef(doc, ref) != nil {
|
||||
if index.ValidateRef(ref) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -107,36 +112,6 @@ func sourceRefsValid(doc *source.SourceDocument, value dnd.CombatTurnList) bool
|
||||
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) {
|
||||
if len(turn.SourceRefs) == 0 {
|
||||
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) {
|
||||
doc := invariantDocument()
|
||||
shapeInvalid := normalizedList()
|
||||
|
||||
@@ -37,7 +37,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if err := combatshape.Validate(req.Value); err != 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 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -48,11 +48,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sourceRefIssues(doc *source.SourceDocument, value dnd.CombatTurnList) []string {
|
||||
func sourceRefIssues(index source.DocumentIndex, value dnd.CombatTurnList) []string {
|
||||
issues := make([]string, 0)
|
||||
for turnIndex, turn := range value.CombatTurns {
|
||||
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())))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,10 +37,14 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if combatshape.Validate(req.Value) != 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))
|
||||
for turnIndex, turn := range req.Value.CombatTurns {
|
||||
citedText, err := shared.CitedText(req.Source, turn.SourceRefs)
|
||||
citedText, err := resolver.CitedText(turn.SourceRefs)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||
interactionmodel "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/npcinteractions"
|
||||
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"
|
||||
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) {
|
||||
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
|
||||
}
|
||||
if v == nil || v.npcResolver == nil {
|
||||
@@ -89,7 +94,7 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if !npcRegistry.Bound() {
|
||||
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 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -100,16 +105,16 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}, nil
|
||||
}
|
||||
|
||||
func allSourceRefsValid(doc *source.SourceDocument, value dnd.NPCInteractionList) bool {
|
||||
func allSourceRefsValid(index source.DocumentIndex, value dnd.NPCInteractionList) bool {
|
||||
for _, interaction := range value.Interactions {
|
||||
if !interactionmodel.ValidSourceRefs(doc, interaction.SourceRefs) {
|
||||
if !interactionmodel.ValidSourceRefs(index, interaction.SourceRefs) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
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)
|
||||
for index, interaction := range value.Interactions {
|
||||
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++ {
|
||||
previous := interaction.SourceRefs[refIndex-1]
|
||||
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))
|
||||
} else if current == previous {
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
|
||||
@@ -41,10 +41,11 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if interactionshape.Validate(req.Value) != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
issues := make([]string, 0)
|
||||
for interactionIndex, interaction := range req.Value.Interactions {
|
||||
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())))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -39,9 +39,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if interactionshape.Validate(req.Value) != 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))
|
||||
for index, interaction := range req.Value.Interactions {
|
||||
citedText, err := shared.CitedText(req.Source, interaction.SourceRefs)
|
||||
citedText, err := resolver.CitedText(interaction.SourceRefs)
|
||||
if err != 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 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
issues := make([]string, 0)
|
||||
for npcIndex, npc := range req.Value.NPCs {
|
||||
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())))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,9 +37,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if err := npcshape.Validate(req.Value); err != 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))
|
||||
for npcIndex, npc := range req.Value.NPCs {
|
||||
citedText, err := shared.CitedText(req.Source, npc.SourceRefs)
|
||||
citedText, err := resolver.CitedText(npc.SourceRefs)
|
||||
if err != 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) {
|
||||
if shape.Validate(req.Value) != nil || !sourceRefsValid(req.Source, req.Value) {
|
||||
if shape.Validate(req.Value) != 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 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
@@ -49,20 +53,20 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
}, nil
|
||||
}
|
||||
|
||||
func sourceRefsValid(doc *source.SourceDocument, value dnd.SceneDescriptionList) bool {
|
||||
func sourceRefsValid(index source.DocumentIndex, value dnd.SceneDescriptionList) bool {
|
||||
for _, scene := range value.Scenes {
|
||||
if source.ValidateRef(doc, scene.SourceRef) != nil {
|
||||
if index.ValidateRef(scene.SourceRef) != nil {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func issuesFor(doc *source.SourceDocument, value dnd.SceneDescriptionList) []string {
|
||||
func issuesFor(index source.DocumentIndex, value dnd.SceneDescriptionList) []string {
|
||||
issues := make([]string, 0)
|
||||
if !sort.SliceIsSorted(value.Scenes, func(left, right int) bool {
|
||||
leftStart, _ := source.UnitIndex(doc, value.Scenes[left].SourceRef.StartUnitID)
|
||||
rightStart, _ := source.UnitIndex(doc, value.Scenes[right].SourceRef.StartUnitID)
|
||||
leftStart, _ := index.Position(value.Scenes[left].SourceRef.StartUnitID)
|
||||
rightStart, _ := index.Position(value.Scenes[right].SourceRef.StartUnitID)
|
||||
if leftStart != rightStart {
|
||||
return leftStart < rightStart
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ func TestValidatorRequiresSceneShapeAndExactlyOneExtractionRecord(t *testing.T)
|
||||
{name: "empty list", value: dnd.SceneDescriptionList{Scenes: []dnd.SceneDescription{}}},
|
||||
{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: "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: "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 {
|
||||
issues = append(issues, "current extraction chunk must not be nil")
|
||||
}
|
||||
for index, scene := range req.Value.Scenes {
|
||||
prefix := fmt.Sprintf("scenes[%d]", index)
|
||||
if err := source.ValidateRef(req.Source, scene.SourceRef); err != nil {
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
for sceneIndex, scene := range req.Value.Scenes {
|
||||
prefix := fmt.Sprintf("scenes[%d]", sceneIndex)
|
||||
if err := index.ValidateRef(scene.SourceRef); err != nil {
|
||||
issues = append(issues, prefix+".source_ref: "+diagnostics.Truncate(err.Error()))
|
||||
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) {
|
||||
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)
|
||||
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 {
|
||||
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 {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
index := source.NewDocumentIndex(req.Source)
|
||||
issues := make([]string, 0)
|
||||
for spellIndex, spell := range req.Value.SpellCasts {
|
||||
for refIndex, ref := range spell.SourceRefs {
|
||||
if err := source.ValidateRef(req.Source, ref); err != nil {
|
||||
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())))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,9 +36,13 @@ func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationReq
|
||||
if err := spellshape.Validate(req.Value); err != 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))
|
||||
for spellIndex, spell := range req.Value.SpellCasts {
|
||||
citedText, err := shared.CitedText(req.Source, spell.SourceRefs)
|
||||
citedText, err := resolver.CitedText(spell.SourceRefs)
|
||||
if err != nil {
|
||||
return contracts.ValidationResult{Approved: true}, nil
|
||||
}
|
||||
|
||||
@@ -40,6 +40,12 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
||||
if err != nil {
|
||||
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{
|
||||
"extract:npcs:dnd/npcs:prompt",
|
||||
"extract:npcs:dnd/npcs:response_schema",
|
||||
@@ -173,6 +179,16 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
||||
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) {
|
||||
t.Helper()
|
||||
for _, reference := range references {
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestRunnerProcessesSeriatimInputWithProductionDNDNPCPipeline(t *testing.T)
|
||||
t.Fatalf("manifest lane = %#v, want NPC production composition", lane)
|
||||
}
|
||||
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)
|
||||
}
|
||||
var npcOutputFile *contracts.OutputFile
|
||||
|
||||
Reference in New Issue
Block a user