feat(linkedin-studio): N7 — trend->newsletter-bro (sourceTrendId + Step 1-inntak + auto-act) + F7 band-cap-gate [skip-docs]
Del 2 (broen): commands/newsletter.md Step 1 trend-intake pre-fills brief (angle/targetLevel/key-points/source-URLs) from a /linkedin:trends candidate via the trends CLI; sourceTrendId persisted at Step 1.5; Step 10 auto-act flips the source trend to acted (closes the discovery->production loop deterministically); Step 2 external fact-package intake path (kilde-så-draft with finished research). edition-state.template.json: per-article sourceTrendId (additive-optional, no schemaVersion bump). A1-8: grep -ci trend commands/newsletter.md 0 -> 18. Del 2.5 (F7 gate, TDD): score.ts capForActionability — a candidate whose reader-grip is explicitly not formulated (actionability.formulated=false) caps to at most High regardless of composite. Pure; overrides the composite->band derivation downward only (composite/dimensions/mode preserved, score stays etterprøvbar); absent actionability left uncapped (legacy-safe). Wired into the item.ts capture path so the PERSISTED priority is gated; store carries the envelope verbatim (re-capture safe). No KTG values hardcoded — the N6 field carries the verdict, the gate only enforces it. Suites (all green): trends 276/0 (was 266, +10), test-runner 139/0, brain 134/0, hooks 140/0, tests 35/0, render 60/0; tsc --noEmit clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014bE7VbkmR3cqHFEeGfzgwb
This commit is contained in:
parent
bace49f8c2
commit
1adb7f4361
6 changed files with 204 additions and 8 deletions
|
|
@ -18,7 +18,7 @@
|
|||
|
||||
import { normalizeField } from "./store.js";
|
||||
import type { TrendInput } from "./store.js";
|
||||
import { requiredDimensions, scoreEnvelope } from "./score.js";
|
||||
import { requiredDimensions, scoreEnvelope, capForActionability } from "./score.js";
|
||||
import type { ScoreMode, DimensionScores } from "./score.js";
|
||||
import type { TrendVerdict, Actionability } from "./types.js";
|
||||
|
||||
|
|
@ -270,9 +270,11 @@ export function normalizeItem(raw: unknown): NormalizeResult {
|
|||
* already validated by normalizeItem) and does NOT derive an `id` (the store owns id via
|
||||
* addTrend→trendId). `publishedAt`/`summary`/`score` are carried only when present (key
|
||||
* omitted otherwise), mirroring the store's conditional-spread idiom. The `score` is turned
|
||||
* into the persisted envelope here (judgment → composite, via scoreEnvelope). On the capture
|
||||
* path the dims are pre-validated by normalizeItem, so scoreEnvelope→composite cannot throw;
|
||||
* called DIRECTLY with bad dims it throws by contract (defense-in-depth — SC2).
|
||||
* into the persisted envelope here (judgment → composite, via scoreEnvelope), then run through
|
||||
* the F7 band-cap gate (`capForActionability`) so a candidate with no formulated reader-grip is
|
||||
* persisted at most `High`, not `Immediate` (MR-F7, N7). On the capture path the dims are
|
||||
* pre-validated by normalizeItem, so scoreEnvelope→composite cannot throw; called DIRECTLY with
|
||||
* bad dims it throws by contract (defense-in-depth — SC2).
|
||||
*/
|
||||
export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
|
||||
return {
|
||||
|
|
@ -283,7 +285,9 @@ export function itemToInput(item: TrendItem, capturedAt: string): TrendInput {
|
|||
topics: [...item.topics],
|
||||
...(item.publishedAt !== undefined ? { publishedAt: item.publishedAt } : {}),
|
||||
...(item.summary !== undefined ? { summary: item.summary } : {}),
|
||||
...(item.score !== undefined ? { score: scoreEnvelope(item.score.mode, item.score.dimensions) } : {}),
|
||||
...(item.score !== undefined
|
||||
? { score: capForActionability(scoreEnvelope(item.score.mode, item.score.dimensions), item.actionability) }
|
||||
: {}),
|
||||
// N6 proposal fields carried through to the store input (validated already; key omitted when absent).
|
||||
...(item.angle !== undefined ? { angle: item.angle } : {}),
|
||||
...(item.targetLevel !== undefined ? { targetLevel: item.targetLevel } : {}),
|
||||
|
|
|
|||
|
|
@ -128,6 +128,43 @@ export function scoreEnvelope(mode: ScoreMode, dimensions: DimensionScores): Tre
|
|||
return { mode, dimensions, composite: c, priority: band(c).priority };
|
||||
}
|
||||
|
||||
/**
|
||||
* A structural view of the reader-grip signal the F7 gate reads — the `formulated` boolean
|
||||
* of types.ts `Actionability`, redeclared here so score.ts stays free of internal imports
|
||||
* (types.ts imports TrendScore from here; importing back would form a cycle). The real
|
||||
* `Actionability` is assignable to this by structure.
|
||||
*/
|
||||
export interface ActionabilitySignal {
|
||||
formulated: boolean;
|
||||
}
|
||||
|
||||
/** Index of the `High` band in the SSOT ordering (BANDS is priority-descending — lower index outranks). */
|
||||
const HIGH_BAND_INDEX = BANDS.findIndex((b) => b.priority === "High");
|
||||
|
||||
/** True when `p` sits strictly above the `High` band — the only bands the F7 gate can cap down. */
|
||||
function aboveHigh(p: Priority): boolean {
|
||||
const i = BANDS.findIndex((b) => b.priority === p);
|
||||
return i !== -1 && i < HIGH_BAND_INDEX;
|
||||
}
|
||||
|
||||
/**
|
||||
* F7 band-cap gate (MR-F7, N7). A candidate whose reader-grip is explicitly NOT formulated
|
||||
* (`actionability.formulated === false`) cannot occupy the top `Immediate` band regardless of
|
||||
* its composite — its priority is capped to at most `High`. This is the ONE place the
|
||||
* deterministic composite->band derivation is overridden, and only downward: composite,
|
||||
* dimensions, and mode are left untouched (the real score stays etterprøvbar; only the priority
|
||||
* label changes). Absent actionability (`undefined`) is left uncapped — legacy / un-annotated
|
||||
* records keep their derived band (the N6 fields are additive-optional, no migration). Pure; no
|
||||
* KTG values here — what counts as a formulated grip is the operator's reader-side judgment
|
||||
* carried by the N6 field, which this gate merely enforces.
|
||||
*/
|
||||
export function capForActionability(score: TrendScore, actionability?: ActionabilitySignal): TrendScore {
|
||||
if (actionability?.formulated === false && aboveHigh(score.priority)) {
|
||||
return { ...score, priority: "High" };
|
||||
}
|
||||
return score;
|
||||
}
|
||||
|
||||
export interface TriageOptions {
|
||||
mode: ScoreMode;
|
||||
threshold: number;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue