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
This commit is contained in:
parent
016d823f3b
commit
edd3e15ef7
12 changed files with 515 additions and 14 deletions
|
|
@ -25,6 +25,7 @@ import {
|
|||
type Candidate,
|
||||
type ProfileDiff,
|
||||
} from "./consolidate.js";
|
||||
import { assemblePostGraph, loadAnalyticsRows } from "./assemble.js";
|
||||
import { dataRoot } from "./dataRoot.js";
|
||||
import { ingestText, listPublished, parsePublishedRecord, scanInbox } from "./ingest.js";
|
||||
import { parseProfile, serializeProfile } from "./profile.js";
|
||||
|
|
@ -59,6 +60,25 @@ function parseFlags(args: string[]): Record<string, string> {
|
|||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* SB-S3c: collect EVERY value of a repeatable `--key <value>` flag into an array
|
||||
* (the single-value `parseFlags` keeps only the last). Scans the raw args directly,
|
||||
* leaving `parseFlags` untouched — so single-value flag behaviour is unchanged.
|
||||
*/
|
||||
function collectRepeated(args: string[], key: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === `--${key}`) {
|
||||
const next = args[i + 1];
|
||||
if (next !== undefined && !next.startsWith("--")) {
|
||||
out.push(next);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
|
@ -68,9 +88,10 @@ function usage(msg: string): never {
|
|||
console.error(
|
||||
"usage:\n" +
|
||||
" init\n" +
|
||||
" ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>]\n" +
|
||||
" ingest --file <path> [--source <s>] [--date <YYYY-MM-DD>] [--specific <id>]… [--trend <id>]…\n" +
|
||||
" ingest --scan-inbox [--source <s>]\n" +
|
||||
" published list [--json]",
|
||||
" published list [--json]\n" +
|
||||
" assemble",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
|
|
@ -89,7 +110,7 @@ function runInit(): void {
|
|||
if (created.length === 0) console.log("Already initialised — nothing to do.");
|
||||
}
|
||||
|
||||
function runIngest(flags: Record<string, string>): void {
|
||||
function runIngest(rest: string[], flags: Record<string, string>): void {
|
||||
if (flags["scan-inbox"] === "true") {
|
||||
const res = scanInbox({ captured_at: today(), source: flags.source });
|
||||
console.log(
|
||||
|
|
@ -100,11 +121,14 @@ function runIngest(flags: Record<string, string>): void {
|
|||
const file = flags.file;
|
||||
if (!file || file === "true") usage("ingest needs --file <path> or --scan-inbox");
|
||||
const body = readFileSync(file, "utf8");
|
||||
// SB-S3c: --specific / --trend are repeatable — read ONLY via collectRepeated.
|
||||
const res = ingestText({
|
||||
body,
|
||||
captured_at: today(),
|
||||
source: flags.source,
|
||||
published_date: flags.date === "true" ? undefined : flags.date,
|
||||
specifics: collectRepeated(rest, "specific"),
|
||||
trends: collectRepeated(rest, "trend"),
|
||||
});
|
||||
if (res.written && res.collision) {
|
||||
console.log(`Collision (different body, same id) → wrote ${res.path}`);
|
||||
|
|
@ -129,6 +153,53 @@ function runPublished(rest: string[], flags: Record<string, string>): void {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SB-S3c: read-only cross-silo assembler view — post → raw-material → performance.
|
||||
* Loads published records + analytics rows (inlined raw-JSON, no analytics import),
|
||||
* prints the join newest-first. Writes NOTHING.
|
||||
*/
|
||||
function runAssemble(_flags: Record<string, string>): void {
|
||||
const pubDir = dataRoot(join("ingest", "published"));
|
||||
const records = existsSync(pubDir)
|
||||
? readdirSync(pubDir)
|
||||
.filter((f) => f.endsWith(".md") && !f.startsWith("."))
|
||||
.map((f) => {
|
||||
try {
|
||||
return parsePublishedRecord(readFileSync(join(pubDir, f), "utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((r): r is NonNullable<typeof r> => r !== null)
|
||||
: [];
|
||||
const analytics = loadAnalyticsRows();
|
||||
const bodyById = new Map(records.map((r) => [r.id, r.body]));
|
||||
const graph = assemblePostGraph({ records, analytics }).sort((a, b) =>
|
||||
b.published_date.localeCompare(a.published_date),
|
||||
);
|
||||
if (graph.length === 0) {
|
||||
console.log(
|
||||
"No published records to assemble. Ingest posts with `brain ingest --file <p> [--specific <id>] [--trend <id>]`.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`Post graph — ${graph.length} record(s); ${analytics.length} analytics row(s):`);
|
||||
for (const node of graph) {
|
||||
const firstLine = (bodyById.get(node.contentId) ?? "").split("\n", 1)[0];
|
||||
console.log(`\n· ${node.contentId} · ${node.published_date} · ${firstLine}`);
|
||||
console.log(` specifics: ${node.specifics.length ? node.specifics.join(", ") : "—"}`);
|
||||
console.log(` trends: ${node.trends.length ? node.trends.join(", ") : "—"}`);
|
||||
if (node.match.confidence === "none") {
|
||||
console.log(" analytics: none (no title-prefix+date match)");
|
||||
} else {
|
||||
const eng = node.match.row?.metrics?.engagementRate;
|
||||
console.log(
|
||||
` analytics: ${node.match.confidence}${eng !== undefined ? ` [eng ${eng}%]` : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderDiffMd(diff: ProfileDiff): string {
|
||||
const lines = ["# Pending profile diff", "", "> Operator-gated. Review, then `brain consolidate --apply --diff brain/pending-diff.json --confirm`.", ""];
|
||||
const section = (title: string, items: string[]) => {
|
||||
|
|
@ -235,9 +306,10 @@ function main(): void {
|
|||
const flags = parseFlags(rest);
|
||||
|
||||
if (command === "init") return runInit();
|
||||
if (command === "ingest") return runIngest(flags);
|
||||
if (command === "ingest") return runIngest(rest, flags);
|
||||
if (command === "published") return runPublished(rest, flags);
|
||||
if (command === "consolidate") return runConsolidate(flags);
|
||||
if (command === "assemble") return runAssemble(flags);
|
||||
|
||||
usage(command ? `unknown command: ${command}` : "no command given");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue