feat(linkedin-studio): N6 — forslags-lag i trends (angle/targetLevel/rationale/relatedIds + selected + --ids + F7/F9-felt actionability/verdict/readerQuestion/painPoint/saturation) [skip-docs]
Artikkelforslaget blir en persistert entitet (steg 2, A1-4) og godkjenningen får
et hjem (steg 3, A1-7) — før dette genererte agenten vinkel/begrunnelse som ble kastet.
Åtte additivt-valgfrie felt på TrendRecord/TrendInput/TrendItem:
- Forslag (A1-4/A1-5): angle · targetLevel (fritekst, brukerdefinert spenn — aldri enum) ·
rationale · relatedIds[] (flerkilde).
- F7 leser-side: actionability {formulated, note?} (N7-bånd-cap-gaten leser `formulated`) ·
verdict BÆRENDE/STØTTE/NYHET (lukket mekanisme-vokab).
- F9 leser-side: readerQuestion · painPoint · saturation (N7.5-sveipet fyller dem).
Ingen schema-bump: feltene er additivt-valgfrie, ingen record trenger migrering →
SCHEMA_VERSION forblir 4 (loadStore Math.max håndterer det). First-sight-persistering
(re-capture unionerer topics + re-scorer kun; klobrer aldri en triagert vinkel).
Validering: typede felt (verdict/actionability) feiler hardt ved malformert input;
fritekst/id-liste normaliseres lempelig (summary/topics-idiomet).
Livssyklus: TrendStatus += "selected" → new→selected→acted|skipped. Ny select-verb +
--ids-batch (act/skip/reset/select); partiell suksess = exit 0 + miss-rapport, all-miss = exit 2.
setStatusMany(): ren batch-mutasjon, per-id found/notFound.
Brief: forslagsfeltene rendres per kandidat (detaljert i topp-treff, kompakt token i bullets) +
ny «🚧 I produksjon»-seksjon (selected=valgt + acted=skrevet), pillar-uavhengig, deterministisk
sortert. selected/acted forlater arbeidskøen men vises i produksjons-boardet.
commands/trends.md Step 5 oppgradert: triage → Velg (select) / Skip, batchet per verb (--ids).
Trends-suite 245→266 (ny floor, +21 N6-tester). To eksisterende tester oppdatert for den
endrede brief-kontrakten (acted vises nå i I produksjon; ranking-descriptoren ekskluderer selected).
tsc rent. Roundtrip bevist: capture m/ alle felt → query → brief rendrer feltene → select → I produksjon.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7SQpXJpBSNvpWaTNq1kWZ
This commit is contained in:
parent
bb0725af66
commit
65b60337fe
9 changed files with 732 additions and 33 deletions
|
|
@ -67,6 +67,12 @@ export interface BriefRanking {
|
|||
singleMatches: BriefEntry[];
|
||||
/** overlap >= 1 AND NOT fresh. */
|
||||
olderMatched: BriefEntry[];
|
||||
/**
|
||||
* The "I produksjon" board (N6, A1-7): records the operator has pulled out of the work queue —
|
||||
* status `selected` (valgt, in progress) or `acted` (skrevet, done). Pillar-independent (already
|
||||
* chosen), so they bypass the overlap filter entirely; sorted selected-before-acted, then title/url.
|
||||
*/
|
||||
inProduction: TrendRecord[];
|
||||
}
|
||||
|
||||
export interface RankOptions {
|
||||
|
|
@ -132,9 +138,16 @@ export function rankForBrief(
|
|||
const wantedLower = pillars.map((p) => p.toLowerCase());
|
||||
|
||||
const entries: BriefEntry[] = [];
|
||||
const inProduction: TrendRecord[] = [];
|
||||
for (const trend of store.trends) {
|
||||
// RE-R3b (A3): acted/skipped are handled — drop from the work queue (the brief is a queue, not an archive).
|
||||
if (effectiveStatus(trend) !== "new") continue;
|
||||
const st = effectiveStatus(trend);
|
||||
// N6 (A1-7): selected/acted are "in production" — collected for their own board, out of the queue.
|
||||
if (st === "selected" || st === "acted") {
|
||||
inProduction.push(trend);
|
||||
continue;
|
||||
}
|
||||
// RE-R3b (A3): skipped is handled — drop from the work queue (the brief is a queue, not an archive).
|
||||
if (st !== "new") continue;
|
||||
const have = new Set(trend.topics.map((t) => t.toLowerCase()));
|
||||
const matchedPillars: string[] = [];
|
||||
for (let i = 0; i < pillars.length; i++) {
|
||||
|
|
@ -185,6 +198,12 @@ export function rankForBrief(
|
|||
const singleMatches = entries.filter((e) => e.overlap === 1 && isFresh(e)).sort(cmp);
|
||||
const olderMatched = entries.filter((e) => !isFresh(e)).sort(cmp); // overlap>=1 (0 already excluded)
|
||||
|
||||
// A total order for the production board: selected (in progress) before acted (done), then title, then url.
|
||||
const prodRank = (t: TrendRecord): number => (effectiveStatus(t) === "selected" ? 0 : 1);
|
||||
inProduction.sort(
|
||||
(a, b) => prodRank(a) - prodRank(b) || a.title.localeCompare(b.title) || a.url.localeCompare(b.url),
|
||||
);
|
||||
|
||||
return {
|
||||
today,
|
||||
freshDays,
|
||||
|
|
@ -192,6 +211,7 @@ export function rankForBrief(
|
|||
topMatches,
|
||||
singleMatches,
|
||||
olderMatched,
|
||||
inProduction,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -241,19 +261,72 @@ function temporalToken(e: BriefEntry): string {
|
|||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* The N6 proposal fields as detailed `- ` lines (top entries only) — each emitted ONLY when present,
|
||||
* so an unproposed trend renders exactly as before (backward-compat). Order is fixed for determinism.
|
||||
* The verdict + reader-grip share one line (the N7 band-cap gate's two inputs, shown together).
|
||||
*/
|
||||
function proposalLines(t: TrendRecord): string[] {
|
||||
const out: string[] = [];
|
||||
if (t.angle) out.push(`- 💡 Vinkel: ${t.angle}`);
|
||||
if (t.targetLevel) out.push(`- 🎚️ Målnivå: ${t.targetLevel}`);
|
||||
if (t.rationale) out.push(`- 🧭 Hvorfor nå: ${t.rationale}`);
|
||||
if (t.verdict || t.actionability) {
|
||||
const grip = t.actionability
|
||||
? `${t.actionability.formulated ? "ja" : "nei"}${t.actionability.note ? ` («${t.actionability.note}»)` : ""}`
|
||||
: "—";
|
||||
out.push(`- 🎯 Dom: ${t.verdict ?? "—"} · leser-grep: ${grip}`);
|
||||
}
|
||||
if (t.readerQuestion) out.push(`- ❓ Leserspørsmål: ${t.readerQuestion}`);
|
||||
if (t.painPoint) out.push(`- 🩹 Smertepunkt: ${t.painPoint}`);
|
||||
if (t.saturation) out.push(`- 🌡️ Metning: ${t.saturation}`);
|
||||
if (t.relatedIds && t.relatedIds.length > 0) out.push(`- ↔️ Relatert: ${t.relatedIds.join(", ")}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The compact proposal token for single-line bullets: verdict + a reader-grip flag, when present. */
|
||||
function proposalToken(t: TrendRecord): string {
|
||||
const bits: string[] = [];
|
||||
if (t.verdict) bits.push(`🎯 ${t.verdict}`);
|
||||
if (t.actionability) bits.push(`grep: ${t.actionability.formulated ? "ja" : "nei"}`);
|
||||
return bits.length > 0 ? ` · ${bits.join(" · ")}` : "";
|
||||
}
|
||||
|
||||
function renderTopEntry(e: BriefEntry, n: number): string[] {
|
||||
const lines = [
|
||||
`### ${n}. ${e.trend.title}`,
|
||||
`- Kilde: ${e.trend.source} · Publisert: ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · Pillarer: ${e.matchedPillars.join(", ")} · \`${e.trend.id}\``,
|
||||
];
|
||||
if (e.trend.summary) lines.push(`- ${e.trend.summary}`);
|
||||
lines.push(...proposalLines(e.trend));
|
||||
lines.push(`- 🔗 ${e.trend.url}`);
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
function renderBulletEntry(e: BriefEntry): string {
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
|
||||
return `- **${e.trend.title}** — «${e.matchedPillars.join(", ")}» · ${e.effectiveDate} (${e.ageDays}d)${scoreToken(e)}${temporalToken(e)}${proposalToken(e.trend)} · 🔗 ${e.trend.url} · \`${e.trend.id}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "I produksjon" board (N6, A1-7): the selected/acted records rankForBrief set aside, each a
|
||||
* compact line tagged [valgt]/[skrevet]. Pure. The section header is always emitted (stable structure);
|
||||
* an empty board renders the explicit "_Ingen i produksjon._" marker.
|
||||
*/
|
||||
function renderInProduction(records: TrendRecord[]): string[] {
|
||||
const lines = ["## 🚧 I produksjon (valgt + skrevet)"];
|
||||
if (records.length === 0) {
|
||||
lines.push("_Ingen i produksjon._", "");
|
||||
return lines;
|
||||
}
|
||||
for (const t of records) {
|
||||
const tag = t.status === "acted" ? "skrevet" : "valgt";
|
||||
const angle = t.angle ? ` · 💡 ${t.angle}` : "";
|
||||
const verdict = t.verdict ? ` · 🎯 ${t.verdict}` : "";
|
||||
lines.push(`- [${tag}] **${t.title}**${angle}${verdict} · \`${t.id}\``);
|
||||
}
|
||||
lines.push("");
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -272,7 +345,7 @@ export function renderBrief(
|
|||
lines.push(`date: ${ranking.today}`);
|
||||
lines.push(`summary: ${briefSummary(ranking, diff)}`);
|
||||
lines.push(`store: { trends: ${totals.trends}, matched: ${totals.matched}, fresh: ${totals.fresh} }`);
|
||||
lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped`);
|
||||
lines.push(`ranking: composite desc, then pillar-overlap desc, then temporal (first-mover↑/saturated↓), then publishedAt desc (capturedAt fallback); freshDays ${ranking.freshDays}; excludes acted/skipped/selected (selected+acted shown in I produksjon)`);
|
||||
// RE-R3e: the set of ids this brief showed — the record the NEXT day's diff reads. Always
|
||||
// emitted (even blank for an empty store); independent of --no-mark (a property of the render).
|
||||
lines.push(`surfaced: ${surfacedIds(ranking).join(",")}`);
|
||||
|
|
@ -323,6 +396,9 @@ export function renderBrief(
|
|||
ranking.olderMatched.slice(0, 5).forEach((e) => lines.push(renderBulletEntry(e)));
|
||||
lines.push("");
|
||||
|
||||
// N6 (A1-7): the production board — where a triaged candidate lives once it leaves the queue.
|
||||
lines.push(...renderInProduction(ranking.inProduction));
|
||||
|
||||
lines.push("---");
|
||||
lines.push("_Neste steg: /linkedin:react <url> · /linkedin:post · /linkedin:newsletter_");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue