From e7f106cddb919d6e145b298524cfc52f1bd6e517 Mon Sep 17 00:00:00 2001 From: "gnani.nutakki" Date: Thu, 11 Jun 2026 17:55:16 -0500 Subject: [PATCH 1/3] Make article synthesizer package consumable --- .gitignore | 1 - README.md | 24 +- dist/assemble.d.ts | 94 ++++++ dist/assemble.js | 293 ++++++++++++++++++ dist/cli.d.ts | 19 ++ dist/cli.js | 215 +++++++++++++ dist/contracts.d.ts | 8 + dist/contracts.js | 8 + dist/copyright.d.ts | 62 ++++ dist/copyright.js | 184 +++++++++++ dist/describe.d.ts | 67 ++++ dist/describe.js | 55 ++++ dist/index.d.ts | 59 ++++ dist/index.js | 36 +++ dist/privacy.d.ts | 24 ++ dist/privacy.js | 66 ++++ dist/provenance.d.ts | 67 ++++ dist/provenance.js | 198 ++++++++++++ dist/provider.d.ts | 151 +++++++++ dist/provider.js | 634 ++++++++++++++++++++++++++++++++++++++ dist/render.d.ts | 56 ++++ dist/render.js | 138 +++++++++ dist/style.d.ts | 71 +++++ dist/style.js | 119 +++++++ dist/synthesize.d.ts | 55 ++++ dist/synthesize.js | 398 ++++++++++++++++++++++++ package-lock.json | 5 +- package.json | 9 +- scripts/package-smoke.mjs | 48 +++ tsconfig.build.json | 4 + 30 files changed, 3150 insertions(+), 18 deletions(-) create mode 100644 dist/assemble.d.ts create mode 100644 dist/assemble.js create mode 100644 dist/cli.d.ts create mode 100644 dist/cli.js create mode 100644 dist/contracts.d.ts create mode 100644 dist/contracts.js create mode 100644 dist/copyright.d.ts create mode 100644 dist/copyright.js create mode 100644 dist/describe.d.ts create mode 100644 dist/describe.js create mode 100644 dist/index.d.ts create mode 100644 dist/index.js create mode 100644 dist/privacy.d.ts create mode 100644 dist/privacy.js create mode 100644 dist/provenance.d.ts create mode 100644 dist/provenance.js create mode 100644 dist/provider.d.ts create mode 100644 dist/provider.js create mode 100644 dist/render.d.ts create mode 100644 dist/render.js create mode 100644 dist/style.d.ts create mode 100644 dist/style.js create mode 100644 dist/synthesize.d.ts create mode 100644 dist/synthesize.js create mode 100644 scripts/package-smoke.mjs create mode 100644 tsconfig.build.json diff --git a/.gitignore b/.gitignore index d6fea12..5422f17 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,6 @@ node_modules/ .pnp.js # Build output -dist/ build/ *.tsbuildinfo .cache/ diff --git a/README.md b/README.md index 000803b..460b922 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,8 @@ For each `Top10Entry`, the synthesizer: the reference list. 3. **Generates original prose** per section via a **cost-guarded, pluggable AI provider** (deterministic → Ollama → OpenAI), with a deterministic fallback - that is itself a complete, publishable article. + that remains in the Ardur voice and is held for editorial review when AI + generation is unavailable. 4. **Assembles** the prose into the in-app `ArticleBlock[]` render model. 5. **Proves provenance** — every factual claim is mapped to the sources that support it; ungrounded claims are dropped. @@ -37,12 +38,8 @@ For each `Top10Entry`, the synthesizer: bodies. Anything that fails the gate degrades to a stricter deterministic article and records a warning. **It never aborts the cycle.** -The output is an `ArticleArtifact` — one `SynthesizedArticle` per Top-10 entry — -that the ardur.ai app renders directly. - -> **This repo is a design spec + scaffold.** The synthesis logic is intentionally -> **not implemented**. See [`docs/spec.md`](./docs/spec.md) for the full design -> and [`ARCHITECTURE.md`](./ARCHITECTURE.md) for the pipeline-wide contract. +The output is an `ArticleArtifact` with published `articles` and a separate +`heldArticles` editorial queue for deterministic or ungrounded fallbacks. ## Baked-in guarantees @@ -62,8 +59,9 @@ npm install npm run typecheck # tsc --noEmit npm test # node --test (deterministic, zero network) npm run build # tsc -> dist/ +npm run test:package # pack + fresh consumer import smoke -# once implemented: +# run deterministic synthesis against local artifacts: ARDUR_AI_PROVIDER=deterministic npm run synthesize \ --top10 data/runtime/top10.json \ --aggregation data/runtime/aggregation.json \ @@ -85,10 +83,12 @@ const articles: ArticleArtifact = await runSynthesis({ }); ``` -`runSynthesis` returns one `SynthesizedArticle` per `Top10Entry`, each carrying -`body: ArticleBlock[]`, `references`, `provenance`, `ai` (provider meta), and a -`legalNote`. See [`src/contracts.ts`](./src/contracts.ts) (the shared, vendored -contract — identical in all four repos). +`runSynthesis` returns a cycle artifact with `articles` for publishable, +AI-grounded output and `heldArticles` for deterministic or ungrounded fallbacks. +Each synthesized article carries `body: ArticleBlock[]`, `references`, +`provenance`, `ai` (provider meta), and a `legalNote`. See +[`src/contracts.ts`](./src/contracts.ts), the package shim over the shared +contract. ## Relationship to the existing ardur.ai system diff --git a/dist/assemble.d.ts b/dist/assemble.d.ts new file mode 100644 index 0000000..4bd4f88 --- /dev/null +++ b/dist/assemble.d.ts @@ -0,0 +1,94 @@ +/** + * Article assembly rules — how 20–30 sources become ONE original piece. + * + * Mirrors the section order from ardur.ai's content-engine contract so the + * in-app render matches the existing site: + * Key Takeaway -> Why This Matters -> What Happened -> Builder View -> + * Open Questions -> Ardur Take + * + * The assembler: + * - dedups and ranks the cluster's members into a weave order (primary/paper + * sources first, then corroborating news), capping the reference list. + * - asks the provider for original prose per section, grounded in metadata only. + * - converts prose into the `ArticleBlock[]` in-app render model. + * - inserts at most one short (< 25-word) attributed quote where a primary + * source's exact wording is editorially necessary. + * - computes wordCount + readingTime and the source-trail block. + * + * VOICE: every section is assembled in the Ardur house voice + * ("GenZ-but-professional", see `style.ts` + docs/voice.md). The plan carries a + * per-section voice directive that is threaded into BOTH the provider prompt and + * the deterministic fallback templates, so a budget=0 article still reads in + * voice rather than as dry newswire. Voice never overrides the copyright, + * provenance, or render gates. + */ +import type { ArticleBlock, SynthesizedArticle, AggregatedItem, Top10Entry, ProviderMeta, ExtractedFact, ChartBlock } from './contracts.ts'; +import { VOICE_STYLE, SECTION_VOICE, buildVoiceDirective, type VoiceStyle } from './style.ts'; +import type { ArticleDraft } from './provider.ts'; +export { VOICE_STYLE, SECTION_VOICE, buildVoiceDirective }; +/** Canonical section ids, in render order. */ +export type SectionId = 'key-takeaway' | 'why-this-matters' | 'what-happened' | 'builder-view' | 'open-questions' | 'ardur-take'; +export interface SectionSpec { + id: SectionId; + heading: string; + /** Whether the section is required for a publishable article. */ + required: boolean; + /** Soft word target — guides the provider, not a hard cap. */ + targetWords: number; +} +/** The fixed section plan (order matters; it is the render contract). */ +export declare const SECTION_PLAN: readonly SectionSpec[]; +/** Default cap on how many references the source-trail block lists. */ +export declare const MAX_REFERENCES = 30; +/** Minimum body length (words) for a publishable, non-`idea` article. */ +export declare const MIN_BODY_WORDS = 150; +/** The ordered weave plan for one topic before prose is generated. */ +export interface AssemblyPlan { + entry: Top10Entry; + /** Cluster members in weave order (most authoritative first). */ + weave: AggregatedItem[]; + /** Reference list after dedup + cap. */ + references: AggregatedItem[]; + sections: readonly SectionSpec[]; + /** The voice this article is written in (defaults to VOICE_STYLE). */ + voice: VoiceStyle; + /** + * Per-section voice directive (from `buildVoiceDirective`), threaded into both + * the provider prompt and the deterministic fallback so both paths sound the + * same. Keyed by SectionId. + */ + voiceDirectives: Record; +} +/** + * Build the deterministic weave/reference plan for a Top-10 entry from its + * cluster members. No prose yet — this decides ordering, dedup, caps, AND the + * per-section voice directives (from `style.ts`) that downstream prose obeys. + */ +export declare function planAssembly(entry: Top10Entry, clusterMembers: readonly AggregatedItem[], voice?: VoiceStyle): AssemblyPlan; +/** + * Convert generated section prose into the in-app `ArticleBlock[]` render model + * (headings, paragraphs, lists, an optional attributed quote, callouts). The + * prose is expected to already be on-voice; `lintVoice` (style.ts) is applied + * here as a final pass, downgrading off-voice phrasing to plainer wording + * (never blocking — accuracy/copyright gates run separately). + */ +export declare function toRenderBlocks(plan: AssemblyPlan, sectionProse: Record): ArticleBlock[]; +/** + * Final assembly: combine plan + prose + provenance into a `SynthesizedArticle`, + * computing wordCount/readingTime and the reference list. Does NOT run the + * copyright gate — the caller (synthesize.ts) does that and may reject. + */ +export declare function assembleArticle(plan: AssemblyPlan, blocks: ArticleBlock[], draft: ArticleDraft, providerMeta: ProviderMeta, upstreamRunId: string, now: Date): SynthesizedArticle; +/** + * Build ChartBlock[] from the quantitative ExtractedFacts for a cluster. + * Only facts with a `quantity` field produce chart datapoints — no invented numbers. + * + * Groups facts by `quantity.metric` and builds one bar chart per metric that + * has ≥2 comparable datapoints. Single-datapoint metrics are skipped (a bar + * chart of one value is not informative). + */ +export declare function buildChartBlocks(facts: readonly ExtractedFact[], refs: readonly { + source: string; + url: string; + sourceDomain: string; +}[]): ChartBlock[]; diff --git a/dist/assemble.js b/dist/assemble.js new file mode 100644 index 0000000..52e774f --- /dev/null +++ b/dist/assemble.js @@ -0,0 +1,293 @@ +/** + * Article assembly rules — how 20–30 sources become ONE original piece. + * + * Mirrors the section order from ardur.ai's content-engine contract so the + * in-app render matches the existing site: + * Key Takeaway -> Why This Matters -> What Happened -> Builder View -> + * Open Questions -> Ardur Take + * + * The assembler: + * - dedups and ranks the cluster's members into a weave order (primary/paper + * sources first, then corroborating news), capping the reference list. + * - asks the provider for original prose per section, grounded in metadata only. + * - converts prose into the `ArticleBlock[]` in-app render model. + * - inserts at most one short (< 25-word) attributed quote where a primary + * source's exact wording is editorially necessary. + * - computes wordCount + readingTime and the source-trail block. + * + * VOICE: every section is assembled in the Ardur house voice + * ("GenZ-but-professional", see `style.ts` + docs/voice.md). The plan carries a + * per-section voice directive that is threaded into BOTH the provider prompt and + * the deterministic fallback templates, so a budget=0 article still reads in + * voice rather than as dry newswire. Voice never overrides the copyright, + * provenance, or render gates. + */ +import { VOICE_STYLE, SECTION_VOICE, buildVoiceDirective, lintVoice } from "./style.js"; +import { scrubUrl } from "./privacy.js"; +export { VOICE_STYLE, SECTION_VOICE, buildVoiceDirective }; +/** The fixed section plan (order matters; it is the render contract). */ +export const SECTION_PLAN = [ + { id: 'key-takeaway', heading: 'Key Takeaway', required: true, targetWords: 60 }, + { id: 'why-this-matters', heading: 'Why This Matters', required: true, targetWords: 110 }, + { id: 'what-happened', heading: 'What Happened', required: true, targetWords: 140 }, + { id: 'builder-view', heading: 'Builder View', required: false, targetWords: 120 }, + { id: 'open-questions', heading: 'Open Questions', required: false, targetWords: 80 }, + { id: 'ardur-take', heading: 'Ardur Take', required: true, targetWords: 90 }, +]; +/** Default cap on how many references the source-trail block lists. */ +export const MAX_REFERENCES = 30; +/** Minimum body length (words) for a publishable, non-`idea` article. */ +export const MIN_BODY_WORDS = 150; +/** Tier priority for weave ordering (lower index = more authoritative). */ +const TIER_ORDER = ['primary', 'paper', 'technical-news', 'security-news', 'news']; +function tierWeight(tier) { + const idx = TIER_ORDER.indexOf(tier); + return idx === -1 ? TIER_ORDER.length : idx; +} +/** + * Build the deterministic weave/reference plan for a Top-10 entry from its + * cluster members. No prose yet — this decides ordering, dedup, caps, AND the + * per-section voice directives (from `style.ts`) that downstream prose obeys. + */ +export function planAssembly(entry, clusterMembers, voice = VOICE_STYLE) { + // 1. Dedup by fingerprint (normalized source+title, per spec §5.1) + const seen = new Set(); + const deduped = []; + for (const item of clusterMembers) { + if (!seen.has(item.fingerprint)) { + seen.add(item.fingerprint); + deduped.push(item); + } + } + // 2. Rank for weave: tier first, then recency, then cross-source corroboration + const sorted = [...deduped].sort((a, b) => { + const tierDiff = tierWeight(a.tier) - tierWeight(b.tier); + if (tierDiff !== 0) + return tierDiff; + // More recent first within the same tier + const dateA = new Date(a.publishedAt).getTime(); + const dateB = new Date(b.publishedAt).getTime(); + if (dateB !== dateA) + return dateB - dateA; + // More corroborated first (higher cross-source mentions) + return (b.interaction.crossSourceMentions ?? 0) - (a.interaction.crossSourceMentions ?? 0); + }); + // 3. Cap reference list at MAX_REFERENCES + const references = sorted.slice(0, MAX_REFERENCES); + // Full weave order for prose generation (all deduped, uncapped) + const weave = sorted; + // 4. Build per-section voice directives + const voiceDirectives = {}; + for (const section of SECTION_PLAN) { + voiceDirectives[section.id] = buildVoiceDirective(voice, section.id); + } + return { entry, weave, references, sections: SECTION_PLAN, voice, voiceDirectives }; +} +// --------------------------------------------------------------------------- +// Block conversion helpers +// --------------------------------------------------------------------------- +/** Rough word count of a string. */ +function wordCount(text) { + return text.trim().split(/\s+/).filter((w) => w.length > 0).length; +} +/** Remove raw HTML tags from text (safety, belt-and-suspenders). */ +function stripHtml(text) { + return text.replace(/<[^>]+>/g, ''); +} +/** + * Apply voice lint to a text, replacing banned phrases with plainer wording. + * Non-blocking — accuracy and copyright gates already passed. + */ +function applyVoiceLint(text, voice) { + const offenders = lintVoice(text, voice); + if (offenders.length === 0) + return text; + let cleaned = text; + // Replace banned lexicon items with plainer alternatives + const REPLACEMENTS = { + 'game-changer': 'meaningful shift', + 'game changer': 'meaningful shift', + "you won't believe": 'note that', + 'breaking': 'new', + 'shocking': 'notable', + 'mind-blowing': 'impressive', + 'insane': 'substantial', + 'revolutionary': 'significant', + 'unprecedented': 'notable', + 'must-read': 'worth reading', + 'thread': 'post', + 'no cap': '', + 'slaps': 'works well', + 'goes hard': 'delivers', + 'lowkey': '', + 'highkey': '', + 'rizz': 'appeal', + }; + for (const offender of offenders) { + const replacement = REPLACEMENTS[offender]; + if (replacement !== undefined) { + const re = new RegExp(offender.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); + cleaned = cleaned.replace(re, replacement); + } + } + // Normalize consecutive exclamation marks + cleaned = cleaned.replace(/!{2,}/g, '!'); + return cleaned; +} +/** + * Split prose into sentences (simple period/question-mark/exclamation split). + * Doesn't split on abbreviations like "U.S." but good enough for article prose. + */ +function splitSentences(prose) { + return prose + .split(/(?<=[.!?])\s+/) + .map((s) => s.trim()) + .filter((s) => s.length > 0); +} +/** + * Convert generated section prose into the in-app `ArticleBlock[]` render model + * (headings, paragraphs, lists, an optional attributed quote, callouts). The + * prose is expected to already be on-voice; `lintVoice` (style.ts) is applied + * here as a final pass, downgrading off-voice phrasing to plainer wording + * (never blocking — accuracy/copyright gates run separately). + */ +export function toRenderBlocks(plan, sectionProse) { + const blocks = []; + for (const section of SECTION_PLAN) { + const prose = sectionProse[section.id]; + if (!prose || !prose.trim()) + continue; + // Heading block + blocks.push({ type: 'heading', text: section.heading }); + // Clean + lint the prose + const cleaned = applyVoiceLint(stripHtml(prose.trim()), plan.voice); + // Break into paragraph(s) — split on blank lines first, then on sentence density + const paragraphs = cleaned.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0); + for (const para of paragraphs) { + // If the paragraph is very long (>80 words), split into two + const words = wordCount(para); + if (words > 80) { + const sentences = splitSentences(para); + const midpoint = Math.ceil(sentences.length / 2); + const first = sentences.slice(0, midpoint).join(' '); + const second = sentences.slice(midpoint).join(' '); + if (first.trim()) + blocks.push({ type: 'paragraph', text: first.trim() }); + if (second.trim()) + blocks.push({ type: 'paragraph', text: second.trim() }); + } + else { + blocks.push({ type: 'paragraph', text: para }); + } + } + } + return blocks; +} +/** + * Final assembly: combine plan + prose + provenance into a `SynthesizedArticle`, + * computing wordCount/readingTime and the reference list. Does NOT run the + * copyright gate — the caller (synthesize.ts) does that and may reject. + */ +export function assembleArticle(plan, blocks, draft, providerMeta, upstreamRunId, now) { + // Convert AggregatedItem[] references to ArticleReference[] + const references = plan.references.map((item) => ({ + source: item.source, + sourceDomain: item.sourceDomain, + tier: item.tier, + url: scrubUrl(item.url) || item.url, // fallback to original if scrub returns '' + title: item.title, + publishedAt: item.publishedAt, + })); + // Word count over all block text (text-type blocks only) + const allText = blocks + .map((b) => { + const tb = b; + return tb.text ?? (tb.items ?? []).join(' '); + }) + .join(' '); + const wc = wordCount(allText); + // ~238 words per minute reading speed (standard estimate) + const readingTimeMinutes = Math.max(1, Math.round(wc / 238)); + const distinctDomains = new Set(plan.references.map((r) => r.sourceDomain)).size; + return { + id: `${plan.entry.clusterId}::${now.toISOString()}`, + rank: plan.entry.rank, + topic: plan.entry.topic, + topicLabel: plan.entry.topicLabel, + headline: draft.headline, + dek: draft.dek, + body: blocks, + keyPoints: draft.keyPoints, + whyItMatters: draft.whyItMatters, + readerAction: draft.readerAction, + tags: draft.tags, + confidence: draft.confidence, + sourceQuality: plan.entry.sourceQuality, + references, + provenance: { + clusterId: plan.entry.clusterId, + sourceCount: plan.references.length, + distinctDomains, + upstreamRunId, + }, + ai: providerMeta, + legalNote: 'Original Ardur synthesis from headline/feed metadata; references preserved; no article body copied.', + wordCount: wc, + readingTimeMinutes, + generatedAt: now.toISOString(), + }; +} +// --------------------------------------------------------------------------- +// S4 — Visual blocks from real extracted data +// --------------------------------------------------------------------------- +/** + * Build ChartBlock[] from the quantitative ExtractedFacts for a cluster. + * Only facts with a `quantity` field produce chart datapoints — no invented numbers. + * + * Groups facts by `quantity.metric` and builds one bar chart per metric that + * has ≥2 comparable datapoints. Single-datapoint metrics are skipped (a bar + * chart of one value is not informative). + */ +export function buildChartBlocks(facts, refs) { + const quantFacts = facts.filter((f) => f.quantity != null); + if (quantFacts.length === 0) + return []; + // Group by metric name + const byMetric = new Map(); + for (const fact of quantFacts) { + const metric = fact.quantity.metric; + const bucket = byMetric.get(metric) ?? []; + bucket.push(fact); + byMetric.set(metric, bucket); + } + const charts = []; + for (const [metric, metricFacts] of byMetric) { + if (metricFacts.length < 2) + continue; // not enough points for a comparison chart + const series = metricFacts.map((f) => ({ + label: f.entities[0] ?? f.quantity.asOf ?? f.id.slice(-6), + value: f.quantity.value, + ...(f.quantity.unit ? { unit: f.quantity.unit } : {}), + })); + const factIds = metricFacts.map((f) => f.id); + // Build attribution from the refs whose domains match the fact provenance + const citedDomains = new Set(metricFacts.flatMap((f) => f.provenance.map((p) => p.sourceDomain))); + const attributionSources = refs + .filter((r) => citedDomains.has(r.sourceDomain)) + .map((r) => ({ source: r.source, url: r.url })) + .slice(0, 5); + if (attributionSources.length === 0) + continue; + const unitSuffix = metricFacts[0]?.quantity?.unit ? ` (${metricFacts[0].quantity.unit})` : ''; + charts.push({ + type: 'chart', + chartType: 'bar', + title: `${metric}${unitSuffix}`, + series, + factIds, + caption: `Based on extracted data from ${attributionSources.map((s) => s.source).join(', ')}.`, + attribution: { sources: attributionSources }, + }); + } + return charts; +} diff --git a/dist/cli.d.ts b/dist/cli.d.ts new file mode 100644 index 0000000..56a73df --- /dev/null +++ b/dist/cli.d.ts @@ -0,0 +1,19 @@ +/** + * CLI — run one synthesis cycle and write the ArticleArtifact. + * + * Uniform agent-ready CLI (hermes-agent-layer.md §4): + * + * --describe + * Emit engine descriptor JSON and exit. + * + * --in --provider [--out ] [--now ] [--run-id ] + * Read combined { top10, aggregation } JSON; write ArticleArtifact to --out (or stdout). + * + * Legacy (ardur-pipeline backward compat — no behaviour change): + * --top10 --aggregation [--out ] + * + * Exit codes: 0 = success; 1 = error. + * On error: { error: { code, message, stage, detail? } } emitted to stdout, diagnostics on stderr. + */ +import { DESCRIPTOR } from './describe.ts'; +export { DESCRIPTOR }; diff --git a/dist/cli.js b/dist/cli.js new file mode 100644 index 0000000..fe6102b --- /dev/null +++ b/dist/cli.js @@ -0,0 +1,215 @@ +/** + * CLI — run one synthesis cycle and write the ArticleArtifact. + * + * Uniform agent-ready CLI (hermes-agent-layer.md §4): + * + * --describe + * Emit engine descriptor JSON and exit. + * + * --in --provider [--out ] [--now ] [--run-id ] + * Read combined { top10, aggregation } JSON; write ArticleArtifact to --out (or stdout). + * + * Legacy (ardur-pipeline backward compat — no behaviour change): + * --top10 --aggregation [--out ] + * + * Exit codes: 0 = success; 1 = error. + * On error: { error: { code, message, stage, detail? } } emitted to stdout, diagnostics on stderr. + */ +import { readFileSync, writeFileSync } from 'node:fs'; +import { runSynthesis } from "./index.js"; +import { assertCompatibleArtifact, SchemaVersionError } from "./contracts.js"; +import { createProvider } from "./provider.js"; +import { DESCRIPTOR } from "./describe.js"; +export { DESCRIPTOR }; +function emitError(code, message, detail) { + const env = { + error: { + code, + message, + stage: 'synthesizer', + ...(detail !== undefined ? { detail } : {}), + }, + }; + process.stdout.write(JSON.stringify(env) + '\n'); + process.exit(1); +} +function parseArgs(argv) { + let describe = false; + let inPath; + let out; + let provider; + let now; + let runId; + let top10; + let aggregation; + for (let i = 0; i < argv.length; i++) { + const cur = argv[i]; + const next = argv[i + 1]; + if (cur === '--describe') { + describe = true; + } + else if (cur === '--in' && next) { + inPath = next; + i++; + } + else if (cur === '--out' && next) { + out = next; + i++; + } + else if (cur === '--provider' && next) { + provider = next; + i++; + } + else if (cur === '--now' && next) { + now = next; + i++; + } + else if (cur === '--run-id' && next) { + runId = next; + i++; + } + else if (cur === '--top10' && next) { + top10 = next; + i++; + } + else if (cur === '--aggregation' && next) { + aggregation = next; + i++; + } + } + return { describe, inPath, out, provider, now, runId, top10, aggregation }; +} +// --------------------------------------------------------------------------- +// I/O helpers +// --------------------------------------------------------------------------- +function readRawJson(pathOrDash) { + let raw; + try { + raw = pathOrDash === '-' ? readFileSync(0, 'utf8') : readFileSync(pathOrDash, 'utf8'); + } + catch (err) { + emitError('READ_ERROR', `Failed to read ${pathOrDash}: ${err instanceof Error ? err.message : String(err)}`); + } + try { + return JSON.parse(raw); + } + catch (err) { + emitError('PARSE_ERROR', `JSON parse failed for ${pathOrDash}: ${err instanceof Error ? err.message : String(err)}`); + } +} +function gateTop10(raw) { + try { + const { envelope, warnings } = assertCompatibleArtifact(raw, 'top10'); + for (const w of warnings) + process.stderr.write(`[warn] top10 gate: ${w}\n`); + return envelope; + } + catch (err) { + if (err instanceof SchemaVersionError) { + emitError('SCHEMA_GATE_FAILED', `top10 schema gate failed: ${err.message}`, JSON.stringify(err.detail)); + } + throw err; + } +} +function gateAggregation(raw) { + try { + const { envelope, warnings } = assertCompatibleArtifact(raw, 'aggregation'); + for (const w of warnings) + process.stderr.write(`[warn] aggregation gate: ${w}\n`); + return envelope; + } + catch (err) { + if (err instanceof SchemaVersionError) { + emitError('SCHEMA_GATE_FAILED', `aggregation schema gate failed: ${err.message}`, JSON.stringify(err.detail)); + } + throw err; + } +} +function writeOutput(outPath, data) { + if (outPath && outPath !== '-') { + writeFileSync(outPath, data, 'utf8'); + process.stderr.write(`ArticleArtifact written to ${outPath}\n`); + } + else { + process.stdout.write(data); + } +} +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.describe) { + process.stdout.write(JSON.stringify(DESCRIPTOR, null, 2) + '\n'); + return; + } + let top10Raw; + let aggregationRaw; + let explicitProvider; + if (args.inPath !== undefined) { + // Uniform path: --in carries combined { top10, aggregation }. + const combined = readRawJson(args.inPath); + if (typeof combined !== 'object' || combined === null || Array.isArray(combined)) { + emitError('INVALID_INPUT', '--in must be a JSON object with "top10" and "aggregation" fields'); + } + const c = combined; + if (!('top10' in c) || !('aggregation' in c)) { + emitError('INVALID_INPUT', '--in JSON must contain "top10" and "aggregation" fields'); + } + top10Raw = c['top10']; + aggregationRaw = c['aggregation']; + if (!args.provider) { + emitError('MISSING_PROVIDER', '--provider is required with --in (no implicit env-driven network mode)'); + } + explicitProvider = args.provider; + } + else if (args.top10 !== undefined && args.aggregation !== undefined) { + // Legacy path — ardur-pipeline backward compat. + top10Raw = readRawJson(args.top10); + aggregationRaw = readRawJson(args.aggregation); + if (args.provider !== undefined) + explicitProvider = args.provider; + } + else { + emitError('MISSING_INPUT', [ + 'Uniform: cli.ts --in --provider [--out ] [--now ] [--run-id ]', + 'Legacy: cli.ts --top10 --aggregation [--out ]', + 'Describe: cli.ts --describe', + ].join('\n')); + } + const top10 = gateTop10(top10Raw); + const aggregation = gateAggregation(aggregationRaw); + let now; + if (args.now !== undefined) { + now = new Date(args.now); + if (isNaN(now.getTime())) { + emitError('INVALID_NOW', `--now is not a valid ISO 8601 date: ${args.now}`); + } + } + else { + now = new Date(); + } + const provider = createProvider({ + ...(explicitProvider !== undefined ? { provider: explicitProvider } : {}), + now, + }); + const artifact = await runSynthesis({ + top10, + aggregation, + provider, + now, + ...(args.runId !== undefined ? { runId: args.runId } : {}), + }); + writeOutput(args.out, JSON.stringify(artifact, null, 2)); + for (const w of artifact.warnings) { + process.stderr.write(`[warn] ${w}\n`); + } +} +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + const env = { + error: { code: 'UNEXPECTED_ERROR', message, stage: 'synthesizer' }, + }; + process.stdout.write(JSON.stringify(env) + '\n'); + process.exit(1); +}); diff --git a/dist/contracts.d.ts b/dist/contracts.d.ts new file mode 100644 index 0000000..7b9ad7c --- /dev/null +++ b/dist/contracts.d.ts @@ -0,0 +1,8 @@ +/** + * Thin re-export shim — the local copy of contracts has been replaced by + * the canonical @ardurai/contracts package (github:ArdurAI/ardur-contracts). + * + * All types, constants, and the assertCompatibleArtifact gate are forwarded + * from the package. No content should live in this file; edit the package instead. + */ +export * from '@ardurai/contracts'; diff --git a/dist/contracts.js b/dist/contracts.js new file mode 100644 index 0000000..7b9ad7c --- /dev/null +++ b/dist/contracts.js @@ -0,0 +1,8 @@ +/** + * Thin re-export shim — the local copy of contracts has been replaced by + * the canonical @ardurai/contracts package (github:ArdurAI/ardur-contracts). + * + * All types, constants, and the assertCompatibleArtifact gate are forwarded + * from the package. No content should live in this file; edit the package instead. + */ +export * from '@ardurai/contracts'; diff --git a/dist/copyright.d.ts b/dist/copyright.d.ts new file mode 100644 index 0000000..f5a2248 --- /dev/null +++ b/dist/copyright.d.ts @@ -0,0 +1,62 @@ +/** + * Copyright-safety guards — the non-negotiable gate every article must pass. + * + * Rules (ratified in ARCHITECTURE.md §6 and ardur.ai's content-engine contract): + * - ORIGINAL TEXT ONLY. The synthesizer writes original prose; it never + * reproduces an external article body. + * - Quotes are < 25 words AND carry attribution (source + canonical url). + * - Every source synthesized is attributed with a canonical link. + * - No verbatim runs from source metadata beyond factual names/titles + * (checked by n-gram overlap against the summaryHint/title corpus). + * - No secrets/credentials leak into body or metadata (regex screen ported + * from `scripts/validate-articles.mjs`). + * + * An article that fails ANY check is rejected by the synthesizer; the offending + * cycle records a warning and falls back to a stricter deterministic article. + * Failing closed (drop the article) is preferred to publishing unsafe text. + */ +import type { SynthesizedArticle, AggregatedItem } from './contracts.ts'; +/** The default maximum quote length, in words. */ +export declare const MAX_QUOTE_WORDS = 25; +/** + * Largest verbatim n-gram (in words) the body may share with any source's + * summaryHint before it is treated as reproduced text. + */ +export declare const MAX_VERBATIM_NGRAM = 8; +/** + * Largest verbatim n-gram (in words) the body may share with any source's + * title field (#21). Higher than the summaryHint threshold because the + * deterministic draft legitimately embeds short cluster headlines; + * threshold > 12 catches verbatim title reproduction without regressing on + * normal headline references (see issue #11 history). + */ +export declare const MAX_VERBATIM_TITLE_NGRAM = 12; +export type CopyrightViolationKind = 'quote-too-long' | 'quote-unattributed' | 'missing-canonical-link' | 'source-unattributed' | 'verbatim-overlap' | 'reproduced-body' | 'credential-leak'; +export interface CopyrightViolation { + kind: CopyrightViolationKind; + detail: string; + /** The block index or reference id the violation was found in, if locatable. */ + locator?: string; +} +export interface CopyrightVerdict { + ok: boolean; + violations: CopyrightViolation[]; +} +/** + * Validate a finished article against every copyright rule. `corpus` is the set + * of cluster members whose metadata the article was synthesized from — used for + * verbatim-overlap detection. + */ +export declare function enforceCopyright(article: SynthesizedArticle, corpus: AggregatedItem[]): CopyrightVerdict; +/** True iff `text` is a quote of strictly < MAX_QUOTE_WORDS words. */ +export declare function isQuoteWithinLimit(text: string, maxWords?: number): boolean; +/** + * Longest verbatim word-run shared between `candidate` and any string in + * `sources`. Used to catch accidental reproduction of source phrasing. + * + * Algorithm: for every (source_start, candidate_start) pair where the first + * words match, extend the run and record the maximum. + * Complexity: O(|sources| * |src_words| * |cand_words| * run_len) — acceptable + * for article-sized text (a few hundred words each). + */ +export declare function longestVerbatimRun(candidate: string, sources: readonly string[]): number; diff --git a/dist/copyright.js b/dist/copyright.js new file mode 100644 index 0000000..04eadb2 --- /dev/null +++ b/dist/copyright.js @@ -0,0 +1,184 @@ +/** + * Copyright-safety guards — the non-negotiable gate every article must pass. + * + * Rules (ratified in ARCHITECTURE.md §6 and ardur.ai's content-engine contract): + * - ORIGINAL TEXT ONLY. The synthesizer writes original prose; it never + * reproduces an external article body. + * - Quotes are < 25 words AND carry attribution (source + canonical url). + * - Every source synthesized is attributed with a canonical link. + * - No verbatim runs from source metadata beyond factual names/titles + * (checked by n-gram overlap against the summaryHint/title corpus). + * - No secrets/credentials leak into body or metadata (regex screen ported + * from `scripts/validate-articles.mjs`). + * + * An article that fails ANY check is rejected by the synthesizer; the offending + * cycle records a warning and falls back to a stricter deterministic article. + * Failing closed (drop the article) is preferred to publishing unsafe text. + */ +/** The default maximum quote length, in words. */ +export const MAX_QUOTE_WORDS = 25; +/** + * Largest verbatim n-gram (in words) the body may share with any source's + * summaryHint before it is treated as reproduced text. + */ +export const MAX_VERBATIM_NGRAM = 8; +/** + * Largest verbatim n-gram (in words) the body may share with any source's + * title field (#21). Higher than the summaryHint threshold because the + * deterministic draft legitimately embeds short cluster headlines; + * threshold > 12 catches verbatim title reproduction without regressing on + * normal headline references (see issue #11 history). + */ +export const MAX_VERBATIM_TITLE_NGRAM = 12; +/** Credential regex patterns ported from validate-articles.mjs */ +const CREDENTIAL_PATTERNS = [ + /\bsk-[A-Za-z0-9_-]{20,}\b/, // OpenAI-style secret key + /\bAIza[A-Za-z0-9_-]{30,}\b/, // Google API key + /\bghp_[A-Za-z0-9]{36,}\b/, // GitHub personal access token + /\bglpat-[A-Za-z0-9_-]{20,}\b/, // GitLab PAT + /password\s*[:=]\s*\S{6,}/i, + /secret\s*[:=]\s*\S{6,}/i, + /api[_-]?key\s*[:=]\s*\S{6,}/i, + /bearer\s+[A-Za-z0-9_.-]{20,}/i, +]; +/** Normalize text for verbatim comparison (lowercase, strip punctuation). */ +function normalizeWords(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((w) => w.length > 0); +} +/** + * Validate a finished article against every copyright rule. `corpus` is the set + * of cluster members whose metadata the article was synthesized from — used for + * verbatim-overlap detection. + */ +export function enforceCopyright(article, corpus) { + const violations = []; + // 1. Quote blocks: < 25 words and must carry attribution + for (let i = 0; i < article.body.length; i++) { + const block = article.body[i]; + if (!block || block.type !== 'quote') + continue; + if (block.text && !isQuoteWithinLimit(block.text)) { + violations.push({ + kind: 'quote-too-long', + detail: `Quote in block ${i} must be strictly less than ${MAX_QUOTE_WORDS} words (${block.text.split(/\s+/).length} words found)`, + locator: String(i), + }); + } + if (!block.attribution?.source || !block.attribution?.url) { + violations.push({ + kind: 'quote-unattributed', + detail: `Quote block at index ${i} is missing attribution (source + url required)`, + locator: String(i), + }); + } + } + // 2. Every referenced source needs a canonical URL + for (const ref of article.references) { + if (!ref.url || !ref.url.startsWith('http')) { + violations.push({ + kind: 'missing-canonical-link', + detail: `Reference "${ref.title}" from ${ref.source} has no valid canonical URL`, + locator: ref.source, + }); + } + } + // 3. Verbatim overlap: non-quote body text vs. source summaryHints AND titles. + // SummaryHints use MAX_VERBATIM_NGRAM (8). + // Titles use MAX_VERBATIM_TITLE_NGRAM (12) — #21: titles were previously excluded + // (issue #11) because the deterministic draft embeds the cluster headline verbatim + // via buildKeyTakeaway. A threshold above the longest typical headline (~12 tokens + // after normalisation) lets us catch true verbatim title reproduction without + // regressing on the legitimate headline embedding pattern. + const summaryHintTexts = corpus.map((item) => item.summaryHint).filter(Boolean); + const titleTexts = corpus.map((item) => item.title).filter(Boolean); + const bodyText = article.body + .filter((b) => b.type !== 'quote') // quotes are expected to share wording + .map((b) => { const tb = b; return tb.text ?? (tb.items ?? []).join(' '); }) + .join(' '); + if (bodyText.trim()) { + const hintOverlap = longestVerbatimRun(bodyText, summaryHintTexts); + if (hintOverlap > MAX_VERBATIM_NGRAM) { + violations.push({ + kind: 'verbatim-overlap', + detail: `Body has a ${hintOverlap}-word verbatim run against source summaryHints (limit: ${MAX_VERBATIM_NGRAM})`, + }); + } + const titleOverlap = longestVerbatimRun(bodyText, titleTexts); + if (titleOverlap > MAX_VERBATIM_TITLE_NGRAM) { + violations.push({ + kind: 'verbatim-overlap', + detail: `Body has a ${titleOverlap}-word verbatim run against source titles (limit: ${MAX_VERBATIM_TITLE_NGRAM})`, + }); + } + } + // 4. Credential/secret leak screen (ported from validate-articles.mjs). + // #22: extend to cover all LLM-generated metadata fields — keyPoints, tags, + // whyItMatters and readerAction were previously unscreened. + const fullText = [ + article.headline, + article.dek, + ...article.body.map((b) => { const tb = b; return tb.text ?? (tb.items ?? []).join(' '); }), + article.legalNote, + ...(Array.isArray(article.keyPoints) ? article.keyPoints : []), + ...(Array.isArray(article.tags) ? article.tags : []), + article.whyItMatters ?? '', + article.readerAction ?? '', + ].filter(Boolean).join(' '); + for (const pattern of CREDENTIAL_PATTERNS) { + if (pattern.test(fullText)) { + violations.push({ + kind: 'credential-leak', + detail: 'Potential credential or secret detected in article text', + }); + break; // one violation per article is enough to fail it + } + } + return { ok: violations.length === 0, violations }; +} +/** True iff `text` is a quote of strictly < MAX_QUOTE_WORDS words. */ +export function isQuoteWithinLimit(text, maxWords = MAX_QUOTE_WORDS) { + const words = text.trim().split(/\s+/).filter((w) => w.length > 0); + return words.length < maxWords; +} +/** + * Longest verbatim word-run shared between `candidate` and any string in + * `sources`. Used to catch accidental reproduction of source phrasing. + * + * Algorithm: for every (source_start, candidate_start) pair where the first + * words match, extend the run and record the maximum. + * Complexity: O(|sources| * |src_words| * |cand_words| * run_len) — acceptable + * for article-sized text (a few hundred words each). + */ +export function longestVerbatimRun(candidate, sources) { + if (sources.length === 0) + return 0; + const candWords = normalizeWords(candidate); + if (candWords.length === 0) + return 0; + let longest = 0; + for (const source of sources) { + const srcWords = normalizeWords(source); + if (srcWords.length === 0) + continue; + for (let si = 0; si < srcWords.length; si++) { + for (let ci = 0; ci < candWords.length; ci++) { + if (srcWords[si] !== candWords[ci]) + continue; + // Words match at (si, ci) — extend the run + let run = 1; + while (si + run < srcWords.length && + ci + run < candWords.length && + srcWords[si + run] === candWords[ci + run]) { + run++; + } + if (run > longest) + longest = run; + } + } + } + return longest; +} diff --git a/dist/describe.d.ts b/dist/describe.d.ts new file mode 100644 index 0000000..e62e200 --- /dev/null +++ b/dist/describe.d.ts @@ -0,0 +1,67 @@ +/** + * Engine self-description for --describe, tool registry, and MCP server. + * Imported by cli.ts (emitted to stdout) and by tests (structural assertions). + */ +export declare const DESCRIPTOR: { + readonly name: "ardur-article-synthesizer"; + readonly stage: "articles"; + readonly contract: { + readonly schemaVersion: "ardur-content-pipeline/v1"; + readonly contractRevision: 3; + }; + readonly input: { + readonly type: "object"; + readonly required: readonly ["top10", "aggregation"]; + readonly properties: { + readonly top10: { + readonly $ref: "Top10Artifact"; + readonly description: "Top-10 selection from ardur-top10-engine"; + }; + readonly aggregation: { + readonly $ref: "AggregationArtifact"; + readonly description: "Clustered sources from ardur-news-aggregator"; + }; + }; + }; + readonly output: { + readonly $ref: "ArticleArtifact"; + readonly description: "Copyright-safe synthesized articles"; + }; + readonly flags: readonly [{ + readonly flag: "--in"; + readonly type: "file|-"; + readonly description: "Combined { top10, aggregation } JSON (or - for stdin)"; + }, { + readonly flag: "--out"; + readonly type: "file|-"; + readonly default: "-"; + readonly description: "Output path or - for stdout"; + }, { + readonly flag: "--provider"; + readonly type: "string"; + readonly enum: readonly ["deterministic", "ollama", "openai"]; + readonly description: "AI provider (required with --in; no implicit env-driven network mode)"; + }, { + readonly flag: "--now"; + readonly type: "iso8601"; + readonly description: "Deterministic wall-clock instant for replay"; + }, { + readonly flag: "--run-id"; + readonly type: "string"; + readonly description: "Deterministic run ID for replay"; + }, { + readonly flag: "--describe"; + readonly type: "boolean"; + readonly description: "Print this descriptor and exit"; + }, { + readonly flag: "--top10"; + readonly type: "file"; + readonly deprecated: true; + readonly description: "Legacy: Top10Artifact path (use --in instead)"; + }, { + readonly flag: "--aggregation"; + readonly type: "file"; + readonly deprecated: true; + readonly description: "Legacy: AggregationArtifact path (use --in instead)"; + }]; +}; diff --git a/dist/describe.js b/dist/describe.js new file mode 100644 index 0000000..1aa16cd --- /dev/null +++ b/dist/describe.js @@ -0,0 +1,55 @@ +/** + * Engine self-description for --describe, tool registry, and MCP server. + * Imported by cli.ts (emitted to stdout) and by tests (structural assertions). + */ +import { SCHEMA_VERSION, CONTRACT_REVISION } from "./contracts.js"; +export const DESCRIPTOR = { + name: 'ardur-article-synthesizer', + stage: 'articles', + contract: { schemaVersion: SCHEMA_VERSION, contractRevision: CONTRACT_REVISION }, + input: { + type: 'object', + required: ['top10', 'aggregation'], + properties: { + top10: { $ref: 'Top10Artifact', description: 'Top-10 selection from ardur-top10-engine' }, + aggregation: { + $ref: 'AggregationArtifact', + description: 'Clustered sources from ardur-news-aggregator', + }, + }, + }, + output: { $ref: 'ArticleArtifact', description: 'Copyright-safe synthesized articles' }, + flags: [ + { + flag: '--in', + type: 'file|-', + description: 'Combined { top10, aggregation } JSON (or - for stdin)', + }, + { flag: '--out', type: 'file|-', default: '-', description: 'Output path or - for stdout' }, + { + flag: '--provider', + type: 'string', + enum: ['deterministic', 'ollama', 'openai'], + description: 'AI provider (required with --in; no implicit env-driven network mode)', + }, + { + flag: '--now', + type: 'iso8601', + description: 'Deterministic wall-clock instant for replay', + }, + { flag: '--run-id', type: 'string', description: 'Deterministic run ID for replay' }, + { flag: '--describe', type: 'boolean', description: 'Print this descriptor and exit' }, + { + flag: '--top10', + type: 'file', + deprecated: true, + description: 'Legacy: Top10Artifact path (use --in instead)', + }, + { + flag: '--aggregation', + type: 'file', + deprecated: true, + description: 'Legacy: AggregationArtifact path (use --in instead)', + }, + ], +}; diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..58efedf --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,59 @@ +/** + * ardur-article-synthesizer — public entrypoint. + * + * Stage 4 of the Ardur content pipeline: take the `Top10Artifact` (what to write + * about) plus the `AggregationArtifact` (the 20–30 clustered sources per topic), + * and emit an `ArticleArtifact` containing one ORIGINAL, copyright-safe article + * per Top-10 entry, rendered in-app with no navigation away. + */ +import type { AggregationArtifact, Top10Artifact, CycleMeta } from './contracts.ts'; +import type { ArticleArtifactExtended } from './synthesize.ts'; +import type { AiProvider } from './provider.ts'; +export * from './contracts.ts'; +export type { ArticleArtifactExtended } from './synthesize.ts'; +export type { AiProvider, ProviderName, GenerateRequest, GenerateResult } from './provider.ts'; +export type { ClaimInput, LegacyClaimProvenance, ProvenanceMap, FactProvenanceResult, SupportStrength, } from './provenance.ts'; +export type { CopyrightVerdict, CopyrightViolation } from './copyright.ts'; +export type { RenderContract, RenderViolation, RenderViolationKind } from './render.ts'; +export type { AssemblyPlan, SectionSpec } from './assemble.ts'; +export { buildChartBlocks } from './assemble.ts'; +export interface SynthesisOptions { + /** + * The upstream Top-10 selection — defines WHICH topics get an article and the + * rank/confidence/references carried forward. + */ + top10: Top10Artifact; + /** + * The aggregation artifact for the SAME cycle — supplies the cluster members + * (20–30 sources/topic) that are woven into each article. Must share + * `cycle.id` with `top10`; mismatches are recorded as warnings. + */ + aggregation: AggregationArtifact; + /** Override the cycle (defaults to `top10.cycle`). */ + cycle?: CycleMeta; + /** + * Hard cap on AI model calls for this run (default `ARDUR_AI_MAX_GENERATIONS`, + * else 20). Once exhausted, remaining articles use the deterministic path. + */ + maxGenerations?: number; + /** Per-model-call timeout before deterministic fallback (default 20000ms). */ + perCallTimeoutMs?: number; + /** Override the wall clock (testing/replay). */ + now?: Date; + /** Injected provider for tests; defaults to the env-resolved provider chain. */ + provider?: AiProvider; + /** Override the artifact run ID for deterministic replay (--run-id). */ + runId?: string; +} +/** + * Synthesize one cycle's articles and return the artifact. + * + * Guarantees: + * - One `SynthesizedArticle` per `Top10Entry`, never more. + * - Every article passes the copyright guard (original text, quotes < 25 words, + * no reproduced bodies, attribution + canonical links present). + * - Every article carries provenance mapping each claim to supporting sources. + * - A model failure/budget exhaustion degrades that article to the deterministic + * path and records a `warning` — it never aborts the run. + */ +export declare function runSynthesis(options: SynthesisOptions): Promise; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..a1e67d6 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,36 @@ +/** + * ardur-article-synthesizer — public entrypoint. + * + * Stage 4 of the Ardur content pipeline: take the `Top10Artifact` (what to write + * about) plus the `AggregationArtifact` (the 20–30 clustered sources per topic), + * and emit an `ArticleArtifact` containing one ORIGINAL, copyright-safe article + * per Top-10 entry, rendered in-app with no navigation away. + */ +import { synthesizeCycle } from "./synthesize.js"; +import { createProvider } from "./provider.js"; +export * from "./contracts.js"; +export { buildChartBlocks } from "./assemble.js"; +/** + * Synthesize one cycle's articles and return the artifact. + * + * Guarantees: + * - One `SynthesizedArticle` per `Top10Entry`, never more. + * - Every article passes the copyright guard (original text, quotes < 25 words, + * no reproduced bodies, attribution + canonical links present). + * - Every article carries provenance mapping each claim to supporting sources. + * - A model failure/budget exhaustion degrades that article to the deterministic + * path and records a `warning` — it never aborts the run. + */ +export function runSynthesis(options) { + const provider = options.provider ?? createProvider(); + const now = options.now ?? new Date(); + return synthesizeCycle({ + top10: options.top10, + aggregation: options.aggregation, + provider, + maxGenerations: options.maxGenerations ?? 20, + perCallTimeoutMs: options.perCallTimeoutMs ?? 20_000, + now, + ...(options.runId !== undefined ? { runId: options.runId } : {}), + }); +} diff --git a/dist/privacy.d.ts b/dist/privacy.d.ts new file mode 100644 index 0000000..6ba8ba5 --- /dev/null +++ b/dist/privacy.d.ts @@ -0,0 +1,24 @@ +/** + * Privacy guards — no PII in URLs or logs, aggregate-only metrics. + * + * Reuses the shared `FORBIDDEN_METRIC_KEY_FRAGMENTS` from contracts.ts (the same + * screen ardur.ai's `refresh-article-intelligence.mjs` applies). The synthesizer + * touches URLs (references) and emits logs/run reports; both must be scrubbed. + */ +import { FORBIDDEN_METRIC_KEY_FRAGMENTS } from './contracts.ts'; +export { FORBIDDEN_METRIC_KEY_FRAGMENTS }; +/** Query/fragment params stripped from every reference URL before it is stored. */ +export declare const STRIPPED_URL_PARAMS: readonly string[]; +/** + * Normalize a public URL for storage/logging: drop credentials, fragment, and + * tracking params. Returns '' for anything unsafe (delegates host/IP/protocol + * safety to the shared source-safety port). PII never reaches the artifact. + */ +export declare function scrubUrl(value: unknown): string; +/** True iff a metric/log key contains any forbidden PII fragment. */ +export declare function isForbiddenKey(key: string): boolean; +/** + * Redact a log/run-report record in place semantics (returns a safe copy): + * removes forbidden keys and scrubs any string value that looks like a URL. + */ +export declare function redactForLog(record: Record): Record; diff --git a/dist/privacy.js b/dist/privacy.js new file mode 100644 index 0000000..11fd2af --- /dev/null +++ b/dist/privacy.js @@ -0,0 +1,66 @@ +/** + * Privacy guards — no PII in URLs or logs, aggregate-only metrics. + * + * Reuses the shared `FORBIDDEN_METRIC_KEY_FRAGMENTS` from contracts.ts (the same + * screen ardur.ai's `refresh-article-intelligence.mjs` applies). The synthesizer + * touches URLs (references) and emits logs/run reports; both must be scrubbed. + */ +import { FORBIDDEN_METRIC_KEY_FRAGMENTS } from "./contracts.js"; +export { FORBIDDEN_METRIC_KEY_FRAGMENTS }; +/** Query/fragment params stripped from every reference URL before it is stored. */ +export const STRIPPED_URL_PARAMS = [ + 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', + 'gclid', 'fbclid', 'msclkid', 'mc_eid', 'mc_cid', 'ref', 'referrer', +]; +/** + * Normalize a public URL for storage/logging: drop credentials, fragment, and + * tracking params. Returns '' for anything unsafe (delegates host/IP/protocol + * safety to the shared source-safety port). PII never reaches the artifact. + */ +export function scrubUrl(value) { + if (typeof value !== 'string' || !value.trim()) + return ''; + let url; + try { + url = new URL(value.trim()); + } + catch { + return ''; + } + // Only allow https and http (no file://, data://, javascript:, etc.) + if (url.protocol !== 'https:' && url.protocol !== 'http:') + return ''; + // Drop credentials — never store user:password@ in any artifact + url.username = ''; + url.password = ''; + // Drop fragment — client-side navigation hint, not canonical + url.hash = ''; + // Drop tracking params + for (const param of STRIPPED_URL_PARAMS) { + url.searchParams.delete(param); + } + return url.toString(); +} +/** True iff a metric/log key contains any forbidden PII fragment. */ +export function isForbiddenKey(key) { + const lower = key.toLowerCase(); + return FORBIDDEN_METRIC_KEY_FRAGMENTS.some((fragment) => lower.includes(fragment)); +} +/** + * Redact a log/run-report record in place semantics (returns a safe copy): + * removes forbidden keys and scrubs any string value that looks like a URL. + */ +export function redactForLog(record) { + const result = {}; + for (const [key, value] of Object.entries(record)) { + if (isForbiddenKey(key)) + continue; + if (typeof value === 'string' && /^https?:\/\//i.test(value)) { + result[key] = scrubUrl(value); + } + else { + result[key] = value; + } + } + return result; +} diff --git a/dist/provenance.d.ts b/dist/provenance.d.ts new file mode 100644 index 0000000..61ce8e9 --- /dev/null +++ b/dist/provenance.d.ts @@ -0,0 +1,67 @@ +/** + * Provenance — every generated claim is traceable to the sources that support it. + * + * Two gate modes: + * + * 1. FACT-GROUNDED (Rev 3, S3) — primary mode when ExtractedFact[] are available. + * Claim sentences are mapped to fact IDs via: + * a) Inline [FACT:id] citations the LLM embedded in its output. + * b) Entity/number overlap as a backstop against bad citations. + * Returns ClaimProvenance[] (contracts Rev 3) for the article wire format. + * + * 2. TITLE-TOKEN (Rev 2, legacy) — fallback when no facts are available (rev-2 + * aggregator). Matches claim tokens against source title + domain vocabulary. + * Preserved exactly from the prior implementation. + * + * The gate is fail-closed: articles with ≥1 ungrounded factual sentence are + * either re-asked (one bounded attempt) or HELD — never published flat. + */ +import type { SourceRef, ExtractedFact, ClaimProvenance } from './contracts.ts'; +export interface ClaimInput { + text: string; + blockIndex: number; + isEditorial: boolean; +} +export interface FactProvenanceResult { + claims: ClaimProvenance[]; + ungroundedClaims: ClaimInput[]; + isGrounded: boolean; +} +/** + * Build fact-grounded provenance. Primary gate mode (S3). + * + * For each factual claim sentence: + * 1. Collect [FACT:id] inline citations. + * 2. Validate each cited ID exists in the provided facts. + * 3. Backstop: if no valid inline citations, check entity/number overlap. + * 4. Claims with zero supporting facts are ungrounded. + */ +export declare function buildProvenanceFromFacts(articleId: string, claims: readonly ClaimInput[], facts: readonly ExtractedFact[]): FactProvenanceResult; +/** How strongly the cited sources back a claim (legacy mode). */ +export type SupportStrength = 'corroborated' | 'single-source' | 'inferred'; +/** One atomic, checkable assertion (legacy internal format). */ +export interface LegacyClaimProvenance { + id: string; + claim: string; + blockIndex: number; + supportingSourceIds: string[]; + strength: SupportStrength; + isEditorial: boolean; +} +/** Article-wide provenance roll-up (legacy mode), keyed by claim id. */ +export interface ProvenanceMap { + claims: LegacyClaimProvenance[]; + citedSources: SourceRef[]; + unsupportedClaimCount: number; +} +/** + * Legacy title-token provenance gate. Used when no ExtractedFacts are available + * (rev-2 aggregator). Matches claim token vocabulary against source titles. + */ +export declare function buildProvenance(articleId: string, claims: readonly { + text: string; + blockIndex: number; + isEditorial: boolean; +}[], sources: readonly SourceRef[]): ProvenanceMap; +/** True iff every factual claim has >= 1 supporting source (legacy gate). */ +export declare function isFullyGrounded(map: ProvenanceMap): boolean; diff --git a/dist/provenance.js b/dist/provenance.js new file mode 100644 index 0000000..31e4ccd --- /dev/null +++ b/dist/provenance.js @@ -0,0 +1,198 @@ +/** + * Provenance — every generated claim is traceable to the sources that support it. + * + * Two gate modes: + * + * 1. FACT-GROUNDED (Rev 3, S3) — primary mode when ExtractedFact[] are available. + * Claim sentences are mapped to fact IDs via: + * a) Inline [FACT:id] citations the LLM embedded in its output. + * b) Entity/number overlap as a backstop against bad citations. + * Returns ClaimProvenance[] (contracts Rev 3) for the article wire format. + * + * 2. TITLE-TOKEN (Rev 2, legacy) — fallback when no facts are available (rev-2 + * aggregator). Matches claim tokens against source title + domain vocabulary. + * Preserved exactly from the prior implementation. + * + * The gate is fail-closed: articles with ≥1 ungrounded factual sentence are + * either re-asked (one bounded attempt) or HELD — never published flat. + */ +/** + * Extract inline [FACT:id] citations from a claim sentence. + * Returns the set of fact IDs cited. + */ +function extractInlineCitations(text) { + const ids = new Set(); + for (const match of text.matchAll(/\[FACT:([^\]]+)\]/g)) { + const id = match[1]?.trim(); + if (id) + ids.add(id); + } + return ids; +} +/** Meaningful content tokens (stop words removed, min length 3). */ +const STOP_WORDS_FACT = new Set([ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', + 'to', 'of', 'and', 'in', 'for', 'on', 'with', 'that', 'this', 'from', + 'it', 'its', 'at', 'by', 'or', 'but', 'as', 'has', 'have', 'had', + 'not', 'all', 'will', 'can', 'may', 'could', 'would', 'should', +]); +function contentTokensFact(text) { + return new Set(text + .toLowerCase() + .replace(/\[FACT:[^\]]+\]/g, ' ') // strip citations before tokenizing + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((w) => w.length >= 3 && !STOP_WORDS_FACT.has(w))); +} +/** + * Build fact-grounded provenance. Primary gate mode (S3). + * + * For each factual claim sentence: + * 1. Collect [FACT:id] inline citations. + * 2. Validate each cited ID exists in the provided facts. + * 3. Backstop: if no valid inline citations, check entity/number overlap. + * 4. Claims with zero supporting facts are ungrounded. + */ +export function buildProvenanceFromFacts(articleId, claims, facts) { + const factById = new Map(facts.map((f) => [f.id, f])); + const resultClaims = []; + const ungroundedClaims = []; + for (const claim of claims) { + if (claim.isEditorial) { + resultClaims.push({ + blockIndex: claim.blockIndex, + text: claim.text, + isEditorial: true, + factIds: [], + corroboration: 0, + confidence: 'high', + }); + continue; + } + // Step 1: inline citations + const citedIds = extractInlineCitations(claim.text); + const validIds = [...citedIds].filter((id) => factById.has(id)); + let supportingFactIds = validIds; + // Step 2: backstop overlap when no valid inline citations. + // #20 (CWE-345): pure lexical overlap is insufficient — require at least one + // named entity from the fact to appear in the claim, plus a raised token + // threshold, so topic-vocabulary coincidence cannot fabricate support. + if (supportingFactIds.length === 0 && facts.length > 0) { + const claimTokens = contentTokensFact(claim.text); + const threshold = Math.max(3, Math.ceil(claimTokens.size * 0.35)); + for (const fact of facts) { + // Entity gate: the claim must mention at least one of the fact's named entities. + const factEntityTokens = contentTokensFact(fact.entities.join(' ')); + const hasEntityOverlap = [...factEntityTokens].some((t) => claimTokens.has(t)); + if (!hasEntityOverlap) + continue; + const factTokens = contentTokensFact(`${fact.statement} ${fact.entities.join(' ')} ${fact.quantity?.metric ?? ''}`); + const matchCount = [...factTokens].filter((t) => claimTokens.has(t)).length; + if (matchCount >= threshold) { + supportingFactIds.push(fact.id); + } + } + } + if (supportingFactIds.length === 0) { + ungroundedClaims.push(claim); + } + // Compute corroboration from the supporting facts + const corrobDomains = new Set(); + for (const id of supportingFactIds) { + const fact = factById.get(id); + if (fact) { + for (const p of fact.provenance) + corrobDomains.add(p.sourceDomain); + } + } + const confidence = supportingFactIds.length === 0 + ? 'low' + : corrobDomains.size >= 2 ? 'high' : 'medium'; + resultClaims.push({ + blockIndex: claim.blockIndex, + text: claim.text, + isEditorial: false, + factIds: supportingFactIds, + corroboration: corrobDomains.size, + confidence, + }); + } + const isGrounded = ungroundedClaims.length === 0; + return { claims: resultClaims, ungroundedClaims, isGrounded }; +} +/** Derive a stable id for a SourceRef. */ +function sourceRefId(ref) { + return `${ref.sourceDomain}::${encodeURIComponent(ref.title)}`; +} +const STOP_WORDS = new Set([ + 'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being', + 'to', 'of', 'and', 'in', 'for', 'on', 'with', 'that', 'this', 'from', + 'it', 'its', 'at', 'by', 'or', 'but', 'as', 'has', 'have', 'had', + 'not', 'all', 'will', 'can', 'may', 'could', 'would', 'should', +]); +function contentTokens(text) { + return text + .toLowerCase() + .replace(/[^a-z0-9\s]/g, ' ') + .split(/\s+/) + .filter((w) => w.length >= 3 && !STOP_WORDS.has(w)); +} +/** + * Legacy title-token provenance gate. Used when no ExtractedFacts are available + * (rev-2 aggregator). Matches claim token vocabulary against source titles. + */ +export function buildProvenance(articleId, claims, sources) { + const citedSourceIdSet = new Set(); + const resultClaims = []; + for (let i = 0; i < claims.length; i++) { + const claim = claims[i]; + if (!claim) + continue; + const supportingSourceIds = []; + if (!claim.isEditorial) { + const claimTokens = new Set(contentTokens(claim.text)); + const threshold = Math.max(2, Math.ceil(claimTokens.size * 0.25)); + for (const source of sources) { + const srcTokens = contentTokens(`${source.title} ${source.source} ${source.sourceDomain}`); + const matchCount = new Set(srcTokens.filter((t) => claimTokens.has(t))).size; + if (matchCount >= threshold) { + const sid = sourceRefId(source); + supportingSourceIds.push(sid); + citedSourceIdSet.add(sid); + } + } + } + let strength; + if (claim.isEditorial) { + strength = 'inferred'; + } + else if (supportingSourceIds.length >= 2) { + strength = 'corroborated'; + } + else if (supportingSourceIds.length === 1) { + strength = 'single-source'; + } + else { + strength = 'inferred'; + } + resultClaims.push({ + id: `${articleId}#c${String(i).padStart(2, '0')}`, + claim: claim.text, + blockIndex: claim.blockIndex, + supportingSourceIds, + strength, + isEditorial: claim.isEditorial, + }); + } + const citedSources = sources.filter((s) => citedSourceIdSet.has(sourceRefId(s))); + const unsupportedClaimCount = resultClaims.filter((c) => !c.isEditorial && c.supportingSourceIds.length === 0).length; + return { + claims: resultClaims, + citedSources: [...citedSources], + unsupportedClaimCount, + }; +} +/** True iff every factual claim has >= 1 supporting source (legacy gate). */ +export function isFullyGrounded(map) { + return map.unsupportedClaimCount === 0; +} diff --git a/dist/provider.d.ts b/dist/provider.d.ts new file mode 100644 index 0000000..bea1274 --- /dev/null +++ b/dist/provider.d.ts @@ -0,0 +1,151 @@ +/** + * Pluggable, cost-guarded AI provider. + * + * Extracted and generalized from `ardur.ai/main:src/lib/aiProvider.mjs` + * (`generateSignalBrief`). + * + * Provider order: deterministic (default, zero-cost) -> ollama (local-first, + * cloud only if OLLAMA_API_KEY set) -> openai (optional). + * + * `ARDUR_AI_ENABLED=0` or `ARDUR_AI_PROVIDER=deterministic` forces the + * deterministic path with NO network calls (this is the CI default). + * + * Every model call is wrapped in a timeout; any failure (non-JSON, HTTP error, + * timeout, budget exhausted) falls back to deterministic output and records the + * reason in `ProviderMeta.reason`. + * + * A per-run generation budget (`ARDUR_AI_MAX_GENERATIONS`) caps model calls; + * once spent, every remaining article is deterministic. + * + * VOICE: both paths share the Ardur house voice ("GenZ-but-professional"). + * The LLM path receives `GenerateRequest.voiceDirective` in its prompt; + * the deterministic path applies the same VOICE_STYLE to its templates so a + * budget=0 article reads on-voice — not as dry newswire. + */ +import { z } from 'zod'; +import type { ProviderMeta, Confidence, SourceRef, ExtractedFact } from './contracts.ts'; +export type ProviderName = 'deterministic' | 'ollama' | 'openai'; +/** Inputs the provider is allowed to see — metadata only, never article bodies. */ +export interface GenerateRequest { + topic: string; + topicLabel: string; + /** The representative cluster headline (factual title, may be quoted as a name). */ + headline: string; + /** Copyright-safe references: title + source + link + date. NO body text. */ + references: SourceRef[]; + /** + * S1/S3 — Rev 3: structured facts extracted from source bodies. + * When present, these are the PRIMARY synthesis input; the LLM writes the + * article FROM the facts, citing `[FACT:id]` inline. When absent (rev-2 + * aggregator), the model falls back to reference-metadata-only synthesis. + */ + facts?: ExtractedFact[]; + /** Deterministic draft used as grounding context in the prompt (never published). */ + fallback: ArticleDraft; + /** + * The Ardur house-voice directive (from `style.ts:buildVoiceDirective`), + * threaded verbatim into the model prompt. + */ + voiceDirective: string; + /** + * S3 — When set, the model is asked to re-ground or drop these specific + * ungrounded claim sentences (one bounded re-ask before HOLD). + */ + reaskClaims?: string[]; +} +/** The structured draft a provider returns. Validated against ARTICLE_SCHEMA. */ +export interface ArticleDraft { + headline: string; + dek: string; + /** Ordered section bodies keyed by canonical section id (see assemble.ts). */ + sections: Record; + keyPoints: string[]; + whyItMatters: string; + readerAction: string; + confidence: Confidence; + tags: string[]; +} +export interface GenerateResult { + draft: ArticleDraft; + meta: ProviderMeta; +} +/** + * JSON-schema the model output is validated against (strict for OpenAI, repaired + * for Ollama). Mirrors `SIGNAL_BRIEF_SCHEMA` but for the article shape. Any field + * that fails validation is replaced from the deterministic `fallback`. + */ +export declare const ARTICLE_SCHEMA: { + readonly type: "object"; + readonly additionalProperties: false; + readonly required: readonly ["headline", "dek", "sections", "keyPoints", "whyItMatters", "readerAction", "confidence", "tags"]; +}; +/** + * Zod schema for LLM-generated ArticleDraft (#22). Used in parseAndMergeDraft as + * a first-pass structural validator before the field-by-field merge fallback. + * passthrough() preserves any extra provider-specific fields without error. + */ +export declare const ArticleDraftSchema: z.ZodObject<{ + headline: z.ZodString; + dek: z.ZodString; + sections: z.ZodRecord; + keyPoints: z.ZodArray; + whyItMatters: z.ZodString; + readerAction: z.ZodString; + confidence: z.ZodEnum<["high", "medium", "low"]>; + tags: z.ZodArray; +}, "passthrough", z.ZodTypeAny, z.objectOutputType<{ + headline: z.ZodString; + dek: z.ZodString; + sections: z.ZodRecord; + keyPoints: z.ZodArray; + whyItMatters: z.ZodString; + readerAction: z.ZodString; + confidence: z.ZodEnum<["high", "medium", "low"]>; + tags: z.ZodArray; +}, z.ZodTypeAny, "passthrough">, z.objectInputType<{ + headline: z.ZodString; + dek: z.ZodString; + sections: z.ZodRecord; + keyPoints: z.ZodArray; + whyItMatters: z.ZodString; + readerAction: z.ZodString; + confidence: z.ZodEnum<["high", "medium", "low"]>; + tags: z.ZodArray; +}, z.ZodTypeAny, "passthrough">>; +/** + * A provider generates one article draft from metadata. Implementations: + * - `DeterministicProvider` — rules over metadata, zero cost, always succeeds. + * - `OllamaProvider` — local-first; cloud iff OLLAMA_API_KEY. + * - `OpenAiProvider` — optional API, strict json_schema. + */ +export interface AiProvider { + readonly name: ProviderName; + /** True if a model call is permitted right now (budget remaining, enabled). */ + canGenerate(): boolean; + /** Generate a draft. MUST resolve (never reject) — failures return fallback. */ + generate(request: GenerateRequest): Promise; + /** Model calls spent this run (for warnings + run report). */ + generationsUsed(): number; +} +export interface ProviderConfig { + provider?: ProviderName; + enabled?: boolean; + maxGenerations?: number; + timeoutMs?: number; + env?: NodeJS.ProcessEnv; + /** Injected wall-clock instant; threads into all providers so generatedAt is deterministic under replay. */ + now?: Date; +} +/** Build a complete on-voice ArticleDraft from metadata alone. No network. */ +export declare function buildDeterministicDraft(request: Omit, confidence?: Confidence): ArticleDraft; +/** + * Parse model JSON output, falling back field-by-field from the deterministic draft. + * #22: first try Zod structural validation; if that passes the whole object is used. + * On Zod failure, merge field-by-field so partial-valid output still contributes. + */ +export declare function parseAndMergeDraft(raw: string, fallback: ArticleDraft): ArticleDraft; +/** + * Resolve the provider chain from env/config. Defaults to the deterministic, + * zero-cost provider — matching ardur.ai's existing `budget=0` posture. + */ +export declare function createProvider(config?: ProviderConfig): AiProvider; diff --git a/dist/provider.js b/dist/provider.js new file mode 100644 index 0000000..8471c50 --- /dev/null +++ b/dist/provider.js @@ -0,0 +1,634 @@ +/** + * Pluggable, cost-guarded AI provider. + * + * Extracted and generalized from `ardur.ai/main:src/lib/aiProvider.mjs` + * (`generateSignalBrief`). + * + * Provider order: deterministic (default, zero-cost) -> ollama (local-first, + * cloud only if OLLAMA_API_KEY set) -> openai (optional). + * + * `ARDUR_AI_ENABLED=0` or `ARDUR_AI_PROVIDER=deterministic` forces the + * deterministic path with NO network calls (this is the CI default). + * + * Every model call is wrapped in a timeout; any failure (non-JSON, HTTP error, + * timeout, budget exhausted) falls back to deterministic output and records the + * reason in `ProviderMeta.reason`. + * + * A per-run generation budget (`ARDUR_AI_MAX_GENERATIONS`) caps model calls; + * once spent, every remaining article is deterministic. + * + * VOICE: both paths share the Ardur house voice ("GenZ-but-professional"). + * The LLM path receives `GenerateRequest.voiceDirective` in its prompt; + * the deterministic path applies the same VOICE_STYLE to its templates so a + * budget=0 article reads on-voice — not as dry newswire. + */ +import { z } from 'zod'; +import { VOICE_STYLE } from "./style.js"; +/** + * JSON-schema the model output is validated against (strict for OpenAI, repaired + * for Ollama). Mirrors `SIGNAL_BRIEF_SCHEMA` but for the article shape. Any field + * that fails validation is replaced from the deterministic `fallback`. + */ +export const ARTICLE_SCHEMA = { + type: 'object', + additionalProperties: false, + required: ['headline', 'dek', 'sections', 'keyPoints', 'whyItMatters', 'readerAction', 'confidence', 'tags'], +}; +/** + * Zod schema for LLM-generated ArticleDraft (#22). Used in parseAndMergeDraft as + * a first-pass structural validator before the field-by-field merge fallback. + * passthrough() preserves any extra provider-specific fields without error. + */ +export const ArticleDraftSchema = z.object({ + headline: z.string().min(1), + dek: z.string().min(1), + sections: z.record(z.string()), + keyPoints: z.array(z.string()), + whyItMatters: z.string().min(1), + readerAction: z.string().min(1), + confidence: z.enum(['high', 'medium', 'low']), + tags: z.array(z.string()), +}).passthrough(); +// --------------------------------------------------------------------------- +// Deterministic provider — rules-based, zero cost, always succeeds +// --------------------------------------------------------------------------- +/** Active verbs for GenZ-but-professional openers. */ +const ACTIVE_VERBS = { + primary: ['shipped', 'released', 'published', 'announced', 'dropped'], + paper: ['published', 'released', 'put out', 'posted'], + 'technical-news': ['covered', 'reported on', 'broke down'], + news: ['reported', 'confirmed', 'covered'], + 'security-news': ['flagged', 'disclosed', 'reported'], +}; +function pickVerb(tier) { + const verbs = ACTIVE_VERBS[tier] ?? ACTIVE_VERBS['news'] ?? ['released']; + // Stable pick based on first char of tier (deterministic, no random) + return verbs[tier.charCodeAt(0) % verbs.length] ?? 'released'; +} +function formatDate(iso) { + try { + const d = new Date(iso); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + } + catch { + return iso.slice(0, 10); + } +} +function topTier(refs) { + const order = ['primary', 'paper', 'technical-news', 'security-news', 'news']; + for (const tier of order) { + if (refs.some((r) => r.tier === tier)) + return tier; + } + return 'news'; +} +function sourceList(refs, max = 3) { + const names = [...new Set(refs.map((r) => r.source))].slice(0, max); + if (names.length === 1) + return names[0] ?? ''; + if (names.length === 2) + return `${names[0] ?? ''} and ${names[1] ?? ''}`; + const last = names[names.length - 1]; + return `${names.slice(0, -1).join(', ')}, and ${last ?? ''}`; +} +/** Strip the existing headline and rewrite in active voice. */ +function rewriteHeadline(original, topSource) { + // If already starts with a source name, keep it; otherwise prepend an Ardur voice rewrite + const src = topSource.source; + if (original.toLowerCase().startsWith(src.toLowerCase())) + return original; + const verb = pickVerb(topSource.tier); + // "Source just shipped X" or keep the headline as-is for clarity + return `${src} ${verb}: ${original}`; +} +function buildDek(topicLabel, refs, isCorroborated) { + const sourceCount = new Set(refs.map((r) => r.sourceDomain)).size; + const hedge = isCorroborated + ? `Corroborated across ${sourceCount} sources` + : `From ${refs[0]?.source ?? 'one source'}`; + return `${hedge} — here's what actually changed and why it matters for ${topicLabel}.`; +} +function buildKeyTakeaway(headline, refs, isCorroborated) { + const top = refs[0]; + if (!top) + return headline; + const verb = pickVerb(topTier(refs)); + const when = formatDate(top.publishedAt); + const sources = sourceList(refs, 2); + const hedge = isCorroborated ? '' : ` According to ${top.source}, `; + return `${top.source} ${verb} this on ${when}${hedge ? '' : `, and ${sources} ${isCorroborated ? 'both' : ''} covered it`}. ${headline}. Here's what actually changed and why your work might be affected.`; +} +function buildWhyThisMatters(topicLabel, refs, isCorroborated) { + const primaryRefs = refs.filter((r) => r.tier === 'primary' || r.tier === 'paper'); + const techRefs = refs.filter((r) => r.tier === 'technical-news'); + const sourceParts = []; + const firstPrimary = primaryRefs[0]; + const firstTech = techRefs[0]; + if (firstPrimary) + sourceParts.push(`primary sources including ${firstPrimary.source}`); + if (firstTech) + sourceParts.push(`technical coverage from ${firstTech.source}`); + const sourceStr = sourceParts.length > 0 + ? `Based on ${sourceParts.join(' and ')}, ` + : (isCorroborated ? `Across multiple reports, ` : `According to ${refs[0]?.source ?? 'reports'}, `); + return `${sourceStr}this development carries real weight for practitioners in ${topicLabel}. ${isCorroborated ? 'Multiple sources confirm the key details, which strengthens the case.' : 'It comes from a single source, so treat specifics as early-stage until more coverage appears.'} The implications are worth tracking if you work anywhere near this space.`; +} +function buildWhatHappened(headline, refs, isCorroborated) { + const primaryRefs = refs.filter((r) => r.tier === 'primary' || r.tier === 'paper'); + const topRef = primaryRefs[0] ?? refs[0]; + if (!topRef) + return headline; + const verb = pickVerb(topRef.tier); + const when = formatDate(topRef.publishedAt); + const sourceCount = new Set(refs.map((r) => r.sourceDomain)).size; + const recentRefs = refs + .slice(0, 3) + .map((r) => r.title) + .filter(Boolean); + let body = `${topRef.source} ${verb} this on ${when}. `; + body += isCorroborated + ? `Coverage spans ${sourceCount} distinct sources. ` + : `Coverage comes from ${topRef.source} at this stage. `; + const ref0 = recentRefs[0]; + const ref1 = recentRefs[1]; + if (recentRefs.length >= 2 && ref0) { + body += `Reported aspects include: "${ref0}"`; + if (ref1) + body += ` and "${ref1}"`; + body += '. '; + } + body += isCorroborated + ? 'Core facts are consistent across outlets.' + : 'Single-source — independent confirmation pending.'; + return body; +} +function buildBuilderView(topicLabel, refs) { + const techRef = refs.find((r) => r.tier === 'technical-news') ?? refs[0]; + if (!techRef) + return `Practitioners in ${topicLabel} should keep an eye on this.`; + return `For builders working in ${topicLabel}: the practical angle here is whether this changes your current setup or tooling. ${techRef.source} covered the technical side — worth a read if you're hands-on with this stack. The core question is whether this is a "note it and move on" or a "test this soon" situation. Based on the available reporting, it leans toward the latter.`; +} +function buildOpenQuestions(refs, isCorroborated) { + const questions = []; + if (!isCorroborated) + questions.push('independent confirmation from additional outlets'); + questions.push('performance details under real-world conditions'); + questions.push('timeline for broader availability or rollout'); + if (refs.some((r) => r.tier === 'paper')) { + questions.push('peer review or replication of any claimed results'); + } + return `A few things still need confirmation: ${questions.join('; ')}. ${isCorroborated ? 'The corroborated reporting is a good sign, but these gaps are worth tracking.' : 'With single-source coverage, treat specifics as preliminary until corroborated.'}`; +} +function buildArdurTake(headline, refs, confidence, isCorroborated) { + const confidencePhrase = { + high: 'Confidence is high', + medium: 'Confidence is moderate', + low: 'Confidence is low', + }[confidence]; + const sourceCount = new Set(refs.map((r) => r.sourceDomain)).size; + return `${isCorroborated ? `${sourceCount} sources point the same direction on this` : `One source for now`} — ${headline.toLowerCase().replace(/[.!?]$/, '')}. ${confidencePhrase} based on the source coverage. ${isCorroborated ? 'Worth acting on if this falls in your domain.' : 'Wait for corroboration before making significant changes based on this alone.'}`; +} +function buildKeyPoints(topicLabel, refs, isCorroborated) { + const points = []; + const topRef = refs[0]; + if (topRef) { + const verb = pickVerb(topRef.tier); + points.push(`${topRef.source} ${verb} a notable development in ${topicLabel}`); + } + if (isCorroborated) { + const count = new Set(refs.map((r) => r.sourceDomain)).size; + points.push(`Covered by ${count} distinct sources — core facts are consistent`); + } + else { + points.push(`Single-source at this stage — independent coverage pending`); + } + const latestDate = refs.reduce((latest, r) => r.publishedAt > latest ? r.publishedAt : latest, refs[0]?.publishedAt ?? ''); + if (latestDate) { + points.push(`Latest reporting from ${formatDate(latestDate)}`); + } + return points; +} +function buildWhyItMattersLine(topicLabel, refs) { + const tech = refs.find((r) => r.tier === 'technical-news' || r.tier === 'primary'); + const src = tech?.source ?? refs[0]?.source ?? 'reporting'; + return `This matters for ${topicLabel} practitioners — ${src} covered the implications worth tracking.`; +} +function buildReaderAction(topicLabel, refs) { + const topRef = refs.find((r) => r.tier === 'primary' || r.tier === 'paper') ?? refs[0]; + if (!topRef) + return `Review the latest developments in ${topicLabel}.`; + return `Check ${topRef.source}'s coverage directly — the canonical source has the full picture.`; +} +function buildTags(topicLabel, refs) { + const tags = new Set([topicLabel.toLowerCase().replace(/\s+/g, '-')]); + for (const ref of refs.slice(0, 5)) { + const domainPart = ref.sourceDomain.replace('www.', '').split('.')[0]; + if (domainPart) + tags.add(domainPart); + } + return [...tags].slice(0, 8); +} +/** Build a complete on-voice ArticleDraft from metadata alone. No network. */ +export function buildDeterministicDraft(request, confidence = 'medium') { + const { topic, topicLabel, headline, references } = request; + const isCorroborated = new Set(references.map((r) => r.sourceDomain)).size >= 2; + const topRef = references.find((r) => r.tier === 'primary' || r.tier === 'paper') ?? references[0]; + return { + headline: topRef ? rewriteHeadline(headline, topRef) : headline, + dek: buildDek(topicLabel, references, isCorroborated), + sections: { + 'key-takeaway': buildKeyTakeaway(headline, references, isCorroborated), + 'why-this-matters': buildWhyThisMatters(topicLabel, references, isCorroborated), + 'what-happened': buildWhatHappened(headline, references, isCorroborated), + 'builder-view': buildBuilderView(topicLabel, references), + 'open-questions': buildOpenQuestions(references, isCorroborated), + 'ardur-take': buildArdurTake(headline, references, confidence, isCorroborated), + }, + keyPoints: buildKeyPoints(topicLabel, references, isCorroborated), + whyItMatters: buildWhyItMattersLine(topicLabel, references), + readerAction: buildReaderAction(topicLabel, references), + confidence, + tags: buildTags(topicLabel, references), + }; +} +class DeterministicProvider { + name = 'deterministic'; + now; + constructor(now = new Date()) { + this.now = now; + } + canGenerate() { + return true; // deterministic is always available + } + async generate(request) { + return { + draft: request.fallback, + meta: { + provider: 'deterministic', + model: 'rules/v1', + status: 'fallback', + reason: 'deterministic provider selected', + generatedAt: this.now.toISOString(), + }, + }; + } + generationsUsed() { + return 0; // deterministic uses no budget + } +} +// --------------------------------------------------------------------------- +// Ollama provider — local-first +// --------------------------------------------------------------------------- +class OllamaProvider { + name = 'ollama'; + _used = 0; + maxGenerations; + timeoutMs; + model; + baseUrl; + now; + constructor(opts) { + this.maxGenerations = opts.maxGenerations; + this.timeoutMs = opts.timeoutMs; + this.model = opts.model ?? 'llama3.1'; + this.baseUrl = opts.baseUrl ?? 'http://127.0.0.1:11434'; + this.now = opts.now ?? new Date(); + } + canGenerate() { + return this._used < this.maxGenerations; + } + generationsUsed() { + return this._used; + } + async generate(request) { + const fallbackMeta = { + provider: 'deterministic', + model: 'rules/v1', + status: 'fallback', + generatedAt: this.now.toISOString(), + }; + if (!this.canGenerate()) { + return { draft: request.fallback, meta: { ...fallbackMeta, reason: 'budget exhausted' } }; + } + const prompt = buildOllamaPrompt(request); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const resp = await fetch(`${this.baseUrl}/api/generate`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: this.model, prompt, format: 'json', stream: false }), + signal: controller.signal, + }); + clearTimeout(timer); + if (!resp.ok) { + return { + draft: request.fallback, + meta: { ...fallbackMeta, reason: `ollama HTTP ${resp.status}` }, + }; + } + const raw = await resp.json(); + const parsed = parseAndMergeDraft(raw.response ?? '', request.fallback); + this._used++; + return { + draft: parsed, + meta: { + provider: 'ollama', + model: this.model, + status: 'generated', + generatedAt: this.now.toISOString(), + }, + }; + } + catch (err) { + clearTimeout(timer); + const reason = err instanceof Error + ? (err.name === 'AbortError' ? 'timeout' : err.message) + : 'unknown error'; + return { draft: request.fallback, meta: { ...fallbackMeta, reason } }; + } + } +} +// --------------------------------------------------------------------------- +// OpenAI provider +// --------------------------------------------------------------------------- +class OpenAiProvider { + name = 'openai'; + _used = 0; + maxGenerations; + timeoutMs; + model; + apiKey; + now; + constructor(opts) { + this.maxGenerations = opts.maxGenerations; + this.timeoutMs = opts.timeoutMs; + this.apiKey = opts.apiKey; + this.model = opts.model ?? 'gpt-4o-mini'; + this.now = opts.now ?? new Date(); + } + canGenerate() { + return this._used < this.maxGenerations; + } + generationsUsed() { + return this._used; + } + async generate(request) { + const fallbackMeta = { + provider: 'deterministic', + model: 'rules/v1', + status: 'fallback', + generatedAt: this.now.toISOString(), + }; + if (!this.canGenerate()) { + return { draft: request.fallback, meta: { ...fallbackMeta, reason: 'budget exhausted' } }; + } + const hasFacts = (request.facts?.length ?? 0) > 0; + const systemPrompt = [ + `You are the Ardur article synthesizer. Write one original article draft as JSON in the Ardur house voice.`, + ``, + request.voiceDirective, + ``, + `RULES:`, + `- Original prose only — never copy or paraphrase source sentences.`, + ...(hasFacts + ? [`- Write FROM the provided ExtractedFacts; cite every factual sentence with [FACT:id].`] + : [`- Metadata (titles, sources, dates) only — no article bodies are provided.`]), + `- Output strict JSON: {headline, dek, sections:{key-takeaway,why-this-matters,what-happened,builder-view,open-questions,ardur-take}, keyPoints:string[], whyItMatters, readerAction, confidence:"high"|"medium"|"low", tags:string[]}`, + ].join('\\n'); + const userPrompt = buildUserPrompt(request); + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.timeoutMs); + try { + const resp = await fetch('https://api.openai.com/v1/chat/completions', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + response_format: { type: 'json_object' }, + temperature: 0.7, + }), + signal: controller.signal, + }); + clearTimeout(timer); + if (!resp.ok) { + return { + draft: request.fallback, + meta: { ...fallbackMeta, reason: `openai HTTP ${resp.status}` }, + }; + } + const data = await resp.json(); + const content = data.choices?.[0]?.message?.content ?? ''; + const parsed = parseAndMergeDraft(content, request.fallback); + this._used++; + return { + draft: parsed, + meta: { + provider: 'openai', + model: this.model, + status: 'generated', + generatedAt: this.now.toISOString(), + }, + }; + } + catch (err) { + clearTimeout(timer); + const reason = err instanceof Error + ? (err.name === 'AbortError' ? 'timeout' : err.message) + : 'unknown error'; + return { draft: request.fallback, meta: { ...fallbackMeta, reason } }; + } + } +} +// --------------------------------------------------------------------------- +// Shared prompt builders +// --------------------------------------------------------------------------- +function buildFactLines(facts, max = 20) { + if (facts.length === 0) + return []; + const lines = [`EXTRACTED FACTS (PRIMARY SOURCE — write FROM these, cite [FACT:id] inline):`]; + for (const f of facts.slice(0, max)) { + const qty = f.quantity + ? ` [${f.quantity.metric}: ${f.quantity.value}${f.quantity.unit ? ' ' + f.quantity.unit : ''}${f.quantity.asOf ? ' as of ' + f.quantity.asOf : ''}]` + : ''; + const corr = f.corroboration >= 2 ? ` (corroborated: ${f.corroboration} sources)` : ` (single-source)`; + lines.push(`[FACT:${f.id}] ${f.statement}${qty}${corr}`); + } + return lines; +} +function buildOllamaPrompt(request) { + const refLines = request.references.slice(0, 10).map((r, i) => `${i + 1}. [${r.tier}] "${r.title}" — ${r.source} (${r.publishedAt.slice(0, 10)}) ${r.url}`); + const factLines = buildFactLines(request.facts ?? []); + const reaskSection = request.reaskClaims && request.reaskClaims.length > 0 + ? [ + '', + 'REGROUND OR DROP — these sentences have no [FACT:id] citation. For each:', + ' a) Add a [FACT:id] citation if a provided fact supports it.', + ' b) Remove the sentence entirely if it cannot be grounded.', + ...request.reaskClaims.map((c, i) => ` ${i + 1}. "${c}"`), + ] + : []; + const hasFacts = factLines.length > 0; + return [ + `Write one original Ardur article as JSON.`, + '', + `VOICE DIRECTIVE:`, + request.voiceDirective, + '', + `TOPIC: ${request.topicLabel}`, + `HEADLINE HINT: ${request.headline}`, + '', + ...(hasFacts ? factLines : [`SOURCES (metadata only — write ORIGINAL prose, do NOT copy):`, ...refLines]), + ...(hasFacts ? ['', 'ATTRIBUTION SOURCES (for reference links only):', ...refLines] : []), + ...reaskSection, + '', + `RULES:`, + `- Write original prose ONLY — never copy or paraphrase source sentences.`, + ...(hasFacts ? [`- Every factual sentence MUST cite ≥1 [FACT:id] inline.`, `- Single-source facts are allowed but will be tagged confidence:low.`] : []), + `- Output strict JSON: {"headline":"...","dek":"...","sections":{"key-takeaway":"...","why-this-matters":"...","what-happened":"...","builder-view":"...","open-questions":"...","ardur-take":"..."},"keyPoints":["..."],"whyItMatters":"...","readerAction":"...","confidence":"high|medium|low","tags":["..."]}`, + '', + `Context draft (structure reference — do NOT copy text):`, + JSON.stringify(request.fallback, null, 2), + ].join('\n'); +} +function buildUserPrompt(request) { + const refLines = request.references.slice(0, 15).map((r, i) => `${i + 1}. [${r.tier}] "${r.title}" (${r.source}, ${r.publishedAt.slice(0, 10)})`); + const factLines = buildFactLines(request.facts ?? [], 20); + const hasFacts = factLines.length > 0; + const reaskSection = request.reaskClaims && request.reaskClaims.length > 0 + ? [ + '', + 'REGROUND OR DROP:', + ...request.reaskClaims.map((c, i) => ` ${i + 1}. "${c}"`), + ] + : []; + return [ + `TOPIC: ${request.topicLabel}`, + `HEADLINE HINT: ${request.headline}`, + '', + ...(hasFacts ? factLines : [`SOURCES (${request.references.length} total):`, ...refLines]), + ...(hasFacts ? ['', `ATTRIBUTION SOURCES:`, ...refLines] : []), + ...reaskSection, + '', + `Write an original Ardur article${hasFacts ? ', grounding every factual sentence with [FACT:id] citations' : ''}. Output valid JSON.`, + ].join('\n'); +} +/** + * Parse model JSON output, falling back field-by-field from the deterministic draft. + * #22: first try Zod structural validation; if that passes the whole object is used. + * On Zod failure, merge field-by-field so partial-valid output still contributes. + */ +export function parseAndMergeDraft(raw, fallback) { + let parsed; + try { + parsed = JSON.parse(raw); + } + catch { + try { + const repaired = repairJsonSync(raw); + parsed = JSON.parse(repaired); + } + catch { + return fallback; + } + } + // Zod fast-path: if the whole object is structurally valid, accept it directly. + const zodResult = ArticleDraftSchema.safeParse(parsed); + if (zodResult.success) { + return zodResult.data; + } + // Field-by-field merge: use parsed value per field only when type-safe. + const partial = parsed; + const VALID_CONFIDENCE = ['high', 'medium', 'low']; + return { + headline: typeof partial.headline === 'string' && partial.headline.trim() ? partial.headline : fallback.headline, + dek: typeof partial.dek === 'string' && partial.dek.trim() ? partial.dek : fallback.dek, + sections: mergeSections(partial.sections, fallback.sections), + keyPoints: Array.isArray(partial.keyPoints) && partial.keyPoints.length > 0 + ? partial.keyPoints.filter((k) => typeof k === 'string') + : fallback.keyPoints, + whyItMatters: typeof partial.whyItMatters === 'string' && partial.whyItMatters.trim() + ? partial.whyItMatters + : fallback.whyItMatters, + readerAction: typeof partial.readerAction === 'string' && partial.readerAction.trim() + ? partial.readerAction + : fallback.readerAction, + confidence: VALID_CONFIDENCE.includes(partial.confidence) + ? partial.confidence + : fallback.confidence, + tags: Array.isArray(partial.tags) && partial.tags.length > 0 + ? partial.tags.filter((t) => typeof t === 'string') + : fallback.tags, + }; +} +function mergeSections(parsed, fallback) { + if (!parsed || typeof parsed !== 'object') + return fallback; + const result = { ...fallback }; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value === 'string' && value.trim()) { + result[key] = value; + } + } + return result; +} +/** Best-effort JSON repair without the external package (fallback). */ +function repairJsonSync(raw) { + // Try to extract a JSON object from the string + const match = raw.match(/\{[\s\S]*\}/); + if (match) + return match[0]; + throw new Error('no JSON object found'); +} +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- +/** + * Resolve the provider chain from env/config. Defaults to the deterministic, + * zero-cost provider — matching ardur.ai's existing `budget=0` posture. + */ +export function createProvider(config = {}) { + const env = config.env ?? process.env; + const enabled = config.enabled ?? (env['ARDUR_AI_ENABLED'] !== '0'); + const providerName = config.provider ?? + env['ARDUR_AI_PROVIDER'] ?? + 'deterministic'; + const maxGenerations = config.maxGenerations + ?? parseInt(env['ARDUR_AI_MAX_GENERATIONS'] ?? '20', 10); + const timeoutMs = config.timeoutMs + ?? parseInt(env['ARDUR_AI_TIMEOUT_MS'] ?? '20000', 10); + const now = config.now; + // Helpers to avoid passing `now: undefined` under exactOptionalPropertyTypes. + const nowOpt = now !== undefined ? { now } : {}; + if (!enabled || providerName === 'deterministic') { + return new DeterministicProvider(now); + } + if (providerName === 'ollama') { + const apiKey = env['OLLAMA_API_KEY']; + const baseUrl = apiKey + ? (env['OLLAMA_API_BASE'] ?? 'https://api.ollama.ai') + : 'http://127.0.0.1:11434'; + return new OllamaProvider({ maxGenerations, timeoutMs, baseUrl, ...nowOpt }); + } + if (providerName === 'openai') { + const apiKey = env['OPENAI_API_KEY']; + if (!apiKey) { + return new DeterministicProvider(now); + } + return new OpenAiProvider({ + maxGenerations, + timeoutMs, + apiKey, + model: env['OPENAI_MODEL'] ?? 'gpt-4o-mini', + ...nowOpt, + }); + } + return new DeterministicProvider(now); +} diff --git a/dist/render.d.ts b/dist/render.d.ts new file mode 100644 index 0000000..d5e71ab --- /dev/null +++ b/dist/render.d.ts @@ -0,0 +1,56 @@ +/** + * In-app render contract — the article is read INSIDE ardur.ai with no + * navigation to another page. + * + * The app consumes `SynthesizedArticle` directly; there is no HTML page hop and + * no Markdown round-trip at read time. `body: ArticleBlock[]` is the render model. + * This module defines the contract the app and the synthesizer agree on, and a + * validator that fails any article the app could not safely render in place. + * + * Render order (matches `ArticleSourceTrail.astro` + content-engine contract): + * 1. headline (original) + * 2. dek (original standfirst) + * 3. meta strip: confidence · sourceQuality · readingTime · generatedAt + * 4. keyPoints (scannable) + * 5. body blocks (the SECTION_PLAN order) + * 6. "Why it matters" + "Reader action" callouts + * 7. SOURCE TRAIL block: numbered canonical links, kept SEPARATE from prose so + * readers can audit without cluttering the body (per ArticleSourceTrail). + * 8. legal/citation posture line. + */ +import type { ArticleBlock, SynthesizedArticle } from './contracts.ts'; +/** + * Allowed block types the in-app renderer knows how to draw. + * Rev 3 adds visual types: chart, image, gif, embed. + * Renderer rule: unknown type ⇒ skip (never throw). + */ +export declare const RENDERABLE_BLOCK_TYPES: readonly ArticleBlock['type'][]; +/** + * The render contract the synthesizer guarantees and the app relies on. + * Encoded as data so both sides can assert against the same object. + */ +export interface RenderContract { + /** No external navigation: every interactive element stays in-app. */ + inApp: true; + /** Body is a typed block array, not raw HTML/Markdown. */ + bodyModel: 'ArticleBlock[]'; + /** Source trail renders as a discrete block, never interleaved in prose. */ + sourceTrailSeparate: true; + /** Quotes render with visible attribution + canonical link. */ + quotesAttributed: true; + /** Max blocks before the renderer paginates/virtualizes in-app. */ + maxBlocks: number; +} +export declare const RENDER_CONTRACT: RenderContract; +export type RenderViolationKind = 'unknown-block-type' | 'quote-without-attribution' | 'raw-html-in-text' | 'empty-block' | 'too-many-blocks' | 'missing-source-trail' | 'chart-missing-attribution' | 'chart-no-data' | 'chart-invented-data' | 'media-missing-license' | 'embed-missing-provider'; +export interface RenderViolation { + kind: RenderViolationKind; + detail: string; + blockIndex?: number; +} +/** + * Validate that an article can be rendered in-app under RENDER_CONTRACT. + * Catches unknown block types, unattributed quotes, raw HTML smuggled into + * `text`, and a missing source trail. + */ +export declare function validateRenderable(article: SynthesizedArticle): RenderViolation[]; diff --git a/dist/render.js b/dist/render.js new file mode 100644 index 0000000..e823fb0 --- /dev/null +++ b/dist/render.js @@ -0,0 +1,138 @@ +/** + * In-app render contract — the article is read INSIDE ardur.ai with no + * navigation to another page. + * + * The app consumes `SynthesizedArticle` directly; there is no HTML page hop and + * no Markdown round-trip at read time. `body: ArticleBlock[]` is the render model. + * This module defines the contract the app and the synthesizer agree on, and a + * validator that fails any article the app could not safely render in place. + * + * Render order (matches `ArticleSourceTrail.astro` + content-engine contract): + * 1. headline (original) + * 2. dek (original standfirst) + * 3. meta strip: confidence · sourceQuality · readingTime · generatedAt + * 4. keyPoints (scannable) + * 5. body blocks (the SECTION_PLAN order) + * 6. "Why it matters" + "Reader action" callouts + * 7. SOURCE TRAIL block: numbered canonical links, kept SEPARATE from prose so + * readers can audit without cluttering the body (per ArticleSourceTrail). + * 8. legal/citation posture line. + */ +/** + * Allowed block types the in-app renderer knows how to draw. + * Rev 3 adds visual types: chart, image, gif, embed. + * Renderer rule: unknown type ⇒ skip (never throw). + */ +export const RENDERABLE_BLOCK_TYPES = [ + 'paragraph', + 'heading', + 'list', + 'quote', + 'callout', + 'chart', + 'image', + 'gif', + 'embed', +]; +export const RENDER_CONTRACT = { + inApp: true, + bodyModel: 'ArticleBlock[]', + sourceTrailSeparate: true, + quotesAttributed: true, + maxBlocks: 120, +}; +/** Raw HTML tags smuggled inside block text — catches