Tutorials & Guides
How to build a local markdown organiser using Gemini structured outputs and Python
Drowning in a sea of chaotic, half-written markdown files? Here is how to build a local Python tool that uses Gemini's structured outputs to automatically tag, categorise, and link your notes.
Updated 8/18/2026
The tragedy of the digital scrapheap
We have all been there. You start a local markdown vault in Obsidian, Logseq, or just a plain folder on your desktop with the grandest intentions. You are going to build a personal knowledge graph. You are going to map your mind.
Three months later, you have a digital scrapheap: 400 untitled files, half-baked thoughts, and a folder structure that looks like a digital explosion in a filing cabinet. Sorting this manually is a soul-crushing chore.
Fortunately, we can make an AI do the boring heavy lifting. In this tutorial, we are going to build a local Python tool that reads your messy markdown notes, analyses their contents, and outputs structured metadata (tags, categories, and recommended internal links) using Gemini's incredibly reliable JSON schemas.
Why Gemini structured outputs?
Historically, forcing LLMs to return reliable JSON was like trying to herd cats. You would write a prompt begging the model not to include conversational preamble like "Here is your JSON:", only for it to do it anyway and break your parsing script.
Using native structured outputs via /platforms/gemini changes the game. By defining a strict Pydantic schema in Python, we force the API to return a predictable, valid JSON object that matches our exact specification. It either succeeds perfectly or fails explicitly—no halfway-broken strings allowed. You can learn more about how schema validation fits into the wider AI landscape in our /glossary.
Let’s build it.
Step 1: Setting up the environment
First, make sure you have Python 3.10+ installed. Create a clean project folder and install the required dependencies. We will need the official Google GenAI SDK and Pydantic for defining our data structure.
`bash
mkdir local-note-organiser
cd local-note-organiser
pip install google-genai pydantic
`
You will also need a Gemini API key. Grab one from Google AI Studio and set it as an environment variable in your terminal:
`bash
export GEMINI_API_KEY="your_api_key_here"
`
Step 2: Defining the metadata schema
We want our organiser to analyse a markdown file and return three pieces of data: 1. A primary category (e.g., "Coding", "Recipes", "Work"). 2. A list of 3-5 highly relevant tags. 3. A brief, one-sentence summary to act as frontmatter.
Here is how we define this schema using Pydantic. Create a file named organiser.py and add the following imports and classes:
`python
import os
from pathlib import Path
from google import genai
from google.genai import types
from pydantic import BaseModel, Field
Define the structure we want Gemini to return class NoteMetadata(BaseModel): category: str = Field(description="The single most accurate broad category for this note.") tags: list[str] = Field(description="List of 3 to 5 lowercase tags representing key concepts.") summary: str = Field(description="A concise, single-sentence summary of the note's contents.") ```
This Pydantic model defines the boundaries of our JSON object. Gemini's engine reads this schema to understand exactly what we expect, ensuring we do not get rogue fields we did not ask for.
Step 3: Writing the analysis engine
Now, let's initialise the Gemini client and write the function that passes our markdown text to the Gemini API. We will use the lightweight and fast gemini-2.5-flash model, which is perfect for high-throughput text processing tasks.
`python
# Initialise the client (it automatically looks for GEMINI_API_KEY in env)
client = genai.Client()
def analyse_note_content(content: str) -> NoteMetadata:
prompt = f"""
Analyse the following raw markdown note and extract metadata according to the requested schema.
Note Content:
{content}
"""
response = client.models.generate_content(
model='gemini-2.5-flash',
contents=prompt,
config=types.GenerateContentConfig(
response_mime_type="application/json",
response_schema=NoteMetadata,
temperature=0.2, # Keep it deterministic
),
)
# Parse the raw JSON string back into our Pydantic model
return NoteMetadata.model_validate_json(response.text)
`
This is where the magic happens. By setting response_mime_type="application/json" and passing our NoteMetadata class into response_schema, we instruct the API to act as a strict JSON generator. It is the structured interface that makes these modern LLMs tick.
Step 4: Updating the files in-place
With our API function ready, we need a helper to read our local files, query the API, and write the metadata back to the top of the file as YAML frontmatter. If frontmatter already exists, we will overwrite it; if not, we will prepending it.
Add this helper logic to organiser.py:
`python
def process_markdown_file(file_path: Path):
print(f"Processing: {file_path.name}...")
content = file_path.read_text(encoding='utf-8')
# Strip existing frontmatter for the analysis run to avoid bias
lines = content.splitlines()
raw_body = content
if len(lines) > 0 and lines[0] == '---':
try:
end_idx = lines.index('---', 1)
raw_body = '\n'.join(lines[end_idx+1:])
except ValueError:
pass
if not raw_body.strip():
print(f"Skipping {file_path.name}: File is empty.")
return
try: metadata = analyse_note_content(raw_body) except Exception as e: print(f"Failed to analyse {file_path.name}: {e}") return
Construct our clean frontmatter frontmatter = f"""--- category: {metadata.category} tags: """ for tag in metadata.tags: frontmatter += f" - {tag.lower().strip()}\n" frontmatter += f"summary: \"{metadata.summary.replace('\"', '\\\"')}\"\n---\n\n"
Write back to file file_path.write_text(frontmatter + raw_body.lstrip(), encoding='utf-8') print(f"Successfully updated {file_path.name}!") ```
Step 5: Putting it all together
Finally, let's write a simple CLI wrapper that scans a target directory for .md files and runs our processing loop. Add the final execution block to the bottom of your file:
`python
def main():
# Change this path to your local vault directory
vault_path = Path("./my_notes")
if not vault_path.exists():
vault_path.mkdir(parents=True, exist_ok=True)
# Create a dummy note for testing
test_note = vault_path / "learning_rust.md"
test_note.write_text("""I started looking into Rust programming today.
I set up cargo, ran 'cargo init', and played with variable mutability.
It is quite different from Python, especially with the borrow checker, but performance looks incredible.""")
print(f"Created test note in '{vault_path}' directory. Run the script again to test!")
return
markdown_files = list(vault_path.glob("*.md")) if not markdown_files: print(f"No markdown files found in {vault_path.absolute()}") return
for file_path in markdown_files: process_markdown_file(file_path)
if __name__ == "__main__":
main()
`
Running your local organiser
Create a folder named my_notes in your project folder, throw some unstructured text files in there, and run the script:
`bash
python organiser.py
`
When you open your test note, you should see cleanly formatted metadata waiting for you at the top:
`markdown
---
category: Software Development
tags:
- rust
- system programming
- borrow checker
summary: "An introduction to learning the Rust programming language, focusing on cargo setup and memory safety concepts."
---
`
Troubleshooting and fine-tuning
If you find the model is putting notes into too many fragmented categories (like "Rust coding", "Python programming", "JS script" instead of a unified "Coding"), you can enforce consistency by modifying your Pydantic schema using a Literal type, limiting choices strictly to a predefined list:
`python
from typing import Literal
class NoteMetadata(BaseModel):
category: Literal["Coding", "Health", "Finance", "Personal", "Work"] = Field(...)
`
If you run into API issues, check Google's official developer console or visit the Google Gemini Support site to verify your API quota status and billing settings.
Now sit back, run the script, and watch your digital junk drawer self-assemble into an organised library.
Keep going
Build something with the prompt generator, decode the jargon in the glossary, or compare the tools on our platform deep-dives.