@happyvertical/smrt-content

STI content with contribution intake, governance workflows, AI reviews, fact-checking, corrections, versioning with transparency snapshots, and prompt-registered thumbnail generation.

v0.29.34GovernanceContributionsPrompt Registry

Overview

@happyvertical/smrt-content is the framework's content engine. v0.24 grows the package well beyond CRUD: it now ships contribution intake, persisted governance (policy / profile / assignment), AI-driven reviews and corrections, versioning with frozen transparency, a dedicated content_assets noun join, and prompt-registered thumbnail strategies.

Key Features

  • STI hierarchy: Article, ContentDocument, Mirror share contents
  • Persisted governance: ContentGovernancePolicy, ContentGovernanceProfile, ContentGovernanceAssignment
  • AI reviews via runReviewAction() with fingerprint staleness detection
  • Post-publication corrections: ContentCorrection (correction / retraction / update / clarification)
  • Version snapshots: ContentVersion with frozen transparency on publication
  • Contributions: ContentContribution + Contributor / Type / Revision / Attachment
  • Dedicated content_assets noun join (replaces ad-hoc AssetAssociation)
  • RSS / Atom feed sync into Mirror rows with an SSRF guard, a 2 MB body cap, and conditional (ETag / Last-Modified) fetches
  • Thumbnail strategies: headline-card, static-map, AI-generate
  • Prompt registry integration via @happyvertical/smrt-prompts
  • API contracts: AssetAssociable, MetadataAccessor

Installation

bash
pnpm add @happyvertical/smrt-content

Imports

typescript
import {
  Content, Article, ContentDocument, Mirror,
  Contents,
  ContentReview, ContentCorrection, ContentVersion,
  ContentGovernancePolicy, ContentGovernanceProfile, ContentGovernanceAssignment,
  ContentContribution, ContentContributor,
  ContentContributionType, ContentContributionRevision, ContentContributionAttachment,
  ContentReference, ContentAsset,
  configureContentGovernance,
  smrtContentReviewPrompt,
  smrtContentApplyCorrectionPrompt,
  smrtContentThumbnailAIGeneratePrompt,
} from '@happyvertical/smrt-content';

STI hierarchy and noun joins

Content uses single-table inheritance keyed by the qualified discriminator — e.g. @happyvertical/smrt-content:Article — and tenant scoping is optional (tenantId=null means global content).

ClassSTI discriminator
Content(base)
Article@happyvertical/smrt-content:Article
ContentDocument@happyvertical/smrt-content:ContentDocument
Mirror@happyvertical/smrt-content:Mirror

Dedicated noun joins

Content owns its asset relationships via ContentAsset (table content_assets) and its content-to-content references via ContentReference (table content_references). These replace any ad-hoc use of AssetAssociation for content-owned assets — keep AssetAssociation for generic / provenance links only.

typescript
await content.addAsset(image, 'thumbnail', 0);   // role, sortOrder
await content.getAssets('attachment');
await content.setThumbnail(image);              // convenience: addAsset + thumbnailAssetId
await content.addReference(otherContent);
await content.getReferences();

Governance workflow

Governance is driven by three persisted models: ContentGovernancePolicy (review rules), ContentGovernanceProfile (named requirement bundles), and ContentGovernanceAssignment (type/variant → profile, feature flags). DB rows merge over static config.

typescript
// 1) Static defaults (optional)
configureContentGovernance({
  policies:    [/* ContentGovernancePolicy seeds */],
  profiles:    [/* ContentGovernanceProfile seeds */],
  assignments: [/* ContentGovernanceAssignment seeds */],
});

// 2) Resolve effective governance for a single content
const resolved = await article.resolveGovernance();
// { isGoverned, reviewPolicies, profileKeys, featureFlags }

// 3) Run an AI review tied to a policy
const review = await article.runReviewAction({
  policyKey: 'editorial.fact-check',
  kind: 'fact-check',
});

// 4) Evaluate a profile's requirements
const readiness = await article.evaluateReviewProfile('editorial.publish-ready');

// 5) Publication triggers ContentVersion + transparency snapshot when 'enforcePublishReadiness' is on
article.status = 'published';
await article.save();  // throws ValidationError if required reviews are missing/stale

ContentReview writes a fingerprint of the content state at review time. When the content changes, the fingerprint mismatch flags the review as stale.

Contributions intake

The contribution family lets external collaborators submit drafts that are held until a human or workflow promotes them to live Content.

ModelPurpose
ContentContributionHeld submission with lifecycle: submitted → approved / rejected / withdrawn → promoted
ContentContributorProfile resolved by email; trust level (standard / trusted / blocked)
ContentContributionTypeChannel configuration: rules and promotion mapping
ContentContributionRevisionRevision history on a single submission
ContentContributionAttachmentHeld file; becomes an Asset on promotion
typescript
// Submit a contribution
const submission = new ContentContribution({
  contributionTypeKey: 'community-tip',
  contributorEmail: 'tipster@example.org',
  title: 'Council just voted on the new park',
  body: '...',
});
await submission.save();

// Approve + promote -- materialises live Content + attachments become Assets
await submission.approve({ reviewerProfileId: 'editor-1' });
const live = await submission.promote();

Corrections and versions

Post-publication corrections

typescript
// Issue a correction tied to the live content
await article.issueCorrectionAction({
  type: 'correction',         // 'correction' | 'retraction' | 'update' | 'clarification'
  summary: 'Fixed misspelled councilor name',
  note: 'Per email from press office, 2026-04-02',
});

const corrections = await article.listCorrections();

Version snapshots

