Skip to content

Commit d6ecd29

Browse files
authored
Merge pull request #2247 from atomantic/claim/issue-2179
feat: voice-drift check can baseline on style-guide voice exemplars (#2179 slice 3)
2 parents 01e535c + 4512f9e commit d6ecd29

8 files changed

Lines changed: 565 additions & 40 deletions

File tree

.changelog/NEXT.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
- **[issue-2179] A series style guide can now carry concrete voice exemplar and anti-exemplar passages — "the tuning fork" — that get injected into every draft and revision prompt.** Adjective lists ("witty, atmospheric") anchor prose voice far more weakly than an actual passage does, so the series style guide gains two new fields: `voiceExemplars` (1–3 short passages that nail the target voice, each with an optional one-line note on what it demonstrates) and `voiceAntiExemplars` (passages in the wrong register kept as negative examples, noted with what's wrong — "too ornate", "wrong emotional temperature"). Add and edit them inline on the series style-guide editor. On generation they render into the house-style block as a "MATCH this voice — echo their rhythm, diction, and register" block and a "NEVER drift toward this" block — conditional, so a guide with no passages injects nothing. They ride the existing `{{series.styleNotes}}` template variable (via `composeStyleNotes`), so prose drafting, revision, arc planning, and episode-seed passes all honor them with no new prompt-template variable and no migration. The sanitizer drops empty passages, trims each to ~2000 chars, and caps the list at 3 so the fixed per-call prompt overhead stays tight. (First slice of #2179 — the AI-assisted voice-discovery flow, Writers Room parity, and the voice-fingerprint baseline hook ship separately.)
3535
- **[issue-2178] Canon can now be reveal-gated so a mystery or twist doesn't leak into the prose before it's due.** Canon context — including character secrets and late-story facts — was injected into every issue's drafting prompt, so the writer model could (and would) spill a reveal issues early. Each canon entry (character, place, or object) now carries optional reveal gating: a `revealIssue` number hides the fact from any issue before it; a `spoiler` flag hard-hides it from all drafting context; and an optional `surfaceDescriptor` is the spoiler-free stand-in shown before the reveal ("the locked east wing" instead of "the wing where the heir is imprisoned"). The drafting/rewrite prompts (`buildStageContext`, the alternate-POV rewrite cast) filter canon to each issue's reveal horizon, while the judge and editorial checks still receive the full canon so they can verify hint placement. Absent gate fields mean "always visible," so every existing canon entry and every federation peer round-trips unchanged. A reveal-timing editor with a spoiler badge lands on every canon-entry card (universe + series). A new `continuity.premature-reveal` editorial check (gated on the series having authored any reveal-gated canon) scans drafted prose for a gated fact stated or unambiguously implied before its reveal issue, distinguishing a genuine leak from deliberate foreshadowing — and its reveal issue feeds the Chekhov setups-payoffs check as an authored payoff so an orphaned reveal is flagged too.
3636
- **[issue-2177] Prose drafts now flow across issue boundaries instead of each issue opening and closing in a vacuum.** When drafting an issue's prose, the generator now injects the previous issue's actual closing prose (its last ~2000 characters) and the next issue's opening beats into the draft prompt — with guidance to open so it flows from the previous ending (without repeating its closing image or re-explaining what the reader just read) and to land the ending so it hands off to the next issue's beats. This is what keeps the narrative voice carrying across units and prevents the "every issue opens/closes the same way" tic, improving the autonomous first draft so the editorial loop has less to fix. Both blocks are token-budgeted (they degrade by trimming on a small-context local model rather than erroring), and when the previous issue hasn't been drafted yet — non-linear or parallel generation, or the first issue of a volume — the block simply doesn't render, so there's never any fabricated continuity. Every full prose (re)generation path gets it: the manual draft button, the auto-runner, and Series Autopilot. Existing installs auto-upgrade their prose prompt (a customized prompt is left untouched and flagged).
37+
- **[issue-2179] The voice-drift check can now measure each issue against the voice you CHOSE — its style-guide exemplars — instead of the average of what got drafted.** The `style.voice-drift` check flags an issue whose prose fingerprint sits more than a threshold's σ from a baseline; that baseline used to be the drafted-issue mean, which has a blind spot — if every issue drifted the same way, the average drifts with them and nothing flags. A new **Drift baseline** setting on the check adds two modes on top of the default `drafted`: `exemplars` re-centers every metric on the combined fingerprint of the style guide's voice exemplars (the "tuning fork" passages from the first #2179 slice), and `blended` uses the midpoint of the two. In exemplar/blended mode the spread (σ) is still the drafted corpus's — only the center shifts — so an issue is judged against the chosen voice while still tolerating the series' natural per-metric variance. When the style guide has too little exemplar prose (under ~40 words combined) the run silently falls back to `drafted` and says so. Findings read "…vs the style guide's chosen voice of 5 words" instead of implying drift from the drafted mean, and the deep-linkable **Voice Fingerprint** matrix view renders the chosen-voice baseline as its own footer row with the outlier cells measured against it. The check now also lists the style guide as a source, so editing an exemplar re-stales the finding — exactly like its LLM siblings. (Third slice of #2179 — the voice-fingerprint baseline hook; the AI-assisted voice-discovery flow and Writers Room parity ship separately.) (`server/lib/editorial/voiceFingerprint.js`, `server/lib/editorial/checks/proseStyle.js`, `server/services/pipeline/voiceFingerprint.js`, `client/src/pages/PipelineVoiceFingerprint.jsx`)
3738

3839
## Human Activity Tracking
3940

client/src/pages/PipelineVoiceFingerprint.jsx

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,19 @@ export default function PipelineVoiceFingerprint() {
6767
const seriesStats = data?.series || {};
6868
const outlierSet = new Set((data?.outliers || []).map((o) => outlierKey(o.issue, o.metricKey)));
6969
const hasMatrix = issues.length > 0;
70+
const baselineMode = data?.baselineMode || 'drafted';
71+
// Only claim the chosen-voice baseline once it was actually applied: a gatedOff
72+
// run (< minIssues) still reports the configured mode but computes no comparison
73+
// and returns `series: {}`, so the footer row + intro copy must fall back to the
74+
// plain description rather than promising a baseline that never ran.
75+
const usesChosenVoice = data?.exemplarBaselineUsed === true && !data?.gatedOff;
76+
// The baseline phrase for the intro copy — blended is the midpoint, NOT the
77+
// exemplars alone, so it must not be described as "the chosen voice."
78+
const baselineNoun = !usesChosenVoice
79+
? 'series mean'
80+
: (baselineMode === 'blended'
81+
? "blend of the series mean and the style guide's chosen voice"
82+
: "style guide's chosen voice (its voice exemplars)");
7083

7184
return (
7285
<div className="h-full overflow-y-auto p-4 md:p-6">
@@ -94,9 +107,11 @@ export default function PipelineVoiceFingerprint() {
94107
<p className="text-xs text-gray-500 mb-4 max-w-3xl">
95108
Every drafted issue's prose fingerprint. Each column is a metric; a cell
96109
highlighted in <span className="text-port-warning">amber</span> is a
97-
statistical outlier — more than {data?.threshold ?? 1.5}σ from the series
98-
mean on that metric. The bottom rows are the series mean and standard
99-
deviation (σ). This measures the same vectors the deterministic{' '}
110+
statistical outlier — more than {data?.threshold ?? 1.5}σ from the{' '}
111+
{baselineNoun}{' '}
112+
on that metric. The bottom rows are the series mean and standard
113+
deviation (σ){usesChosenVoice ? `, plus the ${baselineMode === 'blended' ? 'blended' : 'chosen-voice'} baseline the outliers are measured against` : ''}.
114+
This measures the same vectors the deterministic{' '}
100115
<code className="text-gray-400">style.voice-drift</code> editorial check
101116
flags; it just shows the whole matrix, not only the flagged outliers.
102117
</p>
@@ -121,6 +136,8 @@ export default function PipelineVoiceFingerprint() {
121136
issues={issues}
122137
seriesStats={seriesStats}
123138
outlierSet={outlierSet}
139+
baselineMode={baselineMode}
140+
usesChosenVoice={usesChosenVoice}
124141
/>
125142
<p className="text-xs text-gray-500 mt-3">
126143
{issues.length} issue{issues.length === 1 ? '' : 's'} · {columns.length} metrics
@@ -155,8 +172,16 @@ function EmptyState({ gatedOff, issueCount, minIssues }) {
155172
);
156173
}
157174

158-
function FingerprintMatrix({ columns, issues, seriesStats, outlierSet }) {
175+
function FingerprintMatrix({ columns, issues, seriesStats, outlierSet, baselineMode, usesChosenVoice }) {
159176
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
177+
// The baseline an outlier tooltip / footer row names: the chosen voice (or a
178+
// blend) when the exemplar baseline is active (#2179), else the series mean.
179+
const centerNoun = usesChosenVoice
180+
? (baselineMode === 'blended' ? 'blend of series + chosen voice' : 'chosen voice')
181+
: 'series mean';
182+
const centerOf = (col) => (usesChosenVoice
183+
? (seriesStats?.[col.key]?.center ?? seriesStats?.[col.key]?.mean)
184+
: seriesStats?.[col.key]?.mean);
160185
// Below minIssues the server gates drift off and returns `series: {}`, so a
161186
// mean/σ footer would render misleading 0s — only show it once stats exist.
162187
const hasStats = seriesStats && Object.keys(seriesStats).length > 0;
@@ -196,7 +221,7 @@ function FingerprintMatrix({ columns, issues, seriesStats, outlierSet }) {
196221
? 'bg-port-warning/20 text-port-warning font-semibold'
197222
: 'text-gray-300'
198223
}`}
199-
title={isOutlier ? `Outlier — series mean ${round2(seriesStats?.[col.key]?.mean)}${col.unit || ''}` : undefined}
224+
title={isOutlier ? `Outlier — ${centerNoun} ${round2(centerOf(col))}${col.unit || ''}` : undefined}
200225
>
201226
{round2(v)}{col.unit || ''}
202227
</td>
@@ -223,6 +248,21 @@ function FingerprintMatrix({ columns, issues, seriesStats, outlierSet }) {
223248
</td>
224249
))}
225250
</tr>
251+
{usesChosenVoice ? (
252+
<tr className="bg-port-card">
253+
<td
254+
className="sticky left-0 z-10 bg-port-card text-port-accent px-3 py-1.5"
255+
title={`Outliers are measured against this ${centerNoun}, not the drafted mean.`}
256+
>
257+
{baselineMode === 'blended' ? 'blend' : 'voice'}
258+
</td>
259+
{columns.map((col) => (
260+
<td key={col.key} className="px-2 py-1.5 text-right text-port-accent tabular-nums">
261+
{round2(centerOf(col))}{col.unit || ''}
262+
</td>
263+
))}
264+
</tr>
265+
) : null}
226266
</tfoot>
227267
) : null}
228268
</table>

client/src/pages/PipelineVoiceFingerprint.test.jsx

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,63 @@ describe('PipelineVoiceFingerprint', () => {
8787
expect(screen.queryByText('σ')).not.toBeInTheDocument();
8888
});
8989

90+
it('surfaces the chosen-voice baseline row + copy when exemplars mode is active (#2179)', async () => {
91+
getVoiceFingerprint.mockResolvedValue({
92+
...MATRIX_PAYLOAD,
93+
baselineMode: 'exemplars',
94+
exemplarBaselineUsed: true,
95+
series: {
96+
sentenceLenMean: { mean: 10.5, std: 3, center: 5 },
97+
dialogueRatio: { mean: 15, std: 8, center: 2 },
98+
},
99+
});
100+
renderAt();
101+
await waitFor(() => expect(screen.getByText('Voice Fingerprint')).toBeInTheDocument());
102+
// The intro copy names the chosen-voice baseline instead of the series mean.
103+
expect(screen.getByText(/chosen voice/)).toBeInTheDocument();
104+
// A dedicated "voice" baseline footer row renders (label + the centered value).
105+
expect(screen.getByText('voice')).toBeInTheDocument();
106+
expect(screen.getByText('5 words')).toBeInTheDocument();
107+
});
108+
109+
it('labels blended mode as a blend, not the chosen voice alone (#2179)', async () => {
110+
getVoiceFingerprint.mockResolvedValue({
111+
...MATRIX_PAYLOAD,
112+
baselineMode: 'blended',
113+
exemplarBaselineUsed: true,
114+
series: {
115+
sentenceLenMean: { mean: 10.5, std: 3, center: 7.75 },
116+
dialogueRatio: { mean: 15, std: 8, center: 8.5 },
117+
},
118+
});
119+
renderAt();
120+
await waitFor(() => expect(screen.getByText('Voice Fingerprint')).toBeInTheDocument());
121+
// The intro copy calls it a blend, and the footer row is labeled "blend".
122+
expect(screen.getByText(/blend of the series mean/)).toBeInTheDocument();
123+
expect(screen.getByText('blend')).toBeInTheDocument();
124+
expect(screen.queryByText('voice')).not.toBeInTheDocument();
125+
});
126+
127+
it('does not claim the chosen-voice baseline on a gated-off run (#2179)', async () => {
128+
// A gated-off run (< minIssues) still reports the configured mode, but the
129+
// baseline was never applied and series is empty — the UI must fall back.
130+
getVoiceFingerprint.mockResolvedValue({
131+
...MATRIX_PAYLOAD,
132+
baselineMode: 'exemplars',
133+
exemplarBaselineUsed: true,
134+
gatedOff: true,
135+
issueCount: 2,
136+
outliers: [],
137+
series: {},
138+
matrix: { metricKeys: ['sentenceLenMean'], issues: [{ issue: 1, words: 5, sentences: 1, metrics: { sentenceLenMean: 5 } }, { issue: 2, words: 4, sentences: 1, metrics: { sentenceLenMean: 4 } }] },
139+
});
140+
renderAt();
141+
await waitFor(() => expect(screen.getByText(/Drift detection is off/)).toBeInTheDocument());
142+
// No chosen-voice copy or footer row when the baseline never ran.
143+
expect(screen.queryByText(/chosen voice/)).not.toBeInTheDocument();
144+
expect(screen.queryByText('voice')).not.toBeInTheDocument();
145+
});
146+
90147
it('renders an empty state when nothing is drafted', async () => {
91148
getVoiceFingerprint.mockResolvedValue({
92149
...MATRIX_PAYLOAD,

server/lib/editorial/checks/proseStyle.js

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ import {
3838
styleGuideExpectations,
3939
z,
4040
} from '../checkInfra.js';
41-
import { computeVoiceDrift, describeDrift, parseVoiceWells } from '../voiceFingerprint.js';
41+
import {
42+
computeVoiceDrift, describeDrift, parseVoiceWells, VOICE_BASELINE_MODES,
43+
} from '../voiceFingerprint.js';
4244

4345
export const proseStyleChecks = [
4446
{
@@ -1005,10 +1007,13 @@ export const proseStyleChecks = [
10051007
},
10061008
{
10071009
id: 'style.voice-drift',
1008-
sources: ['manuscript'],
1010+
// Reads the style guide's voice exemplars too (#2179) so an exemplar edit
1011+
// re-stales the finding, exactly as its LLM siblings do — the baseline can be
1012+
// the CHOSEN voice, not just the drafted-issue mean.
1013+
sources: ['manuscript', 'series.styleGuide'],
10091014
label: 'Statistical voice drift (deterministic)',
10101015
description:
1011-
"Deterministic sibling of style.voice-consistency — where that LLM check judges tone subjectively, this MEASURES each issue's prose fingerprint (sentence rhythm, fragment/long-sentence rates, paragraph shape, dialogue ratio, em-dash rate, abstract-noun/simile density, dominant sentence-opener, plus any configured vocabulary wells), computes the series mean/σ per metric, and flags an issue that sits more than a threshold's σ from the series voice — naming the metric, the issue value vs the series mean, and the direction (\"issue 7 sentence-length CV 0.18 vs series 0.41 — prose has gone metronomic\"). It VERIFIES that the asserted voice is statistically true per issue. Gates off below 4 issues drafted — with a tiny series the largest possible σ-distance (√(N−1)) can't reach the default 1.5σ threshold. No LLM cost.",
1016+
"Deterministic sibling of style.voice-consistency — where that LLM check judges tone subjectively, this MEASURES each issue's prose fingerprint (sentence rhythm, fragment/long-sentence rates, paragraph shape, dialogue ratio, em-dash rate, abstract-noun/simile density, dominant sentence-opener, plus any configured vocabulary wells), computes the series mean/σ per metric, and flags an issue that sits more than a threshold's σ from the series voice — naming the metric, the issue value vs the baseline, and the direction (\"issue 7 sentence-length CV 0.18 vs series 0.41 — prose has gone metronomic\"). It VERIFIES that the asserted voice is statistically true per issue. With the \"Drift baseline\" set to exemplars/blended it measures against the style guide's voice-exemplar profile (the CHOSEN voice) instead of the mean of what got drafted — so it flags drift from the voice you picked, not from the average of a corpus that may all have drifted together. Gates off below 4 issues drafted — with a tiny series the largest possible σ-distance (√(N−1)) can't reach the default 1.5σ threshold. No LLM cost.",
10121017
scope: 'series',
10131018
kind: 'deterministic',
10141019
category: 'style',
@@ -1032,6 +1037,19 @@ export const proseStyleChecks = [
10321037
// Optional vocabulary "wells" — register categories to track coverage of,
10331038
// as `name: word, word; name2: word` (series-configurable per-check).
10341039
vocabularyWells: z.string().max(2000).default(''),
1040+
// Which baseline each issue's drift is measured against (#2179):
1041+
// drafted — the mean of the drafted issues (default; original behavior).
1042+
// exemplars — the style guide's voice-exemplar profile (the CHOSEN voice),
1043+
// so an issue is flagged for drifting from the voice the author
1044+
// picked, not from the average of what got drafted.
1045+
// blended — the midpoint of the two.
1046+
// Preprocessed: any unrecognized value coerces to 'drafted' so a hand-typed
1047+
// config can't fail the whole check's safeParse (and an exemplars/blended run
1048+
// with no usable exemplars falls back to drafted at compute time anyway).
1049+
baselineMode: z.preprocess(
1050+
(v) => (VOICE_BASELINE_MODES.includes(v) ? v : 'drafted'),
1051+
z.enum(VOICE_BASELINE_MODES),
1052+
).default('drafted'),
10351053
}),
10361054
configFields: [
10371055
{
@@ -1067,6 +1085,12 @@ export const proseStyleChecks = [
10671085
type: 'text',
10681086
help: 'Register categories to track per issue, as "name: word, word; name2: word". Each becomes a tracked metric (coverage per 1k words) so a series can flag an issue that drops its trade/body/musical register.',
10691087
},
1088+
{
1089+
key: 'baselineMode',
1090+
label: 'Drift baseline',
1091+
type: 'text',
1092+
help: 'What each issue is measured against: "drafted" (the mean of the drafted issues — the default), "exemplars" (the style guide\'s voice-exemplar profile, so drift is judged against the voice you CHOSE, not the average of what got drafted), or "blended" (the midpoint). Exemplars/blended fall back to drafted when the style guide has too little exemplar prose.',
1093+
},
10701094
],
10711095
gate: (ctx) => (ctx.manuscript || '').trim().length > 0,
10721096
run: (ctx) => {
@@ -1076,6 +1100,11 @@ export const proseStyleChecks = [
10761100
threshold: cfg.sigmaThreshold ?? 1.5,
10771101
minIssues: cfg.minIssues ?? 4,
10781102
wells,
1103+
// The chosen-voice baseline (#2179): render the drift against the style
1104+
// guide's voice exemplars when configured. A thin/absent exemplar set makes
1105+
// computeVoiceDrift fall back to the drafted mean and report it.
1106+
baselineMode: cfg.baselineMode || 'drafted',
1107+
voiceExemplars: ctx.series?.styleGuide?.voiceExemplars,
10791108
});
10801109
if (drift.gatedOff) return [];
10811110
const cap = cfg.maxFindings ?? 12;

0 commit comments

Comments
 (0)