@happyvertical/smrt-content
STI content with contribution intake, governance workflows, AI reviews, fact-checking, corrections, versioning with transparency snapshots, and prompt-registered thumbnail generation.
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,Mirrorsharecontents - Persisted governance:
ContentGovernancePolicy,ContentGovernanceProfile,ContentGovernanceAssignment - AI reviews via
runReviewAction()with fingerprint staleness detection - Post-publication corrections:
ContentCorrection(correction / retraction / update / clarification) - Version snapshots:
ContentVersionwith frozen transparency on publication - Contributions:
ContentContribution+ Contributor / Type / Revision / Attachment - Dedicated
content_assetsnoun join (replaces ad-hocAssetAssociation) - RSS / Atom feed sync into
Mirrorrows 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
pnpm add @happyvertical/smrt-contentImports
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).
| Class | STI 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.
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.
// 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/staleContentReview 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.
| Model | Purpose |
|---|---|
ContentContribution | Held submission with lifecycle: submitted → approved / rejected / withdrawn → promoted |
ContentContributor | Profile resolved by email; trust level (standard / trusted / blocked) |
ContentContributionType | Channel configuration: rules and promotion mapping |
ContentContributionRevision | Revision history on a single submission |
ContentContributionAttachment | Held file; becomes an Asset on promotion |
// 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
// 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
// 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
await article.generateThumbnail({
strategy: 'headline-card',
brandColor: '#1a56db',
subtitle: 'Civic News',
});static-map
Requires metadata.latitude + metadata.longitude; uses unary + for strict parsing.
event.metadata = { latitude: 40.7128, longitude: -74.006 };
await event.generateThumbnail({
strategy: 'static-map',
mapProvider: 'mapbox',
zoom: 12,
markerColor: 'red',
});ai-generate
await article.generateThumbnail({
strategy: 'ai-generate',
style: 'photorealistic',
styleHint: 'editorial newsroom photo',
});Bulk fill-in
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.
| Key | Used by |
|---|---|
smrtContent.review | runReviewAction() |
smrtContent.applyCorrection | Correction synthesis flows |
smrtContent.thumbnail.aiGenerate | ThumbnailGenerator 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).
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:
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.
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
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'); // trueGotchas
- Publish readiness enforcement:
save()throwsValidationErrorif 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
metadatafield 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;
AssetAssociationis for generic / provenance only.