linkedin-studio/scripts/brain/src/ingest.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

296 lines
12 KiB
TypeScript

/**
* Ingest — the published-post gold signal (SB-S1).
*
* Captures the user's ACTUAL published posts into `ingest/published/` tagged
* `provenance=published`, so the voice/profile-learning surface can learn from
* human-published content ONLY (the model-collapse guard — never from ai-draft).
*
* This module has two halves:
* - PURE: `PublishedRecord` + `parsePublishedRecord` / `serializePublishedRecord`
* (a file-per-post constrained-header grammar; NO YAML dep, mirroring the brain
* profile.md line-grammar idiom). `parse ∘ serialize = identity` (SC2).
* - IO: `writePublished` / `ingestText` / `scanInbox` / `listPublished` — idempotent,
* collision-safe, create-on-demand under the brain `dataRoot` (SC1/SC3/SC4).
*
* The record id is `mintContentId(VERBATIM body)` (id.ts) — byte-identity, so two
* structurally-different posts never collide and the write path never silently
* drops a differing body.
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { dataRoot } from "./dataRoot.js";
import { mintContentId, normalizeProvenance } from "./id.js";
/** One ingested published post. `provenance` is always `published` in SB-S1. */
export interface PublishedRecord {
/** mintContentId(body) — 12 hex, the dedupe key + filename stem. */
id: string;
/** Always `published` — the type pins it; parse rejects any other value. */
provenance: "published";
/** YYYY-MM-DD — when the post was published (operator-supplied or = captured_at). */
published_date: string;
/** YYYY-MM-DD — when this record was ingested. */
captured_at: string;
/** `manual` (default) | a future connector token. */
source: string;
/**
* SB-S3c — the raw material this post was built from: specifics-bank + trends
* ids (12-hex), threaded hub-side so the post↔specific↔trend↔analytics graph is
* assemblable. Default `[]`; serialized only when non-empty (omit-empty keeps a
* pre-S3c record byte-identical). Validated to 12-hex on parse + at the producer.
*/
specifics: string[];
trends: string[];
/** The verbatim post body — byte-exact, may contain anything (`]`/`|`/newlines/`---`). */
body: string;
}
/** The one separator string, used identically by serialize and parse. */
const SENTINEL = "\n---\n";
/**
* Serialize a record to the file grammar: a fixed 5-line header, the `---`
* sentinel, then the verbatim body. No title, no blank-line padding (the body is
* captured byte-exact, so any padding would break the round-trip identity).
*/
export function serializePublishedRecord(rec: PublishedRecord): string {
const header = [
`id: ${rec.id}`,
`provenance: ${rec.provenance}`,
`published_date: ${rec.published_date}`,
`captured_at: ${rec.captured_at}`,
`source: ${rec.source}`,
];
// SB-S3c: the raw-material id lines are appended AFTER `source:` and emitted ONLY
// when non-empty — so a pre-S3c record (empty arrays) serializes to the unchanged
// 5-line header, byte-identical (SC2). Body-only `mintContentId` is unaffected.
if (rec.specifics.length > 0) header.push(`specifics: ${rec.specifics.join(",")}`);
if (rec.trends.length > 0) header.push(`trends: ${rec.trends.join(",")}`);
return header.join("\n") + SENTINEL + rec.body;
}
const ID_RE = /^[0-9a-f]{12}$/;
/**
* SB-S3c: read an optional comma-separated 12-hex id list from the header slice.
* Absent key → `[]` (a NON-throwing reader — NOT `headerScalar`, which throws on a
* missing key and would break every pre-S3c record). A present-but-malformed id
* throws (never silently dropped).
*/
function headerIdList(header: string, key: string): string[] {
const m = header.match(new RegExp(`^${key}:\\s*(.*?)\\s*$`, "m"));
if (!m) return [];
return m[1]
.split(",")
.map((s) => s.trim())
.filter((s) => s !== "")
.map((id) => {
if (!ID_RE.test(id)) {
throw new Error(`malformed published record: bad ${key} id ${JSON.stringify(id)}`);
}
return id;
});
}
function headerScalar(header: string, key: string): string {
const m = header.match(new RegExp(`^${key}:\\s*(.*?)\\s*$`, "m"));
if (!m) throw new Error(`malformed published record: missing "${key}:" header`);
return m[1];
}
/**
* Parse a record back from the file grammar. Splits on the FIRST `---` sentinel
* (the header lines are constrained `key: value` pairs that never contain one), so
* a body that itself contains `\n---\n` is preserved verbatim. The header scalars
* are read only from the header slice — a header-shaped body line cannot leak in.
* `provenance` is asserted to be `published`: a `published/` record carrying any
* other provenance is a corruption signal and throws (never silently accepted).
*/
export function parsePublishedRecord(text: string): PublishedRecord {
const idx = text.indexOf(SENTINEL);
if (idx === -1) throw new Error("malformed published record: no `---` separator");
const header = text.slice(0, idx);
const body = text.slice(idx + SENTINEL.length);
const id = headerScalar(header, "id");
if (!/^[0-9a-f]{12}$/.test(id)) {
throw new Error(`malformed published record: bad id ${JSON.stringify(id)}`);
}
const provenance = normalizeProvenance(headerScalar(header, "provenance"));
if (provenance !== "published") {
throw new Error(
`corrupt published record: provenance is "${provenance}", expected "published"`,
);
}
return {
id,
provenance,
published_date: headerScalar(header, "published_date"),
captured_at: headerScalar(header, "captured_at"),
source: headerScalar(header, "source"),
specifics: headerIdList(header, "specifics"),
trends: headerIdList(header, "trends"),
body,
};
}
// ── IO ───────────────────────────────────────────────────────────────────────
//
// All IO resolves through the brain package's own `dataRoot` (LINKEDIN_STUDIO_DATA
// overrides the root for tests + installs). Create-on-demand: `ingest/published/`
// is mkdir'd as needed, so ingest does NOT depend on a prior `brain init`.
const PUBLISHED_SUB = join("ingest", "published");
const INBOX_SUB = join("ingest", "inbox");
/** The body stored at `path`, or `null` if it can't be read/parsed (treat as differing). */
function storedBody(path: string): string | null {
try {
return parsePublishedRecord(readFileSync(path, "utf8")).body;
} catch {
return null;
}
}
export interface WriteResult {
/** true iff a file was written (false = identical record already present). */
written: boolean;
/** The path written or matched. */
path: string;
/** true iff an id-collision with a DIFFERING body forced a disambiguated name. */
collision?: boolean;
}
/**
* Write a record idempotently + collision-safely. If `<id>.md` already holds the
* SAME body → no-op (`written:false`). If it holds a DIFFERENT body (a 48-bit hash
* collision or an unreadable file) → write the next free `<id>-N.md` rather than
* clobber or silently skip — so a differing gold record is never lost (B2).
*/
export function writePublished(rec: PublishedRecord): WriteResult {
const dir = dataRoot(PUBLISHED_SUB);
mkdirSync(dir, { recursive: true });
const text = serializePublishedRecord(rec);
const primary = join(dir, `${rec.id}.md`);
if (!existsSync(primary)) {
writeFileSync(primary, text, "utf8");
return { written: true, path: primary };
}
if (storedBody(primary) === rec.body) return { written: false, path: primary };
for (let n = 2; ; n++) {
const cand = join(dir, `${rec.id}-${n}.md`);
if (!existsSync(cand)) {
writeFileSync(cand, text, "utf8");
return { written: true, path: cand, collision: true };
}
if (storedBody(cand) === rec.body) return { written: false, path: cand, collision: true };
}
}
export interface IngestResult extends WriteResult {
record: PublishedRecord;
}
/**
* Mint a record from a verbatim post body and write it. `provenance` is always
* `published`; `published_date` defaults to `captured_at` when not supplied. Pure
* inputs (the caller supplies `captured_at`), idempotent + collision-safe write.
*/
export function ingestText(opts: {
body: string;
captured_at: string;
source?: string;
published_date?: string;
specifics?: string[];
trends?: string[];
}): IngestResult {
// SB-S3c producer guard: a malformed raw-material id fails fast HERE (not only on
// a later re-parse). Empty/absent arrays skip validation, so every pre-S3c caller
// (incl. scanInbox) is unaffected.
const validateIds = (ids: string[] | undefined, kind: string): string[] => {
const list = ids ?? [];
for (const id of list) {
if (!ID_RE.test(id)) throw new Error(`ingest: bad ${kind} id ${JSON.stringify(id)}`);
}
return list;
};
const record: PublishedRecord = {
id: mintContentId(opts.body),
provenance: "published",
published_date: opts.published_date ?? opts.captured_at,
captured_at: opts.captured_at,
source: opts.source ?? "manual",
specifics: validateIds(opts.specifics, "specifics"),
trends: validateIds(opts.trends, "trends"),
body: opts.body,
};
return { record, ...writePublished(record) };
}
export interface ScanResult {
/** ids of records written this scan. */
processed: string[];
/** ids of records already present (skipped as duplicates). */
skipped: string[];
}
/**
* Process the inbox drop-zone into `published/`. Reads TOP-LEVEL `*.md` files only,
* skipping dotfiles (`.DS_Store` etc.) and non-markdown — no recursion. An empty or
* absent inbox is a clean no-op. Non-destructive in SB-S1: processed inbox files are
* left in place (a re-scan re-skips via the published dedup).
*/
export function scanInbox(opts: { captured_at: string; source?: string }): ScanResult {
const dir = dataRoot(INBOX_SUB);
const processed: string[] = [];
const skipped: string[] = [];
if (!existsSync(dir)) return { processed, skipped };
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile() || entry.name.startsWith(".") || !entry.name.endsWith(".md")) continue;
const body = readFileSync(join(dir, entry.name), "utf8");
const res = ingestText({ body, captured_at: opts.captured_at, source: opts.source });
(res.written ? processed : skipped).push(res.record.id);
}
return { processed, skipped };
}
export interface PublishedSummary {
id: string;
provenance: string;
published_date: string;
firstLine: string;
}
/**
* List the published gold corpus. Each file is parsed inside a try/catch — a
* malformed/hand-dropped file is counted as `skipped`, never crashing the command.
* Surfaces `provenance` so the operator can eyeball that nothing `ai-draft` leaked.
*/
export function listPublished(): { records: PublishedSummary[]; skipped: number } {
const dir = dataRoot(PUBLISHED_SUB);
const records: PublishedSummary[] = [];
let skipped = 0;
if (!existsSync(dir)) return { records, skipped };
for (const name of readdirSync(dir)) {
if (name.startsWith(".") || !name.endsWith(".md")) continue;
try {
const rec = parsePublishedRecord(readFileSync(join(dir, name), "utf8"));
records.push({
id: rec.id,
provenance: rec.provenance,
published_date: rec.published_date,
firstLine: rec.body.split("\n", 1)[0],
});
} catch {
skipped++;
}
}
records.sort((a, b) => a.published_date.localeCompare(b.published_date));
return { records, skipped };
}