15 changed files with 439 additions and 275 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"monorepoCommit": "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9", "monorepoCommit": "e33714a5ca38429d1bdb77a961a14461697944f5",
"sourcePath": "examples/local-first/packages/language-reader-runtime" "sourcePath": "examples/local-first/packages/language-reader-runtime"
} }
+50 -10
View File
@@ -25,8 +25,9 @@ Documents preserve their exact original text and source attribution. Their
Canonical sourceRevision is a content hash; variant revisions identify an immutable Canonical sourceRevision is a content hash; variant revisions identify an immutable
adaptation. All ranges use original UTF-16 offsets in that document. adaptation. All ranges use original UTF-16 offsets in that document.
Creating a rewritten document retains the original document. Features contain Creating a rewritten document retains the original document. Features contain
presentation preferences and a processor key; schemas and implementations belong a processor key, enabled state, name and declared options; computation policy
in code. Feature changes use expectedRevision to protect concurrent edits. and any number of render channels belong in code. Creation appearance choices
are historical authoring instructions, never a stored single visual type. Feature changes use expectedRevision to protect concurrent edits.
Inference is admitted through a persisted narrow Agent. Each scope request owns Inference is admitted through a persisted narrow Agent. Each scope request owns
a thread and request; managed graph edges retain those associations. Completion a thread and request; managed graph edges retain those associations. Completion
@@ -169,7 +170,7 @@ conversation at desktop and narrow widths.
## Inline annotator configuration ## Inline annotator configuration
Configuration belongs to annotator code and appears on its page in Reader settings. Configuration belongs to annotator code and appears on its page in Reader settings.
`Processor.config` is a list of `ConfigField` controls, or a function returning that `Processor.config` is a list of `ConfigNode` controls/groups, or a function returning that
list. Supported controls: `color`, `number`, `select`, `toggle`, `text`, with a key, list. Supported controls: `color`, `number`, `select`, `toggle`, `text`, with a key,
label, typed default, optional numeric bounds and select choices. The grammar label, typed default, optional numeric bounds and select choices. The grammar
processor generates one color control per part of speech; names does the same for processor generates one color control per part of speech; names does the same for
@@ -177,8 +178,12 @@ entity types. Declare every option in code so the server can validate it.
`ReadingFeature.options` persists the JSON object. `configureOptions` checks `ReadingFeature.options` persists the JSON object. `configureOptions` checks
`expectedRevision`, validates options and increments `configRevision` atomically. `expectedRevision`, validates options and increments `configRevision` atomically.
The UI edits a draft and saves all processor options together; appearance/reveal Edits autosave through the same validated local transaction used on the server.
controls save directly when the processor explicitly declares them. `addFeature` requires `options` (use `"{}"` for defaults) and optional `authoring` (use null for premades). Controls and the preview update before network acknowledgement and stay editable
while saving. Nested group edits merge only changed leaves into the newest local
snapshot; revision conflicts reject rather than overwrite a concurrent edit.
Submitted edits survive switching annotators or closing settings. Custom config
components call `change(key,value)`; they do not implement a separate Save action. `addFeature` requires `options` (use `"{}"` for defaults) and optional `authoring` (use null for premades).
Concurrent edits reject cleanly. Changing display settings preserves raw cached results; only semantic inputs invalidate computation. Concurrent edits reject cleanly. Changing display settings preserves raw cached results; only semantic inputs invalidate computation.
`Processor.prompt(config)` can include validated config in developer instructions. `Processor.prompt(config)` can include validated config in developer instructions.
@@ -186,8 +191,8 @@ Local processors receive config as their third argument.
The presentation `annotationDisplays` registry can return per-item `color`, `fontFamily`, or The presentation `annotationDisplays` registry can return per-item `color`, `fontFamily`, or
`fontSize`, applied after strict model output validation. Model output stays the `fontSize`, applied after strict model output validation. Model output stays the
four-field range/text/tag contract; rendering choices are owned by checked code. four-field range/text/tag contract; rendering choices are owned by checked code.
Common options `fontFamily` and `fontSize` configure labels. Their style remains An explicitly composed `labelControls` group configures its font and placement.
`overhead`; `options.placement` chooses `"above"` (default) or `"below"`. A There are no implicit common options. A
`Processor.renders` hook can set `placement` independently. Both positions use `Processor.renders` hook can set `placement` independently. Both positions use
fixed source lanes, and display changes reuse semantic caches. Wide labels gain fixed source lanes, and display changes reuse semantic caches. Wide labels gain
faint alignment guides; hovering a label emphasizes its source without invoking faint alignment guides; hovering a label emphasizes its source without invoking
@@ -200,8 +205,9 @@ a React component to `annotatorConfigViews[processor]` in
`LanguageReaderPresentation/src/annotator-config.tsx`; it receives `fields`, `LanguageReaderPresentation/src/annotator-config.tsx`; it receives `fields`,
`values`, `disabled`, and `change(key, value)`. This component edits the same draft `values`, `disabled`, and `change(key, value)`. This component edits the same draft
and validated options. Built annotators have no universal output-type editor. and validated options. Built annotators have no universal output-type editor.
`Processor.settings` explicitly opts into color, font, or timing controls; `Processor.config` is the entire settings schema, including appearance and timing
`description` explains the behavior and `preset` defines a premade output. when the author chooses to expose them. `description` explains the behavior and
`renders` defines each output channel.
The New annotator panel can reuse any installed processor type with its code-owned The New annotator panel can reuse any installed processor type with its code-owned
controls, or describe a new type to the author. `LanguageReader.archiveType` takes controls, or describe a new type to the author. `LanguageReader.archiveType` takes
`{processor, archived}` and updates `archivedTypes`, hiding a type from the creation `{processor, archived}` and updates `archivedTypes`, hiding a type from the creation
@@ -269,7 +275,7 @@ Hover feedback changes source ink immediately and never draws a word outline.
## Tooltip presentation ## Tooltip presentation
Features can use `style: "tooltip"` with `reveal: "hover"` or `"click"`. Render channels can use `style: "tooltip"` with `reveal: "hover"` or `"click"`.
Choose Tooltip in the annotation editor, then choose its reveal trigger. Existing Choose Tooltip in the annotation editor, then choose its reveal trigger. Existing
processors and generated/local content work unchanged; this is a presentation processors and generated/local content work unchanged; this is a presentation
choice. `always` is unsupported for tooltips. Hover inference retains dwell, choice. `always` is unsupported for tooltips. Hover inference retains dwell,
@@ -396,3 +402,37 @@ cached facts through current appearance/display options, never initiates inferen
Author new data models simply, and refactor to richer domain entities when needed Author new data models simply, and refactor to richer domain entities when needed
while preserving user values via supported transformations. Independently scoped UI while preserving user values via supported transformations. Independently scoped UI
work can use bounded Codex subagents; join/check before activation. work can use bounded Codex subagents; join/check before activation.
## Composable, code-owned settings
Only `Processor.config` declares accepted settings. Neither validation nor the UI
adds controls because an annotator renders an underline or a label. Custom React
views in `annotatorConfigViews` own the entire config presentation. Generic
controls use explicit `presentation` descriptors rather than guessing from keys.
Reuse independent nested structs from `config.ts`, for example:
```ts
config: [
underlineControls("primary", "Primary underline", {pattern: "dashed"}),
underlineControls("secondary", "Secondary underline", {color: "#947459"}),
labelControls("gloss", "Gloss text"),
],
renders: [
{style: "underline", reveal: "always", options: "primary"},
{style: "underline", reveal: "click", options: "secondary"},
{style: "overhead", reveal: "click", options: "gloss"},
]
```
The generated React settings compose each group as its own labeled section.
Groups can also be composed in bespoke views. Their defaults, validation and
settings-to-render bindings are explicit; fixed channels need no settings.
For a group's own React presentation, register
`annotatorConfigViews["processor/groupKey"]`; it receives that group's fields and
values and edits that group through the same validated draft. Compose these
views as needed without introducing a universal appearance editor.
Use `computation:false` on display-only leaf fields. Nested semantic input paths
participate in cache identity and enter prompts as nested data. Appearance changes
must preserve the semantic cache. Test changing each exposed control all the way
through its render channel. Never hardcode a value over an exposed setting.
+4 -4
View File
@@ -38,7 +38,7 @@ external atom ReadingWordObject id "atom:reading:word";
external atom ReadingSubscription id "atom:reading:subscription"; external atom ReadingSubscription id "atom:reading:subscription";
external atom ReadingNavigationObject id "atom:reading:navigation"; external atom ReadingNavigationObject id "atom:reading:navigation";
type DocumentData = record {title:string;text:string;sourceRevision:string;source:string;url:string;attribution:string;}; type DocumentData = record {title:string;text:string;sourceRevision:string;source:string;url:string;attribution:string;};
type FeatureData = record {name:string;processor:string;style:string;reveal:string;generation:string;scope:string;color:string;enabled:bool;configRevision:uint32;}; type FeatureData = record {name:string;processor:string;enabled:bool;configRevision:uint32;};
type JobData = record {key:string;kind:string;featureRevision:uint32;sourceRevision:string;start:uint32;end:uint32;status:string;output:string;error:string;createdAt:string;}; type JobData = record {key:string;kind:string;featureRevision:uint32;sourceRevision:string;start:uint32;end:uint32;status:string;output:string;error:string;createdAt:string;};
type ReaderData = record {name:string;size:uint32;width:uint32;font:string;background:string;configRevision:uint32;}; type ReaderData = record {name:string;size:uint32;width:uint32;font:string;background:string;configRevision:uint32;};
type ReaderSeed = record {palette:record {text:string;accent:string;};data:ReaderData;library:interface-ref<LanguageReader>;}; type ReaderSeed = record {palette:record {text:string;accent:string;};data:ReaderData;library:interface-ref<LanguageReader>;};
@@ -71,7 +71,7 @@ package LanguageReaderRuntime id "package:reading:runtime" revision "package:rea
operation searchWikipedia id "reading:runtime:wiki-search" : string -> list<record {title:string;description:string;}> mode call execution remote receiver atom ReadingLibrary; operation searchWikipedia id "reading:runtime:wiki-search" : string -> list<record {title:string;description:string;}> mode call execution remote receiver atom ReadingLibrary;
operation readWikipedia id "reading:runtime:wiki-read" : string -> record {title:string;text:string;source:string;url:string;attribution:string;} mode call execution remote receiver atom ReadingLibrary; operation readWikipedia id "reading:runtime:wiki-read" : string -> record {title:string;text:string;source:string;url:string;attribution:string;} mode call execution remote receiver atom ReadingLibrary;
operation setInferenceProfile id "reading:runtime:inference-profile" : string -> unit mode call execution transaction targets [server] receiver atom ReadingLibrary requires {state profile id "profile" : string [write];}; operation setInferenceProfile id "reading:runtime:inference-profile" : string -> unit mode call execution transaction targets [server] receiver atom ReadingLibrary requires {state profile id "profile" : string [write];};
operation configureOptions id "reading:runtime:configure-options" : record {expectedRevision:uint32;options:string;} -> unit mode call execution transaction targets [server] receiver atom ReadingFeatureObject requires {state data id "data" : FeatureData [read,write];state options id "options" : string [write];}; operation configureOptions id "reading:runtime:configure-options" : record {expectedRevision:uint32;options:string;} -> unit mode call execution transaction targets [server,JavaScript] receiver atom ReadingFeatureObject requires {state data id "data" : FeatureData [read,write];state options id "options" : string [write];};
operation archiveType id "reading:runtime:archive-type" : record {processor:string;archived:bool;} -> unit mode call execution transaction targets [server,JavaScript] receiver atom ReadingLibrary requires {state archives id "archives" : list<string> [read,write];}; operation archiveType id "reading:runtime:archive-type" : record {processor:string;archived:bool;} -> unit mode call execution transaction targets [server,JavaScript] receiver atom ReadingLibrary requires {state archives id "archives" : list<string> [read,write];};
operation catalog id "reading:runtime:catalog" : unit -> list<record {key:string;label:string;strategy:string;scope:string;processorRevision:string;config:string;}> mode call execution snapshot targets [server] receiver any; operation catalog id "reading:runtime:catalog" : unit -> list<record {key:string;label:string;strategy:string;scope:string;processorRevision:string;config:string;}> mode call execution snapshot targets [server] receiver any;
operation createNavigation id "reading:runtime:create-navigation" : record {occurrence:string;} -> interface-ref<ReadingNavigation> mode call execution transaction targets [server] receiver atom ReadingLibrary requires { operation createNavigation id "reading:runtime:create-navigation" : record {occurrence:string;} -> interface-ref<ReadingNavigation> mode call execution transaction targets [server] receiver atom ReadingLibrary requires {
@@ -121,9 +121,9 @@ package LanguageReaderRuntime id "package:reading:runtime" revision "package:rea
operation addDocument id "reading:runtime:add-document" : record {title:string;text:string;source:string;url:string;attribution:string;} -> atom-ref<ReadingDocumentObject> mode call execution transaction targets [server] receiver atom ReadingLibrary requires {creation document id "document" : ReadingDocumentObject input DocumentSeed;interface home id "home" : LanguageReader using [defaultReader.resolve] from input;}; operation addDocument id "reading:runtime:add-document" : record {title:string;text:string;source:string;url:string;attribution:string;} -> atom-ref<ReadingDocumentObject> mode call execution transaction targets [server] receiver atom ReadingLibrary requires {creation document id "document" : ReadingDocumentObject input DocumentSeed;interface home id "home" : LanguageReader using [defaultReader.resolve] from input;};
operation attachAuthoring id "reading:runtime:attach-authoring" : atom-ref<ReadingJobObject> -> unit mode call execution transaction targets [server] receiver atom ReadingFeatureObject requires {edge authoring id "authoring" : optional-one atom ReadingJobObject [resolve,connect,disconnect];edge library id "library" : exactly-one interface LanguageReader [resolve];edge reader id "reader" : optional-one interface ReadingReader [resolve];interface job id "job" : ReadingJob using [library.resolve,reader.resolve] from input;}; operation attachAuthoring id "reading:runtime:attach-authoring" : atom-ref<ReadingJobObject> -> unit mode call execution transaction targets [server] receiver atom ReadingFeatureObject requires {edge authoring id "authoring" : optional-one atom ReadingJobObject [resolve,connect,disconnect];edge library id "library" : exactly-one interface LanguageReader [resolve];edge reader id "reader" : optional-one interface ReadingReader [resolve];interface job id "job" : ReadingJob using [library.resolve,reader.resolve] from input;};
operation setEnabled id "reading:runtime:set-enabled" : bool -> unit mode call execution transaction targets [server,JavaScript] receiver atom ReadingFeatureObject requires {state data id "data" : FeatureData [read,write];}; operation setEnabled id "reading:runtime:set-enabled" : bool -> unit mode call execution transaction targets [server,JavaScript] receiver atom ReadingFeatureObject requires {state data id "data" : FeatureData [read,write];};
operation addFeature id "reading:runtime:add-feature" : record {authoring:optional<atom-ref<ReadingJobObject>>;reader:optional<atom-ref<ReadingReaderObject>>;name:string;processor:string;style:string;reveal:string;generation:string;scope:string;color:string;options:string;} -> atom-ref<ReadingFeatureObject> mode call execution transaction targets [server] receiver atom ReadingLibrary requires {interface authoring id "authoring" : ReadingJob using [library.resolve,reader.resolve] from input;creation feature id "feature" : ReadingFeatureObject input FeatureSeed;interface home id "home" : LanguageReader using [defaultReader.resolve] from input;interface reader id "reader" : ReadingReader using [library.resolve] from input;}; operation addFeature id "reading:runtime:add-feature" : record {authoring:optional<atom-ref<ReadingJobObject>>;reader:optional<atom-ref<ReadingReaderObject>>;name:string;processor:string;options:string;} -> atom-ref<ReadingFeatureObject> mode call execution transaction targets [server] receiver atom ReadingLibrary requires {interface authoring id "authoring" : ReadingJob using [library.resolve,reader.resolve] from input;creation feature id "feature" : ReadingFeatureObject input FeatureSeed;interface home id "home" : LanguageReader using [defaultReader.resolve] from input;interface reader id "reader" : ReadingReader using [library.resolve] from input;};
operation processorRevision id "reading:runtime:processor-revision" : unit -> string mode call execution snapshot targets [server] receiver atom ReadingFeatureObject requires {state data id "data" : FeatureData [read];}; operation processorRevision id "reading:runtime:processor-revision" : unit -> string mode call execution snapshot targets [server] receiver atom ReadingFeatureObject requires {state data id "data" : FeatureData [read];};
operation configure id "reading:runtime:configure" : record {expectedRevision:uint32;name:string;style:string;reveal:string;generation:string;scope:string;color:string;enabled:bool;} -> unit mode call execution transaction targets [server] receiver atom ReadingFeatureObject requires {state data id "data" : FeatureData [read,write];}; operation configure id "reading:runtime:configure" : record {expectedRevision:uint32;name:string;enabled:bool;} -> unit mode call execution transaction targets [server] receiver atom ReadingFeatureObject requires {state data id "data" : FeatureData [read,write];};
operation remember id "reading:runtime:remember" : string -> unit mode call execution transaction targets [server] receiver atom ReadingLibrary requires { operation remember id "reading:runtime:remember" : string -> unit mode call execution transaction targets [server] receiver atom ReadingLibrary requires {
interface home id "home" : LanguageReader using [ensureVocabulary.call] from input; interface home id "home" : LanguageReader using [ensureVocabulary.call] from input;
interface vocabulary id "vocabulary" : ReadingVocabulary using [remember.call] from input; interface vocabulary id "vocabulary" : ReadingVocabulary using [remember.call] from input;
+25 -25
View File
@@ -1,27 +1,27 @@
quixos-lock version 1 { quixos-lock version 1 {
quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "be30143a326d5d7d7b0cbabea5bc5ac0301ef5b9"; } quixos source { repository "https://gitea-external.egads.tutti.syntaxblitz.net/quixos/quixos.git"; commit "e33714a5ca38429d1bdb77a961a14461697944f5"; }
interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "4c8dc969136d0add25c87c8f6cacb00f7dde97c0"; } interface ReadingVocabulary source { repository "https://repos.quixos.org/quixos-templates/interface-reading-vocabulary.git"; commit "9124b19926a5201bb380058d1dba08a208175177"; }
interface AgentContextSubject source { repository "https://repos.quixos.org/quixos-templates/interface-agent-context-subject.git"; commit "bceb5b979b53e9ccbda96c008264e12eaa91f9fb"; } interface AgentContextSubject source { repository "https://repos.quixos.org/quixos-templates/interface-agent-context-subject.git"; commit "7d7b03a73c1937a0979696b9f321a6d0c60128dc"; }
interface AgentDocumentation source { repository "https://repos.quixos.org/quixos-templates/interface-agent-documentation.git"; commit "c053da1b2c2cae1c140e75efec09095f5715176f"; } interface AgentDocumentation source { repository "https://repos.quixos.org/quixos-templates/interface-agent-documentation.git"; commit "27451991845f109763e29b8d6a3f488d129f59bf"; }
interface AgentWorkspace source { repository "https://repos.quixos.org/quixos-templates/interface-agent-workspace.git"; commit "fcf9a8d70e4488927d7b729d22fd972faf6b577a"; } interface AgentWorkspace source { repository "https://repos.quixos.org/quixos-templates/interface-agent-workspace.git"; commit "fdf85e21182ae15b0a0b119a8f6abc9751da447e"; }
interface AgentDirectory source { repository "https://repos.quixos.org/quixos-templates/interface-agent-directory.git"; commit "df28d7ee411168e4a44d890ee30c9be945861689"; } interface AgentDirectory source { repository "https://repos.quixos.org/quixos-templates/interface-agent-directory.git"; commit "d9678c85b20e9811e405437dd1ab9b4ab83b21d5"; }
interface Agent source { repository "https://repos.quixos.org/quixos-templates/interface-agent.git"; commit "dcd3f271b42649b4ce15a890f9b41e19a7271563"; } interface Agent source { repository "https://repos.quixos.org/quixos-templates/interface-agent.git"; commit "c3ef336e8ec85063f7d408efae478d30aa7b5ad3"; }
interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "7f1e9d6278366329fd76f2698cd5a53c136f9914"; } interface AgentRequest source { repository "https://repos.quixos.org/quixos-templates/interface-agent-request.git"; commit "6f3f42f1cba542823f925fd94deca3808f2d9763"; }
interface EventStream source { repository "https://repos.quixos.org/quixos-templates/interface-event-stream.git"; commit "2d13e7489c29dcc64df767e144fa79f782790ecb"; } interface EventStream source { repository "https://repos.quixos.org/quixos-templates/interface-event-stream.git"; commit "c97da9edea3e55ba6a4631bb436ce548f7df3977"; }
interface EventHandler source { repository "https://repos.quixos.org/quixos-templates/interface-event-handler.git"; commit "41825545baba6fb579d46987a28d225b43790d40"; } interface EventHandler source { repository "https://repos.quixos.org/quixos-templates/interface-event-handler.git"; commit "71cc4741a28fe8cdc748e242442240b8cea4f6a1"; }
interface EventSubscription source { repository "https://repos.quixos.org/quixos-templates/interface-event-subscription.git"; commit "cbde0f3b7690e7226a702b1b9517e415ed10c0a2"; } interface EventSubscription source { repository "https://repos.quixos.org/quixos-templates/interface-event-subscription.git"; commit "d340dce0136758abe4476fc552f87e5a5c20456b"; }
interface WebStudioCanvas source { repository "https://repos.quixos.org/quixos-templates/interface-web-studio-canvas.git"; commit "98a61b746983960e152f5ae3a29f95bdb7483d9a"; } interface WebStudioCanvas source { repository "https://repos.quixos.org/quixos-templates/interface-web-studio-canvas.git"; commit "b0c81fc9bfac172b349084b3345d3a816dd9fd8f"; }
interface WebStudioCanvasPlacement source { repository "https://repos.quixos.org/quixos-templates/interface-web-studio-canvas-placement.git"; commit "6c40bcab64cecb7349f688800cea3db447a9dd85"; } interface WebStudioCanvasPlacement source { repository "https://repos.quixos.org/quixos-templates/interface-web-studio-canvas-placement.git"; commit "11cd5e76172fd1e322baf393ebdcc44dd1db2797"; }
interface WebStudioPlaceable source { repository "https://repos.quixos.org/quixos-templates/interface-web-studio-placeable.git"; commit "0a095eaaed67f47185a86816be86471285c031a9"; } interface WebStudioPlaceable source { repository "https://repos.quixos.org/quixos-templates/interface-web-studio-placeable.git"; commit "d52427dd5e1e945cee8e7a8b9294b8aabc37c843"; }
interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "ad65d325d071443ba72edf5a99a5aeaa59f3e7e0"; } interface LanguageReader source { repository "https://repos.quixos.org/quixos-templates/interface-language-reader.git"; commit "03ab458b996852f1998abfd999e699527ad3553c"; }
interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "42b3565df44e6297ef88476fa44fa37e38a70d25"; } interface ReadingReader source { repository "https://repos.quixos.org/quixos-templates/interface-reading-reader.git"; commit "a0e458c19bbf61cc7afa40112e4a8eb30ce4c3c6"; }
interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "6ac56d55ee1dd79992cf28c3d760e1cf190225f1"; } interface ReadingDocument source { repository "https://repos.quixos.org/quixos-templates/interface-reading-document.git"; commit "f44ef80714db6fbc81df64369e1c3b1ba8177f48"; }
interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "2e18f362ce34884e15771b4068197f1cd97c12d4"; } interface ReadingFeature source { repository "https://repos.quixos.org/quixos-templates/interface-reading-feature.git"; commit "4414205afe1bc9e959083638b565d46dab09ec17"; }
interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "3bc1b603b65c981e08c8521d111c9dfb983f3d83"; } interface ReadingJob source { repository "https://repos.quixos.org/quixos-templates/interface-reading-job.git"; commit "0829b97660a2ed788ac0d29acb21502feea3e139"; }
interface ReadingRequest source { repository "https://repos.quixos.org/quixos-templates/interface-reading-request.git"; commit "5cfbb435b22074019741b9123359584a456efa8b"; } interface ReadingRequest source { repository "https://repos.quixos.org/quixos-templates/interface-reading-request.git"; commit "7c7f743a0322a30473eb1a34b3dbd530bc741440"; }
interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "9b302fb44e96671832de4aef4e4563dba5af6f2d"; } interface ReadingWord source { repository "https://repos.quixos.org/quixos-templates/interface-reading-word.git"; commit "0b147f9e0b2926687a2a12bfad627ee9bf502a0d"; }
interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "710cab97959f633a0ffd9a757f2dd2cf9b06f77f"; } interface ReadingNavigation source { repository "https://repos.quixos.org/quixos-templates/interface-reading-navigation.git"; commit "f001eb3813c5e263651474994de7aecf736388c6"; }
interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "ea1ffb89efbc7bdd9c20794cc63dc112055536c8"; } interface ReadingServices source { repository "https://repos.quixos.org/quixos-templates/interface-reading-services.git"; commit "187bcfabc2d221f56b0405fd466e7a0ffb53d6ac"; }
interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "b8f27e813b05231c9d3b4d3692e91b8fc2936dec"; } interface PresentationState source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-state.git"; commit "cf0984feed030ecb0e91e4ca88b1bdfb76526505"; }
interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "4a322809adfbb50494c9d5dd9889b56c907e737d"; } interface PresentationMount source { repository "https://repos.quixos.org/quixos-templates/interface-presentation-mount.git"; commit "a5360f6213e11937e14a534091748b86ee9dc3f1"; }
} }
+14 -1
View File
@@ -11,7 +11,20 @@ export function cacheConfiguration(
return JSON.stringify({ return JSON.stringify({
processor: identity, processor: identity,
scope, scope,
options: Object.fromEntries([...inputs].sort().map((key) => [key, options[key]])), options: Object.fromEntries(
[...inputs]
.sort()
.map((key) => [
key,
key
.split(".")
.reduce<unknown>(
(value, part) =>
value && typeof value === "object" ? (value as Record<string, unknown>)[part] : undefined,
options,
),
]),
),
}); });
} }
+134 -48
View File
@@ -1,18 +1,21 @@
import type { Processor } from "./processors.js"; import type { Processor } from "./processors.js";
export type ConfigValue = string | number | boolean; export type ConfigValue = string | number | boolean | AnnotatorConfig;
export type AnnotatorConfig = Record<string, ConfigValue>; export type AnnotatorConfig = { [key: string]: ConfigValue };
export type ConfigField = { export type ConfigField = {
key: string; key: string;
label: string; label: string;
kind: "color" | "number" | "select" | "toggle" | "text"; kind: "color" | "number" | "select" | "toggle" | "text";
default: ConfigValue; default: string | number | boolean;
presentation?: "font" | "underline" | "position";
options?: { value: string; label: string }[]; options?: { value: string; label: string }[];
min?: number; min?: number;
max?: number; max?: number;
/** False for display-only controls: changing them must reuse inference. */ /** False for display-only controls: changing them must reuse inference. */
computation?: boolean; computation?: boolean;
}; };
export type ConfigGroup = { key: string; label: string; kind: "group"; fields: ConfigNode[] };
export type ConfigNode = ConfigField | ConfigGroup;
export const readingProfiles = [ export const readingProfiles = [
"light-fast", "light-fast",
"light", "light",
@@ -23,61 +26,144 @@ export const readingProfiles = [
"cerebras-light", "cerebras-light",
"cerebras-balanced", "cerebras-balanced",
]; ];
export function fieldsFor(processor: Processor): ConfigField[] { export function fieldsFor(processor: Processor): ConfigNode[] {
return typeof processor.config === "function" ? processor.config() : (processor.config ?? []); return typeof processor.config === "function" ? processor.config() : (processor.config ?? []);
} }
function leaves(fields: ConfigNode[], prefix = ""): { path: string; field: ConfigField }[] {
return fields.flatMap((field) =>
field.kind === "group" ? leaves(field.fields, prefix + field.key + ".") : [{ path: prefix + field.key, field }],
);
}
export function computationFields(processor: Processor) { export function computationFields(processor: Processor) {
return ( return (
processor.cache?.inputs ?? processor.cache?.inputs ??
fieldsFor(processor) leaves(fieldsFor(processor))
.filter((field) => field.computation !== false && field.kind !== "color") .filter(({ field }) => field.computation !== false)
.map((field) => field.key) .map(({ path }) => path)
); );
} }
export function normalizeConfig(processor: Processor, raw: string): AnnotatorConfig { export function normalizeConfig(processor: Processor, raw: string): AnnotatorConfig {
if (raw.length > 16000) throw Error("Annotation settings are too large"); if (raw.length > 16000) throw Error("Annotation settings are too large");
const value = JSON.parse(raw); function normalize(fields: ConfigNode[], value: unknown, path = "", depth = 0): AnnotatorConfig {
if (!value || typeof value !== "object" || Array.isArray(value)) throw Error("Expected annotation settings"); if (depth > 8 || !value || typeof value !== "object" || Array.isArray(value))
const fields: ConfigField[] = [ throw Error("Expected annotation settings: " + path);
{ const source = value as Record<string, unknown>;
key: "placement", if (new Set(fields.map((field) => field.key)).size !== fields.length)
label: "Label position", throw Error("Duplicate annotation control: " + path);
kind: "select",
default: "above",
computation: false,
options: [
{ value: "above", label: "Above the text" },
{ value: "below", label: "Below the text" },
],
},
{ key: "fontFamily", label: "Font", kind: "text", default: "" },
{ key: "fontSize", label: "Font size", kind: "number", default: 13, min: 6, max: 72 },
{
key: "underlinePattern",
label: "Underline pattern",
kind: "select",
default: "solid",
computation: false,
options: ["solid", "dashed", "squiggly"].map((value) => ({ value, label: value })),
},
...fieldsFor(processor),
];
if (new Set(fields.map((field) => field.key)).size !== fields.length) throw Error("Duplicate annotation control");
for (const key of Object.keys(value))
if (!fields.some((field) => field.key === key)) throw Error("Unknown annotation setting: " + key);
const config: AnnotatorConfig = {};
for (const field of fields) {
const entry = Object.hasOwn(value, field.key) ? value[field.key] : field.default;
if ( if (
typeof entry !== typeof field.default || fields.some(
(typeof entry === "number" && (field) =>
(!Number.isFinite(entry) || entry < (field.min ?? -Infinity) || entry > (field.max ?? Infinity))) || !/^[A-Za-z][A-Za-z0-9_]*$/.test(field.key) || ["constructor", "prototype", "__proto__"].includes(field.key),
(typeof entry === "string" && (entry.length > 160 || /[\r\n\x00]/.test(entry))) || )
(field.kind === "color" && (typeof entry !== "string" || !/^#[0-9a-f]{6}$/i.test(entry))) ||
(field.kind === "select" && !field.options?.some((option) => option.value === entry))
) )
throw Error("Invalid annotation setting: " + field.label); throw Error("Invalid annotation control key");
config[field.key] = entry; for (const key of Object.keys(source))
if (!fields.some((field) => field.key === key)) throw Error("Unknown annotation setting: " + path + key);
return Object.fromEntries(
fields.map((field) => {
const present = Object.hasOwn(source, field.key);
if (field.kind === "group")
return [
field.key,
normalize(field.fields, present ? source[field.key] : {}, path + field.key + ".", depth + 1),
];
const entry = present ? source[field.key] : field.default;
if (
typeof entry !== typeof field.default ||
(typeof entry === "number" &&
(!Number.isFinite(entry) || entry < (field.min ?? -Infinity) || entry > (field.max ?? Infinity))) ||
(typeof entry === "string" && (entry.length > 160 || /[\r\n\x00]/.test(entry))) ||
(field.kind === "color" && (typeof entry !== "string" || !/^#[0-9a-f]{6}$/i.test(entry))) ||
(field.kind === "select" && !field.options?.some((option) => option.value === entry))
)
throw Error("Invalid annotation setting: " + path + field.label);
return [field.key, entry as ConfigValue];
}),
);
} }
return config; return normalize(fieldsFor(processor), JSON.parse(raw));
}
/** Reusable, opt-in structs. Keys let one annotator compose several independent sets. */
export function underlineControls(
key: string,
label: string,
defaults: { color?: string; pattern?: "solid" | "dashed" | "squiggly" } = {},
): ConfigGroup {
return {
key,
label,
kind: "group",
fields: [
{ key: "color", label: "Color", kind: "color", default: defaults.color ?? "#66866b", computation: false },
{
key: "underlinePattern",
label: "Pattern",
kind: "select",
presentation: "underline",
default: defaults.pattern ?? "solid",
computation: false,
options: ["solid", "dashed", "squiggly"].map((value) => ({
value,
label: value[0].toUpperCase() + value.slice(1),
})),
},
],
};
}
export function labelControls(
key: string,
label: string,
defaults: { color?: string; fontSize?: number; placement?: "above" | "below" } = {},
): ConfigGroup {
return {
key,
label,
kind: "group",
fields: [
{ key: "color", label: "Color", kind: "color", default: defaults.color ?? "#66866b", computation: false },
{ key: "fontFamily", label: "Font", kind: "text", presentation: "font", default: "", computation: false },
{
key: "fontSize",
label: "Font size",
kind: "number",
default: defaults.fontSize ?? 13,
min: 6,
max: 72,
computation: false,
},
{
key: "placement",
label: "Position",
kind: "select",
presentation: "position",
default: defaults.placement ?? "above",
computation: false,
options: [
{ value: "above", label: "Above" },
{ value: "below", label: "Below" },
],
},
],
};
}
export function colorControl(key = "color", label = "Color", color = "#66866b"): ConfigField {
return { key, label, kind: "color", default: color, computation: false };
}
/** Only declared semantic paths enter a model prompt, preserving nested structs. */
export function semanticConfig(processor: Processor, config: AnnotatorConfig): AnnotatorConfig {
const result: AnnotatorConfig = {};
for (const path of computationFields(processor)) {
const parts = path.split(".");
let source: ConfigValue | undefined = config;
for (const part of parts) source = source && typeof source === "object" ? source[part] : undefined;
if (source === undefined) continue;
let target = result;
for (const part of parts.slice(0, -1)) {
target[part] ??= {};
target = target[part] as AnnotatorConfig;
}
target[parts.at(-1)!] = source;
}
return result;
} }
+23
View File
@@ -0,0 +1,23 @@
import type { PortableImplementation } from "./gen/qx.js";
import { QxReject } from "@quixos/camino-replica-core";
import { processors } from "./processors.js";
import { normalizeConfig } from "./config.js";
export function checkedOptions(processor: string, raw: string) {
if (!processors[processor]) throw new QxReject("INVALID_PROCESSOR");
try {
return normalizeConfig(processors[processor], raw);
} catch {
throw new QxReject("INVALID_ANNOTATOR_OPTIONS");
}
}
/** Identical validation and revision guard for local projection and server replay. */
export const configureOptions: PortableImplementation["configureOptions"] = async ({ input, ports }) => {
const current = await ports.data.get();
if (current.configRevision !== input.expectedRevision) throw new QxReject("READING_FEATURE_CHANGED");
const options = checkedOptions(current.processor, input.options);
await ports.options.set(JSON.stringify(options));
await ports.data.set({ ...current, configRevision: current.configRevision + 1 });
return null;
};
+24 -12
View File
@@ -47,9 +47,10 @@ appends sequentially. The UI prepares a few pages ahead, then annotation plannin
works on the ready output. Never send an entire long document for rewriting. works on the ready output. Never send an entire long document for rewriting.
Ranges refer to UTF-16 offsets in the displayed document; replacement annotation Ranges refer to UTF-16 offsets in the displayed document; replacement annotation
ranges still refer to the original source span, and copying returns that source. ranges still refer to the original source span, and copying returns that source.
ReadingFeature.data configures presentation and reveal; processor implementations ReadingFeature.data stores the instance name, enabled state, processor identity
live in LanguageReaderRuntime/src/processors.ts. Add actual checked code there, and config revision. Processor implementations live in
including its computation strategy, then add a feature through the public graph. LanguageReaderRuntime/src/processors.ts. Add actual checked code there, including
its computation strategy and render channels, then register through the public graph.
Read README.md before editing. Chinese and French source examples are workspace Read README.md before editing. Chinese and French source examples are workspace
authoring examples, not an in-application language selector. authoring examples, not an in-application language selector.
Choose preparation with judgment: cheap local or batch computations can prepare Choose preparation with judgment: cheap local or batch computations can prepare
@@ -81,29 +82,40 @@ source glyphs, outside canvas clipping. It reuses the existing processor and
heavy-hover intent/cache path; never make extra calls from its UI. heavy-hover intent/cache path; never make extra calls from its UI.
Renderer styles also include replace, bold and italic. Overhead-hover recolors the Renderer styles also include replace, bold and italic. Overhead-hover recolors the
corresponding source span without activating word-hover tooltips or computation. corresponding source span without activating word-hover tooltips or computation.
Style overhead supports options.placement:"above" (default) or "below"; a Style overhead supports placement:"above" (default) or "below" on its render
Processor.renders hook can also declare placement. Each side keeps fixed source channel or explicitly bound config group. Each side keeps fixed source
lanes. Position is display-only and must reuse cached semantic values. Long lanes. Position is display-only and must reuse cached semantic values. Long
labels receive faint alignment guides, and punctuation retains its normal ink. labels receive faint alignment guides, and punctuation retains its normal ink.
Short words and fitting overhead labels keep their Short words and fitting overhead labels keep their
source together across line/page breaks; oversized runs may still split. source together across line/page breaks; oversized runs may still split.
Rendering source is LanguageReaderPresentation/src/canvas. It uses measured Rendering source is LanguageReaderPresentation/src/canvas. It uses measured
source spans, fixed rows per annotation source and exact source selection. source spans, fixed rows per annotation source and exact source selection.
Each annotator owns its inline configuration: Processor.config can declare fields Instances have no persisted visual type, generation, scope, color or reveal.
The processor owns these choices and can emit several render channels. Creation
choices are authoring instructions only. Each annotator owns its inline
configuration: Processor.config can declare fields and independent nested groups
or generate them; config.ts validates options on write. ReadingFeature.options or generate them; config.ts validates options on write. ReadingFeature.options
stores a JSON object and configureOptions requires the current configRevision. stores a JSON object and configureOptions requires the current configRevision.
Settings autosave optimistically through validated local transactions. Custom
config components call change(key,value), without their own Save buttons or
network requests. Keep processor declarations and config generation portable;
put server-only dependencies in server execution modules, not the config registry.
Raw results persist indefinitely by source revision, target range, processor, scope, Raw results persist indefinitely by source revision, target range, processor, scope,
actual context and semantic options. Model profile, scheduling, color, font and actual context and semantic options. Model profile, scheduling, color, font and
display thresholds never invalidate semantic facts. An explicit retry regenerates. Processor.cache.inputs display thresholds never invalidate semantic facts. An explicit retry regenerates. Processor.cache.inputs
explicitly names semantic option keys; otherwise non-color, non-display config fields explicitly names semantic option keys; otherwise declared non-display config fields
participate. Mark display-only fields computation:false. prompt(config) receives only participate. Mark display-only fields computation:false. prompt(config) receives only
semantic inputs. Appearance, typography and visibility filters must run in the semantic inputs. Appearance, typography and visibility filters must run in the
presentation's annotation-display.ts; install an annotationDisplays function for presentation's annotation-display.ts; install an annotationDisplays function for
custom filtering or multi-color styles. Do not persist decoration in model results. custom filtering or multi-color styles. Do not persist decoration in model results.
Underlines use the common display-only underlinePattern option: solid (default), No controls are added automatically. Processor.config is the whole settings schema.
dashed or squiggly. Changing the pattern must reuse the existing computation cache. Compose underlineControls(key,label,defaults) or labelControls(key,label,defaults)
The presentation's annotationDisplays hook can override underlinePattern per item from config.ts as explicit named groups, even several underlines in one annotator.
alongside color and font; only these three patterns are valid. All patterns occupy Bind each renders channel with options:"groupKey"; constants stay in unbound code.
These structs have their own nested defaults, validation and React presentation.
Pattern choices are solid, dashed and squiggly. Changing them reuses the cache.
The presentation's annotationDisplays hook can supply underlinePattern per item
alongside color and font; respect any exposed appearance controls. Only these three patterns are valid. All patterns occupy
the same underline band, so switching appearance must not move source text. the same underline band, so switching appearance must not move source text.
Processor.renders projects one cached item through several style/reveal hooks; Processor.renders projects one cached item through several style/reveal hooks;
content:"source" makes a tooltip show the original source without inference. content:"source" makes a tooltip show the original source without inference.
@@ -125,7 +137,7 @@ queue; all nearby word tokens prepare, irrespective of the displayed HSK thresho
The presentation's annotator-config.tsx renders declared controls and exposes The presentation's annotator-config.tsx renders declared controls and exposes
annotatorConfigViews for purpose-built React components with the same values/change annotatorConfigViews for purpose-built React components with the same values/change
contract. Keep these controls inside the general annotator config, not separate contract. Keep these controls inside the general annotator config, not separate
workspace-global settings. Overhead font family/size are common options; mixed workspace-global settings. Opt-in label controls provide font family/size; mixed
sizes share a source row baseline using maximum ascent/descent for each line. sizes share a source row baseline using maximum ascent/descent for each line.
Library.inferenceProfile defaults to quick-fast (Luna, no reasoning, fast mode), Library.inferenceProfile defaults to quick-fast (Luna, no reasoning, fast mode),
is chosen in code by the author (no model picker), and routes future uncached requests. is chosen in code by the author (no model picker), and routes future uncached requests.
-25
View File
@@ -9,46 +9,21 @@ export const chineseFeatures = [
{ {
name: "Pinyin for hard words", name: "Pinyin for hard words",
processor: "pinyin", processor: "pinyin",
style: "overhead",
reveal: "always",
generation: "section",
scope: "word",
color: "#66866b",
}, },
{ {
name: "Word definition", name: "Word definition",
processor: "definition", processor: "definition",
style: "sidebar",
reveal: "click",
generation: "focus",
scope: "word",
color: "#536b92",
}, },
{ {
name: "Sentence definition", name: "Sentence definition",
processor: "translation", processor: "translation",
style: "sidebar",
reveal: "click",
generation: "focus",
scope: "sentence",
color: "#927d5b",
}, },
{ {
name: "Proper nouns", name: "Proper nouns",
processor: "names", processor: "names",
style: "highlight",
reveal: "always",
generation: "section",
scope: "word",
color: "#d4ac53",
}, },
{ {
name: "Pinyin on hover", name: "Pinyin on hover",
processor: "pinyinWord", processor: "pinyinWord",
style: "tooltip",
reveal: "hover",
generation: "section",
scope: "word",
color: "#66866b",
}, },
]; ];
+12 -22
View File
@@ -1,17 +1,17 @@
import type { Processor } from "../processors.js"; import type { Processor } from "../processors.js";
import { labelControls, colorControl } from "../config.js";
/** Two independent fixed lanes following the current click. */ /** Two independent fixed lanes following the current click. */
export const frenchProcessors: Record<string, Processor> = { export const frenchProcessors: Record<string, Processor> = {
frenchAdvancedEnglish: { frenchAdvancedEnglish: {
label: "Advanced words in English", label: "Advanced words in English",
scope: "word",
description: description:
"Replace advanced French vocabulary with short English equivalents where they fit. CEFR levels are estimates.", "Replace advanced French vocabulary with short English equivalents where they fit. CEFR levels are estimates.",
settings: { color: true },
preset: { style: "replace", reveal: "always" },
profile: "light-fast", profile: "light-fast",
strategy: "section", strategy: "section",
scope: "word",
processing: { chunkChars: 600, contextChars: 1600 }, processing: { chunkChars: 600, contextChars: 1600 },
config: [ config: [
colorControl("color", "Replacement ink", "#526c86"),
{ {
key: "minimumLevel", key: "minimumLevel",
label: "Replace vocabulary from", label: "Replace vocabulary from",
@@ -26,7 +26,7 @@ export const frenchProcessors: Record<string, Processor> = {
], ],
cache: { inputs: [] }, cache: { inputs: [] },
renders: [ renders: [
{ style: "replace", reveal: "always", content: "result" }, { style: "replace", reveal: "always", content: "result", options: "" },
{ style: "tooltip", reveal: "hover", content: "source" }, { style: "tooltip", reveal: "hover", content: "source" },
], ],
instructions: instructions:
@@ -35,11 +35,11 @@ export const frenchProcessors: Record<string, Processor> = {
frenchGloss: { frenchGloss: {
profile: "cerebras-light", profile: "cerebras-light",
label: "French word glosses", label: "French word glosses",
description: "English meanings above each word in the selected sentence.",
settings: { color: true, font: true },
preset: { style: "overhead", reveal: "click" },
strategy: "focus",
scope: "sentence", scope: "sentence",
config: [labelControls("label", "Word glosses", { color: "#526c86" })],
renders: [{ style: "overhead", reveal: "click", options: "label" }],
description: "English meanings above each word in the selected sentence.",
strategy: "focus",
processing: { focusChars: 500, contextChars: 1200, beforeSentences: 1, afterSentences: 1 }, processing: { focusChars: 500, contextChars: 1200, beforeSentences: 1, afterSentences: 1 },
instructions: instructions:
"Gloss each French word in the target range in concise English, including function words and repeated words. Use surrounding context to choose the meaning. Return one item per occurrence with the exact supplied JavaScript UTF-16 source offsets. Keep individual glosses short, not whole-sentence translations.", "Gloss each French word in the target range in concise English, including function words and repeated words. Use surrounding context to choose the meaning. Return one item per occurrence with the exact supplied JavaScript UTF-16 source offsets. Keep individual glosses short, not whole-sentence translations.",
@@ -47,11 +47,11 @@ export const frenchProcessors: Record<string, Processor> = {
frenchPassage: { frenchPassage: {
profile: "cerebras-light", profile: "cerebras-light",
label: "French passage translation", label: "French passage translation",
description: "An English translation of a few sentences in the upper lane.",
settings: { color: true, font: true },
preset: { style: "overhead", reveal: "click" },
strategy: "focus",
scope: "section", scope: "section",
config: [labelControls("label", "Passage translation", { color: "#947459" })],
renders: [{ style: "overhead", reveal: "click", options: "label" }],
description: "An English translation of a few sentences in the upper lane.",
strategy: "focus",
processing: { focusChars: 1000, contextChars: 1400, beforeSentences: 1, afterSentences: 1 }, processing: { focusChars: 1000, contextChars: 1400, beforeSentences: 1, afterSentences: 1 },
instructions: instructions:
"Translate the target French passage into natural English. Return one item for each complete sentence in the target range with its exact source range. Do not include surrounding context in the returned source ranges. Be concise while preserving the meaning.", "Translate the target French passage into natural English. Return one item for each complete sentence in the target range with its exact source range. Do not include surrounding context in the returned source ranges. Be concise while preserving the meaning.",
@@ -61,20 +61,10 @@ export const frenchFeatures = [
{ {
name: "Word glosses", name: "Word glosses",
processor: "frenchGloss", processor: "frenchGloss",
style: "overhead",
reveal: "click",
generation: "focus",
scope: "sentence",
color: "#526c86",
}, },
{ {
name: "Passage translation", name: "Passage translation",
processor: "frenchPassage", processor: "frenchPassage",
style: "overhead",
reveal: "click",
generation: "focus",
scope: "section",
color: "#947459",
}, },
]; ];
export const frenchDocument = { export const frenchDocument = {
+1 -1
View File
@@ -20,7 +20,7 @@ export const plan: PortableImplementation["plan"] = async ({ input, ports }) =>
const index = textIndex(document.text), const index = textIndex(document.text),
range = { start: input.start, end: input.end }; range = { start: input.start, end: input.end };
if (range.start >= range.end || range.end > document.text.length) return []; if (range.start >= range.end || range.end > document.text.length) return [];
if (input.kind === "focus") return [index.focus(range, feature.scope, policy)]; if (input.kind === "focus") return [index.focus(range, processor.scope, policy)];
if (input.kind !== "nearby") throw new QxReject("INVALID_READING_PLAN"); if (input.kind !== "nearby") throw new QxReject("INVALID_READING_PLAN");
return index.nearby(range, { start: input.visibleStart, end: input.visibleEnd }, policy); return index.nearby(range, { start: input.visibleStart, end: input.visibleEnd }, policy);
}; };
+2
View File
@@ -1,9 +1,11 @@
import { configureOptions } from "./configuration.js";
import { plan } from "./planning.js"; import { plan } from "./planning.js";
import { archiveType } from "./type-archives.js"; import { archiveType } from "./type-archives.js";
import { openDocument, selectSettings, attachReader } from "./navigation.js"; import { openDocument, selectSettings, attachReader } from "./navigation.js";
import { createRegistry } from "./gen/qx.js"; import { createRegistry } from "./gen/qx.js";
import { QxReject } from "@quixos/camino-replica-core"; import { QxReject } from "@quixos/camino-replica-core";
export const registry = createRegistry({ export const registry = createRegistry({
configureOptions,
archiveType, archiveType,
plan, plan,
openDocument, openDocument,
+45 -16
View File
@@ -4,12 +4,10 @@
* Never start one background LLM request per token to implement a section pass. * Never start one background LLM request per token to implement a section pass.
*/ */
import type { ProcessingPolicy } from "./text-processing.js"; import type { ProcessingPolicy } from "./text-processing.js";
import type { AnnotatorConfig, ConfigField } from "./config.js"; import { labelControls, underlineControls, type AnnotatorConfig, type ConfigNode } from "./config.js";
export type Processor = { export type Processor = {
label: string; label: string;
description?: string; description?: string;
settings?: { color?: boolean; font?: boolean; when?: string[] };
preset?: { style: string; reveal: string };
strategy: "local" | "section" | "focus"; strategy: "local" | "section" | "focus";
scope: "word" | "sentence" | "section"; scope: "word" | "sentence" | "section";
instructions: string; instructions: string;
@@ -18,7 +16,7 @@ export type Processor = {
/** Require model ranges to echo their exact original source; opt out only for a documented need. */ /** Require model ranges to echo their exact original source; opt out only for a documented need. */
verifySource?: boolean; verifySource?: boolean;
processing?: Partial<ProcessingPolicy> | ((config: AnnotatorConfig) => Partial<ProcessingPolicy>); processing?: Partial<ProcessingPolicy> | ((config: AnnotatorConfig) => Partial<ProcessingPolicy>);
config?: ConfigField[] | (() => ConfigField[]); config?: ConfigNode[] | (() => ConfigNode[]);
prompt?: (config: AnnotatorConfig) => string; prompt?: (config: AnnotatorConfig) => string;
/** Explicit semantic inputs, excluding color/font/filter preferences. */ /** Explicit semantic inputs, excluding color/font/filter preferences. */
cache?: { cache?: {
@@ -33,11 +31,17 @@ export type Processor = {
}; };
/** Present one semantic data source in several ways, without new model jobs. /** Present one semantic data source in several ways, without new model jobs.
* Source content keeps the original UTF-16 range (e.g. a replacement tooltip). */ * Source content keeps the original UTF-16 range (e.g. a replacement tooltip). */
renders?: readonly { renders: readonly {
style: "overhead" | "highlight" | "underline" | "color" | "sidebar" | "tooltip" | "replace" | "bold" | "italic"; style: "overhead" | "highlight" | "underline" | "color" | "sidebar" | "tooltip" | "replace" | "bold" | "italic";
reveal: "always" | "click" | "hover"; reveal: "always" | "click" | "hover";
content?: "result" | "source"; content?: "result" | "source";
placement?: "above" | "below"; placement?: "above" | "below";
color?: string;
fontFamily?: string;
fontSize?: number;
underlinePattern?: "solid" | "dashed" | "squiggly";
/** Explicit path to a code-owned appearance config struct. */
options?: string;
}[]; }[];
local?: (text: string, start: number, config?: AnnotatorConfig) => AnnotationResult[]; local?: (text: string, start: number, config?: AnnotatorConfig) => AnnotationResult[];
}; };
@@ -48,12 +52,12 @@ export const processors: Record<string, Processor> = {
pinyin: { pinyin: {
label: "Pinyin for hard words", label: "Pinyin for hard words",
description: "Show pronunciation above words beyond your HSK level.", description: "Show pronunciation above words beyond your HSK level.",
settings: { color: true, font: true }, renders: [{ style: "overhead", reveal: "always", options: "label" }],
preset: { style: "overhead", reveal: "always" },
strategy: "section", strategy: "section",
scope: "word", scope: "word",
processing: { chunkChars: 160 }, processing: { chunkChars: 160 },
config: [ config: [
labelControls("label", "Pronunciation"),
{ {
key: "minimumLevel", key: "minimumLevel",
label: "Your HSK level", label: "Your HSK level",
@@ -76,15 +80,15 @@ export const processors: Record<string, Processor> = {
strategy: "section", strategy: "section",
scope: "word", scope: "word",
processing: { chunkChars: 160 }, processing: { chunkChars: 160 },
preset: { style: "tooltip", reveal: "hover" }, renders: [{ style: "tooltip", reveal: "hover" }],
cache: { identity: "pinyin", inputs: [] }, cache: { identity: "pinyin", inputs: [] },
instructions: "", instructions: "",
}, },
gloss: { gloss: {
label: "English meaning", label: "English meaning",
config: [labelControls("label", "Gloss appearance")],
description: "Short English meanings above useful words.", description: "Short English meanings above useful words.",
settings: { color: true, font: true }, renders: [{ style: "overhead", reveal: "always", options: "label" }],
preset: { style: "overhead", reveal: "always" },
strategy: "section", strategy: "section",
scope: "word", scope: "word",
instructions: instructions:
@@ -93,7 +97,7 @@ export const processors: Record<string, Processor> = {
grammar: { grammar: {
label: "Parts of speech", label: "Parts of speech",
description: "Color words by their grammatical role.", description: "Color words by their grammatical role.",
preset: { style: "color", reveal: "always" }, renders: [{ style: "color", reveal: "always" }],
strategy: "section", strategy: "section",
scope: "word", scope: "word",
config: () => config: () =>
@@ -108,6 +112,7 @@ export const processors: Record<string, Processor> = {
key: tag + "Color", key: tag + "Color",
label: tag[0].toUpperCase() + tag.slice(1), label: tag[0].toUpperCase() + tag.slice(1),
kind: "color" as const, kind: "color" as const,
computation: false,
default: color, default: color,
})), })),
instructions: instructions:
@@ -116,7 +121,7 @@ export const processors: Record<string, Processor> = {
names: { names: {
label: "Proper nouns", label: "Proper nouns",
description: "Highlight people, places, and organizations.", description: "Highlight people, places, and organizations.",
preset: { style: "highlight", reveal: "always" }, renders: [{ style: "highlight", reveal: "always" }],
strategy: "section", strategy: "section",
scope: "word", scope: "word",
config: () => config: () =>
@@ -124,6 +129,7 @@ export const processors: Record<string, Processor> = {
key: tag + "Color", key: tag + "Color",
label: tag[0].toUpperCase() + tag.slice(1), label: tag[0].toUpperCase() + tag.slice(1),
kind: "color" as const, kind: "color" as const,
computation: false,
default: color, default: color,
})), })),
instructions: instructions:
@@ -132,16 +138,38 @@ export const processors: Record<string, Processor> = {
definition: { definition: {
label: "Word definition", label: "Word definition",
description: "Click a word for its meaning and usage in the sidebar.", description: "Click a word for its meaning and usage in the sidebar.",
preset: { style: "sidebar", reveal: "click" }, renders: [{ style: "sidebar", reveal: "click" }],
strategy: "focus", strategy: "focus",
scope: "word", scope: "word",
instructions: instructions:
"Explain the focused Chinese word in its sentence context. Include pinyin, a concise English definition and one usage note. Return one item covering exactly the supplied focus range. Neighboring text is context, not a request to annotate every word.", "Explain the focused Chinese word in its sentence context. Include pinyin, a concise English definition and one usage note. Return one item covering exactly the supplied focus range. Neighboring text is context, not a request to annotate every word.",
}, },
definitionTooltip: {
label: "Word definition tooltip",
description: "Explain a word in a floating note.",
strategy: "focus",
scope: "word",
instructions: "",
cache: { identity: "definition", inputs: [] },
config: [
{
key: "reveal",
label: "Show definition",
kind: "select",
default: "hover",
computation: false,
options: [
{ value: "hover", label: "When hovering" },
{ value: "click", label: "When clicked" },
],
},
],
renders: [{ style: "tooltip", reveal: "hover", options: "" }],
},
passageTranslation: { passageTranslation: {
label: "Passage translation", label: "Passage translation",
description: "English translations alongside the passage you are reading.", description: "English translations alongside the passage you are reading.",
preset: { style: "sidebar", reveal: "click" }, renders: [{ style: "sidebar", reveal: "click" }],
strategy: "section", strategy: "section",
scope: "sentence", scope: "sentence",
instructions: instructions:
@@ -150,7 +178,7 @@ export const processors: Record<string, Processor> = {
translation: { translation: {
label: "Sentence definition", label: "Sentence definition",
description: "Click a word to explain the sentence in the sidebar.", description: "Click a word to explain the sentence in the sidebar.",
preset: { style: "sidebar", reveal: "click" }, renders: [{ style: "sidebar", reveal: "click" }],
strategy: "focus", strategy: "focus",
scope: "sentence", scope: "sentence",
instructions: instructions:
@@ -159,7 +187,8 @@ export const processors: Record<string, Processor> = {
punctuation: { punctuation: {
label: "Sentence punctuation", label: "Sentence punctuation",
description: "Mark sentence endings with an underline.", description: "Mark sentence endings with an underline.",
preset: { style: "underline", reveal: "always" }, config: [underlineControls("underline", "Punctuation underline")],
renders: [{ style: "underline", reveal: "always", options: "underline" }],
strategy: "local", strategy: "local",
scope: "section", scope: "section",
instructions: "", instructions: "",
+44 -68
View File
@@ -1,3 +1,4 @@
import { configureOptions, checkedOptions } from "./configuration.js";
import { plan } from "./planning.js"; import { plan } from "./planning.js";
import { archiveType } from "./type-archives.js"; import { archiveType } from "./type-archives.js";
import { openDocument, selectSettings, attachReader } from "./navigation.js"; import { openDocument, selectSettings, attachReader } from "./navigation.js";
@@ -18,7 +19,14 @@ import { processorSource, annotationTaskSource } from "./source-version.js";
import { processorIdentity } from "./processor-identity.js"; import { processorIdentity } from "./processor-identity.js";
import { createVariant, parseVariant, nextSection, applySection, variantText } from "./variants.js"; import { createVariant, parseVariant, nextSection, applySection, variantText } from "./variants.js";
import { textIndex, processingPolicy } from "./text-processing.js"; import { textIndex, processingPolicy } from "./text-processing.js";
import { fieldsFor, normalizeConfig, computationFields, readingProfiles, type AnnotatorConfig } from "./config.js"; import {
fieldsFor,
normalizeConfig,
computationFields,
readingProfiles,
type AnnotatorConfig,
semanticConfig,
} from "./config.js";
const as = <Id extends string>(ref: ExactReference, id: Id): ManagedReference<"interface", Id> => ({ const as = <Id extends string>(ref: ExactReference, id: Id): ManagedReference<"interface", Id> => ({
...ref, ...ref,
@@ -63,6 +71,10 @@ Inspect existing component patterns, design intentional layouts, and check them
in a browser. Independent UI implementation/design can use a bounded subagent; in a browser. Independent UI implementation/design can use a bounded subagent;
join it before checking or activating the workspace. Prefer native retained join it before checking or activating the workspace. Prefer native retained
navigation and CRDT drafts over browser-only page or input state. navigation and CRDT drafts over browser-only page or input state.
Annotator instances store implementation identity and declared options, not a single
visual type. Their code owns every render channel and every settings control.
Use explicit nested control groups or custom React config views; never infer
controls from an output style or override a setting the user can edit.
Keep hover effects from reflowing text. Changes to font metrics, source content Keep hover effects from reflowing text. Changes to font metrics, source content
or spacing need permanent or click activation; hover can change ink or show an or spacing need permanent or click activation; hover can change ink or show an
anchored tooltip while the text stays in place. anchored tooltip while the text stays in place.
@@ -77,7 +89,9 @@ heavy per-word requests must stay focused, cached, and debounced on hover. Revea
timing is independent. Add checked processor code (and UI or contracts if needed), timing is independent. Add checked processor code (and UI or contracts if needed),
run qx-workspace check for affected packages and root, then automatically activate run qx-workspace check for affected packages and root, then automatically activate
the successful candidate. Finally register the feature through LanguageReader.addFeature the successful candidate. Finally register the feature through LanguageReader.addFeature
with the appropriate processor, style, scope, generation, and options (JSON string). with its processor and options (JSON string). Instances do not store a visual type,
color, reveal mode, scope, or generation strategy: the checked processor owns
computation and every render channel. An annotator can render several ways at once.
Cache raw semantic results through the shared cache wrapper. Explicitly declare Cache raw semantic results through the shared cache wrapper. Explicitly declare
Processor.cache.inputs for options that alter computation. Color, font, display Processor.cache.inputs for options that alter computation. Color, font, display
thresholds and reveal preferences must not cause inference. Mark display-only thresholds and reveal preferences must not cause inference. Mark display-only
@@ -90,6 +104,14 @@ the same reading. Prefer bounded batch preparation over a request per word.
Use Processor.renders when one semantic source needs several appearances. A replace Use Processor.renders when one semantic source needs several appearances. A replace
hook already supports colored ink; a hover tooltip with content:"source" shows hook already supports colored ink; a hover tooltip with content:"source" shows
the original source locally. Do not create extra inference for another appearance. the original source locally. Do not create extra inference for another appearance.
There are no automatic settings based on output style or field names. Reuse
underlineControls(key,label,defaults) or labelControls(key,label,defaults) from
config.ts when useful; these are independent nested structs with their own UI.
Compose several named groups when several channels need different settings.
Bind each Processor.renders channel explicitly with options:"groupKey"; unbound
channels retain code constants. Custom appearance functions must honor exposed
controls, not overwrite them. Add a settings-to-render regression and verify
changing each offered control actually changes the output without fresh inference.
Inline custom configuration is a core annotator feature: declare or generate config Inline custom configuration is a core annotator feature: declare or generate config
fields in the Processor definition and use the presentation annotationDisplays registry for dynamic fields in the Processor definition and use the presentation annotationDisplays registry for dynamic
colors/fonts/filtering. For bespoke controls, install a React component in colors/fonts/filtering. For bespoke controls, install a React component in
@@ -99,12 +121,12 @@ Use the request's reader context when registering the feature (addFeature.reader
Before every turn, including follow-ups, read ReadingReader.data, its features, Before every turn, including follow-ups, read ReadingReader.data, its features,
and each feature's current data and options through the graph. The requested and each feature's current data and options through the graph. The requested
configuration is a historical snapshot, not the live configuration. Preserve configuration is a historical snapshot, not the live configuration. Preserve
changes made since that snapshot. The creation choices are authoring instructions, not a permanent universal settings schema. Generate meaningful code-owned settings; built types are fixed unless code changes. Declare Processor.settings only for controls that make sense (color/font/when), and supply Processor.description and preset for premades. Associate registered features with this authoring job via addFeature.authoring, using the request data authoringJob reference. Honor type/reveal suggestions where possible. Generate a concise descriptive title when the requested title is empty. changes made since that snapshot. The creation choices are authoring instructions, not a permanent universal settings schema. Generate meaningful code-owned settings; built types are fixed unless code changes. Declare only meaningful Processor.config fields or nested groups, and supply Processor.description and renders for premades. Associate registered features with this authoring job via addFeature.authoring, using the request data authoringJob reference. Honor type/reveal suggestions where possible. Generate a concise descriptive title when the requested title is empty.
Reuse an installed processor type when it already implements the requested behavior; Reuse an installed processor type when it already implements the requested behavior;
create a new type only when code must change. Removing an annotator detaches that create a new type only when code must change. Removing an annotator detaches that
instance; archiveType only hides its processor from the creation library and never instance; archiveType only hides its processor from the creation library and never
removes other instances or cached results. Labels share style:"overhead" with removes other instances or cached results. Labels share style:"overhead" with
options.placement:"above" or "below"; honor the requested position. A rendering a declared labelControls group and a render channel options binding; honor the requested position. A rendering
hook can independently declare placement when one source needs both positions. hook can independently declare placement when one source needs both positions.
Do not use hover for behavior that reflows text: labels, replacements, bold, Do not use hover for behavior that reflows text: labels, replacements, bold,
italic, font changes, sizes, or spacing. Use permanent or click activation for italic, font changes, sizes, or spacing. Use permanent or click activation for
@@ -114,7 +136,7 @@ cerebras-light (Cerebras GPT OSS 120B) first for ordinary inference when its con
Reusable local/section work should prepare near the reading position. Expensive Reusable local/section work should prepare near the reading position. Expensive
contextual per-word work should cache, wait for intent, and never fan out one contextual per-word work should cache, wait for intent, and never fan out one
request per word across a document. Code-owned custom config is required where request per word across a document. Code-owned custom config is required where
the behavior needs it; generic color/font preferences are optional suggestions. Keep the existing the behavior needs it; shared appearance controls are opt-in code components. Keep the existing
app and source working on failure. Ask a focused question only if the feature cannot app and source working on failure. Ask a focused question only if the feature cannot
be implemented sensibly without the answer.`; be implemented sensibly without the answer.`;
const submission = (text: string, data: string | null, createdAt: string, overrides: Record<string, unknown> = {}) => ({ const submission = (text: string, data: string | null, createdAt: string, overrides: Record<string, unknown> = {}) => ({
@@ -133,28 +155,8 @@ const submission = (text: string, data: string | null, createdAt: string, overri
causalDepth: 0, causalDepth: 0,
...overrides, ...overrides,
}); });
function validateFeature(data: { function validateFeature(data: { name: string }) {
name: string; if (!data.name.trim() || data.name.length > 100) throw new QxReject("INVALID_READING_FEATURE");
style: string;
reveal: string;
generation: string;
scope: string;
color: string;
}) {
if (
!data.name.trim() ||
data.name.length > 100 ||
!["overhead", "highlight", "underline", "color", "sidebar", "tooltip", "replace", "bold", "italic"].includes(
data.style,
) ||
!["always", "hover", "click"].includes(data.reveal) ||
(data.style === "tooltip" && data.reveal === "always") ||
(["overhead", "replace", "bold", "italic"].includes(data.style) && data.reveal === "hover") ||
!["local", "section", "focus"].includes(data.generation) ||
!["word", "sentence", "section"].includes(data.scope) ||
!/^#[0-9a-f]{6}$/i.test(data.color)
)
throw new QxReject("INVALID_READING_FEATURE");
} }
const emptyJob = (key: string, kind: string, createdAt: string) => ({ const emptyJob = (key: string, kind: string, createdAt: string) => ({
key, key,
@@ -195,7 +197,7 @@ function computation(processorName: string, scope: string, generation: string, o
} }
async function annotationKey( async function annotationKey(
document: { sourceRevision: string; text: string }, document: { sourceRevision: string; text: string },
feature: { processor: string; scope: string; generation: string }, feature: { processor: string },
options: AnnotatorConfig, options: AnnotatorConfig,
start: number, start: number,
end: number, end: number,
@@ -209,7 +211,7 @@ async function annotationKey(
const context = index.context(range, policy); const context = index.context(range, policy);
return `${await processorRevision(feature.processor)}:${await hashIdentity("reading:annotation", [ return `${await processorRevision(feature.processor)}:${await hashIdentity("reading:annotation", [
document.sourceRevision, document.sourceRevision,
computation(feature.processor, feature.scope, feature.generation, options), computation(feature.processor, processor.scope, processor.strategy, options),
String(start), String(start),
String(end), String(end),
JSON.stringify(index.tokens(range)), JSON.stringify(index.tokens(range)),
@@ -241,14 +243,6 @@ const attachType = (field: "receiver" | "handler") => {
.methods.find((method) => method.portName === "delivery" && method.name === "attach.call")!.input as any; .methods.find((method) => method.portName === "delivery" && method.name === "attach.call")!.input as any;
return field === "receiver" ? input.fields.receiver.expectation : input.fields.input.fields.handler.expectation; return field === "receiver" ? input.fields.receiver.expectation : input.fields.input.fields.handler.expectation;
}; };
function checkedOptions(processor: string, raw: string) {
if (!processors[processor]) throw new QxReject("INVALID_PROCESSOR");
try {
return normalizeConfig(processors[processor], raw);
} catch {
throw new QxReject("INVALID_ANNOTATOR_OPTIONS");
}
}
const readerDefaults = { const readerDefaults = {
name: "Chinese reading", name: "Chinese reading",
size: 23, size: 23,
@@ -333,7 +327,7 @@ export const registry = createRegistry({
authoring: null, authoring: null,
data: { ...feature, enabled: true, configRevision: 1 }, data: { ...feature, enabled: true, configRevision: 1 },
library, library,
options: "{}", options: JSON.stringify(normalizeConfig(processors[feature.processor], "{}")),
}, },
{ key: "feature:" + index }, { key: "feature:" + index },
); );
@@ -439,14 +433,7 @@ export const registry = createRegistry({
await ports.profile.set(input); await ports.profile.set(input);
return null; return null;
}, },
configureOptions: async ({ input, ports }) => { configureOptions,
const current = await ports.data.get();
if (current.configRevision !== input.expectedRevision) throw new QxReject("READING_FEATURE_CHANGED");
const options = checkedOptions(current.processor, input.options);
await ports.options.set(JSON.stringify(options));
await ports.data.set({ ...current, configRevision: current.configRevision + 1 });
return null;
},
processorRevision: async ({ ports }) => processorRevision((await ports.data.get()).processor), processorRevision: async ({ ports }) => processorRevision((await ports.data.get()).processor),
speechStatus: async ({ ports }) => { speechStatus: async ({ ports }) => {
const value = await ports.settings.get(); const value = await ports.settings.get();
@@ -473,9 +460,7 @@ export const registry = createRegistry({
config: JSON.stringify({ config: JSON.stringify({
profile: value.profile, profile: value.profile,
fields: fieldsFor(value), fields: fieldsFor(value),
settings: value.settings ?? {},
description: value.description ?? "", description: value.description ?? "",
preset: value.preset,
renders: value.renders, renders: value.renders,
cache: { cache: {
identity: value.cache?.identity ?? key, identity: value.cache?.identity ?? key,
@@ -602,7 +587,7 @@ export const registry = createRegistry({
authoring: null, authoring: null,
data: { ...feature, configRevision: 1, enabled: true }, data: { ...feature, configRevision: 1, enabled: true },
library: iface(receiver, "library"), library: iface(receiver, "library"),
options: "{}", options: JSON.stringify(normalizeConfig(processors[feature.processor], "{}")),
}, },
{ key: `feature:${index}` }, { key: `feature:${index}` },
); );
@@ -633,12 +618,7 @@ export const registry = createRegistry({
addFeature: async ({ receiver, input, ports }) => { addFeature: async ({ receiver, input, ports }) => {
validateFeature(input); validateFeature(input);
const processor = processors[input.processor]; const processor = processors[input.processor];
if ( if (!processor) throw new QxReject("INVALID_PROCESSOR_STRATEGY");
!processor ||
(processor.strategy === "focus" && input.generation !== "focus") ||
(processor.strategy === "local" && input.generation !== "local")
)
throw new QxReject("INVALID_PROCESSOR_STRATEGY");
const { options: raw, reader: requestedReader, authoring, ...data } = input; const { options: raw, reader: requestedReader, authoring, ...data } = input;
const reader = requestedReader const reader = requestedReader
? iface(requestedReader, "reader") ? iface(requestedReader, "reader")
@@ -670,12 +650,7 @@ export const registry = createRegistry({
if (current.configRevision !== input.expectedRevision) throw new QxReject("READING_FEATURE_CHANGED"); if (current.configRevision !== input.expectedRevision) throw new QxReject("READING_FEATURE_CHANGED");
validateFeature(input); validateFeature(input);
const processor = processors[current.processor]; const processor = processors[current.processor];
if ( if (!processor) throw new QxReject("INVALID_PROCESSOR_STRATEGY");
!processor ||
(processor.strategy === "focus" && input.generation !== "focus") ||
(processor.strategy === "local" && input.generation !== "local")
)
throw new QxReject("INVALID_PROCESSOR_STRATEGY");
const { expectedRevision, ...changes } = input; const { expectedRevision, ...changes } = input;
await ports.data.set({ ...current, ...changes, configRevision: current.configRevision + 1 }); await ports.data.set({ ...current, ...changes, configRevision: current.configRevision + 1 });
return null; return null;
@@ -752,7 +727,7 @@ export const registry = createRegistry({
); );
const index = textIndex(document.text), const index = textIndex(document.text),
range = { start: input.start, end: input.end }; range = { start: input.start, end: input.end };
const maximum = feature.generation === "focus" ? policy.focusChars : policy.chunkChars; const maximum = processor.strategy === "focus" ? policy.focusChars : policy.chunkChars;
if ( if (
input.end <= input.start || input.end <= input.start ||
input.end > document.text.length || input.end > document.text.length ||
@@ -781,7 +756,7 @@ export const registry = createRegistry({
processors[processor.cache?.identity ?? feature.processor]?.cache?.previousRevisions?.length) processors[processor.cache?.identity ?? feature.processor]?.cache?.previousRevisions?.length)
) { ) {
const revision = await processorRevision(feature.processor); const revision = await processorRevision(feature.processor);
const config = computation(feature.processor, feature.scope, feature.generation, options); const config = computation(feature.processor, processor.scope, processor.strategy, options);
const history = await ports.jobs["jobs.resolve"]({ receiver: iface(receiver, "document"), input: null }); const history = await ports.jobs["jobs.resolve"]({ receiver: iface(receiver, "document"), input: null });
for (let offset = 0; offset < history.length; offset += 32) { for (let offset = 0; offset < history.length; offset += 32) {
const batch = history.slice(offset, offset + 32); const batch = history.slice(offset, offset + 32);
@@ -808,12 +783,12 @@ export const registry = createRegistry({
} }
} }
} }
const semanticOptions = Object.fromEntries(computationFields(processor).map((key) => [key, options[key]])); const semanticOptions = semanticConfig(processor, options);
const local = processor.local?.(document.text.slice(input.start, input.end), input.start, semanticOptions); const local = processor.local?.(document.text.slice(input.start, input.end), input.start, semanticOptions);
const job = await ports.job.create( const job = await ports.job.create(
{ {
reader: null, reader: null,
configuration: computation(feature.processor, feature.scope, feature.generation, options), configuration: computation(feature.processor, processor.scope, processor.strategy, options),
data: { data: {
...emptyJob(key, "annotation", input.createdAt), ...emptyJob(key, "annotation", input.createdAt),
featureRevision: feature.configRevision, featureRevision: feature.configRevision,
@@ -848,7 +823,7 @@ export const registry = createRegistry({
const tokens = index.tokens(range), const tokens = index.tokens(range),
contextRange = index.context(range, policy); contextRange = index.context(range, policy);
const task = JSON.stringify({ const task = JSON.stringify({
scope: feature.scope, scope: processor.scope,
start: input.start, start: input.start,
end: input.end, end: input.end,
text: excerpt, text: excerpt,
@@ -863,7 +838,7 @@ export const registry = createRegistry({
{ {
receiver: agentRef(agent), receiver: agentRef(agent),
input: submission( input: submission(
`${processors[processor.cache?.identity ?? feature.processor].instructions}\n${processor.prompt?.(Object.fromEntries(computationFields(processor).map((key) => [key, options[key]]))) ?? ""}\nApply this task to task_data. Return the configured JSON shape. Use original UTF-16 offsets.${processor.verifySource !== false ? " Copy exact original source text into each item.source." : ""}`, `${processors[processor.cache?.identity ?? feature.processor].instructions}\n${processor.prompt?.(semanticConfig(processor, options)) ?? ""}\nApply this task to task_data. Return the configured JSON shape. Use original UTF-16 offsets.${processor.verifySource !== false ? " Copy exact original source text into each item.source." : ""}`,
task, task,
input.createdAt, input.createdAt,
{ publication: key, profile, response: response(annotationSchemaFor(processor)) }, { publication: key, profile, response: response(annotationSchemaFor(processor)) },
@@ -1098,6 +1073,7 @@ export const registry = createRegistry({
if (data.kind === "annotation") { if (data.kind === "annotation") {
if (!document || !feature || !processors[feature.processor]) currentAnnotation = false; if (!document || !feature || !processors[feature.processor]) currentAnnotation = false;
else { else {
const processor = processors[feature.processor];
const options = normalizeConfig( const options = normalizeConfig(
processors[feature.processor], processors[feature.processor],
await ports.feature_api["options.get"]({ receiver: featureRef!, input: null }), await ports.feature_api["options.get"]({ receiver: featureRef!, input: null }),
@@ -1109,7 +1085,7 @@ export const registry = createRegistry({
acceptedProcessorRevision(data.key, feature.processor, revision) && acceptedProcessorRevision(data.key, feature.processor, revision) &&
sameCacheConfiguration( sameCacheConfiguration(
await ports.configuration.get(), await ports.configuration.get(),
computation(feature.processor, feature.scope, feature.generation, options), computation(feature.processor, processor.scope, processor.strategy, options),
)); ));
} }
} }
+60 -42
View File
@@ -23,46 +23,64 @@ assert.deepEqual(processors.punctuation.local!("你好。", 4), [
]); ]);
console.log("PASS structured annotation validation, scope limits and processor strategies"); console.log("PASS structured annotation validation, scope limits and processor strategies");
import { normalizeConfig, fieldsFor } from "../src/config.js"; import { normalizeConfig, fieldsFor, underlineControls, labelControls, semanticConfig } from "../src/config.js";
const defaultOptions = normalizeConfig(processors.grammar, "{}"); const defaultOptions = normalizeConfig(processors.grammar, "{}");
assert.equal(defaultOptions.nounColor, "#6a8aac"); assert.equal(defaultOptions.nounColor, "#6a8aac");
assert.equal(fieldsFor(processors.grammar).length, 6); assert.equal(fieldsFor(processors.grammar).length, 6);
const customized = normalizeConfig( assert(!Object.hasOwn(defaultOptions, "underlinePattern"));
processors.grammar, assert(!Object.hasOwn(defaultOptions, "fontSize"));
JSON.stringify({ nounColor: "#112233", fontFamily: "monospace", fontSize: 24 }),
);
assert.equal(customized.fontSize, 24);
assert.equal(defaultOptions.underlinePattern, "solid");
assert.equal(defaultOptions.placement, "above");
assert.equal(normalizeConfig(processors.pinyin, '{"placement":"below"}').placement, "below");
for (const placement of ["under", "", null, 1])
assert.throws(() => normalizeConfig(processors.pinyin, JSON.stringify({ placement })), /Label position/);
for (const underlinePattern of ["solid", "dashed", "squiggly"]) {
assert.equal(
normalizeConfig(processors.grammar, JSON.stringify({ underlinePattern })).underlinePattern,
underlinePattern,
);
}
for (const underlinePattern of ["wavy", "", null, 1]) {
assert.throws(() => normalizeConfig(processors.grammar, JSON.stringify({ underlinePattern })), /Underline pattern/);
}
for (const options of [ for (const options of [
"[]", '{"underlinePattern":"dashed"}',
"null", '{"placement":"below"}',
'{"unknown":true}', '{"fontSize":24}',
'{"fontSize":0}', '{"fontFamily":"monospace"}',
'{"fontSize":null}', ])
'{"fontSize":80}', assert.throws(() => normalizeConfig(processors.grammar, options), /Unknown annotation setting/);
'{"fontSize":"24"}', const grouped = {
'{"nounColor":"red"}', ...processors.punctuation,
'{"minimumLevel":"not-a-level"}', config: [
'{"fontFamily":"bad\\nfont"}', underlineControls("primary", "Primary", { pattern: "dashed" }),
]) { underlineControls("secondary", "Secondary"),
assert.throws(() => labelControls("label", "Labels"),
normalizeConfig(options.includes("minimumLevel") ? processors.pinyin : processors.grammar, options), {
); key: "meaning",
} label: "Meaning",
kind: "group" as const,
fields: [{ key: "language", label: "Language", kind: "text" as const, default: "English" }],
},
],
};
const options = normalizeConfig(
grouped,
JSON.stringify({
primary: { underlinePattern: "squiggly", color: "#112233" },
secondary: { underlinePattern: "solid" },
label: { placement: "below", fontSize: 24 },
meaning: { language: "French" },
}),
);
assert.deepEqual(options.primary, { color: "#112233", underlinePattern: "squiggly" });
assert.deepEqual(options.secondary, { color: "#66866b", underlinePattern: "solid" });
assert.deepEqual(semanticConfig(grouped, options), { meaning: { language: "French" } });
assert.throws(() => normalizeConfig(grouped, '{"primary":{"underlinePattern":"wavy"}}'), /primary.Pattern/);
assert.throws(() => normalizeConfig(grouped, '{"primary":{"color":"red"}}'), /primary.Color/);
assert.throws(() => normalizeConfig(grouped, '{"primary":"dashed"}'), /Expected annotation settings/);
assert.throws(() => normalizeConfig(grouped, '{"primary":{"unknown":true}}'), /primary.unknown/);
assert.throws(() => normalizeConfig(grouped, '{"label":{"fontSize":0}}'), /label.Font size/);
assert.throws(() => normalizeConfig(grouped, '{"label":{"placement":"under"}}'), /label.Position/);
assert.throws(
() =>
normalizeConfig({ ...grouped, config: [underlineControls("same", "One"), underlineControls("same", "Two")] }, "{}"),
/Duplicate/,
);
const defaultLabel = normalizeConfig(processors.pinyin, '{"label":{"placement":"below"}}').label;
assert.equal(typeof defaultLabel === "object" && defaultLabel.placement, "below");
assert.equal(processors.pinyin.prompt, undefined); assert.equal(processors.pinyin.prompt, undefined);
assert.deepEqual(fieldsFor(processors.definition), [], "Fixed sidebar annotators expose no invented controls");
assert.deepEqual(normalizeConfig(processors.definitionTooltip, "{}"), { reveal: "hover" });
assert.deepEqual(normalizeConfig(processors.definitionTooltip, '{"reveal":"click"}'), { reveal: "click" });
assert.deepEqual(semanticConfig(processors.definitionTooltip, { reveal: "click" }), {});
assert.throws(() => normalizeConfig(processors.definitionTooltip, '{"reveal":"always"}'), /Show definition/);
console.log("PASS generated annotator config, validation, prompt options and dynamic typography/color"); console.log("PASS generated annotator config, validation, prompt options and dynamic typography/color");
import { TextIndex, processingPolicy, textIndex } from "../src/text-processing.js"; import { TextIndex, processingPolicy, textIndex } from "../src/text-processing.js";
@@ -183,14 +201,14 @@ assert.equal(
); );
assert.deepEqual(computationFields(processors.names), [], "Per-category colors never reach inference"); assert.deepEqual(computationFields(processors.names), [], "Per-category colors never reach inference");
assert.equal( assert.equal(
cached(processors.grammar, normalizeConfig(processors.grammar, '{"underlinePattern":"solid"}')), cached(grouped, options),
cached(processors.grammar, normalizeConfig(processors.grammar, '{"underlinePattern":"squiggly"}')), cached(grouped, normalizeConfig(grouped, '{"meaning":{"language":"French"}}')),
"Changing underline geometry reuses the same computed annotation results", "Independent nested appearance groups preserve semantic caches",
); );
assert.equal( assert.notEqual(
cached(processors.pinyin, normalizeConfig(processors.pinyin, '{"underlinePattern":"dashed"}')), cached(grouped, options),
cached(processors.pinyin, normalizeConfig(processors.pinyin, "{}")), cached(grouped, normalizeConfig(grouped, "{}")),
"Common underline appearance never reaches semantic prompt configuration", "Nested semantic fields isolate caches",
); );
const custom = { ...processors.pinyin, cache: { inputs: ["targetLanguage"] } }; const custom = { ...processors.pinyin, cache: { inputs: ["targetLanguage"] } };
assert.notEqual( assert.notEqual(