Personal Journal with RAG Chatbot

Build a journaling app with an AI accountability partner using RAG.

Introduction

30 Second Summary

Most of us journal inconsistently. When we do look back at old entries, we struggle to spot patterns in our own moods and habits. What if your journal could read itself and tell you the truth about whether you are actually improving?

In this project, you will build a personal journal web app with an AI chatbot that retrieves your past entries using semantic search and gives you brutally honest, data-backed feedback about your progress.

What You'll Build

A locally-hosted FastAPI web application that stores journal entries with mood and goal tracking in Supabase, generates vector embeddings using a local AI model, and uses RAG to power a chatbot that gives direct feedback grounded in your own writing.

By the end of this project, you'll have:

  • A FastAPI backend with journal CRUD and RAG chat endpoints, plus a frontend for writing entries and chatting with your assistant.
  • A Supabase database with pgvector storing your journal entries and their 384-dimensional embeddings for semantic search.
  • A local embedding pipeline using sentence-transformers that generates vectors for free without any external API calls.
  • A RAG chatbot personality that references your actual entries, dates, and goal numbers to tell you whether you are progressing or just talking about it.
  • Secret Mission: Add a habit tracker that logs daily habits and lets the chatbot reference your streaks and consistency.

Are there any prerequisites?

You should have Python installed on your machine, a free Supabase account, and a Groq API key. Basic familiarity with HTML, CSS, JavaScript, and Python will help you follow along smoothly.

Before We Start

A personal journal is powerful on its own. Pairing it with an AI that actually remembers what you wrote turns it into an accountability partner that holds you to your own words.

Before we start building, you need a project folder, a Python virtual environment with all the libraries installed, and a free Supabase account for your database.

In this step, get ready to:
  • Create a project folder and open it in VS Code.
  • Set up a Python virtual environment and install dependencies.
  • Create a free Supabase account for your journal's database.
Create your project folder
  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • Open the integrated terminal by pressing Ctrl+` (both macOS and Windows).
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir journal-app
cd journal-app
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the journal-app folder on your Desktop, then click Open.

You should see the journal-app folder name in the VS Code Explorer sidebar on the left. The folder is empty for now.

Don't see the folder in the sidebar?

Make sure you selected the journal-app folder itself, not the Desktop. The Explorer sidebar should show the folder name at the top.

If the terminal closed when you opened the folder, reopen it with Ctrl+`.

Set up your Python virtual environment and install dependencies

A virtual environment keeps this project's packages isolated from the rest of your system. This prevents version conflicts between different Python projects.

  • Create a virtual environment by running this command in your terminal:

macOS

python3 -m venv venv

Windows

python -m venv venv

What does this command do?

The venv module creates a self-contained Python environment inside a folder called venv. When activated, any packages you install go into this folder instead of your system-wide Python.

  • Activate the virtual environment by running:

macOS

source venv/bin/activate

Windows

venv\Scripts\activate

You should see (venv) appear at the beginning of your terminal prompt. This confirms the virtual environment is active.

Don't see (venv) in your prompt?

Make sure you ran the activate command for your operating system. On macOS, use source venv/bin/activate. On Windows, use venv\Scripts\activate.

If you see a permissions error on Windows, try running Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned in PowerShell first, then retry the activate command.

Help me activate my Python virtual environment.

  • Create a requirements.txt file that lists all the packages your project needs. In your terminal, run:

macOS

touch requirements.txt

Windows

New-Item requirements.txt
  • Open requirements.txt in VS Code by clicking it in the Explorer sidebar.
  • Paste the following content into the file:
fastapi==0.136.3
uvicorn==0.34.0
supabase==2.31.0
groq==1.4.0
sentence-transformers==5.5.1
python-dotenv==1.1.0

What are these packages?

  • fastapi is the web framework that runs your journal's API server.
  • uvicorn is the server that actually hosts the FastAPI app.
  • supabase is the Python client for communicating with your Supabase database.
  • groq is the SDK for calling the Groq LLM API to generate chatbot responses.
  • sentence-transformers runs the local AI model that converts your journal text into vector embeddings.
  • python-dotenv loads your environment variables from a .env file so you don't hardcode secrets.
  • Save requirements.txt by pressing Cmd+S (macOS) or Ctrl+S (Windows).
  • Install all the packages by running this command in your terminal:
pip install -r requirements.txt

This install takes a few minutes

The sentence-transformers package downloads PyTorch and the AI model files. Expect the install to take 2-5 minutes depending on your internet speed. You will see a progress bar as packages download.

When the install finishes, you should see a line containing Successfully installed followed by a list of package names.

Seeing an installation error?

Make sure (venv) appears in your terminal prompt. If it does not, your virtual environment is not active. Go back and run the activation command for your operating system.

If you see an error about Python version compatibility, confirm you are running Python 3.10 or higher. Run python3 --version (macOS) or python --version (Windows) to check.

Help me fix my pip install error.

  • Confirm the files exist by running:
ls

You should see requirements.txt and venv in the output.

✔️ Awesome, I've got everything!

Great. Double check that you have saved requirements.txt and that the install completed with no errors.

ⓧ I'd like to double check the full code

Here is the complete requirements.txt file for comparison:

fastapi==0.136.3
uvicorn==0.34.0
supabase==2.31.0
groq==1.4.0
sentence-transformers==5.5.1
python-dotenv==1.1.0
Create a free Supabase account

Supabase provides a hosted PostgreSQL database with built-in vector search support. You will use it to store journal entries and their embeddings.

  • Open your browser and go to supabase.com.
  • Click the sign-up button on the homepage.
  • Sign up using your GitHub account or email address.
  • If you signed up with email, check your inbox and confirm your email address.

Once you are signed in and can see the Supabase dashboard, you are ready to move on. You will create a project and database table in the next step.

Why Supabase?

Supabase gives you a full PostgreSQL database with the pgvector extension for vector search. This means your journal entries and their AI-generated embeddings live in the same database. No extra services to manage.

The free tier includes 500MB of database storage and 5GB of bandwidth per month. A personal journal will stay well within those limits.

Your development environment is ready. Next up, you will build the journal API and connect it to your Supabase database.

Set Up Supabase and Environment

Your code needs a place to store journal entries along with their vector embeddings. A regular database can store text, but you also need a column that holds 384-dimensional number arrays and can search them by similarity.

Supabase gives you a full Postgres database with pgvector support on the free tier. In this step, you will create the database, the table for your entries, and a SQL function that finds similar entries by comparing their embeddings.

In this step, get ready to:
  • Create a Supabase project and enable the pgvector extension.
  • Create the journal_entries table with an embedding column.
  • Set up the similarity search function and environment variables.
Create a Supabase project and enable pgvector

Every Supabase project is a dedicated Postgres database. You need to create a project first, then enable the pgvector extension so the database can store and compare vector embeddings.

  • Open the Supabase Dashboard in your browser.
  • Click New Project.
  • Enter a project name (e.g. journal-rag) and set a database password.
  • Select the region closest to you.
  • Click Create new project and wait for the database to finish launching.

What is pgvector?

pgvector is a Postgres extension that adds a vector data type and distance operators to your database. It lets you store arrays of numbers (embeddings) alongside your regular data and query them by similarity using standard SQL.

Once the project is ready, you will enable the pgvector extension using the SQL Editor.

  • In the left sidebar, click the SQL Editor section.
  • Click New Query to open a blank editor.
  • Enable the pgvector extension by pasting this SQL and clicking Run (or pressing Cmd+Enter on macOS / Ctrl+Enter on Windows):
create extension vector with schema extensions;

What does this command do?

This tells Postgres to load the pgvector extension into the extensions schema. Once enabled, you can use the vector data type in your table columns.

You should see a Success. No rows returned message in the results panel.

Now create the table that will hold your journal entries and their embeddings.

  • Clear the SQL Editor, then paste and run this query:
create table journal_entries (
  id bigint generated always as identity primary key,
  title text,
  content text,
  mood int,
  goal_progress int,
  embedding extensions.vector(384),
  created_at timestamptz default now()
);

What does this table store?

  • title and content hold the journal entry text.
  • mood is an integer from 1 to 5 tracking how you feel.
  • goal_progress is a percentage (0-100) tracking progress toward your goals.
  • embedding stores a 384-dimensional vector generated by the AI model.
  • created_at records when the entry was written.

You should see another Success. No rows returned message.

  • Verify the table exists by clicking Table Editor in the left sidebar.
  • You should see journal_entries listed with all six columns.

Don't see the table?

Make sure you clicked Run after pasting the CREATE TABLE query. Check the results panel for any error messages about missing extensions. If you see an error mentioning the vector type, go back and re-run the create extension vector command first.

Help me debug my journal_entries table creation in Supabase.

Create the similarity search function and configure environment variables

The Supabase Python client cannot use pgvector operators directly in queries. You need to wrap the similarity search in a Postgres function and call it via supabase.rpc() from your Python code.

  • Switch back to the SQL Editor in the left sidebar.
  • Click New Query, then paste and run this SQL to create the match_entries function:
create or replace function match_entries(
  query_embedding extensions.vector(384),
  match_threshold float,
  match_count int
)
returns table (
  id bigint,
  title text,
  content text,
  mood int,
  goal_progress int,
  created_at timestamptz,
  similarity float
)
language plpgsql
as $$
begin
  return query
  select
    journal_entries.id,
    journal_entries.title,
    journal_entries.content,
    journal_entries.mood,
    journal_entries.goal_progress,
    journal_entries.created_at,
    1 - (journal_entries.embedding <=> query_embedding) as similarity
  from journal_entries
  where 1 - (journal_entries.embedding <=> query_embedding) > match_threshold
  order by journal_entries.embedding <=> query_embedding
  limit match_count;
end;
$$;

What does this function do?

This function accepts a query embedding (the vector representation of your chat message), a similarity threshold, and a result count. It compares the query embedding against every stored journal entry embedding using cosine distance (the <=> operator).

Only entries with similarity above the threshold are returned, ordered from most to least similar. Your Python code will call this function using supabase.rpc("match_entries", {...}) to find the most relevant journal entries when the chatbot needs context.

You should see Success. No rows returned confirming the function was created.

Why use a Postgres function instead of a direct query?

The Supabase Python client uses PostgREST under the hood, which does not support pgvector operators like <=> in standard queries. By wrapping the vector search in a database function, you keep the logic inside Postgres where it runs fastest and call it from Python with a simple RPC call.

Now you need to grab your project credentials so your Python app can connect to Supabase.

  • In the Supabase Dashboard, click Settings in the left sidebar.
  • Click API Keys.
  • Copy the Project URL (it looks like https://abc123.supabase.co).
  • Under the Legacy API Keys tab, copy the anon key value.

Keep this tab open

You will need both the Project URL and anon key in a moment. Keep the Supabase settings tab open in your browser so you can copy them into your code.

  • Switch back to VS Code.
  • Create a new file called .env in the root of your project folder by running:
touch .env
  • Open .env and add the following, replacing each placeholder with your real values:
SUPABASE_URL=your-supabase-project-url-here
SUPABASE_KEY=your-supabase-anon-key-here
GROQ_API_KEY=your-groq-api-key-here

What are these variables?

  • SUPABASE_URL is the API gateway URL for your Supabase project.
  • SUPABASE_KEY is the anon key that lets your app read and write to the database (respecting Row Level Security).
  • GROQ_API_KEY is the key you already have for the Groq LLM API.
  • Save .env.
  • Verify the file exists by running:
ls -a

You should see .env listed among your project files.

Not seeing .env in the file list?

Files starting with a dot are hidden by default. The -a flag in ls -a shows all files including hidden ones. If you still do not see it, make sure you created the file in your project folder (not a parent directory).

Help me find my .env file.

✔️ Awesome, I've got everything!

Great. Make sure you have saved .env with your real Supabase URL, Supabase anon key, and Groq API key filled in.

ⓧ I'd like to double check the full code

Your .env file should look like this (with your own values instead of the placeholders):

SUPABASE_URL=your-supabase-project-url-here
SUPABASE_KEY=your-supabase-anon-key-here
GROQ_API_KEY=your-groq-api-key-here

Your database is ready and your app has the credentials it needs to connect. Next up, you will build the FastAPI endpoints that let you create and retrieve journal entries.

Build the Journal API

Your environment is set up and your Supabase database is ready to store journal entries. Now it's time to build the API that your frontend will talk to.

In this step, you'll create a FastAPI backend with endpoints for creating and reading journal entries. These endpoints accept your journal text, mood, and goal progress, then save everything to Supabase.

In this step, get ready to:
  • Create a FastAPI app with Pydantic models for data validation.
  • Build POST and GET endpoints for journal entries.
  • Test the API using FastAPI's built-in interactive docs page.
Create the FastAPI app and data models

Every API needs a foundation: the app instance, client connections to external services, and data models that define what shape your requests should take.

  • Create a new file called main.py in the root of your journal-rag project folder.
  • Add the following code to set up the app, connect to Supabase and Groq, and define data models:
import os
from datetime import datetime, timezone
from contextlib import asynccontextmanager

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
from supabase import create_client
from groq import Groq

# Load environment variables from .env
load_dotenv()

# Global variable for the embedding model (loaded during startup)
embedding_model = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load the embedding model once when the app starts
    global embedding_model
    embedding_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
    yield


# Create the FastAPI app with a lifespan handler
app = FastAPI(lifespan=lifespan)

# Initialize Supabase and Groq clients using env vars
supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])
groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])


# Define the shape of data for creating a journal entry
class EntryCreate(BaseModel):
    title: str
    content: str
    mood: int
    goal_progress: int


# Define the shape of data for chat messages
class ChatMessage(BaseModel):
    message: str

What does this code do?

The lifespan function runs once when your server starts and loads the embedding model into memory. This avoids reloading a 90MB model on every single request.

The two Pydantic models (EntryCreate and ChatMessage) define what fields the API expects. FastAPI automatically rejects requests that don't match these shapes.

  • Save main.py.
  • Confirm you see main.py in the VS Code Explorer sidebar.

Don't see main.py in your Explorer?

Make sure you saved the file inside your project folder (the same folder that contains requirements.txt and .env). If VS Code has a different folder open, click File then Open Folder and select your project folder.

help me find where main.py was saved

Add the POST /entries endpoint

With the app skeleton in place, you need an endpoint that accepts a new journal entry and saves it to your Supabase database.

  • In main.py, add the following code below the ChatMessage class:
@app.post("/entries")
def create_entry(entry: EntryCreate):
    # Build a dictionary with the entry fields and a timestamp
    data = {
        "title": entry.title,
        "content": entry.content,
        "mood": entry.mood,
        "goal_progress": entry.goal_progress,
        "created_at": datetime.now(timezone.utc).isoformat(),
    }
    # Insert the entry into the journal_entries table
    result = supabase.table("journal_entries").insert(data).execute()
    return {"status": "created", "entry": result.data[0]}

What does this endpoint do?

When someone sends a POST request to /entries, FastAPI validates the request body against EntryCreate (title, content, mood, goal_progress). If validation passes, the endpoint adds a UTC timestamp and inserts the row into your journal_entries table.

Notice we are not sending an embedding yet. You'll add embedding generation in the next step.

  • Save main.py.
  • You won't see a visible change yet. The next chunk adds read endpoints, then you'll test everything together.
Add the GET endpoints to read entries

Your API can create entries, but you also need to read them back. Two read endpoints cover both cases: listing all entries and fetching a single entry by ID.

  • In main.py, add the following code below the create_entry function:
@app.get("/entries")
def get_entries():
    # Fetch all entries, newest first, excluding the embedding column
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .order("created_at", desc=True)
        .execute()
    )
    return result.data


@app.get("/entries/{entry_id}")
def get_entry(entry_id: int):
    # Fetch a single entry by its ID
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .eq("id", entry_id)
        .single()
        .execute()
    )
    if not result.data:
        raise HTTPException(status_code=404, detail="Entry not found")
    return result.data

What do these endpoints do?

GET /entries returns every journal entry ordered by date (newest first). The select call explicitly lists columns so the large embedding vector is excluded from responses.

GET /entries/{entry_id} fetches one specific entry. If the ID does not exist, FastAPI returns a 404 error with a clear message.

  • Save main.py.
  • You'll test all three endpoints in the next substep.

✔️ Awesome, I've got everything!

Great. Double check you've saved your file before moving on.

ⓧ I'd like to double check the full code

import os
from datetime import datetime, timezone
from contextlib import asynccontextmanager

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
from supabase import create_client
from groq import Groq

load_dotenv()

embedding_model = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global embedding_model
    embedding_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
    yield


app = FastAPI(lifespan=lifespan)

supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])
groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])


class EntryCreate(BaseModel):
    title: str
    content: str
    mood: int
    goal_progress: int


class ChatMessage(BaseModel):
    message: str


@app.post("/entries")
def create_entry(entry: EntryCreate):
    data = {
        "title": entry.title,
        "content": entry.content,
        "mood": entry.mood,
        "goal_progress": entry.goal_progress,
        "created_at": datetime.now(timezone.utc).isoformat(),
    }
    result = supabase.table("journal_entries").insert(data).execute()
    return {"status": "created", "entry": result.data[0]}


@app.get("/entries")
def get_entries():
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .order("created_at", desc=True)
        .execute()
    )
    return result.data


@app.get("/entries/{entry_id}")
def get_entry(entry_id: int):
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .eq("id", entry_id)
        .single()
        .execute()
    )
    if not result.data:
        raise HTTPException(status_code=404, detail="Entry not found")
    return result.data
Test the API with the interactive docs page

FastAPI automatically generates a Swagger UI page at /docs where you can test every endpoint interactively. No external tools like Postman needed.

  • Start your FastAPI server by running this command in your terminal:
uvicorn main:app --reload

What does this command do?

uvicorn is the server that runs your FastAPI app. main:app tells it to look for the app variable inside main.py. The --reload flag restarts the server automatically whenever you save changes.

The first time you run this, the server will download the all-MiniLM-L6-v2 model (about 90MB). This may take a minute depending on your internet speed.

  • Wait until you see a line like Uvicorn running on http://127.0.0.1:8000 in your terminal.
  • Open your browser and go to http://127.0.0.1:8000/docs.

You should see the Swagger UI page listing your three endpoints: POST /entries, GET /entries, and GET /entries/{entry_id}.

  • Click on POST /entries to expand it.
  • Click Try it out.
  • Replace the example JSON body with a test entry:
{
  "title": "First entry",
  "content": "Today I started building my journal app.",
  "mood": 4,
  "goal_progress": 25
}
  • Click Execute.
  • Scroll down to the response section. You should see a 200 response with "status": "created" and your entry data returned.
  • Now click on GET /entries, click Try it out, then click Execute.
  • You should see your test entry in the response array.

