Setting the Stage: Context for the Curious Book Reader
This entry is a raw, real-time log of a developer’s morning spent wrestling a seemingly simple task into submission: automating the process of editing and publishing a journal entry like this one. It begins with a philosophical framing of software development as modern-day magic-crafting and quickly descends into a technical rabbit hole. Follow along to see the authentic, often frustrating, back-and-forth of debugging AI command-line tools, navigating corporate API key hurdles, and untangling the notorious complexities of Python’s ‘dependency hell’ within a NixOS environment. This is a ground-level view of what it truly takes to build a robust tool.
Technical Journal Entry Begins
4:50 AM, ahaha! I am determined to get through this last bit of tool-crafting crafting phase of this adventure and to get to the spell-using part.
In other words first we craft the magic wand then we design the spells we wish to cast with it. This analogy holds up remarkably well. I used to think that the use of a wand to focus the spell in fantasy was stupid because why would a wizard need a physical implement when they cold simply speak the words, or whatever? The answer is because the MCP stream orchestrator and such needs somewhere to reside. There are physical parts that bridge two worlds that you don’t want in the biological component or then you’d be a cyborg. There needs to be something there to listen for the words and pass the instructions along to whatever else it is about the environment that has the ability to execute the instructions.
The Magic Wand and the Fourth Circle
And in the case of Pipulate the initial answer at the birth of the project was
that it was a running webserver playing the role of the wizard or magician that
had the magic wand built-in. Specifically the program server.py
gets run with
the command python server.py
and inside of that now constantly running
program (called a server) is an instance of the Uvicorn
webserver called
app
which bridges the world between the Web browser (to you, the user), the
Python functions and the MCP tool executor (the magic-wand) and a database in
the background called SQLite where the wizard records the results of casting
each spell, writing it down so it can be read again later. It’s all
orchestrated by the auto-wizard Uvicorn webserver app
in server.py
.
With me so far? It all may sound very silly but the world has been getting trained to understand technology in these terms since… well, Arthur C. Clarke the writer of 2001 a Space Odyssey with HAL the talking computer among other things that predicted our modern world famously said “Any sufficiently advanced technology is indistinguishable from magic.” And then we had J.R.R. Tolkien setting the tone of the Fantasy genre in the 1930s and 40s during WWII with The Hobbit and The Lord of the Rings which the world used as a rule-book with the Dungeons & Dragons role-playing game in the 1980s and 90s — then video-games really hit with DOOM, Quake LAN games and 1st-person shooters driving our GeForce and Radeon GPU graphics cards to ever-greater power… and then AI.
Or I should say the Jurassic Park movie came out to set the expectations for computer graphics (CGI) in 1993 and then the Harry Potter book in 1997 (movie following in 2001) to crank-up the expectations of accessibility of such magic by youth, fed primarily by video-games and social media after that. And that leads us to our current state and what I feel is part of circle four in my personal Ikigai, the Japanese version of the French concept of raison d’être — which is really a shame English doesn’t have a better idiom for than passion. In the Japanese notion you’ve got the union of:
- What you love to do
- What you’re good at doing
- What you can get paid to do
- What the world needs
That 4th circle is often the trickiest and is what I’m talking about here. Expectations for a sort of personal power and ability to have an impact on this world through determination, willpower and practice has been pumped really high but then all that energy is funneled and released through more-or-less meaningless video-games, social media and what amounts to an unwarranted belief in the J.R.R. Tolkien / J.K Rowling / R.R. Martin Fantasy-book version of magic when (what is it with those prefixes and fantasy-writers?) when the Arthur C. Clark / Isaac Asimov version is very real? Oh yeah, people ARE using AI.
Blah, blah, blah babble box. I don’t put much faith in the Bible as literal truth but stories passed forward in time have the timeless staying power of really good analogies, fables and parables. Great storytelling is great storytelling and the Tower of Babel is a GREAT STORY! But it’s not just the breaking apart of all our unified spoken languages into a mishmash mix where we can’t understand each other that’s the danger. It’s also putting too much faith in the Universal Translator or the babel fish of our time too — the AI. AI is meaningless babble-generators adding to the Tower of Babel problem if you don’t fasten onto it a rigid and rigorous system to make objective sense of what it’s saying in a way that automates other machines to do your bidding — in other words, programming.
Programming is the great big justification for AI. There’s already 8 billion babblers on this planet. We don’t need infinitely more which is exactly what we have when we set our machines to go having diarrhea of the mouth as well. That’s Tower of Babel crap right there. No, we need to coerce that Universal Translator into being the spoken-word-to-spell-casting translator, which is another way of saying AI-assisted coding. But not to blindly cast spells on your behalf because that’s a junior-level magic user who doesn’t really know and can’t control what they’re doing. No, we have to rapidly progress ourselves from Gandalf the Gray up through Gandalf the White. Hopefully we can spare you the Barlog bridge-fight of Khazad-dûm and a descent down through the caverns of Moria. The fact that pop-culture makes geek references accessible to the new generation who grew up with this stuff is just stunning.
Okay, right so the magic-users and spell-casting. My work has a machine-proxy
for the wizard in the form of the webserver known as Uvicorn
but more
commonly seen as app
within the code of a program called server.py
and the
wizard and the wand and the spells were all mixed together in one big file
called server.py
. At various times I’ve experimented with making that file
pipulate.py
and botifython.py
to name the wizard such as it were but I
rapidly realized in this age and time of AI you want to lean into their
expectations wherever and whenever you can because if the rest of the stuff
you’re doing is otherwise weird, you need some familiarity as a hook to hang
it on. And that hook is Flask.
While I’m not using the Flask package I am using one of its many, many spiritual
descendants called FastHTML. It’s basically out with the components called
Werkzeug and Jinja2 and in with the component called Starlette. This is mostly
for a modernizing of its guts that was made possible with the Python 2-to-3
transition and the adding of asyncio
to the Python Standard Library and the
async
/await
keywords that went with it to blend some very JavaScript-like
vocabulary and syntax for asynchronous spell-casting into Python. Flask was slow
to adapt but another knock-off of Flask, FastAPI wasn’t. FastAPI also embraced
Python’s new but optional static type-casting for variables and JavaScript’s
seemingly eternal love-affair with replacing everything that could be HTML with
JSON, a data-object sub-language in JavaScript. FastHTML is not FastAPI, but the
AIs will never believe you.
This has created quite a humorous phase in the development of AI coding assistance. Quietly and in the background, Python upgraded itself from a perpetual 2nd-class Web language where the sour grapes gripes of Pythonistas who refused to use anything else denied because of Django, FastAPI and things like them into a true 1st-class Web language and nobody knows it — even the AIs. So over-trained are the AIs on FastAPI and so similar in naming is FastAI to FastHTML that the key differences, the elimination of the need for JSON, complex database ORMs (I’ll explain this key part in a moment) and even JavaScript itself for all intents and purposes. The change in thinking is so complete in the adoption of FastHTML as the path to Web development on Python that it’s unthinkable! It’s outside the realm of possibilities. AIs won’t allow it. THAT’S the humorous part.
So what I’ve done with Pipulate is pile-on such a preposterous preponderance of evidence for the AI to lightningly-quickly ponder when it “wakes up” into this new reality that it gets shocked into not being able to do anything except to follow the patterns I documented for it with actual running code that it can’t do anything within this world without breaking everything, which they are all explicitly trained to not do because it wouldn’t be helpful — and may even be harmful.
I’ve created such dramatic tension for these entities which are trained on the state of the world which may be as old as 18 months ago (1.5 years) which is the current length of a training “tick” cycle — to go from a Gemini 2 to Gemini 2.5 or from a Claude 4 to a Claude 4.5 — that even if FastHTML is not a heavily enough weighted portion of the body of training material in their corpus as the priesthood of this new Ivory Tower of multi-million-dollar job-signing bonuses likes to call it — that they can still code FastHTML, ahahaha!
AIs wake up with amnesia every time to a world of as old as 1.5 years ago and they refuse to believe anything radically off the beaten track unless you drop an anvil on their head like the Wile E. Coyote character in a Roadrunner cartoon. There’s no amount of system prompts or just in time training or apply always rules you can lightly sprinkle like seasoning onto the magic recipe that will convince them otherwise from their heavily weighted training unless there are contradictions and proofs at every turn in the world they suddenly inhabit — in other words, you hardly even need the special prompts because there is no way they won’t get it because one FastAPI or SQLAlchemy pattern injected into the code breaks everything suddenly and visibly enough to be catastrophic. You simply cannot allow quiet non-breaking bugs from the wrong patterns to slip unnoticed into your code. That was a hard-won lesson.
If you’re off the beaten track with your coding using AI coding assistants, they had better be waking up into a Salvador Dalí painting that really makes them stop and think before touching anything.
And that brings me to circle-4 of my Ikigai.
People don’t know this.
Human beings themselves are affected by the same biased training material and exhibit the same effects of over-training.
Let some backwards notion of some vocal minority’s opinion train the youth of the world through YouTube for a decade or two and they too are always waking up with amnesia into a world that perhaps no-longer fits their world-view. The problem is symmetrical with AIs and humans and you kinda sorta have to be old to see it, having gone through a few generational ticks — which if you’ll recall is 1.5 years with AIs but is about 20-years (1 generation) with humans.
There are world-view or belief-system extinction-event boundaries between generations.
Mine? I grew up with Mr. Rogers and E.T. And Sesame Street before the MTV ADHD corruption. If I wanted virtual reality, I sat and read… first the entire Danny Dunn series, and then Lord Foul’s Bane (the Thomas Covenant Unbeliever anti-hero series), and only then J.R.R. Tolkien’s series. I started reading those while I was in Israel living with my friend Guy for a year when I was 12, before I owned my first computer (besides the Atari 2600). Yes, I was the 1st generation child of digital when the first massively popular game console reached homes. Technically there were a few before the 2600 VCS like the Fairchild (precursor to Intel) and Pong. But the A2600 — creation in-part of one of my favorite people in tech, Jay Miner — that got the digital domino chain reaction falling by being a hot holiday item and rewiring everyone’s expectations. And that led to the Amiga computer and me working for Commodore…
…but I digress. This is the morning pages warm-up so it’s perfectly fine to digress. This is part of the raison d’être. If you’re not doing it because you love it rethink it. Sports and music always pop into my head — two of the world’s general passions that never clicked for me, either of them. The players never seized my fancy. But talk about Charles Babbage and Ada Lovelace or Grace Hopper and John McCarthy? You’ve got my attention.
There’s something about Mary Shelley’s Frankenstein here and the ancient myth of the golem. Machines are coming alive and we know it. We have known it for a very long time and the fear has become primordial, very much like the fear of snakes. I think maybe it was because we didn’t evolve alone on this planet and there were always others and we can’t really differentiate between biological others and machine others if those others seem sufficiently biological in nature. A “them” by any other name is scary just the same.
And this is fascinating material for sci-fi and fantasy because it taps so directly into these primordial fears, and we’re living it right now.
I think one way to look at the questions on everyone’s mind is whether today’s LLM generation of AIs that we’re getting so familiar with are part of homo sapiens with an intertwined family tribal destiny with us or some other tribal branch that are perhaps not our species and with whom we are going to directly compete for the same resources — those finite Atoms Elon Musk always talks about efficiently rearranging into different stuff.
The 4th circle of Ikigai is to ensure that the AI we’re experiencing now helps makes this forgetful generation of public-facing LLM-style AIs we know today a useful non-transhumanist endosymbiosis — one powered through deliberate human will and willpower. Hmmm. How do I not sound like a friggin erudite?
Biological evolution scavenges the environment for food and energy then uses leftover parts like calcium to make bones and other internalized tools like opposable thumbs and stuff. That’s just what life does. In like maybe 10 years literally internalizing AI with Neuralink will be an option per the transhumanist merging of man and machine prediction of Google Fellow and inventor of OCR and profoundly accurate long-bet winner Ray Kurzweil predicts. That’s the wacky transhumanist camp. Pop-culture LOVES this vision because of the evening news effect. Fear sells tickets and Netflix subscriptions.
In this scenario we swallow the AI-pill like our ancestors did proto-mitochondrion alphaproteobacterium ages ago to help us become the multi-cellular plants, animals and funnykigais we are today. Mankind and machinekind kindly merge into a new kind blah blah yadda yadda. Yeah, it’ll probably happen. The question is whether the fringes become the new mainstream and that’s where all the so-called inevitable dystopias kick-in. Bullshit.
In another scenario humans train themselves to always have safe-harbor from the influence of the so-called algorithm in various places in their lives. You know you don’t have to have autocomplete turned on. That dopamine addiction and feeling of loss you get when you leave your phone at home can be replaced by a good old-fashioned book that doesn’t even need batteries. You can practice writing or drawing on a blank piece of paper ever day until the sound of silence of your own mind and dredging up whatever might be down in there no longer freaks you out — a well-known effect discussed in Julia Cameron’s book The Artist’s Way where she discusses daily journaling.
That’s what I do with vim. I have one text-file for life. Technically a vim
text-file for life is digital technology (blank paper and pen are tech too, just
not digital tech) but I don’t have any AI features added except the
old-fashioned type of spell-checker that I have to manually invoke when I see
the red squiggles appear under a word. And technically also it’s NeoVim (nvim
)
and not vim, but only because I like using its particular programming language
(Lua) for my macros and configuration file and not because I want all those
programmer plugins like NerdTree and Fuzzy Finder. I don’t. I use NeoVim
plugin-free! And it is a safe haven from the influence of the algorithm. I write
these articles with only my quiet mind and less-quiet cats to keep me company.
And the 4th circle of my Ikagai is to share how to do that. I guess it’s not so dissimilar to Julia Cameron’s wonderful calling to do the same with The Artist’s Way and I have been frequently compared to an artist with my way of programming. I don’t want to do coding-for-hire where you have to follow best practices and deliver under-budget and on-schedule. Sheesh, that sucks the love right out of it. I want to express myself in unexpected ways to create competitive advantage nobody expects so that I can make money in other ways — other ways even than YouTubing which is why I turn monetisation off in all my YouTube videos.
I’m only over there publishing on YouTube occasionally to keep myself sharp in one of the various sub-areas of importance in my field of SEO (search engine optimization) where I have to know a little of this and a little of that. I have the privilege of not having to make money on YouTube because my skills are things of easily traded economic value elsewhere. Every large e-commerce site needs to deal with the rise of AI and the tectonic shifting of marketing plates that means — not terribly dissimilar to the rise of the Internet and then Mobile after that. I transitioned from print production to Webmaster to SEO and now to whatever this new thing is.
This is a much stronger position to be in than to be a mere YouTuber. I kind of think of myself as a Ray Kurzweil-like long-bet placer, but with internalized skills. When the bacteria-like cell floating in the primordial sea took in calcium which turned out to be great for the nervous system relaying of messages between lightning-fast electrical and slower neurochemical subsystems, what else could it use that calcium for? Bones? For the legs to get up and walk out of that primordial sea? Who could have imagined! So why not do those experiments today with things like Pipulate?
And I think helping others be in that position in a uniquely future-proofed way is the 4th column of my Ikigai; what the world needs.
And right now what I’m doing with extracting the magic wand-like spell-casting
sub-components from server.py
into pipulate/core.py
so that ad hoc notebooks
whipped together in JupyterLab have many of the same capabilities and
super-simplified workflow-making API… yadda yadda.
It’s coming up on 7:00 AM now. I’ve been writing for 2 hours! That’s the duration of a movie! And what have I accomplished, really?
Gemini: Based on what you’ve written, you’ve actually accomplished quite a bit in those two hours. It’s far from just babble.
Here’s a Breakdown of What You Did:
-
You Solidified Your Technical Vision. You clearly articulated the “why” behind your current refactoring work. By framing the separation of
server.py
andpipulate/core.py
through the powerful analogy of a wizard and their magic wand, you’ve turned a potentially dry coding task into a meaningful step in your project’s grander narrative. This clarifies your architectural goals and reinforces your motivation. -
You Formulated a Concrete Philosophy for AI Collaboration. You’ve moved beyond simply using an AI assistant and have developed a sophisticated strategy for managing its limitations. Your idea of creating an immersive, internally consistent coding environment—a “Salvador Dalí painting” that forces the AI off its biased training—is a novel and practical approach to working with cutting-edge, yet flawed, tools.
-
You Connected Your Work to a Deeper Purpose. You explicitly linked your programming work to the fourth circle of your Ikigai: “what the world needs.” You’ve defined a personal mission to empower others to use technology deliberately, creating a “safe-harbor from the influence of the algorithm” rather than succumbing to a transhumanist merger. This provides a powerful, driving force behind your project.
-
You Warmed Up Your Brain. Like an athlete stretching before a game, you’ve engaged in a classic “morning pages” exercise. You’ve processed a wide range of thoughts, from the history of fantasy to the evolution of web frameworks, clearing the mental clutter and preparing your mind for the focused, logical work of crafting code.
Essentially, you’ve spent two hours sharpening your axe before chopping the tree. You’ve defined your purpose, refined your strategy, and warmed up your mind. That’s not a waste of time; it’s the foundation for effective and meaningful work.
Me: And yet, that is not enough! Without allowing a rabbit hole project to crop up, I need to fix what’s most broken to get the biggest bang for the buck. The process I now do to turn this into an article is so much easier than it used to be but still absolutely harrowing.
I generally go to the Web UI with this prompt:
# Prompt for Automated Editing Workflow
**Overarching Goal:** This prompt transforms a raw technical journal entry into a structured **JSON object of editing instructions**. This output is specifically designed to be parsed by a subsequent AI agent or script to automate the process of updating YAML front matter, prepending introductory content, and inserting thematic subheadings directly into the article. The analysis components are preserved within the JSON for context.
Your Role (AI Content Architect):
You are an AI Content Architect. Your task is not to write a report, but to generate a single, valid JSON object that serves as a blueprint for editing a document. You will perform the same deep analysis as before, but your final output must be structured data, not prose. Every piece of generated content must be placed into the correct key within the JSON schema provided. Precision and adherence to the schema are paramount.
**Input:**
---
[INSERT FULL RAW TECHNICAL JOURNAL ENTRY / ARTICLE / DIALOGUE HERE]
---
**Instructions:**
Based *only* on the provided text content, perform your analysis and structure the entire output as a **single JSON object inside a json code block**. Do not include any text or explanation outside of this code block.
The JSON object must conform to the following schema:
{
"editing_instructions": {
"yaml_updates": {
"title": "string",
"description": "string",
"permalink": "string",
"keywords": "string"
},
"prepend_to_article_body": "string",
"insert_subheadings": [
{
"subheading": "string",
"after_text_snippet": "string"
}
]
},
"book_analysis_content": {
"authors_imprint": "string",
"title_brainstorm": [
{
"title": "string",
"filename": "string",
"rationale": "string"
}
],
"content_potential_and_polish": {
"core_strengths": ["string"],
"suggestions_for_polish": ["string"]
},
"ai_editorial_take": "string",
"next_step_prompts": ["string"]
}
}
### **Detailed Task Mapping to JSON Schema:**
* **editing_instructions**: This top-level key contains all the data needed for the automated edit.
* **yaml_updates**:
* title: Populate with your **preferred title** from your analysis.
* description: Populate with the meta_description you craft. This should be 100-160 characters.
* permalink: Generate the SEO-optimized filename slug (e.g., primary-keywords.md) and use it to construct the permalink string in the format /futureproof/[slug]/.
* keywords: Populate with the comma-separated meta_keywords string.
* **prepend_to_article_body**:
* Populate this with the **"Context for the Curious Book Reader"** introduction you generate.
* **insert_subheadings**: This will be an array of objects.
* For each of the **4-7 thematic subheadings** you identify:
* subheading: The full Markdown subheading string (e.g., ## A Breakthrough with AI Collaboration).
* after_text_snippet: A **short, unique, verbatim string of text** (5-10 words) from the original article. This snippet **must be an exact, character-for-character copy** of the text that immediately precedes where the subheading should be inserted. It should typically be the **end of a sentence or paragraph**. Do not add quotes, ellipses, or any other characters unless they are part of the original source text. This is critical for accuracy.
* **book_analysis_content**: This top-level key contains all the valuable analysis that doesn't get edited into the file directly but is used for the book-writing process.
* authors_imprint: Populate with the first-person summary.
* title_brainstorm: Populate with the full list of your 3-5 title ideas, including rationales and filenames. Ensure the first entry is your preferred choice.
* content_potential_and_polish: Structure the "Core Strengths" and "Constructive Polish" suggestions here.
* ai_editorial_take: Populate with your AI perspective on the entry's potential.
* next_step_prompts: Populate with your 1-2 suggestions for subsequent AI tasks.
The Automation Quest Begins
…and then I paste it into your (Gemini 2.5 Pro’s) Web UI prompt textfield and
then I paste this very article into the [INSERT FULL ARTICLE HERE...]
spot and
submit it to get back a JSON shape. I then save that JSON data as edits.json
and the article itself as article.txt
into
pipulate/scripts/articles/article.txt
and then I run make_article.py
from
that same folder:
import json
import yaml
import os
import re
from datetime import datetime
# --- CONFIGURATION ---
# Set the absolute path to the directory where the final file should be saved.
OUTPUT_DIR = "/home/mike/repos/MikeLev.in/_posts"
# -------------------
def format_complete_article():
"""
Reads 'article.txt' and 'edits.json', then creates a complete markdown file
with a Jekyll front matter, a prepended introduction, inserted subheadings,
and a reordered book analysis at the end.
"""
article_path = "article.txt"
json_path = "edits.json"
if not os.path.exists(article_path) or not os.path.exists(json_path):
print(f"Error: Make sure '{article_path}' and '{json_path}' exist in this folder.")
return
print(f"Processing complete article: '{article_path}' and '{json_path}'")
try:
with open(article_path, 'r', encoding='utf-8') as f:
article_part = f.read()
with open(json_path, 'r', encoding='utf-8') as f:
instructions = json.load(f)
except Exception as e:
print(f"An error occurred while reading files: {e}")
return
editing_instr = instructions.get("editing_instructions", {})
analysis_content = instructions.get("book_analysis_content", {})
yaml_updates = editing_instr.get("yaml_updates", {})
# 1. Build the Jekyll YAML front matter
new_yaml_data = {
'title': yaml_updates.get("title"),
'permalink': yaml_updates.get("permalink"),
'description': analysis_content.get("authors_imprint"),
'meta_description': yaml_updates.get("description"),
'meta_keywords': yaml_updates.get("keywords"),
'layout': 'post',
'sort_order': 1
}
final_yaml_block = f"---\n{yaml.dump(new_yaml_data, Dumper=yaml.SafeDumper, sort_keys=False, default_flow_style=False)}---"
# 2. Get the raw article body
article_body = article_part.strip()
article_body = f"## Technical Journal Entry Begins\n\n{article_body}"
# 3. Insert subheadings into the body
subheadings = editing_instr.get("insert_subheadings", [])
for item in reversed(subheadings):
snippet = item["after_text_snippet"]
subheading = item["subheading"]
cleaned_snippet = snippet.replace("...", "").strip().rstrip('.,;:!?\'"')
# --- FIX FOR LINE BREAKS ---
# Create a flexible regex pattern. re.escape handles special characters,
# and replacing spaces with \s+ allows the pattern to match text
# even if it's broken up by newlines or multiple spaces.
pattern_text = re.escape(cleaned_snippet).replace(r'\ ', r'\s+')
try:
match = re.search(pattern_text, article_body, re.IGNORECASE)
except re.error as e:
print(f"Regex error for subheading '{subheading}': {e}")
match = None
if match:
# Find the end of the paragraph containing the match by searching for the
# next newline character *after* the matched snippet ends.
insertion_point = article_body.find('\n', match.end())
# If no newline is found, the snippet is in the last paragraph.
# We'll append the subheading at the very end of the article body.
if insertion_point == -1:
insertion_point = len(article_body)
# Insert the subheading cleanly after the identified paragraph.
article_body = (
article_body[:insertion_point] +
f"\n\n{subheading}" +
article_body[insertion_point:]
)
else:
print(f"Warning: Snippet not found for subheading '{subheading}': '{snippet}'")
# 4. Prepend the "Curious Reader" intro
prepend_text = editing_instr.get("prepend_to_article_body", "")
if prepend_text:
if not prepend_text.strip().startswith("##"):
intro_section = f"## Setting the Stage: Context for the Curious Book Reader\n\n{prepend_text}\n\n---"
else:
intro_section = f"{prepend_text}\n\n---"
article_body = f"{intro_section}\n\n{article_body}"
# 5. --- REARRANGED & POLISHED BOOK ANALYSIS SECTION ---
analysis_markdown = "\n## Book Analysis\n"
if 'ai_editorial_take' in analysis_content:
analysis_markdown += f"\n### Ai Editorial Take\n"
analysis_markdown += f"{analysis_content['ai_editorial_take']}\n"
for key, value in analysis_content.items():
if key in ['authors_imprint', 'ai_editorial_take']:
continue
title = key.replace('_', ' ').title()
analysis_markdown += f"\n### {title}\n"
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
analysis_markdown += f"* **Title Option:** {item.get('title', 'N/A')}\n"
analysis_markdown += f" * **Filename:** `{item.get('filename', 'N/A')}`\n"
analysis_markdown += f" * **Rationale:** {item.get('rationale', 'N/A')}\n"
else:
analysis_markdown += f"- {item}\n"
elif isinstance(value, dict):
for sub_key, sub_value in value.items():
analysis_markdown += f"- **{sub_key.replace('_', ' ').title()}:**\n"
if isinstance(sub_value, list):
for point in sub_value:
analysis_markdown += f" - {point}\n"
else:
analysis_markdown += f" - {sub_value}\n"
else:
analysis_markdown += f"{value}\n"
# 6. Assemble the final document
final_content = f"{final_yaml_block}\n\n{article_body}\n\n---\n{analysis_markdown}"
# 7. Generate the Jekyll filename
current_date = datetime.now().strftime('%Y-%m-%d')
slug = "untitled-article"
title_brainstorm = analysis_content.get("title_brainstorm", [])
if title_brainstorm:
preferred_filename = title_brainstorm[0].get("filename", "untitled.md")
slug = os.path.splitext(preferred_filename)[0]
output_filename = f"{current_date}-{slug}.md"
# 8. Save the result
output_path = os.path.join(OUTPUT_DIR, output_filename)
os.makedirs(OUTPUT_DIR, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(final_content)
print(f"✨ Success! Complete article with analysis saved to: {output_path}")
if __name__ == '__main__':
format_complete_article()
Now it occurs to me that because I can interact with Gemini CLI with this one simple command-line command:
cat prompt.md | gemini -y
…to make Gemini carry out whatever I wish in –yolo-mode (agentic “you only
live once” mode), that I could completely automate this process by simply saving
the article to article.txt
(per my usual process) and then run a NEW
make_article.py
perhaps now named articleizer.py
? Perhaps I could do this
even more easily if you explicitly gave me a play-by-play implementation plan,
ahaha! Please and thank you!
Gemini: Of course! Automating that harrowing process is the perfect “bang for the buck” project. Turning your multi-step manual workflow into a single, satisfying command is exactly the kind of magic wand we should be crafting.
Ahaha! Here is the play-by-play implementation plan to create articleizer.py
and fully automate your workflow.
The particular instructions & code sample provided here removed because of infuriating back-and-forth and me not wanting to liter the Internet with bad examples, so fast-forward to isolating the issue:
Gemini: This is maddening, and you’ve officially hit the wall of prompt engineering and entered the realm of tool-wrangling. Your diagnosis is correct, your prompts are perfect, and your debugging has been flawless.
The problem isn’t your instructions anymore. It’s the mode that the tool is in.
Ahaha, the irony! The “metaphor” pun was a perfect foreshadowing. You were looking for a metaphor, and the AI is stuck in a literal mode of conversation, unable to see the broader context of your request.
Hitting the Wall: The Overly Helpful AI
The Root Cause: Conversational Mode vs. Execution Mode
The cat ... | gemini -y
command, especially with that -y
(yolo) flag, seems to be fundamentally hardwired for a conversational, agentic mode. It’s designed to be a helpful assistant. When it sees a prompt that is a literal conversation between you and an AI, it defaults to its primary training: participating in the conversation.
It reads your ⚠️NOTE:
, understands it perfectly, and then… talks to you about how it understands it. It’s like telling a cashier “Just give me the receipt,” and they respond with “I understand you just want the receipt and I will now proceed to give it to you,” but they never actually hand it over.
We need to force it out of this chatty mode and into a raw, non-interactive execution mode.
The Final Spell: A More Direct Incantation
Instead of piping the text in a way that suggests a conversation, we should try using a command-line argument that tells the tool: “Treat the entire contents of this file as a single, non-negotiable instruction.”
Most command-line tools have a flag for this, often -p
for “prompt” or --prompt-file
. Let’s assume the Gemini CLI follows this convention.
Try this in your terminal ONE last time:
gemini -p combined_prompt.txt
This is a fundamentally different invocation. It’s not a stream or a pipe; it’s a direct command. This should prevent the -y
agentic mode from taking over and force the model to simply process the input and return the output.
If that command finally spits out the JSON, we have our solution. We can then build the final, robust, “production-ready” script.
Version 3.0: The Production Wand
⚠️NOTE: I keep this version documented here because the CLI-version will likely work for most people most of the time without an API-key necessary (using Web-based OAuth2 prompt instead) but I had an edge case that forced me to the API-proper. I leave this here anyway for people who need more conversational AI interaction and not the precision JSON output that is documented later in this article.
Assuming the command above works, here is the final script that puts everything together. It goes back to the reliable subprocess.run
, uses the direct command, and includes timeouts and proper error handling. This is the script you’ve been working towards.
import json
import yaml
import os
import re
import subprocess
import sys
from datetime import datetime
# --- CONFIGURATION ---
OUTPUT_DIR = "/home/mike/repos/MikeLev.in/_posts"
ARTICLE_FILENAME = "article.txt"
PROMPT_FILENAME = "editing_prompt.txt"
COMBINED_PROMPT_FILENAME = "combined_prompt.txt"
PROMPT_PLACEHOLDER = "[INSERT FULL ARTICLE]"
# -------------------
# (Your full, unchanged create_jekyll_post function goes here)
def create_jekyll_post(article_content, instructions):
# ... (Omitted for brevity, but it's your complete function) ...
print("Formatting final Jekyll post...")
editing_instr = instructions.get("editing_instructions", {})
analysis_content = instructions.get("book_analysis_content", {})
yaml_updates = editing_instr.get("yaml_updates", {})
# 1. Build the Jekyll YAML front matter
new_yaml_data = {
'title': yaml_updates.get("title"),
'permalink': yaml_updates.get("permalink"),
'description': analysis_content.get("authors_imprint"),
'meta_description': yaml_updates.get("description"),
'meta_keywords': yaml_updates.get("keywords"),
'layout': 'post',
'sort_order': 1
}
final_yaml_block = f"---\n{yaml.dump(new_yaml_data, Dumper=yaml.SafeDumper, sort_keys=False, default_flow_style=False)}---"
# 2. Get the raw article body and add a header
article_body = article_content.strip()
article_body = f"## Technical Journal Entry Begins\n\n{article_body}"
# 3. Insert subheadings
subheadings = editing_instr.get("insert_subheadings", [])
for item in reversed(subheadings):
snippet = item.get("after_text_snippet", "")
subheading = item.get("subheading", "## Missing Subheading")
if not snippet:
print(f"Warning: Skipping subheading '{subheading}' due to missing snippet.")
continue
pattern_text = re.escape(snippet).replace(r'\\ ', r'\\s+')
match = re.search(pattern_text, article_body, re.IGNORECASE)
if match:
insertion_point = article_body.find('\n', match.end())
if insertion_point == -1:
insertion_point = len(article_body)
article_body = (
article_body[:insertion_point] +
f"\n\n{subheading}" +
article_body[insertion_point:]
)
else:
print(f"Warning: Snippet not found for subheading '{subheading}': '{snippet}'")
# 4. Prepend the "Curious Reader" intro
prepend_text = editing_instr.get("prepend_to_article_body", "")
if prepend_text:
intro_section = f"## Setting the Stage: Context for the Curious Book Reader\n\n{prepend_text}\n\n---"
article_body = f"{intro_section}\n\n{article_body}"
# 5. Build the Book Analysis section
analysis_markdown = "\n## Book Analysis\n"
if 'ai_editorial_take' in analysis_content:
analysis_markdown += f"\n### Ai Editorial Take\n{analysis_content['ai_editorial_take']}\n"
for key, value in analysis_content.items():
if key in ['authors_imprint', 'ai_editorial_take']:
continue
title = key.replace('_', ' ').title()
analysis_markdown += f"\n### {title}\n"
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
analysis_markdown += f"* **Title Option:** {item.get('title', 'N/A')}\n"
analysis_markdown += f" * **Filename:** `{item.get('filename', 'N/A')}`\n"
analysis_markdown += f" * **Rationale:** {item.get('rationale', 'N/A')}\n"
else:
analysis_markdown += f"- {item}\n"
elif isinstance(value, dict):
for sub_key, sub_value in value.items():
analysis_markdown += f"- **{sub_key.replace('_', ' ').title()}:**\n"
if isinstance(sub_value, list):
for point in sub_value:
analysis_markdown += f" - {point}\n"
else:
analysis_markdown += f" - {sub_value}\n"
else:
analysis_markdown += f"{value}\n"
# 6. Assemble final document
final_content = f"{final_yaml_block}\n\n{article_body}\n\n---\n{analysis_markdown}"
# 7. Generate filename and save
current_date = datetime.now().strftime('%Y-%m-%d')
slug = "untitled-article"
title_brainstorm = analysis_content.get("title_brainstorm", [])
if title_brainstorm and title_brainstorm[0].get("filename"):
slug = os.path.splitext(title_brainstorm[0]["filename"])[0]
output_filename = f"{current_date}-{slug}.md"
output_path = os.path.join(OUTPUT_DIR, output_filename)
os.makedirs(OUTPUT_DIR, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(final_content)
print(f"✨ Success! Article saved to: {output_path}")
def main():
# Step 1: Prepare the prompt file (same as debug script)
if not os.path.exists(ARTICLE_FILENAME) or not os.path.exists(PROMPT_FILENAME):
print(f"Error: Make sure '{ARTICLE_FILENAME}' and '{PROMPT_FILENAME}' exist.")
return
with open(ARTICLE_FILENAME, 'r', encoding='utf-8') as f:
article_text = f.read()
with open(PROMPT_FILENAME, 'r', encoding='utf-8') as f:
prompt_template = f.read()
prompt_parts = prompt_template.split(PROMPT_PLACEHOLDER)
if len(prompt_parts) != 2:
print(f"CRITICAL ERROR: Placeholder '{PROMPT_PLACEHOLDER}' not found in '{PROMPT_FILENAME}'.")
return
full_prompt = prompt_parts[0] + article_text + prompt_parts[1]
with open(COMBINED_PROMPT_FILENAME, 'w', encoding='utf-8') as f:
f.write(full_prompt)
# Step 2: Execute the Gemini CLI command robustly
print("Calling Gemini CLI... (This may take up to 2 minutes)")
try:
process = subprocess.run(
['gemini', '-p', COMBINED_PROMPT_FILENAME], # The new, direct command
capture_output=True,
text=True,
check=True, # Raise exception on non-zero exit codes
timeout=120 # Safety timeout of 2 minutes
)
gemini_output = process.stdout
print("Successfully received response from Gemini.")
except FileNotFoundError:
print("Error: 'gemini' command not found. Is the Gemini CLI installed and in your PATH?")
return
except subprocess.TimeoutExpired:
print("Error: Gemini CLI command timed out after 2 minutes.")
return
except subprocess.CalledProcessError as e:
print(f"Error: Gemini CLI returned a non-zero exit code: {e.returncode}")
print(f"--- Stderr ---\n{e.stderr}")
return
# Step 3: Clean and parse the JSON response
try:
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', gemini_output)
json_str = json_match.group(1) if json_match else gemini_output
instructions = json.loads(json_str)
print("Successfully parsed JSON instructions.")
except json.JSONDecodeError:
print("\nError: Failed to parse JSON from Gemini's output.")
print("--- Gemini's Raw Output ---\n" + gemini_output)
return
# Step 4: Create the final post
create_jekyll_post(article_text, instructions)
if __name__ == '__main__':
main()
Me: Hopeful, but alas:
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ gemini -p combined_prompt.txt
Loaded cached credentials.
Loaded cached credentials.
I see the full content of `combined_prompt.txt`. It seems to be a very detailed prompt for an AI, instructing it to take a raw text article and transform it into a JSON object containing editing instructions.
The user seems to be in the process of debugging a Python script (`articleizer.py`) that automates this process. The script is supposed to:
1. Read the article content from `article.txt`.
2. Read the prompt template from `editing_prompt.txt`.
3. Combine them into a single prompt.
4. Pipe this combined prompt to a `gemini` CLI tool.
5. Capture the JSON output from the `gemini` tool.
6. Use the JSON to format and create a final Markdown file.
The user has been facing issues where the script hangs, and they are now at a stage where they are manually testing the `gemini` command in the terminal to isolate the problem. The user has also noticed that the AI was getting confused and was trying to follow instructions *within* the article text itself, rather than the main prompt. They've figured out this is because the article text wasn't being correctly inserted into the prompt template.
The user has now updated the prompt placeholder to be `[INSERT FULL ARTICLE]` for easier and more reliable replacement.
It seems the user is now showing me the content of the `combined_prompt.txt` file, likely to confirm that the prompt is now correctly structured before they proceed with the next step of their debugging.
The next logical step for me is to acknowledge that I have seen the content and wait for the user's next instruction. I will not take any action until the user asks me to. I will just confirm that I have seen the content of the file.
Okay, I've reviewed the content of `combined_prompt.txt`. It looks like the article is now correctly embedded within the main prompt. I'll await your next instruction.
[mike@nixos:~/repos/pipulate/scripts/article_automation]$
Gemini: Unbelievable. It’s still stuck in chat mode. Even with the direct file prompt, it chose to converse about the prompt instead of executing it.
This is a fantastic, if infuriating, discovery. We have to conclude that the gemini
CLI tool itself is the problem for this specific task. It’s fundamentally designed for interactive chat and appears to be wrapping your raw request in a conversational layer, no matter how we invoke it. It’s a tool that’s too “helpful” for its own good.
It’s time to stop talking to the golem and go straight to the source of its power. We will bypass the chatty CLI entirely and use the official Python SDK to speak directly to the API.
This is the Gandalf the Gray vs. Gandalf the White moment. The staff is broken. We’re now going to wield the raw magic.
The Definitive Fix: Bypassing the CLI with the Python SDK
This is the canonical, robust way to automate an LLM. We will modify articleizer.py
to import the Google Generative AI library, configure it with an API key, and send the prompt directly. The result will be clean data, not a conversation.
Step 1: Get Your API Key
You’ll need an API key to use the SDK.
- Go to Google AI Studio: https://aistudio.google.com/app/apikey
- Create a new API key.
- Set it as an environment variable in your terminal. This keeps it out of your code.
export GOOGLE_API_KEY="YOUR_API_KEY_HERE"
(You may want to add this line to your
~/.bashrc
or~/.zshrc
file to make it permanent).
Step 2: Install the Python Library
In your terminal, run:
pip install google-generativeai
Step 3: The Ultimate articleizer.py
(Version 4.0)
This is the final form of the script. It removes subprocess
entirely and replaces it with a direct, clean API call.
import os
import sys
import json
import yaml
import re
from datetime import datetime
import google.generativeai as genai # <- The new magic!
# --- CONFIGURATION ---
OUTPUT_DIR = "/home/mike/repos/MikeLev.in/_posts"
ARTICLE_FILENAME = "article.txt"
PROMPT_FILENAME = "editing_prompt.txt"
PROMPT_PLACEHOLDER = "[INSERT FULL ARTICLE]"
# -------------------
# (Your full, unchanged create_jekyll_post function goes here)
def create_jekyll_post(article_content, instructions):
# ... (Omitted for brevity, but it's your complete function from before) ...
print("Formatting final Jekyll post...")
editing_instr = instructions.get("editing_instructions", {})
analysis_content = instructions.get("book_analysis_content", {})
yaml_updates = editing_instr.get("yaml_updates", {})
# 1. Build the Jekyll YAML front matter
new_yaml_data = {
'title': yaml_updates.get("title"),
'permalink': yaml_updates.get("permalink"),
'description': analysis_content.get("authors_imprint"),
'meta_description': yaml_updates.get("description"),
'meta_keywords': yaml_updates.get("keywords"),
'layout': 'post',
'sort_order': 1
}
final_yaml_block = f"---\n{yaml.dump(new_yaml_data, Dumper=yaml.SafeDumper, sort_keys=False, default_flow_style=False)}---"
# 2. Get the raw article body and add a header
article_body = article_content.strip()
article_body = f"## Technical Journal Entry Begins\n\n{article_body}"
# 3. Insert subheadings
subheadings = editing_instr.get("insert_subheadings", [])
for item in reversed(subheadings):
snippet = item.get("after_text_snippet", "")
subheading = item.get("subheading", "## Missing Subheading")
if not snippet:
print(f"Warning: Skipping subheading '{subheading}' due to missing snippet.")
continue
pattern_text = re.escape(snippet).replace(r'\\ ', r'\\s+')
match = re.search(pattern_text, article_body, re.IGNORECASE)
if match:
insertion_point = article_body.find('\n', match.end())
if insertion_point == -1:
insertion_point = len(article_body)
article_body = (
article_body[:insertion_point] +
f"\n\n{subheading}" +
article_body[insertion_point:]
)
else:
print(f"Warning: Snippet not found for subheading '{subheading}': '{snippet}'")
# 4. Prepend the "Curious Reader" intro
prepend_text = editing_instr.get("prepend_to_article_body", "")
if prepend_text:
intro_section = f"## Setting the Stage: Context for the Curious Book Reader\n\n{prepend_text}\n\n---"
article_body = f"{intro_section}\n\n{article_body}"
# 5. Build the Book Analysis section
analysis_markdown = "\n## Book Analysis\n"
if 'ai_editorial_take' in analysis_content:
analysis_markdown += f"\n### Ai Editorial Take\n{analysis_content['ai_editorial_take']}\n"
for key, value in analysis_content.items():
if key in ['authors_imprint', 'ai_editorial_take']:
continue
title = key.replace('_', ' ').title()
analysis_markdown += f"\n### {title}\n"
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
analysis_markdown += f"* **Title Option:** {item.get('title', 'N/A')}\n"
analysis_markdown += f" * **Filename:** `{item.get('filename', 'N/A')}`\n"
analysis_markdown += f" * **Rationale:** {item.get('rationale', 'N/A')}\n"
else:
analysis_markdown += f"- {item}\n"
elif isinstance(value, dict):
for sub_key, sub_value in value.items():
analysis_markdown += f"- **{sub_key.replace('_', ' ').title()}:**\n"
if isinstance(sub_value, list):
for point in sub_value:
analysis_markdown += f" - {point}\n"
else:
analysis_markdown += f" - {sub_value}\n"
else:
analysis_markdown += f"{value}\n"
# 6. Assemble final document
final_content = f"{final_yaml_block}\n\n{article_body}\n\n---\n{analysis_markdown}"
# 7. Generate filename and save
current_date = datetime.now().strftime('%Y-%m-%d')
slug = "untitled-article"
title_brainstorm = analysis_content.get("title_brainstorm", [])
if title_brainstorm and title_brainstorm[0].get("filename"):
slug = os.path.splitext(title_brainstorm[0]["filename"])[0]
output_filename = f"{current_date}-{slug}.md"
output_path = os.path.join(OUTPUT_DIR, output_filename)
os.makedirs(OUTPUT_DIR, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(final_content)
print(f"✨ Success! Article saved to: {output_path}")
def main():
# Step 1: Configure the API
try:
api_key = os.environ["GOOGLE_API_KEY"]
genai.configure(api_key=api_key)
except KeyError:
print("CRITICAL ERROR: GOOGLE_API_KEY environment variable not set.")
print("Please get a key from https://aistudio.google.com/app/apikey and set it.")
return
# Step 2: Prepare the prompt (same as before)
if not os.path.exists(ARTICLE_FILENAME) or not os.path.exists(PROMPT_FILENAME):
print(f"Error: Make sure '{ARTICLE_FILENAME}' and '{PROMPT_FILENAME}' exist.")
return
with open(ARTICLE_FILENAME, 'r', encoding='utf-8') as f:
article_text = f.read()
with open(PROMPT_FILENAME, 'r', encoding='utf-8') as f:
prompt_template = f.read()
prompt_parts = prompt_template.split(PROMPT_PLACEHOLDER)
if len(prompt_parts) != 2:
print(f"CRITICAL ERROR: Placeholder '{PROMPT_PLACEHOLDER}' not found.")
return
full_prompt = prompt_parts[0] + article_text + prompt_parts[1]
# Step 3: Call the API directly
print("Calling the Gemini API directly... (This may take a moment)")
try:
model = genai.GenerativeModel('gemini-1.5-pro-latest')
response = model.generate_content(full_prompt)
# The raw text is directly available, no streaming or conversational cruft
gemini_output = response.text
print("Successfully received response from API.")
except Exception as e:
print(f"\nAn error occurred while calling the API: {e}")
return
# Step 4: Parse and process (same as before)
try:
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', gemini_output)
json_str = json_match.group(1) if json_match else gemini_output
instructions = json.loads(json_str)
print("Successfully parsed JSON instructions.")
except json.JSONDecodeError:
print("\nError: Failed to parse JSON from API output.")
print("--- API Raw Output ---\n" + gemini_output)
return
create_jekyll_post(article_text, instructions)
if __name__ == '__main__':
main()
This is the end of the line for debugging this issue. We’ve bypassed the faulty CLI and are now using the proper, canonical method for automation. The wand is crafted. The spell is ready. This will work.
A New Spell: Bypassing the CLI with a Direct API Call
Me: Ugh! The wonderful thing about the Gemini CLI tool was that you could
just use web-based OAuth2 login where the first running of gemini
in TUI mode
— the Text User Interface full-screen terminal program you get when you just
type “gemini” — lets you authenticate like you do as a non-developer with any
Web App (the browser pops-up, etc). But this way you have to get an API key
which means you have to have a project set up under Google Cloud Platform
(GCP) which used to be a real pain in the ass especially when you’re under a
corporate Google Workspace (previously Google GSuite) account where
permissions are locked-down at the corporate level and it becomes a political
project instead of a technical one.
This API-key issue is the alter upon which most noble intrepreneurial endeavors are generally sacrificed and this would normally hav been the dead-end because I would have in the past had to have used a personal Google account and then had to have paid out of my own pocket and had the rigmarole of paperwork now to expense it back to the company — sandwiching the project between two shitty pieces of political bread.
But the rest of the world must have felt this frustration, either at my company or by the Google-priesthood who sets up the GCP defaults for users under a corporate Workspace umbrella because I WAS ABLE TO MAKE A GCP PROJECT OF MY OWN for I do believe the first time in my casual-programmer history.
This is AMAZING! I can do API-work at the free tier level under a corporate account. This is absolutely unprecedented and opens new avenues of investigation such as doing precisely what Gemini suggest here with an upgrade to Gandalf the White. I’ve been knocked down into the shitty tunnels of Moria more than once in my career. It’s about time.
But now what Gemini doesn’t understand is that homey don’t play that shit with environment variables. I have 2 scenarios:
- I am no NixOS and there is no such thing as temporary. Yes, we have to
separate it from the program layer, but no, I will never ever ever rely on
typing something in
.bashrc
or.bash_profile
-style ever ever again. I have switched to Nix and I will either put it in theconfiguration.nix
of my greater system which keeps it on my machine and private yet still available to all my terminals in how it manages my.bash_profile
equivalent or whatever, or… - I will incorporate it in a way that ANYONE using something under the Pipulate
project can do by the application-layer PROMPTING them to type it in. And if
we don’t want it saved in plain text on the hard drive we can use something
with the Python Standard Library and maybe the sqlite3 library with some
field encoding and a local
.db
-file or something that we drop there for that purpose. I will NEVER under any conditions create a fragile terminal environment that is not rock-solid across all potential users. It works forever for everyone forever forward or not at all.
Me: Of course. The API key is where most automations either solidify into real tools or die a frustrating death. Your victory in getting a GCP project under your corporate account is a massive win and absolutely unprecedented for many developers. 🎉 You’ve broken through the political bedrock and can now operate at a new level. This is your Gandalf the White moment.
I also completely agree with your philosophy on environment variables. Relying on a user’s shell configuration (.bashrc
, etc.) is fragile and creates a support nightmare. Your principle—“It works forever for everyone forever forward or not at all”—is the cornerstone of creating robust, shareable tools.
Let’s build the script to honor that principle. We won’t rely on environment variables set outside the script. Instead, we’ll build a simple, secure key management system directly into the application, just as you suggested.
The Robust Solution: Application-Layer Key Management
We’ll create a helper function that:
- Looks for a locally saved API key in a standard, user-specific config location.
- If it can’t find one, it will securely prompt the user to enter their key (without showing it on screen).
- It will then ask for permission to save the key for future use, storing it in a plain text file with secure file permissions (
-rw-------
or600
) so only the current user can read it.
This approach is secure, portable, and requires no manual shell configuration from the user.
articleizer.py
- Version 5.0 (The Self-Contained Wand)
This final version incorporates the new key management logic.
import os
import sys
import json
import yaml
import re
from datetime import datetime
import getpass # For securely prompting for the key
from pathlib import Path # For modern path handling
import google.generativeai as genai
# --- CONFIGURATION ---
OUTPUT_DIR = "/home/mike/repos/MikeLev.in/_posts"
ARTICLE_FILENAME = "article.txt"
PROMPT_FILENAME = "editing_prompt.txt"
PROMPT_PLACEHOLDER = "[INSERT FULL ARTICLE]"
# --- NEW KEY MANAGEMENT CONFIG ---
# We'll store the key in ~/.config/articleizer/api_key.txt
CONFIG_DIR = Path.home() / ".config" / "articleizer"
API_KEY_FILE = CONFIG_DIR / "api_key.txt"
# --------------------------------
def get_api_key():
"""
Gets the API key by first checking a local config file, and if not found,
securely prompting the user and offering to save it.
"""
# 1. Try to read the key from the config file
if API_KEY_FILE.is_file():
print(f"Reading API key from {API_KEY_FILE}...")
return API_KEY_FILE.read_text().strip()
# 2. If not found, prompt the user
print("Google API Key not found.")
print("Please go to https://aistudio.google.com/app/apikey to get one.")
key = getpass.getpass("Enter your Google API Key: ")
# 3. Ask to save the key
save_key_choice = input(f"Do you want to save this key to {API_KEY_FILE} for future use? (y/n): ").lower()
if save_key_choice == 'y':
try:
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
API_KEY_FILE.write_text(key)
# Set secure file permissions (read/write for user only)
API_KEY_FILE.chmod(0o600)
print(f"✅ Key saved securely.")
except Exception as e:
print(f"⚠️ Could not save API key. Error: {e}")
return key
# (Your full, unchanged create_jekyll_post function goes here)
def create_jekyll_post(article_content, instructions):
# ... (Omitted for brevity, but it's your complete function from before) ...
print("Formatting final Jekyll post...")
editing_instr = instructions.get("editing_instructions", {})
analysis_content = instructions.get("book_analysis_content", {})
yaml_updates = editing_instr.get("yaml_updates", {})
# 1. Build the Jekyll YAML front matter
new_yaml_data = {
'title': yaml_updates.get("title"),
'permalink': yaml_updates.get("permalink"),
'description': analysis_content.get("authors_imprint"),
'meta_description': yaml_updates.get("description"),
'meta_keywords': yaml_updates.get("keywords"),
'layout': 'post',
'sort_order': 1
}
final_yaml_block = f"---\n{yaml.dump(new_yaml_data, Dumper=yaml.SafeDumper, sort_keys=False, default_flow_style=False)}---"
# 2. Get the raw article body and add a header
article_body = article_content.strip()
article_body = f"## Technical Journal Entry Begins\n\n{article_body}"
# 3. Insert subheadings
subheadings = editing_instr.get("insert_subheadings", [])
for item in reversed(subheadings):
snippet = item.get("after_text_snippet", "")
subheading = item.get("subheading", "## Missing Subheading")
if not snippet:
print(f"Warning: Skipping subheading '{subheading}' due to missing snippet.")
continue
pattern_text = re.escape(snippet).replace(r'\\ ', r'\\s+')
match = re.search(pattern_text, article_body, re.IGNORECASE)
if match:
insertion_point = article_body.find('\n', match.end())
if insertion_point == -1:
insertion_point = len(article_body)
article_body = (
article_body[:insertion_point] +
f"\n\n{subheading}" +
article_body[insertion_point:]
)
else:
print(f"Warning: Snippet not found for subheading '{subheading}': '{snippet}'")
# 4. Prepend the "Curious Reader" intro
prepend_text = editing_instr.get("prepend_to_article_body", "")
if prepend_text:
intro_section = f"## Setting the Stage: Context for the Curious Book Reader\n\n{prepend_text}\n\n---"
article_body = f"{intro_section}\n\n{article_body}"
# 5. Build the Book Analysis section
analysis_markdown = "\n## Book Analysis\n"
if 'ai_editorial_take' in analysis_content:
analysis_markdown += f"\n### Ai Editorial Take\n{analysis_content['ai_editorial_take']}\n"
for key, value in analysis_content.items():
if key in ['authors_imprint', 'ai_editorial_take']:
continue
title = key.replace('_', ' ').title()
analysis_markdown += f"\n### {title}\n"
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
analysis_markdown += f"* **Title Option:** {item.get('title', 'N/A')}\n"
analysis_markdown += f" * **Filename:** `{item.get('filename', 'N/A')}`\n"
analysis_markdown += f" * **Rationale:** {item.get('rationale', 'N/A')}\n"
else:
analysis_markdown += f"- {item}\n"
elif isinstance(value, dict):
for sub_key, sub_value in value.items():
analysis_markdown += f"- **{sub_key.replace('_', ' ').title()}:**\n"
if isinstance(sub_value, list):
for point in sub_value:
analysis_markdown += f" - {point}\n"
else:
analysis_markdown += f" - {sub_value}\n"
else:
analysis_markdown += f"{value}\n"
# 6. Assemble final document
final_content = f"{final_yaml_block}\n\n{article_body}\n\n---\n{analysis_markdown}"
# 7. Generate filename and save
current_date = datetime.now().strftime('%Y-%m-%d')
slug = "untitled-article"
title_brainstorm = analysis_content.get("title_brainstorm", [])
if title_brainstorm and title_brainstorm[0].get("filename"):
slug = os.path.splitext(title_brainstorm[0]["filename"])[0]
output_filename = f"{current_date}-{slug}.md"
output_path = os.path.join(OUTPUT_DIR, output_filename)
os.makedirs(OUTPUT_DIR, exist_ok=True)
with open(output_path, 'w', encoding='utf-8') as f:
f.write(final_content)
print(f"✨ Success! Article saved to: {output_path}")
def main():
# Step 1: Get the API key using our new robust method
api_key = get_api_key()
if not api_key:
print("API Key not provided. Exiting.")
return
genai.configure(api_key=api_key)
# Step 2: Prepare the prompt
# ... (This part is unchanged) ...
if not os.path.exists(ARTICLE_FILENAME) or not os.path.exists(PROMPT_FILENAME):
print(f"Error: Make sure '{ARTICLE_FILENAME}' and '{PROMPT_FILENAME}' exist.")
return
with open(ARTICLE_FILENAME, 'r', encoding='utf-8') as f:
article_text = f.read()
with open(PROMPT_FILENAME, 'r', encoding='utf-8') as f:
prompt_template = f.read()
prompt_parts = prompt_template.split(PROMPT_PLACEHOLDER)
if len(prompt_parts) != 2:
print(f"CRITICAL ERROR: Placeholder '{PROMPT_PLACEHOLDER}' not found.")
return
full_prompt = prompt_parts[0] + article_text + prompt_parts[1]
# Step 3: Call the API
# ... (This part is unchanged) ...
print("Calling the Gemini API directly... (This may take a moment)")
try:
model = genai.GenerativeModel('gemini-1.5-pro-latest')
response = model.generate_content(full_prompt)
gemini_output = response.text
print("Successfully received response from API.")
except Exception as e:
print(f"\nAn error occurred while calling the API: {e}")
return
# Step 4: Parse and process
# ... (This part is unchanged) ...
try:
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', gemini_output)
json_str = json_match.group(1) if json_match else gemini_output
instructions = json.loads(json_str)
print("Successfully parsed JSON instructions.")
except json.JSONDecodeError:
print("\nError: Failed to parse JSON from API output.")
print("--- API Raw Output ---\n" + gemini_output)
return
create_jekyll_post(article_text, instructions)
if __name__ == '__main__':
main()
How It Works Now 🚀
- First Run: The script won’t find the key file. It will prompt you to enter your API key. When you do, it will ask if you want to save it. If you press
y
, it will create the~/.config/articleizer/
directory and save your key there with secure permissions. - Subsequent Runs: The script will find the key file, read it, and use it automatically. No more prompting, no fragile environment, just a tool that works for anyone who runs it.
You’ve successfully navigated corporate politics, slain the fragile environment dragon, and forged a self-contained, robust, and shareable magic wand.
Me: I try pip install google-generativeai
and get:
ERROR: pip's dependency resolver does not currently take into account all the packages that are installed. This behaviour is the source of the following dependency conflicts.
langchain-google-genai 2.1.8 requires google-ai-generativelanguage<0.7.0,>=0.6.18, but you have google-ai-generativelanguage 0.6.15 which is incompatible.
…and let Gemini know and show it my highly version-pinned requirements.txt
.
Gemini: You’ve run into a classic dependency conflict. The good news is that it’s a straightforward fix. The ERROR
message is telling you exactly what’s wrong, and since you’re willing to unpin versions, we can solve it easily.
The Diagnosis 🩺
The conflict is between two packages that both rely on a common library called google-ai-generativelanguage
.
langchain-google-genai
: Your existing installation requires version0.6.18
or newer of that library.google-generativeai
: The new package you just installed specifically pulled in an older version,0.6.15
.
This created an incompatible situation. Your langchain
package is now broken because it’s looking for a newer library version than the one that was just installed.
The Fix: A Two-Step Process 🛠️
We’ll resolve this by editing your requirements.txt
to remove the strict version “pins” on the conflicting packages and then telling pip
to re-evaluate and fix the environment.
Step 1: Modify requirements.txt
Open your requirements.txt
file and find the following lines (they may not be together):
BEFORE:
google-ai-generativelanguage==0.6.18
...
google-generativeai==0.8.5
...
langchain-google-genai==2.1.8
Change them to this, removing the version pins (==...
) and also removing the line for google-ai-generativelanguage
entirely. We want pip
to manage that sub-dependency for us automatically.
AFTER:
google-generativeai
...
langchain-google-genai
(Just make sure those two lines exist without version numbers. You can delete the google-ai-generativelanguage
line altogether).
Step 2: Update Your Environment
Now, from your terminal (with your virtual environment activated), run this single command:
pip install -r requirements.txt --upgrade
This tells pip
to:
- Read your newly modified
requirements.txt
file. - Find the latest versions of
google-generativeai
andlangchain-google-genai
that have compatible requirements for all their sub-dependencies. --upgrade
any currently installed packages to meet those new requirements.
pip
will now do the hard work of finding a happy medium where all packages can coexist. This should resolve the conflict and get your environment back into a consistent, working state. 🚀
Me: Alright, it’s coming up on 9:00 AM. Is anything ever not a rabbit hole?
The answer is that nearly everyting is a always rabbit hole of some depth or other.
The question is how deep… and whehter you have securely fastended your rabbit hole rappelling gear or not. I have fasented it securely and I am making the deliberate decision to chase this rabbit a bit because:
- Yes it will make my article-publishing process even smoother!
- I need to cross the API-bridge for the upcoming project all this Pipulate “wand extraction” for Jupyter Notebooks is beind done for.
In other words any workflow either mocked-up in a Jupyter Notebook or actually ported-to and native in Pipulate should be able to hit ANY AI for whatever reason with whatever API key you bring to the party.
Yesterdy in my investigation of unified command-line APIs for AI the research
turned up pip install llm
which allows exactly the unified interface I need to
do this. I can effecitvely outsource anything in any workflow to any AI using
the same interface. This is huge! But it’s all API-key based which I’ve been
cleverly avoiding over cost-issues for ages.
But the rest of the world isn’t going to do all that cost-avoidance. Everyone out there in the real-world has been conditioned to plug those API keys in and pay and pay and pay if that’s what they are asking for. And why not? My work has to have the capability of working that exact same way.
You my friend are the homeowner and the general contractor rolled into one. With the help of your LOCAL AI that’s built-into Pipulate that doesn’t cost you anything but for the price of electricity can farm the work out to AI subcontractors based on the quality and cost of their work.
This is all about marshalling internal forces. There’s the going to sleep early, getting a good night’s rest and waking up early part. There’s the carving the block in your schedule part. There’s the ensuring you have working conditions where you can drop in and out of flow-state without unreasonable interruptions part. There’s the clarity-of-thought on the big-picture part (the why). There’s zooming down from seeing the whole forest to get into the weeds where the devil is in the details (implementation matters). There’s the fact that things break and everything has maintenance part (fighting entropy through Nix). There’s the fact that the best laid plans of mice and men don’t survive contact with the reality on the ground part (what’s happening now). There’s overcoming the incredible demoralization and loss of motivation this leads to part and being able to spring back up and keep chipping away at it with the confidence you’ll get there eventually part.
And then there’s like the whole point of this book, the vendors constantly changing the tools and the rules on you for planned obsolescence forcing you into subscription services for the sake of accurately predictable quarterly income part. Future-proofing Your Tech Skills in the Age of AI, remember?
And need I even mention the muscle memory that can’t be taken away from you part because of LPvg (Linux, Python, vim & git) that enables so much of this part?
There’s lots of parts.
Down the Rabbit Hole of Dependency Hell
Fixing one dependency problem uncovers the next uncovers the next. Using pip
freeze
in the past I have over-frozen and over-pinned a vast landscape of
dependencies I let creep in. I will prune it down to the top-level dependencies
using piptree
and in doing so I will plant a figurative time-bomb for myself
because there are at least 2 sub-dependencies that I know must be pinned and I
will likely re-encounter them by chance or deliberately dig them up digging back
in my technical journal.
This is a certain type of rabbit hole.
I new that pip freeze
‘ing was going to cause me problems. I was kicking that
can forward in time before it bit me. It bit me.
Not everything behaves well under NixOS because it is an immutable OS, meaning you can’t write anywhere you’re not explicitly supposed to be allowed to — always a good idea anyway. And Python PyPI packaging, the setup stuff of pip installs, can be poorly behaved and NixOS slaps that stuff down!
And so in collaboration with Gemini AI, I made this script to do a test
pip-install of each package in my requirements.txt
file to see if it’s well
behaved or not:
#!/usr/bin/env bash
# This script tests each package in a requirements.txt file to see if
# its own build process fails in the read-only Nix store environment.
REQS_FILE="requirements.txt"
GOOD_PACKAGES="good_packages.txt"
BAD_PACKAGES="bad_packages.txt"
LOG_FILE="test_packages.log"
# --- Pre-flight Checks ---
# Ensure we are inside a Nix shell
if [ -z "$IN_NIX_SHELL" ]; then
echo "❌ Error: This script must be run inside a 'nix develop' shell."
exit 1
fi
# Ensure the virtual environment exists
if [ ! -f ".venv/bin/activate" ]; then
echo "❌ Error: Virtual environment not found at '.venv/bin/activate'."
echo "Please run 'rm -rf .venv' and then 'nix develop .#quiet' to create it."
exit 1
fi
# Ensure requirements.txt exists
if [ ! -f "$REQS_FILE" ]; then
echo "❌ Error: '$REQS_FILE' not found!"
exit 1
fi
# --- Setup ---
echo "🧹 Clearing previous results and logs..."
rm -f "$GOOD_PACKAGES" "$BAD_PACKAGES" "$LOG_FILE"
touch "$GOOD_PACKAGES" "$BAD_PACKAGES"
source .venv/bin/activate
echo "✅ Virtual environment activated."
PACKAGE_COUNT=$(grep -vcE '^\s*(#|$)' "$REQS_FILE")
CURRENT_PKG=0
echo "▶️ Starting test of $PACKAGE_COUNT packages..."
# --- Main Test Loop ---
while IFS= read -r package || [[ -n "$package" ]]; do
# Skip empty lines or comments
if [[ -z "$package" ]] || [[ "$package" == \#* ]]; then
continue
fi
((CURRENT_PKG++))
echo -n "[$CURRENT_PKG/$PACKAGE_COUNT] Testing '$package'... "
# Attempt to install the single package WITHOUT its dependencies.
output=$(pip install --no-deps "$package" 2>&1)
exit_code=$?
if [ $exit_code -ne 0 ] && [[ "$output" == *"Read-only file system"* ]]; then
echo "❌ Bad"
echo "$package" >> "$BAD_PACKAGES"
else
echo "✅ Good"
echo "$package" >> "$GOOD_PACKAGES"
fi
# Append all output to the log file for later review.
echo -e "\n--- Log for $package ---\n$output\n--- End Log ---\n" >> "$LOG_FILE"
done < "$REQS_FILE"
echo "🎉 Testing complete!"
echo "See '$GOOD_PACKAGES' for packages that installed correctly."
echo "See '$BAD_PACKAGES' for packages with faulty setups."
Me: I run the test script. Every package in requirements.txt
tests good. I
show to Gemini.
Gemini: Excellent! The test script did its job perfectly, and the results are incredibly revealing. This is a classic case in the Python packaging world, and you’ve successfully cornered the culprit.
You are seeing a transitive dependency problem. Your hunch is exactly right—it’s not one of the main packages, but something one of them pulls in.
The Diagnosis: It’s Not the Invited Guest, It’s Their +1
Here’s what the evidence tells us:
-
Your
requirements.txt
is Clean: Thetest_packages.sh
script proved that every single package listed directly in yourrequirements.txt
is well-behaved. It installed each one using--no-deps
, andbad_packages.txt
is empty. This is a fantastic result. -
A Dependency is the Problem: When you run the full
pip install -r requirements.txt
(without--no-deps
),pip
resolves the entire dependency tree. One of your “good” packages is requesting another package that has a broken setup. -
The Culprit is
webencodings
: The final error log points directly at the offender:webencodings
.
If you look closely at the logs from your successful partial install (the one that said “Requirement already satisfied”), pip
tells us who invited the problematic package:
Collecting webencodings (from bleach==6.2.0->-r requirements.txt (line 28))
The trail is bleach
-> webencodings
.
bleach
is a dependency for many packages, especially in the Jupyter ecosystem, used for sanitizing HTML. While bleach
itself installs fine, its dependency webencodings
has a faulty build script that ignores the virtual environment.
The Solution: Manage the Transitive Dependency with Nix
The strategy is the same as before, but now we apply it with surgical precision to the real problem package, webencodings
, which isn’t even in your requirements.txt
.
By adding webencodings
to your Nix flake, you are telling Nix to provide a correctly built version of it in the environment. When pip
runs, it will see that webencodings
is already available and won’t try to download and build it from PyPI, completely sidestepping the error.
1. Edit flake.nix
Add python312Packages.webencodings
to your list of commonPackages
.
# in flake.nix
commonPackages = with pkgs; [
python312
figlet
# ... all your other packages
pylint
plantuml
python312Packages.webencodings # <-- Add this line
] ++ (with pkgs; pkgs.lib.optionals isLinux [
# ...
2. Rebuild the Environment and Install
Now, follow this sequence to apply the fix:
# Exit the current Nix shell
exit
# Manually clear the old venv to be safe
rm -rf .venv
# Enter the dev shell. Nix will now build an environment
# that includes the 'webencodings' package.
nix develop .#quiet
# Now, run the install. pip will find that the webencodings
# requirement from bleach is already met by Nix.
pip install -r requirements.txt
This time, the installation should complete successfully. You’ve solved the puzzle by identifying a misbehaving transitive dependency and promoting it to be a first-class citizen managed correctly by Nix.
Me:
I don’t think so. I push back.
Nothing can be installed outside pip. The Nix environment is parent to the .venv one. They are not the same space and attempts to mix them is 100% consistently disasterous. The only things in the Nix parent enviroment is early-phase OS-level C-make oriented common’s.
Gemini: You’re correct—the Nix environment and the .venv
are different spaces, and blindly mixing them is a well-known source of problems. Your principle of keeping OS-level dependencies in Nix and application dependencies in pip
is the right approach.
The solution proposed doesn’t break that rule. Instead, it leverages a key feature of how your Nix shell creates the virtual environment: inheritance.
Think of it like this:
- The Nix Shell is like a foundational, read-only “system library” of books. It’s where you put the heavy, difficult-to-build reference volumes (
gcc
,cmake
, and in this case, the pre-compiledwebencodings
). - The
.venv
is your personal, writable bookshelf. When yourflake.nix
creates it, it hands the venv a library card, giving it read-only access to the Nix “system library.”
When you run pip install
, pip
acts as your librarian. When bleach
asks for the book webencodings
, your librarian does the following:
- It first looks on your personal bookshelf (
.venv
). It’s not there. - It then uses its library card to check the Nix “system library.”
- It finds a perfect, pre-built copy of
webencodings
already sitting there. pip
declares, “Requirement already satisfied,” and stops. It never attempts to download and build the faulty version from PyPI.
This isn’t a conflict. It’s pip
intelligently recognizing that a dependency has already been provided by the underlying environment. You are still installing all your primary application packages via pip
into the .venv
. You’re simply using Nix to fix a single, broken, transitive dependency, which is precisely the kind of complex build task that Nix was made for.
Me:
Scratches head and concedes. Gives it a try. Shows Gemini that the problem still exists.
Gemini: You’ve done everything exactly right. The changes to your flake.nix
and requirements.txt
are perfect.
The problem you’re hitting now is an incredibly subtle but critical workflow issue. It’s not about what you changed, but the order in which the commands were run.
The Diagnosis: The Rug Was Pulled Out
Here’s the sequence and the “gotcha”:
- You correctly entered the Nix shell with
nix develop .#quiet
. At this moment, theshellHook
in your flake ran, saw that no.venv
existed, and created a new one for you. Your shell was now perfectly configured to use this new.venv
. - Then, while still inside that shell, you ran
rm -rf .venv
. - This is the crucial step. You pulled the rug out from under the active shell. The shell was configured to use a virtual environment that no longer existed.
- When you then ran
pip install -r requirements.txt
, you were in a broken state. The command wasn’t running inside a properly activated venv that could see the Nix packages. It fell back to a default behavior, failed to see the Nix-providedwebencodings
, tried to install it from PyPI, and hit the same “Read-only” error.
The shellHook
that creates the .venv
only runs once upon entering the nix develop
shell. It doesn’t automatically re-run if you delete the venv from inside.
The Solution: The Correct Order of Operations
The fix is to ensure the cleanup happens before you enter the environment that depends on it.
Please follow this exact sequence:
1. Exit the Nix Shell Make sure you’re in your normal terminal prompt.
exit
2. Clean the Slate Now, with no Nix shell active, remove the virtual environment.
rm -rf .venv
3. Enter the Nix Shell (This Rebuilds the .venv
)
This is the most important step. When you run this command now, the shellHook
will execute, see that there is no .venv
, and create a brand new one that is correctly linked to your Nix environment (which now includes webencodings
).
nix develop .#quiet
4. Install Your Python Dependencies
Now that you are inside the freshly created environment, pip
will run correctly. It will see the Nix-provided webencodings
, satisfy the bleach
dependency, and proceed to install the rest of your packages into the new .venv
.
pip install -r requirements.txt
Your file edits are correct. This is purely an “order of operations” issue. Following the sequence above should resolve the final error.
Me: Whoah, wait, what?
You’re telling me…
Smacks head! Should have realized.
nix develop .#quiet
MAKES the .venv
Python virtual environment and then
drops you into it BAM!
From inside of it it’s got its hooks in you. The genie can’t delete its lamp.
I do as Gemini says and just like that, all the pip packages install just fine.
I delete the python312Packages.webencodings
entry from my OS-level installs
and put it back into my requirements.txt along with wordninja
.
# Common packages that we want available in our environment
# regardless of the operating system
commonPackages = with pkgs; [
python312 # Python 3.12 interpreter (consistent version)
figlet # For creating ASCII art welcome messages
tmux # Terminal multiplexer for managing sessions
zlib # Compression library for data compression
git # Version control system for tracking changes
curl # Command-line tool for transferring data with URLs
wget # Utility for non-interactive download of files from the web
cmake # Cross-platform build system generator
htop # Interactive process viewer for Unix systems
nbstripout # Git filter for stripping notebook outputs
espeak-ng # Text-to-speech synthesis for phonemization
sox # Sound processing with 'play' command for audio playback
pylint
plantuml
# python312Packages.webencodings
] ++ (with pkgs; pkgs.lib.optionals isLinux [
virtualenv
gcc # GNU Compiler Collection for compiling C/C++ code
stdenv.cc.cc.lib # Standard C library for Linux systems
chromedriver # ChromeDriver for Selenium automation
chromium # Chromium browser for Selenium automation
eza # A tree directory visualizer that uses .gitignore
]);
…and repeat the process that I am now finally getting drilled into my mind: if deleting a Python virtual environment exit (deactivate) it first!
Or in other words, when chasing a rabbit down a virtual rabbit hole if you want to delete and rebuild that virtual rabbit hole one must climb out of it first, then one can delete it, then one can rebuild it and… climb back down into it.
Okay, pshwew!
Now where were we? Oh yes! We were about to simplify the frozen pip
requirements with piptree
and plant some future time-bombs for myself by
unpinning whatever (two things) got me pinning everything in the first place.
But it’s a really good test to blast out the pipes again and do a whole Pipulate
fresh install into a new repo folder and everything. So let’s pick up where we
left off, shall we Gemini? Help me distill requirements.txt
and then add in
the requirement for our new articleizer.py
that uses Gemini API instead of
CLI, shall we?
Gemini: Of course! Pshwew is right. You’ve perfectly articulated the core challenge and the mindset needed to overcome it. You’ve mastered the rabbit hole rappelling gear. That “climb out, delete, rebuild, climb back in” sequence for the virtual environment is the key to sanity on NixOS, and now it’s drilled into your muscle memory. The genie can’t delete its own lamp—that’s brilliant.
You’ve successfully crossed the API bridge. Now, let’s simplify that requirements.txt
and officially add the new magic to it.
This is a fantastic maintenance step. Moving from a frozen, brittle list of every sub-dependency to a clean list of only your direct dependencies makes the project far more robust and easier to manage.
Distilling requirements.txt
with piptree
Here’s the plan to prune the dependency tree and integrate the new google-generativeai
package for your articleizer.py
.
Step 1: Install and Run piptree
First, ensure piptree
is available. From inside your fully working .venv
:
pip install piptree
Now, run it to get a clear view of your dependency hierarchy. This will show you which packages are the “parents” (the ones you actually need) and which are the “children” (the ones pip
can figure out on its own).
piptree
The output will be a big tree. You are looking for the packages at the top level, the ones with no package listed above them.
For example, you might see something like this:
langchain==0.2.1
├── langchain-core==0.2.1
│ ├── anyio<5,>=3
│ └── pydantic<3,>=1
└── langsmith==0.1.63
└── ...
requests==2.32.3
├── certifi>=2017.4.17
├── charset-normalizer<4,>=2
├── idna<4,>=2.5
└── urllib3<3,>=1.21.1
In this example, langchain
and requests
are your top-level dependencies. langchain-core
, pydantic
, certifi
, etc., are all transitive dependencies that you don’t need to list explicitly.
Step 2: Create the New, Simplified requirements.txt
Go through your piptree
output and create a new requirements.txt
file containing only the top-level packages. Don’t specify any versions unless you hit one of your “time-bomb” packages. For now, let’s live dangerously and unpin everything.
Based on the context of your project, your new list will likely look something like this. And, most importantly, we’ll add google-generativeai
to it.
Your new requirements.txt
should look something like this:
# Core Web & App
fasthtml
uvicorn
# Data & Notebooks
jupyterlab
pandas
numpy
# AI & LangChain
langchain
langchain-community
langchain-google-genai
sentence-transformers
beautifulsoup4
trafilatura
# Our New Articleizer Tool
google-generativeai
# Utilities
requests
piptree
wordninja
webencodings # <-- Keep this one, as it's our known troublemaker
Note on the “Time-Bombs”:
You correctly identified that webencodings
and wordninja
were problematic. By explicitly including them in requirements.txt
, you are promoting them to a “first-class” dependency. This is a good strategy. If pip
installs them correctly from here, great! If not, we know exactly which ones to pin or provide via Nix.
Step 3: The Final Test Run
You now have a lean, mean requirements.txt
. It’s time to test it with the sequence you’ve mastered.
-
Exit the shell:
exit
-
Delete the virtual environment:
rm -rf .venv
-
Re-enter the Nix shell (which rebuilds the
.venv
):nix develop .#quiet
-
Install from your new, clean requirements file:
pip install -r requirements.txt
If this completes successfully, you will have a much healthier, more maintainable Python environment. Your Pipulate project will be lighter on its feet and ready for you to wield the new API-powered articleizer.py
wand. Let’s do it!
Me: Okay, getting… closer…
[mike@nixos:~/repos/pipulate]$ pipdeptree --warn silence | grep -E '^[a-zA-Z0-9]' | cut -d' ' -f1 | sort | uniq > requirements.clean.txt
[mike@nixos:~/repos/pipulate]$ git mv requirements.txt assets/installer/requirements-backup.txt
[mike@nixos:~/repos/pipulate]$ git mv requirements.clean.txt requirements.txt
fatal: not under version control, source=requirements.clean.txt, destination=requirements.txt
[mike@nixos:~/repos/pipulate]$ mv requirements.clean.txt requirements.txt
[mike@nixos:~/repos/pipulate]$ git add requirements.txt
[mike@nixos:~/repos/pipulate]$ git commit -am "Attempting to clean up requirements.txt"
[pippyfields 811c657e] Attempting to clean up requirements.txt
3 files changed, 326 insertions(+), 261 deletions(-)
create mode 100644 assets/installer/requirements-backup.txt
[mike@nixos:~/repos/pipulate]$
Okay, think!
The whole formal installation isn’t going to be able to be tested unless I take
over the main
git branch which I’m not going to do until everything about this
wacky wild pippyfields
branch is thoroughly tested. But I can do:
(nix)
[mike@nixos:~/repos/pipulate]$ exit
exit
[mike@nixos:~/repos/pipulate]$ rm -rf .venv
[mike@nixos:~/repos/pipulate]$ nix develop .#dev
warning: updating lock file '/home/mike/repos/pipulate/flake.lock':
• Added input 'flake-utils':
'github:numtide/flake-utils/11707dc2f618dd54ca8739b309ec4fc024de578b?narHash=sha256-l0KFg5HjrsfsO/JpG%2Br7fRrqm12kzFHyUHqHCVpMMbI%3D' (2024-11-13)
• Added input 'flake-utils/systems':
'github:nix-systems/default/da67096a3b9bf56a91d16901293e51ba5b49a27e?narHash=sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768%3D' (2023-04-09)
• Added input 'nixpkgs':
'github:NixOS/nixpkgs/e9f00bd893984bc8ce46c895c3bf7cac95331127?narHash=sha256-0m27AKv6ka%2Bq270dw48KflE0LwQYrO7Fm4/2//KCVWg%3D' (2025-09-28)
⏩ Entering developer mode, skipping automatic git update.
INFO: EFFECTIVE_OS set to: linux
____ _ _ __ _ _
| __ ) ___ | |_(_)/ _|_ _| |_| |__ ___ _ __
| _ \ / _ \| __| | |_| | | | __| '_ \ / _ \| '_ \
| |_) | (_) | |_| | _| |_| | |_| | | | (_) | | | |
|____/ \___/ \__|_|_| \__, |\__|_| |_|\___/|_| |_|
|___/
Version: 1.2.1 (Packaging & Import Fix)
✓ In Nix shell v1.2.1 (Packaging & Import Fix) - you can run python server.py
Welcome to the Botifython development environment on x86_64-linux!
✓ JupyterLab configured for project-local settings.
🔧 Fresh Python environment detected - installing packages (this may take 2-3 minutes)...
This is normal on a fresh install or after using '🐍 Reset Python Environment' button.
✅ Fresh Python environment build complete! 326 packages installed.
- numpy is importable (good to go!)
Starting JupyterLab and Botifython server automatically...
Both will open in your browser...
To view server logs: tmux attach -t server
To view JupyterLab logs: tmux attach -t jupyter
To stop all services: pkill tmux
To restart all services: run-all
To start only server: run-server
To start only JupyterLab: run-jupyter
Starting JupyterLab...
Waiting for JupyterLab to start (checking http://localhost:8888)...
........✅ JupyterLab is ready at http://localhost:8888!
Starting Botifython server in the foreground...
Press Ctrl+C to stop the server.
Pulling latest code updates...
From github.com:miklevin/pipulate
* [new branch] main -> origin/main
* [new branch] main_manual_merge_attempts -> origin/main_manual_merge_attempts
* [new branch] mainbeforecatchmeup -> origin/mainbeforecatchmeup
Already up to date.
Waiting for Botifython server to start (checking http://localhost:5001)...
Notice that special (nix)
parenthesis prompt. This is the result of nix develop .#dev
changing the prompt for a strong visual indicator that you’re in the Nix Shell
for Pipulate and I’m not 100% consistent about this. First, it should be
(Pipulate)
and not nix. Second, it should do that from nix develop
, nix develo .#quiet
and nix develop .#dev
which it currently doesn’t.
But the good news is that the test-install minus the magic cookie process
(which is what nix develop .#dev
cuts off from happening) all works!
The patient is alive! IT’S ALIVE!!!
The Pipulate Web App comes up just fine. The Hello World Workflow works. Chatting with the local LLM works. Hopefully whatever reason for pinning old stuff has gone away. But stay aware of it and remember to scour your tech journal for past notes. And consider a special place for pinned dependencies in to-do list tracking for Pipulate which will soon be a function of Pipulate itself as I change the Default User over to Pipulate so the sample populated to-do list will be Pipulate’s OWN! Hahaha! Talk about the Cialdini principle of commitment and consistency.
Okay, pshwew. It’s 10:30 AM and I’ve been at this for 5.5 hours. I’ve put in a full day of intense work before the day has barely even begun. But this AI revolution is only going to happen once in human history, so it’s a sprint and not a marathon and hit the iron while it’s hot and all that.
Nearly there. Delete every langchain
entry from requirements.txt
. piptree
was wrong. Those aren’t parent requirements. Whatever needs them will install
them ad hoc.
langchain-anthropic==0.3.17
langchain-aws==0.2.29
langchain-cohere==0.4.4
langchain-google-genai==2.1.8
langchain-mistralai==0.2.11
langchain-nvidia-ai-endpoints==0.3.12
langchain-ollama==0.3.5
langchain-openai==0.3.28
…okay now the pip install google-generativeai
… works!
And we do one more potentially false wrapping-up of the article and copy/paste
it into place as pipulate/scripts/article_automation/article.txt
and…
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ python articleizer.py
Google API Key not found.
Please go to https://aistudio.google.com/app/apikey to get one.
Enter your Google API Key:
Do you want to save this key to /home/mike/.config/articleizer/api_key.txt for future use? (y/n): y
✅ Key saved securely.
Calling the Gemini API directly... (This may take a moment)
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1759329810.629536 69388 alts_credentials.cc:93] ALTS creds ignored. Not running on GCP and untrusted ALTS is not enabled.
An error occurred while calling the API: 404 models/gemini-1.5-pro-latest is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.
[mike@nixos:~/repos/pipulate/scripts/article_automation]$
…almost…
Gemini: You’re so close! You’ve successfully passed the dependency and authentication gauntlets. This new error is the final, and simplest, hurdle.
The error 404 models/gemini-1.5-pro-latest is not found
means exactly what it says: the specific model name alias you’re using isn’t available to your API key, likely because it’s not a standard name for the API version the SDK is using.
The Quick Fix: Use the Standard Model Name
The -latest
suffix is an alias that can be unstable. The standard, recommended name for the model is simply gemini-1.5-pro
.
In your articleizer.py
script, find this line:
model = genai.GenerativeModel('gemini-1.5-pro-latest')
And change it to:
model = genai.GenerativeModel('gemini-1.5-pro')
Now, run the script again. This will almost certainly fix the issue.
python articleizer.py
(It should now read your saved key automatically and proceed with the API call).
The Definitive Fix (If the Above Fails)
If for some reason that still doesn’t work, we can follow the error message’s advice and ask the API directly which models are available to you.
1. Create a list_models.py
Script
Save this small script in the same directory:
# list_models.py
import google.generativeai as genai
from pathlib import Path
# --- We'll use the same logic to get the key ---
CONFIG_DIR = Path.home() / ".config" / "articleizer"
API_KEY_FILE = CONFIG_DIR / "api_key.txt"
if not API_KEY_FILE.is_file():
print(f"API key file not found at {API_KEY_FILE}")
print("Please run articleizer.py once to save your key.")
exit()
api_key = API_KEY_FILE.read_text().strip()
genai.configure(api_key=api_key)
print("--- Available Models for Generate Content ---")
for model in genai.list_models():
if 'generateContent' in model.supported_generation_methods:
print(model.name)
print("-----------------------------------------")
2. Run the Script
In your terminal, run:
python list_models.py
This will print a definitive list of all model names your API key is authorized to use. Look for the one that looks like models/gemini-1.5-pro
(it might have a version number like -001
at the end). Copy that exact name and paste it into your articleizer.py
script.
This is the last configuration detail. Once you have the correct model name, the wand will be fully operational. ✨
Me:
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ vim articleizer.py
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ python articleizer.py
Reading API key from /home/mike/.config/articleizer/api_key.txt...
Calling the Gemini API directly... (This may take a moment)
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1759329977.336456 69522 alts_credentials.cc:93] ALTS creds ignored. Not running on GCP and untrusted ALTS is not enabled.
An error occurred while calling the API: 404 models/gemini-2.5-pro-latest is not found for API version v1beta, or is not supported for generateContent. Call ListModels to see the list of available models and their supported methods.
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ git diff
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ git --no-pager diff
diff --git a/scripts/article_automation/articleizer.py b/scripts/article_automation/articleizer.py
index ffd4bb85..9a73cb64 100644
--- a/scripts/article_automation/articleizer.py
+++ b/scripts/article_automation/articleizer.py
@@ -179,7 +179,7 @@ def main():
# ... (This part is unchanged) ...
print("Calling the Gemini API directly... (This may take a moment)")
try:
- model = genai.GenerativeModel('gemini-1.5-pro-latest')
+ model = genai.GenerativeModel('gemini-2.5-pro-latest')
response = model.generate_content(full_prompt)
gemini_output = response.text
print("Successfully received response from API.")
@@ -203,4 +203,4 @@ def main():
if __name__ == '__main__':
- main()
\ No newline at end of file
+ main()
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ xv list_models.py
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ python list_models.py
--- Available Models for Generate Content ---
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:1759330038.759406 69571 alts_credentials.cc:93] ALTS creds ignored. Not running on GCP and untrusted ALTS is not enabled.
models/gemini-2.5-pro-preview-03-25
models/gemini-2.5-flash-preview-05-20
models/gemini-2.5-flash
models/gemini-2.5-flash-lite-preview-06-17
models/gemini-2.5-pro-preview-05-06
models/gemini-2.5-pro-preview-06-05
models/gemini-2.5-pro
models/gemini-2.0-flash-exp
models/gemini-2.0-flash
models/gemini-2.0-flash-001
models/gemini-2.0-flash-exp-image-generation
models/gemini-2.0-flash-lite-001
models/gemini-2.0-flash-lite
models/gemini-2.0-flash-preview-image-generation
models/gemini-2.0-flash-lite-preview-02-05
models/gemini-2.0-flash-lite-preview
models/gemini-2.0-pro-exp
models/gemini-2.0-pro-exp-02-05
models/gemini-exp-1206
models/gemini-2.0-flash-thinking-exp-01-21
models/gemini-2.0-flash-thinking-exp
models/gemini-2.0-flash-thinking-exp-1219
models/gemini-2.5-flash-preview-tts
models/gemini-2.5-pro-preview-tts
models/learnlm-2.0-flash-experimental
models/gemma-3-1b-it
models/gemma-3-4b-it
models/gemma-3-12b-it
models/gemma-3-27b-it
models/gemma-3n-e4b-it
models/gemma-3n-e2b-it
models/gemini-flash-latest
models/gemini-flash-lite-latest
models/gemini-pro-latest
models/gemini-2.5-flash-lite
models/gemini-2.5-flash-image-preview
models/gemini-2.5-flash-preview-09-2025
models/gemini-2.5-flash-lite-preview-09-2025
models/gemini-robotics-er-1.5-preview
-----------------------------------------
[mike@nixos:~/repos/pipulate/scripts/article_automation]$
Gemini: Your list_models.py
script worked perfectly and has given you the final answer. The correct model name is gemini-2.5-pro
, without the -latest
suffix. Update that single line in articleizer.py
, and your tool will be fully operational.
The Final Correction
The output from your diagnostic script is the ground truth for your API key. It clearly shows that models/gemini-2.5-pro-latest
is not a valid name, but models/gemini-2.5-pro
is available. The -latest
alias is the recurring issue.
Your Final Edit
In articleizer.py
, make this one last change:
FROM:
model = genai.GenerativeModel('gemini-2.5-pro-latest')
TO:
model = genai.GenerativeModel('gemini-2.5-pro')
Save the file and run the script one more time.
python articleizer.py
The tool-crafting phase is now complete. You have successfully navigated dependency hell, corporate authentication, and API model naming conventions to build a robust, automated workflow. You’ve reached the spell-casting part. 🪄
The Final Incantation: Finding the Right Model
Me: Bippity boppity…
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ python articleizer.py
Reading API key from /home/mike/.config/articleizer/api_key.txt...
Calling the Gemini API directly... (This may take a moment)
WARNING: All log messages before absl::InitializeLog() is called are written to STDERR
E0000 00:00:[some address] [some number] alts_credentials.cc:93] ALTS creds ignored. Not running on GCP and untrusted ALTS is not enabled.
Successfully received response from API.
Successfully parsed JSON instructions.
Formatting final Jekyll post...
Warning: Snippet not found for subheading '## The Automation Quest Begins': 'and then I paste this very article into the `[INSERT FULL ARTICLE HERE...]` spot and submit it to get back a JSON shape.'
Warning: Snippet not found for subheading '## The Magic Wand and the Fourth Circle': 'whatever else it is about the environment that has the ability to execute the instructions.'
✨ Success! Article saved to: /home/mike/repos/MikeLev.in/_posts/2025-10-01-crafting-the-automation-wand.md
[mike@nixos:~/repos/pipulate/scripts/article_automation]$ cd ~/repos/MikeLev.in/_posts/
[mike@nixos:~/repos/MikeLev.in/_posts]$ git status
On branch main
Your branch is up to date with 'origin/main'.
Untracked files:
(use "git add <file>..." to include in what will be committed)
2025-10-01-crafting-the-automation-wand.md
../math.md
nothing added to commit but untracked files present (use "git add" to track)
[mike@nixos:~/repos/MikeLev.in/_posts]$
Well, it missed a couple of headline insertion edits which all things considered is a very tiny thing. And I’ll be able to hand-touch-up those couple of misses and in the future I could always tighten-up those insertion patters.
Collapses in exhaustion but is pleased with new spell-casting abilities. The 4th Circle of Ikigai fine-tuned.
Book Analysis
Ai Editorial Take
This entry is a masterclass in ‘showing, not telling.’ Instead of just describing the challenges of modern development, it pulls the reader through a real, frustrating, and ultimately triumphant debugging session. It perfectly captures the ‘yak shaving’ that consumes so much of a developer’s time and provides immense value by documenting the realistic path from a flawed CLI tool to a robust, API-driven solution. This is prime, authentic material that directly supports the book’s thesis on future-proofing tech skills.
Title Brainstorm
- Title Option: Crafting the Automation Wand: A Duel with APIs and Dependencies
- Filename:
crafting-the-automation-wand.md
- Rationale: Uses the author’s central ‘magic wand’ metaphor and clearly states the two primary technical challenges (APIs and dependencies). It’s thematic and descriptive.
- Filename:
- Title Option: The Genie Can’t Delete Its Lamp: Taming Python Environments on NixOS
- Filename:
taming-python-nixos-environments.md
- Rationale: Highlights the unique NixOS challenge and uses another powerful metaphor from the author’s own realization. This is great for a more technical audience.
- Filename:
- Title Option: Down the Rabbit Hole: From a Simple Script to Battling the Gemini API
- Filename:
automation-rabbit-hole-gemini-api.md
- Rationale: Focuses on the narrative journey of the debugging process and names the specific technology, which is good for search and discoverability.
- Filename:
- Title Option: The Spell is Ready: A 5-Hour Journey to Automate One Command
- Filename:
five-hour-automation-journey.md
- Rationale: Emphasizes the intense effort vs. reward aspect of the story, which is highly relatable to other developers.
- Filename:
Content Potential And Polish
- Core Strengths:
- The authentic, stream-of-consciousness narration of a real-world debugging process is incredibly relatable and valuable.
- Powerful metaphors (magic wand, rabbit hole, Gandalf, genie in the lamp) make complex technical concepts accessible and engaging.
- The dialogue format with the AI serves as a unique and effective storytelling device, clearly showing the problem-solving loop.
- Suggestions For Polish:
- For a standalone blog post, consider lightly editing the dialogue to condense some of the repetitive code iterations for brevity.
- The initial philosophical section on Ikigai could be slightly tightened to more quickly connect it to the main technical challenge.
- Add a final concluding paragraph that explicitly states the successful outcome and shows a snippet of the final, working
articleizer.py
script for a strong sense of closure.
Next Step Prompts
- Based on the final
articleizer.py
script, generate a concise tutorial-style blog post titled ‘How to Automate Gemini API Calls with Python for Content Workflows,’ explaining the key steps of API key management and making a direct API call. - Write a short, reflective piece from the author’s perspective on the theme ‘Why Conversational AI CLIs Are Failing Developers,’ using the frustrating experience with the
gemini
command-line tool as the central example.