/** * Pure, schema-driven grouping logic for the draft-review workspace. * * A content writer typically emits N candidate drafts per approved topic ("3 * candidates, pick 1"), but draft records may carry no stored parent-topic id. * Cluster by an explicit `topic_ref` when present (exact), else by the shared * primary source URL, else the shared "Candidate N — " name suffix. Pure * + framework-free so it's unit-tested in isolation. */ import type { EntityRecord } from './types'; import { readData } from './data'; import { runContentChecks, type ContentCheck } from '../validation'; export interface DraftSource { url: string; outlet?: string; date?: string; } function str(v: unknown): string { return v == null ? '' : String(v); } /** * Sources arrive in two shapes: a pipe-delimited string ("Outlet (2024-08-04) * https://url | note without a url") or a JSON array of { url, outlet, date }. * Normalize both to a list of sources that actually carry a URL. */ export function parseSources(raw: unknown): DraftSource[] { if (Array.isArray(raw)) { return raw .map((s): DraftSource | null => { if (typeof s === 'string') return s ? { url: s } : null; if (s && typeof s === 'object') { const o = s as Record; const url = str(o.url); return url ? { url, outlet: str(o.outlet) || undefined, date: str(o.date) || undefined } : null; } return null; }) .filter((s): s is DraftSource => s !== null); } if (typeof raw === 'string') { return raw .split('|') .map((part): DraftSource | null => { const m = part.match(/https?:\/\/\S+/); if (!m) return null; const outlet = part.slice(0, part.indexOf('(') === -1 ? part.indexOf('http') : part.indexOf('(')).trim(); const dm = part.match(/\((\d{4}-\d{2}-\d{2})\)/); return { url: m[0].replace(/[.,)]+$/, ''), outlet: outlet || undefined, date: dm ? dm[1] : undefined }; }) .filter((s): s is DraftSource => s !== null); } return []; } /** host + path (no scheme/www/trailing slash), lowercased — a stable dedupe key. */ export function normUrl(u: string): string { try { const x = new URL(/^https?:\/\//.test(u) ? u : `https://${u}`); return (x.hostname.replace(/^www\./, '') + x.pathname.replace(/\/+$/, '')).toLowerCase(); } catch { return u.trim().toLowerCase(); } } /** * The clustering key for a draft. Prefer an explicit topic_ref (stamped by the * writer) — exact. Older drafts have none, so fall back to the normalized primary * source URL, then the shared "Candidate N — " suffix, then id. */ export function groupKeyOf(r: EntityRecord): string { const topicRef = str(readData(r.data, 'topic_ref')); if (topicRef) return `ref:${topicRef}`; const sources = parseSources(readData(r.data, 'sources')); if (sources.length > 0) return `src:${normUrl(sources[0].url)}`; const name = str(r.name) || str(readData(r.data, 'story_title')); const suffix = name.replace(/^\s*candidate\s*\d+\s*[—\-:]\s*/i, '').trim(); if (suffix) return `name:${suffix.toLowerCase()}`; return `id:${r.id}`; } /** Candidate title without the "Candidate N — " scaffolding a writer adds. */ export function candidateTitle(r: EntityRecord): string { const raw = str(readData(r.data, 'story_title')) || str(r.name); return raw.replace(/^\s*candidate\s*\d+\s*[—\-:]\s*/i, '').trim() || raw || `#${r.id}`; } export function candidateIndex(r: EntityRecord): number { const n = Number(readData(r.data, 'candidate_index')); return Number.isFinite(n) ? n : 0; } export function isChosen(r: EntityRecord): boolean { const v = readData(r.data, 'chosen'); return v === true || v === 'true' || v === 1; } export interface DraftGroup { key: string; label: string; contentType: string; candidates: EntityRecord[]; chosen: EntityRecord | null; /** A candidate has been approved (status='ready') — the final is ready to post. */ ready: boolean; } /** * Cluster draft records into candidate sets (one per story), each sorted by * candidate_index. Unresolved stories (nothing picked) float to the top — that's * the work to do — and ready-to-post ones sink. */ export function groupDrafts(records: EntityRecord[]): DraftGroup[] { const byKey = new Map(); for (const r of records) { const k = groupKeyOf(r); (byKey.get(k) ?? byKey.set(k, []).get(k)!).push(r); } const groups: DraftGroup[] = []; for (const [key, cands] of byKey) { cands.sort((a, b) => candidateIndex(a) - candidateIndex(b)); const chosen = cands.find(isChosen) ?? null; const head = chosen ?? cands[0]; const ready = cands.some((c) => str(readData(c.data, 'status')) === 'ready'); const topicTitle = str(readData(head.data, 'topic_title')); groups.push({ key, label: topicTitle || candidateTitle(head), contentType: str(readData(head.data, 'content_type')), candidates: cands, chosen, ready, }); } const rank = (g: DraftGroup) => (g.ready ? 2 : g.chosen ? 1 : 0); return groups.sort((a, b) => rank(a) - rank(b) || a.label.localeCompare(b.label)); } /** Word-count targets by content type, from the OGMC skill specs (generic defaults). */ export function wordTargets(contentType: string): { blog: [number, number]; linkedin: [number, number] } { switch (contentType) { case 'lead_magnet': return { blog: [3000, 4500], linkedin: [0, 0] }; case 'weekly_brief': return { blog: [400, 500], linkedin: [150, 250] }; default: return { blog: [350, 600], linkedin: [120, 280] }; } } export type CheckStatus = 'ok' | 'low' | 'high' | 'none'; /** Grade a word count against a [lo, hi] target. hi===0 means "no target". */ export function gradeWords(count: number, [lo, hi]: [number, number]): CheckStatus { if (hi === 0) return 'none'; if (count < lo) return 'low'; if (count > hi) return 'high'; return 'ok'; } /** * The record change a Pick applies to ONE candidate: mark it chosen (true for the * picked candidate, false for the rest). Picking is a selection flag — it must NOT * write a lifecycle `status`. The tenant constrains draft `status` to an enum, so * writing 'selected' (or '') 400s the whole pick (startsim-yvd3); the "picked" * badge already keys off `chosen` via isChosen(). Pure, so it's unit-tested. */ export function pickChanges(candidateId: EntityRecord['id'], pickedId: EntityRecord['id']): { chosen: boolean } { return { chosen: String(candidateId) === String(pickedId) }; } /** * The verbatim-overlap guardrail (768w.18.16.2) for a single draft. Runs the * shared `source-overlap` check over the draft's blog + linkedin against the * cited source article TEXT stamped on the record (`data.source_texts`, populated * by the writer's Stamp Source Texts step). Returns null when the draft carries * no source text — there is no ground truth to compare against, so there is * nothing to surface. Pure, so the wiring is unit-testable without React. */ export function sourceOverlapCheck(r: EntityRecord): ContentCheck | null { const raw = readData(r.data, 'source_texts'); const sourceTexts = Array.isArray(raw) ? raw.map(str).filter(Boolean) : []; if (sourceTexts.length === 0) return null; const blog = str(readData(r.data, 'blog')); const linkedin = str(readData(r.data, 'linkedin')); return runContentChecks({ blog, linkedin }, { sourceTexts }).find((c) => c.id === 'source-overlap') ?? null; }