Server won't start or seeing an error?

If you see ModuleNotFoundError, make sure your virtual environment is activated (you should see the environment name in your terminal prompt) and that you ran pip install -r requirements.txt previously.

If you see an error mentioning SUPABASE_URL or SUPABASE_KEY, double check your .env file has the correct values with no extra spaces around the = sign.

help me debug why my FastAPI server won't start

Your journal API is live and talking to Supabase. Next up, you'll supercharge it with AI-powered embeddings so the chatbot can actually search through your entries by meaning.

Add Embedding Generation and Storage

Your journal API can save and retrieve entries, but right now each entry is just text sitting in a database row. For the chatbot to find relevant past entries later, it needs a way to understand what each entry means.

Vector embeddings solve this. In this step, you'll write a helper function that converts journal entries into 384-dimensional numerical representations using the local all-MiniLM-L6-v2 model. Every time you save a new entry, an embedding gets stored alongside it, ready for semantic search.

In this step, get ready to:
  • Create a helper function that generates embeddings from entry text.
  • Update the POST /entries endpoint to store embeddings with each entry.
  • Verify embeddings appear in your Supabase dashboard.
Create the embedding helper function

The all-MiniLM-L6-v2 model is already loaded during app startup (via the lifespan function you wrote earlier). Now you need a reusable function that takes a string and returns a list of 384 floating-point numbers.

  • In main.py, find the ChatMessage class at the bottom of your model definitions.
  • Add the following function directly below the ChatMessage class:
def generate_embedding(text: str) -> list[float]:
    # Encode the text into a 384-dimensional vector
    embedding = embedding_model.encode(text)
    return embedding.tolist()

What does this code do?

embedding_model.encode(text) passes your string through the all-MiniLM-L6-v2 neural network and returns a NumPy array of 384 numbers. These numbers capture the semantic meaning of the text.

.tolist() converts the NumPy array into a plain Python list, which is what Supabase expects when inserting into a vector(384) column.

  • Save main.py.

You won't see a visible change yet. The next chunk uses this function inside the endpoint.

Update the POST /entries endpoint to generate and store embeddings

The embedding should capture everything meaningful about an entry. Instead of embedding just the content field, you'll combine the title, content, mood, and goal progress into a single string so the model understands the full picture.

  • In main.py, find the create_entry function.
  • Find this line inside the function:
    data = {
        "title": entry.title,
        "content": entry.content,
        "mood": entry.mood,
        "goal_progress": entry.goal_progress,
        "created_at": datetime.now(timezone.utc).isoformat(),
    }
  • Replace the entire create_entry function body (everything after def create_entry(entry: EntryCreate):) with the following:
@app.post("/entries")
def create_entry(entry: EntryCreate):
    # Combine all fields into a single string for richer embeddings
    combined_text = (
        f"Title: {entry.title}. "
        f"Content: {entry.content}. "
        f"Mood: {entry.mood}/5. "
        f"Goal progress: {entry.goal_progress}%."
    )
    embedding = generate_embedding(combined_text)

    data = {
        "title": entry.title,
        "content": entry.content,
        "mood": entry.mood,
        "goal_progress": entry.goal_progress,
        "embedding": embedding,
        "created_at": datetime.now(timezone.utc).isoformat(),
    }

    result = supabase.table("journal_entries").insert(data).execute()
    return {"status": "created", "entry": result.data[0]}

What does this code do?

  • combined_text merges the title, content, mood score, and goal percentage into one descriptive string. This gives the embedding model full context about the entry.
  • generate_embedding(combined_text) converts that string into a 384-number vector that captures its semantic meaning.
  • The "embedding": embedding key in the data dictionary stores the vector in the embedding column of your journal_entries table. pgvector handles it from there.
  • Save main.py.

Seeing a dimension mismatch error?

If you see an error about vector dimensions not matching, double-check that your journal_entries table's embedding column is defined as vector(384). The all-MiniLM-L6-v2 model produces exactly 384 dimensions, so the column size must match.

Help me debug a vector dimension mismatch in my Supabase journal app.

✔️ Awesome, I've got everything!

Great. Double check you've saved main.py and move on to verify your embeddings are working.

ⓧ I'd like to double check the full code

import os
from datetime import datetime, timezone
from contextlib import asynccontextmanager

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
from supabase import create_client
from groq import Groq

load_dotenv()

embedding_model = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global embedding_model
    embedding_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
    yield


app = FastAPI(lifespan=lifespan)

supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])
groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])


class EntryCreate(BaseModel):
    title: str
    content: str
    mood: int
    goal_progress: int


class ChatMessage(BaseModel):
    message: str


def generate_embedding(text: str) -> list[float]:
    embedding = embedding_model.encode(text)
    return embedding.tolist()


@app.post("/entries")
def create_entry(entry: EntryCreate):
    combined_text = (
        f"Title: {entry.title}. "
        f"Content: {entry.content}. "
        f"Mood: {entry.mood}/5. "
        f"Goal progress: {entry.goal_progress}%."
    )
    embedding = generate_embedding(combined_text)

    data = {
        "title": entry.title,
        "content": entry.content,
        "mood": entry.mood,
        "goal_progress": entry.goal_progress,
        "embedding": embedding,
        "created_at": datetime.now(timezone.utc).isoformat(),
    }

    result = supabase.table("journal_entries").insert(data).execute()
    return {"status": "created", "entry": result.data[0]}


@app.get("/entries")
def get_entries():
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .order("created_at", desc=True)
        .execute()
    )
    return result.data


@app.get("/entries/{entry_id}")
def get_entry(entry_id: int):
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .eq("id", entry_id)
        .single()
        .execute()
    )
    if not result.data:
        raise HTTPException(status_code=404, detail="Entry not found")
    return result.data
Verify embeddings are stored in Supabase

Time to test the full flow. You'll start the server, create a journal entry via the built-in API docs, then confirm the embedding landed in your database.

  • Start your FastAPI server by running this command in your terminal:
uvicorn main:app --reload

What does this command do?

uvicorn main:app --reload starts your FastAPI server with auto-reload enabled. The first time it runs, the lifespan function downloads the all-MiniLM-L6-v2 model (~90MB). This only happens once.

  • Open your browser and navigate to http://127.0.0.1:8000/docs.
  • Expand the POST /entries endpoint and click Try it out.
  • Replace the request body with this test entry:
{
  "title": "First day of coding",
  "content": "Started building my journal app today. Feeling motivated but a little overwhelmed by all the pieces.",
  "mood": 4,
  "goal_progress": 15
}
  • Click Execute.

You should see a 200 response with "status": "created" in the response body.

  • Switch to your Supabase dashboard.
  • Click Table Editor in the left sidebar.
  • Select the journal_entries table.
  • Look at the embedding column in the row you just created.

You should see a long array of numbers (like [0.0234, -0.0891, ...]) in the embedding column. That's your 384-dimensional vector.

Why combine all fields into one embedding?

When the chatbot searches for relevant entries later, it compares the meaning of your question against the meaning of each entry. If you only embedded the content field, the model would miss the mood and goal context entirely.

By combining title, content, mood, and goal progress into one string before embedding, a question like "Am I making progress?" can match entries where the goal_progress number tells the real story.

🙋‍♀️ Embedding column shows NULL?

Make sure you saved main.py after adding both the generate_embedding function and the updated create_entry endpoint. If uvicorn auto-reloaded before you added the function, the endpoint might have failed silently.

Check your terminal for error messages. If you see NameError: name 'generate_embedding' is not defined, ensure the function appears above the create_entry endpoint in your file.

Help me figure out why my embedding column is NULL in Supabase.

Your entries are now stored with semantic meaning baked in. Next up, you'll build the chatbot that searches these embeddings to find relevant context and give you honest feedback.

Build the RAG Chatbot

Your journal entries are stored with vector embeddings, ready to be searched. Now it is time to build the brain of your app: a chatbot that finds your most relevant past entries and uses them to give you brutally honest feedback.

This is where RAG (Retrieval-Augmented Generation) comes together. Your chatbot will embed the user's question, search for similar journal entries using cosine similarity, then pass those entries as context to Groq's LLM so it can respond with data-backed honesty.

In this step, get ready to:
  • Create a chat endpoint that embeds the user's message.
  • Retrieve the most relevant journal entries using semantic similarity.
  • Build a personality-driven system prompt and call Groq's LLM.
Create the chat endpoint and embed the user's message

The first stage of any RAG pipeline is turning the user's question into the same vector format as your stored data. By embedding the chat message with the same model you used for journal entries, you can compare them mathematically.

  • In main.py, scroll to the bottom of the file (after the get_entry function).
  • Add the beginning of the chat endpoint by pasting this code:
@app.post("/chat")
def chat(msg: ChatMessage):
    # Convert the user's message into a vector embedding
    query_embedding = generate_embedding(msg.message)

What does this code do?

This creates a new POST endpoint at /chat that accepts a ChatMessage (the Pydantic model you defined earlier with a message field).

It calls the same generate_embedding() function you built for journal entries. This produces a 384-dimensional vector that represents the meaning of the user's question.

You won't see a change yet. The next chunk adds the retrieval logic that uses this embedding.

Retrieve the most relevant journal entries

With the query embedding ready, you can now search your database for the journal entries that are most semantically similar to the user's question. This is the "Retrieval" in RAG.

  • Directly below the query_embedding line you just added, paste the following code (still inside the chat function):
    # Call the Supabase RPC function to find similar entries
    similar_entries = supabase.rpc(
        "match_entries",
        {
            "query_embedding": query_embedding,
            "match_threshold": 0.3,
            "match_count": 5,
        },
    ).execute()

    # Format each matching entry into a readable context string
    context_parts = []
    for entry in similar_entries.data:
        context_parts.append(
            f"[{entry['created_at'][:10]}] "
            f"(Mood: {entry['mood']}/5, Goal: {entry['goal_progress']}%) "
            f"{entry['title']}: {entry['content']}"
        )

    context = "\n\n".join(context_parts) if context_parts else "No journal entries found yet."

What does this code do?

  • supabase.rpc("match_entries", ...) calls the SQL function you created earlier in Supabase. It compares the query embedding against every entry's embedding using cosine distance.
  • match_threshold: 0.3 filters out entries that are too dissimilar. Only entries with a similarity score above 0.3 are returned.
  • match_count: 5 limits results to the top 5 most relevant entries.
  • The loop formats each entry into a human-readable string that includes the date, mood, goal progress, title, and content. This context string is what the LLM will read.

You still won't see a visible change. The final chunk sends this context to the LLM.

Build the accountability prompt and call Groq

The "Generation" part of RAG happens here. You will construct a system prompt that tells the LLM exactly how to behave, inject your retrieved journal entries as context, and send it all to Groq for a response.

  • Directly below the context line, paste this final section of the chat function:
    # Define the chatbot's personality and rules
    system_prompt = (
        "You are a direct, no-nonsense accountability partner. "
        "You have access to the user's journal entries which include their mood (1-5) "
        "and goal progress (0-100%). Your job is to tell them the TRUTH about their progress.\n\n"
        "Rules:\n"
        "- Compare goal_progress numbers across dates. If they are flat or declining, say so directly.\n"
        "- If the user says they are working hard but their numbers show no improvement, call it out.\n"
        "- If they ARE genuinely improving, celebrate it with specifics (dates, numbers).\n"
        "- Never give empty encouragement like 'keep going!' without backing it with data.\n"
        "- Reference specific entries by date when making points.\n"
        "- Be the friend who tells you what you need to hear, not what you want to hear.\n"
        "- If mood is consistently low, acknowledge it honestly and suggest they reflect on why.\n\n"
        f"Relevant journal entries:\n{context}"
    )

    # Send the prompt and user message to Groq's LLM
    response = groq_client.chat.completions.create(
        model="llama-3.3-70b-versatile",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": msg.message},
        ],
        temperature=0.7,
        max_tokens=1024,
    )

    return {"response": response.choices[0].message.content}

What does this code do?

  • The system_prompt string defines the chatbot's personality. It includes explicit rules about comparing numbers across dates, calling out stagnation, celebrating real improvement, and never giving empty encouragement.
  • The retrieved journal entries are injected at the end of the prompt so the LLM can reference specific dates and progress values.
  • groq_client.chat.completions.create(...) sends the prompt to llama-3.3-70b-versatile on Groq's infrastructure.
  • temperature=0.7 gives the model some creativity while keeping responses grounded. max_tokens=1024 caps the response length.

💡 Why this personality approach?

Most chatbot tutorials use a generic "helpful assistant" prompt. The problem is that generic prompts produce generic responses.

By giving the LLM specific rules about comparing numbers and referencing dates, you force it to ground its answers in your actual data. The chatbot cannot say "great job!" without pointing to a specific entry that shows improvement.

  • Save main.py.
  • Start your server by running this command in the terminal:
uvicorn main:app --reload
  • Open your browser and go to http://127.0.0.1:8000/docs.
  • Click on the POST /chat endpoint, then click Try it out.
  • Enter a test message like {"message": "Am I making progress?"} and click Execute.
  • You should see a JSON response with a direct, data-backed answer referencing your journal entries.

Getting a 500 error or empty response?

  • Check that you have at least one journal entry saved in Supabase with an embedding. The chatbot needs entries to retrieve.
  • Make sure your GROQ_API_KEY in .env is valid. An invalid key returns an authentication error.
  • Verify the match_entries function exists in Supabase by checking the SQL Editor. If it is missing, the RPC call fails.

help me debug my /chat endpoint error

✔️ Awesome, I've got everything!

Great. Double check you have saved main.py and your server is running without errors.

ⓧ I'd like to double check the full code

import os
from datetime import datetime, timezone
from contextlib import asynccontextmanager

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
from supabase import create_client
from groq import Groq

load_dotenv()

embedding_model = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global embedding_model
    embedding_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
    yield


app = FastAPI(lifespan=lifespan)

supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])
groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])


class EntryCreate(BaseModel):
    title: str
    content: str
    mood: int
    goal_progress: int


class ChatMessage(BaseModel):
    message: str


def generate_embedding(text: str) -> list[float]:
    embedding = embedding_model.encode(text)
    return embedding.tolist()


@app.post("/entries")
def create_entry(entry: EntryCreate):
    combined_text = (
        f"Title: {entry.title}. "
        f"Content: {entry.content}. "
        f"Mood: {entry.mood}/5. "
        f"Goal progress: {entry.goal_progress}%."
    )
    embedding = generate_embedding(combined_text)

    data = {
        "title": entry.title,
        "content": entry.content,
        "mood": entry.mood,
        "goal_progress": entry.goal_progress,
        "embedding": embedding,
        "created_at": datetime.now(timezone.utc).isoformat(),
    }

    result = supabase.table("journal_entries").insert(data).execute()
    return {"status": "created", "entry": result.data[0]}


@app.get("/entries")
def get_entries():
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .order("created_at", desc=True)
        .execute()
    )
    return result.data


@app.get("/entries/{entry_id}")
def get_entry(entry_id: int):
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .eq("id", entry_id)
        .single()
        .execute()
    )
    if not result.data:
        raise HTTPException(status_code=404, detail="Entry not found")
    return result.data


@app.post("/chat")
def chat(msg: ChatMessage):
    query_embedding = generate_embedding(msg.message)

    similar_entries = supabase.rpc(
        "match_entries",
        {
            "query_embedding": query_embedding,
            "match_threshold": 0.3,
            "match_count": 5,
        },
    ).execute()

    context_parts = []
    for entry in similar_entries.data:
        context_parts.append(
            f"[{entry['created_at'][:10]}] "
            f"(Mood: {entry['mood']}/5, Goal: {entry['goal_progress']}%) "
            f"{entry['title']}: {entry['content']}"
        )

    context = "\n\n".join(context_parts) if context_parts else "No journal entries found yet."

    system_prompt = (
        "You are a direct, no-nonsense accountability partner. "
        "You have access to the user's journal entries which include their mood (1-5) "
        "and goal progress (0-100%). Your job is to tell them the TRUTH about their progress.\n\n"
        "Rules:\n"
        "- Compare goal_progress numbers across dates. If they are flat or declining, say so directly.\n"
        "- If the user says they are working hard but their numbers show no improvement, call it out.\n"
        "- If they ARE genuinely improving, celebrate it with specifics (dates, numbers).\n"
        "- Never give empty encouragement like 'keep going!' without backing it with data.\n"
        "- Reference specific entries by date when making points.\n"
        "- Be the friend who tells you what you need to hear, not what you want to hear.\n"
        "- If mood is consistently low, acknowledge it honestly and suggest they reflect on why.\n\n"
        f"Relevant journal entries:\n{context}"
    )

    response = groq_client.chat.completions.create(
        model="llama-3.3-70b-versatile",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": msg.message},
        ],
        temperature=0.7,
        max_tokens=1024,
    )

    return {"response": response.choices[0].message.content}

Your RAG chatbot is alive and grounded in your own writing. Next up, you will build a frontend so you can journal and chat without using the raw API docs page.

Build the Frontend

Your backend API is fully functional. It can save journal entries, generate embeddings, and chat using RAG. But right now, the only way to interact with it is through the /docs page.

In this step, you'll build a frontend interface that lets you write journal entries with mood and goal sliders, view your past entries, and chat with your accountability partner directly in the browser.

In this step, get ready to:
  • Create an HTML page with a journal form and chat interface.
  • Write JavaScript to handle form submissions and chat messages.
  • Serve the frontend from FastAPI and test the full flow.
Create the HTML page and stylesheet

Your frontend needs a folder to live in. FastAPI serves static files from a dedicated directory, so you'll create a static/ folder inside your project.

  • Create the static directory by running this command in your terminal:
mkdir static
  • Confirm the folder was created by running:
ls

You should see static listed alongside main.py and requirements.txt.

  • Create a new file called index.html inside the static/ folder in VS Code.
  • Paste the following HTML into static/index.html:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Journal</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <div class="container">
        <h1>My Journal</h1>

        <section class="journal-form">
            <h2>New Entry</h2>
            <form id="entry-form">
                <input type="text" id="title" placeholder="Entry title..." required>
                <textarea id="content" placeholder="Write your thoughts..." required></textarea>
                <div class="sliders">
                    <label>Mood: <span id="mood-value">3</span>/5
                        <input type="range" id="mood" min="1" max="5" value="3">
                    </label>
                    <label>Goal Progress: <span id="goal-value">50</span>%
                        <input type="range" id="goal-progress" min="0" max="100" value="50">
                    </label>
                </div>
                <button type="submit">Save Entry</button>
            </form>
        </section>

        <section class="entries-list">
            <h2>Past Entries</h2>
            <div id="entries"></div>
        </section>

        <section class="chat-section">
            <h2>Chat with Your Journal</h2>
            <div id="chat-messages"></div>
            <form id="chat-form">
                <input type="text" id="chat-input" placeholder="Ask about your journal..." required>
                <button type="submit">Send</button>
            </form>
        </section>
    </div>
    <script src="app.js"></script>
