Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/NEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
- **[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.)
- **[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.
- **[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).
- **[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`)

## Human Activity Tracking

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

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

function FingerprintMatrix({ columns, issues, seriesStats, outlierSet }) {
function FingerprintMatrix({ columns, issues, seriesStats, outlierSet, baselineMode, usesChosenVoice }) {
const round2 = (n) => Math.round((Number(n) || 0) * 100) / 100;
// The baseline an outlier tooltip / footer row names: the chosen voice (or a
// blend) when the exemplar baseline is active (#2179), else the series mean.
const centerNoun = usesChosenVoice
? (baselineMode === 'blended' ? 'blend of series + chosen voice' : 'chosen voice')
: 'series mean';
const centerOf = (col) => (usesChosenVoice
? (seriesStats?.[col.key]?.center ?? seriesStats?.[col.key]?.mean)
: seriesStats?.[col.key]?.mean);
// Below minIssues the server gates drift off and returns `series: {}`, so a
// mean/σ footer would render misleading 0s — only show it once stats exist.
const hasStats = seriesStats && Object.keys(seriesStats).length > 0;
Expand Down Expand Up @@ -196,7 +221,7 @@ function FingerprintMatrix({ columns, issues, seriesStats, outlierSet }) {
? 'bg-port-warning/20 text-port-warning font-semibold'
: 'text-gray-300'
}`}
title={isOutlier ? `Outlier — series mean ${round2(seriesStats?.[col.key]?.mean)}${col.unit || ''}` : undefined}
title={isOutlier ? `Outlier — ${centerNoun} ${round2(centerOf(col))}${col.unit || ''}` : undefined}
>
{round2(v)}{col.unit || ''}
</td>
Expand All @@ -223,6 +248,21 @@ function FingerprintMatrix({ columns, issues, seriesStats, outlierSet }) {
</td>
))}
</tr>
{usesChosenVoice ? (
<tr className="bg-port-card">
<td
className="sticky left-0 z-10 bg-port-card text-port-accent px-3 py-1.5"
title={`Outliers are measured against this ${centerNoun}, not the drafted mean.`}
>
{baselineMode === 'blended' ? 'blend' : 'voice'}
</td>
{columns.map((col) => (
<td key={col.key} className="px-2 py-1.5 text-right text-port-accent tabular-nums">
{round2(centerOf(col))}{col.unit || ''}
</td>
))}
</tr>
) : null}
</tfoot>
) : null}
</table>
Expand Down
57 changes: 57 additions & 0 deletions client/src/pages/PipelineVoiceFingerprint.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,63 @@ describe('PipelineVoiceFingerprint', () => {
expect(screen.queryByText('σ')).not.toBeInTheDocument();
});

it('surfaces the chosen-voice baseline row + copy when exemplars mode is active (#2179)', async () => {
getVoiceFingerprint.mockResolvedValue({
...MATRIX_PAYLOAD,
baselineMode: 'exemplars',
exemplarBaselineUsed: true,
series: {
sentenceLenMean: { mean: 10.5, std: 3, center: 5 },
dialogueRatio: { mean: 15, std: 8, center: 2 },
},
});
renderAt();
await waitFor(() => expect(screen.getByText('Voice Fingerprint')).toBeInTheDocument());
// The intro copy names the chosen-voice baseline instead of the series mean.
expect(screen.getByText(/chosen voice/)).toBeInTheDocument();
// A dedicated "voice" baseline footer row renders (label + the centered value).
expect(screen.getByText('voice')).toBeInTheDocument();
expect(screen.getByText('5 words')).toBeInTheDocument();
});

it('labels blended mode as a blend, not the chosen voice alone (#2179)', async () => {
getVoiceFingerprint.mockResolvedValue({
...MATRIX_PAYLOAD,
baselineMode: 'blended',
exemplarBaselineUsed: true,
series: {
sentenceLenMean: { mean: 10.5, std: 3, center: 7.75 },
dialogueRatio: { mean: 15, std: 8, center: 8.5 },
},
});
renderAt();
await waitFor(() => expect(screen.getByText('Voice Fingerprint')).toBeInTheDocument());
// The intro copy calls it a blend, and the footer row is labeled "blend".
expect(screen.getByText(/blend of the series mean/)).toBeInTheDocument();
expect(screen.getByText('blend')).toBeInTheDocument();
expect(screen.queryByText('voice')).not.toBeInTheDocument();
});

