Finish implementation of the scriptorium migration
This commit is contained in:
@@ -1,295 +1,33 @@
|
|||||||
# Scriptorium LLM Runtime Roadmap
|
# Completed Scriptorium LLM Runtime Migration
|
||||||
|
|
||||||
This roadmap describes the target state for replacing Notarius' local
|
The Scriptorium-backed LLM runtime migration is complete. Notarius now uses
|
||||||
OpenAI-compatible LLM adapter with an integration based on
|
`gitea.maximumdirect.net/eric/scriptorium` as the production LLM execution
|
||||||
`gitea.maximumdirect.net/eric/scriptorium`.
|
engine behind Notarius-owned module contracts, diagnostics, manifest
|
||||||
|
provenance, and secret-handling policy.
|
||||||
The upgrade is worthwhile only if it preserves Notarius' architectural
|
|
||||||
boundaries:
|
## Implemented Outcomes
|
||||||
|
|
||||||
- modules own prompt intent, prompt variables, response schemas, validation, and
|
- production LLM execution uses Scriptorium prompt assets and profiles;
|
||||||
domain interpretation;
|
- chunk, extract, and normalize modules call LLMs through the Notarius
|
||||||
- provider plumbing stays behind framework LLM contracts;
|
`StructuredLLMClient` boundary;
|
||||||
- diagnostics and manifests remain Notarius-owned and secret-free;
|
- modules pass prompt IDs, prompt versions, profile IDs, session IDs, input
|
||||||
- raw API keys are resolved operationally and must not be stored in config,
|
materials, and prompt variables instead of locally rendered chat messages;
|
||||||
diagnostics, manifests, examples, or prompt/profile assets;
|
- source transcript input is preserved as run-scoped input material for
|
||||||
- Scriptorium types do not leak into chunk, extract, or normalize module
|
byte-stable cacheable prompt messages;
|
||||||
contracts unless explicitly chosen as a future public contract.
|
- Scriptorium profile provenance is recorded in run manifests without raw
|
||||||
|
prompts, schemas, source payloads, references, or secrets.
|
||||||
## Motivation
|
|
||||||
|
## Canonical Docs
|
||||||
Notarius currently has a local structured-output OpenAI-compatible adapter. That
|
|
||||||
adapter is intentionally narrow, but it makes Notarius responsible for provider
|
Current behavior is documented in:
|
||||||
request shape, profile support, structured-output retries, model-specific
|
|
||||||
extensions, and future cache-related request fields.
|
- [Configuration](../config.md)
|
||||||
|
- [CLI Reference](../cli.md)
|
||||||
Scriptorium adds capabilities that are directly useful for Notarius:
|
- [Internal LLM Runtime](../internal/llm.md)
|
||||||
|
- [Pipeline Internals](../internal/pipeline.md)
|
||||||
- built-in profiles for many providers and model families, including OpenRouter
|
- [JSON Output Integration](../integrations/json-output.md)
|
||||||
and local OpenAI-compatible inference endpoints;
|
- [Troubleshooting](../troubleshooting.md)
|
||||||
- prompt definitions with multiple system and user messages;
|
|
||||||
- per-prompt `session_id` support for sticky provider routing and input cache
|
There is no active Scriptorium-specific roadmap work in this file. Deferred
|
||||||
affinity;
|
work related to broader LLM, reference, retrieval, context-window, or provider
|
||||||
- per-message `cache_control` support for providers that understand it;
|
strategy belongs in [Future Roadmap](future.md).
|
||||||
- a simple structured-output path where a prompt supplies a schema and
|
|
||||||
Scriptorium sends it upstream and retries non-compliant responses.
|
|
||||||
|
|
||||||
The most important near-term motivation is input-cache reuse. Existing D&D
|
|
||||||
recap prompts already use a cacheable user message shaped as:
|
|
||||||
|
|
||||||
```text
|
|
||||||
A transcript of a Dungeons & Dragons gameplay session is provided below.
|
|
||||||
|
|
||||||
{{ input "transcript" }}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `transcript` input is the same Seriatim JSON transcript that Notarius
|
|
||||||
already accepts as MVP input, including stable numbered segments. Notarius
|
|
||||||
should align chunk, extract, and normalize LLM calls so this transcript message
|
|
||||||
can be byte-identical across recap generation and structured extraction work.
|
|
||||||
For large transcripts, this can materially reduce provider cost when cached
|
|
||||||
input reads are available.
|
|
||||||
|
|
||||||
## Target State
|
|
||||||
|
|
||||||
Notarius uses Scriptorium as the production LLM execution engine behind the
|
|
||||||
existing module-facing LLM boundary.
|
|
||||||
|
|
||||||
Chunk, extract, and normalize modules continue to receive a Notarius
|
|
||||||
`StructuredLLMClient` through their existing request contracts. They do not
|
|
||||||
construct provider clients, read secrets, or depend directly on Scriptorium
|
|
||||||
request/result types.
|
|
||||||
|
|
||||||
The production CLI constructs a Scriptorium-backed client from the effective
|
|
||||||
Notarius configuration, wraps it in Notarius scheduling and diagnostics policy
|
|
||||||
where needed, and returns Notarius-owned non-secret LLM profile manifest
|
|
||||||
metadata.
|
|
||||||
|
|
||||||
Module-owned prompts are rendered as ordered chat messages, not only as one
|
|
||||||
system message and one user message. Prompt rendering must support:
|
|
||||||
|
|
||||||
- repeated roles;
|
|
||||||
- shared input messages;
|
|
||||||
- module-specific task and instruction messages;
|
|
||||||
- optional cache-control metadata;
|
|
||||||
- a stable session ID for calls that should share provider routing/cache
|
|
||||||
affinity;
|
|
||||||
- structured-output schema metadata owned by the module.
|
|
||||||
|
|
||||||
The shared D&D transcript message should be rendered from the original Seriatim
|
|
||||||
input bytes, not reconstructed from normalized source units. The parsed source
|
|
||||||
document remains the canonical source-reference model for validation and
|
|
||||||
artifact grounding, but the cacheable transcript prompt message should preserve
|
|
||||||
the exact transcript payload supplied to the run.
|
|
||||||
|
|
||||||
Other large text inputs, including references such as campaign glossaries,
|
|
||||||
rosters, previous recaps, and future reference producers, should follow the same
|
|
||||||
input-material model. They should be available to prompt rendering as stable
|
|
||||||
input artifacts that can be placed in cacheable messages without being copied
|
|
||||||
into manifests or diagnostics.
|
|
||||||
|
|
||||||
## Boundary Requirements
|
|
||||||
|
|
||||||
### Provider Plumbing
|
|
||||||
|
|
||||||
Provider-specific request fields, profile expansion, and OpenAI-compatible wire
|
|
||||||
details should be isolated in the framework LLM runtime or in the
|
|
||||||
Scriptorium-backed adapter. Stage modules should request structured completion
|
|
||||||
through Notarius contracts and should not know whether the backing client is the
|
|
||||||
legacy local adapter or Scriptorium.
|
|
||||||
|
|
||||||
### Prompt Ownership
|
|
||||||
|
|
||||||
Modules remain responsible for prompt intent, prompt versioning, response schema
|
|
||||||
selection, and semantic validation. Scriptorium provides the prompt definition,
|
|
||||||
rendering, execution, and structured-output workflow, but it should not become
|
|
||||||
the owner of D&D-specific semantics.
|
|
||||||
|
|
||||||
Shared prompt assets are allowed when they express generic reusable context,
|
|
||||||
such as the D&D transcript input message. Module-specific task prompts should
|
|
||||||
remain owned by the relevant module.
|
|
||||||
|
|
||||||
### Diagnostics And Manifests
|
|
||||||
|
|
||||||
Notarius should continue to decide what appears in diagnostics and run
|
|
||||||
manifests. Scriptorium prepared/run metadata may be useful input, but Notarius
|
|
||||||
must filter it through existing policy:
|
|
||||||
|
|
||||||
- no raw prompt payloads by default;
|
|
||||||
- no raw response schema content in manifests;
|
|
||||||
- no source transcript payloads in manifests;
|
|
||||||
- no API keys or bearer tokens;
|
|
||||||
- enough prompt/profile/schema hashes and IDs to audit a run later.
|
|
||||||
|
|
||||||
### Secret Handling
|
|
||||||
|
|
||||||
Raw API keys should stay out of durable config and prompt/profile assets.
|
|
||||||
Notarius may continue resolving API keys from configured environment-variable
|
|
||||||
names, then pass the secret request-scope to Scriptorium. Redacted diagnostics
|
|
||||||
must continue to prove that resolved secrets are not serialized.
|
|
||||||
|
|
||||||
## Prompt And Cache Strategy
|
|
||||||
|
|
||||||
The desired D&D LLM prompt shape is:
|
|
||||||
|
|
||||||
1. stable shared system message;
|
|
||||||
2. stable shared transcript user message containing the original Seriatim JSON;
|
|
||||||
3. optional stable cacheable context messages, such as previous recap,
|
|
||||||
glossary, roster, or other references;
|
|
||||||
4. module-specific task message;
|
|
||||||
5. module-specific instruction message;
|
|
||||||
6. module-owned structured-output schema, when the module expects JSON.
|
|
||||||
|
|
||||||
The transcript message should be byte-identical whenever the same transcript
|
|
||||||
input bytes are used. Avoid reconstructing JSON from parsed source units because
|
|
||||||
formatting, key order, whitespace, or escaping changes would defeat cache reuse.
|
|
||||||
|
|
||||||
The transcript and large reference messages should be cacheable when the active
|
|
||||||
provider path supports cache-control metadata. Providers that ignore cache
|
|
||||||
controls should still receive a valid prompt.
|
|
||||||
|
|
||||||
The session ID should be stable for all LLM calls that operate on the same
|
|
||||||
session transcript and should be explicitly visible in diagnostics or manifest
|
|
||||||
metadata only as a non-secret identifier.
|
|
||||||
|
|
||||||
Notarius should provide an easy CLI UX for setting the session ID. This lets an
|
|
||||||
external D&D pipeline orchestrator pass the same session ID used by recap or
|
|
||||||
other LLM steps, maximizing provider routing affinity and cached input reuse.
|
|
||||||
|
|
||||||
## Configuration Intent
|
|
||||||
|
|
||||||
Notarius configuration should remain the user-facing source of pipeline
|
|
||||||
composition and operational settings. For LLM execution profiles, Notarius
|
|
||||||
should cut over to Scriptorium profile configuration instead of maintaining a
|
|
||||||
separate Notarius-specific profile schema.
|
|
||||||
|
|
||||||
Notarius should allow configuration to select Scriptorium built-in profiles and
|
|
||||||
to point at Scriptorium profile files or directories. This avoids evolving two
|
|
||||||
nearly identical profile systems and gives users immediate access to the
|
|
||||||
provider and model catalog that motivated the migration.
|
|
||||||
|
|
||||||
Notarius still owns validation, redaction, and manifest provenance for the
|
|
||||||
effective pipeline. The integration should wrap Scriptorium profile loading so
|
|
||||||
that errors are actionable, secrets remain environment-based, and emitted
|
|
||||||
metadata stays non-secret.
|
|
||||||
|
|
||||||
## Compatibility Policy
|
|
||||||
|
|
||||||
This feature should be a hard cutover to Scriptorium-backed prompt execution and
|
|
||||||
profile loading. Backward compatibility with the local Notarius prompt renderer,
|
|
||||||
local LLM profile schema, or local OpenAI-compatible adapter is not a product
|
|
||||||
requirement for this migration.
|
|
||||||
|
|
||||||
The final production runtime should have one documented LLM execution path.
|
|
||||||
Module behavior may still be tested with fake Notarius `StructuredLLMClient`
|
|
||||||
implementations, but production runtime behavior should be Scriptorium-backed.
|
|
||||||
|
|
||||||
## Documentation Outcomes
|
|
||||||
|
|
||||||
When this feature is implemented, current-behavior docs should be updated to
|
|
||||||
describe:
|
|
||||||
|
|
||||||
- the implemented production LLM runtime;
|
|
||||||
- supported LLM profile fields and provider/profile selection behavior;
|
|
||||||
- cacheable transcript prompt behavior, if exposed to users;
|
|
||||||
- session ID behavior;
|
|
||||||
- diagnostics and manifest provenance;
|
|
||||||
- troubleshooting for Scriptorium profile, prompt, schema, and validation
|
|
||||||
failures.
|
|
||||||
|
|
||||||
The OpenAI-compatible integration doc should either be retired, narrowed to the
|
|
||||||
legacy fallback, or reframed as an upstream provider contract delegated through
|
|
||||||
Scriptorium, depending on the final runtime shape.
|
|
||||||
|
|
||||||
## Deferred Work
|
|
||||||
|
|
||||||
The Scriptorium migration does not itself require:
|
|
||||||
|
|
||||||
- a general workflow language;
|
|
||||||
- arbitrary per-stage prompt authoring by end users;
|
|
||||||
- token budgeting or context-window planning;
|
|
||||||
- non-file reference producers;
|
|
||||||
- semantic retrieval over transcript or reference content;
|
|
||||||
- multiple effective LLM profiles in one Notarius run.
|
|
||||||
|
|
||||||
Those remain separate future features.
|
|
||||||
|
|
||||||
## Resolved Design Choices
|
|
||||||
|
|
||||||
### Prompt Asset Ownership And Format
|
|
||||||
|
|
||||||
Production LLM prompts should cut over to Scriptorium-compatible prompt
|
|
||||||
definitions without preserving backward compatibility for the current Notarius
|
|
||||||
system/user prompt renderer.
|
|
||||||
|
|
||||||
Module-owned prompt definitions should keep task prompts and schemas near the
|
|
||||||
module that owns the semantic behavior. Shared prompt messages, such as the D&D
|
|
||||||
transcript input message and shared reference/context messages, should live in a
|
|
||||||
shared prompt asset area and be referenced by module-owned prompt definitions.
|
|
||||||
|
|
||||||
This uses Scriptorium's native strengths: ordered messages, cache-control
|
|
||||||
metadata, session IDs, input helpers, profile selection, and schema-backed
|
|
||||||
execution. It also gives Notarius a direct way to reuse the exact transcript and
|
|
||||||
reference messages across D&D recap, chunking, extraction, and normalization
|
|
||||||
work without each module hand-rolling message assembly.
|
|
||||||
|
|
||||||
### Raw Transcript Preservation Location
|
|
||||||
|
|
||||||
Original input bytes should be preserved as run-scoped input material and made
|
|
||||||
available to prompt rendering without storing them in run manifests. The parsed
|
|
||||||
`SourceDocument` should continue to carry normalized source units for framework
|
|
||||||
logic and source-reference validation.
|
|
||||||
|
|
||||||
The same model should apply to all large text inputs, including references.
|
|
||||||
Glossaries, campaign rosters, previous recaps, and other future reference
|
|
||||||
documents may be large enough to deserve their own cacheable messages. They
|
|
||||||
should be treated as prompt input artifacts rather than being copied into source
|
|
||||||
document metadata.
|
|
||||||
|
|
||||||
This keeps the cacheable transcript message byte-identical to the original
|
|
||||||
Seriatim JSON and avoids source-format leakage into chunk, extract, and
|
|
||||||
normalize contracts. It also reduces the risk that raw inputs or references are
|
|
||||||
accidentally serialized into diagnostics or manifests.
|
|
||||||
|
|
||||||
### Session ID Source
|
|
||||||
|
|
||||||
Notarius should add an explicit session ID concept for LLM calls, with a
|
|
||||||
deterministic default derived from the source document identity when no
|
|
||||||
operator-provided value is configured.
|
|
||||||
|
|
||||||
The CLI should provide an easy way to supply this session ID. This lets an
|
|
||||||
external D&D pipeline orchestrator call Notarius with the same session ID used
|
|
||||||
by recap generation or other LLM steps, maximizing provider routing affinity and
|
|
||||||
cached input savings.
|
|
||||||
|
|
||||||
OpenRouter sticky routing and provider cache behavior are most useful when
|
|
||||||
every related call for the same session shares a stable identifier. An explicit
|
|
||||||
concept makes the behavior auditable and avoids each module inventing its own
|
|
||||||
ID. A deterministic default keeps simple local runs ergonomic.
|
|
||||||
|
|
||||||
### Scriptorium Profile Exposure
|
|
||||||
|
|
||||||
Notarius should cut over to Scriptorium profile configuration instead of
|
|
||||||
maintaining a separate Notarius LLM profile schema. Configuration should be able
|
|
||||||
to select Scriptorium built-in profiles and point at Scriptorium profile files
|
|
||||||
or directories.
|
|
||||||
|
|
||||||
The Scriptorium profile format is already close to the desired Notarius target.
|
|
||||||
Maintaining a separate Notarius profile schema would likely create duplicate
|
|
||||||
configuration that eventually converges back toward Scriptorium's model. A hard
|
|
||||||
cutover avoids that churn and gives users immediate access to Scriptorium's
|
|
||||||
provider and model catalog.
|
|
||||||
|
|
||||||
Notarius still needs to wrap this profile loading with Notarius-owned
|
|
||||||
validation, diagnostics, manifest provenance, and secret-redaction policy.
|
|
||||||
|
|
||||||
### Structured-Output Retry Ownership
|
|
||||||
|
|
||||||
Notarius should use Scriptorium's structured-output execution and retry behavior
|
|
||||||
for production calls, while translating results and errors back into Notarius'
|
|
||||||
`StructuredLLMClient` response and error expectations.
|
|
||||||
|
|
||||||
This avoids duplicating structured-output enforcement in Notarius and lets
|
|
||||||
Scriptorium own provider-specific request and repair mechanics. Notarius still
|
|
||||||
retains module-level validation and source-reference checks after decoded
|
|
||||||
structured output is returned.
|
|
||||||
|
|||||||
@@ -489,16 +489,10 @@ func effectiveLLMProfileIDs(resolved pipeline.ResolvedPipeline) []string {
|
|||||||
seen[id] = struct{}{}
|
seen[id] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
add(resolved.Input)
|
|
||||||
add(resolved.Chunk)
|
add(resolved.Chunk)
|
||||||
add(resolved.Output)
|
|
||||||
for _, lane := range resolved.ArtifactLanes {
|
for _, lane := range resolved.ArtifactLanes {
|
||||||
add(lane.Extract)
|
add(lane.Extract)
|
||||||
add(lane.Merge)
|
|
||||||
add(lane.Normalize)
|
add(lane.Normalize)
|
||||||
for _, validator := range lane.Validators {
|
|
||||||
add(validator)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ids := make([]string, 0, len(seen))
|
ids := make([]string, 0, len(seen))
|
||||||
for id := range seen {
|
for id := range seen {
|
||||||
|
|||||||
@@ -707,6 +707,63 @@ pipelines:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunConfigValidateIgnoresNonLLMStageScriptoriumProfiles(t *testing.T) {
|
||||||
|
profilePath := writeScriptoriumProfileFile(t, "known", "http://profile.test/v1", "test-model")
|
||||||
|
configPath := writeTestConfig(t, `version: 2
|
||||||
|
scriptorium:
|
||||||
|
profile_file: `+profilePath+`
|
||||||
|
pipelines:
|
||||||
|
example:
|
||||||
|
input:
|
||||||
|
module: fake/input
|
||||||
|
llm_profile: missing-input
|
||||||
|
output:
|
||||||
|
module: json
|
||||||
|
llm_profile: missing-output
|
||||||
|
artifacts:
|
||||||
|
events:
|
||||||
|
extract: fake/extract
|
||||||
|
merge:
|
||||||
|
module: appendorder
|
||||||
|
llm_profile: missing-merge
|
||||||
|
`)
|
||||||
|
var stdout bytes.Buffer
|
||||||
|
var stderr bytes.Buffer
|
||||||
|
|
||||||
|
code := RunWithOptions([]string{"config", "validate", "--config", configPath, "--pipeline", "example"}, &stdout, &stderr, Options{
|
||||||
|
Catalog: fakeCatalog(t),
|
||||||
|
})
|
||||||
|
|
||||||
|
if code != 0 {
|
||||||
|
t.Fatalf("RunWithOptions() code = %d, stderr=%q, want success", code, stderr.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(stderr.String(), "missing-") {
|
||||||
|
t.Fatalf("stderr = %q, want non-LLM stage profiles ignored", stderr.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEffectiveLLMProfileIDsUsesLLMCapableStagesOnly(t *testing.T) {
|
||||||
|
resolved := pipeline.ResolvedPipeline{
|
||||||
|
Input: pipeline.ModuleBinding{LLMProfile: "input-profile"},
|
||||||
|
Chunk: pipeline.ModuleBinding{LLMProfile: "chunk-profile"},
|
||||||
|
Output: pipeline.ModuleBinding{LLMProfile: "output-profile"},
|
||||||
|
ArtifactLanes: []pipeline.ResolvedArtifactLane{
|
||||||
|
{
|
||||||
|
Extract: pipeline.ModuleBinding{LLMProfile: "extract-profile"},
|
||||||
|
Merge: pipeline.ModuleBinding{LLMProfile: "merge-profile"},
|
||||||
|
Normalize: pipeline.ModuleBinding{LLMProfile: "normalize-profile"},
|
||||||
|
Validators: []pipeline.ModuleBinding{{LLMProfile: "validator-profile"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
got := effectiveLLMProfileIDs(resolved)
|
||||||
|
want := []string{"chunk-profile", "extract-profile", "normalize-profile"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("effectiveLLMProfileIDs() = %#v, want %#v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRunPipelineSessionIDFlagRecordsExplicitTrimmedValue(t *testing.T) {
|
func TestRunPipelineSessionIDFlagRecordsExplicitTrimmedValue(t *testing.T) {
|
||||||
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
|
configPath := writeTestConfig(t, mvpConfigYAML("dnd-session", "dnd/spells"))
|
||||||
inputPath := writeSeriatimInput(t)
|
inputPath := writeSeriatimInput(t)
|
||||||
|
|||||||
@@ -65,16 +65,10 @@ func (c Config) Resolve(input ResolveInput) (EffectiveConfig, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyLLMProfileOverride(profile *pipeline.PipelineProfile, profileID string) {
|
func applyLLMProfileOverride(profile *pipeline.PipelineProfile, profileID string) {
|
||||||
profile.Input.LLMProfile = profileID
|
|
||||||
profile.Chunk.LLMProfile = profileID
|
profile.Chunk.LLMProfile = profileID
|
||||||
profile.Output.LLMProfile = profileID
|
|
||||||
for laneID, lane := range profile.Artifacts {
|
for laneID, lane := range profile.Artifacts {
|
||||||
lane.Extract.LLMProfile = profileID
|
lane.Extract.LLMProfile = profileID
|
||||||
lane.Merge.LLMProfile = profileID
|
|
||||||
lane.Normalize.LLMProfile = profileID
|
lane.Normalize.LLMProfile = profileID
|
||||||
for i := range lane.Validators {
|
|
||||||
lane.Validators[i].LLMProfile = profileID
|
|
||||||
}
|
|
||||||
profile.Artifacts[laneID] = lane
|
profile.Artifacts[laneID] = lane
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,6 +156,14 @@ func TestResolveDigestChangesWhenEffectiveConfigChanges(t *testing.T) {
|
|||||||
|
|
||||||
func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) {
|
func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) {
|
||||||
cfg := validConfig()
|
cfg := validConfig()
|
||||||
|
profile := cfg.Pipelines["example"]
|
||||||
|
profile.Input.LLMProfile = "input-profile"
|
||||||
|
profile.Output.LLMProfile = "output-profile"
|
||||||
|
lane := profile.Artifacts["events"]
|
||||||
|
lane.Merge.LLMProfile = "merge-profile"
|
||||||
|
lane.Validators[0].LLMProfile = "validator-profile"
|
||||||
|
profile.Artifacts["events"] = lane
|
||||||
|
cfg.Pipelines["example"] = profile
|
||||||
|
|
||||||
base, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
base, err := cfg.Resolve(ResolveInput{PipelineID: "example", Catalog: fakeCatalog(t)})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -173,18 +181,30 @@ func TestResolveLLMProfileOverrideAppliesBeforeDigest(t *testing.T) {
|
|||||||
if base.ResolvedPipeline.Digest == effective.ResolvedPipeline.Digest {
|
if base.ResolvedPipeline.Digest == effective.ResolvedPipeline.Digest {
|
||||||
t.Fatalf("expected digest to change after LLM profile override")
|
t.Fatalf("expected digest to change after LLM profile override")
|
||||||
}
|
}
|
||||||
for _, binding := range resolvedBindings(effective.ResolvedPipeline) {
|
for _, binding := range llmCapableBindings(effective.ResolvedPipeline) {
|
||||||
if binding.LLMProfile != "runtime" {
|
if binding.LLMProfile != "runtime" {
|
||||||
t.Fatalf("binding profile = %q, want runtime", binding.LLMProfile)
|
t.Fatalf("LLM-capable binding profile = %q, want runtime", binding.LLMProfile)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if effective.ResolvedPipeline.Input.LLMProfile != "input-profile" {
|
||||||
|
t.Fatalf("input profile = %q, want original input-profile", effective.ResolvedPipeline.Input.LLMProfile)
|
||||||
|
}
|
||||||
|
if effective.ResolvedPipeline.Output.LLMProfile != "output-profile" {
|
||||||
|
t.Fatalf("output profile = %q, want original output-profile", effective.ResolvedPipeline.Output.LLMProfile)
|
||||||
|
}
|
||||||
|
eventLane := effective.ResolvedPipeline.ArtifactLanes[0]
|
||||||
|
if eventLane.Merge.LLMProfile != "merge-profile" {
|
||||||
|
t.Fatalf("merge profile = %q, want original merge-profile", eventLane.Merge.LLMProfile)
|
||||||
|
}
|
||||||
|
if len(eventLane.Validators) != 1 || eventLane.Validators[0].LLMProfile != "validator-profile" {
|
||||||
|
t.Fatalf("validator profiles = %#v, want original validator-profile", eventLane.Validators)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvedBindings(resolved pipeline.ResolvedPipeline) []pipeline.ModuleBinding {
|
func llmCapableBindings(resolved pipeline.ResolvedPipeline) []pipeline.ModuleBinding {
|
||||||
bindings := []pipeline.ModuleBinding{resolved.Input, resolved.Chunk, resolved.Output}
|
bindings := []pipeline.ModuleBinding{resolved.Chunk}
|
||||||
for _, lane := range resolved.ArtifactLanes {
|
for _, lane := range resolved.ArtifactLanes {
|
||||||
bindings = append(bindings, lane.Extract, lane.Merge, lane.Normalize)
|
bindings = append(bindings, lane.Extract, lane.Normalize)
|
||||||
bindings = append(bindings, lane.Validators...)
|
|
||||||
}
|
}
|
||||||
return bindings
|
return bindings
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,14 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
"gitea.maximumdirect.net/eric/notarius/internal/core/source"
|
||||||
)
|
)
|
||||||
|
|
||||||
type LLMMessage struct {
|
|
||||||
Role string `json:"role"`
|
|
||||||
Content string `json:"content"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type StructuredCompletionRequest struct {
|
type StructuredCompletionRequest struct {
|
||||||
StageName string `json:"stage_name"`
|
StageName string `json:"stage_name"`
|
||||||
PromptID string `json:"prompt_id,omitempty"`
|
PromptID string `json:"prompt_id,omitempty"`
|
||||||
PromptVersion string `json:"prompt_version,omitempty"`
|
PromptVersion string `json:"prompt_version,omitempty"`
|
||||||
ProfileID string `json:"profile_id,omitempty"`
|
ProfileID string `json:"profile_id,omitempty"`
|
||||||
SessionID string `json:"session_id,omitempty"`
|
SessionID string `json:"session_id,omitempty"`
|
||||||
Inputs LLMInputSet `json:"inputs,omitempty"`
|
Inputs LLMInputSet `json:"inputs,omitempty"`
|
||||||
Vars map[string]any `json:"vars,omitempty"`
|
Vars map[string]any `json:"vars,omitempty"`
|
||||||
Messages []LLMMessage `json:"messages"`
|
|
||||||
Model string `json:"model,omitempty"`
|
|
||||||
ResponseSchemaName string `json:"response_schema_name,omitempty"`
|
|
||||||
ResponseSchema json.RawMessage `json:"response_schema,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StructuredCompletionResponse struct {
|
type StructuredCompletionResponse struct {
|
||||||
|
|||||||
@@ -269,8 +269,9 @@ func (extractor walkingSkeletonExtractor) Extract(ctx context.Context, req contr
|
|||||||
Call int `json:"call"`
|
Call int `json:"call"`
|
||||||
}
|
}
|
||||||
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: "fake/extract",
|
StageName: "fake/extract",
|
||||||
ResponseSchemaName: "fake_event",
|
PromptID: "fake.event",
|
||||||
|
PromptVersion: "v1",
|
||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.ExtractionResult{}, err
|
return contracts.ExtractionResult{}, err
|
||||||
}
|
}
|
||||||
@@ -349,8 +350,9 @@ func (normalizer walkingSkeletonNormalizer) Normalize(ctx context.Context, req c
|
|||||||
Call int `json:"call"`
|
Call int `json:"call"`
|
||||||
}
|
}
|
||||||
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
if _, err := req.LLMClient.CompleteStructured(ctx, contracts.StructuredCompletionRequest{
|
||||||
StageName: "fake/normalize",
|
StageName: "fake/normalize",
|
||||||
ResponseSchemaName: "fake_normalize",
|
PromptID: "fake.normalize",
|
||||||
|
PromptVersion: "v1",
|
||||||
}, &response); err != nil {
|
}, &response); err != nil {
|
||||||
return contracts.NormalizeResult{}, err
|
return contracts.NormalizeResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,9 +118,6 @@ func TestChunkReturnsSceneChunksFromStructuredOutput(t *testing.T) {
|
|||||||
if req.SessionID != "session-123" || req.ProfileID != "profile-scenes" {
|
if req.SessionID != "session-123" || req.ProfileID != "profile-scenes" {
|
||||||
t.Fatalf("session/profile = %q/%q, want session-123/profile-scenes", req.SessionID, req.ProfileID)
|
t.Fatalf("session/profile = %q/%q, want session-123/profile-scenes", req.SessionID, req.ProfileID)
|
||||||
}
|
}
|
||||||
if len(req.Messages) != 0 || req.ResponseSchemaName != "" || len(req.ResponseSchema) != 0 {
|
|
||||||
t.Fatalf("legacy prompt fields set: messages=%#v schema=%q/%s", req.Messages, req.ResponseSchemaName, req.ResponseSchema)
|
|
||||||
}
|
|
||||||
transcript, ok := req.Inputs["transcript"]
|
transcript, ok := req.Inputs["transcript"]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatalf("transcript input missing from %#v", req.Inputs)
|
t.Fatalf("transcript input missing from %#v", req.Inputs)
|
||||||
@@ -493,8 +490,18 @@ func (client *fakeScenesLLMClient) CompleteStructured(ctx context.Context, req c
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
|
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
|
||||||
req.Messages = append([]contracts.LLMMessage(nil), req.Messages...)
|
|
||||||
req.ResponseSchema = append(json.RawMessage(nil), req.ResponseSchema...)
|
|
||||||
req.Inputs = req.Inputs.Clone()
|
req.Inputs = req.Inputs.Clone()
|
||||||
|
req.Vars = cloneVars(req.Vars)
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneVars(in map[string]any) map[string]any {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(in))
|
||||||
|
for key, value := range in {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,9 +46,6 @@ func TestExtractReturnsSpellCandidateFromStructuredOutput(t *testing.T) {
|
|||||||
if req.SessionID != "session-123" || req.ProfileID != "profile-spells" {
|
if req.SessionID != "session-123" || req.ProfileID != "profile-spells" {
|
||||||
t.Fatalf("session/profile = %q/%q, want session-123/profile-spells", req.SessionID, req.ProfileID)
|
t.Fatalf("session/profile = %q/%q, want session-123/profile-spells", req.SessionID, req.ProfileID)
|
||||||
}
|
}
|
||||||
if len(req.Messages) != 0 || req.ResponseSchemaName != "" || len(req.ResponseSchema) != 0 {
|
|
||||||
t.Fatalf("legacy prompt fields set: messages=%#v schema=%q/%s", req.Messages, req.ResponseSchemaName, req.ResponseSchema)
|
|
||||||
}
|
|
||||||
transcript := req.Inputs["transcript"]
|
transcript := req.Inputs["transcript"]
|
||||||
if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:transcript" || transcript.OriginURI != "file:///session-alpha.json" {
|
if transcript.Name != "transcript" || transcript.MediaType != "application/json" || transcript.Digest != "sha256:transcript" || transcript.OriginURI != "file:///session-alpha.json" {
|
||||||
t.Fatalf("transcript metadata = %#v", transcript)
|
t.Fatalf("transcript metadata = %#v", transcript)
|
||||||
@@ -135,8 +132,8 @@ func TestExtractPassesReferencesAsPromptInputs(t *testing.T) {
|
|||||||
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
t.Fatalf("LLM calls = %d, want 1", len(client.requests))
|
||||||
}
|
}
|
||||||
request := client.requests[0]
|
request := client.requests[0]
|
||||||
if len(request.Messages) != 0 {
|
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
|
||||||
t.Fatalf("Messages = %#v, want no locally rendered prompt", request.Messages)
|
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion)
|
||||||
}
|
}
|
||||||
if got := string(request.Inputs["roster"].Content); got != "Aria Brightmantle: party cleric" {
|
if got := string(request.Inputs["roster"].Content); got != "Aria Brightmantle: party cleric" {
|
||||||
t.Fatalf("roster input = %q, want reference content", got)
|
t.Fatalf("roster input = %q, want reference content", got)
|
||||||
@@ -337,8 +334,18 @@ func (client *fakeSpellsLLMClient) CompleteStructured(ctx context.Context, req c
|
|||||||
}
|
}
|
||||||
|
|
||||||
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
|
func cloneStructuredCompletionRequest(req contracts.StructuredCompletionRequest) contracts.StructuredCompletionRequest {
|
||||||
req.Messages = append([]contracts.LLMMessage(nil), req.Messages...)
|
|
||||||
req.ResponseSchema = append(json.RawMessage(nil), req.ResponseSchema...)
|
|
||||||
req.Inputs = req.Inputs.Clone()
|
req.Inputs = req.Inputs.Clone()
|
||||||
|
req.Vars = cloneVars(req.Vars)
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneVars(in map[string]any) map[string]any {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]any, len(in))
|
||||||
|
for key, value := range in {
|
||||||
|
out[key] = value
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
@@ -153,8 +153,8 @@ func TestRunnerPassesRosterAndGlossaryReferencesToDNDSpellsPrompt(t *testing.T)
|
|||||||
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
|
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
|
||||||
}
|
}
|
||||||
request := llmClient.requests[0]
|
request := llmClient.requests[0]
|
||||||
if len(request.Messages) != 0 {
|
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
|
||||||
t.Fatalf("Messages = %#v, want no locally rendered prompt", request.Messages)
|
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion)
|
||||||
}
|
}
|
||||||
if got := string(request.Inputs["roster"].Content); got != "Aria: party cleric\nBorin: fighter" {
|
if got := string(request.Inputs["roster"].Content); got != "Aria: party cleric\nBorin: fighter" {
|
||||||
t.Fatalf("roster input = %q, want reference text", got)
|
t.Fatalf("roster input = %q, want reference text", got)
|
||||||
@@ -191,8 +191,8 @@ func TestRunnerDoesNotExtractSpellMentionedOnlyInRoster(t *testing.T) {
|
|||||||
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
|
t.Fatalf("LLM calls = %d, want 1", len(llmClient.requests))
|
||||||
}
|
}
|
||||||
request := llmClient.requests[0]
|
request := llmClient.requests[0]
|
||||||
if len(request.Messages) != 0 {
|
if request.PromptID != PromptID || request.PromptVersion != SchemaVersion {
|
||||||
t.Fatalf("Messages = %#v, want no locally rendered prompt", request.Messages)
|
t.Fatalf("prompt = %q/%q, want %q/%q", request.PromptID, request.PromptVersion, PromptID, SchemaVersion)
|
||||||
}
|
}
|
||||||
if got := string(request.Inputs["roster"].Content); !strings.Contains(got, "Lightning Bolt") {
|
if got := string(request.Inputs["roster"].Content); !strings.Contains(got, "Lightning Bolt") {
|
||||||
t.Fatalf("roster input = %q, want roster-only spell in reference input", got)
|
t.Fatalf("roster input = %q, want roster-only spell in reference input", got)
|
||||||
|
|||||||
Reference in New Issue
Block a user