</body>
</html>

What does this HTML do?

The page has three sections. The first is a journal entry form with text fields for the title and content, plus two range sliders for mood (1-5) and goal progress (0-100%).

The second section displays past entries in a list. The third section is the chat interface where you talk to your accountability partner. Each section uses an id attribute so JavaScript can target it later.

  • Save static/index.html.

You won't see a visual change yet. The page needs CSS and JavaScript before it is useful.

  • Create a new file called style.css inside the static/ folder.
  • Paste the following CSS into static/style.css:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    background: #f5f5f5;
    color: #333;
    line-height: 1.6;
}

.container {
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
}

h1 {
    text-align: center;
    margin-bottom: 2rem;
    color: #2c3e50;
}

h2 {
    margin-bottom: 1rem;
    color: #34495e;
}

section {
    background: white;
    border-radius: 8px;
    padding: 1.5rem;
    margin-bottom: 1.5rem;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

input[type="text"],
textarea {
    width: 100%;
    padding: 0.75rem;
    border: 1px solid #ddd;
    border-radius: 4px;
    margin-bottom: 1rem;
    font-size: 1rem;
}

textarea {
    min-height: 120px;
    resize: vertical;
}

.sliders {
    display: flex;
    gap: 2rem;
    margin-bottom: 1rem;
}

.sliders label {
    flex: 1;
}

input[type="range"] {
    width: 100%;
}

button {
    background: #3498db;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
    font-size: 1rem;
}

button:hover {
    background: #2980b9;
}

.entry-card {
    border: 1px solid #eee;
    border-radius: 4px;
    padding: 1rem;
    margin-bottom: 0.75rem;
}

.entry-card .meta {
    font-size: 0.85rem;
    color: #888;
    margin-top: 0.5rem;
}

#chat-messages {
    max-height: 300px;
    overflow-y: auto;
    margin-bottom: 1rem;
    padding: 1rem;
    background: #fafafa;
    border-radius: 4px;
}

.chat-msg {
    margin-bottom: 0.75rem;
    padding: 0.5rem 0.75rem;
    border-radius: 4px;
}

.chat-msg.user {
    background: #3498db;
    color: white;
    margin-left: 20%;
}

.chat-msg.assistant {
    background: #ecf0f1;
    margin-right: 20%;
}

#chat-form {
    display: flex;
    gap: 0.5rem;
}

#chat-form input {
    flex: 1;
    margin-bottom: 0;
}

What does this stylesheet do?

The CSS creates a clean, card-based layout. Each section gets a white background with rounded corners and a subtle shadow. The chat messages are colour-coded: blue bubbles for your messages (pushed right) and grey bubbles for the assistant's replies (pushed left).

The sliders sit side by side using flexbox, and the chat form is a horizontal row with the input stretching to fill available space.

  • Save static/style.css.

Files not appearing in the sidebar?

Make sure you created both files inside the static/ folder, not in the project root. In the VS Code Explorer, expand the static folder and confirm you see index.html and style.css listed there.

Help me figure out where my files ended up

Add JavaScript for interactivity

The HTML page is static right now. You need JavaScript to connect the form and chat interface to your FastAPI endpoints.

  • Create a new file called app.js inside the static/ folder.
  • Paste the following initialization code into static/app.js:
const entryForm = document.getElementById("entry-form");
const chatForm = document.getElementById("chat-form");
const entriesDiv = document.getElementById("entries");
const chatMessages = document.getElementById("chat-messages");
const moodSlider = document.getElementById("mood");
const goalSlider = document.getElementById("goal-progress");
const moodValue = document.getElementById("mood-value");
const goalValue = document.getElementById("goal-value");

moodSlider.addEventListener("input", () => {
    moodValue.textContent = moodSlider.value;
});

goalSlider.addEventListener("input", () => {
    goalValue.textContent = goalSlider.value;
});

async function loadEntries() {
    const res = await fetch("/entries");
    const entries = await res.json();
    entriesDiv.innerHTML = entries
        .map(
            (e) => `
        <div class="entry-card">
            <strong>${e.title}</strong>
            <p>${e.content}</p>
            <div class="meta">
                ${e.created_at.slice(0, 10)} | Mood: ${e.mood}/5 | Goal: ${e.goal_progress}%
            </div>
        </div>
    `
        )
        .join("");
}

What does this code do?

  • The first block caches references to all the DOM elements the script needs (forms, sliders, display areas).
  • The slider event listeners update the displayed mood and goal numbers in real time as you drag the sliders.
  • The loadEntries() function fetches all entries from the GET /entries endpoint and renders them as cards with the title, content, date, mood, and goal progress.

You won't see a change yet. The next chunk adds the form handlers that make the page interactive.

  • Add the following event handlers below the loadEntries function in static/app.js:
entryForm.addEventListener("submit", async (e) => {
    e.preventDefault();
    const data = {
        title: document.getElementById("title").value,
        content: document.getElementById("content").value,
        mood: parseInt(moodSlider.value),
        goal_progress: parseInt(goalSlider.value),
    };

    const res = await fetch("/entries", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
    });

    if (res.ok) {
        entryForm.reset();
        moodSlider.value = 3;
        goalSlider.value = 50;
        moodValue.textContent = "3";
        goalValue.textContent = "50";
        loadEntries();
    }
});

chatForm.addEventListener("submit", async (e) => {
    e.preventDefault();
    const input = document.getElementById("chat-input");
    const message = input.value;
    input.value = "";

    chatMessages.innerHTML += `<div class="chat-msg user">${message}</div>`;
    chatMessages.scrollTop = chatMessages.scrollHeight;

    const res = await fetch("/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message }),
    });

    const data = await res.json();
    chatMessages.innerHTML += `<div class="chat-msg assistant">${data.response}</div>`;
    chatMessages.scrollTop = chatMessages.scrollHeight;
});

loadEntries();

What do these handlers do?

The entry form handler collects the title, content, mood, and goal progress values, sends them as JSON to POST /entries, then resets the form and reloads the entry list on success.

The chat handler shows your message immediately as a blue bubble, sends it to POST /chat, and appends the assistant's reply as a grey bubble. The final loadEntries() call at the bottom loads existing entries when the page first opens.

  • Save static/app.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three static files before moving on.

ⓧ I'd like to double check the full code

const entryForm = document.getElementById("entry-form");
const chatForm = document.getElementById("chat-form");
const entriesDiv = document.getElementById("entries");
const chatMessages = document.getElementById("chat-messages");
const moodSlider = document.getElementById("mood");
const goalSlider = document.getElementById("goal-progress");
const moodValue = document.getElementById("mood-value");
const goalValue = document.getElementById("goal-value");

moodSlider.addEventListener("input", () => {
    moodValue.textContent = moodSlider.value;
});

goalSlider.addEventListener("input", () => {
    goalValue.textContent = goalSlider.value;
});

async function loadEntries() {
    const res = await fetch("/entries");
    const entries = await res.json();
    entriesDiv.innerHTML = entries
        .map(
            (e) => `
        <div class="entry-card">
            <strong>${e.title}</strong>
            <p>${e.content}</p>
            <div class="meta">
                ${e.created_at.slice(0, 10)} | Mood: ${e.mood}/5 | Goal: ${e.goal_progress}%
            </div>
        </div>
    `
        )
        .join("");
}

entryForm.addEventListener("submit", async (e) => {
    e.preventDefault();
    const data = {
        title: document.getElementById("title").value,
        content: document.getElementById("content").value,
        mood: parseInt(moodSlider.value),
        goal_progress: parseInt(goalSlider.value),
    };

    const res = await fetch("/entries", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(data),
    });

    if (res.ok) {
        entryForm.reset();
        moodSlider.value = 3;
        goalSlider.value = 50;
        moodValue.textContent = "3";
        goalValue.textContent = "50";
        loadEntries();
    }
});

chatForm.addEventListener("submit", async (e) => {
    e.preventDefault();
    const input = document.getElementById("chat-input");
    const message = input.value;
    input.value = "";

    chatMessages.innerHTML += `<div class="chat-msg user">${message}</div>`;
    chatMessages.scrollTop = chatMessages.scrollHeight;

    const res = await fetch("/chat", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ message }),
    });

    const data = await res.json();
    chatMessages.innerHTML += `<div class="chat-msg assistant">${data.response}</div>`;
    chatMessages.scrollTop = chatMessages.scrollHeight;
});

loadEntries();
Serve the frontend and test the full flow

FastAPI needs to know about your static/ folder. You'll mount it so that visiting the root URL serves your index.html automatically.

  • Switch back to main.py in VS Code.
  • Scroll to the very bottom of the file, after the chat function's return statement.
  • Add this line at the end of the file:
app.mount("/", StaticFiles(directory="static", html=True), name="static")

Why mount at the root?

Setting the path to "/" means your HTML is served at the base URL. The html=True parameter tells FastAPI to automatically serve index.html when someone visits the root path.

This line MUST be the last line in the file. FastAPI checks routes top-to-bottom. If you mount static files before your API endpoints, the static handler would swallow requests meant for /entries or /chat.

  • Save main.py.

✔️ Awesome, I've got everything!

Great. Make sure you saved main.py before running the server.

