npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2024 – Pkg Stats / Ryan Hefner

narrator-ai

v0.3.0

Published

**NarratorAI** is a **Node JS** & **React** package that makes it easy to generate meta-content "narration" based on other content. It shines at reading and understanding your existing content like articles, help pages, blog posts, etc, and generating sho

Downloads

10

Readme

NarratorAI - AI-powered text narrations for your content

NarratorAI is a Node JS & React package that makes it easy to generate meta-content "narration" based on other content. It shines at reading and understanding your existing content like articles, help pages, blog posts, etc, and generating short, friendly summaries that tell the reader what content may be most useful to them.

For content creators, it lets you focus on your content itself, offloading the boring bits like "if you liked this, you might like our articles on X, Y and Z interesting" intro and outro content. It can also dynamically generate this content per-user, based on what they've been looking at or doing so far.

Narration regeneration with Narrator AI

Examples of where it can be used include:

  • Summarizing search results a la the AI Overview on Google search results
  • Introducing your latest content tagged with XYZ
  • Paragraphs to introduce the topics most recently discussed on your blog
  • "Read Next" paragraphs that link to other content based on what the user is currently viewing

Key Features

  • Automates tedious meta-content generation: Uses an LLM of your choice to generate meta-content for you
  • Quick & easy Training: CLI and UI-driven training options to rapidly align the LLM to what you want
  • Built on top of Vercel AI SDK: Fast, flexible and extensible foundation
  • Optional React Components: make it easy to integrate and train Narrator live in your UI

Installation

There are 2 packages - narrator-ai and @narrator-ai/react. narrator-ai generates the meta-content for you, and @narrator-ai/react is a collection of React components that make it easy to show, regenerate and train Narrator via your UI:

npm install narrator-ai
npm install @narrator-ai/react

The React package is optional and it's easy to roll your own. Use it to get started or if you want to do in-place training live in your UI.

Usage

Generating Content

All of the configurations for Narrator are optional, but if you want to generate and save content you can pass in outputDir and (optionally) outputFilename to have Narrator automatically save its generations for you. For example, if we want to save our generated content as .md files in the ./editorial directory, we can configure it like this:

export const narrator = new Narrator({
  outputFilename: (docId) => `${docId}.md`,
  outputDir: path.join(process.cwd(), "editorial"),
});

Now we can generate some content, which in this case will be saved to ./editorial/tag/ai.md (directories will be created for you):

const content = await narrator.generate(
  {
    docId: "tag/ai",
    suffix: "Please reply with only the markdown you generate", //suffix is optional
    prompt: `
These are summaries of my most recent articles about AI. Your task is to generate a 2-3 sentence
introduction that tells readers at-a-glance what I've been writing about. Please generate markdown,
and include links to the articles. Do not use triple backticks or section headings in your response.

<<Articles go here>>
`,
  },
  { save: true }
);

This will generate content for you and save it according to the configuration you provided. You can set docId to whatever you want - in this case we're generating intro text for a blog that contains articles about AI. If you don't specify a model it will default to using OpenAI's "gpt-4o", but you can pass in any model provided by the Vercel AI SDK.

Rendering Content

See the @narrator-ai/react docs for

NarratorAI outputs text and optionally saves it to a file of your choice, so you can render it how you like from there. If you're using React you may choose to use the @narrator-ai/react package to get some nice features out of the box:

Example of @narrator-ai/react

Check out the @narrator-ai/react docs for more.

Prompting

In the example above I left an <<Articles go here>> string inside the prompt that I passed to generate(), but that's not much use - we need to provide the articles themselves. My prompt also referred to summaries of the articles - you could pass the whole article in but it's generally a better idea to summarize them first or the LLM can lose track of what its supposed to be doing.

Generating the prompt strings is really up to you, but a good pattern is to use a Factory to create the prompt for a given docId. This allows you to encapsulate the logic for fetching those articles (or whatever else you need to fetch) in a centralized place, and as an added benefit makes it easy to regenerate content for a given docId - which is exactly what @narrator-ai/react does when you click the "Regenerate section" button.

Factory pattern

Here's a simplified version of the Factory I use to generate content for my blog. I generate 3 types of content:

  • Tag intro sections - docIds like tag/ai or tag/rsc
  • Post outro sections - docIds like post/whatever-the-post-url-slug-is
  • Blog intro section - like a tag intro section but it's for all recent posts (docId is recent-posts)

I'll skip that last category for simplicity and pretend we only generate tag intros and post outros:

class TaskFactory {
  //returns a GenerationTask for a given docId
  jobForId(docId: string): GenerationTask {
    const [exampleKey, slug] = docId.split("/");
    const { publishedPosts } = this.posts;

    if (exampleKey === "post") {
      return this.postJob(publishedPosts.find((post) => post.slug === slug));
    } else if (exampleKey === "tag") {
      return this.tagJob({ tag: slug });
    }
  }

