Task629 Build Log

To be able to build skills and grow, you need to keep track of what you are doing, and know how that aligns with the skills in question. The trouble is that sitting down and listing all of the skills demonstrated by a task is in itself not a quick process. Adding this admin to your own day-to-day brings enough cognitive load that we often choose not to bother. The motivation for this tool then presents itself: make the tracking of skills easy by inferring them from the descriptions of tasks.

This idea has been the driving force of an ongoing project of mine for the last few months. I have had a few runs at this idea and I have created a few working protoypes - for me, the issue is that the output was only ever that, a prototype. I think that this idea has legs, so I am keen to build it in a clean, slick way, which could be deployed as a Docker container which other people might be interested in using. It is for this reason that I am taking another stab at the project. This document serves as a build log for this process.

The name

As the ancient wisdom says, naming things is one of the hardest parts of programming. Lucky for us, I have this nailed down right from the start of this build process. This tool will go by the name “Task629”. This is because the data which we will be dealing with are “tasks” submitted by the user, and I will be working on this outside of working hours, that is (roughly) 6 to 9.

Planning

Before we can make a real start, we need to understand what we want to build. To this end, let’s decide what our flow should look like. At a high level, we want to:

  1. take some “task” input from the user
  2. store that task
  3. analyse that task, to find skills associated with it
  4. store those skills

At a high level, that is:

sequenceDiagram
    actor user
    participant db@{ "type" : "database" }
    
    user->>db:task submitted<br>and stored
    db->>model:task analysis<br>requested
    model->>db:skills stored

Already, we have a few decisions to make. What shall I use for my:

As previously mentioned, this project is one which I have had a few test runs at. In previous iterations I have used:

I am keen to keep this stack as it is for now, as I am familiar with the tools, and they do what I need them to do. My plan for development is to build with these tools in mind, but to build in a way which will allow me to accomodate for alternatives in the future. One does not wish to over-complicate unnecessarily, but, this plan does seem to lend itself quite well to the strategy design pattern. I will keep this in mind when I start building.

These specific technologies can fit in to our sequence diagram as follows:

sequenceDiagram
    actor User

    User->>Node: Submit task via Telegram
    Node->>DB: Store task
    Node->>Ollama: Analyse task
    Ollama->>Node: Skills
    Node->>DB: Store skills

Building

We are just about ready to get building. To do this and keep myself pointing in the right direction, I am going to roughly plan out the steps I want to take. That is, more or less, I am going to make myself a backlog. Once I have these backlog items I will go about completing them in a test driven way. How exciting.

Backlog 1

  1. Create Telegram bot
    • It should receive messages
    • It should be able to execute code on receipt of a message
  2. Create MongoDB connection
    • It should be able to write
    • It should be able to read
    • It should handle connection errors
  3. Allow requests to Ollama API
    • It should abstract network calls
    • It should handle errors
  4. Create prompt for model
    • It should insert a user’s task into a prompt template
    • It should throw an error if task input is empty
  5. Create Mongoose schema
    • It should accomodate for:
      • A task description
      • Timestamps
      • A skills list
      • A status enum

Backlog 1 - Item 1 / Item 5

The tests created ahead of the development of item 1 are as follows:

I implemented this functionality with Telegraf. Really my Bot class is just a wrapper for existing functions. At the moment I would go as far as to say that it is an unnecessary wrapper, but building this structure early doors will make expansion easier later on. Here is the class itself:

export class Bot {
    bot: Telegraf;
    taskHandler: TaskHandler;

    constructor(botToken: string, taskHandler: TaskHandler) {
        this.bot = new Telegraf(botToken);
        this.taskHandler = taskHandler;

        this.bot.on(message('text'), this.handleTelegramMessage.bind(this));

        this.bot.launch();
    }

    async handleTelegramMessage(ctx: TextMessageContext): Promise<void> {
        await this.taskHandler.handle(ctx.message.text);
    }
}

I have a TaskHandler interface here used here. Again, this is not the most interesting of structures just now, but will give us a more manageable codebase moving forward - especially when we look to adding different strategies.