typescript
// Manual snapshot
await article.mutateVersionAction({ kind: 'manual', summary: 'Before structural edit' });

// Publication snapshots are created automatically with transparency frozen in metadata
const versions = await article.listVersions();

// Two flavours of transparency
const publishedSnapshot = await article.getPublishedTransparencyAction();
const livePreview       = await article.previewTransparencyAction();

Use getPublishedTransparencyAction() for public display (frozen at publication) and previewTransparencyAction() for editor-side previews.

Thumbnail generation

Three strategies via ThumbnailGenerator. The AI-generate strategy is wired through the prompt registry so tenants can override template/style at runtime.

headline-card

typescript
await article.generateThumbnail({
  strategy: 'headline-card',
  brandColor: '#1a56db',
  subtitle: 'Civic News',
});

static-map

Requires metadata.latitude + metadata.longitude; uses unary + for strict parsing.

typescript
event.metadata = { latitude: 40.7128, longitude: -74.006 };
await event.generateThumbnail({
  strategy: 'static-map',
  mapProvider: 'mapbox',
  zoom: 12,
  markerColor: 'red',
});

ai-generate

typescript
await article.generateThumbnail({
  strategy: 'ai-generate',
  style: 'photorealistic',
  styleHint: 'editorial newsroom photo',
});

Bulk fill-in

typescript
const result = await contents.generateMissingThumbnails({
  strategy: 'headline-card',
  where: { type: 'article', status: 'published' },
  brandColor: '#1a56db',
  limit: 100,
});

Prompt Registry

Content's AI features are registered with @happyvertical/smrt-prompts so tenants can override template, profile, model, or parameters per environment.

KeyUsed by
smrtContent.reviewrunReviewAction()
smrtContent.applyCorrectionCorrection synthesis flows
smrtContent.thumbnail.aiGenerateThumbnailGenerator strategy 'ai-generate'

PII contract for thumbnail.aiGenerate

Allow-listed variables: style, title, styleHint, descriptionClause. Excluded: id, tenantId, the free-form metadata blob (may carry tenant-private configuration or coordinates unrelated to the visual prompt).

typescript
import {
  smrtContentReviewPrompt,
  smrtContentApplyCorrectionPrompt,
  smrtContentThumbnailAIGeneratePrompt,
} from '@happyvertical/smrt-content';

API contracts: AssetAssociable + MetadataAccessor

Content implements two contracts (issue #1162). Consumers can type their parameters as the contract and rely on the methods existing without typeof === 'function' defensive checks:

typescript
import type { AssetAssociable, MetadataAccessor } from '@happyvertical/smrt-content';

async function attachThumbnail(doc: AssetAssociable, asset: Asset) {
  await doc.addAsset(asset, 'thumbnail', 0); // contract guaranteed
}

function bumpRevision(doc: MetadataAccessor) {
  const meta = doc.getMetadata();
  doc.updateMetadata({ revision: (meta.revision ?? 0) + 1 });
}

RSS / Atom feed sync

A ContentFeedSource row tracks an external RSS or Atom feed; calling syncContentFeedSource(source) fetches it and upserts each entry as a Mirror content row (deduped per source by guid / normalized URL). Both feed formats are auto-detected by parseContentFeed — there is no separate RSS vs Atom entry point.

typescript
import {
  ContentFeedSource,
  syncContentFeedSource,
} from '@happyvertical/smrt-content';

const source = new ContentFeedSource({
  name: 'Civic Newswire',
  feedUrl: 'https://example.org/feed.xml',
});
await source.save();

const result = await syncContentFeedSource(source, {
  maxItems: 50,            // cap entries imported per run
  maxResponseBytes: 2_000_000,  // default 2 MB body cap
  fetchTimeoutMs: 10_000,       // default 10 s
  status: 'published',          // status to give imported Mirror rows
});
// { fetched, notModified, imported, updated, skipped }

Conditional fetch

After a successful fetch the source persists the response ETag and Last-Modified. The next sync sends them as If-None-Match / If-Modified-Since; a 304 Not Modified short-circuits with notModified: true and imports nothing. Sources in paused or archived status are skipped entirely.

Chat integration

Content exposes GET/POST /api/v1/contents/{id}/chat which creates chat sessions via @happyvertical/smrt-chat. The endpoint gracefully returns session: null with a notice when chat tables are not yet provisioned. Editor components support onAssistantContextChange; server-side consumers can reuse the exported helpers (getOrCreateContentEditorChatSession, createContentEditorChatThread, listContentEditorChatThreadMessages, sendContentEditorChatThreadMessage) for app-specific tenancy / AI route wiring.

Category navigation

typescript
content.category = 'technology/ai/nlp';

content.getCategorySegments();      // ['technology', 'ai', 'nlp']
content.getParentCategory();        // 'technology/ai'
content.getRootCategory();          // 'technology'
content.getAncestorPaths();         // ['technology', 'technology/ai', ...]
content.isInCategory('technology'); // true

Gotchas

  • Publish readiness enforcement: save() throws ValidationError if blocking requirements aren't met when setting status to 'published'.
  • Stale reviews: detected by fingerprint mismatch on the content's reviewable state.
  • Transparency snapshots: published transparency is frozen in ContentVersion.metadata.transparency — use published for public display, preview for editors.
  • Metadata is primary extension pattern: prefer the JSON metadata field over additional class fields.
  • Static map coordinates: uses unary + for strict parsing (rejects "45invalid").
  • content_assets, not AssetAssociation: use the dedicated noun join for content-owned assets; AssetAssociation is for generic / provenance only.