Compare commits
7 Commits
516af12916
...
d9b87347b8
| Author | SHA1 | Date | |
|---|---|---|---|
| d9b87347b8 | |||
| 20397ef710 | |||
| fc449863f2 | |||
| 51d62de1f3 | |||
| fc76805075 | |||
| 8e680cf96e | |||
| ece1bca460 |
@@ -14,9 +14,32 @@
|
|||||||
"properties": {
|
"properties": {
|
||||||
"members": {
|
"members": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string"}
|
"items": {"$ref": "#/$defs/selector"}
|
||||||
},
|
},
|
||||||
"canonical": {"type": "string"}
|
"canonical": {"$ref": "#/$defs/selector"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"selector": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["name", "source_refs"],
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "string", "minLength": 1},
|
||||||
|
"source_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["start_unit_id", "end_unit_id"],
|
||||||
|
"properties": {
|
||||||
|
"start_unit_id": {"type": "integer", "minimum": 1},
|
||||||
|
"end_unit_id": {"type": "integer", "minimum": 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,10 @@ in party possession established by the transcript. This is an occurrence history
|
|||||||
not an inventory or ledger: do not calculate balances, resolve item identity
|
not an inventory or ledger: do not calculate balances, resolve item identity
|
||||||
across records, or infer ownership that the transcript does not establish.
|
across records, or infer ownership that the transcript does not establish.
|
||||||
|
|
||||||
For every occurrence, copy the exact `item_id` and `name` pair from the supplied
|
For every occurrence, use the supplied canonical item `name`. Record a stated
|
||||||
item registry. Record a stated quantity as an integer and leave it null when the transcript
|
quantity as an integer and leave it null when the transcript does not state
|
||||||
does not state one. Use a concise observed item name and preserve the stated
|
one. Preserve the stated currency denomination through the selected canonical
|
||||||
currency denomination.
|
registry name.
|
||||||
|
|
||||||
Use `discovered` when the party learns of or encounters an item without
|
Use `discovered` when the party learns of or encounters an item without
|
||||||
establishing possession. Use `acquired` when the party or a party member gains
|
establishing possession. Use `acquired` when the party or a party member gains
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
Use the supplied item registry only to ground each occurrence. Every record
|
Use the supplied item registry only to ground each occurrence. Every record
|
||||||
must copy one registry item's exact `id` and exact `name`; do not invent,
|
must use one registry item's canonical `name`; do not invent, rename, merge,
|
||||||
rename, merge, or infer registry items. The registry is not transcript
|
or infer registry items. The registry is not transcript evidence: cite only the
|
||||||
evidence: cite only the current transcript chunk in `source_refs`.
|
current transcript chunk in `source_refs`.
|
||||||
|
|
||||||
{{ input "item_registry" }}
|
{{ input "item_registry" }}
|
||||||
|
|||||||
@@ -10,9 +10,8 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["item_id", "name", "kind", "quantity", "from", "to", "source_refs"],
|
"required": ["name", "kind", "quantity", "from", "to", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"item_id": {"type": "string"},
|
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
"kind": {"type": "string"},
|
"kind": {"type": "string"},
|
||||||
"quantity": {"type": ["integer", "null"]},
|
"quantity": {"type": ["integer", "null"]},
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ location only when the chunk's context supports that coreference. It must not
|
|||||||
create a registry location, and registry content or provenance must never
|
create a registry location, and registry content or provenance must never
|
||||||
replace current-chunk evidence.
|
replace current-chunk evidence.
|
||||||
|
|
||||||
|
For every occurrence, return the exact selector from the location registry:
|
||||||
|
the canonical `name`, plus an empty `registry_refs` array for a unique name or
|
||||||
|
the complete ordered `registry_refs` array for a repeated name. Registry ranges
|
||||||
|
and context identify the location only; they are not occurrence evidence.
|
||||||
|
|
||||||
For overlapping support, visited outranks planned, recalled, and mentioned;
|
For overlapping support, visited outranks planned, recalled, and mentioned;
|
||||||
planned outranks recalled and mentioned; recalled outranks mentioned. A passage
|
planned outranks recalled and mentioned; recalled outranks mentioned. A passage
|
||||||
may produce multiple records when it independently establishes separate facts,
|
may produce multiple records when it independently establishes separate facts,
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
A normalized location registry is provided below for identity grounding. It may
|
A contextual location registry is provided below for identity grounding. It may
|
||||||
be empty. Each record contains the exact location ID and canonical display name
|
be empty. Every record supplies a canonical display name. A name that appears
|
||||||
to copy when the transcript establishes an occurrence of that place.
|
once is selected with that name and an empty `registry_refs` array. A repeated
|
||||||
|
name is selected only by copying both its name and its complete, ordered
|
||||||
|
`registry_refs` array exactly as supplied.
|
||||||
|
|
||||||
Registry content is context, not occurrence evidence. Do not derive an
|
Registry content is context, not occurrence evidence. Do not derive an
|
||||||
occurrence or a source range from the registry, and do not infer a location
|
occurrence or `source_refs` range from the registry. Do not invent a location
|
||||||
that is absent from it.
|
or selector that is absent from it.
|
||||||
|
|
||||||
{{ input "location_registry" }}
|
{{ input "location_registry" }}
|
||||||
|
|||||||
@@ -10,10 +10,21 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["location_id", "name", "kind", "source_refs"],
|
"required": ["name", "registry_refs", "kind", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"location_id": {"type": "string"},
|
|
||||||
"name": {"type": "string"},
|
"name": {"type": "string"},
|
||||||
|
"registry_refs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["start_unit_id", "end_unit_id"],
|
||||||
|
"properties": {
|
||||||
|
"start_unit_id": {"type": "integer", "minimum": 1},
|
||||||
|
"end_unit_id": {"type": "integer", "minimum": 1}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"kind": {"enum": ["visited", "planned", "recalled", "mentioned"]},
|
"kind": {"enum": ["visited", "planned", "recalled", "mentioned"]},
|
||||||
"source_refs": {
|
"source_refs": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
Extract Dungeons & Dragons NPC occurrences from the supplied
|
Extract Dungeons & Dragons NPC occurrences from the supplied
|
||||||
transcript. Include an occurrence only when the transcript establishes one
|
transcript. Include an occurrence only when the transcript establishes one
|
||||||
supplied NPC, one occurrence kind, and a coherent passage supporting both.
|
supplied NPC, one occurrence kind, and a coherent passage supporting both.
|
||||||
Use the exact `npc_id` and matching `name` pair from the supplied NPC registry;
|
Use the supplied canonical NPC `name`; never invent or substitute a similar
|
||||||
never invent an ID or substitute a similar name.
|
name. Cite current-transcript evidence for every occurrence.
|
||||||
|
|
||||||
Do not summarize, infer relationships, sentiment, factions, motives, aliases,
|
Do not summarize, infer relationships, sentiment, factions, motives, aliases,
|
||||||
or persistent state. Do not identify player characters, anonymous groups, or
|
or persistent state. Do not identify player characters, anonymous groups, or
|
||||||
|
|||||||
@@ -10,11 +10,8 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"required": ["npc_id", "name", "kind", "source_refs"],
|
"required": ["name", "kind", "source_refs"],
|
||||||
"properties": {
|
"properties": {
|
||||||
"npc_id": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"name": {
|
"name": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
Identify only well-supported duplicate groups among the supplied candidates.
|
Identify only well-supported duplicate groups among the supplied candidates.
|
||||||
|
|
||||||
Candidate keys are opaque identifiers. Copy each selected key exactly. A group
|
Return each selected candidate's supplied contextual descriptor exactly: its
|
||||||
must contain at least two supplied keys, and its `canonical` key must be one of
|
`name` and complete ordered `source_refs`. A group must contain at least two
|
||||||
its members. Do not create keys, records, names, source references, evidence,
|
supplied descriptors, and its `canonical` descriptor must be one of its
|
||||||
or replacement values. Omit any uncertain or unsafe group.
|
members. Do not invent names, ranges, records, evidence, or replacement values.
|
||||||
|
Omit any uncertain or unsafe group.
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
# ADR-0012: Resolve opaque entity identifiers deterministically
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
**Date:** 2026-08-08
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Entity IDs in durable Notarius artifacts are application-owned, deterministic
|
||||||
|
identifiers. They are useful to artifact consumers, but their hash-based form
|
||||||
|
does not help a model distinguish entities and would make the model reproduce
|
||||||
|
an opaque implementation detail. A plain name is likewise insufficient where
|
||||||
|
multiple supplied records share that name.
|
||||||
|
|
||||||
|
The LLM boundary must preserve the typed artifact and durable-schema ownership
|
||||||
|
of [ADR-0003](0003-strongly-typed-stage-interfaces.md) and the distinction
|
||||||
|
between disambiguating references and source evidence in
|
||||||
|
[ADR-0009](0009-prefer-minimal-evidence-grounded-extraction-artifacts.md).
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Callers present a model with semantic selections: a canonical name when it is
|
||||||
|
unique in the request, or a contextual descriptor containing the name and
|
||||||
|
source coordinates when that context is needed to distinguish supplied
|
||||||
|
records. The model returns only those supplied selections. The caller resolves
|
||||||
|
each accepted selection against the request-local supplied records and attaches
|
||||||
|
the opaque application ID deterministically.
|
||||||
|
|
||||||
|
Source coordinates are permitted in a selection solely as identity context.
|
||||||
|
They neither establish an occurrence fact nor replace that occurrence's
|
||||||
|
current-transcript evidence. A selector must resolve exactly; unknown,
|
||||||
|
ambiguous, partial, reordered, or otherwise unsafe selections are not mapped.
|
||||||
|
Where an operation requires a complete grounded artifact, that failure rejects
|
||||||
|
the complete artifact rather than accepting a partially mapped result.
|
||||||
|
|
||||||
|
An explicitly scoped request-local short label is permitted only when a
|
||||||
|
contextual descriptor would be impractical and the caller can deterministically
|
||||||
|
map the label within that one request. Such a label is not a durable ID, must
|
||||||
|
not escape the request boundary, and requires a concrete justification in its
|
||||||
|
own module contract.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- Ask the model to return durable IDs. This exposes opaque implementation
|
||||||
|
state, does not improve semantic disambiguation, and makes model output
|
||||||
|
depend on hash formatting.
|
||||||
|
- Select by name alone. This cannot safely distinguish same-name records.
|
||||||
|
- Make request-local labels durable identifiers. This would turn prompt
|
||||||
|
presentation into a public identity contract and create avoidable migration
|
||||||
|
pressure.
|
||||||
|
- Let the model invent identifiers or resolve ambiguity. This makes identity
|
||||||
|
assignment non-deterministic and weakens validation.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Durable integration contracts retain their exact ID/name pairs while models
|
||||||
|
operate on readable contextual selections. Calling modules must own selector
|
||||||
|
construction, exact resolution, ambiguity handling, and conversion into their
|
||||||
|
durable artifact type; PromptKit and its adapter remain transport-only.
|
||||||
|
|
||||||
|
Some ambiguous or invalid proposals are deliberately omitted, retried, or
|
||||||
|
rejected according to the caller's existing failure policy. Internal candidate
|
||||||
|
keys may support deterministic request-local mapping, but they are not
|
||||||
|
model-visible selectors or durable data. This adds local validation work while
|
||||||
|
keeping identity assignment auditable and stable.
|
||||||
@@ -24,12 +24,13 @@ An incompatible shape change requires a new schema version.
|
|||||||
|
|
||||||
Both extraction and normalization require an `item_registry` reference bound to
|
Both extraction and normalization require an `item_registry` reference bound to
|
||||||
an earlier normalized `dnd/item-registry` artifact. The registry is immutable
|
an earlier normalized `dnd/item-registry` artifact. The registry is immutable
|
||||||
for an operation and contributes only its ordered `{id,name}` projection after
|
for an operation and contributes names-only grounding after the shared evidence
|
||||||
the shared evidence message. It is never occurrence evidence.
|
message. Notarius resolves the model's selected name into the unchanged exact
|
||||||
|
durable ID/name pair. It is never occurrence evidence.
|
||||||
|
|
||||||
Each occurrence must use one exact registry ID/name pair. An extraction response
|
Each occurrence must use one exact registry ID/name pair. An extraction response
|
||||||
with an unknown ID or mismatched name is rejected as invalid model output; the
|
with an unknown or ambiguous selected name is rejected as invalid model output;
|
||||||
configured pipeline may retry it and never accepts a partial artifact.
|
the configured pipeline may retry it and never accepts a partial artifact.
|
||||||
Normalization and validation remain defense in depth for artifacts entering
|
Normalization and validation remain defense in depth for artifacts entering
|
||||||
through other boundaries: normalization canonicalizes a recognized name by ID,
|
through other boundaries: normalization canonicalizes a recognized name by ID,
|
||||||
preserves unknown values for the registry validator, and the registry validator
|
preserves unknown values for the registry validator, and the registry validator
|
||||||
|
|||||||
@@ -85,9 +85,10 @@ for later artifacts.
|
|||||||
|
|
||||||
`dnd/item-occurrences` requires one approved item registry through its
|
`dnd/item-occurrences` requires one approved item registry through its
|
||||||
`item_registry` reference slot for both extraction and normalization. Its
|
`item_registry` reference slot for both extraction and normalization. Its
|
||||||
consumer receives only an ordered, source-free `{id,name}` projection; the
|
consumer receives names-only grounding; Notarius resolves the selected name
|
||||||
registry’s source references are never occurrence evidence. Unknown IDs and
|
into the unchanged exact durable ID/name pair. The registry’s source references
|
||||||
mismatched pairs are rejected by the occurrence contract. See the
|
are never occurrence evidence. Unknown or ambiguous selections are rejected by
|
||||||
|
the occurrence contract. See the
|
||||||
[item-occurrence artifact](dnd-item-occurrence-artifacts.md) for that strict
|
[item-occurrence artifact](dnd-item-occurrence-artifacts.md) for that strict
|
||||||
wire contract, [Configuration](../config.md#d-d-reference-slots) for binding
|
wire contract, [Configuration](../config.md#d-d-reference-slots) for binding
|
||||||
rules and validator selection, and the [JSON output contract](json-output.md)
|
rules and validator selection, and the [JSON output contract](json-output.md)
|
||||||
|
|||||||
@@ -73,10 +73,12 @@ complete canonical evidence sequence.
|
|||||||
|
|
||||||
Both extraction and normalization require exactly one `location_registry` reference of
|
Both extraction and normalization require exactly one `location_registry` reference of
|
||||||
kind `dnd/location-registry`, media type `application/json`, and at most 1 MiB. The
|
kind `dnd/location-registry`, media type `application/json`, and at most 1 MiB. The
|
||||||
registry provides identity grounding only: unknown IDs and mismatched ID/name
|
registry provides identity grounding only. The model selects a supplied
|
||||||
pairs are rejected rather than guessed or reassigned. The current transcript is
|
contextual name-and-registry-reference descriptor, and Notarius resolves it
|
||||||
the only evidence source for an occurrence; registry evidence and provenance
|
into the exact durable ID/name pair. Unknown, partial, or ambiguous selections
|
||||||
never become occurrence evidence.
|
are rejected rather than guessed or reassigned. The current transcript is the
|
||||||
|
only evidence source for an occurrence; registry evidence and provenance never
|
||||||
|
become occurrence evidence.
|
||||||
|
|
||||||
See [Configuration](../config.md#d-d-reference-slots) for the selectable slot
|
See [Configuration](../config.md#d-d-reference-slots) for the selectable slot
|
||||||
and generated-handoff compatibility, [D&D module internals](../internal/dnd.md)
|
and generated-handoff compatibility, [D&D module internals](../internal/dnd.md)
|
||||||
|
|||||||
@@ -83,9 +83,11 @@ not evidence for later artifacts.
|
|||||||
## Consumers and publication
|
## Consumers and publication
|
||||||
|
|
||||||
`dnd/location-occurrences` requires one approved location registry through its
|
`dnd/location-occurrences` requires one approved location registry through its
|
||||||
`location_registry` reference slot. Its prompt receives an ordered source-free `{id,
|
`location_registry` reference slot. Its prompt receives contextual selectors
|
||||||
name}` projection and must not treat registry references as occurrence
|
containing a canonical name and registry references; Notarius resolves a
|
||||||
evidence. See the [location-occurrence artifact](dnd-location-occurrence-artifacts.md)
|
selection into the unchanged exact durable ID/name pair. Registry references
|
||||||
|
must not be treated as occurrence evidence. See the
|
||||||
|
[location-occurrence artifact](dnd-location-occurrence-artifacts.md)
|
||||||
for that contract, [Configuration](../config.md#references-and-ordered-handoffs)
|
for that contract, [Configuration](../config.md#references-and-ordered-handoffs)
|
||||||
for binding rules, and the [JSON output contract](json-output.md) for
|
for binding rules, and the [JSON output contract](json-output.md) for
|
||||||
publication.
|
publication.
|
||||||
|
|||||||
@@ -67,10 +67,12 @@ for uncertain classification.
|
|||||||
|
|
||||||
## Identity, evidence, and order
|
## Identity, evidence, and order
|
||||||
|
|
||||||
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md) resolves
|
The required normalized [NPC registry artifact](dnd-npc-registry-artifacts.md)
|
||||||
the exact `{npc_id, name}` pair. Unknown IDs and names that do not match their
|
supplies names-only contextual grounding to the model. Notarius resolves the
|
||||||
ID are rejected; normalization does not repair names by similarity. Registry
|
selected name and writes the exact `{npc_id, name}` pair. An unknown or
|
||||||
references are provenance only and never replace an occurrence's own evidence.
|
ambiguous selection rejects the complete model result; normalization does not
|
||||||
|
repair names by similarity. Registry references are provenance only and never
|
||||||
|
replace an occurrence's own evidence.
|
||||||
The registry may include an identity established by a factual third-party
|
The registry may include an identity established by a factual third-party
|
||||||
mention; that provenance alone does not create a `mentioned` occurrence. Each
|
mention; that provenance alone does not create a `mentioned` occurrence. Each
|
||||||
occurrence remains a separately cited fact in the current transcript.
|
occurrence remains a separately cited fact in the current transcript.
|
||||||
|
|||||||
@@ -82,9 +82,10 @@ with its own cited evidence and category.
|
|||||||
This registry can ground actor or caster names in the [spell](dnd-spell-artifacts.md)
|
This registry can ground actor or caster names in the [spell](dnd-spell-artifacts.md)
|
||||||
and [combat-turn](dnd-combat-turn-artifacts.md) artifacts. It is required to
|
and [combat-turn](dnd-combat-turn-artifacts.md) artifacts. It is required to
|
||||||
resolve the canonical `name` in an [NPC occurrence](dnd-npc-occurrence-artifacts.md).
|
resolve the canonical `name` in an [NPC occurrence](dnd-npc-occurrence-artifacts.md).
|
||||||
Occurrence consumers receive an ordered source-free `{id,name}` projection;
|
Occurrence consumers receive names-only grounding; Notarius resolves the
|
||||||
spells, combat turns, and the [enemy-event artifact](dnd-enemy-event-artifacts.md)
|
selected canonical name and writes the unchanged exact durable ID/name pair.
|
||||||
receive names-only grounding for actor or subject display. None of these
|
Spells, combat turns, and the [enemy-event artifact](dnd-enemy-event-artifacts.md)
|
||||||
|
also receive names-only grounding for actor or subject display. None of these
|
||||||
projections supply later-artifact evidence. [Configuration](../config.md#d-d-reference-slots)
|
projections supply later-artifact evidence. [Configuration](../config.md#d-d-reference-slots)
|
||||||
owns the `npc_registry` binding rules.
|
owns the `npc_registry` binding rules.
|
||||||
The [JSON output contract](json-output.md) defines publication, and
|
The [JSON output contract](json-output.md) defines publication, and
|
||||||
|
|||||||
@@ -127,14 +127,17 @@ combine results from distinct scenes, so it intentionally does not apply that
|
|||||||
rule. Configuration owns the exact validator key and chain position.
|
rule. Configuration owns the exact validator key and chain position.
|
||||||
|
|
||||||
Normalizers are deterministic for spells, combat turns, item occurrences, NPC
|
Normalizers are deterministic for spells, combat turns, item occurrences, NPC
|
||||||
occurrences, scene descriptions, enemy events, and location occurrences. They canonicalize display
|
occurrences, scene descriptions, enemy events, and location occurrences. They
|
||||||
values and evidence, use source-document order for stable output, and issue
|
canonicalize display values and evidence, use source-document order for stable
|
||||||
bounded warnings for changes or collapsed duplicates. The NPC and location
|
output, and issue bounded warnings for changes or collapsed duplicates. NPC,
|
||||||
normalizers are intentional exceptions: each first produces a deterministic
|
item, and location registry normalizers are intentional exceptions: each first
|
||||||
candidate set, then may use a bounded structured-LLM proposal to reconcile
|
produces a deterministic candidate set, then may use a bounded structured-LLM
|
||||||
identity groups. Invalid or unusable proposals retain the deterministic result
|
proposal to reconcile identity groups. The proposal selects supplied
|
||||||
and surface retry or fallback diagnostics; the model does not directly replace
|
descriptors—names with their candidate source references—not durable IDs.
|
||||||
durable records.
|
Request-local candidate keys may support resolution internally, but are never
|
||||||
|
included in model input or output. Colliding descriptors are ineligible, and
|
||||||
|
invalid or unusable proposals retain the deterministic result with retry or
|
||||||
|
fallback diagnostics; the model does not directly replace durable records.
|
||||||
|
|
||||||
## Generated References And Grounding
|
## Generated References And Grounding
|
||||||
|
|
||||||
@@ -144,11 +147,19 @@ producer provenance; consumers resolve the handed-off artifact into an
|
|||||||
immutable, validated projection for each operation. External files are checked
|
immutable, validated projection for each operation. External files are checked
|
||||||
during preparation, while generated artifacts are resolved at the handoff.
|
during preparation, while generated artifacts are resolved at the handoff.
|
||||||
|
|
||||||
NPC, location, and item registries project ordered, source-free `{id, name}`
|
NPC and item registry consumers receive names-only grounding. Location
|
||||||
pairs to their respective occurrence extractors and normalizers. Exact ID/name
|
consumers receive a contextual selector containing the canonical name and the
|
||||||
matching preserves every identity the registry recognizes, including same-name
|
registry references needed to distinguish same-name places. The calling module
|
||||||
locations with distinct source anchors. The NPC registry additionally supplies
|
resolves those supplied selections locally and maps them into the unchanged
|
||||||
names-only actor grounding to spells, combat turns, and enemy events.
|
durable ID/name pair; an unknown or ambiguous selection rejects the complete
|
||||||
|
occurrence result rather than accepting a partial mapping. The NPC registry
|
||||||
|
additionally supplies names-only actor grounding to spells, combat turns, and
|
||||||
|
enemy events.
|
||||||
|
|
||||||
|
Registry references establish a registry identity and may disambiguate a
|
||||||
|
selection, but never become occurrence evidence. Each occurrence keeps its own
|
||||||
|
current-transcript source references, even when it was grounded through the
|
||||||
|
same registry record.
|
||||||
Scene descriptions are eligibility-only projections: they retain current-chunk
|
Scene descriptions are eligibility-only projections: they retain current-chunk
|
||||||
classification data, not scene prose or evidence, and exist to route combat
|
classification data, not scene prose or evidence, and exist to route combat
|
||||||
extraction. Enemy-event extraction also projects combat turns to `actor` and
|
extraction. Enemy-event extraction also projects combat turns to `actor` and
|
||||||
@@ -170,7 +181,7 @@ checkpoint fingerprint.
|
|||||||
| Spells | May use a spell-catalog overlay and optional NPC grounding; the catalog validator supplies domain-specific semantic checks. |
|
| Spells | May use a spell-catalog overlay and optional NPC grounding; the catalog validator supplies domain-specific semantic checks. |
|
||||||
| NPC registry | Establishes transcript-grounded NPC identities, including factual third-party mentions, without assigning occurrence categories. It does not consume an NPC registry, and its normalizer is the LLM-assisted reconciliation exception described above. |
|
| NPC registry | Establishes transcript-grounded NPC identities, including factual third-party mentions, without assigning occurrence categories. It does not consume an NPC registry, and its normalizer is the LLM-assisted reconciliation exception described above. |
|
||||||
| Combat turns | Requires a scene-description artifact. It calls the LLM only for an exact `combat` classification; exact non-combat classifications return an accepted empty result, while missing or mismatched classifications return an empty result with a bounded warning. Optional NPC grounding never becomes evidence. |
|
| Combat turns | Requires a scene-description artifact. It calls the LLM only for an exact `combat` classification; exact non-combat classifications return an accepted empty result, while missing or mismatched classifications return an empty result with a bounded warning. Optional NPC grounding never becomes evidence. |
|
||||||
| Item occurrences | Requires the normalized item registry for exact ID/name grounding at extraction and normalization. Campaign context may disambiguate, but the registry never becomes occurrence evidence. |
|
| Item occurrences | Requires the normalized item registry for exact deterministic grounding at extraction and normalization. Campaign context may disambiguate, but the registry never becomes occurrence evidence. |
|
||||||
| Item registry | Produces source-grounded item types and unique designations. Its LLM-assisted reconciliation is proposal-only, preserves distinct currency denominations and item types, and does not create per-instance identities. |
|
| Item registry | Produces source-grounded item types and unique designations. Its LLM-assisted reconciliation is proposal-only, preserves distinct currency denominations and item types, and does not create per-instance identities. |
|
||||||
| NPC occurrences | Requires the normalized NPC registry at extraction and normalization, using it for canonical actor grounding only. It separately emits cited current-transcript occurrence facts, including `mentioned`, rather than deriving them from registry provenance. |
|
| NPC occurrences | Requires the normalized NPC registry at extraction and normalization, using it for canonical actor grounding only. It separately emits cited current-transcript occurrence facts, including `mentioned`, rather than deriving them from registry provenance. |
|
||||||
| Scene descriptions | Produces the classifications consumed by combat routing; it does not consume an NPC registry or provide evidence for combat artifacts. |
|
| Scene descriptions | Produces the classifications consumed by combat routing; it does not consume an NPC registry or provide evidence for combat artifacts. |
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ adapter does not own source evidence, artifact conversion, normalization, or
|
|||||||
durable schemas. Those responsibilities remain with the module and its
|
durable schemas. Those responsibilities remain with the module and its
|
||||||
[integration contract](../integrations/).
|
[integration contract](../integrations/).
|
||||||
|
|
||||||
|
The calling module also resolves contextual entity selections and attaches any
|
||||||
|
application identity; PromptKit and this adapter do not own entity identity.
|
||||||
|
|
||||||
`PromptKitClient` validates the request target and prompt identity, maps each
|
`PromptKitClient` validates the request target and prompt identity, maps each
|
||||||
named material to a PromptKit inline artifact while preserving its origin URI,
|
named material to a PromptKit inline artifact while preserving its origin URI,
|
||||||
passes the supplied request session through to PromptKit's direct per-run
|
passes the supplied request session through to PromptKit's direct per-run
|
||||||
|
|||||||
@@ -183,6 +183,12 @@ The caller of the LLM owns prompt selection, prompt inputs, response schema,
|
|||||||
and interpretation of structured output. Provider adapters do not own source-
|
and interpretation of structured output. Provider adapters do not own source-
|
||||||
or domain-specific prompt logic.
|
or domain-specific prompt logic.
|
||||||
|
|
||||||
|
When a model selects an application entity, callers must supply a contextual
|
||||||
|
selection and deterministically attach the opaque application identity whenever
|
||||||
|
the selection resolves exactly. Models do not receive or reproduce opaque
|
||||||
|
application identifiers; [ADR-0012](../adr/0012-resolve-opaque-entity-identifiers-deterministically.md)
|
||||||
|
records the rationale and limited request-local-label exception.
|
||||||
|
|
||||||
LLM calls and other external operations accept cancellation and respect
|
LLM calls and other external operations accept cancellation and respect
|
||||||
timeouts. Concurrency control belongs in shared runtime plumbing rather than in
|
timeouts. Concurrency control belongs in shared runtime plumbing rather than in
|
||||||
individual modules.
|
individual modules.
|
||||||
|
|||||||
350
docs/roadmap/contextual-entity-grounding.md
Normal file
350
docs/roadmap/contextual-entity-grounding.md
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
# Contextual Entity Grounding
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
Notarius should use an LLM for semantic interpretation of source evidence, not
|
||||||
|
for referential-integrity work that deterministic code can perform more
|
||||||
|
reliably. D&D prompts must therefore stop requiring models to reproduce opaque
|
||||||
|
machine identifiers such as hash-derived entity IDs. Models should identify
|
||||||
|
entities through human-readable, evidence-grounded context, after which
|
||||||
|
Notarius resolves the selection and attaches the canonical durable identity.
|
||||||
|
|
||||||
|
This roadmap defines the policy, affected D&D prompt families, and intended
|
||||||
|
end state. The ordered work needed to reach that state is maintained in
|
||||||
|
[Implementation Plan](implementation.md).
|
||||||
|
|
||||||
|
## User Intent
|
||||||
|
|
||||||
|
The change has two goals:
|
||||||
|
|
||||||
|
- prevent otherwise useful model responses from failing because a long,
|
||||||
|
non-semantic string was copied incorrectly; and
|
||||||
|
- avoid spending prompt space and model effort on exact-copy work that provides
|
||||||
|
no semantic value.
|
||||||
|
|
||||||
|
The policy is not a ban on identifiers. Durable artifacts may continue to use
|
||||||
|
application-owned IDs, and prompts may continue to request source-unit ranges
|
||||||
|
that locate evidence. The policy governs which identity work is assigned to
|
||||||
|
the model.
|
||||||
|
|
||||||
|
## Policy
|
||||||
|
|
||||||
|
An LLM-facing prompt input or private response schema must not require a model
|
||||||
|
to reproduce an opaque machine identifier when Notarius can establish the same
|
||||||
|
association deterministically.
|
||||||
|
|
||||||
|
Opaque machine identifiers include cryptographic hashes, UUIDs, digests,
|
||||||
|
database keys, durable entity IDs, and other tokens whose characters do not
|
||||||
|
carry source-grounded meaning for the model. These values may remain in
|
||||||
|
application state, provenance, diagnostics, checkpoints, and durable artifact
|
||||||
|
contracts, but should be omitted from model-visible material when they do not
|
||||||
|
help the model make a semantic decision.
|
||||||
|
|
||||||
|
The intended responsibility boundary is:
|
||||||
|
|
||||||
|
- the model decides which contextual entity is supported by the supplied
|
||||||
|
evidence and returns the bounded semantic facts requested by the module;
|
||||||
|
- the calling module validates that the contextual selection resolves to
|
||||||
|
exactly one supplied candidate;
|
||||||
|
- deterministic code supplies the canonical display value and durable entity
|
||||||
|
ID; and
|
||||||
|
- existing validators continue to enforce referential integrity at later
|
||||||
|
artifact boundaries.
|
||||||
|
|
||||||
|
Transcript `start_unit_id` and `end_unit_id` values are permitted. They are
|
||||||
|
contextual source coordinates and form part of the evidence contract rather
|
||||||
|
than arbitrary identity tokens. Prompt IDs, schema IDs, fingerprints, session
|
||||||
|
IDs, and digests may also remain in runtime metadata that the model is not
|
||||||
|
asked to reproduce.
|
||||||
|
|
||||||
|
Short request-local labels are a narrowly permitted fallback only when a
|
||||||
|
contextual selector cannot uniquely represent the available choices without
|
||||||
|
unreasonable prompt cost. Such a label must be compact, scoped to one request,
|
||||||
|
validated against the supplied candidate set, and never reused as a durable
|
||||||
|
identity. Current D&D occurrence and reconciliation prompts should be designed
|
||||||
|
without this exception; adopting it later requires a concrete demonstrated
|
||||||
|
need and documented rationale.
|
||||||
|
|
||||||
|
## Current State
|
||||||
|
|
||||||
|
The initial NPC, item, and location registry extractors already follow the
|
||||||
|
desired pattern: the model returns contextual names and evidence, and Notarius
|
||||||
|
derives durable IDs afterward. Spells, combat turns, and enemy events use
|
||||||
|
contextual actor names rather than requiring hash-derived NPC IDs.
|
||||||
|
|
||||||
|
Two current prompt families diverge from that pattern:
|
||||||
|
|
||||||
|
1. `dnd/npc-occurrences`, `dnd/item-occurrences`, and
|
||||||
|
`dnd/location-occurrences` place durable registry IDs in model-visible
|
||||||
|
projections and require the private LLM response to repeat those IDs.
|
||||||
|
2. NPC-, item-, and location-registry normalization use the shared entity
|
||||||
|
reconciliation prompt, which labels candidates with opaque
|
||||||
|
`candidate-000001`-style keys and requires the model to copy those keys into
|
||||||
|
duplicate-group proposals.
|
||||||
|
|
||||||
|
The durable occurrence artifacts correctly retain canonical entity IDs. The
|
||||||
|
problem is the private model transport contract, not the published artifact
|
||||||
|
contract.
|
||||||
|
|
||||||
|
## Target Architecture
|
||||||
|
|
||||||
|
### Model proposals and durable artifacts
|
||||||
|
|
||||||
|
Private LLM response types must express contextual semantic proposals rather
|
||||||
|
than reuse the durable artifact type when that type contains an opaque entity
|
||||||
|
ID. The extractor maps a validated private response into the existing durable
|
||||||
|
artifact only after identity resolution succeeds.
|
||||||
|
|
||||||
|
No affected durable artifact kind, media type, schema ID, schema version, or
|
||||||
|
JSON field changes as part of this work. NPC, item, and location occurrence
|
||||||
|
artifacts continue to publish their exact canonical ID/name pair. Registry
|
||||||
|
artifacts likewise retain their IDs and evidence.
|
||||||
|
|
||||||
|
The private schemas and prompt declarations may remain at their current `v1`
|
||||||
|
identities because Notarius is pre-release and these are not external
|
||||||
|
contracts. Their content hashes, mapping-policy fingerprints, and affected
|
||||||
|
prompt fingerprints must change so incompatible checkpoints are not reused.
|
||||||
|
|
||||||
|
### NPC occurrence grounding
|
||||||
|
|
||||||
|
The NPC occurrence prompt receives an ordered names-only projection of the
|
||||||
|
normalized NPC registry. Its private response contains the canonical NPC name,
|
||||||
|
occurrence kind, and current-transcript source ranges, but no `npc_id`.
|
||||||
|
|
||||||
|
The extractor resolves the returned name under the existing NPC comparison
|
||||||
|
policy. Resolution must produce exactly one registry entry. It then writes that
|
||||||
|
entry's canonical display name and durable ID into the `dnd.NPCOccurrence`.
|
||||||
|
An unknown or ambiguous selection invalidates the extraction operation; the
|
||||||
|
extractor must not guess, use fuzzy matching, silently omit the record, or
|
||||||
|
accept a partial response.
|
||||||
|
|
||||||
|
### Item occurrence grounding
|
||||||
|
|
||||||
|
The item occurrence prompt receives an ordered names-only projection of the
|
||||||
|
normalized item registry. Its private response contains the canonical item
|
||||||
|
name, occurrence kind, kind-specific fields, and current-transcript source
|
||||||
|
ranges, but no `item_id`.
|
||||||
|
|
||||||
|
The extractor resolves the returned name under the existing item comparison
|
||||||
|
and identity policies. Resolution must produce exactly one registry entry,
|
||||||
|
whose canonical name and durable ID are attached deterministically. Unknown or
|
||||||
|
ambiguous selections invalidate the complete extraction operation rather than
|
||||||
|
being guessed, repaired by similarity, or dropped.
|
||||||
|
|
||||||
|
### Location occurrence grounding
|
||||||
|
|
||||||
|
Location identity cannot always be resolved from a display name alone: the
|
||||||
|
current registry intentionally permits same-name locations with distinct
|
||||||
|
source anchors. The location occurrence prompt must therefore receive a
|
||||||
|
contextual registry descriptor that contains the canonical display name plus
|
||||||
|
the minimum source-grounded registry evidence needed to distinguish same-name
|
||||||
|
records. It must not contain the durable `location:sha256:...` value.
|
||||||
|
|
||||||
|
The private response uses two required selector fields: `name` and
|
||||||
|
`registry_refs`. For a comparison-unique canonical name, `registry_refs` is an
|
||||||
|
empty array and Notarius resolves the name under the location comparison
|
||||||
|
policy. For a name shared by multiple registry records, `registry_refs`
|
||||||
|
contains that record's complete canonically ordered registry ranges as
|
||||||
|
`start_unit_id` and `end_unit_id` pairs, without `source_id`.
|
||||||
|
|
||||||
|
Every model-facing registry entry uses one fixed shape with required `name`,
|
||||||
|
`registry_refs`, and `context` fields. `context` is an array of strict objects
|
||||||
|
containing only `unit_id` and `text`. Comparison-unique entries use empty
|
||||||
|
`registry_refs` and `context` arrays. Same-name entries use the complete
|
||||||
|
registry-range selector and the bounded context described below. The model
|
||||||
|
returns only `name` and `registry_refs`; it does not reproduce `context`.
|
||||||
|
|
||||||
|
For same-name groups, the projection also supplies bounded transcript units
|
||||||
|
covered by each record's registry ranges so the model receives meaningful
|
||||||
|
identity context rather than coordinates alone. Those ranges must resolve
|
||||||
|
against the current source document, and the resulting contextual selectors
|
||||||
|
must be unique. An invalid range or selector collision prevents the LLM call
|
||||||
|
and fails the operation. Unique-name entries do not repeat registry ranges or
|
||||||
|
context in the selector, preserving compatibility with a valid registry from
|
||||||
|
another source when the name alone is unambiguous.
|
||||||
|
|
||||||
|
The private response separately supplies current-transcript `source_refs` that
|
||||||
|
prove the occurrence. Registry identity evidence and occurrence evidence must
|
||||||
|
remain different fields and must never be merged. The model should omit an
|
||||||
|
occurrence when the transcript does not support choosing among same-name
|
||||||
|
locations. If a returned selector does not resolve to exactly one supplied
|
||||||
|
registry record, the extractor invalidates the complete operation rather than
|
||||||
|
guessing.
|
||||||
|
|
||||||
|
### Registry reconciliation
|
||||||
|
|
||||||
|
The shared entity-reconciliation input replaces opaque candidate keys with
|
||||||
|
contextual candidate descriptors. At minimum, a descriptor contains the
|
||||||
|
candidate's display name and its canonically ordered source-reference ranges;
|
||||||
|
the existing transcript windows remain available for semantic judgment.
|
||||||
|
|
||||||
|
Duplicate-group members and the canonical member in the private response use
|
||||||
|
the same contextual descriptor shape. The shared reconciliation helper maps
|
||||||
|
each descriptor back to exactly one internal candidate before assessing the
|
||||||
|
proposal. Exact deterministic duplicates should already be removed before the
|
||||||
|
LLM call; any remaining descriptor collision makes the affected candidate
|
||||||
|
ineligible for model-assisted reconciliation rather than authorizing an
|
||||||
|
arbitrary choice.
|
||||||
|
|
||||||
|
Existing safety behavior remains in force: groups must contain at least two
|
||||||
|
supplied candidates, the canonical candidate must be a member, groups must not
|
||||||
|
overlap, and domain-specific eligibility rules remain authoritative. Invalid,
|
||||||
|
ambiguous, or unsafe groups are discarded through the existing bounded
|
||||||
|
fallback and diagnostic behavior. The model never directly mutates the
|
||||||
|
durable registry.
|
||||||
|
|
||||||
|
The shared private reconciliation schema and helper must remain domain-neutral
|
||||||
|
within the D&D family. NPC-, item-, and location-specific duplicate policy
|
||||||
|
continues to live in the owning normalizer.
|
||||||
|
|
||||||
|
## Prompt And Asset Changes
|
||||||
|
|
||||||
|
The following LLM-facing assets are in scope:
|
||||||
|
|
||||||
|
- the prompt instructions, registry input fragments, and private response
|
||||||
|
schemas for NPC, item, and location occurrences;
|
||||||
|
- the prompt manifests where input shape or selected fragments change;
|
||||||
|
- the shared D&D entity-reconciliation fragment and private response schema;
|
||||||
|
and
|
||||||
|
- the NPC-, item-, and location-registry normalization prompt inputs that use
|
||||||
|
the shared reconciliation contract.
|
||||||
|
|
||||||
|
Affected projections must exclude durable entity IDs rather than merely stop
|
||||||
|
mentioning them in prose. Prompt instructions should describe the contextual
|
||||||
|
selection rule once at the narrowest owning asset and must preserve the current
|
||||||
|
distinction between registry grounding and transcript evidence.
|
||||||
|
|
||||||
|
Prompt ordering and cache controls should remain unchanged unless the new
|
||||||
|
contextual input requires an intentional manifest change. Unrelated shared
|
||||||
|
prompt bytes should not be edited. Prompt and schema fingerprints should
|
||||||
|
invalidate only the operations whose selected assets or mapping semantics
|
||||||
|
changed.
|
||||||
|
|
||||||
|
## Code And Validation Changes
|
||||||
|
|
||||||
|
The occurrence extractors need private response types and deterministic
|
||||||
|
registry-resolution paths appropriate to their domain. Shared code is
|
||||||
|
appropriate only for demonstrated mechanics that have identical semantics;
|
||||||
|
NPC, item, and location ambiguity policies must not be forced behind a generic
|
||||||
|
resolver merely to reduce line count.
|
||||||
|
|
||||||
|
Registry projections should expose explicit model-facing methods whose names
|
||||||
|
describe whether they are names-only or contextual identity projections. The
|
||||||
|
existing ID/name projections may remain only for deterministic consumers that
|
||||||
|
genuinely require them; they must no longer be wired to an LLM input.
|
||||||
|
|
||||||
|
Mapping-policy and normalization-policy identifiers must be reviewed and
|
||||||
|
advanced wherever their semantics change. Checkpoint fingerprints must cover
|
||||||
|
the new projection content, private schema, prompt assets, and mapping policy,
|
||||||
|
while continuing to exclude irrelevant internal implementation details.
|
||||||
|
|
||||||
|
Durable occurrence normalizers and registry validators remain defense in
|
||||||
|
depth. They continue to validate exact ID/name pairs on artifacts entering
|
||||||
|
through checkpoints, codecs, or other boundaries even though the LLM no longer
|
||||||
|
produces the ID directly.
|
||||||
|
|
||||||
|
## Testing And Evaluation
|
||||||
|
|
||||||
|
Tests should protect the behavioral boundary rather than prompt prose or
|
||||||
|
private helper structure. The completed work should demonstrate that:
|
||||||
|
|
||||||
|
- affected model-facing registry projections do not contain durable entity
|
||||||
|
IDs;
|
||||||
|
- private occurrence schemas reject opaque ID fields and accept the intended
|
||||||
|
contextual shape;
|
||||||
|
- valid contextual selections map to the exact canonical durable ID/name pair;
|
||||||
|
- unknown, mismatched, and ambiguous selections fail without fuzzy matching,
|
||||||
|
partial acceptance, or arbitrary reassignment;
|
||||||
|
- same-name locations remain distinguishable through contextual evidence;
|
||||||
|
- reconciliation preserves equal-name candidates, resolves valid contextual
|
||||||
|
groups, and discards ambiguous or unsafe proposals;
|
||||||
|
- registry evidence never becomes occurrence evidence;
|
||||||
|
- durable codec, normalization, and validator behavior remains compatible; and
|
||||||
|
- representative assembled D&D pipelines still prepare and execute with fake
|
||||||
|
structured-LLM responses.
|
||||||
|
|
||||||
|
Do not add repository-wide prompt-prose snapshots, exact-message-count tests,
|
||||||
|
or a change-detector test that merely scans for today's field names. Focused
|
||||||
|
projection, schema, mapping, fallback, and integration tests are the stable
|
||||||
|
owners of these risks. Model-quality evaluation with representative
|
||||||
|
transcripts remains a manual development aid rather than an offline test gate.
|
||||||
|
|
||||||
|
## Documentation And Architectural Record
|
||||||
|
|
||||||
|
This policy is durable and applies to future modules, so it warrants
|
||||||
|
`docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md`, which
|
||||||
|
records:
|
||||||
|
|
||||||
|
- the semantic-proposal versus referential-integrity boundary;
|
||||||
|
- why durable opaque IDs are excluded from model response contracts;
|
||||||
|
- why contextual evidence coordinates remain permitted;
|
||||||
|
- the narrowly scoped request-local-label exception;
|
||||||
|
- alternatives including durable IDs, names-only matching, and short opaque
|
||||||
|
handles; and
|
||||||
|
- the consequences for private schemas, deterministic resolution, debugging,
|
||||||
|
and ambiguous identities.
|
||||||
|
|
||||||
|
`docs/policy/architecture.md` states the general LLM boundary invariant and
|
||||||
|
links to the ADR. `docs/internal/dnd.md` describes the concrete occurrence
|
||||||
|
projections, contextual reconciliation selectors, resolution and failure
|
||||||
|
behavior, and the continued separation of registry grounding from occurrence
|
||||||
|
evidence. `docs/internal/llm.md` contains only a short clarification that
|
||||||
|
caller-owned modules, not PromptKit or the transport adapter, resolve
|
||||||
|
contextual model selections into application identities.
|
||||||
|
|
||||||
|
The NPC, item, and location occurrence and registry integration documents must
|
||||||
|
continue to own their durable wire contracts, while removing current claims
|
||||||
|
that the model-facing consumer projection contains `{id,name}` or that the raw
|
||||||
|
LLM response supplies the durable ID. They should instead explain that
|
||||||
|
Notarius resolves contextual model output and publishes the same exact durable
|
||||||
|
ID/name pair. No public schema examples need to remove those IDs.
|
||||||
|
|
||||||
|
The generic LLM-assisted deduplication entry in `docs/roadmap/future.md` must be
|
||||||
|
reconciled with this policy: stable IDs may exist inside deterministic state,
|
||||||
|
but a future model-facing proposal should use contextual selectors or a
|
||||||
|
documented request-local-label exception rather than durable IDs.
|
||||||
|
|
||||||
|
## Compatibility And Operational Effects
|
||||||
|
|
||||||
|
This work intentionally changes private prompt inputs, private structured
|
||||||
|
responses, and mapping semantics. It will invalidate affected checkpoints
|
||||||
|
through existing prompt, schema, projection, and policy fingerprints. No
|
||||||
|
manual checkpoint migration is required.
|
||||||
|
|
||||||
|
Durable D&D artifacts and generated-reference compatibility remain unchanged.
|
||||||
|
Operators do not receive new configuration fields or CLI controls. The feature
|
||||||
|
does not change PromptKit, provider routing, profile selection, retries,
|
||||||
|
concurrency, or public output placement.
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
This work does not:
|
||||||
|
|
||||||
|
- remove canonical IDs from durable registries or occurrence artifacts;
|
||||||
|
- change occurrence categories, evidence rules, or registry identity policy;
|
||||||
|
- add fuzzy, probabilistic, or embedding-based entity resolution;
|
||||||
|
- allow registry provenance to substitute for occurrence evidence;
|
||||||
|
- introduce a general entity graph or cross-artifact identity framework;
|
||||||
|
- redesign unrelated D&D prompts or their schemas;
|
||||||
|
- implement the future generic deduplication normalizer; or
|
||||||
|
- add provider-specific prompt behavior.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
The target state is complete when:
|
||||||
|
|
||||||
|
- no maintained D&D prompt requires a model to reproduce a durable opaque
|
||||||
|
entity ID;
|
||||||
|
- current D&D reconciliation prompts no longer require opaque candidate keys;
|
||||||
|
- NPC, item, and location occurrence LLM outputs are resolved
|
||||||
|
deterministically into their unchanged durable artifacts;
|
||||||
|
- same-name location and reconciliation cases remain safe and unambiguous;
|
||||||
|
- invalid contextual selections preserve the existing extraction-failure or
|
||||||
|
normalization-fallback semantics appropriate to their stage;
|
||||||
|
- affected checkpoint identities change without altering public schema
|
||||||
|
versions;
|
||||||
|
- focused and repository-wide tests pass offline;
|
||||||
|
- the ADR, architecture invariant, D&D internal guide, LLM internal guide,
|
||||||
|
relevant integration contracts, and future roadmap accurately describe
|
||||||
|
their canonical portions of the implemented policy; and
|
||||||
|
- no unrelated code, prompt behavior, or public contract changes are included.
|
||||||
@@ -29,13 +29,15 @@ not as committed release dates.
|
|||||||
- Add a reusable normalizer that asks an LLM to identify duplicate sets in a
|
- Add a reusable normalizer that asks an LLM to identify duplicate sets in a
|
||||||
list and propose one replacement element for each set.
|
list and propose one replacement element for each set.
|
||||||
- Define the minimum domain-neutral input contract, initially an ordered list
|
- Define the minimum domain-neutral input contract, initially an ordered list
|
||||||
whose elements have stable unique IDs. Artifact-kind registrations or
|
whose elements retain stable unique IDs as internal deterministic state.
|
||||||
adapters may expose that structure without moving domain rules into the
|
Model proposals use contextual descriptors, or a specifically justified
|
||||||
generic package.
|
request-local short label, rather than durable IDs. Artifact-kind
|
||||||
|
registrations or adapters may expose that structure without moving domain
|
||||||
|
rules into the generic package.
|
||||||
- Keep mutation deterministic: parse and validate the model's duplicate groups,
|
- Keep mutation deterministic: parse and validate the model's duplicate groups,
|
||||||
require every referenced ID to exist, reject overlapping or malformed groups,
|
resolve every supplied descriptor or local label exactly, reject overlapping
|
||||||
prevent unrelated insertion or deletion, and apply only approved replacement
|
or malformed groups, prevent unrelated insertion or deletion, and apply only
|
||||||
operations in code.
|
approved replacement operations in code.
|
||||||
- Preserve provenance needed for audit and downstream validation, and emit
|
- Preserve provenance needed for audit and downstream validation, and emit
|
||||||
warnings describing every collapsed group.
|
warnings describing every collapsed group.
|
||||||
- Evaluate batching and context-window limits before applying the normalizer to
|
- Evaluate batching and context-window limits before applying the normalizer to
|
||||||
|
|||||||
612
docs/roadmap/implementation.md
Normal file
612
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,612 @@
|
|||||||
|
# Contextual Entity Grounding Implementation Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
|
||||||
|
Implement [Contextual Entity Grounding](contextual-entity-grounding.md) so D&D
|
||||||
|
LLM prompts return evidence-grounded contextual selectors while Notarius owns
|
||||||
|
canonical entity IDs and referential integrity. Preserve every durable D&D
|
||||||
|
artifact contract and remove opaque IDs only from model-visible inputs and
|
||||||
|
private model responses.
|
||||||
|
|
||||||
|
This plan is written for a gpt-5.6-terra coding agent. Implement the stages in
|
||||||
|
numeric order. Each stage is intentionally scoped to one implementation prompt
|
||||||
|
and must leave the repository buildable and its focused tests passing before
|
||||||
|
the next stage begins.
|
||||||
|
|
||||||
|
## Plan-Wide Decisions
|
||||||
|
|
||||||
|
Apply these decisions throughout every stage:
|
||||||
|
|
||||||
|
- Read `docs/development.md`, all files under `docs/policy/`, the feature
|
||||||
|
roadmap, and the focused implementation/tests named by the stage before
|
||||||
|
editing.
|
||||||
|
- Preserve the fixed pipeline, typed artifact boundaries, root `assets`
|
||||||
|
content-only rule, module ownership, PromptKit boundary, and evidence rules.
|
||||||
|
- Do not change the durable NPC-, item-, location-registry, or occurrence Go
|
||||||
|
types, JSON schemas, schema IDs, schema versions, media types, reference-slot
|
||||||
|
contracts, categories, or generated-reference compatibility.
|
||||||
|
- Keep the affected prompt and private response-schema identities at `v1`.
|
||||||
|
They are private pre-release transport contracts; their changed content
|
||||||
|
hashes provide the required compatibility boundary.
|
||||||
|
- Advance semantic policy identifiers exactly as directed in each stage. Do
|
||||||
|
not bump unrelated policy identifiers.
|
||||||
|
- A contextual name match uses the entity family's existing comparison policy,
|
||||||
|
never fuzzy matching. A model selection must resolve to exactly one supplied
|
||||||
|
record before a durable ID is attached.
|
||||||
|
- An invalid NPC, item, or location selection invalidates the complete
|
||||||
|
extraction operation. Do not silently drop one response record, accept a
|
||||||
|
partial artifact, or defer a known mapping failure to a later validator.
|
||||||
|
- Registry provenance remains grounding only. Only the current extraction
|
||||||
|
chunk's `source_refs` become occurrence evidence.
|
||||||
|
- Preserve prompt message order and cache controls unless a stage explicitly
|
||||||
|
directs otherwise. Edit only the selected module or shared assets; do not
|
||||||
|
rewrite unrelated shared prompt bytes.
|
||||||
|
- Preserve internal opaque IDs where deterministic code needs them. The rule
|
||||||
|
applies to material shown to the model or requested from it, not to maps,
|
||||||
|
fingerprints, checkpoints, diagnostics, or durable artifacts.
|
||||||
|
- Follow `docs/policy/testing.md`: test package-level behavior and meaningful
|
||||||
|
failure modes, not prompt prose, exact message counts, private helper
|
||||||
|
structure, or a repository-wide string-scanning change detector. All tests
|
||||||
|
remain deterministic, offline, and credential-free.
|
||||||
|
- Use `apply_patch` for edits, `gofmt` changed Go files, and preserve unrelated
|
||||||
|
worktree changes.
|
||||||
|
|
||||||
|
## Final Private Selector Contracts
|
||||||
|
|
||||||
|
These shapes are implementation requirements, not public artifact schemas.
|
||||||
|
|
||||||
|
### NPC occurrence response
|
||||||
|
|
||||||
|
Each response record contains exactly the required fields `name`, `kind`, and
|
||||||
|
`source_refs`. It does not contain `npc_id`. Notarius resolves `name` through
|
||||||
|
the normalized NPC registry and writes the matched registry record's `ID` and
|
||||||
|
canonical `Name` into the durable occurrence.
|
||||||
|
|
||||||
|
### Item occurrence response
|
||||||
|
|
||||||
|
Each response record contains the existing required `name`, `kind`,
|
||||||
|
`quantity`, `from`, `to`, and `source_refs` fields. It does not contain
|
||||||
|
`item_id`. Retain the current nullable representation and kind-specific
|
||||||
|
semantics. Notarius resolves `name` through the normalized item registry and
|
||||||
|
adds the matched `ID` and canonical `Name`.
|
||||||
|
|
||||||
|
### Location occurrence response
|
||||||
|
|
||||||
|
Each response record contains exactly the required fields `name`,
|
||||||
|
`registry_refs`, `kind`, and `source_refs`. `registry_refs` is always an array
|
||||||
|
of strict objects containing required integer `start_unit_id` and
|
||||||
|
`end_unit_id`; it may be empty.
|
||||||
|
|
||||||
|
- When `name` has one comparison-identity match in the supplied registry,
|
||||||
|
`registry_refs` must be empty and name resolution selects that record.
|
||||||
|
- When multiple registry records share the comparison identity,
|
||||||
|
`registry_refs` must equal one record's complete canonically ordered source
|
||||||
|
ranges with `source_id` removed.
|
||||||
|
- The model-facing registry projection uses the same `name` plus
|
||||||
|
`registry_refs` selector and adds a required `context` array. Unique-name
|
||||||
|
records project empty `registry_refs` and `context` arrays. The private
|
||||||
|
response does not reproduce `context`.
|
||||||
|
- Same-name records receive bounded identity context consisting of the ordered
|
||||||
|
source units covered by their registry ranges. Each context element is a
|
||||||
|
strict object with exactly the required fields `unit_id` (integer) and
|
||||||
|
`text` (string); do not expose the durable location ID, source ID, digest,
|
||||||
|
or a replacement token.
|
||||||
|
- Building same-name grounding validates that every registry range belongs to
|
||||||
|
and resolves against the current source. If two records still produce the
|
||||||
|
same contextual selector, grounding construction fails before the LLM call.
|
||||||
|
- `registry_refs` never flow into the durable occurrence's `source_refs`.
|
||||||
|
|
||||||
|
### Entity-reconciliation response
|
||||||
|
|
||||||
|
The shared response remains an object with required `duplicate_groups`.
|
||||||
|
Every group has required `members` and `canonical`. A member and the canonical
|
||||||
|
selection are strict contextual objects containing:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "Mira Thorn",
|
||||||
|
"source_refs": [
|
||||||
|
{"start_unit_id": 12, "end_unit_id": 12}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The candidate prompt input uses the same descriptor and contains no `key`.
|
||||||
|
`source_refs` is required and non-empty for every eligible candidate. The
|
||||||
|
shared helper may retain its existing `candidate-000001`-style keys strictly
|
||||||
|
inside Go state to preserve input-position mapping; those keys must never be
|
||||||
|
serialized into prompt input or accepted in the private response.
|
||||||
|
|
||||||
|
If two candidates produce an identical contextual descriptor, neither is
|
||||||
|
eligible for model-assisted reconciliation because the model cannot identify
|
||||||
|
them independently. Otherwise the helper converts returned descriptors to its
|
||||||
|
internal candidate keys before applying all existing unknown-member,
|
||||||
|
ineligible-member, duplicate-member, canonical-membership, overlap, retry, and
|
||||||
|
fallback rules.
|
||||||
|
|
||||||
|
## Stage 1: Convert NPC Occurrences To Name-Based Resolution
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Remove durable NPC IDs from the NPC-occurrence prompt and private response,
|
||||||
|
then resolve the model's contextual name deterministically without weakening
|
||||||
|
checkpoint identity or downstream validation.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Inspect:
|
||||||
|
- `assets/dnd/npc-occurrences/`;
|
||||||
|
- `internal/modules/dnd/extract/npcoccurrences/`;
|
||||||
|
- `internal/modules/dnd/npcs/registry/`;
|
||||||
|
- NPC-occurrence normalizer and validator checkpoint fingerprints; and
|
||||||
|
- their focused tests.
|
||||||
|
2. Change `dnd_npc_occurrences_llm.v1.json` so every occurrence requires only
|
||||||
|
`name`, `kind`, and `source_refs`, continues to reject unknown fields, and
|
||||||
|
no longer declares `npc_id`.
|
||||||
|
3. Revise the NPC-occurrence instructions to require a supplied canonical NPC
|
||||||
|
name and current-chunk evidence, with no instruction to copy or invent an
|
||||||
|
ID. Continue using the existing shared names-only NPC registry fragment and
|
||||||
|
preserve manifest order/cache controls.
|
||||||
|
4. Remove `NPCID` from the private `occurrenceResponse`. After canonicalizing
|
||||||
|
response evidence, resolve every response name with the existing
|
||||||
|
`npcregistry.Registry.Lookup` comparison-key lookup. On the first unknown
|
||||||
|
or non-unique selection, return an extractor-scoped mapping error and no
|
||||||
|
value. For a match, construct the durable occurrence with the registry
|
||||||
|
record's exact `ID` and canonical `Name`.
|
||||||
|
5. Change `mappingPolicy` to
|
||||||
|
`dnd.npc_occurrences.extract_mapping.v3`.
|
||||||
|
6. Stop passing `IdentityPromptInput()` to the LLM; use the existing
|
||||||
|
names-only `PromptInput()`.
|
||||||
|
7. Replace the misleading exported model-input API used only for identity
|
||||||
|
fingerprints: retain the unexported ordered `{id,name}` projection and its
|
||||||
|
digest, expose that value as `IdentityDigest() string`, remove
|
||||||
|
`IdentityPromptInput()`, and update NPC-occurrence extractor, normalizer,
|
||||||
|
invariant-validator, and registry-validator fingerprints to use
|
||||||
|
`IdentityDigest()`. The digest must still distinguish ID/name identity from
|
||||||
|
the names-only prompt projection.
|
||||||
|
8. Rewrite existing focused tests around observable behavior: rendered NPC
|
||||||
|
registry input is names-only; the private schema rejects `npc_id`; valid
|
||||||
|
names acquire the registry ID; comparison-equivalent names canonicalize;
|
||||||
|
unknown names fail the whole extraction; registry identity fingerprints
|
||||||
|
remain distinct and defensive; empty registries accept only empty model
|
||||||
|
results. Remove tests whose only purpose was requiring the model to return
|
||||||
|
exact ID/name pairs.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- No NPC-occurrence LLM input or private response contains a durable NPC ID.
|
||||||
|
- Durable NPC occurrences still contain the exact registry ID/name pair.
|
||||||
|
- Mapping failures remain extractor failures eligible for the configured
|
||||||
|
pipeline retry behavior.
|
||||||
|
- Deterministic consumers still fingerprint the ordered registry identity,
|
||||||
|
while spells, combat turns, enemy events, and NPC occurrences share the
|
||||||
|
names-only model projection.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go fmt ./internal/modules/dnd/npcs/registry ./internal/modules/dnd/extract/npcoccurrences ./internal/modules/dnd/normalize/npcoccurrences ./internal/modules/dnd/validate/npcoccurrences/...
|
||||||
|
go test ./internal/modules/dnd/npcs/registry ./internal/modules/dnd/extract/npcoccurrences ./internal/modules/dnd/normalize/npcoccurrences ./internal/modules/dnd/validate/npcoccurrences/...
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one gpt-5.6-terra prompt.
|
||||||
|
|
||||||
|
## Stage 2: Convert Item Occurrences To Name-Based Resolution
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Give item occurrences the same contextual-name/deterministic-ID boundary while
|
||||||
|
preserving item-specific nullable fields and kind rules.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Inspect `assets/dnd/item-occurrences/`, the item occurrence extractor, the
|
||||||
|
item registry, the item occurrence normalizer and registry validator, and
|
||||||
|
their focused tests.
|
||||||
|
2. Change `dnd_item_occurrences_llm.v1.json` to remove `item_id` from required
|
||||||
|
fields and properties. Preserve required `name`, `kind`, `quantity`, `from`,
|
||||||
|
`to`, and `source_refs`, all current enums/nullability, and strict unknown
|
||||||
|
field rejection.
|
||||||
|
3. Rewrite the item registry fragment and module instructions to require the
|
||||||
|
supplied canonical name and current-chunk evidence without mentioning an
|
||||||
|
ID. Preserve prompt order and cache controls.
|
||||||
|
4. Change the item registry's model projection from ordered `{id,name}` pairs
|
||||||
|
to ordered names-only objects, add a comparison-key index, and expose a
|
||||||
|
defensive `Lookup(name) (dnd.Item, bool)` analogous to the NPC registry.
|
||||||
|
Retain exact `LookupID` for durable normalizers and validators. Because item
|
||||||
|
IDs are derived from the item comparison identity, the names-only
|
||||||
|
`ProjectionDigest` remains sufficient for model input and existing
|
||||||
|
checkpoint consumers.
|
||||||
|
5. Remove `ItemID` from the private response. During response canonicalization,
|
||||||
|
resolve every contextual name, replace it with the registry record's
|
||||||
|
canonical name, and attach its durable ID when constructing the final
|
||||||
|
`dnd.ItemOccurrence`. Unknown selections fail the complete extraction; do
|
||||||
|
not alter evidence or nullable-field validation ownership.
|
||||||
|
6. Change `mappingPolicy` to
|
||||||
|
`dnd.item_occurrences.extract_mapping.v2`.
|
||||||
|
7. Update focused tests to cover names-only projection, defensive comparison
|
||||||
|
lookup, schema rejection of `item_id`, deterministic durable mapping,
|
||||||
|
unknown-name failure after an otherwise valid record, empty registry/result
|
||||||
|
behavior, and preservation of nullable/kind-specific fields.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- Model-visible item registry and response content contain no item hash.
|
||||||
|
- Every accepted durable occurrence has the matched registry ID and canonical
|
||||||
|
name.
|
||||||
|
- Invalid selection remains all-or-nothing, and existing normalizer/validator
|
||||||
|
defense in depth remains unchanged.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go fmt ./internal/modules/dnd/items/registry ./internal/modules/dnd/extract/itemoccurrences
|
||||||
|
go test ./internal/modules/dnd/items/registry ./internal/modules/dnd/extract/itemoccurrences ./internal/modules/dnd/normalize/itemoccurrences ./internal/modules/dnd/validate/itemoccurrences/...
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one gpt-5.6-terra prompt.
|
||||||
|
|
||||||
|
## Stage 3: Add Contextual Location Grounding
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Replace the location registry's ID/name prompt projection with an immutable,
|
||||||
|
source-aware grounding object that can represent same-name locations safely.
|
||||||
|
Introduce the new path alongside the old occurrence input so this stage remains
|
||||||
|
buildable; Stage 4 performs the atomic extractor cutover and removes the old
|
||||||
|
path.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Inspect the location registry, location identity and source-reference
|
||||||
|
helpers, the generic source document index, occurrence checkpoint consumers,
|
||||||
|
and their focused tests.
|
||||||
|
2. In `internal/modules/dnd/locations/registry`, define the private-model
|
||||||
|
types needed by both grounding and the location occurrence extractor:
|
||||||
|
- a returned selector with exactly `name` and `registry_refs`;
|
||||||
|
- a registry projection entry with exactly `name`, `registry_refs`, and
|
||||||
|
`context`;
|
||||||
|
- a source-free range with exactly `start_unit_id` and `end_unit_id`; and
|
||||||
|
- a context unit with exactly `unit_id` and `text`.
|
||||||
|
All fields are required in their private JSON shapes, and constructors and
|
||||||
|
accessors must make defensive copies.
|
||||||
|
3. Add an operation-scoped immutable grounding type constructed from a resolved
|
||||||
|
registry and the current `*source.SourceDocument`. Its API must provide:
|
||||||
|
- a cloned `contracts.LLMInputMaterial` for the `location_registry` slot;
|
||||||
|
- deterministic resolution of a returned selector to one cloned
|
||||||
|
`dnd.Location`.
|
||||||
|
The prompt material's existing `Digest` field owns the digest of the exact
|
||||||
|
model projection; do not expose a second grounding-specific digest API.
|
||||||
|
4. Construct the projection in registry order. Group entries by the existing
|
||||||
|
location comparison key:
|
||||||
|
- every projection entry has exactly the required fields `name`,
|
||||||
|
`registry_refs`, and `context`;
|
||||||
|
- comparison-unique entries use empty `registry_refs` and `context` arrays;
|
||||||
|
- every same-name entry uses its complete canonical source ranges stripped
|
||||||
|
of `source_id` and includes ordered context units covered by those ranges;
|
||||||
|
- each context unit contains exactly required integer `unit_id` and string
|
||||||
|
`text` fields, and units are deduplicated in source order; and
|
||||||
|
- same-name ranges must have `SourceID == doc.ID` and pass
|
||||||
|
`source.DocumentIndex.ValidateRef`.
|
||||||
|
5. Fail grounding construction with a bounded, content-safe error if the
|
||||||
|
source is nil, a same-name range is invalid or belongs to another source,
|
||||||
|
a comparison key is empty, or two records produce the same selector. Do not
|
||||||
|
expose transcript text in the error.
|
||||||
|
6. Resolution uses the existing comparison key. A unique-name selector is
|
||||||
|
accepted only with empty `registry_refs`; a same-name selector is accepted
|
||||||
|
only on an exact canonical range match. Reject unknown names, a non-empty
|
||||||
|
range list for a unique name, an empty/partial/reordered range list for an
|
||||||
|
ambiguous name, or any selector not present in the grounding.
|
||||||
|
7. Separate deterministic identity fingerprinting from LLM material. Add
|
||||||
|
`IdentityDigest()` over the registry's ordered `{id,name}` identity
|
||||||
|
projection, and update the location normalizer and registry-validator
|
||||||
|
checkpoint consumers to use it. The new operation grounding carries the
|
||||||
|
model projection digest in its `LLMInputMaterial`. Retain the old ID-bearing
|
||||||
|
prompt accessor only as a documented transitional dependency of the
|
||||||
|
still-unchanged location occurrence extractor; do not add new callers.
|
||||||
|
8. Add focused tests for unique names, same-name context and selectors,
|
||||||
|
canonical range order, deterministic projection/digest, defensive copies,
|
||||||
|
exact selector resolution, nil/foreign/invalid references, selector
|
||||||
|
collisions, empty registries, and identity fingerprint stability. Do not
|
||||||
|
assert large rendered prompt strings; decode the JSON projection and assert
|
||||||
|
its semantic shape.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- The location package can build and resolve contextual selectors without
|
||||||
|
exposing `location_id`, `source_id`, digests, or replacement labels.
|
||||||
|
- Same-name locations remain distinct and receive meaningful bounded context.
|
||||||
|
- Deterministic checkpoint consumers retain an ID-sensitive fingerprint.
|
||||||
|
- Only the existing occurrence extractor remains wired to the legacy
|
||||||
|
ID-bearing prompt path until Stage 4; the repository compiles and tests pass.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go fmt ./internal/modules/dnd/locations/registry ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
|
||||||
|
go test ./internal/modules/dnd/locations/registry ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one gpt-5.6-terra prompt.
|
||||||
|
|
||||||
|
## Stage 4: Convert Location Occurrences To Contextual Resolution
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Wire the Stage 3 grounding object into location occurrence extraction and
|
||||||
|
remove durable location IDs from the prompt and private response.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Inspect `assets/dnd/location-occurrences/`, the location occurrence model,
|
||||||
|
schema loader, extractor, canonicalization, prompt tests, and Stage 3
|
||||||
|
grounding tests.
|
||||||
|
2. Change `dnd_location_occurrences_llm.v1.json` so each occurrence requires
|
||||||
|
exactly `name`, `registry_refs`, `kind`, and `source_refs`; remove
|
||||||
|
`location_id`. Keep all four occurrence kinds. Make `registry_refs` a
|
||||||
|
required array, including an empty array, of strict required positive
|
||||||
|
integer ranges. Keep occurrence `source_refs` separate and unchanged.
|
||||||
|
3. Rewrite `location-registry.md` and module instructions to explain the two
|
||||||
|
selector cases, require exact supplied contextual selectors, prohibit
|
||||||
|
invented locations, and state that registry ranges/context are identity
|
||||||
|
grounding rather than occurrence evidence. Preserve manifest order and
|
||||||
|
cache controls.
|
||||||
|
4. Change the private response type to `Name`, `RegistryRefs`, `Kind`, and
|
||||||
|
`SourceRefs`. Do not reuse `source.SourceRef` for the source-free registry
|
||||||
|
range type.
|
||||||
|
5. In `Extract`, construct operation grounding from the resolved registry and
|
||||||
|
`req.Source` before calling the LLM, put its projection in the
|
||||||
|
`location_registry` input, and resolve every returned selector after
|
||||||
|
completion. Attach the selected registry record's exact ID and canonical
|
||||||
|
name to the durable occurrence while retaining only the response's
|
||||||
|
current-source `source_refs` as evidence.
|
||||||
|
6. Fail the whole extraction on grounding-construction failure or the first
|
||||||
|
unknown, malformed, mismatched, or ambiguous selector. This replaces the
|
||||||
|
current behavior that can preserve unknown ID/name pairs for later
|
||||||
|
validators. Keep later normalizer and validator checks as defense in depth
|
||||||
|
for artifacts entering other boundaries.
|
||||||
|
7. Change `mappingPolicy` to
|
||||||
|
`dnd.location_occurrences.extract_mapping.v2`.
|
||||||
|
8. Remove the legacy ID-bearing registry `PromptInput` and its model-projection
|
||||||
|
digest once the extractor uses operation grounding. Keep the occurrence's
|
||||||
|
static module fingerprint based on `IdentityDigest()`, prompt/schema
|
||||||
|
fingerprints, and mapping policy. The operation-scoped projection is already
|
||||||
|
covered by source/chunk identity and configured or generated reference
|
||||||
|
dependencies, while its `LLMInputMaterial.Digest` identifies the exact model
|
||||||
|
input; do not add a second digest API or operation-aware static fingerprint.
|
||||||
|
9. Update focused schema, prompt, extractor, canonicalization, checkpoint, and
|
||||||
|
generated-reference tests. Cover unique-name empty selectors, successful
|
||||||
|
same-name selection, failure for an unsupported ambiguous mention,
|
||||||
|
partial/reordered ranges, no registry-to-occurrence evidence leakage,
|
||||||
|
all-or-nothing failure, empty registry/result behavior, and unchanged
|
||||||
|
durable ordering/deduplication.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- The location prompt and private response contain no durable location ID.
|
||||||
|
- Unique and same-name records resolve according to the final selector
|
||||||
|
contract.
|
||||||
|
- Accepted durable output is unchanged in shape and still contains an exact
|
||||||
|
location ID/name pair.
|
||||||
|
- Registry context cannot become durable occurrence evidence.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go fmt ./internal/modules/dnd/extract/locationoccurrences ./internal/modules/dnd/locations/registry
|
||||||
|
go test ./internal/modules/dnd/locations/registry ./internal/modules/dnd/extract/locationoccurrences ./internal/modules/dnd/normalize/locationoccurrences ./internal/modules/dnd/validate/locationoccurrences/...
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one gpt-5.6-terra prompt.
|
||||||
|
|
||||||
|
## Stage 5: Replace Reconciliation Keys With Contextual Descriptors
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Change the shared NPC/item/location registry-normalization proposal contract so
|
||||||
|
opaque candidate keys remain internal and the model sees and returns only
|
||||||
|
names plus evidence coordinates.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Inspect:
|
||||||
|
- `internal/modules/dnd/shared/entityreconcile/`;
|
||||||
|
- `assets/dnd/shared/prompts/common-dnd-entity-reconciliation.md`;
|
||||||
|
- `assets/dnd/entity-reconciliation/schemas/`;
|
||||||
|
- all three registry normalization manifests and prompt tests; and
|
||||||
|
- the NPC, item, and location registry normalizers and reconciliation tests.
|
||||||
|
2. Introduce one exported, defensively copied contextual selector type in
|
||||||
|
`entityreconcile` with JSON `name` and `source_refs`, plus a strict
|
||||||
|
source-free range type. Use it for candidate input views and for
|
||||||
|
`DuplicateGroup.Members` and `.Canonical`.
|
||||||
|
3. Keep deterministic candidate keys only inside `Materials`. During
|
||||||
|
`BuildContext`, validate and canonicalize candidate references as today,
|
||||||
|
serialize candidate views without `key`, derive a stable internal lookup
|
||||||
|
from canonical selector JSON to the corresponding internal candidate key,
|
||||||
|
and detect descriptor collisions before eligibility is established.
|
||||||
|
Colliding candidates must not appear in the prompt input or become
|
||||||
|
eligible; their records remain in deterministic normalization output.
|
||||||
|
4. Update `Materials.Assess` to resolve every returned selector through that
|
||||||
|
internal lookup before running the existing group assessment. Preserve
|
||||||
|
existing issue categories where their meaning still applies. Treat an
|
||||||
|
unknown or collided descriptor as an unknown/ineligible selection, discard
|
||||||
|
only the affected group, and retain existing overlap handling. `SafeGroup`
|
||||||
|
may continue returning internal candidate keys so the three domain
|
||||||
|
normalizers retain their position mapping; those keys are not model-facing.
|
||||||
|
5. Rewrite `dnd_entity_reconcile_llm.v1.json` so members and canonical are
|
||||||
|
strict selector objects. Require non-empty `name` structurally where the
|
||||||
|
current schemas do so, require `source_refs`, and make each range strict
|
||||||
|
with required positive integer endpoints. Preserve `duplicate_groups` and
|
||||||
|
the existing semantic assessment of minimum group size, membership,
|
||||||
|
duplicates, eligibility, and overlap rather than moving every semantic
|
||||||
|
failure into JSON Schema.
|
||||||
|
6. Rewrite the shared reconciliation fragment to tell the model to return
|
||||||
|
supplied contextual descriptors and never invent names or ranges. Remove
|
||||||
|
every instruction about opaque keys. Preserve all three manifests' message
|
||||||
|
ordering and cache controls.
|
||||||
|
7. Change registry normalization policy identifiers to:
|
||||||
|
- `dnd.npc_registry.normalize.v4`;
|
||||||
|
- `dnd.item_registry.normalize.v2`; and
|
||||||
|
- `dnd.location_registry.normalize.v2`.
|
||||||
|
8. Update shared and domain tests to cover candidate JSON without keys,
|
||||||
|
contextual proposal decoding, valid selector-to-internal-key mapping,
|
||||||
|
equal names with different evidence, descriptor collision exclusion,
|
||||||
|
unknown/partial/reordered descriptors, overlapping groups, canonical
|
||||||
|
membership, invalid structured-output fallback, currency safety, and
|
||||||
|
preservation of every non-applied deterministic candidate. Update prompt
|
||||||
|
asset fixtures to the new selector schema; do not snapshot prompt prose.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- No registry normalization prompt input or private response contains a
|
||||||
|
`candidate-*` key.
|
||||||
|
- Internal keys remain inaccessible to the model but may still support safe
|
||||||
|
deterministic position mapping.
|
||||||
|
- All existing normalizer safety, retry, fallback, warning, currency, and
|
||||||
|
same-name-location policies remain intact.
|
||||||
|
- Identical contextual descriptors cannot be arbitrarily reconciled.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go fmt ./internal/modules/dnd/shared/entityreconcile ./internal/modules/dnd/normalize/npcregistry ./internal/modules/dnd/normalize/itemregistry ./internal/modules/dnd/normalize/locationregistry
|
||||||
|
go test ./internal/modules/dnd/shared/entityreconcile ./internal/modules/dnd/normalize/npcregistry ./internal/modules/dnd/normalize/itemregistry ./internal/modules/dnd/normalize/locationregistry
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the largest stage, but it is one cohesive shared-contract migration
|
||||||
|
and is suitable for one gpt-5.6-terra prompt when implemented exactly within
|
||||||
|
the listed packages. Do not combine it with occurrence or documentation work.
|
||||||
|
|
||||||
|
## Stage 6: Record The Decision And Update Canonical Documentation
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Document the implemented policy in its durable architectural, internal, and
|
||||||
|
integration homes without duplicating volatile details or presenting roadmap
|
||||||
|
work as current behavior prematurely.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Re-read `docs/policy/documentation.md`, ADR-0003, ADR-0009, ADR-0011,
|
||||||
|
`docs/internal/dnd.md`, `docs/internal/llm.md`, and the six affected registry
|
||||||
|
and occurrence integration documents. Verify the code before describing it.
|
||||||
|
2. Add
|
||||||
|
`docs/adr/0012-resolve-opaque-entity-identifiers-deterministically.md` in
|
||||||
|
the repository's Nygard ADR format with status `Accepted` and the actual
|
||||||
|
implementation date. Record the model-semantic/deterministic-identity
|
||||||
|
boundary, source-coordinate allowance, request-local-label exception,
|
||||||
|
alternatives, ambiguity behavior, and consequences. Link ADR-0003 and
|
||||||
|
ADR-0009 rather than repeating their complete decisions.
|
||||||
|
3. Add a concise normative invariant under the LLM boundary in
|
||||||
|
`docs/policy/architecture.md`: callers use contextual model selections and
|
||||||
|
attach opaque application identities deterministically when possible. Link
|
||||||
|
ADR-0012 for rationale.
|
||||||
|
4. Update `docs/internal/dnd.md` to replace exact model-facing `{id,name}`
|
||||||
|
claims with the implemented NPC/item names-only and location contextual
|
||||||
|
selector behavior. Document reconciliation descriptors, internal-only keys,
|
||||||
|
all-or-nothing occurrence mapping failures, normalization fallback, and the
|
||||||
|
separation between registry and occurrence evidence. Do not duplicate the
|
||||||
|
private JSON schemas.
|
||||||
|
5. Add only a short ownership clarification to `docs/internal/llm.md`: the
|
||||||
|
calling module resolves contextual selections; PromptKit and its adapter do
|
||||||
|
not own entity identity.
|
||||||
|
6. Update these durable integration contracts while preserving their public
|
||||||
|
ID-bearing wire examples and schema statements:
|
||||||
|
- `docs/integrations/dnd-npc-registry-artifacts.md`;
|
||||||
|
- `docs/integrations/dnd-npc-occurrence-artifacts.md`;
|
||||||
|
- `docs/integrations/dnd-item-registry-artifacts.md`;
|
||||||
|
- `docs/integrations/dnd-item-occurrence-artifacts.md`;
|
||||||
|
- `docs/integrations/dnd-location-registry-artifacts.md`; and
|
||||||
|
- `docs/integrations/dnd-location-occurrence-artifacts.md`.
|
||||||
|
Remove claims that LLM consumers receive `{id,name}` or that raw model
|
||||||
|
output supplies an ID. State that Notarius maps contextual output into the
|
||||||
|
unchanged exact durable pair.
|
||||||
|
7. Revise the generic LLM-assisted deduplication entry in
|
||||||
|
`docs/roadmap/future.md`: stable unique IDs remain internal deterministic
|
||||||
|
state, while a future model proposal uses contextual descriptors or a
|
||||||
|
specifically justified request-local short label.
|
||||||
|
8. Do not change README, CLI, configuration, operations, examples, or public
|
||||||
|
schema files; this feature has no user-selectable surface or public wire
|
||||||
|
change.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- ADR-0012 owns rationale; architecture owns the normative boundary; internal
|
||||||
|
docs own mechanics; integration docs own unchanged durable contracts; and
|
||||||
|
the future roadmap no longer proposes durable IDs as the default model
|
||||||
|
selector.
|
||||||
|
- No current-behavior document claims that a model copies hash-based entity
|
||||||
|
IDs or opaque reconciliation keys.
|
||||||
|
- Documentation does not duplicate private schemas or implementation history.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git diff --check
|
||||||
|
rg -n '\{id,name\}|ID/name grounding|Candidate keys are opaque|candidate-[0-9]' docs assets/dnd
|
||||||
|
```
|
||||||
|
|
||||||
|
Review every search result semantically; durable wire-contract ID/name
|
||||||
|
requirements and internal test fixtures are not automatically errors.
|
||||||
|
|
||||||
|
This stage is suitable for one gpt-5.6-terra prompt.
|
||||||
|
|
||||||
|
## Stage 7: Integration Audit And Final Verification
|
||||||
|
|
||||||
|
### Goal
|
||||||
|
|
||||||
|
Verify the assembled D&D family, remove obsolete identity-copy paths, and
|
||||||
|
finish with a clean, policy-compliant implementation.
|
||||||
|
|
||||||
|
### Work
|
||||||
|
|
||||||
|
1. Audit every maintained D&D prompt manifest, selected fragment, private
|
||||||
|
schema, and constructed prompt projection. Confirm that no model is asked to
|
||||||
|
reproduce `npc:sha256:...`, `item:sha256:...`,
|
||||||
|
`location:sha256:...`, `candidate-*`, a UUID, a digest, or another opaque
|
||||||
|
entity handle. Do not confuse runtime metadata or durable output contracts
|
||||||
|
with model-visible material.
|
||||||
|
2. Trace all former APIs and fields, including `IdentityPromptInput`,
|
||||||
|
ID-bearing item/location prompt projections, private `NPCID`/`ItemID`/
|
||||||
|
`LocationID` response fields, and model-visible candidate keys. Remove dead
|
||||||
|
code, obsolete comments, stale test names, and unused assets. Retain
|
||||||
|
identity-only digests and exact durable lookup APIs used by deterministic
|
||||||
|
consumers.
|
||||||
|
3. Review prompt fingerprint registration and checkpoint fingerprints. Confirm
|
||||||
|
that each affected prompt/schema/policy/projection change invalidates the
|
||||||
|
relevant operation and that unrelated D&D lanes retain their existing
|
||||||
|
fingerprints.
|
||||||
|
4. Run representative production registration and multi-step pipeline tests
|
||||||
|
using existing fakes. Update only tests whose stable behavior changed.
|
||||||
|
Confirm generated NPC/item/location registry handoffs still prepare and
|
||||||
|
that final durable occurrences encode and validate under their existing
|
||||||
|
`v1` codecs.
|
||||||
|
5. Run formatting, focused suites, full tests, vet, build, and documentation
|
||||||
|
whitespace checks. Fix only failures caused by this feature. Report any
|
||||||
|
unrelated pre-existing failure without broadening scope.
|
||||||
|
6. Review the feature roadmap acceptance criteria one by one. Do not delete
|
||||||
|
`contextual-entity-grounding.md` or this implementation plan in this stage;
|
||||||
|
roadmap retirement is a separate maintainer action after review.
|
||||||
|
|
||||||
|
### Acceptance Criteria
|
||||||
|
|
||||||
|
- All feature-roadmap acceptance criteria are met.
|
||||||
|
- The repository contains no obsolete model-facing opaque-identity path.
|
||||||
|
- Public artifacts and generated handoffs remain compatible.
|
||||||
|
- Tests are focused on behavior rather than prose or implementation shape.
|
||||||
|
- The worktree contains only intentional feature and documentation changes.
|
||||||
|
|
||||||
|
### Validation
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go fmt ./internal/modules/dnd/...
|
||||||
|
go test ./internal/modules/dnd/...
|
||||||
|
go test ./internal/modules/integration/...
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./cmd/notarius
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
This stage is suitable for one gpt-5.6-terra prompt.
|
||||||
@@ -219,14 +219,15 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
|
|||||||
}
|
}
|
||||||
for _, request := range locationRequests {
|
for _, request := range locationRequests {
|
||||||
registryInput := request.Inputs["location_registry"]
|
registryInput := request.Inputs["location_registry"]
|
||||||
if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") {
|
if !strings.Contains(string(registryInput.Content), "Moon Gate") || !strings.Contains(string(registryInput.Content), "registry_refs") || strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") {
|
||||||
t.Fatalf("location occurrence registry input = %q, want source-free ID grounding", registryInput.Content)
|
t.Fatalf("location occurrence registry input = %q, want contextual selector grounding", registryInput.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
promptID string
|
promptID string
|
||||||
slot string
|
slot string
|
||||||
name string
|
name string
|
||||||
|
requiresIDs bool
|
||||||
}{
|
}{
|
||||||
{promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"},
|
{promptID: npcoccurrences.PromptID, slot: "npc_registry", name: "Kesh"},
|
||||||
{promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"},
|
{promptID: itemoccurrences.PromptID, slot: "item_registry", name: "Moonblade"},
|
||||||
@@ -237,8 +238,9 @@ func TestMaintainedCompleteExamplePublishesRegistryBackedEntityOccurrences(t *te
|
|||||||
}
|
}
|
||||||
for _, request := range requests {
|
for _, request := range requests {
|
||||||
registryInput := request.Inputs[test.slot]
|
registryInput := request.Inputs[test.slot]
|
||||||
if !strings.Contains(string(registryInput.Content), test.name) || !strings.Contains(string(registryInput.Content), `"id"`) || strings.Contains(string(registryInput.Content), "source_refs") {
|
hasID := strings.Contains(string(registryInput.Content), `"id"`)
|
||||||
t.Fatalf("%s registry input = %q, want source-free ID grounding", test.promptID, registryInput.Content)
|
if !strings.Contains(string(registryInput.Content), test.name) || hasID != test.requiresIDs || strings.Contains(string(registryInput.Content), "source_refs") {
|
||||||
|
t.Fatalf("%s registry input = %q, want source-free configured grounding", test.promptID, registryInput.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -319,16 +321,16 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
|||||||
} else {
|
} else {
|
||||||
var registry struct {
|
var registry struct {
|
||||||
Items []struct {
|
Items []struct {
|
||||||
ID string `json:"id"`
|
Name string `json:"name"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(request.Inputs["item_registry"].Content, ®istry); err != nil {
|
if err := json.Unmarshal(request.Inputs["item_registry"].Content, ®istry); err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated item registry: %w", err)
|
||||||
}
|
}
|
||||||
if len(registry.Items) != 1 {
|
if len(registry.Items) != 1 || registry.Items[0].Name != "Moonblade" {
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items))
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated item registry has %d items, want 1", len(registry.Items))
|
||||||
}
|
}
|
||||||
content = []byte(fmt.Sprintf(`{"occurrences":[{"item_id":%q,"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`, registry.Items[0].ID))
|
content = []byte(`{"occurrences":[{"name":"Moonblade","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":5,"end_segment":5}]}]}`)
|
||||||
}
|
}
|
||||||
case combat.PromptID:
|
case combat.PromptID:
|
||||||
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
|
content = []byte(`{"combat_turns":[{"actor":"Kesh","turn_kind":"turn","source_refs":[{"start_unit_id":8,"end_unit_id":8}]}]}`)
|
||||||
@@ -336,23 +338,27 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
|||||||
if combatScene {
|
if combatScene {
|
||||||
var registry struct {
|
var registry struct {
|
||||||
NPCs []struct {
|
NPCs []struct {
|
||||||
ID string `json:"id"`
|
Name string `json:"name"`
|
||||||
} `json:"npcs"`
|
} `json:"npcs"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, ®istry); err != nil {
|
if err := json.Unmarshal(request.Inputs["npc_registry"].Content, ®istry); err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("decode generated NPC registry: %w", err)
|
||||||
}
|
}
|
||||||
if len(registry.NPCs) == 0 {
|
if len(registry.NPCs) == 0 || registry.NPCs[0].Name != "Kesh" {
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs")
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated NPC registry has no NPCs")
|
||||||
}
|
}
|
||||||
content = []byte(fmt.Sprintf(`{"occurrences":[{"npc_id":%q,"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`, registry.NPCs[0].ID))
|
content = []byte(`{"occurrences":[{"name":"Kesh","kind":"combat_opponent","source_refs":[{"start_unit_id":7,"end_unit_id":7}]}]}`)
|
||||||
} else {
|
} else {
|
||||||
content = []byte(`{"occurrences":[]}`)
|
content = []byte(`{"occurrences":[]}`)
|
||||||
}
|
}
|
||||||
case locationoccurrences.PromptID:
|
case locationoccurrences.PromptID:
|
||||||
var registry struct {
|
var registry struct {
|
||||||
Locations []struct {
|
Locations []struct {
|
||||||
ID string `json:"id"`
|
Name string `json:"name"`
|
||||||
|
RegistryRefs []struct {
|
||||||
|
StartUnitID int `json:"start_unit_id"`
|
||||||
|
EndUnitID int `json:"end_unit_id"`
|
||||||
|
} `json:"registry_refs"`
|
||||||
} `json:"locations"`
|
} `json:"locations"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(request.Inputs["location_registry"].Content, ®istry); err != nil {
|
if err := json.Unmarshal(request.Inputs["location_registry"].Content, ®istry); err != nil {
|
||||||
@@ -362,14 +368,18 @@ func (client *enemyEventLLMClient) CompleteStructured(ctx context.Context, reque
|
|||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations")
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has no locations")
|
||||||
}
|
}
|
||||||
unitID := 1
|
unitID := 1
|
||||||
locationID := registry.Locations[0].ID
|
location := registry.Locations[0]
|
||||||
if combatScene {
|
if combatScene {
|
||||||
unitID = 7
|
unitID = 7
|
||||||
if len(registry.Locations) > 1 {
|
if len(registry.Locations) > 1 {
|
||||||
locationID = registry.Locations[1].ID
|
location = registry.Locations[1]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content = []byte(fmt.Sprintf(`{"occurrences":[{"location_id":%q,"name":"Moon Gate","kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, locationID, unitID, unitID))
|
registryRefs, err := json.Marshal(location.RegistryRefs)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("encode location selector: %w", err)
|
||||||
|
}
|
||||||
|
content = []byte(fmt.Sprintf(`{"occurrences":[{"name":%q,"registry_refs":%s,"kind":"visited","source_refs":[{"start_unit_id":%d,"end_unit_id":%d}]}]}`, location.Name, registryRefs, unitID, unitID))
|
||||||
case enemyevents.PromptID:
|
case enemyevents.PromptID:
|
||||||
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
|
content = []byte(`{"events":[{"name":"Kesh","kind":"fled","source_refs":[{"start_unit_id":10,"end_unit_id":10}]}]}`)
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -10,24 +10,34 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
type orderedItemOccurrenceResponse struct {
|
type orderedItemOccurrence struct {
|
||||||
value itemOccurrenceResponse
|
value dnd.ItemOccurrence
|
||||||
earliest int
|
earliest int
|
||||||
hasEvidence bool
|
hasEvidence bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) error {
|
func canonicalItemOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string, registry *itemregistry.Registry) (dnd.ItemOccurrenceList, error) {
|
||||||
if response == nil {
|
if response.Occurrences == nil {
|
||||||
return nil
|
return dnd.ItemOccurrenceList{}, nil
|
||||||
}
|
}
|
||||||
ordered := make([]orderedItemOccurrenceResponse, len(response.Occurrences))
|
ordered := make([]orderedItemOccurrence, len(response.Occurrences))
|
||||||
for index := range response.Occurrences {
|
for index, occurrence := range response.Occurrences {
|
||||||
if err := validateRegistryPair(index, response.Occurrences[index], registry); err != nil {
|
item, found := registry.Lookup(occurrence.Name)
|
||||||
return err
|
if !found {
|
||||||
|
return dnd.ItemOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the item registry", index)
|
||||||
}
|
}
|
||||||
earliest, hasEvidence := canonicalizeItemOccurrence(&response.Occurrences[index], order, sourceID)
|
refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID))
|
||||||
ordered[index] = orderedItemOccurrenceResponse{
|
earliest, hasEvidence := order.EarliestValid(refs)
|
||||||
value: response.Occurrences[index],
|
ordered[index] = orderedItemOccurrence{
|
||||||
|
value: dnd.ItemOccurrence{
|
||||||
|
ItemID: item.ID,
|
||||||
|
Name: item.Name,
|
||||||
|
Kind: dnd.ItemOccurrenceKind(occurrence.Kind),
|
||||||
|
Quantity: cloneQuantity(occurrence.Quantity),
|
||||||
|
From: occurrence.From,
|
||||||
|
To: occurrence.To,
|
||||||
|
SourceRefs: refs,
|
||||||
|
},
|
||||||
earliest: earliest,
|
earliest: earliest,
|
||||||
hasEvidence: hasEvidence,
|
hasEvidence: hasEvidence,
|
||||||
}
|
}
|
||||||
@@ -41,49 +51,11 @@ func canonicalizeResponse(response *extractionResponse, order shared.SourceRefOr
|
|||||||
}
|
}
|
||||||
return ordered[left].earliest < ordered[right].earliest
|
return ordered[left].earliest < ordered[right].earliest
|
||||||
})
|
})
|
||||||
for index := range ordered {
|
occurrences := make([]dnd.ItemOccurrence, len(ordered))
|
||||||
response.Occurrences[index] = ordered[index].value
|
for index, occurrence := range ordered {
|
||||||
|
occurrences[index] = occurrence.value
|
||||||
}
|
}
|
||||||
return nil
|
return dnd.ItemOccurrenceList{Occurrences: occurrences}, nil
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalizeItemOccurrence(occurrence *itemOccurrenceResponse, order shared.SourceRefOrder, sourceID string) (int, bool) {
|
|
||||||
if occurrence == nil {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
refs := order.Canonicalize(itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID))
|
|
||||||
occurrence.SourceRefs = itemOccurrenceResponseRefs(refs)
|
|
||||||
return order.EarliestValid(refs)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateRegistryPair(index int, occurrence itemOccurrenceResponse, registry *itemregistry.Registry) error {
|
|
||||||
item, found := registry.LookupID(occurrence.ItemID)
|
|
||||||
if !found {
|
|
||||||
return fmt.Errorf("occurrences[%d].item_id is not in the item registry", index)
|
|
||||||
}
|
|
||||||
if item.Name != occurrence.Name {
|
|
||||||
return fmt.Errorf("occurrences[%d].name does not match item_id", index)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func canonicalItemOccurrenceList(response extractionResponse, sourceID string) dnd.ItemOccurrenceList {
|
|
||||||
if response.Occurrences == nil {
|
|
||||||
return dnd.ItemOccurrenceList{}
|
|
||||||
}
|
|
||||||
occurrences := make([]dnd.ItemOccurrence, 0, len(response.Occurrences))
|
|
||||||
for _, occurrence := range response.Occurrences {
|
|
||||||
occurrences = append(occurrences, dnd.ItemOccurrence{
|
|
||||||
ItemID: occurrence.ItemID,
|
|
||||||
Name: occurrence.Name,
|
|
||||||
Kind: dnd.ItemOccurrenceKind(occurrence.Kind),
|
|
||||||
Quantity: cloneQuantity(occurrence.Quantity),
|
|
||||||
From: occurrence.From,
|
|
||||||
To: occurrence.To,
|
|
||||||
SourceRefs: itemOccurrenceSourceRefs(occurrence.SourceRefs, sourceID),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return dnd.ItemOccurrenceList{Occurrences: occurrences}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
||||||
@@ -97,17 +69,6 @@ func itemOccurrenceSourceRefs(refs []itemOccurrenceSourceRefResponse, sourceID s
|
|||||||
return values
|
return values
|
||||||
}
|
}
|
||||||
|
|
||||||
func itemOccurrenceResponseRefs(refs []source.SourceRef) []itemOccurrenceSourceRefResponse {
|
|
||||||
if refs == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
values := make([]itemOccurrenceSourceRefResponse, len(refs))
|
|
||||||
for index, ref := range refs {
|
|
||||||
values[index] = itemOccurrenceSourceRefResponse{StartSegment: ref.StartUnitID, EndSegment: ref.EndUnitID}
|
|
||||||
}
|
|
||||||
return values
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneQuantity(value *int) *int {
|
func cloneQuantity(value *int) *int {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const (
|
|||||||
ItemRegistryMaxBytes = itemregistry.MaxBytes
|
ItemRegistryMaxBytes = itemregistry.MaxBytes
|
||||||
)
|
)
|
||||||
|
|
||||||
const mappingPolicy = "dnd.item_occurrences.extract_mapping.v1"
|
const mappingPolicy = "dnd.item_occurrences.extract_mapping.v2"
|
||||||
|
|
||||||
var requiredCapabilities = []string{
|
var requiredCapabilities = []string{
|
||||||
"chunks",
|
"chunks",
|
||||||
@@ -161,10 +161,11 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
if err := canonicalizeResponse(&response, order, req.Source.ID, registry); err != nil {
|
value, err := canonicalItemOccurrenceList(response, order, req.Source.ID, registry)
|
||||||
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err)
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{}, extractorErrorf("map item occurrence response: %w", err)
|
||||||
}
|
}
|
||||||
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: canonicalItemOccurrenceList(response, req.Source.ID)}, nil
|
return contracts.TypedExtractionResult[dnd.ItemOccurrenceList]{Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
|
func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
|
||||||
id := itemidentity.DeriveID("Torch")
|
id := itemidentity.DeriveID("Torch")
|
||||||
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
|
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
|
||||||
{ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
||||||
}}}
|
}}}
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.References = itemRegistryReferences(t)
|
req.References = itemRegistryReferences(t)
|
||||||
@@ -24,55 +24,38 @@ func TestExtractGroundsOccurrencesInRequiredRegistry(t *testing.T) {
|
|||||||
t.Fatalf("occurrences = %#v", result.Value.Occurrences)
|
t.Fatalf("occurrences = %#v", result.Value.Occurrences)
|
||||||
}
|
}
|
||||||
input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
|
input := client.requests[0].Inputs[ItemRegistryReferenceSlot]
|
||||||
if input.Name != ItemRegistryReferenceSlot || string(input.Content) == "" {
|
if input.Name != ItemRegistryReferenceSlot || string(input.Content) != `{"items":[{"name":"Torch"}]}` || strings.Contains(string(input.Content), "item:sha256:") {
|
||||||
t.Fatalf("registry prompt input = %#v", input)
|
t.Fatalf("registry prompt input = %#v, want names-only projection", input)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractRejectsInvalidRegistryPairs(t *testing.T) {
|
func TestExtractCanonicalizesComparisonEquivalentNames(t *testing.T) {
|
||||||
id := itemidentity.DeriveID("Torch")
|
|
||||||
for _, test := range []struct {
|
|
||||||
name string
|
|
||||||
occurrence itemOccurrenceResponse
|
|
||||||
wantError string
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "unknown item ID",
|
|
||||||
occurrence: itemOccurrenceResponse{ItemID: "unknown", Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
|
||||||
wantError: "occurrences[0].item_id is not in the item registry",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "mismatched item name",
|
|
||||||
occurrence: itemOccurrenceResponse{ItemID: id, Name: "Lantern", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
|
||||||
wantError: "occurrences[0].name does not match item_id",
|
|
||||||
},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{test.occurrence}}}
|
|
||||||
req := extractionRequest()
|
|
||||||
req.References = itemRegistryReferences(t)
|
|
||||||
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
|
||||||
if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
|
||||||
t.Fatalf("Extract() error = %v, want %q", err, test.wantError)
|
|
||||||
}
|
|
||||||
if len(result.Value.Occurrences) != 0 {
|
|
||||||
t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestExtractRejectsResponseWithInvalidRegistryPairAfterValidOccurrence(t *testing.T) {
|
|
||||||
id := itemidentity.DeriveID("Torch")
|
id := itemidentity.DeriveID("Torch")
|
||||||
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
|
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
|
||||||
{ItemID: id, Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
{Name: " tORCH ", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
||||||
{ItemID: "unknown", Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
|
|
||||||
}}}
|
}}}
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.References = itemRegistryReferences(t)
|
req.References = itemRegistryReferences(t)
|
||||||
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||||
if err == nil || !strings.Contains(err.Error(), "occurrences[1].item_id") {
|
if err != nil {
|
||||||
t.Fatalf("Extract() error = %v, want occurrence index and item ID", err)
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
occurrence := result.Value.Occurrences[0]
|
||||||
|
if occurrence.ItemID != id || occurrence.Name != "Torch" {
|
||||||
|
t.Fatalf("canonical occurrence = %#v", occurrence)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsUnknownNameAfterValidOccurrence(t *testing.T) {
|
||||||
|
client := &fakeItemOccurrencesLLMClient{response: extractionResponse{Occurrences: []itemOccurrenceResponse{
|
||||||
|
{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)},
|
||||||
|
{Name: "Unknown", Kind: "lost", From: "party", SourceRefs: responseRefs(2, 2)},
|
||||||
|
}}}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.References = itemRegistryReferences(t)
|
||||||
|
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "occurrences[1].name is not in the item registry") {
|
||||||
|
t.Fatalf("Extract() error = %v, want occurrence index and unknown name", err)
|
||||||
}
|
}
|
||||||
if len(result.Value.Occurrences) != 0 {
|
if len(result.Value.Occurrences) != 0 {
|
||||||
t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences)
|
t.Fatalf("Extract() returned partial occurrences: %#v", result.Value.Occurrences)
|
||||||
@@ -86,9 +69,36 @@ func TestExtractRequiresItemRegistry(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractAcceptsOnlyEmptyResponseForEmptyRegistry(t *testing.T) {
|
||||||
|
references := emptyItemRegistryReferences(t)
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
response extractionResponse
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{name: "empty", response: extractionResponse{Occurrences: []itemOccurrenceResponse{}}},
|
||||||
|
{name: "selection", response: extractionResponse{Occurrences: []itemOccurrenceResponse{{Name: "Torch", Kind: "lost", From: "party", SourceRefs: responseRefs(1, 1)}}}, wantErr: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
client := &fakeItemOccurrencesLLMClient{response: test.response}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.References = references
|
||||||
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||||
|
if test.wantErr {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "name is not in the item registry") || len(result.Value.Occurrences) != 0 {
|
||||||
|
t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || result.Value.Occurrences == nil || len(result.Value.Occurrences) != 0 {
|
||||||
|
t.Fatalf("Extract() = %#v, %v; want present empty occurrences", result, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractPreservesNullableFields(t *testing.T) {
|
func TestExtractPreservesNullableFields(t *testing.T) {
|
||||||
id := itemidentity.DeriveID("Torch")
|
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
|
||||||
client := &fakeItemOccurrencesLLMClient{content: []byte(`{"occurrences":[{"item_id":"` + id + `","name":"Torch","kind":"discovered","quantity":null,"from":null,"to":null,"source_refs":[{"start_segment":1,"end_segment":1}]}]}`)}
|
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.References = itemRegistryReferences(t)
|
req.References = itemRegistryReferences(t)
|
||||||
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
result, err := newExtractor(t, client, req.References).Extract(context.Background(), req)
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ type extractionResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type itemOccurrenceResponse struct {
|
type itemOccurrenceResponse struct {
|
||||||
ItemID string `json:"item_id"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
Quantity *int `json:"quantity,omitempty"`
|
Quantity *int `json:"quantity,omitempty"`
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestPromptAssetsPrepareItemOccurrencePrompt(t *testing.T) {
|
|||||||
"players": promptkit.Inline("item-occurrence-player"),
|
"players": promptkit.Inline("item-occurrence-player"),
|
||||||
"party": promptkit.Inline(" "),
|
"party": promptkit.Inline(" "),
|
||||||
"glossary": promptkit.Inline(" "),
|
"glossary": promptkit.Inline(" "),
|
||||||
"item_registry": promptkit.Inline(`{"items":[{"id":"item:sha256:test","name":"Torch"}]}`),
|
"item_registry": promptkit.Inline(`{"items":[{"name":"Torch"}]}`),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -21,6 +21,15 @@ func itemRegistryReferences(t *testing.T) contracts.ReferenceSet {
|
|||||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func emptyItemRegistryReferences(t *testing.T) contracts.ReferenceSet {
|
||||||
|
t.Helper()
|
||||||
|
content, err := itemcodec.New().Encode(dnd.ItemRegistry{Items: []dnd.Item{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ItemRegistryReferenceSlot: {Slot: contracts.ReferenceSlot{Name: ItemRegistryReferenceSlot}, Items: []contracts.ReferenceItem{{SlotName: ItemRegistryReferenceSlot, Content: content, MediaType: "application/json", ArtifactKind: dnd.ItemRegistryKind}}}}}
|
||||||
|
}
|
||||||
|
|
||||||
func testSourceRefs() []source.SourceRef {
|
func testSourceRefs() []source.SourceRef {
|
||||||
return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}
|
return []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 1, EndUnitID: 1}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
valid := map[string]any{"occurrences": []any{
|
valid := map[string]any{"occurrences": []any{
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"item_id": "item:sha256:test", "name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
|
"name": "", "kind": "unsupported", "quantity": 0, "from": "party", "to": "Party",
|
||||||
"source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
|
"source_refs": []any{map[string]any{"start_segment": 0, "end_segment": -1}},
|
||||||
},
|
},
|
||||||
map[string]any{
|
map[string]any{
|
||||||
"item_id": "item:sha256:test", "name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
|
"name": "Hidden Cache", "kind": "discovered", "quantity": nil, "from": nil, "to": nil,
|
||||||
"source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
|
"source_refs": []any{map[string]any{"start_segment": 1, "end_segment": 1}},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -41,6 +41,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
|||||||
{"missing occurrences", map[string]any{}},
|
{"missing occurrences", map[string]any{}},
|
||||||
{"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}},
|
{"missing occurrence name", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "name")}}},
|
||||||
{"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}},
|
{"missing nullable field", map[string]any{"occurrences": []any{withoutField(responseOccurrence(), "quantity")}}},
|
||||||
|
{"opaque item identifier", map[string]any{"occurrences": []any{withField(responseOccurrence(), "item_id", "item:sha256:opaque")}}},
|
||||||
{"unknown occurrence field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "extra", true)}}},
|
{"unknown occurrence field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "extra", true)}}},
|
||||||
{"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
|
{"unknown reference field", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1, "end_segment": 1, "extra": true}})}}},
|
||||||
{"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
|
{"noninteger range", map[string]any{"occurrences": []any{withField(responseOccurrence(), "source_refs", []any{map[string]any{"start_segment": 1.5, "end_segment": 1}})}}},
|
||||||
@@ -68,7 +69,7 @@ func TestResponseSchemaIsStrictlyStructuralAndPrivate(t *testing.T) {
|
|||||||
|
|
||||||
func responseOccurrence() map[string]any {
|
func responseOccurrence() map[string]any {
|
||||||
return map[string]any{
|
return map[string]any{
|
||||||
"item_id": "item:sha256:test", "name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
|
"name": "Ring", "kind": "acquired", "quantity": nil, "from": nil, "to": "party", "source_refs": []any{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package locationoccurrences
|
package locationoccurrences
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"reflect"
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -15,17 +17,21 @@ type orderedOccurrence struct {
|
|||||||
hasEvidence bool
|
hasEvidence bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string) dnd.LocationOccurrenceList {
|
func canonicalOccurrenceList(response extractionResponse, order shared.SourceRefOrder, sourceID string, grounding *locationregistry.Grounding) (dnd.LocationOccurrenceList, error) {
|
||||||
if response.Occurrences == nil {
|
if response.Occurrences == nil {
|
||||||
return dnd.LocationOccurrenceList{}
|
return dnd.LocationOccurrenceList{}, nil
|
||||||
}
|
}
|
||||||
ordered := make([]orderedOccurrence, len(response.Occurrences))
|
ordered := make([]orderedOccurrence, len(response.Occurrences))
|
||||||
for index, occurrence := range response.Occurrences {
|
for index, occurrence := range response.Occurrences {
|
||||||
|
location, ok := grounding.Resolve(locationregistry.Selector{Name: occurrence.Name, RegistryRefs: occurrence.RegistryRefs})
|
||||||
|
if !ok {
|
||||||
|
return dnd.LocationOccurrenceList{}, fmt.Errorf("occurrence %d does not match a supplied location selector", index)
|
||||||
|
}
|
||||||
refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID))
|
refs := order.Canonicalize(canonicalSourceRefs(occurrence.SourceRefs, sourceID))
|
||||||
earliest, hasEvidence := order.EarliestValid(refs)
|
earliest, hasEvidence := order.EarliestValid(refs)
|
||||||
ordered[index] = orderedOccurrence{value: dnd.LocationOccurrence{
|
ordered[index] = orderedOccurrence{value: dnd.LocationOccurrence{
|
||||||
LocationID: occurrence.LocationID,
|
LocationID: location.ID,
|
||||||
Name: occurrence.Name,
|
Name: location.Name,
|
||||||
Kind: dnd.LocationOccurrenceKind(occurrence.Kind),
|
Kind: dnd.LocationOccurrenceKind(occurrence.Kind),
|
||||||
SourceRefs: refs,
|
SourceRefs: refs,
|
||||||
}, earliest: earliest, hasEvidence: hasEvidence}
|
}, earliest: earliest, hasEvidence: hasEvidence}
|
||||||
@@ -39,7 +45,7 @@ func canonicalOccurrenceList(response extractionResponse, order shared.SourceRef
|
|||||||
occurrences = append(occurrences, occurrence.value)
|
occurrences = append(occurrences, occurrence.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return dnd.LocationOccurrenceList{Occurrences: occurrences}
|
return dnd.LocationOccurrenceList{Occurrences: occurrences}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func lessOccurrence(left, right orderedOccurrence, order shared.SourceRefOrder) bool {
|
func lessOccurrence(left, right orderedOccurrence, order shared.SourceRefOrder) bool {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/location-occurrences"
|
Key = "dnd/location-occurrences"
|
||||||
mappingPolicy = "dnd.location_occurrences.extract_mapping.v1"
|
mappingPolicy = "dnd.location_occurrences.extract_mapping.v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -121,7 +121,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
{Name: "prompt", Value: e.promptSHA},
|
{Name: "prompt", Value: e.promptSHA},
|
||||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||||
{Name: "mapping_policy", Value: mappingPolicy},
|
{Name: "mapping_policy", Value: mappingPolicy},
|
||||||
{Name: "location_registry", Value: e.locationResolver.Seeded().ProjectionDigest()},
|
{Name: "location_registry", Value: e.locationResolver.Seeded().IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,17 +143,25 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
if !registry.Bound() {
|
if !registry.Bound() {
|
||||||
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("location registry reference is required")
|
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("location registry reference is required")
|
||||||
}
|
}
|
||||||
|
grounding, err := locationregistry.NewGrounding(registry, req.Source)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("prepare location grounding: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||||
inputs[LocationRegistryReferenceSlot] = registry.PromptInput()
|
inputs[LocationRegistryReferenceSlot] = grounding.PromptInput()
|
||||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
|
StageName: Key, PromptID: PromptID, PromptVersion: SchemaVersion,
|
||||||
ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
|
ProfileID: req.LLMProfile, SessionID: req.SessionID, Inputs: inputs,
|
||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{Value: canonicalOccurrenceList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID)}, nil
|
occurrences, err := canonicalOccurrenceList(response, shared.NewSourceRefOrder(req.Source), req.Source.ID, grounding)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{}, extractorErrorf("resolve location grounding: %w", err)
|
||||||
|
}
|
||||||
|
return contracts.TypedExtractionResult[dnd.LocationOccurrenceList]{Value: occurrences}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
|
|||||||
@@ -14,19 +14,20 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry"
|
locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
|
||||||
|
locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testing.T) {
|
func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testing.T) {
|
||||||
locations := locationRegistry(t, "The Tavern", "The Tavern")
|
locations := locationRegistry(t, "The Tavern", "The Tavern")
|
||||||
first, second := locations.Locations[0], locations.Locations[1]
|
first, second := locations.Locations[0], locations.Locations[1]
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
{LocationID: second.ID, Name: second.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)},
|
{Name: second.Name, RegistryRefs: registryRefs(second), Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30)},
|
||||||
{LocationID: first.ID, Name: first.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
{LocationID: first.ID, Name: first.Name, Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "recalled", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
{LocationID: first.ID, Name: first.Name, Kind: "planned", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "planned", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)},
|
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: append(occurrenceRefs(10, 10), occurrenceRefs(10, 10)...)},
|
||||||
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(20, 20)},
|
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(20, 20)},
|
||||||
{LocationID: first.ID, Name: first.Name, Kind: "visited", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: first.Name, RegistryRefs: registryRefs(first), Kind: "visited", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
}}}
|
}}}
|
||||||
references := registryReferences(t, locations)
|
references := registryReferences(t, locations)
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -50,11 +51,11 @@ func TestExtractMapsKindsOrdersOccurrencesAndPreservesIndependentFacts(t *testin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) {
|
func TestExtractResolvesContextualSelectorsAndUsesCurrentTranscriptEvidenceOnly(t *testing.T) {
|
||||||
locations := locationRegistry(t, "The Tavern", "The Tavern")
|
locations := locationRegistry(t, "The Tavern", "The Tavern")
|
||||||
first, second := locations.Locations[0], locations.Locations[1]
|
first, second := locations.Locations[0], locations.Locations[1]
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
||||||
LocationID: second.ID, Name: second.Name, Kind: "visited", SourceRefs: occurrenceRefs(10, 10),
|
Name: second.Name, RegistryRefs: registryRefs(second), Kind: "visited", SourceRefs: occurrenceRefs(10, 10),
|
||||||
}}}}
|
}}}}
|
||||||
references := registryReferences(t, locations)
|
references := registryReferences(t, locations)
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -67,10 +68,10 @@ func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) {
|
|||||||
t.Fatalf("occurrence = %#v", occurrence)
|
t.Fatalf("occurrence = %#v", occurrence)
|
||||||
}
|
}
|
||||||
input := client.requests[0].Inputs[LocationRegistryReferenceSlot]
|
input := client.requests[0].Inputs[LocationRegistryReferenceSlot]
|
||||||
if input.Name != LocationRegistryReferenceSlot || !strings.Contains(string(input.Content), first.ID) || !strings.Contains(string(input.Content), second.ID) {
|
if input.Name != LocationRegistryReferenceSlot || !strings.Contains(string(input.Content), `"registry_refs":[{"start_unit_id":20,"end_unit_id":20}]`) {
|
||||||
t.Fatalf("location prompt input = %#v", input)
|
t.Fatalf("location prompt input = %#v", input)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{"source_refs", "source_id", "other-session"} {
|
for _, forbidden := range []string{"source_refs", "source_id", first.ID, second.ID} {
|
||||||
if strings.Contains(string(input.Content), forbidden) {
|
if strings.Contains(string(input.Content), forbidden) {
|
||||||
t.Fatalf("location prompt leaked %q: %s", forbidden, input.Content)
|
t.Fatalf("location prompt leaked %q: %s", forbidden, input.Content)
|
||||||
}
|
}
|
||||||
@@ -84,22 +85,46 @@ func TestExtractUsesIDsNamesAndCurrentTranscriptEvidenceOnly(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractPreservesUnknownOrMismatchedGroundingForValidators(t *testing.T) {
|
func TestExtractRejectsUnknownMalformedOrMismatchedSelectorsAtomically(t *testing.T) {
|
||||||
locations := locationRegistry(t, "The Mill")
|
locations := locationRegistry(t, "The Mill")
|
||||||
known := locations.Locations[0]
|
known := locations.Locations[0]
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
|
||||||
{LocationID: "location:sha256:unknown", Name: "The Mill", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
|
||||||
{LocationID: known.ID, Name: "A Different Mill", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)},
|
|
||||||
}}}
|
|
||||||
references := registryReferences(t, locations)
|
references := registryReferences(t, locations)
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.References = references
|
req.References = references
|
||||||
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
for _, occurrences := range [][]occurrenceResponse{
|
||||||
if err != nil {
|
{{Name: "Unknown", RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}},
|
||||||
t.Fatal(err)
|
{{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 0}}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}},
|
||||||
|
{{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 10}}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}},
|
||||||
|
{{Name: known.Name, RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}, {Name: "Unknown", RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)}},
|
||||||
|
} {
|
||||||
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: occurrences}}
|
||||||
|
if result, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || result.Value.Occurrences != nil || !strings.Contains(err.Error(), "location selector") {
|
||||||
|
t.Fatalf("Extract() = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sharedName := "The Tavern"
|
||||||
|
firstRefs := []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 10, EndUnitID: 10}, {SourceID: req.Source.ID, StartUnitID: 20, EndUnitID: 20}}
|
||||||
|
secondRefs := []source.SourceRef{{SourceID: req.Source.ID, StartUnitID: 30, EndUnitID: 30}}
|
||||||
|
duplicateLocations := dnd.LocationRegistry{Locations: []dnd.Location{
|
||||||
|
{ID: identity.DeriveID(sharedName, firstRefs), Name: sharedName, SourceRefs: firstRefs},
|
||||||
|
{ID: identity.DeriveID(sharedName, secondRefs), Name: sharedName, SourceRefs: secondRefs},
|
||||||
|
}}
|
||||||
|
duplicateReferences := registryReferences(t, duplicateLocations)
|
||||||
|
for _, selector := range []struct {
|
||||||
|
name string
|
||||||
|
refs []locationregistry.RegistryRef
|
||||||
|
}{
|
||||||
|
{name: sharedName, refs: []locationregistry.RegistryRef{}},
|
||||||
|
{name: sharedName, refs: []locationregistry.RegistryRef{{StartUnitID: 10, EndUnitID: 10}}},
|
||||||
|
{name: sharedName, refs: []locationregistry.RegistryRef{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 10, EndUnitID: 10}}},
|
||||||
|
} {
|
||||||
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{Name: selector.name, RegistryRefs: selector.refs, Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)}}}}
|
||||||
|
duplicateRequest := extractionRequest()
|
||||||
|
duplicateRequest.References = duplicateReferences
|
||||||
|
if _, err := newExtractor(t, client, duplicateReferences).Extract(context.Background(), duplicateRequest); err == nil || !strings.Contains(err.Error(), "location selector") {
|
||||||
|
t.Fatalf("Extract(%#v) error = %v", selector, err)
|
||||||
}
|
}
|
||||||
if result.Value.Occurrences[0].LocationID != "location:sha256:unknown" || result.Value.Occurrences[1].Name != "A Different Mill" {
|
|
||||||
t.Fatalf("extractor repaired validator-owned grounding errors: %#v", result.Value.Occurrences)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +150,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTimeAndDoesNotMutateResponse
|
|||||||
locations := locationRegistry(t, "The Mill")
|
locations := locationRegistry(t, "The Mill")
|
||||||
location := locations.Locations[0]
|
location := locations.Locations[0]
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
||||||
LocationID: location.ID, Name: location.Name, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30),
|
Name: location.Name, RegistryRefs: []locationregistry.RegistryRef{}, Kind: "mentioned", SourceRefs: occurrenceRefs(30, 30),
|
||||||
}}}}
|
}}}}
|
||||||
references := registryReferences(t, locations)
|
references := registryReferences(t, locations)
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -135,7 +160,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTimeAndDoesNotMutateResponse
|
|||||||
if err != nil || result.Value.Occurrences[0].Name != "The Mill" {
|
if err != nil || result.Value.Occurrences[0].Name != "The Mill" {
|
||||||
t.Fatalf("Extract() = %#v, %v", result, err)
|
t.Fatalf("Extract() = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
if input := client.requests[0].Inputs[LocationRegistryReferenceSlot]; !strings.Contains(string(input.Content), location.ID) || input.OriginURI != "" {
|
if input := client.requests[0].Inputs[LocationRegistryReferenceSlot]; strings.Contains(string(input.Content), location.ID) || input.OriginURI != "" {
|
||||||
t.Fatalf("generated registry prompt input = %#v", input)
|
t.Fatalf("generated registry prompt input = %#v", input)
|
||||||
}
|
}
|
||||||
if _, ok := extractor.ManifestMetadata()["location_registry_digest"]; ok {
|
if _, ok := extractor.ManifestMetadata()["location_registry_digest"]; ok {
|
||||||
@@ -215,21 +240,38 @@ func TestExtractorContractsMetadataAndFailures(t *testing.T) {
|
|||||||
t.Fatalf("metadata[%q] = %#v", key, metadata[key])
|
t.Fatalf("metadata[%q] = %#v", key, metadata[key])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := newExtractor(t, &fakeOccurrencesLLMClient{}, references).CheckpointFingerprints(); len(got) != 4 || got[3].Name != "location_registry" {
|
if got := newExtractor(t, &fakeOccurrencesLLMClient{}, references).CheckpointFingerprints(); len(got) != 4 || got[3].Name != "location_registry" || got[3].Value != locationRegistryIdentityDigest(t, references) {
|
||||||
t.Fatalf("fingerprints = %#v", got)
|
t.Fatalf("fingerprints = %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func locationRegistryIdentityDigest(t *testing.T, references contracts.ReferenceSet) string {
|
||||||
|
t.Helper()
|
||||||
|
resolver, err := locationregistry.NewResolver(references)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return resolver.Seeded().IdentityDigest()
|
||||||
|
}
|
||||||
|
|
||||||
func locationRegistry(t *testing.T, names ...string) dnd.LocationRegistry {
|
func locationRegistry(t *testing.T, names ...string) dnd.LocationRegistry {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
locations := make([]dnd.Location, len(names))
|
locations := make([]dnd.Location, len(names))
|
||||||
for index, name := range names {
|
for index, name := range names {
|
||||||
refs := []source.SourceRef{{SourceID: "other-session", StartUnitID: index + 1, EndUnitID: index + 1}}
|
refs := []source.SourceRef{{SourceID: sourceDocument().ID, StartUnitID: (index + 1) * 10, EndUnitID: (index + 1) * 10}}
|
||||||
locations[index] = dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs}
|
locations[index] = dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs}
|
||||||
}
|
}
|
||||||
return dnd.LocationRegistry{Locations: locations}
|
return dnd.LocationRegistry{Locations: locations}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func registryRefs(location dnd.Location) []locationregistry.RegistryRef {
|
||||||
|
refs := make([]locationregistry.RegistryRef, len(location.SourceRefs))
|
||||||
|
for index, ref := range location.SourceRefs {
|
||||||
|
refs[index] = locationregistry.RegistryRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||||
|
}
|
||||||
|
return refs
|
||||||
|
}
|
||||||
|
|
||||||
func registryReferences(t *testing.T, locations dnd.LocationRegistry) contracts.ReferenceSet {
|
func registryReferences(t *testing.T, locations dnd.LocationRegistry) contracts.ReferenceSet {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
content, err := locationcodec.New().Encode(locations)
|
content, err := locationcodec.New().Encode(locations)
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
package locationoccurrences
|
package locationoccurrences
|
||||||
|
|
||||||
|
import locationregistry "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/registry"
|
||||||
|
|
||||||
type extractionResponse struct {
|
type extractionResponse struct {
|
||||||
Occurrences []occurrenceResponse `json:"occurrences"`
|
Occurrences []occurrenceResponse `json:"occurrences"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type occurrenceResponse struct {
|
type occurrenceResponse struct {
|
||||||
LocationID string `json:"location_id"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
RegistryRefs []locationregistry.RegistryRef `json:"registry_refs"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
|
|||||||
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test",
|
PromptID: PromptID, PromptVersion: SchemaVersion, ProfileID: "location-occurrences-test",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"transcript": promptkit.Inline(`{"units":[{"sentinel":"location-occurrence-transcript"}]}`), "players": promptkit.Inline("location-occurrence-player"), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "),
|
"transcript": promptkit.Inline(`{"units":[{"sentinel":"location-occurrence-transcript"}]}`), "players": promptkit.Inline("location-occurrence-player"), "party": promptkit.Inline(" "), "glossary": promptkit.Inline(" "),
|
||||||
"location_registry": promptkit.Inline(`{"locations":[{"id":"location:sha256:test","name":"location-occurrence-registry"}]}`),
|
"location_registry": promptkit.Inline(`{"locations":[{"name":"location-occurrence-registry","registry_refs":[{"start_unit_id":7,"end_unit_id":7}],"context":[{"unit_id":7,"text":"registry context"}]}]}`),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -41,11 +41,11 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
var registryMessage string
|
var registryMessage string
|
||||||
for _, message := range prepared.Messages {
|
for _, message := range prepared.Messages {
|
||||||
if strings.Contains(message.Content, "normalized location registry") {
|
if strings.Contains(message.Content, "contextual location registry") {
|
||||||
registryMessage = message.Content
|
registryMessage = message.Content
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(registryMessage, "location:sha256:test") || !strings.Contains(registryMessage, "location-occurrence-registry") || strings.Contains(registryMessage, "source_refs") {
|
if !strings.Contains(registryMessage, "location-occurrence-registry") || !strings.Contains(registryMessage, `"registry_refs":[{"start_unit_id":7,"end_unit_id":7}]`) || strings.Contains(registryMessage, "source_id") || strings.Contains(registryMessage, "location:sha256") {
|
||||||
t.Fatalf("rendered prompt did not preserve source-free registry grounding: %s", registryMessage)
|
t.Fatalf("rendered prompt did not preserve source-free registry grounding: %s", registryMessage)
|
||||||
}
|
}
|
||||||
content := make([]string, len(prepared.Messages))
|
content := make([]string, len(prepared.Messages))
|
||||||
@@ -54,7 +54,7 @@ func TestRegisterPromptAssetsPreparesLocationOccurrencePrompt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
rendered := strings.Join(content, "\n")
|
rendered := strings.Join(content, "\n")
|
||||||
policy := strings.ReplaceAll(rendered, "\n", " ")
|
policy := strings.ReplaceAll(rendered, "\n", " ")
|
||||||
if !strings.Contains(policy, "context supports that coreference") || !strings.Contains(policy, "must not create a registry location") || !strings.Contains(policy, "provenance must never replace current-chunk evidence") {
|
if !strings.Contains(policy, "context supports that coreference") || !strings.Contains(policy, "must not create a registry location") || !strings.Contains(policy, "provenance must never replace current-chunk evidence") || !strings.Contains(policy, "complete, ordered") {
|
||||||
t.Fatalf("rendered prompt = %q, want contextual coreference without registry-derived evidence", rendered)
|
t.Fatalf("rendered prompt = %q, want contextual coreference without registry-derived evidence", rendered)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,16 +18,17 @@ func TestResponseSchemaRestrictsPrivateOccurrenceStructureAndKinds(t *testing.T)
|
|||||||
t.Fatalf("schema = %#v", schema)
|
t.Fatalf("schema = %#v", schema)
|
||||||
}
|
}
|
||||||
valid := map[string]any{"occurrences": []any{map[string]any{
|
valid := map[string]any{"occurrences": []any{map[string]any{
|
||||||
"location_id": "location:sha256:test", "name": "The Mill", "kind": "visited",
|
"name": "The Mill", "registry_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}}, "kind": "visited",
|
||||||
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
||||||
}}}
|
}}}
|
||||||
if err := validateSchema(t, valid, schema.JSONSchema); err != nil {
|
if err := validateSchema(t, valid, schema.JSONSchema); err != nil {
|
||||||
t.Fatalf("valid response rejected: %v", err)
|
t.Fatalf("valid response rejected: %v", err)
|
||||||
}
|
}
|
||||||
for _, mutate := range []func(map[string]any){
|
for _, mutate := range []func(map[string]any){
|
||||||
func(value map[string]any) { delete(value, "location_id") },
|
func(value map[string]any) { delete(value, "registry_refs") },
|
||||||
func(value map[string]any) { value["kind"] = "other" },
|
func(value map[string]any) { value["kind"] = "other" },
|
||||||
func(value map[string]any) { value["unexpected"] = true },
|
func(value map[string]any) { value["unexpected"] = true },
|
||||||
|
func(value map[string]any) { value["registry_refs"].([]any)[0].(map[string]any)["start_unit_id"] = 0 },
|
||||||
func(value map[string]any) {
|
func(value map[string]any) {
|
||||||
value["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
|
value["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
package npcoccurrences
|
package npcoccurrences
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
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"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,12 +52,16 @@ func canonicalizeOccurrence(occurrence *occurrenceResponse, order shared.SourceR
|
|||||||
return order.EarliestValid(refs)
|
return order.EarliestValid(refs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.NPCOccurrenceList {
|
func canonicalOccurrenceList(response extractionResponse, sourceID string, registry *npcregistry.Registry) (dnd.NPCOccurrenceList, error) {
|
||||||
occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences))
|
occurrences := make([]dnd.NPCOccurrence, len(response.Occurrences))
|
||||||
for index, occurrence := range response.Occurrences {
|
for index, occurrence := range response.Occurrences {
|
||||||
|
canonical, ok := registry.Lookup(occurrence.Name)
|
||||||
|
if !ok {
|
||||||
|
return dnd.NPCOccurrenceList{}, fmt.Errorf("occurrences[%d].name is not in the NPC registry", index)
|
||||||
|
}
|
||||||
occurrences[index] = dnd.NPCOccurrence{
|
occurrences[index] = dnd.NPCOccurrence{
|
||||||
NPCID: occurrence.NPCID,
|
NPCID: canonical.ID,
|
||||||
Name: occurrence.Name,
|
Name: canonical.Name,
|
||||||
Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
|
Kind: dnd.NPCOccurrenceKind(occurrence.Kind),
|
||||||
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
|
SourceRefs: canonicalSourceRefs(occurrence.SourceRefs, sourceID),
|
||||||
}
|
}
|
||||||
@@ -63,7 +69,7 @@ func canonicalOccurrenceList(response extractionResponse, sourceID string) dnd.N
|
|||||||
if response.Occurrences == nil {
|
if response.Occurrences == nil {
|
||||||
occurrences = nil
|
occurrences = nil
|
||||||
}
|
}
|
||||||
return dnd.NPCOccurrenceList{Occurrences: occurrences}
|
return dnd.NPCOccurrenceList{Occurrences: occurrences}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func canonicalSourceRefs(refs []occurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
func canonicalSourceRefs(refs []occurrenceSourceRefResponse, sourceID string) []source.SourceRef {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Key = "dnd/npc-occurrences"
|
Key = "dnd/npc-occurrences"
|
||||||
mappingPolicy = "dnd.npc_occurrences.extract_mapping.v2"
|
mappingPolicy = "dnd.npc_occurrences.extract_mapping.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -132,7 +132,7 @@ func (e *Extractor) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
{Name: "prompt", Value: e.promptSHA},
|
{Name: "prompt", Value: e.promptSHA},
|
||||||
{Name: "response_schema", Value: e.responseSchemaSHA},
|
{Name: "response_schema", Value: e.responseSchemaSHA},
|
||||||
{Name: "mapping_policy", Value: mappingPolicy},
|
{Name: "mapping_policy", Value: mappingPolicy},
|
||||||
{Name: "npc_registry", Value: seeded.IdentityPromptInput().Digest},
|
{Name: "npc_registry", Value: seeded.IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
|
|
||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
inputs := shared.PromptInputs(sourceInput, req.References)
|
inputs := shared.PromptInputs(sourceInput, req.References)
|
||||||
inputs[NPCRegistryReferenceSlot] = npcRegistry.IdentityPromptInput()
|
inputs[NPCRegistryReferenceSlot] = npcRegistry.PromptInput()
|
||||||
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := e.llm.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: Key,
|
StageName: Key,
|
||||||
PromptID: PromptID,
|
PromptID: PromptID,
|
||||||
@@ -170,26 +170,13 @@ func (e *Extractor) Extract(ctx context.Context, req contracts.TypedExtractionRe
|
|||||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("complete structured output: %w", err)
|
||||||
}
|
}
|
||||||
canonicalizeResponse(&response, order, req.Source.ID)
|
canonicalizeResponse(&response, order, req.Source.ID)
|
||||||
value := canonicalOccurrenceList(response, req.Source.ID)
|
value, err := canonicalOccurrenceList(response, req.Source.ID, npcRegistry)
|
||||||
if err := validateRegistryPairs(value, npcRegistry); err != nil {
|
if err != nil {
|
||||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("validate NPC registry pairs: %w", err)
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{}, extractorErrorf("resolve NPC names against registry: %w", err)
|
||||||
}
|
}
|
||||||
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil
|
return contracts.TypedExtractionResult[dnd.NPCOccurrenceList]{Value: value}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateRegistryPairs(value dnd.NPCOccurrenceList, registry *npcregistry.Registry) error {
|
|
||||||
for index, occurrence := range value.Occurrences {
|
|
||||||
canonical, ok := registry.LookupID(occurrence.NPCID)
|
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("occurrences[%d].npc_id is not in the NPC registry", index)
|
|
||||||
}
|
|
||||||
if occurrence.Name != canonical.Name {
|
|
||||||
return fmt.Errorf("occurrences[%d].name does not match npc_id", index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func ModuleSpec() pipeline.ModuleSpec {
|
func ModuleSpec() pipeline.ModuleSpec {
|
||||||
return pipeline.ModuleSpec{
|
return pipeline.ModuleSpec{
|
||||||
Key: Key,
|
Key: Key,
|
||||||
|
|||||||
@@ -18,13 +18,13 @@ import (
|
|||||||
|
|
||||||
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
{NPCID: identity.DeriveID("Other"), Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
{Name: "Other", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
||||||
{NPCID: identity.DeriveID("Opponent"), Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
|
{Name: "Opponent", Kind: "combat_opponent", SourceRefs: occurrenceRefs(20, 20)},
|
||||||
{NPCID: identity.DeriveID("Ally"), Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
|
{Name: "Ally", Kind: "combat_ally", SourceRefs: occurrenceRefs(5, 5)},
|
||||||
{NPCID: identity.DeriveID("Speaker"), Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
|
{Name: "Speaker", Kind: "dialogue", SourceRefs: append(occurrenceRefs(2, 2), occurrenceRefs(2, 2)...)},
|
||||||
{NPCID: identity.DeriveID("Present"), Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
|
{Name: "Present", Kind: "noncombat_presence", SourceRefs: occurrenceRefs(7, 7)},
|
||||||
{NPCID: identity.DeriveID("Mentioned"), Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: "Mentioned", Kind: "mentioned", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
{NPCID: identity.DeriveID("Invalid"), Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
|
{Name: "Invalid", Kind: "unsupported", SourceRefs: occurrenceRefs(0, 0)},
|
||||||
}}}
|
}}}
|
||||||
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
references := requiredRegistryReferences(t, "Mentioned", "Speaker", "Present", "Ally", "Opponent", "Other", "Invalid")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -51,6 +51,9 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|||||||
if refs := result.Value.Occurrences[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
|
if refs := result.Value.Occurrences[1].SourceRefs; !reflect.DeepEqual(refs, []source.SourceRef{{SourceID: "session-alpha", StartUnitID: 2, EndUnitID: 2}}) {
|
||||||
t.Fatalf("canonical source refs = %#v", refs)
|
t.Fatalf("canonical source refs = %#v", refs)
|
||||||
}
|
}
|
||||||
|
if id := result.Value.Occurrences[0].NPCID; id != identity.DeriveID("Mentioned") {
|
||||||
|
t.Fatalf("durable NPC ID = %q, want registry identity", id)
|
||||||
|
}
|
||||||
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
if invalid := result.Value.Occurrences[6]; invalid.Name != "Invalid" || invalid.Kind != "unsupported" || !reflect.DeepEqual(invalid.SourceRefs, []source.SourceRef{{SourceID: "session-alpha"}}) {
|
||||||
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
|
t.Fatalf("invalid candidate = %#v, want preserved values with current source identity", invalid)
|
||||||
}
|
}
|
||||||
@@ -61,14 +64,14 @@ func TestExtractMapsEveryKindAndOrdersBySourcePosition(t *testing.T) {
|
|||||||
|
|
||||||
func TestExtractUsesDocumentOrderForReferencesAndOccurrences(t *testing.T) {
|
func TestExtractUsesDocumentOrderForReferencesAndOccurrences(t *testing.T) {
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
{NPCID: identity.DeriveID("Later"), Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
{Name: "Later", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
{NPCID: identity.DeriveID("First"), Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
|
{Name: "First", Kind: "mentioned", SourceRefs: []occurrenceSourceRefResponse{
|
||||||
{StartUnitID: 10, EndUnitID: 10},
|
{StartUnitID: 10, EndUnitID: 10},
|
||||||
{StartUnitID: 30, EndUnitID: 30},
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
{StartUnitID: 30, EndUnitID: 30},
|
{StartUnitID: 30, EndUnitID: 30},
|
||||||
{StartUnitID: 999, EndUnitID: 0},
|
{StartUnitID: 999, EndUnitID: 0},
|
||||||
}},
|
}},
|
||||||
{NPCID: identity.DeriveID("Second"), Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
{Name: "Second", Kind: "other", SourceRefs: occurrenceRefs(30, 30)},
|
||||||
}}}
|
}}}
|
||||||
references := requiredRegistryReferences(t, "Later", "First", "Second")
|
references := requiredRegistryReferences(t, "Later", "First", "Second")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -107,9 +110,9 @@ func TestNewRequiresLLMAndRejectsAmbiguousReferenceSets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
|
func TestExtractUsesNamesOnlyRegistryAndCurrentTranscriptEvidence(t *testing.T) {
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
||||||
NPCID: identity.DeriveID("Mira Thorn"), Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
|
Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
|
||||||
}}}}
|
}}}}
|
||||||
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
|
references := requiredRegistryReferences(t, "Mira Thorn", "Hooded Guard")
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
@@ -119,10 +122,10 @@ func TestExtractUsesRegistryIDsAndCurrentTranscriptEvidence(t *testing.T) {
|
|||||||
}
|
}
|
||||||
request := client.requests[0]
|
request := client.requests[0]
|
||||||
registry := request.Inputs[NPCRegistryReferenceSlot]
|
registry := request.Inputs[NPCRegistryReferenceSlot]
|
||||||
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"},{"id":"`+identity.DeriveID("Hooded Guard")+`","name":"Hooded Guard"}]}` {
|
if registry.Name != NPCRegistryReferenceSlot || registry.MediaType != "application/json" || string(registry.Content) != `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}` {
|
||||||
t.Fatalf("registry prompt input = %#v, want exact ID and name projection", registry)
|
t.Fatalf("registry prompt input = %#v, want names-only projection", registry)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{"other-session", "start_unit_id"} {
|
for _, forbidden := range []string{"npc:sha256:", "other-session", "start_unit_id"} {
|
||||||
if strings.Contains(string(registry.Content), forbidden) {
|
if strings.Contains(string(registry.Content), forbidden) {
|
||||||
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
|
t.Fatalf("registry prompt input leaked %q: %s", forbidden, registry.Content)
|
||||||
}
|
}
|
||||||
@@ -162,24 +165,37 @@ func TestExtractRequiresBoundRegistryBeforeLLMCall(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExtractRejectsUnknownIDsAndMismatchedNames(t *testing.T) {
|
func TestExtractRejectsUnknownNamesWithoutPartialResult(t *testing.T) {
|
||||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||||
for _, test := range []struct {
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{
|
||||||
name string
|
{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)},
|
||||||
occurrence occurrenceResponse
|
{Name: "Unknown NPC", Kind: "mentioned", SourceRefs: occurrenceRefs(20, 20)},
|
||||||
want string
|
}}}
|
||||||
}{
|
|
||||||
{"unknown ID", occurrenceResponse{NPCID: "npc:unknown", Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "npc_id is not in the NPC registry"},
|
|
||||||
{"mismatched name", occurrenceResponse{NPCID: identity.DeriveID("Mira Thorn"), Name: "Hooded Guard", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}, "name does not match npc_id"},
|
|
||||||
} {
|
|
||||||
t.Run(test.name, func(t *testing.T) {
|
|
||||||
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{test.occurrence}}}
|
|
||||||
req := extractionRequest()
|
req := extractionRequest()
|
||||||
req.References = references
|
req.References = references
|
||||||
if _, err := newExtractor(t, client, references).Extract(context.Background(), req); err == nil || !strings.Contains(err.Error(), test.want) {
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||||
t.Fatalf("Extract() error = %v, want %q", err, test.want)
|
if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") {
|
||||||
|
t.Fatalf("Extract() error = %v, want unknown name failure", err)
|
||||||
}
|
}
|
||||||
})
|
if len(result.Value.Occurrences) != 0 {
|
||||||
|
t.Fatalf("Extract() returned partial result = %#v", result.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractCanonicalizesComparisonEquivalentRegistryNames(t *testing.T) {
|
||||||
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||||
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{
|
||||||
|
Name: " mIRA\u2003thorn ", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10),
|
||||||
|
}}}}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.References = references
|
||||||
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Extract() error = %v", err)
|
||||||
|
}
|
||||||
|
occurrence := result.Value.Occurrences[0]
|
||||||
|
if occurrence.Name != "Mira Thorn" || occurrence.NPCID != identity.DeriveID("Mira Thorn") {
|
||||||
|
t.Fatalf("canonical occurrence = %#v", occurrence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +208,7 @@ func TestExtractResolvesGeneratedRegistryAtOperationTime(t *testing.T) {
|
|||||||
if _, err := extractor.Extract(context.Background(), req); err != nil {
|
if _, err := extractor.Extract(context.Background(), req); err != nil {
|
||||||
t.Fatalf("Extract() error = %v", err)
|
t.Fatalf("Extract() error = %v", err)
|
||||||
}
|
}
|
||||||
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` || input.OriginURI != "" {
|
if input := client.requests[0].Inputs[NPCRegistryReferenceSlot]; string(input.Content) != `{"npcs":[{"name":"Mira Thorn"}]}` || input.OriginURI != "" {
|
||||||
t.Fatalf("generated registry prompt input = %#v", input)
|
t.Fatalf("generated registry prompt input = %#v", input)
|
||||||
}
|
}
|
||||||
metadata := extractor.ManifestMetadata()
|
metadata := extractor.ManifestMetadata()
|
||||||
@@ -221,6 +237,24 @@ func TestExtractAcceptsEmptyBoundRegistryAndEmptyResponse(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestExtractRejectsNonemptyResponseForEmptyBoundRegistry(t *testing.T) {
|
||||||
|
content, err := npccodec.New().Encode(dnd.NPCRegistry{NPCs: []dnd.NPC{}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
references := contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{NPCRegistryReferenceSlot: {
|
||||||
|
Slot: contracts.ReferenceSlot{Name: NPCRegistryReferenceSlot},
|
||||||
|
Items: []contracts.ReferenceItem{{SlotName: NPCRegistryReferenceSlot, MediaType: npccodec.MediaType, Content: content}},
|
||||||
|
}}}
|
||||||
|
client := &fakeOccurrencesLLMClient{response: extractionResponse{Occurrences: []occurrenceResponse{{Name: "Mira Thorn", Kind: "dialogue", SourceRefs: occurrenceRefs(10, 10)}}}}
|
||||||
|
req := extractionRequest()
|
||||||
|
req.References = references
|
||||||
|
result, err := newExtractor(t, client, references).Extract(context.Background(), req)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "name is not in the NPC registry") || len(result.Value.Occurrences) != 0 {
|
||||||
|
t.Fatalf("Extract() = %#v, %v; want no accepted occurrences", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
func TestExtractRejectsInvalidRequestsAndProviderFailures(t *testing.T) {
|
||||||
references := requiredRegistryReferences(t, "Mira Thorn")
|
references := requiredRegistryReferences(t, "Mira Thorn")
|
||||||
valid := extractionRequest()
|
valid := extractionRequest()
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ type extractionResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type occurrenceResponse struct {
|
type occurrenceResponse struct {
|
||||||
NPCID string `json:"npc_id"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Kind string `json:"kind"`
|
Kind string `json:"kind"`
|
||||||
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
SourceRefs []occurrenceSourceRefResponse `json:"source_refs"`
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
|
func TestExtractionResponsePreservesValidatorOwnedSemantics(t *testing.T) {
|
||||||
content := []byte(`{"occurrences":[{"npc_id":"","name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
content := []byte(`{"occurrences":[{"name":"","kind":"unsupported","source_refs":[{"start_unit_id":0,"end_unit_id":-1}]}]}`)
|
||||||
var response extractionResponse
|
var response extractionResponse
|
||||||
if err := json.Unmarshal(content, &response); err != nil {
|
if err := json.Unmarshal(content, &response); err != nil {
|
||||||
t.Fatalf("json.Unmarshal() error = %v", err)
|
t.Fatalf("json.Unmarshal() error = %v", err)
|
||||||
}
|
}
|
||||||
occurrence := response.Occurrences[0]
|
occurrence := response.Occurrences[0]
|
||||||
if occurrence.NPCID != "" || occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (occurrenceSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
if occurrence.Name != "" || occurrence.Kind != "unsupported" || occurrence.SourceRefs[0] != (occurrenceSourceRefResponse{StartUnitID: 0, EndUnitID: -1}) {
|
||||||
t.Fatalf("decoded response = %#v", occurrence)
|
t.Fatalf("decoded response = %#v", occurrence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ func TestRegisterPromptAssetsAndPrepareOccurrencePrompt(t *testing.T) {
|
|||||||
"players": promptkit.Inline("occurrence-player"),
|
"players": promptkit.Inline("occurrence-player"),
|
||||||
"party": promptkit.Inline("Mira: ranger"),
|
"party": promptkit.Inline("Mira: ranger"),
|
||||||
"glossary": promptkit.Inline("Greencloak: title"),
|
"glossary": promptkit.Inline("Greencloak: title"),
|
||||||
"npc_registry": promptkit.Inline(`{"npcs":[{"id":"npc:sha256:test","name":"occurrence-npc"}]}`),
|
"npc_registry": promptkit.Inline(`{"npcs":[{"name":"occurrence-npc"}]}`),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ func TestResponseSchemaOwnsOnlyPrivateStructuralContract(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, mutate := range []func(map[string]any){
|
for _, mutate := range []func(map[string]any){
|
||||||
func(record map[string]any) { delete(record, "npc_id") },
|
|
||||||
func(record map[string]any) { delete(record, "name") },
|
func(record map[string]any) { delete(record, "name") },
|
||||||
func(record map[string]any) { record["kind"] = 1 },
|
func(record map[string]any) { record["kind"] = 1 },
|
||||||
|
func(record map[string]any) { record["npc_id"] = "npc:sha256:opaque" },
|
||||||
func(record map[string]any) { record["unexpected"] = true },
|
func(record map[string]any) { record["unexpected"] = true },
|
||||||
func(record map[string]any) {
|
func(record map[string]any) {
|
||||||
record["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
|
record["source_refs"].([]any)[0].(map[string]any)["source_id"] = "assigned later"
|
||||||
@@ -81,7 +81,7 @@ func TestResponseSchemaIsDefensiveAndContentSafe(t *testing.T) {
|
|||||||
|
|
||||||
func validOccurrenceResponse() map[string]any {
|
func validOccurrenceResponse() map[string]any {
|
||||||
return map[string]any{"occurrences": []any{map[string]any{
|
return map[string]any{"occurrences": []any{map[string]any{
|
||||||
"npc_id": "npc:sha256:test", "name": "Mira Thorn", "kind": "dialogue",
|
"name": "Mira Thorn", "kind": "dialogue",
|
||||||
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
"source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 2}},
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,7 +185,7 @@ func (r *Registry) Digest() string {
|
|||||||
return r.digest
|
return r.digest
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectionDigest returns the SHA-256 digest of the exact ID/name prompt
|
// ProjectionDigest returns the SHA-256 digest of the exact names-only prompt
|
||||||
// projection, including for an unbound or empty registry.
|
// projection, including for an unbound or empty registry.
|
||||||
func (r *Registry) ProjectionDigest() string {
|
func (r *Registry) ProjectionDigest() string {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
@@ -202,8 +202,8 @@ func (r *Registry) Count() int {
|
|||||||
return len(r.list.Items)
|
return len(r.list.Items)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptInput returns the ordered ID/name registry projection as a content-safe
|
// PromptInput returns the ordered names-only registry projection as a
|
||||||
// prompt input. Evidence and reference provenance are omitted.
|
// content-safe prompt input. Durable IDs, evidence, and provenance are omitted.
|
||||||
func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return contracts.LLMInputMaterial{}
|
return contracts.LLMInputMaterial{}
|
||||||
@@ -242,7 +242,6 @@ func semanticDigest(content []byte) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type projectedItem struct {
|
type projectedItem struct {
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -253,7 +252,7 @@ type projectedItemRegistry struct {
|
|||||||
func promptProjection(list dnd.ItemRegistry) ([]byte, error) {
|
func promptProjection(list dnd.ItemRegistry) ([]byte, error) {
|
||||||
projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))}
|
projection := projectedItemRegistry{Items: make([]projectedItem, len(list.Items))}
|
||||||
for index, item := range list.Items {
|
for index, item := range list.Items {
|
||||||
projection.Items[index] = projectedItem{ID: item.ID, Name: item.Name}
|
projection.Items[index] = projectedItem{Name: item.Name}
|
||||||
}
|
}
|
||||||
return json.Marshal(projection)
|
return json.Marshal(projection)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveProjectsOrderedIDsAndNamesWithoutEvidence(t *testing.T) {
|
func TestResolveProjectsOrderedNamesWithoutEvidence(t *testing.T) {
|
||||||
registry := resolveRegistry(t, fixture())
|
registry := resolveRegistry(t, fixture())
|
||||||
if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
|
if !registry.Bound() || registry.Digest() == "" || registry.Count() != 2 {
|
||||||
t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count())
|
t.Fatalf("registry identity = bound %t digest %q count %d", registry.Bound(), registry.Digest(), registry.Count())
|
||||||
}
|
}
|
||||||
want := `{"items":[{"id":"` + identity.DeriveID("Silver Key") + `","name":"Silver Key"},{"id":"` + identity.DeriveID("Healer's Kit") + `","name":"Healer's Kit"}]}`
|
want := `{"items":[{"name":"Silver Key"},{"name":"Healer's Kit"}]}`
|
||||||
if got := string(registry.PromptInput().Content); got != want {
|
if got := string(registry.PromptInput().Content); got != want {
|
||||||
t.Fatalf("prompt projection = %s, want %s", got, want)
|
t.Fatalf("prompt projection = %s, want %s", got, want)
|
||||||
}
|
}
|
||||||
for _, forbidden := range []string{"source_refs", "source_id", "session-alpha"} {
|
for _, forbidden := range []string{"item:sha256:", "source_refs", "source_id", "session-alpha"} {
|
||||||
if strings.Contains(string(registry.PromptInput().Content), forbidden) {
|
if strings.Contains(string(registry.PromptInput().Content), forbidden) {
|
||||||
t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content)
|
t.Fatalf("projection leaked %q: %s", forbidden, registry.PromptInput().Content)
|
||||||
}
|
}
|
||||||
@@ -52,6 +52,9 @@ func TestRegistryAccessorsAndLookupsAreDefensive(t *testing.T) {
|
|||||||
if item, ok := registry.LookupID(identity.DeriveID("Healer's Kit")); !ok || item.Name != "Healer's Kit" {
|
if item, ok := registry.LookupID(identity.DeriveID("Healer's Kit")); !ok || item.Name != "Healer's Kit" {
|
||||||
t.Fatalf("LookupID() = %#v, %t", item, ok)
|
t.Fatalf("LookupID() = %#v, %t", item, ok)
|
||||||
}
|
}
|
||||||
|
if _, ok := registry.Lookup("Unknown Item"); ok {
|
||||||
|
t.Fatal("Lookup() accepted an unknown item")
|
||||||
|
}
|
||||||
|
|
||||||
items := registry.Items()
|
items := registry.Items()
|
||||||
items[0].Name = "changed"
|
items[0].Name = "changed"
|
||||||
|
|||||||
187
internal/modules/dnd/locations/registry/grounding.go
Normal file
187
internal/modules/dnd/locations/registry/grounding.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
locationcodec "gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/codec/locationregistry"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Selector identifies one location using its canonical name and, when needed,
|
||||||
|
// source-free registry ranges.
|
||||||
|
type Selector struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
RegistryRefs []RegistryRef `json:"registry_refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryRef is a source-free range used only for location identity grounding.
|
||||||
|
type RegistryRef struct {
|
||||||
|
StartUnitID int `json:"start_unit_id"`
|
||||||
|
EndUnitID int `json:"end_unit_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContextUnit supplies the bounded transcript context for an ambiguous
|
||||||
|
// location's registry ranges.
|
||||||
|
type ContextUnit struct {
|
||||||
|
UnitID int `json:"unit_id"`
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grounding is an immutable, operation-scoped location selector projection.
|
||||||
|
// Its prompt input contains no durable IDs or source IDs.
|
||||||
|
type Grounding struct {
|
||||||
|
promptInput contracts.LLMInputMaterial
|
||||||
|
locationsBySelector map[string]dnd.Location
|
||||||
|
}
|
||||||
|
|
||||||
|
type groundingProjection struct {
|
||||||
|
Locations []groundingProjectionLocation `json:"locations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type groundingProjectionLocation struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
RegistryRefs []RegistryRef `json:"registry_refs"`
|
||||||
|
Context []ContextUnit `json:"context"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGrounding creates the contextual location projection for one source
|
||||||
|
// document and resolved registry.
|
||||||
|
func NewGrounding(registry *Registry, doc *source.SourceDocument) (*Grounding, error) {
|
||||||
|
if registry == nil {
|
||||||
|
return nil, fmt.Errorf("location registry is required")
|
||||||
|
}
|
||||||
|
if doc == nil {
|
||||||
|
return nil, fmt.Errorf("source document is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
index := source.NewDocumentIndex(doc)
|
||||||
|
order := shared.NewSourceRefOrderFromIndex(index)
|
||||||
|
locations := registry.Locations()
|
||||||
|
grouped := make(map[string][]int, len(locations))
|
||||||
|
for locationIndex, location := range locations {
|
||||||
|
key := identity.ComparisonKey(location.Name)
|
||||||
|
if key == "" {
|
||||||
|
return nil, fmt.Errorf("location registry record %d has an empty comparison name", locationIndex)
|
||||||
|
}
|
||||||
|
grouped[key] = append(grouped[key], locationIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
projection := groundingProjection{Locations: make([]groundingProjectionLocation, len(locations))}
|
||||||
|
locationsBySelector := make(map[string]dnd.Location, len(locations))
|
||||||
|
for locationIndex, location := range locations {
|
||||||
|
key := identity.ComparisonKey(location.Name)
|
||||||
|
registryRefs := make([]RegistryRef, 0)
|
||||||
|
context := make([]ContextUnit, 0)
|
||||||
|
if len(grouped[key]) > 1 {
|
||||||
|
var err error
|
||||||
|
registryRefs, context, err = contextualRanges(locationIndex, location.SourceRefs, doc, index, order)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
projection.Locations[locationIndex] = groundingProjectionLocation{
|
||||||
|
Name: location.Name,
|
||||||
|
RegistryRefs: cloneRegistryRefs(registryRefs),
|
||||||
|
Context: cloneContextUnits(context),
|
||||||
|
}
|
||||||
|
selectorKey := contextualSelectorKey(key, registryRefs)
|
||||||
|
if _, exists := locationsBySelector[selectorKey]; exists {
|
||||||
|
return nil, fmt.Errorf("location registry records produce the same contextual selector")
|
||||||
|
}
|
||||||
|
locationsBySelector[selectorKey] = cloneLocation(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := json.Marshal(projection)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("encode location grounding projection: %w", err)
|
||||||
|
}
|
||||||
|
digest := semanticDigest(content)
|
||||||
|
return &Grounding{
|
||||||
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, digest, ""),
|
||||||
|
locationsBySelector: locationsBySelector,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PromptInput returns a defensive copy of the contextual projection for the
|
||||||
|
// location_registry prompt slot.
|
||||||
|
func (g *Grounding) PromptInput() contracts.LLMInputMaterial {
|
||||||
|
if g == nil {
|
||||||
|
return contracts.LLMInputMaterial{}
|
||||||
|
}
|
||||||
|
return g.promptInput.Clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve maps a contextual selector to one canonical location.
|
||||||
|
func (g *Grounding) Resolve(selector Selector) (dnd.Location, bool) {
|
||||||
|
if g == nil {
|
||||||
|
return dnd.Location{}, false
|
||||||
|
}
|
||||||
|
key := identity.ComparisonKey(selector.Name)
|
||||||
|
if key == "" {
|
||||||
|
return dnd.Location{}, false
|
||||||
|
}
|
||||||
|
location, ok := g.locationsBySelector[contextualSelectorKey(key, selector.RegistryRefs)]
|
||||||
|
if !ok {
|
||||||
|
return dnd.Location{}, false
|
||||||
|
}
|
||||||
|
return cloneLocation(location), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextualRanges(locationIndex int, refs []source.SourceRef, doc *source.SourceDocument, index source.DocumentIndex, order shared.SourceRefOrder) ([]RegistryRef, []ContextUnit, error) {
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.SourceID != doc.ID || index.ValidateRef(ref) != nil {
|
||||||
|
return nil, nil, fmt.Errorf("location registry record %d has an invalid source reference", locationIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canonical := order.Canonicalize(refs)
|
||||||
|
ranges := make([]RegistryRef, len(canonical))
|
||||||
|
for refIndex, ref := range canonical {
|
||||||
|
ranges[refIndex] = RegistryRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}
|
||||||
|
}
|
||||||
|
return ranges, contextUnits(doc, index, canonical), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextUnits(doc *source.SourceDocument, index source.DocumentIndex, refs []source.SourceRef) []ContextUnit {
|
||||||
|
units := make([]ContextUnit, 0)
|
||||||
|
seen := make(map[int]struct{})
|
||||||
|
for _, ref := range refs {
|
||||||
|
start, _ := index.Position(ref.StartUnitID)
|
||||||
|
end, _ := index.Position(ref.EndUnitID)
|
||||||
|
for position := start; position <= end; position++ {
|
||||||
|
unit := doc.Units[position]
|
||||||
|
if _, exists := seen[unit.ID]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[unit.ID] = struct{}{}
|
||||||
|
units = append(units, ContextUnit{UnitID: unit.ID, Text: unit.Text})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return units
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextualSelectorKey(name string, refs []RegistryRef) string {
|
||||||
|
key, _ := json.Marshal(struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
RegistryRefs []RegistryRef `json:"registry_refs"`
|
||||||
|
}{Name: name, RegistryRefs: cloneRegistryRefs(refs)})
|
||||||
|
return string(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneRegistryRefs(values []RegistryRef) []RegistryRef {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return []RegistryRef{}
|
||||||
|
}
|
||||||
|
return append([]RegistryRef(nil), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneContextUnits(values []ContextUnit) []ContextUnit {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return []ContextUnit{}
|
||||||
|
}
|
||||||
|
return append([]ContextUnit(nil), values...)
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
ReferenceSlot = "location_registry"
|
ReferenceSlot = "location_registry"
|
||||||
MaxBytes = 1048576
|
MaxBytes = 1048576
|
||||||
emptyPrompt = `{"locations":[]}`
|
emptyRegistryContent = `{"locations":[]}`
|
||||||
)
|
)
|
||||||
|
|
||||||
// Registry is an immutable, validated location registry prepared for prompt
|
// Registry is an immutable, validated location registry prepared for prompt
|
||||||
@@ -30,8 +30,7 @@ type Registry struct {
|
|||||||
list dnd.LocationRegistry
|
list dnd.LocationRegistry
|
||||||
canonical []byte
|
canonical []byte
|
||||||
digest string
|
digest string
|
||||||
projectionDigest string
|
identityDigest string
|
||||||
promptInput contracts.LLMInputMaterial
|
|
||||||
lookupByID map[string]int
|
lookupByID map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,13 +98,12 @@ func locationReferenceSpec() registryresolver.ReferenceSpec {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func emptyRegistry() *Registry {
|
func emptyRegistry() *Registry {
|
||||||
content := []byte(emptyPrompt)
|
content := []byte(emptyRegistryContent)
|
||||||
projectionDigest := semanticDigest(content)
|
identityDigest := semanticDigest(content)
|
||||||
return &Registry{
|
return &Registry{
|
||||||
list: dnd.LocationRegistry{Locations: []dnd.Location{}},
|
list: dnd.LocationRegistry{Locations: []dnd.Location{}},
|
||||||
canonical: append([]byte(nil), content...),
|
canonical: append([]byte(nil), content...),
|
||||||
projectionDigest: projectionDigest,
|
identityDigest: identityDigest,
|
||||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, content, projectionDigest, ""),
|
|
||||||
lookupByID: map[string]int{},
|
lookupByID: map[string]int{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,19 +127,18 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
|||||||
for index, location := range list.Locations {
|
for index, location := range list.Locations {
|
||||||
lookupByID[location.ID] = index
|
lookupByID[location.ID] = index
|
||||||
}
|
}
|
||||||
projection, err := promptProjection(list)
|
projection, err := identityProjection(list)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("encode location prompt projection: %w", err)
|
return nil, fmt.Errorf("encode location prompt projection: %w", err)
|
||||||
}
|
}
|
||||||
digest := semanticDigest(content)
|
digest := semanticDigest(content)
|
||||||
projectionDigest := semanticDigest(projection)
|
identityDigest := semanticDigest(projection)
|
||||||
return &Registry{
|
return &Registry{
|
||||||
bound: true,
|
bound: true,
|
||||||
list: list,
|
list: list,
|
||||||
canonical: append([]byte(nil), content...),
|
canonical: append([]byte(nil), content...),
|
||||||
digest: digest,
|
digest: digest,
|
||||||
projectionDigest: projectionDigest,
|
identityDigest: identityDigest,
|
||||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, locationcodec.MediaType, projection, projectionDigest, ""),
|
|
||||||
lookupByID: lookupByID,
|
lookupByID: lookupByID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -182,13 +179,13 @@ func (r *Registry) Digest() string {
|
|||||||
return r.digest
|
return r.digest
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProjectionDigest returns the digest of the exact source-free prompt
|
// IdentityDigest returns the digest of the ordered ID/name identity projection
|
||||||
// projection, including for an unbound or empty registry.
|
// used by deterministic consumers.
|
||||||
func (r *Registry) ProjectionDigest() string {
|
func (r *Registry) IdentityDigest() string {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return r.projectionDigest
|
return r.identityDigest
|
||||||
}
|
}
|
||||||
|
|
||||||
// Count returns the number of validated location records.
|
// Count returns the number of validated location records.
|
||||||
@@ -199,15 +196,6 @@ func (r *Registry) Count() int {
|
|||||||
return len(r.list.Locations)
|
return len(r.list.Locations)
|
||||||
}
|
}
|
||||||
|
|
||||||
// PromptInput returns the ordered ID-and-name projection without evidence or
|
|
||||||
// reference provenance.
|
|
||||||
func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
|
||||||
if r == nil {
|
|
||||||
return contracts.LLMInputMaterial{}
|
|
||||||
}
|
|
||||||
return r.promptInput.Clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup returns the canonical location for an exact durable location ID.
|
// Lookup returns the canonical location for an exact durable location ID.
|
||||||
func (r *Registry) Lookup(id string) (dnd.Location, bool) {
|
func (r *Registry) Lookup(id string) (dnd.Location, bool) {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
@@ -231,19 +219,19 @@ func semanticDigest(content []byte) string {
|
|||||||
return "sha256:" + hex.EncodeToString(sum[:])
|
return "sha256:" + hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
type projectedLocation struct {
|
type identityProjectedLocation struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type projectedLocationRegistry struct {
|
type identityProjectedLocationRegistry struct {
|
||||||
Locations []projectedLocation `json:"locations"`
|
Locations []identityProjectedLocation `json:"locations"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func promptProjection(list dnd.LocationRegistry) ([]byte, error) {
|
func identityProjection(list dnd.LocationRegistry) ([]byte, error) {
|
||||||
projection := projectedLocationRegistry{Locations: make([]projectedLocation, len(list.Locations))}
|
projection := identityProjectedLocationRegistry{Locations: make([]identityProjectedLocation, len(list.Locations))}
|
||||||
for index, location := range list.Locations {
|
for index, location := range list.Locations {
|
||||||
projection.Locations[index] = projectedLocation{ID: location.ID, Name: location.Name}
|
projection.Locations[index] = identityProjectedLocation{ID: location.ID, Name: location.Name}
|
||||||
}
|
}
|
||||||
return json.Marshal(projection)
|
return json.Marshal(projection)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package registry
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -12,36 +14,26 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/locations/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestResolveUnboundRegistryHasEmptyProjection(t *testing.T) {
|
func TestResolveUnboundRegistryHasEmptyIdentity(t *testing.T) {
|
||||||
registry, err := Resolve(contracts.ReferenceSet{})
|
registry, err := Resolve(contracts.ReferenceSet{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
input := registry.PromptInput()
|
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(registry.CanonicalBytes()) != emptyRegistryContent {
|
||||||
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt {
|
t.Fatalf("unbound registry = %#v", registry)
|
||||||
t.Fatalf("unbound registry = %#v, input = %#v", registry, input)
|
|
||||||
}
|
}
|
||||||
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
if registry.IdentityDigest() == "" {
|
||||||
t.Fatalf("projection digest/input = %q/%#v", registry.ProjectionDigest(), input)
|
t.Fatal("unbound registry identity digest is empty")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolveProjectsOrderedLocationsWithoutEvidence(t *testing.T) {
|
func TestResolveRetainsSeparateDurableAndIdentityDigests(t *testing.T) {
|
||||||
registry := resolveList(t, registryFixture())
|
registry := resolveList(t, registryFixture())
|
||||||
if !registry.Bound() || registry.Count() != 2 || registry.Digest() == "" {
|
if !registry.Bound() || registry.Count() != 2 || registry.Digest() == "" {
|
||||||
t.Fatalf("registry identity = bound %t count %d digest %q", registry.Bound(), registry.Count(), registry.Digest())
|
t.Fatalf("registry identity = bound %t count %d digest %q", registry.Bound(), registry.Count(), registry.Digest())
|
||||||
}
|
}
|
||||||
projection := string(registry.PromptInput().Content)
|
if registry.IdentityDigest() == "" || registry.Digest() == registry.IdentityDigest() {
|
||||||
if !strings.Contains(projection, `"locations":[{"id":`) || !strings.Contains(projection, `"name":"The Tavern"`) || !strings.Contains(projection, `"name":"The Tavern"},{"id":`) {
|
t.Fatalf("full/identity digests = %q/%q", registry.Digest(), registry.IdentityDigest())
|
||||||
t.Fatalf("projection ordering = %s", projection)
|
|
||||||
}
|
|
||||||
for _, forbidden := range []string{"source_refs", "source_id", "session-alpha"} {
|
|
||||||
if strings.Contains(projection, forbidden) {
|
|
||||||
t.Fatalf("projection leaked %q: %s", forbidden, projection)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if registry.PromptInput().Digest != registry.ProjectionDigest() || registry.Digest() == registry.ProjectionDigest() {
|
|
||||||
t.Fatalf("full/projection digests = %q/%q", registry.Digest(), registry.ProjectionDigest())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,12 +52,10 @@ func TestRegistryLookupUsesIDAndReturnsDefensiveCopies(t *testing.T) {
|
|||||||
locations[0].SourceRefs[0].SourceID = "changed"
|
locations[0].SourceRefs[0].SourceID = "changed"
|
||||||
canonical := registry.CanonicalBytes()
|
canonical := registry.CanonicalBytes()
|
||||||
canonical[0] = '['
|
canonical[0] = '['
|
||||||
input := registry.PromptInput()
|
|
||||||
input.Content[0] = '['
|
|
||||||
if next, ok := registry.Lookup(first.ID); !ok || next.Name != first.Name || next.SourceRefs[0].SourceID != "session-alpha" {
|
if next, ok := registry.Lookup(first.ID); !ok || next.Name != first.Name || next.SourceRefs[0].SourceID != "session-alpha" {
|
||||||
t.Fatalf("registry mutated through accessor: %#v, %t", next, ok)
|
t.Fatalf("registry mutated through accessor: %#v, %t", next, ok)
|
||||||
}
|
}
|
||||||
if registry.CanonicalBytes()[0] != '{' || registry.PromptInput().Content[0] != '{' {
|
if registry.CanonicalBytes()[0] != '{' {
|
||||||
t.Fatal("registry bytes mutated through accessor")
|
t.Fatal("registry bytes mutated through accessor")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,3 +179,180 @@ func item(content []byte) contracts.ReferenceItem {
|
|||||||
func referenceSet(items ...contracts.ReferenceItem) contracts.ReferenceSet {
|
func referenceSet(items ...contracts.ReferenceItem) contracts.ReferenceSet {
|
||||||
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: items}}}
|
return contracts.ReferenceSet{Slots: map[string]contracts.ResolvedReferenceSlot{ReferenceSlot: {Items: items}}}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGroundingProjectsAndResolvesUniqueAndSameNameLocations(t *testing.T) {
|
||||||
|
doc := groundingDocument()
|
||||||
|
sharedName := "The Tavern"
|
||||||
|
firstRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}
|
||||||
|
secondRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}
|
||||||
|
marketRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 40, EndUnitID: 40}}
|
||||||
|
list := dnd.LocationRegistry{Locations: []dnd.Location{
|
||||||
|
location(sharedName, firstRefs),
|
||||||
|
location(sharedName, secondRefs),
|
||||||
|
location("Market", marketRefs),
|
||||||
|
}}
|
||||||
|
registry := resolveList(t, list)
|
||||||
|
grounding, err := NewGrounding(registry, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
projection := decodeGroundingProjection(t, grounding.PromptInput().Content)
|
||||||
|
if len(projection.Locations) != 3 {
|
||||||
|
t.Fatalf("projection = %#v", projection)
|
||||||
|
}
|
||||||
|
first := projection.Locations[0]
|
||||||
|
if first.Name != sharedName || !reflect.DeepEqual(first.RegistryRefs, []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}) || !reflect.DeepEqual(first.Context, []ContextUnit{{UnitID: 30, Text: "Tavern entrance"}, {UnitID: 20, Text: "Tavern cellar"}}) {
|
||||||
|
t.Fatalf("first contextual projection = %#v", first)
|
||||||
|
}
|
||||||
|
second := projection.Locations[1]
|
||||||
|
if !reflect.DeepEqual(second.RegistryRefs, []RegistryRef{{StartUnitID: 10, EndUnitID: 10}}) || !reflect.DeepEqual(second.Context, []ContextUnit{{UnitID: 10, Text: "Tavern common room"}}) {
|
||||||
|
t.Fatalf("second contextual projection = %#v", second)
|
||||||
|
}
|
||||||
|
market := projection.Locations[2]
|
||||||
|
if market.Name != "Market" || len(market.RegistryRefs) != 0 || len(market.Context) != 0 {
|
||||||
|
t.Fatalf("unique-name projection = %#v", market)
|
||||||
|
}
|
||||||
|
if bytes.Contains(grounding.PromptInput().Content, []byte("location:sha256:")) || bytes.Contains(grounding.PromptInput().Content, []byte(`"source_id"`)) {
|
||||||
|
t.Fatalf("grounding projection exposed durable identity: %s", grounding.PromptInput().Content)
|
||||||
|
}
|
||||||
|
if grounding.PromptInput().Digest == "" || grounding.PromptInput().Digest == registry.IdentityDigest() {
|
||||||
|
t.Fatalf("grounding/identity digests = %q/%q", grounding.PromptInput().Digest, registry.IdentityDigest())
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, ok := grounding.Resolve(Selector{Name: " the tavern ", RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}})
|
||||||
|
if !ok || resolved.ID != list.Locations[0].ID || resolved.Name != sharedName {
|
||||||
|
t.Fatalf("Resolve(same name) = %#v, %t", resolved, ok)
|
||||||
|
}
|
||||||
|
if resolved, ok = grounding.Resolve(Selector{Name: "MARKET", RegistryRefs: []RegistryRef{}}); !ok || resolved.ID != list.Locations[2].ID {
|
||||||
|
t.Fatalf("Resolve(unique name) = %#v, %t", resolved, ok)
|
||||||
|
}
|
||||||
|
for _, selector := range []Selector{
|
||||||
|
{Name: "Market", RegistryRefs: []RegistryRef{{StartUnitID: 40, EndUnitID: 40}}},
|
||||||
|
{Name: sharedName, RegistryRefs: []RegistryRef{}},
|
||||||
|
{Name: sharedName, RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}}},
|
||||||
|
{Name: sharedName, RegistryRefs: []RegistryRef{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 30, EndUnitID: 30}}},
|
||||||
|
{Name: "Unknown", RegistryRefs: []RegistryRef{}},
|
||||||
|
} {
|
||||||
|
if _, ok := grounding.Resolve(selector); ok {
|
||||||
|
t.Fatalf("Resolve(%#v) accepted an unsupported selector", selector)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input := grounding.PromptInput()
|
||||||
|
input.Content[0] = '['
|
||||||
|
resolved.SourceRefs[0].SourceID = "changed"
|
||||||
|
if grounding.PromptInput().Content[0] != '{' {
|
||||||
|
t.Fatal("grounding prompt input was mutable")
|
||||||
|
}
|
||||||
|
if next, ok := grounding.Resolve(Selector{Name: sharedName, RegistryRefs: []RegistryRef{{StartUnitID: 30, EndUnitID: 30}, {StartUnitID: 20, EndUnitID: 20}}}); !ok || next.SourceRefs[0].SourceID != doc.ID {
|
||||||
|
t.Fatalf("grounding resolved location was mutable: %#v, %t", next, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroundingHandlesEmptyRegistriesAndIdentityFingerprints(t *testing.T) {
|
||||||
|
doc := groundingDocument()
|
||||||
|
empty := resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{}})
|
||||||
|
grounding, err := NewGrounding(empty, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if projection := decodeGroundingProjection(t, grounding.PromptInput().Content); projection.Locations == nil || len(projection.Locations) != 0 {
|
||||||
|
t.Fatalf("empty projection = %#v", projection)
|
||||||
|
}
|
||||||
|
if _, ok := grounding.Resolve(Selector{Name: "Market", RegistryRefs: []RegistryRef{}}); ok {
|
||||||
|
t.Fatal("empty grounding resolved a location")
|
||||||
|
}
|
||||||
|
|
||||||
|
baseRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}
|
||||||
|
base := resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{location("Market", baseRefs)}})
|
||||||
|
expandedRefs := append(append([]source.SourceRef(nil), baseRefs...), source.SourceRef{SourceID: doc.ID, StartUnitID: 40, EndUnitID: 40})
|
||||||
|
expanded := resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{location("Market", expandedRefs)}})
|
||||||
|
baseGrounding, err := NewGrounding(base, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
expandedGrounding, err := NewGrounding(expanded, doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if base.IdentityDigest() != expanded.IdentityDigest() || baseGrounding.PromptInput().Digest != expandedGrounding.PromptInput().Digest {
|
||||||
|
t.Fatalf("identity/model fingerprints = %q/%q and %q/%q", base.IdentityDigest(), expanded.IdentityDigest(), baseGrounding.PromptInput().Digest, expandedGrounding.PromptInput().Digest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGroundingRejectsUnsafeContextualConstruction(t *testing.T) {
|
||||||
|
doc := groundingDocument()
|
||||||
|
validRefs := []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}
|
||||||
|
valid := resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{location("Market", validRefs)}})
|
||||||
|
if _, err := NewGrounding(valid, nil); err == nil {
|
||||||
|
t.Fatal("NewGrounding(nil source) error = nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
registry *Registry
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "foreign reference",
|
||||||
|
registry: resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{
|
||||||
|
location("The Tavern", []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}),
|
||||||
|
location("The Tavern", []source.SourceRef{{SourceID: "other-source", StartUnitID: 10, EndUnitID: 10}}),
|
||||||
|
}}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid reference",
|
||||||
|
registry: resolveList(t, dnd.LocationRegistry{Locations: []dnd.Location{
|
||||||
|
location("The Tavern", []source.SourceRef{{SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}),
|
||||||
|
location("The Tavern", []source.SourceRef{{SourceID: doc.ID, StartUnitID: 999, EndUnitID: 999}}),
|
||||||
|
}}),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "selector collision",
|
||||||
|
registry: &Registry{list: dnd.LocationRegistry{Locations: []dnd.Location{
|
||||||
|
{ID: "first", Name: "The Tavern", SourceRefs: validRefs},
|
||||||
|
{ID: "second", Name: "The Tavern", SourceRefs: validRefs},
|
||||||
|
}}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty comparison name",
|
||||||
|
registry: &Registry{list: dnd.LocationRegistry{Locations: []dnd.Location{{ID: "first", Name: " ", SourceRefs: validRefs}}}},
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
if _, err := NewGrounding(test.registry, doc); err == nil || strings.Contains(err.Error(), "Tavern entrance") {
|
||||||
|
t.Fatalf("NewGrounding() error = %v, want content-safe failure", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type decodedGroundingProjection struct {
|
||||||
|
Locations []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
RegistryRefs []RegistryRef `json:"registry_refs"`
|
||||||
|
Context []ContextUnit `json:"context"`
|
||||||
|
} `json:"locations"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeGroundingProjection(t *testing.T, content []byte) decodedGroundingProjection {
|
||||||
|
t.Helper()
|
||||||
|
var projection decodedGroundingProjection
|
||||||
|
if err := json.Unmarshal(content, &projection); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return projection
|
||||||
|
}
|
||||||
|
|
||||||
|
func groundingDocument() *source.SourceDocument {
|
||||||
|
return &source.SourceDocument{ID: "session-alpha", Units: []source.SourceUnit{
|
||||||
|
{ID: 30, Text: "Tavern entrance"},
|
||||||
|
{ID: 10, Text: "Tavern common room"},
|
||||||
|
{ID: 20, Text: "Tavern cellar"},
|
||||||
|
{ID: 40, Text: "Market square"},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func location(name string, refs []source.SourceRef) dnd.Location {
|
||||||
|
return dnd.Location{ID: identity.DeriveID(name, refs), Name: name, SourceRefs: refs}
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "dnd/item-registry"
|
Key = "dnd/item-registry"
|
||||||
PromptID = "dnd.item_registry.normalize"
|
PromptID = "dnd.item_registry.normalize"
|
||||||
normalizationPolicy = "dnd.item_registry.normalize.v1"
|
normalizationPolicy = "dnd.item_registry.normalize.v2"
|
||||||
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
||||||
semanticContextRadius = 2
|
semanticContextRadius = 2
|
||||||
NormalizationPolicy = normalizationPolicy
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -101,7 +102,7 @@ func TestNormalizeAppliesSafeAliasProposal(t *testing.T) {
|
|||||||
t.Fatalf("merged item = %#v, warnings = %#v", merged, result.Warnings)
|
t.Fatalf("merged item = %#v, warnings = %#v", merged, result.Warnings)
|
||||||
}
|
}
|
||||||
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
|
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
|
||||||
if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) {
|
if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) {
|
||||||
t.Fatalf("private inputs = %s", encoded)
|
t.Fatalf("private inputs = %s", encoded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -261,7 +262,7 @@ func TestRegisterPromptAssetsPreparesItemNormalizationPrompt(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"Rope","source_refs":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "item-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"Rope","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -285,10 +286,51 @@ func (c *recordingNormalizerClient) CompleteStructured(_ context.Context, reques
|
|||||||
if response == "" {
|
if response == "" {
|
||||||
response = `{"duplicate_groups":[]}`
|
response = `{"duplicate_groups":[]}`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(response), output); err != nil {
|
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
|
||||||
|
if err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, err
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
}
|
}
|
||||||
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil
|
if err := json.Unmarshal(content, output); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
|
||||||
|
if !strings.Contains(response, "candidate-") {
|
||||||
|
return []byte(response), nil
|
||||||
|
}
|
||||||
|
var selection struct {
|
||||||
|
DuplicateGroups []struct {
|
||||||
|
Members []string `json:"members"`
|
||||||
|
Canonical string `json:"canonical"`
|
||||||
|
} `json:"duplicate_groups"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(response), &selection); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var candidates struct {
|
||||||
|
Candidates []entityreconcile.Selector `json:"candidates"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selector := func(key string) entityreconcile.Selector {
|
||||||
|
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
|
||||||
|
if err != nil || index < 1 || index > len(candidates.Candidates) {
|
||||||
|
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
|
||||||
|
}
|
||||||
|
return candidates.Candidates[index-1].Clone()
|
||||||
|
}
|
||||||
|
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
|
||||||
|
for index, group := range selection.DuplicateGroups {
|
||||||
|
members := make([]entityreconcile.Selector, len(group.Members))
|
||||||
|
for memberIndex, key := range group.Members {
|
||||||
|
members[memberIndex] = selector(key)
|
||||||
|
}
|
||||||
|
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
|
||||||
|
}
|
||||||
|
return json.Marshal(proposal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
|
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||||
{Name: "location_registry", Value: n.locationResolver.Seeded().ProjectionDigest()},
|
{Name: "location_registry", Value: n.locationResolver.Seeded().IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "dnd/location-registry"
|
Key = "dnd/location-registry"
|
||||||
PromptID = "dnd.location_registry.normalize"
|
PromptID = "dnd.location_registry.normalize"
|
||||||
normalizationPolicy = "dnd.location_registry.normalize.v1"
|
normalizationPolicy = "dnd.location_registry.normalize.v2"
|
||||||
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
||||||
semanticContextRadius = 2
|
semanticContextRadius = 2
|
||||||
NormalizationPolicy = normalizationPolicy
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestNormalizePreparesOnlyExactDuplicatesAndRetainsSameNameAndNestedPlaces(t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeAppliesSafeAliasGroupAndUsesOpaqueInputs(t *testing.T) {
|
func TestNormalizeAppliesSafeAliasGroupAndUsesContextualInputs(t *testing.T) {
|
||||||
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
|
client := &recordingLocationNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000002"}]}`}
|
||||||
doc := semanticDocument()
|
doc := semanticDocument()
|
||||||
input := dnd.LocationRegistry{Locations: []dnd.Location{
|
input := dnd.LocationRegistry{Locations: []dnd.Location{
|
||||||
@@ -77,7 +77,7 @@ func TestNormalizeAppliesSafeAliasGroupAndUsesOpaqueInputs(t *testing.T) {
|
|||||||
t.Fatalf("merged location = %#v, warnings = %#v", merged, result.Warnings)
|
t.Fatalf("merged location = %#v, warnings = %#v", merged, result.Warnings)
|
||||||
}
|
}
|
||||||
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
|
encoded := string(client.requests[0].Inputs["candidates"].Content) + string(client.requests[0].Inputs["transcript"].Content)
|
||||||
if strings.Contains(encoded, doc.ID) || !strings.Contains(encoded, "candidate-000001") || strings.Contains(encoded, merged.ID) {
|
if strings.Contains(encoded, doc.ID) || strings.Contains(encoded, "candidate-") || strings.Contains(encoded, merged.ID) || !strings.Contains(encoded, `"source_refs"`) {
|
||||||
t.Fatalf("private inputs = %s", encoded)
|
t.Fatalf("private inputs = %s", encoded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"The Tavern","source_refs":[]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "location-normalize-test", Inputs: map[string]promptkit.ArtifactRef{"candidates": promptkit.Inline(`{"candidates":[{"name":"The Tavern","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`), "transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`)}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -40,14 +40,14 @@ func TestRegisterPromptAssetsPreparesLocationNormalizationPrompt(t *testing.T) {
|
|||||||
t.Fatalf("message %d cache = %#v", index, prepared.Messages[index].CacheControl)
|
t.Fatalf("message %d cache = %#v", index, prepared.Messages[index].CacheControl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !strings.Contains(prepared.Messages[3].Content, "candidate-000001") || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
|
if !strings.Contains(prepared.Messages[3].Content, `"The Tavern"`) || strings.Contains(prepared.Messages[3].Content, `"candidate-`) || strings.Contains(prepared.Messages[3].Content, `"windows"`) {
|
||||||
t.Fatalf("candidate message = %q", prepared.Messages[3].Content)
|
t.Fatalf("candidate message = %q", prepared.Messages[3].Content)
|
||||||
}
|
}
|
||||||
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, "candidate-000001") {
|
if !strings.Contains(prepared.Messages[4].Content, `"windows"`) || strings.Contains(prepared.Messages[4].Content, `"The Tavern"`) {
|
||||||
t.Fatalf("transcript message = %q", prepared.Messages[4].Content)
|
t.Fatalf("transcript message = %q", prepared.Messages[4].Content)
|
||||||
}
|
}
|
||||||
for index, message := range prepared.Messages {
|
for index, message := range prepared.Messages {
|
||||||
if index != 3 && strings.Contains(message.Content, "candidate-000001") {
|
if index != 3 && strings.Contains(message.Content, `"The Tavern"`) {
|
||||||
t.Errorf("message %d unexpectedly rendered candidate input", index)
|
t.Errorf("message %d unexpectedly rendered candidate input", index)
|
||||||
}
|
}
|
||||||
if index != 4 && strings.Contains(message.Content, `"windows"`) {
|
if index != 4 && strings.Contains(message.Content, `"windows"`) {
|
||||||
|
|||||||
@@ -3,11 +3,14 @@ package locationregistry
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
|
||||||
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd"
|
||||||
|
"gitea.maximumdirect.net/eric/notarius/internal/modules/dnd/shared/entityreconcile"
|
||||||
)
|
)
|
||||||
|
|
||||||
type recordingLocationNormalizerClient struct {
|
type recordingLocationNormalizerClient struct {
|
||||||
@@ -25,10 +28,51 @@ func (c *recordingLocationNormalizerClient) CompleteStructured(_ context.Context
|
|||||||
if response == "" {
|
if response == "" {
|
||||||
response = `{"duplicate_groups":[]}`
|
response = `{"duplicate_groups":[]}`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(response), output); err != nil {
|
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
|
||||||
|
if err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, err
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
}
|
}
|
||||||
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil
|
if err := json.Unmarshal(content, output); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
|
||||||
|
if !strings.Contains(response, "candidate-") {
|
||||||
|
return []byte(response), nil
|
||||||
|
}
|
||||||
|
var selection struct {
|
||||||
|
DuplicateGroups []struct {
|
||||||
|
Members []string `json:"members"`
|
||||||
|
Canonical string `json:"canonical"`
|
||||||
|
} `json:"duplicate_groups"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(response), &selection); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var candidates struct {
|
||||||
|
Candidates []entityreconcile.Selector `json:"candidates"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selector := func(key string) entityreconcile.Selector {
|
||||||
|
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
|
||||||
|
if err != nil || index < 1 || index > len(candidates.Candidates) {
|
||||||
|
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
|
||||||
|
}
|
||||||
|
return candidates.Candidates[index-1].Clone()
|
||||||
|
}
|
||||||
|
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
|
||||||
|
for index, group := range selection.DuplicateGroups {
|
||||||
|
members := make([]entityreconcile.Selector, len(group.Members))
|
||||||
|
for memberIndex, key := range group.Members {
|
||||||
|
members[memberIndex] = selector(key)
|
||||||
|
}
|
||||||
|
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
|
||||||
|
}
|
||||||
|
return json.Marshal(proposal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
|
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func (n *Normalizer) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "normalization_policy", Value: normalizationPolicy},
|
{Name: "normalization_policy", Value: normalizationPolicy},
|
||||||
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityPromptInput().Digest},
|
{Name: "npc_registry", Value: n.npcResolver.Seeded().IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
Key = "dnd/npc-registry"
|
Key = "dnd/npc-registry"
|
||||||
PromptID = "dnd.npc_registry.normalize"
|
PromptID = "dnd.npc_registry.normalize"
|
||||||
normalizationPolicy = "dnd.npc_registry.normalize.v3"
|
normalizationPolicy = "dnd.npc_registry.normalize.v4"
|
||||||
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
semanticContextPolicy = "dnd.entity_reconcile.context.v1"
|
||||||
NormalizationPolicy = normalizationPolicy
|
NormalizationPolicy = normalizationPolicy
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -156,10 +157,51 @@ func (c *recordingNPCNormalizerClient) CompleteStructured(_ context.Context, req
|
|||||||
if response == "" {
|
if response == "" {
|
||||||
response = `{"duplicate_groups":[]}`
|
response = `{"duplicate_groups":[]}`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal([]byte(response), output); err != nil {
|
content, err := contextualProposalResponse(response, request.Inputs["candidates"].Content)
|
||||||
|
if err != nil {
|
||||||
return contracts.StructuredCompletionResponse{}, err
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
}
|
}
|
||||||
return contracts.StructuredCompletionResponse{Content: json.RawMessage(response)}, nil
|
if err := json.Unmarshal(content, output); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func contextualProposalResponse(response string, candidateContent []byte) ([]byte, error) {
|
||||||
|
if !strings.Contains(response, "candidate-") {
|
||||||
|
return []byte(response), nil
|
||||||
|
}
|
||||||
|
var selection struct {
|
||||||
|
DuplicateGroups []struct {
|
||||||
|
Members []string `json:"members"`
|
||||||
|
Canonical string `json:"canonical"`
|
||||||
|
} `json:"duplicate_groups"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(response), &selection); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var candidates struct {
|
||||||
|
Candidates []entityreconcile.Selector `json:"candidates"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selector := func(key string) entityreconcile.Selector {
|
||||||
|
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
|
||||||
|
if err != nil || index < 1 || index > len(candidates.Candidates) {
|
||||||
|
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
|
||||||
|
}
|
||||||
|
return candidates.Candidates[index-1].Clone()
|
||||||
|
}
|
||||||
|
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
|
||||||
|
for index, group := range selection.DuplicateGroups {
|
||||||
|
members := make([]entityreconcile.Selector, len(group.Members))
|
||||||
|
for memberIndex, key := range group.Members {
|
||||||
|
members[memberIndex] = selector(key)
|
||||||
|
}
|
||||||
|
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
|
||||||
|
}
|
||||||
|
return json.Marshal(proposal)
|
||||||
}
|
}
|
||||||
|
|
||||||
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
|
func newNormalizer(t *testing.T, client contracts.StructuredLLMClient) *Normalizer {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestRegisterPromptAssetsPreparesNormalizationPrompt(t *testing.T) {
|
|||||||
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
prepared, err := engine.Prepare(context.Background(), promptkit.RunRequest{
|
||||||
PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "normalize-test-profile",
|
PromptID: PromptID, PromptVersion: entityreconcile.SchemaVersion, ProfileID: "normalize-test-profile",
|
||||||
Inputs: map[string]promptkit.ArtifactRef{
|
Inputs: map[string]promptkit.ArtifactRef{
|
||||||
"candidates": promptkit.Inline(`{"candidates":[{"key":"candidate-000001","name":"Mira","source_refs":[]}]}`),
|
"candidates": promptkit.Inline(`{"candidates":[{"name":"Mira","source_refs":[{"start_unit_id":1,"end_unit_id":1}]}]}`),
|
||||||
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
|
"transcript": promptkit.Inline(`{"windows":[{"units":[]}]}`),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package npcregistry
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -174,8 +175,8 @@ func TestNormalizeDoesNotAccumulateSafeGroupsAcrossAttempts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) {
|
func TestNormalizeRetainsRecordsWhenAProposalUsesAnUnknownDescriptor(t *testing.T) {
|
||||||
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`}
|
client := &recordingNPCNormalizerClient{response: `{"duplicate_groups":[{"members":["candidate-000001","unknown"],"canonical":"candidate-000001"}]}`}
|
||||||
normalizer := newNormalizer(t, client)
|
normalizer := newNormalizer(t, client)
|
||||||
doc := semanticDocument()
|
doc := semanticDocument()
|
||||||
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
|
input := dnd.NPCRegistry{NPCs: []dnd.NPC{
|
||||||
@@ -187,8 +188,8 @@ func TestNormalizeExcludesIneligibleRecordsFromSemanticGroups(t *testing.T) {
|
|||||||
if err != nil || result.Retry == nil || len(result.Value.NPCs) != 3 {
|
if err != nil || result.Retry == nil || len(result.Value.NPCs) != 3 {
|
||||||
t.Fatalf("Normalize() = %#v, %v; want unchanged retry fallback", result, err)
|
t.Fatalf("Normalize() = %#v, %v; want unchanged retry fallback", result, err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(result.Retry.Message, "member_ineligible") || result.Value.NPCs[1].Name != "Broken" {
|
if !strings.Contains(result.Retry.Message, "member_unknown") || result.Value.NPCs[1].Name != "Broken" {
|
||||||
t.Fatalf("result = %#v, want ineligible record excluded but preserved", result)
|
t.Fatalf("result = %#v, want unknown descriptor rejected without dropping a record", result)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +207,14 @@ func TestReconciliationCandidatesKeepEqualDisplayNamesDistinct(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 {
|
if !reflect.DeepEqual(keys, []string{"candidate-000001", "candidate-000002"}) || strings.Count(string(materials.Candidates.Content), `"The Guard"`) != 2 {
|
||||||
t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content)
|
t.Fatalf("candidate keys and inputs = %#v, %s; want distinct equal-display candidates", keys, materials.Candidates.Content)
|
||||||
}
|
}
|
||||||
|
var candidateInput struct {
|
||||||
|
Candidates []entityreconcile.Selector `json:"candidates"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(materials.Candidates.Content, &candidateInput); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{
|
assessment := materials.Assess(entityreconcile.ProposalResponse{DuplicateGroups: []entityreconcile.DuplicateGroup{{
|
||||||
Members: keys, Canonical: keys[1],
|
Members: candidateInput.Candidates, Canonical: candidateInput.Candidates[1],
|
||||||
}}})
|
}}})
|
||||||
groups := reconciliationGroups(assessment, keys)
|
groups := reconciliationGroups(assessment, keys)
|
||||||
if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 {
|
if assessment.DiscardedGroups() != 0 || len(groups) != 1 || groups[0].canonical != 1 {
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ type Registry struct {
|
|||||||
canonical []byte
|
canonical []byte
|
||||||
digest string
|
digest string
|
||||||
projectionDigest string
|
projectionDigest string
|
||||||
|
identityDigest string
|
||||||
promptInput contracts.LLMInputMaterial
|
promptInput contracts.LLMInputMaterial
|
||||||
identityInput contracts.LLMInputMaterial
|
|
||||||
lookupByKey map[string]int
|
lookupByKey map[string]int
|
||||||
lookupByID map[string]int
|
lookupByID map[string]int
|
||||||
}
|
}
|
||||||
@@ -110,8 +110,8 @@ func emptyRegistry() *Registry {
|
|||||||
list: dnd.NPCRegistry{NPCs: []dnd.NPC{}},
|
list: dnd.NPCRegistry{NPCs: []dnd.NPC{}},
|
||||||
canonical: append([]byte(nil), content...),
|
canonical: append([]byte(nil), content...),
|
||||||
projectionDigest: projectionDigest,
|
projectionDigest: projectionDigest,
|
||||||
|
identityDigest: projectionDigest,
|
||||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
||||||
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, content, projectionDigest, ""),
|
|
||||||
lookupByKey: map[string]int{},
|
lookupByKey: map[string]int{},
|
||||||
lookupByID: map[string]int{},
|
lookupByID: map[string]int{},
|
||||||
}
|
}
|
||||||
@@ -155,9 +155,9 @@ func loadRegistry(referenceContent []byte) (*Registry, error) {
|
|||||||
canonical: append([]byte(nil), content...),
|
canonical: append([]byte(nil), content...),
|
||||||
digest: digest,
|
digest: digest,
|
||||||
projectionDigest: projectionDigest,
|
projectionDigest: projectionDigest,
|
||||||
|
identityDigest: identityProjectionDigest,
|
||||||
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
promptInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, projection, projectionDigest, ""),
|
||||||
lookupByKey: lookupByKey,
|
lookupByKey: lookupByKey,
|
||||||
identityInput: contracts.NewLLMInputMaterial(ReferenceSlot, npccodec.MediaType, identityProjection, identityProjectionDigest, ""),
|
|
||||||
lookupByID: lookupByID,
|
lookupByID: lookupByID,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -207,6 +207,15 @@ func (r *Registry) ProjectionDigest() string {
|
|||||||
return r.projectionDigest
|
return r.projectionDigest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IdentityDigest returns the SHA-256 digest of the ordered ID/name identity
|
||||||
|
// projection. It binds durable identities without exposing them to the model.
|
||||||
|
func (r *Registry) IdentityDigest() string {
|
||||||
|
if r == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return r.identityDigest
|
||||||
|
}
|
||||||
|
|
||||||
// Count returns the number of validated NPC records.
|
// Count returns the number of validated NPC records.
|
||||||
func (r *Registry) Count() int {
|
func (r *Registry) Count() int {
|
||||||
if r == nil {
|
if r == nil {
|
||||||
@@ -224,15 +233,6 @@ func (r *Registry) PromptInput() contracts.LLMInputMaterial {
|
|||||||
return r.promptInput.Clone()
|
return r.promptInput.Clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
// IdentityPromptInput returns the ordered ID/name projection for consumers
|
|
||||||
// that must bind output records to exact registry identities.
|
|
||||||
func (r *Registry) IdentityPromptInput() contracts.LLMInputMaterial {
|
|
||||||
if r == nil {
|
|
||||||
return contracts.LLMInputMaterial{}
|
|
||||||
}
|
|
||||||
return r.identityInput.Clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lookup returns the canonical NPC for an exact canonical-name match under the
|
// Lookup returns the canonical NPC for an exact canonical-name match under the
|
||||||
// NPC identity comparison policy.
|
// NPC identity comparison policy.
|
||||||
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
func (r *Registry) Lookup(value string) (dnd.NPC, bool) {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ func TestResolveUnboundRegistryHasExactEmptyProjection(t *testing.T) {
|
|||||||
t.Fatalf("Resolve() error = %v", err)
|
t.Fatalf("Resolve() error = %v", err)
|
||||||
}
|
}
|
||||||
input := registry.PromptInput()
|
input := registry.PromptInput()
|
||||||
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || string(registry.IdentityPromptInput().Content) != emptyPrompt {
|
if registry.Bound() || registry.Digest() != "" || registry.Count() != 0 || string(input.Content) != emptyPrompt || registry.IdentityDigest() != registry.ProjectionDigest() {
|
||||||
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
|
t.Fatalf("registry = %#v input = %#v, want unbound empty registry", registry, input)
|
||||||
}
|
}
|
||||||
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
if registry.ProjectionDigest() == "" || input.Digest != registry.ProjectionDigest() || input.OriginURI != "" {
|
||||||
@@ -46,12 +46,15 @@ func TestResolveKeepsDurableProvenanceAndProjectsOnlyOrderedNames(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestIdentityPromptInputProjectsOrderedIDsAndNames(t *testing.T) {
|
func TestIdentityDigestTracksOrderedIDsAndNames(t *testing.T) {
|
||||||
registry := resolveList(t, registryFixture())
|
registry := resolveList(t, registryFixture())
|
||||||
input := registry.IdentityPromptInput()
|
projection, err := identityProjection(registryFixture())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
want := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Captain Vale") + `","name":"Captain Vale"}]}`
|
want := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Captain Vale") + `","name":"Captain Vale"}]}`
|
||||||
if string(input.Content) != want || input.Digest == registry.ProjectionDigest() {
|
if string(projection) != want || registry.IdentityDigest() != semanticDigest(projection) || registry.IdentityDigest() == registry.ProjectionDigest() {
|
||||||
t.Fatalf("identity projection = %#v, want %s", input, want)
|
t.Fatalf("identity digest = %q, want digest for %s", registry.IdentityDigest(), want)
|
||||||
}
|
}
|
||||||
if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" {
|
if npc, ok := registry.LookupID(identity.DeriveID("Mira Thorn")); !ok || npc.Name != "Mira Thorn" {
|
||||||
t.Fatalf("LookupID() = %#v, %t", npc, ok)
|
t.Fatalf("LookupID() = %#v, %t", npc, ok)
|
||||||
@@ -213,7 +216,7 @@ func TestProjectionIsStableForEquivalentNormalizedRegistries(t *testing.T) {
|
|||||||
secondList := registryFixture()
|
secondList := registryFixture()
|
||||||
secondList.NPCs[0].SourceRefs = append(secondList.NPCs[0].SourceRefs, source.SourceRef{SourceID: "session-beta", StartUnitID: 8, EndUnitID: 8})
|
secondList.NPCs[0].SourceRefs = append(secondList.NPCs[0].SourceRefs, source.SourceRef{SourceID: "session-beta", StartUnitID: 8, EndUnitID: 8})
|
||||||
second := resolveList(t, secondList)
|
second := resolveList(t, secondList)
|
||||||
if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.Digest() == second.Digest() {
|
if !reflect.DeepEqual(first.PromptInput().Content, second.PromptInput().Content) || first.ProjectionDigest() != second.ProjectionDigest() || first.IdentityDigest() != second.IdentityDigest() || first.Digest() == second.Digest() {
|
||||||
t.Fatalf("projection/full identity mismatch: %#v %#v", first, second)
|
t.Fatalf("projection/full identity mismatch: %#v %#v", first, second)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,25 @@ type Candidate struct {
|
|||||||
SourceRefs []source.SourceRef
|
SourceRefs []source.SourceRef
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Selector identifies one candidate through its canonical name and source-free
|
||||||
|
// evidence ranges. It is the complete model-facing candidate descriptor.
|
||||||
|
type Selector struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
SourceRefs []SourceRange `json:"source_refs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clone returns an owned copy of the selector.
|
||||||
|
func (s Selector) Clone() Selector {
|
||||||
|
s.SourceRefs = cloneSourceRanges(s.SourceRefs)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SourceRange is a source-free evidence coordinate used in a selector.
|
||||||
|
type SourceRange struct {
|
||||||
|
StartUnitID int `json:"start_unit_id"`
|
||||||
|
EndUnitID int `json:"end_unit_id"`
|
||||||
|
}
|
||||||
|
|
||||||
// Materials contains owned prompt inputs and opaque candidate-key mappings.
|
// Materials contains owned prompt inputs and opaque candidate-key mappings.
|
||||||
type Materials struct {
|
type Materials struct {
|
||||||
Candidates contracts.LLMInputMaterial
|
Candidates contracts.LLMInputMaterial
|
||||||
@@ -29,6 +48,8 @@ type Materials struct {
|
|||||||
|
|
||||||
candidateKeys []string
|
candidateKeys []string
|
||||||
eligible map[string]struct{}
|
eligible map[string]struct{}
|
||||||
|
keyBySelector map[string]string
|
||||||
|
collidedSelectors map[string]struct{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CandidateKeys returns all deterministic keys in candidate input order.
|
// CandidateKeys returns all deterministic keys in candidate input order.
|
||||||
@@ -49,18 +70,7 @@ func (m Materials) EligibleCandidateKeys() []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type candidateInput struct {
|
type candidateInput struct {
|
||||||
Candidates []candidateView `json:"candidates"`
|
Candidates []Selector `json:"candidates"`
|
||||||
}
|
|
||||||
|
|
||||||
type candidateView struct {
|
|
||||||
Key string `json:"key"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
SourceRefs []candidateSourceRef `json:"source_refs"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type candidateSourceRef struct {
|
|
||||||
StartUnitID int `json:"start_unit_id"`
|
|
||||||
EndUnitID int `json:"end_unit_id"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type transcriptInput struct {
|
type transcriptInput struct {
|
||||||
@@ -93,6 +103,8 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
|
|||||||
materials := Materials{
|
materials := Materials{
|
||||||
candidateKeys: make([]string, len(candidates)),
|
candidateKeys: make([]string, len(candidates)),
|
||||||
eligible: make(map[string]struct{}),
|
eligible: make(map[string]struct{}),
|
||||||
|
keyBySelector: make(map[string]string),
|
||||||
|
collidedSelectors: make(map[string]struct{}),
|
||||||
}
|
}
|
||||||
for index := range candidates {
|
for index := range candidates {
|
||||||
key := fmt.Sprintf(candidateKeyFormat, index+1)
|
key := fmt.Sprintf(candidateKeyFormat, index+1)
|
||||||
@@ -103,7 +115,15 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
|
|||||||
}
|
}
|
||||||
|
|
||||||
index := source.NewDocumentIndex(doc)
|
index := source.NewDocumentIndex(doc)
|
||||||
views := make([]candidateView, 0, len(candidates))
|
type preparedCandidate struct {
|
||||||
|
key string
|
||||||
|
selector Selector
|
||||||
|
intervals []sourceInterval
|
||||||
|
lookupKey string
|
||||||
|
}
|
||||||
|
prepared := make([]preparedCandidate, 0, len(candidates))
|
||||||
|
selectorCounts := make(map[string]int, len(candidates))
|
||||||
|
views := make([]Selector, 0, len(candidates))
|
||||||
intervals := make([]sourceInterval, 0)
|
intervals := make([]sourceInterval, 0)
|
||||||
cited := make([]bool, len(doc.Units))
|
cited := make([]bool, len(doc.Units))
|
||||||
for candidateIndex, candidate := range candidates {
|
for candidateIndex, candidate := range candidates {
|
||||||
@@ -112,9 +132,23 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := materials.candidateKeys[candidateIndex]
|
key := materials.candidateKeys[candidateIndex]
|
||||||
materials.eligible[key] = struct{}{}
|
selector := Selector{Name: candidate.Name, SourceRefs: references}
|
||||||
views = append(views, candidateView{Key: key, Name: candidate.Name, SourceRefs: references})
|
lookupKey, err := selectorLookupKey(selector)
|
||||||
for _, interval := range candidateIntervals {
|
if err != nil {
|
||||||
|
return Materials{}, false, fmt.Errorf("build entity reconciliation context: invalid candidate material")
|
||||||
|
}
|
||||||
|
prepared = append(prepared, preparedCandidate{key: key, selector: selector, intervals: candidateIntervals, lookupKey: lookupKey})
|
||||||
|
selectorCounts[lookupKey]++
|
||||||
|
}
|
||||||
|
for _, candidate := range prepared {
|
||||||
|
if selectorCounts[candidate.lookupKey] != 1 {
|
||||||
|
materials.collidedSelectors[candidate.lookupKey] = struct{}{}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
materials.eligible[candidate.key] = struct{}{}
|
||||||
|
materials.keyBySelector[candidate.lookupKey] = candidate.key
|
||||||
|
views = append(views, candidate.selector.Clone())
|
||||||
|
for _, interval := range candidate.intervals {
|
||||||
for position := interval.start; position <= interval.end; position++ {
|
for position := interval.start; position <= interval.end; position++ {
|
||||||
cited[position] = true
|
cited[position] = true
|
||||||
}
|
}
|
||||||
@@ -145,24 +179,56 @@ func BuildContext(doc *source.SourceDocument, candidates []Candidate, radius int
|
|||||||
return materials, true, nil
|
return materials, true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]candidateSourceRef, []sourceInterval, bool) {
|
func candidateReferences(index source.DocumentIndex, refs []source.SourceRef) ([]SourceRange, []sourceInterval, bool) {
|
||||||
if len(refs) == 0 {
|
if len(refs) == 0 {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
references := make([]candidateSourceRef, 0, len(refs))
|
type referencedInterval struct {
|
||||||
intervals := make([]sourceInterval, 0, len(refs))
|
reference SourceRange
|
||||||
|
interval sourceInterval
|
||||||
|
}
|
||||||
|
prepared := make([]referencedInterval, 0, len(refs))
|
||||||
for _, ref := range refs {
|
for _, ref := range refs {
|
||||||
if err := index.ValidateRef(ref); err != nil {
|
if err := index.ValidateRef(ref); err != nil {
|
||||||
return nil, nil, false
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
start, _ := index.Position(ref.StartUnitID)
|
start, _ := index.Position(ref.StartUnitID)
|
||||||
end, _ := index.Position(ref.EndUnitID)
|
end, _ := index.Position(ref.EndUnitID)
|
||||||
references = append(references, candidateSourceRef{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID})
|
prepared = append(prepared, referencedInterval{reference: SourceRange{StartUnitID: ref.StartUnitID, EndUnitID: ref.EndUnitID}, interval: sourceInterval{start: start, end: end}})
|
||||||
intervals = append(intervals, sourceInterval{start: start, end: end})
|
}
|
||||||
|
sort.Slice(prepared, func(left, right int) bool {
|
||||||
|
if prepared[left].interval.start != prepared[right].interval.start {
|
||||||
|
return prepared[left].interval.start < prepared[right].interval.start
|
||||||
|
}
|
||||||
|
return prepared[left].interval.end < prepared[right].interval.end
|
||||||
|
})
|
||||||
|
references := make([]SourceRange, 0, len(prepared))
|
||||||
|
intervals := make([]sourceInterval, 0, len(prepared))
|
||||||
|
for _, item := range prepared {
|
||||||
|
if len(references) > 0 && references[len(references)-1] == item.reference {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
references = append(references, item.reference)
|
||||||
|
intervals = append(intervals, item.interval)
|
||||||
}
|
}
|
||||||
return references, intervals, true
|
return references, intervals, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func selectorLookupKey(selector Selector) (string, error) {
|
||||||
|
content, err := json.Marshal(selector.Clone())
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(content), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneSourceRanges(values []SourceRange) []SourceRange {
|
||||||
|
if len(values) == 0 {
|
||||||
|
return []SourceRange{}
|
||||||
|
}
|
||||||
|
return append([]SourceRange(nil), values...)
|
||||||
|
}
|
||||||
|
|
||||||
func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
|
func coalesceIntervals(intervals []sourceInterval) []sourceInterval {
|
||||||
if len(intervals) == 0 {
|
if len(intervals) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
"github.com/santhosh-tekuri/jsonschema/v6"
|
"github.com/santhosh-tekuri/jsonschema/v6"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
|
func TestBuildContextUsesContextualSelectorsSourceOrderAndOwnedData(t *testing.T) {
|
||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{
|
||||||
{ID: 40, Kind: "narration", Text: "zero"},
|
{ID: 40, Kind: "narration", Text: "zero"},
|
||||||
{ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}},
|
{ID: 10, Kind: "speech", Text: "one", Metadata: map[string]any{"speaker": map[string]any{"name": "Mira"}}},
|
||||||
@@ -52,10 +52,10 @@ func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
|
|||||||
if err := json.Unmarshal(materials.Candidates.Content, &candidatePayload); err != nil {
|
if err := json.Unmarshal(materials.Candidates.Content, &candidatePayload); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Key != "candidate-000001" || candidatePayload.Candidates[1].Key != "candidate-000002" || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name {
|
if len(candidatePayload.Candidates) != 2 || candidatePayload.Candidates[0].Name != candidatePayload.Candidates[1].Name || strings.Contains(string(materials.Candidates.Content), "candidate-") {
|
||||||
t.Fatalf("candidate payload = %#v, want distinct opaque keys for equal names", candidatePayload)
|
t.Fatalf("candidate payload = %#v, want contextual descriptors without keys", candidatePayload)
|
||||||
}
|
}
|
||||||
if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (candidateSourceRef{StartUnitID: 10, EndUnitID: 20}) {
|
if got := candidatePayload.Candidates[0].SourceRefs[0]; got != (SourceRange{StartUnitID: 10, EndUnitID: 20}) {
|
||||||
t.Fatalf("candidate reference = %#v", got)
|
t.Fatalf("candidate reference = %#v", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +86,50 @@ func TestBuildContextUsesOpaqueKeysSourceOrderAndOwnedData(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildContextExcludesCollidingDescriptors(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 1}, {ID: 2}, {ID: 3}}}
|
||||||
|
candidates := []Candidate{
|
||||||
|
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
|
||||||
|
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 1, EndUnitID: 1}}},
|
||||||
|
{Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 3, EndUnitID: 3}}},
|
||||||
|
}
|
||||||
|
materials, ready, err := BuildContext(doc, candidates, 0)
|
||||||
|
if err != nil || ready || len(materials.EligibleCandidateKeys()) != 1 || strings.Contains(string(materials.Candidates.Content), "The Tavern") {
|
||||||
|
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
|
||||||
|
}
|
||||||
|
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
|
||||||
|
Members: []Selector{{Name: "The Tavern", SourceRefs: []SourceRange{{StartUnitID: 1, EndUnitID: 1}}}, {Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}}},
|
||||||
|
Canonical: Selector{Name: "The Market", SourceRefs: []SourceRange{{StartUnitID: 3, EndUnitID: 3}}},
|
||||||
|
}}})
|
||||||
|
if !hasIssue(assessment.Issues(), "member_ineligible") {
|
||||||
|
t.Fatalf("Assess() issues = %#v, want collided descriptor rejection", assessment.Issues())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAssessmentRejectsPartialAndReorderedDescriptors(t *testing.T) {
|
||||||
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 10}, {ID: 20}, {ID: 30}}}
|
||||||
|
materials, ready, err := BuildContext(doc, []Candidate{
|
||||||
|
{Name: "The Tavern", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 20, EndUnitID: 20}, {SourceID: doc.ID, StartUnitID: 10, EndUnitID: 10}}},
|
||||||
|
{Name: "The Market", SourceRefs: []source.SourceRef{{SourceID: doc.ID, StartUnitID: 30, EndUnitID: 30}}},
|
||||||
|
}, 0)
|
||||||
|
if err != nil || !ready {
|
||||||
|
t.Fatalf("BuildContext() = %#v, %t, %v", materials, ready, err)
|
||||||
|
}
|
||||||
|
selectors := materialSelectors(t, materials)
|
||||||
|
for _, refs := range [][]SourceRange{
|
||||||
|
{{StartUnitID: 10, EndUnitID: 10}},
|
||||||
|
{{StartUnitID: 20, EndUnitID: 20}, {StartUnitID: 10, EndUnitID: 10}},
|
||||||
|
} {
|
||||||
|
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{{
|
||||||
|
Members: []Selector{{Name: "The Tavern", SourceRefs: refs}, selectors[1]},
|
||||||
|
Canonical: selectors[1],
|
||||||
|
}}})
|
||||||
|
if !hasIssue(assessment.Issues(), "member_unknown") {
|
||||||
|
t.Fatalf("Assess(%#v) issues = %#v, want descriptor mismatch rejection", refs, assessment.Issues())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *testing.T) {
|
func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *testing.T) {
|
||||||
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}}}
|
doc := &source.SourceDocument{ID: "session", Units: []source.SourceUnit{{ID: 9}, {ID: 3}, {ID: 8}, {ID: 1}, {ID: 7}}}
|
||||||
candidates := []Candidate{
|
candidates := []Candidate{
|
||||||
@@ -116,22 +160,21 @@ func TestBuildContextExcludesUnsafeReferencesAndCoalescesAdjacentWindows(t *test
|
|||||||
|
|
||||||
func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
|
func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
|
||||||
materials := preparedMaterials(t, 4, true)
|
materials := preparedMaterials(t, 4, true)
|
||||||
keys := materials.CandidateKeys()
|
selectors := materialSelectors(t, materials)
|
||||||
unsafe := []struct {
|
unsafe := []struct {
|
||||||
name string
|
name string
|
||||||
response ProposalResponse
|
response ProposalResponse
|
||||||
category string
|
category string
|
||||||
}{
|
}{
|
||||||
{"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"", keys[1]}, Canonical: keys[1]}}}, "member_blank"},
|
{"blank member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{}, selectors[1]}, Canonical: selectors[1]}}}, "member_blank"},
|
||||||
{"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{"candidate-999999", keys[1]}, Canonical: keys[1]}}}, "member_unknown"},
|
{"unknown member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{{Name: "unknown", SourceRefs: []SourceRange{{StartUnitID: 99, EndUnitID: 99}}}, selectors[1]}, Canonical: selectors[1]}}}, "member_unknown"},
|
||||||
{"ineligible member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[3]}, Canonical: keys[0]}}}, "member_ineligible"},
|
{"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[0]}, Canonical: selectors[0]}}}, "repeated_member"},
|
||||||
{"repeated member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[0]}, Canonical: keys[0]}}}, "repeated_member"},
|
{"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0]}, Canonical: selectors[0]}}}, "fewer_than_two_members"},
|
||||||
{"too small", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0]}, Canonical: keys[0]}}}, "fewer_than_two_members"},
|
{"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: Selector{}}}}, "canonical_blank"},
|
||||||
{"canonical blank", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: ""}}}, "canonical_blank"},
|
{"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[2]}}}, "canonical_not_member"},
|
||||||
{"canonical not member", ProposalResponse{DuplicateGroups: []DuplicateGroup{{Members: []string{keys[0], keys[1]}, Canonical: keys[2]}}}, "canonical_not_member"},
|
|
||||||
{"overlapping", ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
{"overlapping", ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||||
{Members: []string{keys[0], keys[1]}, Canonical: keys[0]},
|
{Members: []Selector{selectors[0], selectors[1]}, Canonical: selectors[0]},
|
||||||
{Members: []string{keys[1], keys[2]}, Canonical: keys[2]},
|
{Members: []Selector{selectors[1], selectors[2]}, Canonical: selectors[2]},
|
||||||
}}, "overlapping_member"},
|
}}, "overlapping_member"},
|
||||||
}
|
}
|
||||||
for _, test := range unsafe {
|
for _, test := range unsafe {
|
||||||
@@ -147,9 +190,10 @@ func TestAssessmentRejectsEveryUnsafeProposalCategory(t *testing.T) {
|
|||||||
func TestAssessmentReturnsNonOverlappingSafeGroupsAndDefensiveCopies(t *testing.T) {
|
func TestAssessmentReturnsNonOverlappingSafeGroupsAndDefensiveCopies(t *testing.T) {
|
||||||
materials := preparedMaterials(t, 4, false)
|
materials := preparedMaterials(t, 4, false)
|
||||||
keys := materials.CandidateKeys()
|
keys := materials.CandidateKeys()
|
||||||
|
selectors := materialSelectors(t, materials)
|
||||||
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
assessment := materials.Assess(ProposalResponse{DuplicateGroups: []DuplicateGroup{
|
||||||
{Members: []string{keys[1], keys[0]}, Canonical: keys[1]},
|
{Members: []Selector{selectors[1], selectors[0]}, Canonical: selectors[1]},
|
||||||
{Members: []string{keys[3], keys[2]}, Canonical: keys[2]},
|
{Members: []Selector{selectors[3], selectors[2]}, Canonical: selectors[2]},
|
||||||
}})
|
}})
|
||||||
groups := assessment.SafeGroups()
|
groups := assessment.SafeGroups()
|
||||||
if assessment.DiscardedGroups() != 0 || len(assessment.Issues()) != 0 || len(groups) != 2 {
|
if assessment.DiscardedGroups() != 0 || len(assessment.Issues()) != 0 || len(groups) != 2 {
|
||||||
@@ -183,12 +227,12 @@ func TestSharedResponseSchemaIsPrivateStrictAndRegisterableOnce(t *testing.T) {
|
|||||||
valid bool
|
valid bool
|
||||||
}{
|
}{
|
||||||
{"empty groups", map[string]any{"duplicate_groups": []any{}}, true},
|
{"empty groups", map[string]any{"duplicate_groups": []any{}}, true},
|
||||||
{"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{""}, "canonical": ""}}}, true},
|
{"semantic proposal problem", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 1, "end_unit_id": 1}}}}}}, true},
|
||||||
{"missing groups", map[string]any{}, false},
|
{"missing groups", map[string]any{}, false},
|
||||||
{"unknown top level", map[string]any{"duplicate_groups": []any{}, "extra": true}, false},
|
{"unknown top level", map[string]any{"duplicate_groups": []any{}, "extra": true}, false},
|
||||||
{"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "name": "replacement"}}}, false},
|
{"replacement name", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}, "name": "replacement"}}}, false},
|
||||||
{"replacement evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": "candidate-000001", "source_refs": []any{}}}}, false},
|
{"missing selector evidence", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{}, "canonical": map[string]any{"name": "Mira"}}}}, false},
|
||||||
{"wrong key type", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{1}, "canonical": "candidate-000001"}}}, false},
|
{"invalid range", map[string]any{"duplicate_groups": []any{map[string]any{"members": []any{map[string]any{"name": "Mira", "source_refs": []any{map[string]any{"start_unit_id": 0, "end_unit_id": 1}}}}, "canonical": map[string]any{"name": "Mira", "source_refs": []any{}}}}}, false},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
content, err := json.Marshal(test.value)
|
content, err := json.Marshal(test.value)
|
||||||
@@ -236,6 +280,15 @@ func preparedMaterials(t *testing.T, count int, includeIneligible bool) Material
|
|||||||
return materials
|
return materials
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func materialSelectors(t *testing.T, materials Materials) []Selector {
|
||||||
|
t.Helper()
|
||||||
|
var input candidateInput
|
||||||
|
if err := json.Unmarshal(materials.Candidates.Content, &input); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return input.Candidates
|
||||||
|
}
|
||||||
|
|
||||||
func cloneCandidates(input []Candidate) []Candidate {
|
func cloneCandidates(input []Candidate) []Candidate {
|
||||||
output := make([]Candidate, len(input))
|
output := make([]Candidate, len(input))
|
||||||
copy(output, input)
|
copy(output, input)
|
||||||
|
|||||||
@@ -6,15 +6,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ProposalResponse is the private structured response exchanged with the
|
// ProposalResponse is the private structured response exchanged with the
|
||||||
// reconciliation prompt. It identifies candidates only by opaque keys.
|
// reconciliation prompt. It identifies candidates by contextual selectors.
|
||||||
type ProposalResponse struct {
|
type ProposalResponse struct {
|
||||||
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
|
DuplicateGroups []DuplicateGroup `json:"duplicate_groups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DuplicateGroup proposes candidate keys that might denote one entity.
|
// DuplicateGroup proposes contextual candidate descriptors that might denote
|
||||||
|
// one entity.
|
||||||
type DuplicateGroup struct {
|
type DuplicateGroup struct {
|
||||||
Members []string `json:"members"`
|
Members []Selector `json:"members"`
|
||||||
Canonical string `json:"canonical"`
|
Canonical Selector `json:"canonical"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Issue identifies one unsafe proposal category without prescribing a warning
|
// Issue identifies one unsafe proposal category without prescribing a warning
|
||||||
@@ -59,16 +60,13 @@ func (a Assessment) DiscardedGroups() int { return a.discardedGroups }
|
|||||||
// Issues returns the deterministic rejection categories in proposal order.
|
// Issues returns the deterministic rejection categories in proposal order.
|
||||||
func (a Assessment) Issues() []Issue { return append([]Issue(nil), a.issues...) }
|
func (a Assessment) Issues() []Issue { return append([]Issue(nil), a.issues...) }
|
||||||
|
|
||||||
// Assess validates a proposal against the opaque keys created by BuildContext.
|
// Assess resolves contextual descriptors to internal candidate keys, then
|
||||||
|
// validates the proposal without exposing those keys to the model.
|
||||||
func (m Materials) Assess(response ProposalResponse) Assessment {
|
func (m Materials) Assess(response ProposalResponse) Assessment {
|
||||||
all := make(map[string]struct{}, len(m.candidateKeys))
|
|
||||||
for _, key := range m.candidateKeys {
|
|
||||||
all[key] = struct{}{}
|
|
||||||
}
|
|
||||||
groups := make([]assessedGroup, len(response.DuplicateGroups))
|
groups := make([]assessedGroup, len(response.DuplicateGroups))
|
||||||
issues := make([]Issue, 0)
|
issues := make([]Issue, 0)
|
||||||
for groupIndex, proposal := range response.DuplicateGroups {
|
for groupIndex, proposal := range response.DuplicateGroups {
|
||||||
groups[groupIndex] = assessGroup(proposal, all, m.eligible)
|
groups[groupIndex] = m.assessGroup(proposal)
|
||||||
for _, category := range groups[groupIndex].issues {
|
for _, category := range groups[groupIndex].issues {
|
||||||
issues = append(issues, Issue{GroupIndex: groupIndex, Category: category})
|
issues = append(issues, Issue{GroupIndex: groupIndex, Category: category})
|
||||||
}
|
}
|
||||||
@@ -115,12 +113,13 @@ type assessedGroup struct {
|
|||||||
conflicting bool
|
conflicting bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) assessedGroup {
|
func (m Materials) assessGroup(proposal DuplicateGroup) assessedGroup {
|
||||||
issues := make([]string, 0)
|
issues := make([]string, 0)
|
||||||
members := make([]string, 0, len(proposal.Members))
|
members := make([]string, 0, len(proposal.Members))
|
||||||
seen := make(map[string]struct{}, len(proposal.Members))
|
seen := make(map[string]struct{}, len(proposal.Members))
|
||||||
for _, key := range proposal.Members {
|
for _, selector := range proposal.Members {
|
||||||
if category := keyCategory(key, all, eligible); category != "" {
|
key, category := m.selectorKey(selector)
|
||||||
|
if category != "" {
|
||||||
issues = append(issues, "member_"+category)
|
issues = append(issues, "member_"+category)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -131,31 +130,39 @@ func assessGroup(proposal DuplicateGroup, all, eligible map[string]struct{}) ass
|
|||||||
seen[key] = struct{}{}
|
seen[key] = struct{}{}
|
||||||
members = append(members, key)
|
members = append(members, key)
|
||||||
}
|
}
|
||||||
canonicalCategory := keyCategory(proposal.Canonical, all, eligible)
|
canonical, canonicalCategory := m.selectorKey(proposal.Canonical)
|
||||||
if canonicalCategory != "" {
|
if canonicalCategory != "" {
|
||||||
issues = append(issues, "canonical_"+canonicalCategory)
|
issues = append(issues, "canonical_"+canonicalCategory)
|
||||||
}
|
}
|
||||||
if len(members) < 2 {
|
if len(members) < 2 {
|
||||||
issues = append(issues, "fewer_than_two_members")
|
issues = append(issues, "fewer_than_two_members")
|
||||||
}
|
}
|
||||||
if canonicalCategory == "" && !contains(members, proposal.Canonical) {
|
if canonicalCategory == "" && !contains(members, canonical) {
|
||||||
issues = append(issues, "canonical_not_member")
|
issues = append(issues, "canonical_not_member")
|
||||||
}
|
}
|
||||||
sort.Strings(members)
|
sort.Strings(members)
|
||||||
return assessedGroup{members: members, canonical: proposal.Canonical, issues: issues, locallyValid: len(issues) == 0}
|
return assessedGroup{members: members, canonical: canonical, issues: issues, locallyValid: len(issues) == 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
func keyCategory(key string, all, eligible map[string]struct{}) string {
|
func (m Materials) selectorKey(selector Selector) (string, string) {
|
||||||
if strings.TrimSpace(key) == "" {
|
if strings.TrimSpace(selector.Name) == "" {
|
||||||
return "blank"
|
return "", "blank"
|
||||||
}
|
}
|
||||||
if _, ok := all[key]; !ok {
|
lookupKey, err := selectorLookupKey(selector)
|
||||||
return "unknown"
|
if err != nil {
|
||||||
|
return "", "unknown"
|
||||||
}
|
}
|
||||||
if _, ok := eligible[key]; !ok {
|
if _, collided := m.collidedSelectors[lookupKey]; collided {
|
||||||
return "ineligible"
|
return "", "ineligible"
|
||||||
}
|
}
|
||||||
return ""
|
key, ok := m.keyBySelector[lookupKey]
|
||||||
|
if !ok {
|
||||||
|
return "", "unknown"
|
||||||
|
}
|
||||||
|
if _, eligible := m.eligible[key]; !eligible {
|
||||||
|
return "", "ineligible"
|
||||||
|
}
|
||||||
|
return key, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func contains(values []string, want string) bool {
|
func contains(values []string, want string) bool {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
if v == nil || v.locationResolver == nil {
|
if v == nil || v.locationResolver == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "location_registry", Value: v.locationResolver.Seeded().ProjectionDigest()}}
|
return []pipeline.CheckpointFingerprint{{Name: "policy", Value: policy}, {Name: "location_registry", Value: v.locationResolver.Seeded().IdentityDigest()}}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) {
|
func (v *Validator) Validate(_ context.Context, req contracts.TypedValidationRequest[dnd.LocationOccurrenceList]) (contracts.ValidationResult, error) {
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "policy", Value: policy},
|
{Name: "policy", Value: policy},
|
||||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
|
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ func (v *Validator) CheckpointFingerprints() []pipeline.CheckpointFingerprint {
|
|||||||
}
|
}
|
||||||
return []pipeline.CheckpointFingerprint{
|
return []pipeline.CheckpointFingerprint{
|
||||||
{Name: "policy", Value: policy},
|
{Name: "policy", Value: policy},
|
||||||
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityPromptInput().Digest},
|
{Name: "npc_registry", Value: v.npcResolver.Seeded().IdentityDigest()},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,8 @@ func TestLocationRegistryHandoffProducesOccurrencesAndEvidence(t *testing.T) {
|
|||||||
|
|
||||||
request := client.requestFor(t, locationoccurrences.PromptID)
|
request := client.requestFor(t, locationoccurrences.PromptID)
|
||||||
registryInput := request.Inputs["location_registry"]
|
registryInput := request.Inputs["location_registry"]
|
||||||
if registryInput.MediaType != locationcodec.MediaType || strings.Contains(string(registryInput.Content), "source_refs") || !strings.Contains(string(registryInput.Content), registry.Locations[0].ID) || !strings.Contains(string(registryInput.Content), registry.Locations[1].ID) {
|
if registryInput.MediaType != locationcodec.MediaType || strings.Contains(string(registryInput.Content), "source_refs") || strings.Contains(string(registryInput.Content), registry.Locations[0].ID) || strings.Contains(string(registryInput.Content), registry.Locations[1].ID) || !strings.Contains(string(registryInput.Content), "registry_refs") {
|
||||||
t.Fatalf("occurrence registry input = %#v, want source-free generated ID projection", registryInput)
|
t.Fatalf("occurrence registry input = %#v, want contextual selector projection", registryInput)
|
||||||
}
|
}
|
||||||
contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json")
|
contextArtifact := outputFileContent(t, output.OutputFiles, "evidence-context.json")
|
||||||
evidence, err := evidencecontext.New().Decode(contextArtifact)
|
evidence, err := evidencecontext.New().Decode(contextArtifact)
|
||||||
@@ -218,7 +218,11 @@ func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context,
|
|||||||
case locationoccurrences.PromptID:
|
case locationoccurrences.PromptID:
|
||||||
var projection struct {
|
var projection struct {
|
||||||
Locations []struct {
|
Locations []struct {
|
||||||
ID string `json:"id"`
|
Name string `json:"name"`
|
||||||
|
RegistryRefs []struct {
|
||||||
|
StartUnitID int `json:"start_unit_id"`
|
||||||
|
EndUnitID int `json:"end_unit_id"`
|
||||||
|
} `json:"registry_refs"`
|
||||||
} `json:"locations"`
|
} `json:"locations"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(request.Inputs["location_registry"].Content, &projection); err != nil {
|
if err := json.Unmarshal(request.Inputs["location_registry"].Content, &projection); err != nil {
|
||||||
@@ -227,7 +231,7 @@ func (client *locationHandoffLLMClient) CompleteStructured(ctx context.Context,
|
|||||||
if len(projection.Locations) != 2 {
|
if len(projection.Locations) != 2 {
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has %d locations, want 2", len(projection.Locations))
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("generated location registry has %d locations, want 2", len(projection.Locations))
|
||||||
}
|
}
|
||||||
payload = map[string]any{"occurrences": []any{map[string]any{"location_id": projection.Locations[0].ID, "name": "Moon Gate", "kind": "visited", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}, map[string]any{"location_id": projection.Locations[1].ID, "name": "Moon Gate", "kind": "mentioned", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}}}
|
payload = map[string]any{"occurrences": []any{map[string]any{"name": projection.Locations[0].Name, "registry_refs": projection.Locations[0].RegistryRefs, "kind": "visited", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}, map[string]any{"name": projection.Locations[1].Name, "registry_refs": projection.Locations[1].RegistryRefs, "kind": "mentioned", "source_refs": []any{map[string]int{"start_unit_id": 3, "end_unit_id": 3}}}}}
|
||||||
default:
|
default:
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected prompt %q", request.PromptID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ func TestNPCOutputGroundsSpellAndCombatConsumersThroughOneOperation(t *testing.T
|
|||||||
for name, value := range map[string]string{
|
for name, value := range map[string]string{
|
||||||
"extract:npc_registry:dnd/npc-registry:mapping_policy": "dnd.npc_registry.extract_mapping.v2",
|
"extract:npc_registry:dnd/npc-registry:mapping_policy": "dnd.npc_registry.extract_mapping.v2",
|
||||||
"normalize:npc_registry:dnd/npc-registry:identity_policy": "dnd.npc_registry.identity.v1",
|
"normalize:npc_registry:dnd/npc-registry:identity_policy": "dnd.npc_registry.identity.v1",
|
||||||
"normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v3",
|
"normalize:npc_registry:dnd/npc-registry:normalization_policy": "dnd.npc_registry.normalize.v4",
|
||||||
"normalize:npc_registry:dnd/npc-registry:semantic_context_policy": "dnd.entity_reconcile.context.v1:2",
|
"normalize:npc_registry:dnd/npc-registry:semantic_context_policy": "dnd.entity_reconcile.context.v1:2",
|
||||||
"extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2",
|
"extract:spells:dnd/spells:mapping_policy": "dnd.spells.extract_mapping.v2",
|
||||||
"extract:combat:dnd/combat-turns:scene_gate_policy": "dnd.combat_turns.scene_gate.v1",
|
"extract:combat:dnd/combat-turns:scene_gate_policy": "dnd.combat_turns.scene_gate.v1",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
request := client.requestFor(t, occurrenceextract.PromptID)
|
request := client.requestFor(t, occurrenceextract.PromptID)
|
||||||
wantRegistry := `{"npcs":[{"id":"` + identity.DeriveID("Mira Thorn") + `","name":"Mira Thorn"},{"id":"` + identity.DeriveID("Hooded Guard") + `","name":"Hooded Guard"}]}`
|
wantRegistry := `{"npcs":[{"name":"Mira Thorn"},{"name":"Hooded Guard"}]}`
|
||||||
if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry {
|
if got := string(request.Inputs["npc_registry"].Content); got != wantRegistry {
|
||||||
t.Fatalf("occurrence registry input = %s, want names-only projection %s", got, wantRegistry)
|
t.Fatalf("occurrence registry input = %s, want names-only projection %s", got, wantRegistry)
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,9 @@ func TestNPCOccurrencePipelineUsesAcceptedRegistryAndCurrentEvidence(t *testing.
|
|||||||
if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" {
|
if first.Name != "Mira Thorn" || string(first.Kind) != "dialogue" || second.Name != "Hooded Guard" || string(second.Kind) != "noncombat_presence" {
|
||||||
t.Fatalf("occurrences = %#v, want canonical names, kinds, and source chronology", occurrences)
|
t.Fatalf("occurrences = %#v, want canonical names, kinds, and source chronology", occurrences)
|
||||||
}
|
}
|
||||||
|
if first.NPCID != identity.DeriveID("Mira Thorn") || second.NPCID != identity.DeriveID("Hooded Guard") {
|
||||||
|
t.Fatalf("occurrences = %#v, want durable registry IDs", occurrences)
|
||||||
|
}
|
||||||
assertOccurrenceEvidence(t, first.SourceRefs)
|
assertOccurrenceEvidence(t, first.SourceRefs)
|
||||||
assertOccurrenceEvidence(t, second.SourceRefs)
|
assertOccurrenceEvidence(t, second.SourceRefs)
|
||||||
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
|
if first.SourceRefs[0].StartUnitID >= second.SourceRefs[0].StartUnitID {
|
||||||
@@ -113,8 +116,8 @@ func TestSemanticNPCNormalizationCrossesOrderedRegistryHandoff(t *testing.T) {
|
|||||||
t.Fatalf("occurrence output step = %q, want ordered downstream step", occurrenceOutput.StepID)
|
t.Fatalf("occurrence output step = %q, want ordered downstream step", occurrenceOutput.StepID)
|
||||||
}
|
}
|
||||||
registryRequest := client.requestFor(t, occurrenceextract.PromptID)
|
registryRequest := client.requestFor(t, occurrenceextract.PromptID)
|
||||||
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"id":"`+identity.DeriveID("Mira Thorn")+`","name":"Mira Thorn"}]}` {
|
if got := string(registryRequest.Inputs["npc_registry"].Content); got != `{"npcs":[{"name":"Mira Thorn"}]}` {
|
||||||
t.Fatalf("downstream registry = %s, want one canonical NPC identity", got)
|
t.Fatalf("downstream registry = %s, want one canonical NPC name", got)
|
||||||
}
|
}
|
||||||
manifestContent, err := json.Marshal(output.Manifest)
|
manifestContent, err := json.Marshal(output.Manifest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -289,7 +292,14 @@ func (client *semanticNPCOccurrenceClient) CompleteStructured(_ context.Context,
|
|||||||
}
|
}
|
||||||
payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
|
payload = map[string]any{"npcs": []any{map[string]any{"name": name, "source_refs": []any{map[string]int{"start_unit_id": client.npcCalls, "end_unit_id": client.npcCalls}}}}}
|
||||||
case npcnormalize.PromptID:
|
case npcnormalize.PromptID:
|
||||||
payload = map[string]any{"duplicate_groups": []any{map[string]any{"members": []string{"candidate-000001", "candidate-000002"}, "canonical": "candidate-000001"}}}
|
content, err := contextualReconciliationContent([]byte(`{"duplicate_groups":[{"members":["candidate-000001","candidate-000002"],"canonical":"candidate-000001"}]}`), request.Inputs["candidates"].Content)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, out); err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
|
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||||
case occurrenceextract.PromptID:
|
case occurrenceextract.PromptID:
|
||||||
payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}}
|
payload = map[string]any{"occurrences": []any{map[string]any{"npc_id": identity.DeriveID("Mira Thorn"), "name": "Mira Thorn", "kind": "dialogue", "source_refs": []any{map[string]int{"start_unit_id": 1, "end_unit_id": 1}}}}}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
@@ -266,6 +267,11 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
|
|||||||
}
|
}
|
||||||
content = append([]byte(nil), client.normalizeResponses[index]...)
|
content = append([]byte(nil), client.normalizeResponses[index]...)
|
||||||
}
|
}
|
||||||
|
var err error
|
||||||
|
content, err = contextualReconciliationContent(content, req.Inputs["candidates"].Content)
|
||||||
|
if err != nil {
|
||||||
|
return contracts.StructuredCompletionResponse{}, err
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected fake NPC prompt %q", req.PromptID)
|
return contracts.StructuredCompletionResponse{}, fmt.Errorf("unexpected fake NPC prompt %q", req.PromptID)
|
||||||
}
|
}
|
||||||
@@ -275,6 +281,43 @@ func (client *fakeNPCProductionLLMClient) CompleteStructured(_ context.Context,
|
|||||||
return contracts.StructuredCompletionResponse{Content: content}, nil
|
return contracts.StructuredCompletionResponse{Content: content}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func contextualReconciliationContent(content, candidateContent []byte) ([]byte, error) {
|
||||||
|
if !strings.Contains(string(content), "candidate-") {
|
||||||
|
return content, nil
|
||||||
|
}
|
||||||
|
var selection struct {
|
||||||
|
DuplicateGroups []struct {
|
||||||
|
Members []string `json:"members"`
|
||||||
|
Canonical string `json:"canonical"`
|
||||||
|
} `json:"duplicate_groups"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(content, &selection); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var candidates struct {
|
||||||
|
Candidates []entityreconcile.Selector `json:"candidates"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(candidateContent, &candidates); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selector := func(key string) entityreconcile.Selector {
|
||||||
|
index, err := strconv.Atoi(strings.TrimPrefix(key, "candidate-"))
|
||||||
|
if err != nil || index < 1 || index > len(candidates.Candidates) {
|
||||||
|
return entityreconcile.Selector{Name: key, SourceRefs: []entityreconcile.SourceRange{}}
|
||||||
|
}
|
||||||
|
return candidates.Candidates[index-1].Clone()
|
||||||
|
}
|
||||||
|
proposal := entityreconcile.ProposalResponse{DuplicateGroups: make([]entityreconcile.DuplicateGroup, len(selection.DuplicateGroups))}
|
||||||
|
for index, group := range selection.DuplicateGroups {
|
||||||
|
members := make([]entityreconcile.Selector, len(group.Members))
|
||||||
|
for memberIndex, key := range group.Members {
|
||||||
|
members[memberIndex] = selector(key)
|
||||||
|
}
|
||||||
|
proposal.DuplicateGroups[index] = entityreconcile.DuplicateGroup{Members: members, Canonical: selector(group.Canonical)}
|
||||||
|
}
|
||||||
|
return json.Marshal(proposal)
|
||||||
|
}
|
||||||
|
|
||||||
func (client *fakeNPCProductionLLMClient) requestCount(promptID string) int {
|
func (client *fakeNPCProductionLLMClient) requestCount(promptID string) int {
|
||||||
count := 0
|
count := 0
|
||||||
for _, request := range client.requests {
|
for _, request := range client.requests {
|
||||||
|
|||||||
Reference in New Issue
Block a user