export interface TaskHandler {
    handle(task: string): Promise<void>;
}

At this point, we can test what we have by creating a Telegram bot and plugging in our key.

Backlog 1 - Item 2

As above, I can guide my development by outlining my test cases:

Mongo and node are friends, so implementing my database should not be an issue. I have used Mongoose a lot before, so it is a technology which I am happy to use here. Down the line I can think a bit more about my schemata, but for now I am going to make a guess about what my task model will look like so I have something to implement. I can create a database instance with mongoose.connect call. Once this has been called, I can implement my model as:

const TaskSchema = new Schema({
    description: { type: String, required: true },
    skills: [{ type: String }],
    status: { type: String, enum: ['NEW', 'COMPLETE'], default: 'NEW' }
}, { timestamps: true });

const Task = mongoose.model('Task', TaskSchema);

Backlog 1 - Item 3

Now I can move on to my Ollama connection. There are npm packages for Ollama, but to be honest, I don’t think it is worth using these. What I will be using Ollama for is making simple text-based requests. I might as well just hit the API directly for this.

This was all well and good, but to actually test what I have done I need to make a request. This is where I faced the first issue with the implementation so far. My requests kept on timing out. When I think about how my Ollama network requests are going to be used, the plan is to do this completely asynchronously. That is, it should not matter at all how long my requests take, as long as they finish. I need to tell node this. From a small amount of Googling, it seems that my issue is not a default timeout from Node, but rather an issue with how Undici, a dependancy, handles things. To be rigorous I should stop and figure this out, but I am in a prototyping mood. I am going to apply a Stack Overflow fix and put a pin in this as something to come back to. The Stack Overflow fix in question is creating an AbortController instance, and including this as a signal value in my request:

const controller = new AbortController();

const timeout = setTimeout(() => {
    controller.abort();
}, 15 * 60 * 1000);

Backlog 1 - Item 4

The final bit of work to be done with regards to this backlog is some function to generate my prompts. Really this is is going to be a simple piece of code which takes a task variable and plonks it inside of a template. I am going to add two features to this to enhance the simple function. These are:

Stringing things together

Now we have all of our components, we can make our version 1.0.0. First piece of software sellotape, we need the task handler which our bot uses to include the line const newTask = Task.create({description: task}). Simples.

After that, we just need a way of taking new tasks and sending them off for analysis. Since we have our functions created, we just need to put them in the right order. Remeber our friend from earlier:

sequenceDiagram
    actor user
    participant db@{ "type" : "database" }
    
    user->>db:task submitted<br>and stored
    db->>model:task analysis<br>requested
    model->>db:skills stored

It is time for him to be realised.

The storage is taken care of in the task handler we just described. After that, we need to make a request. This can be handled with the following function:

const analyseTask = async (task: string, ollama: Ollama): Promise<string[]> => {
    let prompt: string = '';

    try {
        prompt = generateTaskAnalysisRequest(task);
    } catch (err) {
        console.log(err)
    }

    const response = await ollama.request(prompt);
    const skills: string[] = response.split(",");

    return skills;
}

With a little more mongoose magic we can make sure we are requesting analysis on the right task, and update the document with the result of the analysis:

const analyseNextTask = async (ollama: Ollama): Promise<void> => {
    const taskToAnalyse = await Task.findOne({status: "NEW"});
    
    if (!taskToAnalyse) {
        console.log("no task found")
        return;
    }

    console.log(`Analysing task: ${taskToAnalyse.description}`)

    const skills: string[] = await analyseTask(taskToAnalyse.description, ollama);

    taskToAnalyse.status = "COMPLETE";
    taskToAnalyse.skills = skills;
    await taskToAnalyse.save();

    console.log(`Finished task analysis of: ${taskToAnalyse.description}`)
}

For a nice finishing touch, we can schedule our job to run regularly. Inside of our index.ts we can add

setInterval(() => {
        analyseNextTask(ollama).catch(console.error);
    }, HOUR);

And we can call it a day. Task629 version 1.0.0 is wrapped. The repo is here, and the docker image is available at benjamingodfrey/task629. Good hunting!

Comments

Loading comments...