  //returns a GenerationTask for a post outro
  postJob(post): GenerationTask {
    //summaries of related articles
    const relatedArticles = post.related
      ?.map((slug) => this.posts.publishedPosts.find((p) => p.slug === slug))
      .map((post) => ({ post, summary: this.readNext.getSummaryById(post.slug) }));

    return {
      docId: `post/${post.slug}`,
      prompt: postReadNextPrompt(post, this.posts.getContent(post), relatedArticles),
      suffix: "Please reply with a 2 sentence suggestion for what the reader should read next.",
    };
  }

  //returns a GenerationTask for a tag intro
  tagJob({ tag }): GenerationTask {
    //the 10 most recent posts for a given tag
    const recentPosts = this.posts.publishedPosts
      .filter((post) => post.tags.includes(tag))
      .slice(0, 10)
      .map((post) => ({ post, summary: this.readNext.getSummaryById(post.slug) }));

    return {
      docId: `tag/${tag}`,
      prompt: tagIntroPrompt(tag, recentPosts),
    };
  }
}

A GenerationTask is just a type with a docId, prompt and optional suffix to help keep the LLM on track. I have a little class called Posts that exposes publishedPosts and a way to getContent for a given post - pretty basic stuff. Both types of task are using ReadNext to generate article summaries (as well as populating the list of related articles for the postJob), but you can do that however you like.

The tagIntroPrompt and postReadNextPrompt functions just return strings that get passed in as the prompt. Here's what my tagIntroPrompt function looks like:

//it's just a string. A long string, but a string.
function tagIntroPrompt(tag: string, recentPosts: RecentPost[] = []) {
  return `
    These are summaries of the ${recentPosts.length} most recent blog posts on my technical blog for the tag "${tag}".
    The summaries have been specifically prepared for you so that you have the context you need to 
    a very brief 2 paragraph overview of what I've been writing about recently regarding this tag. I want you 
    to write this editorial in my own tone of voice, as if I were writing it myself. It should be around 100 words.
    
    Your response will be rendered in a markdown file and published on my blog. It should contain 
    several links to various posts, giving extra credence to topics that have been covered more than
    once, or covering open source projects that I've been working on. You will be told the article
    relative url for each post, so you can link to them in your editorial. Please link to as many
    posts as you can.
    All of the links inside the text that you generate should be relative links, precisely matching
    the relativeLink you were given for each post, or absolute links with edspencer.net as the domain.
    
    Do not include any headings as there is already a heading for this section in the page layout.
  
    If the most recent articles are more than 5 years old, the first part of your answer should be to point out
    to the reader that nothing has been posted for this tag in a while.
  
    Keep it humble and not too high-faluting. I'm a technical writer, not a poet. Avoid starting with the phrase
    "I've been diving in" or similar.
    
    Here are the summaries of the recent blog posts:
    
    ${recentPosts.map(({ post, summary }) => articleRenderer(post, summary)).join("\n\n")}  

    You must not include \`\`\`markdown or \`\`\`html code blocks in your response.
    You must not include any headings in your response.
`;
}

//LLM-friendly string for a given post summary
const articleRenderer = (post, summary) => `
ARTICLE METADATA:
Article Title: ${post.title}
Article relative url: ${post.relativeLink}
Tags: ${post.tags.join(", ")}
Published: ${timeAgo.format(new Date(post.date))}
ARTICLE SUMMARY: ${summary}
`;

Now we can generate/regenerate content for a section just based on a docId string:

await narrator.generate(factory.taskForId("tag/ai"));

Batch Generating

It's probably pretty obvious now how to generate more than one piece of content. Here's a simple script I use to do it for my blog:

//to expose the OPENAI_API_KEY
import * as dotenv from "dotenv";
dotenv.config();

import Posts from "@/lib/blog/Posts";
import { TaskFactory, narrator } from "@/lib/blog/TaskFactory";

async function main() {
  const taskFactory = new TaskFactory();
  const posts = new Posts();

  //generate post "read next" outros
  for (const post of posts.publishedPosts) {
    await narrator.generate(taskFactory.jobForId(`post/${post.slug}`)!, { save: true });
  }

  //generate the intro per tag (but only for tags with 3 or more posts)
  const tags = posts.getTagsWithCounts().filter(({ count }) => count > 3);

  for (const tag of tags) {
    await narrator.generate(taskFactory.jobForId(`tag/${tag.tag}`)!, { save: true });
  }

  //generate the overall /blog intro
  await narrator.generate(taskFactory.jobForId("recent-posts")!);
}

main()
  .catch(console.error)
  .then(() => process.exit(0));

In my package.json I added a couple of scripts:

  "scripts": {
    "narrator:train": "npx tsx script/train-narrator.ts",
    "narrator:generate": "npx tsx script/generate-narration.ts"
  },

So now I can just run this to generate all of the meta-content for my blog:

npm run narrator:generate

My actual script uses commander so that I can have it just do the posts, the tags, the overview, or some combination based on flags I pass in, but you get the idea.

Training

Spending 5 minutes to train Narrator is likely to significantly improve your results. Training just means giving a good/bad verdict to a piece of content that Narrator generated. Narrator saves this verdict, along with an optional reason (you don't have to give a reason, but it's a good idea), and next time it generates content for you it will use those good/bad examples to produce a better response.

Under the covers, Narrator uses Few Shot Prompting to grab a selection of the content examples you marked as good or bad, and automatically passes them to the LLM along with your prompt when you call generate.

Training is done via the train and saveExample functions, and is easy to hook up to the command line, and even easier to hook up to your UI if you use @narrator-ai/react.

Training in the CLI

Let's provide an examplesDir to our Narrator this time:

//this time we configured an examplesDir to tell Narrator where to save good/bad examples
export const narrator = new Narrator({
  outputFilename: (docId) => `${docId}.md`,
  outputDir: path.join(process.cwd(), "editorial"),
  examplesDir: path.join(process.cwd(), "editorial", "examples"),
});

Here's a simple script I use to train Narrator on content that it generates for my post outros GenerationTasks:

//expose the OPENAI_API_KEY
import * as dotenv from "dotenv";
dotenv.config();

import { TaskFactory, narrator } from "@/lib/blog/TaskFactory";
import Posts from "@/lib/blog/Posts";

async function main() {
  const taskFactory = new TaskFactory();
  const posts = new Posts();

  for (const post of posts.publishedPosts) {
    await narrator.train(taskFactory.jobForId("post/" + post.slug));
  }
}

main()
  .catch(console.error)
  .then(() => process.exit(0));

That's pretty basic. We're just looping over each published post, creating a GenerationTask (docId + prompt) for each, and passing it in to the train function, which is optimized for CLI-based training. It will generate content and ask you to give it a good/bad rating, or skip it. It will also ask you for a reason why you gave that rating.

Here's the actual shell output from running this:

Training via the CLI

It will keep doing that until it runs out of published posts to generate content for, or you ctrl-c out of it. Note that all this is doing is saving good/bad examples, calling train does generate text, but it won't save it as the actual text for the given docId.

Note that each of the examples above will be saved under the post key - internally, Narrator takes everything before the / in the docId (if there is one) and treats that as a group. Above, we trained by generating content for post/read-next-ai-content-recommendations-node and post/ai-content-recommendations-typescript, and the verdicts and optional reasons we supplied were saved under the post key. Next time we either generate or train on a docId that starts with post/, Narrator will grab some or all of those good/bad examples and pass them to the LLM to tell it what we like and don't like.

The performance of the LLM in generating the type of content you want goes up significantly even after only training on a few examples. You only have to do it once; under the covers the examples are saved as .yml files and should be committed to source control.

Training in the UI

If you are using @narrator-ai/react, you can train Narrator directly in your UI while you're developing. This is perhaps even easier than doing it via the CLI, and has the benefit of showing you the fully rendered content in the actual context it will be shown in inside your UI:

Example of @narrator-ai/react

Check out the @narrator-ai/react docs for more.

Configuration

All of the configurations for Narrator are optional, but you can significantly influence its operation by passing some or all of the following:

export const narrator = new Narrator({
  //lets you specify the output filename for a given docId
  outputFilename: (docId) => `${docId}.md`,

  //directory where generated content will be saved (nested under the docId)
  outputDir: path.join(process.cwd(), "editorial"),

  //directory where examples of good/bad content will be saved (in .yml files)
  examplesDir: path.join(process.cwd(), "editorial", "examples"),

  //provide your own model (using Vercel AI SDK)
  model: openai("gpt-4o-mini"),

  //if you want to set the temperature (defaults to 0.9)
  temperature: 0.7,

  //if you want to pass in your own winston logger instance
  logger: myCustomWinstonLogger,

  //you probably won't do this, just lets you pass in a different class to evaluate during training
  //check out Trainer.ts if this interests you
  trainer: myTrainer,

  //if you want to change how the examples are presented to the LLM
  exampleTemplate: ({ verdict, content }) => `This content was marked as ${verdict}: ${content}`,
});

Generation Options

As well as the GenerationTask, you can pass in some options when generating:

const docId = "tag/ai";
const prompt = "Generate some intro text for blah blah blah...";

narrator.generate(
  { docId, prompt },
  {
    //to save the generated content (defaults to false)
    save: true,

    //to return a text stream object instead of a string response (defaults to false)
    stream: true,

    //if you want to use a different model for this generation
    model: openai("gpt-4o-mini"),

    //if you want to use a different temperature for this generation
    temperature: 0.3,

    //to change the default limit of good examples passed to the LLM (defaults to 5)
    goodExamplesLimit: 10,

    //to change the default limit of bad examples passed to the LLM (defaults to 5)
    badExamplesLimit: 10,
  }
);

If you have more good/bad examples than the limits allow, Narrator will randomly select as many as you asked for.

Example Saving Options

When saving some content as a good/bad example, you can pass in the following (docId and verdict are required):

narrator.saveExample({
  docId: "tag/ai",

  //could also be 'bad'...
  verdict: "good",

  //optional, but recommended
  reason: "it has that certain je ne sais quoi",

  //optional, but if you don't pass it in then Narrator will try to retrieve it based on docId
  content: "If you don't provide this yourself, Narrator will try to find it",
});