Tom Newby

Sales Bro: LinkedIn, but it's just one sales bro posting all the time

2024 September 25

View Demo: Sales Bro


Background

It's really dumb. I know, but hey it's fun.

Imagine a world where LinkedIn was just ones unbearable sales bro posting inane shit all the time.

Imagine no more!


Tech/tools used


The build (webapp bits)

Continuing to build on Cloudfront Pages & Page functions, this was a fairly simple exercise to have a React component poll an endpoint to fetch posts from the backend.

Backgrounding the post generation

I wanted to avoid having a "generate new post" button, lest it gets spammed by someone etc. In an ideal world, I wanted it so that when you loaded the page, it generated a new post. It can take a little bit of time to generate a post (see The LLM stuff section), so I didn't want to have the user wait for a new post being generated before they could see any posts.

I'd generally reach here for a background worker that runs off a task queue, but Cloudflare worker queues are only available on the paid plans and it's not really worth it for this.

Instead, I pieced together a bit of a hack. When the user first loads the page, the frontend hits the route naked, we load the "old" posts from a KV store (lazy man's database) but we background the work of generating a new post, but still within the same worker execution. This way, the user gets a response quickly, but we're still generating a new post in the background, even if it takes a little bit of time.

It's definitely not as robust as a proper task queue, but it's a fun little hack!

// Simplified example of the request handler
export const onRequest: PagesFunction<Env> = async (context) => {
    const oldPosts = await postStore.getPosts();

    // context.waitUntil tells the worker that work is still happening, but it's async so we're not blocking the response
    context.waitUntil(async () => {
        const post = await generatePost(context.env.OPENAI_API_KEY)

        postStore.addPost(post)
    });

    return Response.json({
        posts: oldPosts,
    });
};

Using tanstack-query for polling

To setup the polling of the frontend to the backend, I used tanstack-query (FKA react-query). This was the first I'd come across this library before but boy it's pretty neat!

It was simple enough to implement some behaviour where the frontend kept track of a timestamp of the latest posts created_at, then on subsequent requests it sends through ?since={timestamp} to the backend. The backend then checks if there's been any new posts since that timestamp. If there's been no new posts since then it returns a 204 - No Content, otherwise it returns all the posts. (It should probably just return the "new posts" but it's not particularly consquential for this).

There was a bit of "holding it right" to get tanstack-query to play nicely with the 204 response. The code below shows the work around - capturing the 204 as a custom error, then overriding the retry behaviour.

// Truncated example of using tanstack-query to handle 204 response and sending since params
const expectedResponse = z.object({
    posts: z.array(postSchema),
});

export class NotModifiedError extends Error {
    constructor() {
        super('Response not modified');
    }
}

export const loadPosts = async (since: null | string): Promise<Post[]> => {
    const url = new URL('/salesbro/posts', window.location.origin);
    if (since !== null) {
        url.searchParams.set('since', since);
    }

    const response = await fetch(url);
    if (response.status === 204) {
        throw new NotModifiedError();
    }

    const {posts} = expectedResponse.parse(await response.json());

    return posts;
}

const loadPostsAndSetSince = async (data: undefined | QueryState): Promise<QueryState> => {
    const posts = await loadPosts(data?.since ?? null);

    const since = posts.length > 0 ? posts[0].created_at : null;

    return {
        posts,
        since
    };
};
const query = useQuery({
    queryKey: ['posts'],
    queryFn: (): Promise<QueryState> => loadPostsAndSetSince(query.data),
    retry: (_, error) => {
        if (error instanceof NotModifiedError) {
            // Don't retry - it's fine bro
            return false;
        }

        return true;
    },
    refetchInterval: 5000
});

The LLM stuff

Continuing on from my talk on Langchain and having worked with it a fair bit now at work, I picked up my trusty tools again.

This problem was different for me though! All my other usages have been in use cases where accuracy/precision is prioritised, but here the goal was creativity. How can you meaningfully evaluate something when what you're looking for is inherently beautiful hallucinations?

I first tried getting the model to generate the post directly in a single LLM call. This took a fair bit of prompt engineering to get remotely good and I found that even when I dialled the temperature up, it was still pretty boring and easily got stuck on a few topics.

I then branched out to try and break down the problem into more discrete steps:

  1. Generate an outline for a post

Example prompt below. {postIdea} would be a random selection from a list of broad themes.

You are tasked with drafting an outline of a LinkedIn post for a narcissistic enterprise software salesperson for
satirical, entertaining purposes.

General post idea: {postIdea}

Generate the outline of the post.
The outline should be {bulletCount} bullet points in length.
Each bullet point should be less than 10 words.
The outline should be specific and detailed.

Example output:

{
    outline: "1. Big announcement: I'm leaving LinkedIn. \n" +
    "2. But wait, there's more! \n" +
    "3. Can't resist sharing my brilliance! \n" +
    '4. Expect more posts than ever before!'
}
  1. Use the outline to generate the post

Example prompt below.

You are tasked with drafting a LinkedIn post for a narcissistic enterprise software salesperson for satirical,
entertaining purposes.

Post guidelines:

- the post should follow the outline provided
- the post should be cringe-worthy
- the content of the post should be 10-50 words long
- each paragraph should be one sentence long
- separate paragraphs with one newline character
- include 0-10 hashtags that are relevant to the post, but also cringey

Results

Again, I'm not really sure how to evaluate anything here beyond vibe checks.

The results are OK, but they do get pretty monotonous even when dialing up the temperature. For example, it seemingly always talks about the coffee shops when prompted around "local business" themes, but there's no direct reference to coffee shops. It surprises me how focussed it is on coffee shops with a high temperature!

I think getting the model to generate more specific details (with temperature still high) would be a good progression but it's really just a toy project and the creativity direction of using LLMs is not really something I've explored much or have a great interest in.

What I could do next

Things I'd love to do to build upon this: