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
156 lines
6.3 KiB
TypeScript
156 lines
6.3 KiB
TypeScript
/**
|
||
* 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 (~3–5 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;
|
||
}
|