it('does not claim the chosen-voice baseline on a gated-off run (#2179)', async () => {
// A gated-off run (< minIssues) still reports the configured mode, but the
// baseline was never applied and series is empty — the UI must fall back.
getVoiceFingerprint.mockResolvedValue({
...MATRIX_PAYLOAD,
baselineMode: 'exemplars',
exemplarBaselineUsed: true,
gatedOff: true,
issueCount: 2,
outliers: [],
series: {},
matrix: { metricKeys: ['sentenceLenMean'], issues: [{ issue: 1, words: 5, sentences: 1, metrics: { sentenceLenMean: 5 } }, { issue: 2, words: 4, sentences: 1, metrics: { sentenceLenMean: 4 } }] },
});
renderAt();
await waitFor(() => expect(screen.getByText(/Drift detection is off/)).toBeInTheDocument());
// No chosen-voice copy or footer row when the baseline never ran.
expect(screen.queryByText(/chosen voice/)).not.toBeInTheDocument();
expect(screen.queryByText('voice')).not.toBeInTheDocument();
});

it('renders an empty state when nothing is drafted', async () => {
getVoiceFingerprint.mockResolvedValue({
...MATRIX_PAYLOAD,
Expand Down
35 changes: 32 additions & 3 deletions server/lib/editorial/checks/proseStyle.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ import {
styleGuideExpectations,
z,
} from '../checkInfra.js';
import { computeVoiceDrift, describeDrift, parseVoiceWells } from '../voiceFingerprint.js';
import {
computeVoiceDrift, describeDrift, parseVoiceWells, VOICE_BASELINE_MODES,
} from '../voiceFingerprint.js';

export const proseStyleChecks = [
{
Expand Down Expand Up @@ -1005,10 +1007,13 @@ export const proseStyleChecks = [
},
{
id: 'style.voice-drift',
sources: ['manuscript'],
// Reads the style guide's voice exemplars too (#2179) so an exemplar edit
// re-stales the finding, exactly as its LLM siblings do — the baseline can be
// the CHOSEN voice, not just the drafted-issue mean.
sources: ['manuscript', 'series.styleGuide'],
label: 'Statistical voice drift (deterministic)',
description:
"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.",
"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.",
scope: 'series',
kind: 'deterministic',
category: 'style',
Expand All @@ -1032,6 +1037,19 @@ export const proseStyleChecks = [
// Optional vocabulary "wells" — register categories to track coverage of,
// as `name: word, word; name2: word` (series-configurable per-check).
vocabularyWells: z.string().max(2000).default(''),
// Which baseline each issue's drift is measured against (#2179):
// drafted — the mean of the drafted issues (default; original behavior).
// exemplars — the style guide's voice-exemplar profile (the CHOSEN voice),
// so an issue is flagged for drifting from the voice the author
// picked, not from the average of what got drafted.
// blended — the midpoint of the two.
// Preprocessed: any unrecognized value coerces to 'drafted' so a hand-typed
// config can't fail the whole check's safeParse (and an exemplars/blended run
// with no usable exemplars falls back to drafted at compute time anyway).
baselineMode: z.preprocess(
(v) => (VOICE_BASELINE_MODES.includes(v) ? v : 'drafted'),
z.enum(VOICE_BASELINE_MODES),
).default('drafted'),
}),
configFields: [
{
Expand Down Expand Up @@ -1067,6 +1085,12 @@ export const proseStyleChecks = [
type: 'text',
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.',
},
{
key: 'baselineMode',
label: 'Drift baseline',
type: 'text',
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.',
},
],
gate: (ctx) => (ctx.manuscript || '').trim().length > 0,
run: (ctx) => {
Expand All @@ -1076,6 +1100,11 @@ export const proseStyleChecks = [
threshold: cfg.sigmaThreshold ?? 1.5,
minIssues: cfg.minIssues ?? 4,
wells,
// The chosen-voice baseline (#2179): render the drift against the style
// guide's voice exemplars when configured. A thin/absent exemplar set makes
// computeVoiceDrift fall back to the drafted mean and report it.
baselineMode: cfg.baselineMode || 'drafted',
voiceExemplars: ctx.series?.styleGuide?.voiceExemplars,
});
if (drift.gatedOff) return [];
const cap = cfg.maxFindings ?? 12;
Expand Down
Loading