linkedin-studio/scripts/brain/src/assemble.ts
Kjell Tore Guttormsen edd3e15ef7 feat(linkedin-studio): SB-S3c — cross-silo id-threading + post→analytics assembler [skip-docs]
Hub-side design: the published record now carries the specifics/trends ids
it was built from (additive, omit-empty → byte-backward-compatible), and a
new pure assembler (scripts/brain/src/assemble.ts + `brain assemble`) joins
post↔analytics by normalized title-prefix + date with honest confidence
tiers (high/low/none). Answers the arc's north-star query: which raw
material actually performs? (specific → post → measured analytics).

All four tributaries untouched (analytics READ-only via inlined raw-JSON,
no package import); profile.md grammar untouched (the fact→post link stays
OUT — C-1). The repeatable --specific/--trend ingest flags collect via a
new collectRepeated helper, leaving parseFlags untouched.

TDD: 19 new brain tests (ingest 4 + publish 3 + assemble 8 + cli 4), all
SC1–SC12. brain 113/113, gate 95/0/0, BRAIN_TESTS_FLOOR 94→113,
ASSERT_BASELINE_FLOOR unchanged at 80. Light-Voyage hardened
(brief-review 5 FIX · plan-critic 1 BLOCK+4 MAJOR+4 MINOR · scope-guardian ALIGNED).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RigJBiRFNtFZKCz21qNbQ4
2026-06-23 20:47:34 +02:00

156 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* SB-S3c — the cross-silo assembler (the payoff).
*
* Answers the arc's north-star query (`architecture.md:17`): *which raw material
* actually performs?* — `specific → post → measured analytics`. The post record
* (`ingest/published/<contentId>.md`) already carries the `specifics`/`trends` ids
* it was built from (SB-S3c hub-side threading); this module joins each post to
* its measured analytics row and surfaces the whole graph.
*
* PURE core: `assemblePostGraph({records, analytics})` takes already-loaded inputs
* and returns the graph — no FS/clock/network. The analytics↔post join is an honest
* HEURISTIC, never a guaranteed key: analytics carries no body and no URN (only a
* title-prefix + date), so `matchRow` joins by normalized title-prefix + date with
* explicit confidence tiers (`high`/`low`/`none`) — a real-CSV `none` is a
* normalization-tightening signal, not a proof of no match.
*
* DECOUPLED: this module treats tributary ids as opaque 12-hex strings and takes a
* minimal `AnalyticsRowInput` shape — it never imports the analytics/trends/
* specifics packages. The thin read-only `loadAnalyticsRows` IO inlines a raw-JSON
* read of the shared data-root (NOT the analytics package's `loadAllPosts`).
*/
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { dataRoot } from "./dataRoot.js";
import type { PublishedRecord } from "./ingest.js";
/**
* The minimal analytics-row shape the resolver needs, extracted from the raw
* `AnalyticsBatch.posts[]` JSON (`analytics/src/models/types.ts`). Note the field
* is `publishedDate` (analytics) vs `published_date` (the brain record).
*/
export interface AnalyticsRowInput {
title: string;
publishedDate: string; // YYYY-MM-DD
metrics?: { engagementRate?: number } & Record<string, unknown>;
}
export type MatchConfidence = "high" | "low" | "none";
/** A post's matched analytics: the WHOLE row reference (FIX 4), or none. */
export interface PostMatch {
confidence: MatchConfidence;
row?: AnalyticsRowInput;
}
export interface PostGraphNode {
contentId: string;
published_date: string;
specifics: string[];
trends: string[];
match: PostMatch;
}
/**
* Minimum normalized-title length to attempt a prefix match. The hook quality-rule
* floor is 110 chars; 24 normalized chars (~35 words) is the shortest opener
* specific enough that a prefix-match is not coincidental, while staying well under
* any real hook. Below floor → `none` (an operator can still eyeball).
*/
const PREFIX_FLOOR = 24;
/** Brain-local copy of the specifics-bank `normalizeContent` idiom (NOT imported). */
export function normalize(s: string): string {
return s.trim().toLowerCase().replace(/\s+/g, " ");
}
/** Strip a trailing LinkedIn truncation marker (`…`/`...`) so a `…`-suffixed export title still prefix-matches. */
function stripTrailingEllipsis(s: string): string {
return s.replace(/(?:…|\.{3})\s*$/, "").trimEnd();
}
/**
* Match one analytics row to one published record. Returns the tiered match, or
* `null` when the row does not qualify (no prefix / below floor) — STUB until S3c
* Step 3.
*/
export function matchRow(record: PublishedRecord, row: AnalyticsRowInput): PostMatch | null {
const nt = stripTrailingEllipsis(normalize(row.title));
if (nt.length < PREFIX_FLOOR) return null; // too short to discriminate → none
if (!normalize(record.body).startsWith(nt)) return null; // no prefix → none
const confidence: MatchConfidence = record.published_date === row.publishedDate ? "high" : "low";
return { confidence, row };
}
/**
* Assemble the post → raw-material → performance graph. Pure (no FS/clock/network).
* For each record, the BEST qualifying analytics row: `high` (same date) beats `low`
* (different date); within a tier, the longest matched title wins. The analytics rows
* are sorted once (publishedDate desc, title asc) so an exact-length tie is stable —
* never `readdirSync`-order-dependent.
*/
export function assemblePostGraph(args: {
records: PublishedRecord[];
analytics: AnalyticsRowInput[];
}): PostGraphNode[] {
const analytics = [...args.analytics].sort(
(a, b) => b.publishedDate.localeCompare(a.publishedDate) || a.title.localeCompare(b.title),
);
return args.records.map((record) => {
let best: PostMatch | null = null;
let bestLen = -1;
for (const row of analytics) {
const m = matchRow(record, row);
if (!m) continue;
const len = stripTrailingEllipsis(normalize(row.title)).length;
const better =
best === null ||
(m.confidence === "high" && best.confidence === "low") ||
(m.confidence === best.confidence && len > bestLen);
if (better) {
best = m;
bestLen = len;
}
}
return {
contentId: record.id,
published_date: record.published_date,
specifics: record.specifics,
trends: record.trends,
match: best ?? { confidence: "none" },
};
});
}
/**
* Read-only loader: inline a raw-JSON read of the analytics batches under the shared
* data-root and extract the minimal row shape. STUB until S3c Step 3.
*
* NOTE (root-skew caveat): resolves via the brain `dataRoot` (`${LINKEDIN_STUDIO_DATA}/
* analytics/posts`); the analytics package additionally honours the deprecated
* `ANALYTICS_ROOT` override, which this path does NOT — if set to a non-default
* path, the join degrades to every-post-`none` (accepted cost of the no-import
* decoupling; the M0 default leaves `ANALYTICS_ROOT` unset).
*/
export function loadAnalyticsRows(): AnalyticsRowInput[] {
const dir = dataRoot(join("analytics", "posts"));
if (!existsSync(dir)) return []; // fresh-clone / no imports yet → no rows
const rows: AnalyticsRowInput[] = [];
for (const name of readdirSync(dir)) {
if (!name.endsWith(".json") || name.startsWith(".")) continue;
try {
const batch = JSON.parse(readFileSync(join(dir, name), "utf8")) as { posts?: unknown[] };
for (const p of batch?.posts ?? []) {
const row = p as Partial<AnalyticsRowInput>;
if (typeof row?.title === "string" && typeof row?.publishedDate === "string") {
rows.push({ title: row.title, publishedDate: row.publishedDate, metrics: row.metrics });
}
}
} catch {
// skip a malformed/unreadable batch file — never crash (mirrors listPublished)
}
}
return rows;
}