ⓧ I'd like to double check the full code

import os
from datetime import datetime, timezone
from contextlib import asynccontextmanager

from dotenv import load_dotenv
from fastapi import FastAPI, HTTPException
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from sentence_transformers import SentenceTransformer
from supabase import create_client
from groq import Groq

load_dotenv()

embedding_model = None


@asynccontextmanager
async def lifespan(app: FastAPI):
    global embedding_model
    embedding_model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
    yield


app = FastAPI(lifespan=lifespan)

supabase = create_client(os.environ["SUPABASE_URL"], os.environ["SUPABASE_KEY"])
groq_client = Groq(api_key=os.environ["GROQ_API_KEY"])


class EntryCreate(BaseModel):
    title: str
    content: str
    mood: int
    goal_progress: int


class ChatMessage(BaseModel):
    message: str


def generate_embedding(text: str) -> list[float]:
    embedding = embedding_model.encode(text)
    return embedding.tolist()


@app.post("/entries")
def create_entry(entry: EntryCreate):
    combined_text = (
        f"Title: {entry.title}. "
        f"Content: {entry.content}. "
        f"Mood: {entry.mood}/5. "
        f"Goal progress: {entry.goal_progress}%."
    )
    embedding = generate_embedding(combined_text)

    data = {
        "title": entry.title,
        "content": entry.content,
        "mood": entry.mood,
        "goal_progress": entry.goal_progress,
        "embedding": embedding,
        "created_at": datetime.now(timezone.utc).isoformat(),
    }

    result = supabase.table("journal_entries").insert(data).execute()
    return {"status": "created", "entry": result.data[0]}


@app.get("/entries")
def get_entries():
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .order("created_at", desc=True)
        .execute()
    )
    return result.data


@app.get("/entries/{entry_id}")
def get_entry(entry_id: int):
    result = (
        supabase.table("journal_entries")
        .select("id, title, content, mood, goal_progress, created_at")
        .eq("id", entry_id)
        .single()
        .execute()
    )
    if not result.data:
        raise HTTPException(status_code=404, detail="Entry not found")
    return result.data


@app.post("/chat")
def chat(msg: ChatMessage):
    query_embedding = generate_embedding(msg.message)

    similar_entries = supabase.rpc(
        "match_entries",
        {
            "query_embedding": query_embedding,
            "match_threshold": 0.3,
            "match_count": 5,
        },
    ).execute()

    context_parts = []
    for entry in similar_entries.data:
        context_parts.append(
            f"[{entry['created_at'][:10]}] "
            f"(Mood: {entry['mood']}/5, Goal: {entry['goal_progress']}%) "
            f"{entry['title']}: {entry['content']}"
        )

    context = "\n\n".join(context_parts) if context_parts else "No journal entries found yet."

    system_prompt = (
        "You are a direct, no-nonsense accountability partner. "
        "You have access to the user's journal entries which include their mood (1-5) "
        "and goal progress (0-100%). Your job is to tell them the TRUTH about their progress.\n\n"
        "Rules:\n"
        "- Compare goal_progress numbers across dates. If they are flat or declining, say so directly.\n"
        "- If the user says they are working hard but their numbers show no improvement, call it out.\n"
        "- If they ARE genuinely improving, celebrate it with specifics (dates, numbers).\n"
        "- Never give empty encouragement like 'keep going!' without backing it with data.\n"
        "- Reference specific entries by date when making points.\n"
        "- Be the friend who tells you what you need to hear, not what you want to hear.\n"
        "- If mood is consistently low, acknowledge it honestly and suggest they reflect on why.\n\n"
        f"Relevant journal entries:\n{context}"
    )

    response = groq_client.chat.completions.create(
        model="llama-3.3-70b-versatile",
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": msg.message},
        ],
        temperature=0.7,
        max_tokens=1024,
    )

    return {"response": response.choices[0].message.content}


app.mount("/", StaticFiles(directory="static", html=True), name="static")
  • Start the server by running this command:
uvicorn main:app --reload

What should I see?

The terminal shows a message that the application has started. The first time may take a few seconds as the embedding model loads into memory. Once you see the startup message, the server is ready.

  • Open your browser and navigate to http://127.0.0.1:8000.

You should see the My Journal page with the entry form, past entries section, and chat interface.

  • Test the full flow by filling in the journal form. Type a title like Week 1 update and some content about your goals.
  • Adjust the mood slider and goal progress slider.
  • Click **Save Entry**.
  • Verify the entry appears in the Past Entries section below the form.
  • In the chat input, type Am I making progress? and press **Send**.
  • Wait a few seconds for the response. The chatbot should reply with a direct, data-backed answer referencing your journal entry.

Page shows "Not Found" or a blank screen?

  • Check that the app.mount line is the very last line in main.py. If it appears before your endpoint definitions, the static handler will intercept API requests.
  • Confirm static/index.html exists. The file must be named exactly index.html (not Index.html or index.HTML).
  • If the chat returns an error, verify your .env file has valid keys and that your Supabase project is running.

Help me debug my frontend loading issue

Your journal app is fully functional with entries, embeddings, and an AI chatbot. Next up, you'll add the ability to tag, edit, delete, and search your entries so the app stays usable as your journal grows.

Add Entry Tags, Edit, and Search

Your journal app works, but as entries pile up, managing them becomes painful. You cannot fix a typo without deleting and rewriting an entry, and there is no way to find that one post about a breakthrough you had two weeks ago.

In this step, you'll give entries a tagging system, add edit and delete capabilities, and build search filtering. These are the features that turn a demo into something you actually want to use daily.

In this step, get ready to:
  • Add a tags column to your database and update the API model.
  • Build edit and delete endpoints with embedding regeneration.
  • Add search filtering and update the frontend with tag input, edit/delete buttons, and filters.
Add a tags column and update the API

Tags let you categorize entries by theme (study, personal, project). You need a new column in your database and an updated Pydantic model so the API accepts tags.

  • Open your Supabase project dashboard.
  • Click SQL Editor in the left sidebar.
  • Add the tags column by running this SQL:
alter table journal_entries
add column tags text[] default '{}';

What does this do?

This adds a tags column that stores an array of text strings. The default is an empty array, so existing entries won't break.

  • Click Run to execute the SQL.
  • Verify by clicking Table Editor and selecting journal_entries. You should see a new tags column.
  • Switch back to main.py in VS Code.
  • Find the EntryCreate class. Replace it with this updated version that includes tags:
class EntryCreate(BaseModel):
    title: str
    content: str
    mood: int
    goal_progress: int
    tags: list[str] = []  # Optional list of tags, defaults to empty

What changed?

The tags field is a list of strings with a default of an empty list. This means existing API calls without tags still work, but new entries can include tags like ["study", "project"].

  • Find the create_entry function. Add tags to the data dictionary. Find the line that says "created_at": and add a new line below it:
        "tags": entry.tags,
  • Also update the select calls in both get_entries and get_entry to include the tags column. Change the select string to:
"id, title, content, mood, goal_progress, tags, created_at"
  • Save main.py.

Server crashes after adding tags?

Make sure the tags field has a default value (= []) in the Pydantic model. Without the default, every existing API call that omits tags will fail validation.

help me fix my tags field causing validation errors

Add edit and delete endpoints

Right now, a typo in an entry means you are stuck with it forever. You need a PUT endpoint for editing and a DELETE endpoint for removing entries.

  • In main.py, add these two new endpoints below the get_entry function and above the chat function:
@app.put("/entries/{entry_id}")
def update_entry(entry_id: int, entry: EntryCreate):
    # Regenerate the embedding with the updated content
    combined_text = (
        f"Title: {entry.title}. "
        f"Content: {entry.content}. "
        f"Mood: {entry.mood}/5. "
        f"Goal progress: {entry.goal_progress}%."
    )
    embedding = generate_embedding(combined_text)

    data = {
        "title": entry.title,
        "content": entry.content,
        "mood": entry.mood,
        "goal_progress": entry.goal_progress,
        "tags": entry.tags,
        "embedding": embedding,
    }

    result = (
        supabase.table("journal_entries")
        .update(data)
        .eq("id", entry_id)
        .execute()
    )
    if not result.data:
        raise HTTPException(status_code=404, detail="Entry not found")
    return {"status": "updated", "entry": result.data[0]}


@app.delete("/entries/{entry_id}")
def delete_entry(entry_id: int):
    result = (
        supabase.table("journal_entries")
        .delete()
        .eq("id", entry_id)
        .execute()
    )
    if not result.data:
        raise HTTPException(status_code=404, detail="Entry not found")
    return {"status": "deleted"}

Why regenerate the embedding on edit?

The embedding is a vector representation of the entry's content. If you change the text but keep the old embedding, your RAG chatbot will search against stale data. Regenerating ensures the chatbot always finds entries based on their current content.

  • Save main.py.
  • Your server should auto-reload. Open http://127.0.0.1:8000/docs in your browser and confirm you see PUT /entries/{entry_id} and DELETE /entries/{entry_id} listed.

Don't see the new endpoints?

Make sure you placed the new functions above the app.mount line at the bottom of main.py. Any endpoint defined after the static mount will be swallowed by the file server.

help me figure out why my new endpoints aren't showing up

Add search filtering and update the frontend

Your GET endpoint returns every entry. As the journal grows, you need filtering by tag, keyword, and date range so you can find what you need.

  • In main.py, add this import at the top of the file, below the existing imports:
from typing import Optional
  • Replace the existing get_entries function with this enhanced version that accepts query parameters:
@app.get("/entries")
def get_entries(
    tag: Optional[str] = None,
    keyword: Optional[str] = None,
    start_date: Optional[str] = None,
    end_date: Optional[str] = None,
):
    # Start building the query
    query = supabase.table("journal_entries").select(
        "id, title, content, mood, goal_progress, tags, created_at"
    )

    # Filter by tag if provided
    if tag:
        query = query.contains("tags", [tag])

    # Filter by keyword in title or content
    if keyword:
        query = query.or_(f"title.ilike.%{keyword}%,content.ilike.%{keyword}%")

    # Filter by date range
    if start_date:
        query = query.gte("created_at", start_date)
    if end_date:
        query = query.lte("created_at", end_date)

    result = query.order("created_at", desc=True).execute()
    return result.data

How does the filtering work?

Each query parameter is optional. When provided, it adds a filter to the Supabase query. contains checks if the tags array includes the given tag. ilike does a case-insensitive keyword search across title and content.

When no filters are passed, the endpoint behaves exactly as before and returns all entries.

  • Save main.py.

Now update the frontend to support tags, editing, deleting, and filtering.

  • Open static/index.html. Find the closing </div> tag for the sliders div. Add a tags input directly below it, before the submit button:
                <input type="text" id="tags" placeholder="Tags (comma-separated, e.g. study, project)...">
  • Find the entries-list section. Replace the entire section with this updated version that includes filter controls:
        <section class="entries-list">
            <h2>Past Entries</h2>
            <div class="filters">
                <input type="text" id="filter-keyword" placeholder="Search by keyword...">
                <input type="text" id="filter-tag" placeholder="Filter by tag...">
                <button id="filter-btn">Filter</button>
                <button id="clear-btn">Clear</button>
            </div>
            <div id="entries"></div>
        </section>
  • Save static/index.html.
  • Open static/style.css. Add these styles at the bottom of the file:
.filters {
    display: flex;
    gap: 0.5rem;
    margin-bottom: 1rem;
    flex-wrap: wrap;
}

.filters input {
    flex: 1;
    min-width: 120px;
    margin-bottom: 0;
}

.filters button {
    padding: 0.5rem 1rem;
}

.entry-card .tags {
    margin-top: 0.5rem;
}

.entry-card .tags span {
    display: inline-block;
    background: #e8f4f8;
    color: #2980b9;
    padding: 0.2rem 0.5rem;
    border-radius: 3px;
    font-size: 0.8rem;
    margin-right: 0.3rem;
}

.entry-card .actions {
    margin-top: 0.5rem;
}

.entry-card .actions button {
    padding: 0.3rem 0.7rem;
    font-size: 0.8rem;
    margin-right: 0.3rem;
}

.entry-card .actions .delete-btn {
    background: #e74c3c;
}

.entry-card .actions .delete-btn:hover {
    background: #c0392b;
}
  • Save static/style.css.
  • Open static/app.js. Replace the entire loadEntries function with this updated version that supports filtering and adds edit/delete buttons:
async function loadEntries(filters = {}) {
    // Build query string from filter parameters
    const params = new URLSearchParams();
    if (filters.tag) params.append("tag", filters.tag);
    if (filters.keyword) params.append("keyword", filters.keyword);

    const res = await fetch(`/entries?${params.toString()}`);
    const entries = await res.json();
    entriesDiv.innerHTML = entries
        .map(
            (e) => `
        <div class="entry-card">
            <strong>${e.title}</strong>
            <p>${e.content}</p>
            <div class="meta">
                ${e.created_at.slice(0, 10)} | Mood: ${e.mood}/5 | Goal: ${e.goal_progress}%
            </div>
            ${e.tags && e.tags.length > 0 ? `<div class="tags">${e.tags.map((t) => `<span>${t}</span>`).join("")}</div>` : ""}
            <div class="actions">
                <button onclick="editEntry(${e.id}, '${e.title.replace(/'/g, "\\'"}', '${e.content.replace(/'/g, "\\'").replace(/\n/g, "\\n")}', ${e.mood}, ${e.goal_progress}, '${(e.tags || []).join(",")}')">Edit</button>
                <button class="delete-btn" onclick="deleteEntry(${e.id})">Delete</button>
            </div>
        </div>
    `
        )
        .join("");
}
  • Add the edit, delete, and filter handler functions below the loadEntries function:
let editingId = null;

function editEntry(id, title, content, mood, goalProgress, tags) {
    // Pre-fill the form with existing entry data
    editingId = id;
    document.getElementById("title").value = title;
    document.getElementById("content").value = content;
    moodSlider.value = mood;
    goalSlider.value = goalProgress;
    moodValue.textContent = mood;
    goalValue.textContent = goalProgress;
    document.getElementById("tags").value = tags;
    // Scroll to the form
    document.querySelector(".journal-form").scrollIntoView({ behavior: "smooth" });
}

async function deleteEntry(id) {
    if (!confirm("Are you sure you want to delete this entry?")) return;
    const res = await fetch(`/entries/${id}`, { method: "DELETE" });
    if (res.ok) loadEntries();
}

// Filter button handlers
document.getElementById("filter-btn").addEventListener("click", () => {
    const keyword = document.getElementById("filter-keyword").value;
    const tag = document.getElementById("filter-tag").value;
    loadEntries({ keyword, tag });
});

document.getElementById("clear-btn").addEventListener("click", () => {
    document.getElementById("filter-keyword").value = "";
    document.getElementById("filter-tag").value = "";
    loadEntries();
});

What does the edit flow do?

Clicking Edit on a card pre-fills the journal form with that entry's data and stores its ID. When you submit the form, it detects the stored ID and sends a PUT request instead of a POST. After saving, it clears the editing state.

  • Finally, update the entry form submit handler to support both creating and editing. Replace the existing entryForm.addEventListener block with this version:
entryForm.addEventListener("submit", async (e) => {
    e.preventDefault();
    const tagsRaw = document.getElementById("tags").value;
    const tags = tagsRaw ? tagsRaw.split(",").map((t) => t.trim()).filter(Boolean) : [];

    const data = {
        title: document.getElementById("title").value,
        content: document.getElementById("content").value,
        mood: parseInt(moodSlider.value),
        goal_progress: parseInt(goalSlider.value),
        tags: tags,
    };

    let res;
    if (editingId) {
        // Update existing entry
        res = await fetch(`/entries/${editingId}`, {
            method: "PUT",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
        });
    } else {
        // Create new entry
        res = await fetch("/entries", {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify(data),
        });
    }

    if (res.ok) {
        editingId = null;
        entryForm.reset();
        moodSlider.value = 3;
        goalSlider.value = 50;
        moodValue.textContent = "3";
        goalValue.textContent = "50";
        loadEntries();
    }
});
  • Save static/app.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved all three files before testing.

ⓧ I'd like to double check the full code

Compare your static/app.js against the full updated file. Make sure the loadEntries function, edit/delete helpers, filter handlers, and updated form submit handler are all present.

  • Refresh your browser at http://127.0.0.1:8000.
  • Create a new entry with a title like Study session and add study, python in the tags field.
  • Click Save Entry.
  • Verify the entry appears with tag badges below it.
  • Click Edit on the entry. The form should scroll up and pre-fill with the entry's data.
  • Change the title and click Save Entry again. The entry should update in place.
  • Type study in the tag filter and click Filter. Only entries with the study tag should appear.
  • Click Clear to reset the filter.
  • Click Delete on a test entry and confirm the deletion. The entry should disappear.

Edit button not pre-filling the form?

Check that the editEntry function is defined in global scope (not inside another function). The onclick attribute on the button calls it directly, so it must be accessible globally.

Also check for quote escaping issues in the entry title or content. If an entry contains a single quote, the onclick handler can break.

help me debug why my edit button isn't working

Your entries are now fully manageable with tags, editing, and search. Next up, you'll visualize your mood and goal data over time with interactive charts.

Add Visual Insights and Charts

You can now tag, edit, and search your entries. But numbers in isolation tell you very little. A mood of 3 today means nothing unless you can see whether it has been climbing or falling over the past month.

In this step, you'll build a trends API endpoint, pull in Chart.js for interactive line charts, and add a day-of-week correlation view. You'll also fix the slider layout to read more cleanly inline.

In this step, get ready to:
  • Build a trends API endpoint that returns mood and goal data grouped by date.
  • Add Chart.js and render interactive line charts.
  • Add day-of-week mood correlation and fix inline slider formatting.
Build the trends API endpoint

The frontend needs structured data to draw charts. You'll add an endpoint that returns daily averages of mood and goal progress, plus a day-of-week breakdown.

  • In main.py, add these two new endpoints below the delete_entry function and above the chat function:
@app.get("/entries/trends")
def get_trends(days: int = 30):
    # Calculate the start date based on the days parameter
    start = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
    result = (
        supabase.table("journal_entries")
        .select("mood, goal_progress, created_at")
        .gte("created_at", start)
        .order("created_at")
        .execute()
    )

    # Group entries by date and calculate daily averages
    daily = {}
    for entry in result.data:
        date = entry["created_at"][:10]
        if date not in daily:
            daily[date] = {"moods": [], "goals": []}
        daily[date]["moods"].append(entry["mood"])
        daily[date]["goals"].append(entry["goal_progress"])

    trends = []
    for date, values in sorted(daily.items()):
        trends.append({
            "date": date,
            "avg_mood": round(sum(values["moods"]) / len(values["moods"]), 1),
            "avg_goal": round(sum(values["goals"]) / len(values["goals"]), 1),
        })

    return trends


@app.get("/entries/correlations")
def get_correlations():
    # Fetch all entries to compute day-of-week averages
    result = (
        supabase.table("journal_entries")
        .select("mood, goal_progress, created_at")
        .execute()
    )

    # Group by day of week (0=Monday, 6=Sunday)
    days = {i: {"moods": [], "goals": []} for i in range(7)}
    for entry in result.data:
        dt = datetime.fromisoformat(entry["created_at"].replace("Z", "+00:00"))
        dow = dt.weekday()
        days[dow]["moods"].append(entry["mood"])
        days[dow]["goals"].append(entry["goal_progress"])

    day_names = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
    correlations = []
    for i, name in enumerate(day_names):
        if days[i]["moods"]:
            correlations.append({
                "day": name,
                "avg_mood": round(sum(days[i]["moods"]) / len(days[i]["moods"]), 1),
                "avg_goal": round(sum(days[i]["goals"]) / len(days[i]["goals"]), 1),
            })
        else:
            correlations.append({"day": name, "avg_mood": 0, "avg_goal": 0})

    return correlations

What do these endpoints return?

The /entries/trends endpoint returns an array of objects with a date, average mood, and average goal progress for each day. The days parameter controls how far back to look (defaults to 30 days).

The /entries/correlations endpoint groups all entries by day of week and computes averages. This answers questions like "Am I happier on weekends?"

  • Save main.py.
  • Test the endpoint by visiting http://127.0.0.1:8000/entries/trends in your browser. You should see a JSON array of date/mood/goal objects.

Seeing an empty array?

The trends endpoint only returns entries from the last 30 days. If your test entries are older, try /entries/trends?days=365 or create a few new entries first.

help me debug why my trends endpoint returns empty data

Add Chart.js and render line charts

Chart.js is a lightweight charting library you can load from a CDN. No installation needed.

  • Open static/index.html. Add this script tag in the <head> section, below the stylesheet link:
    <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
  • Add a new insights section. Find the entries-list section's closing </section> tag. Add this new section directly below it, before the chat section:
        <section class="insights-section">
            <h2>Insights</h2>
            <div class="chart-container">
                <canvas id="trends-chart"></canvas>
            </div>
            <div class="chart-container">
                <canvas id="correlation-chart"></canvas>
            </div>
        </section>
  • Save static/index.html.
  • Open static/style.css. Add these chart styles at the bottom:
.chart-container {
    position: relative;
    margin-bottom: 1.5rem;
    height: 250px;
}
  • Save static/style.css.
  • Open static/app.js. Add the chart rendering functions at the bottom of the file, before the final loadEntries() call:
async function loadCharts() {
    // Fetch trends data for the last 30 days
    const trendsRes = await fetch("/entries/trends?days=30");
    const trends = await trendsRes.json();

    // Fetch day-of-week correlation data
    const corrRes = await fetch("/entries/correlations");
    const correlations = await corrRes.json();

    // Render the mood/goal trends line chart
    const trendsCtx = document.getElementById("trends-chart").getContext("2d");
    new Chart(trendsCtx, {
        type: "line",
        data: {
            labels: trends.map((t) => t.date),
            datasets: [
                {
                    label: "Mood (1-5)",
                    data: trends.map((t) => t.avg_mood),
                    borderColor: "#3498db",
                    backgroundColor: "rgba(52, 152, 219, 0.1)",
                    yAxisID: "y",
                    tension: 0.3,
                },
                {
                    label: "Goal Progress (%)",
                    data: trends.map((t) => t.avg_goal),
                    borderColor: "#2ecc71",
                    backgroundColor: "rgba(46, 204, 113, 0.1)",
                    yAxisID: "y1",
                    tension: 0.3,
                },
            ],
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            scales: {
                y: { min: 1, max: 5, position: "left", title: { display: true, text: "Mood" } },
                y1: { min: 0, max: 100, position: "right", title: { display: true, text: "Goal %" }, grid: { drawOnChartArea: false } },
            },
        },
    });

    // Render the day-of-week bar chart
    const corrCtx = document.getElementById("correlation-chart").getContext("2d");
    new Chart(corrCtx, {
        type: "bar",
        data: {
            labels: correlations.map((c) => c.day),
            datasets: [
                {
                    label: "Avg Mood",
                    data: correlations.map((c) => c.avg_mood),
                    backgroundColor: "rgba(52, 152, 219, 0.6)",
                },
            ],
        },
        options: {
            responsive: true,
            maintainAspectRatio: false,
            scales: {
                y: { min: 0, max: 5, title: { display: true, text: "Avg Mood" } },
            },
        },
    });
}

loadCharts();

What do these charts show?

The first chart is a dual-axis line chart: mood (1-5) on the left, goal progress (0-100%) on the right. This lets you spot whether mood drops when goal progress stalls.

The second chart is a bar chart showing average mood by day of week. It answers "Are my weekends happier than my weekdays?"

  • Save static/app.js.
Fix inline slider formatting

The mood and goal sliders currently stack vertically inside each label. A small CSS fix makes the label text and value sit inline, reducing visual clutter.

  • In static/style.css, find the .sliders label rule. Replace it with this updated version:
.sliders label {
    flex: 1;
    display: flex;
    flex-direction: column;
    font-size: 0.9rem;
    font-weight: 500;
}
  • Save static/style.css.
  • Refresh your browser at http://127.0.0.1:8000. You should see the Insights section with two charts below your entries list.

Charts showing but empty?

Charts need data to render. If you only have one or two entries, the line chart will look sparse. Create 3-4 test entries across different days (you can backdate them in Supabase's Table Editor by editing the created_at field directly) to see meaningful trends.

help me debug why my charts aren't rendering

Your journal now shows trends over time. Next up, you'll build an AI coach that proactively generates weekly reflections and prompts you based on your mood patterns.

Secret mission

Add a Habit Tracker

Your chatbot knows about your journal entries, but it has no visibility into your daily habits. In this secret mission, you will add a habit tracker that logs daily habits, calculates streaks, and feeds that data into the chatbot so it can call you out for skipping days or celebrate your consistency.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally with free-tier cloud services. There are no ongoing costs unless you exceed Supabase's 500MB database limit or Groq's free-tier rate limits.

Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Supabase project with journal_entries and habits tables (free tier, 500MB database).
  • Local project folder with Python virtual environment, FastAPI app, and static frontend files.
  • Groq API key (free tier, no charges).
  • Downloaded all-MiniLM-L6-v2 model cached locally by sentence-transformers.

Keep everything running

No action needed. Choose this if you plan to keep journaling and using the chatbot.

  • Your Supabase database persists your journal entries and habit logs indefinitely on the free tier.
  • The local FastAPI server only runs when you start it manually with uvicorn. It does not consume resources when stopped.
  • The sentence-transformers model is cached in your home directory under .cache/huggingface/. It takes up approximately 90MB of disk space.
  • Your Groq API key remains active in your .env file. Groq does not charge on the free tier.

Pause - I'll come back to this later

Shut down the local server to free up memory. Your data stays safe in Supabase until you return.

  • Stop the running uvicorn process by pressing Ctrl+C in the terminal where it is running.
  • Deactivate your Python virtual environment by running:
deactivate
  • Your Supabase project remains active on the free tier. All journal entries and habit data persist.
  • To resume later, reactivate your virtual environment and run uvicorn main:app --reload from your project folder.

Delete - I don't want to use this again

Remove all project resources and start fresh. This permanently deletes your journal entries and habit data.

  • Stop the running uvicorn process by pressing Ctrl+C in the terminal.
  • Deactivate your virtual environment by running:
deactivate
  • Delete your local project folder by running the appropriate command for your system:

macOS

cd ~/Desktop
rm -rf journal-app

Windows

cd ~\Desktop
rmdir /s /q journal-app
  • Delete your Supabase project. Open the Supabase dashboard, select your project, then navigate to Project Settings in the sidebar.
  • On the General settings page, scroll down and click Delete project. Confirm the deletion when prompted.
  • Clear the cached sentence-transformers model to free up disk space:

macOS

rm -rf ~/.cache/huggingface/hub/models--sentence-transformers--all-MiniLM-L6-v2

Windows

rmdir /s /q %USERPROFILE%\.cache\huggingface\hub\models--sentence-transformers--all-MiniLM-L6-v2

What about your Groq account?

Your Groq account and API key are not project-specific. Keep them for future projects that use Groq's LLM inference. If you want to revoke the specific API key, you can do so from the Groq console's API Keys page.

That's a Wrap!

That's a Wrap!

Nice work! You just built a personal journal web application with a RAG-powered AI accountability partner that gives you honest, data-backed feedback grounded in your own writing.

You've learned how to:

  • Build a full RAG pipeline from end to end: generate vector embeddings locally with sentence-transformers, store them in Supabase pgvector, retrieve semantically similar entries via cosine similarity, and generate contextual responses with Groq.
  • Create a FastAPI backend with CRUD endpoints for journal entries, a /chat endpoint that orchestrates the full retrieval-augmented generation flow, and serve a static frontend from the same server.
  • Configure a chatbot personality through system prompt engineering that references real dates, mood scores, and goal progress numbers to deliver direct, specific feedback instead of generic encouragement.
  • Secret Mission: Added a habit tracker with streak calculations and integrated habit data into the chatbot's context so it can call out broken streaks and celebrate consistency.

Ready to quiz yourself?