Build a Technical Document Generator
Use Python and Gemini AI to generate structured technical docs from a project description.
Introduction
30 Second Summary
Before any code gets written on a real engineering project, someone has to write the documents. Project charters, requirements specs, architecture decisions. These take hours to draft, and most teams dread creating them from scratch.
In this project, you will build a Python command-line tool that uses the Gemini API to generate three types of professional technical documents from a single project description. You will use Pydantic schemas to force the AI to return structured, validated output every time.
What You'll Build
A CLI tool called docgen that takes a project name and description as input, sends them to the Gemini API with structured output schemas, and writes formatted Markdown documents to a local folder.
By the end of this project, you'll have:
- A working Python script that connects to the Gemini API and generates structured JSON output using the free google-genai SDK.
- Three Pydantic data models that define the structure of real-world technical documents and enforce schema validation on every LLM response.
- A set of generated Markdown files including a Project Charter, Technical Requirements Document, and Architecture Decision Record ready to use in a real project.
- An optional interactive refinement mode that asks follow-up questions to improve the generated documents (Secret Mission).
Are there any prerequisites?
You should have basic Python knowledge (functions, classes, and f-strings). You will also need a free Google AI Studio API key, which you can get at aistudio.google.com. No billing or credit card is required.
Before We Start
Before We Start...
Set Up Your Project Environment
Before writing any generation logic, you need a working Python environment with the right packages installed. Your document generator depends on two key libraries: the google-genai SDK for talking to the Gemini API, and Pydantic for defining document schemas.
In this step, you'll create an isolated project folder, install your dependencies, and run a quick test to make sure the Gemini API responds correctly.
In this step, get ready to:
- Create a Python project folder with a virtual environment.
- Install the google-genai and pydantic packages.
- Verify your Gemini API connection with a test script.
Create the project folder and virtual environment
Every Python project benefits from its own folder and virtual environment. A virtual environment keeps your project's packages separate from everything else on your machine, so nothing conflicts.
- Open your terminal.
How do I open a terminal?
Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar. Type Terminal (macOS) or PowerShell (Windows) and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir docgen
cd docgen
- Create a virtual environment by running:
macOS
python3 -m venv venv
- Activate the virtual environment by running:
source venv/bin/activate
Windows
python -m venv venv
- Activate the virtual environment by running:
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated copy of Python for your project. Packages you install inside it stay separate from your system Python, so different projects can use different versions of the same library without clashing.
Don't see (venv) in your prompt?
- Make sure you ran the activation command for your operating system. macOS uses source venv/bin/activate and Windows uses venv\Scripts\activate.
- Confirm you are inside the docgen folder by checking that ls shows a venv directory.
Still stuck? Ask for help.
Now you need to tell Python which packages your project uses. You do this with a requirements.txt file.
- In your text editor, create a new file called requirements.txt inside the docgen folder.
- Paste the following into the file:
google-genai>=1.73.0
pydantic>=2.11.0
What are these packages?
- google-genai is the official Python SDK for the Gemini API. It replaces the older google-generativeai package, which was deprecated in November 2025.
- pydantic is a data validation library. You'll use it to define the exact structure each document must follow, and Gemini will fill in the values.
- Save requirements.txt.
- Back in your terminal, install the dependencies by running:
pip install -r requirements.txt
You should see output ending with Successfully installed followed by the package names and version numbers.
Seeing a pip error?
- Check that your virtual environment is active. You should see (venv) in your terminal prompt.
- Make sure you are inside the docgen folder where requirements.txt lives.
- If you see a permissions error, do not use sudo. Reactivate your virtual environment instead.
Still stuck? Ask for help.
Configure your API key
The Gemini API requires an API key to authenticate your requests. You'll store this key as an environment variable so your Python code can read it without hardcoding secrets into your files.
- Set your API key as an environment variable by running this command:
macOS
export GEMINI_API_KEY=your-api-key-here
Windows
set GEMINI_API_KEY=your-api-key-here
- Replace your-api-key-here with the API key from your Google AI Studio account.
Pro tip: where to find your API key
Go to aistudio.google.com, sign in with your Google account, and click Get API key. The free tier gives you enough tokens for this project with no billing required.
Now let's verify the key works by sending a test request to the Gemini API.
- In your text editor, create a new file called main.py inside the docgen folder.
- Paste the following test code into the file:
import os
from google import genai
# Create a client with your API key
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# Send a test prompt to verify the connection
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Explain what a project charter is in one sentence."
)
print(response.text)
What does this code do?
- genai.Client(api_key=...) creates a connection to the Gemini API using the key you just exported.
- client.models.generate_content() sends a prompt to the gemini-2.5-flash model and returns the response.
- response.text contains the model's reply as a plain string.
This test code is temporary. You'll replace it with the full CLI tool in a later step.
- Save main.py.
- Run the script to verify your API connection by running:
python main.py
You should see a one-sentence explanation of what a project charter is printed in your terminal.
Seeing an error instead of a response?
- If you see KeyError: 'GEMINI_API_KEY', your environment variable was not set. Re-run the export (macOS) or set (Windows) command from the previous step in the same terminal window.
- If you see API key not valid, double-check that you copied the full key from Google AI Studio with no extra spaces.
- If you see ModuleNotFoundError: No module named 'google', make sure your virtual environment is active and you ran pip install -r requirements.txt inside it.
Still stuck? Ask for help.
Set up the project file structure
Your document generator is split across four Python files, each with a single responsibility. You already have main.py. Now you need three more.
- Create the three remaining project files by running:
macOS
touch schemas.py generator.py renderer.py
Windows
New-Item schemas.py, generator.py, renderer.py
- Confirm the files exist by running:
ls
You should see generator.py, main.py, renderer.py, requirements.txt, schemas.py, and the venv folder in the output.
What are these files for?
- schemas.py will hold your Pydantic models. These define the exact structure of each document type.
- generator.py will contain the Gemini API logic. It sends prompts and parses structured responses.
- renderer.py will convert the structured data into formatted Markdown files.
- main.py will tie everything together as the CLI entry point.
Your environment is ready and your API key works. Next up, you'll define the Pydantic schemas that tell Gemini exactly what shape each document should take.
Define Document Schemas with Pydantic
Your environment is ready and your API connection is confirmed. Now it's time to define the exact shape of the documents your tool will generate.
Pydantic models act as blueprints that tell the Gemini API exactly what fields to return, what types those fields should be, and what each field means. You'll create three schemas for three real-world technical documents that engineering teams write before starting a project.
In this step, get ready to:
- Define a Project Charter schema with stakeholders, milestones, and risks.
- Define a Technical Requirements Document schema with prioritized requirements.
- Define an Architecture Decision Record schema with alternatives and consequences.
Build the Project Charter schema
A Project Charter is the kickoff document for any engineering project. It captures the project's purpose, who is involved, what's in scope, the timeline, and the risks.
Before you can define the charter itself, you need smaller models for its nested data. Stakeholders, milestones, and risks each have their own structure.
- In schemas.py, add the imports and helper models by copying the following code:
from pydantic import BaseModel, Field
from typing import List, Literal, Optional
# Helper model for people involved in the project
class Stakeholder(BaseModel):
name: str = Field(description="Name or role title of the stakeholder")
role: str = Field(description="Responsibility of this stakeholder in the project")
# Helper model for timeline entries
class Milestone(BaseModel):
name: str = Field(description="Short name for the milestone")
description: str = Field(description="What this milestone delivers")
target_date: str = Field(description="Estimated target date, e.g. Week 2, Month 1")
# Helper model for project risks
class Risk(BaseModel):
description: str = Field(description="Description of the risk")
mitigation: str = Field(description="How this risk will be mitigated")
What does this code do?
- BaseModel is the base class from Pydantic that turns a regular Python class into a data model with automatic validation.
- Field(description=...) adds a human-readable description to each field. Gemini reads these descriptions when deciding what content to put in each field.
- Each helper model represents a repeatable structure. A project has multiple stakeholders, multiple milestones, and multiple risks, so each one gets its own model.
You won't see a change yet. The next chunk uses these helper models as building blocks for the full Project Charter.
Now you can define the main ProjectCharter model. This is the top-level schema that groups all the charter sections together.
- Below the Risk class in schemas.py, add the ProjectCharter model:
class ProjectCharter(BaseModel):
project_name: str = Field(description="Official name of the project")
executive_summary: str = Field(description="A 2-3 sentence summary of the project purpose and value")
objectives: List[str] = Field(description="List of specific, measurable project objectives")
scope_in: List[str] = Field(description="Items that are in scope for this project")
scope_out: List[str] = Field(description="Items that are explicitly out of scope")
stakeholders: List[Stakeholder] = Field(description="Key stakeholders and their roles")
timeline: List[Milestone] = Field(description="Major milestones with target dates")
risks: List[Risk] = Field(description="Key risks and their mitigations")
What does this code do?
- ProjectCharter is the complete schema for a Project Charter document. It references the helper models you just created.
- List[str] fields like objectives tell Gemini to return a list of plain strings.
- List[Stakeholder] tells Gemini to return a list of objects, each matching the Stakeholder model's fields. The nesting is what gives your output its structure.
- Save schemas.py.
- Verify the schema loads correctly by running this command:
python -c "from schemas import ProjectCharter; print('ProjectCharter fields:', list(ProjectCharter.model_fields.keys()))"
You should see a list of field names printed: project_name, executive_summary, objectives, and so on.
Seeing an ImportError or SyntaxError?
- Make sure your virtual environment is still activated. You should see (venv) at the start of your terminal prompt.
- Check that every Field(description=...) has matching parentheses and quotes. A missing closing quote is the most common cause of syntax errors here.
- Confirm you're running the command from inside your docgen folder where schemas.py lives.
Why can't Python find my ProjectCharter class?
Build the Technical Requirements Document schema
A Technical Requirements Document lists every feature the system must support and the quality standards it must meet. Each requirement has a priority level so the team knows what to build first.
You'll use a Pydantic feature called Literal types to constrain the priority field to exactly three allowed values. This prevents Gemini from inventing its own priority labels.
- Below the ProjectCharter class in schemas.py, add the Requirement helper model:
# Helper model for individual requirements
class Requirement(BaseModel):
id: str = Field(description="Unique requirement identifier, e.g. FR-001 or NFR-001")
title: str = Field(description="Short title for the requirement")
description: str = Field(description="Detailed description of the requirement")
priority: Literal["must-have", "should-have", "nice-to-have"] = Field(
description="Priority level of the requirement"
)
What does the Literal type do?
Literal["must-have", "should-have", "nice-to-have"] restricts the priority field to only those three exact strings. When Pydantic converts this to a JSON schema, it becomes an enum constraint that Gemini must follow.
Without this, Gemini might return values like "high", "critical", or "P1". The Literal type keeps your output consistent across every generation.
You won't see a change yet. The next chunk uses Requirement as a building block for the full document schema.
- Below the Requirement class, add the TechnicalRequirementsDoc model:
class TechnicalRequirementsDoc(BaseModel):
project_name: str = Field(description="Name of the project")
overview: str = Field(description="Brief overview of what this system does")
functional_requirements: List[Requirement] = Field(
description="List of functional requirements"
)
non_functional_requirements: List[Requirement] = Field(
description="List of non-functional requirements like performance, security, scalability"
)
constraints: List[str] = Field(description="Technical or business constraints")
assumptions: List[str] = Field(description="Assumptions made during requirements gathering")
What does this code do?
- functional_requirements and non_functional_requirements both reuse the same Requirement model. This keeps the structure consistent between the two sections.
- The descriptions on each field guide Gemini on what kind of content to generate. For example, the non-functional requirements description mentions "performance, security, scalability" to steer the AI toward quality attributes.
- Save schemas.py.
- Verify the new models load by running:
python -c "from schemas import TechnicalRequirementsDoc; print('TechnicalRequirementsDoc fields:', list(TechnicalRequirementsDoc.model_fields.keys()))"
You should see field names like project_name, functional_requirements, constraints, and assumptions printed to the terminal.
Seeing a NameError for Literal?
Make sure your import line at the top of schemas.py includes Literal: from typing import List, Literal, Optional.
Why is Python not recognizing my Literal type?
Build the Architecture Decision Record schema
An Architecture Decision Record (ADR) captures a major technical choice and the reasoning behind it. It lists the alternatives that were considered, with pros and cons for each, so future team members understand why the decision was made.
- Below the TechnicalRequirementsDoc class in schemas.py, add the Alternative helper model:
# Helper model for alternatives in an architecture decision
class Alternative(BaseModel):
name: str = Field(description="Name of the alternative option")
description: str = Field(description="Brief description of this alternative")
pros: List[str] = Field(description="Advantages of this alternative")
cons: List[str] = Field(description="Disadvantages of this alternative")
What does this code do?
Each Alternative captures one option that was evaluated during the decision process. The pros and cons fields are lists of strings, so Gemini will return multiple bullet points for each.
You won't see a change yet. The next chunk uses Alternative inside the full ADR model.
- Below the Alternative class, add the ArchitectureDecisionRecord model:
class ArchitectureDecisionRecord(BaseModel):
title: str = Field(description="Title of the architecture decision")
status: Literal["proposed", "accepted", "deprecated"] = Field(
description="Current status of this decision"
)
context: str = Field(
description="The context and problem statement that motivates this decision"
)
decision: str = Field(description="The chosen approach and why it was selected")
alternatives: List[Alternative] = Field(
description="Alternative options that were considered"
)
consequences: List[str] = Field(
description="Consequences of this decision, both positive and negative"
)
What does this code do?
- The status field uses another Literal type to restrict values to "proposed", "accepted", or "deprecated". This mirrors the standard ADR lifecycle.
- The alternatives field uses List[Alternative] to nest multiple structured alternatives inside the decision record. Each alternative has its own pros and cons.
- The consequences field captures both positive and negative outcomes. The description tells Gemini to include both sides, not just the benefits.
- Save schemas.py.
Seeing a syntax error on the Literal line?
Make sure the three values inside Literal[...] are each wrapped in double quotes and separated by commas: Literal["proposed", "accepted", "deprecated"]. A missing comma or mismatched quote will cause a syntax error.
Why is my ArchitectureDecisionRecord model causing a syntax error?
Awesome, I've got everything!
Double check you've saved schemas.py before moving on.
I'd like to double check the full code
from pydantic import BaseModel, Field
from typing import List, Literal, Optional
class Stakeholder(BaseModel):
name: str = Field(description="Name or role title of the stakeholder")
role: str = Field(description="Responsibility of this stakeholder in the project")
class Milestone(BaseModel):
name: str = Field(description="Short name for the milestone")
description: str = Field(description="What this milestone delivers")
target_date: str = Field(description="Estimated target date, e.g. Week 2, Month 1")
class Risk(BaseModel):
description: str = Field(description="Description of the risk")
mitigation: str = Field(description="How this risk will be mitigated")
class ProjectCharter(BaseModel):
project_name: str = Field(description="Official name of the project")
executive_summary: str = Field(description="A 2-3 sentence summary of the project purpose and value")
objectives: List[str] = Field(description="List of specific, measurable project objectives")
scope_in: List[str] = Field(description="Items that are in scope for this project")
scope_out: List[str] = Field(description="Items that are explicitly out of scope")
stakeholders: List[Stakeholder] = Field(description="Key stakeholders and their roles")
timeline: List[Milestone] = Field(description="Major milestones with target dates")
risks: List[Risk] = Field(description="Key risks and their mitigations")
class Requirement(BaseModel):
id: str = Field(description="Unique requirement identifier, e.g. FR-001 or NFR-001")
title: str = Field(description="Short title for the requirement")
description: str = Field(description="Detailed description of the requirement")
priority: Literal["must-have", "should-have", "nice-to-have"] = Field(
description="Priority level of the requirement"
)
class TechnicalRequirementsDoc(BaseModel):
project_name: str = Field(description="Name of the project")
overview: str = Field(description="Brief overview of what this system does")
functional_requirements: List[Requirement] = Field(
description="List of functional requirements"
)
non_functional_requirements: List[Requirement] = Field(
description="List of non-functional requirements like performance, security, scalability"
)
constraints: List[str] = Field(description="Technical or business constraints")
assumptions: List[str] = Field(description="Assumptions made during requirements gathering")
class Alternative(BaseModel):
name: str = Field(description="Name of the alternative option")
description: str = Field(description="Brief description of this alternative")
pros: List[str] = Field(description="Advantages of this alternative")
cons: List[str] = Field(description="Disadvantages of this alternative")
class ArchitectureDecisionRecord(BaseModel):
title: str = Field(description="Title of the architecture decision")
status: Literal["proposed", "accepted", "deprecated"] = Field(
description="Current status of this decision"
)
context: str = Field(
description="The context and problem statement that motivates this decision"
)
decision: str = Field(description="The chosen approach and why it was selected")
alternatives: List[Alternative] = Field(
description="Alternative options that were considered"
)
consequences: List[str] = Field(
description="Consequences of this decision, both positive and negative"
)
Now verify that all three schemas work together by printing the full JSON schema for one of them.
- Test all three schemas by running this command:
python -c "from schemas import ProjectCharter, TechnicalRequirementsDoc, ArchitectureDecisionRecord; print('All 3 schemas loaded.'); import json; print(json.dumps(ProjectCharter.model_json_schema(), indent=2))"
You should see All 3 schemas loaded. followed by a JSON object showing the full schema for ProjectCharter. Look for the properties key listing every field and its description.
Why does the JSON schema matter?
This JSON schema is exactly what gets sent to the Gemini API in the next step. Gemini reads the field names, types, and descriptions from this schema and uses them to structure its response. The better your descriptions, the more accurate the generated documents will be.
Seeing an error when importing all three models?
- Check that each class name is spelled exactly as shown: ProjectCharter, TechnicalRequirementsDoc, ArchitectureDecisionRecord.
- Make sure helper models (Stakeholder, Milestone, Risk, Requirement, Alternative) are defined ABOVE the models that reference them.
Why are my Pydantic schemas failing to load?
Your three document schemas are complete. Next up, you'll connect these schemas to the Gemini API and watch it fill them with real, structured content.
Build the Document Generator
Your Pydantic schemas define what each document looks like. Now you need the engine that sends a project description to Gemini and gets structured JSON back.
In this step, you will write the core generation logic in generator.py. This file connects your schemas to the Gemini API, sends carefully crafted prompts, and validates every response against your Pydantic models.
In this step, get ready to:
- Create a reusable function that sends any Pydantic schema to Gemini and gets validated output back.
- Write prompt templates that guide Gemini to generate each document type.
- Test the full pipeline with a sample project description.
Create the core generation function
The generator needs one reusable function that works with any document type. It takes a prompt and a Pydantic model class, sends them to Gemini with the right config, and parses the JSON response into a validated object.
- Open generator.py and add the following imports and function:
import os
from google import genai
from schemas import ProjectCharter, TechnicalRequirementsDoc, ArchitectureDecisionRecord
def generate_document(project_name, description, prompt_template, schema_class):
# Create a Gemini client using the API key from environment variables
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
# Fill in the prompt template with the project details
prompt = prompt_template.format(
project_name=project_name, description=description
)
# Call Gemini with structured output config
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt,
config={
"response_mime_type": "application/json",
"response_json_schema": schema_class.model_json_schema(),
},
)
# Parse the JSON response into a validated Pydantic object
return schema_class.model_validate_json(response.text)
What does this code do?
- genai.Client(api_key=...) creates a client that authenticates with the Gemini API using your environment variable.
- prompt_template.format(...) injects the project name and description into the prompt string.
- The config dict is the key part. Setting response_mime_type to application/json forces Gemini to return JSON. Passing response_json_schema tells Gemini exactly what shape that JSON must have.
- model_validate_json() takes the raw JSON string from Gemini and converts it into a fully validated Pydantic object. If the JSON does not match the schema, Pydantic raises an error.
- Save generator.py.
You will not see any output yet. The next chunk adds the prompt templates that this function needs.
Write prompt templates for each document type
The generate_document function is generic. It does not know what a Project Charter or an ADR is. That knowledge lives in the prompt templates, which tell Gemini what role to play and what kind of document to write.
- In generator.py, add these three prompt templates between your imports and the generate_document function:
CHARTER_PROMPT = """You are a senior technical writer with 15 years of experience creating project documentation.
Generate a comprehensive Project Charter for the following project:
Project: {project_name}
Description: {description}
Create a realistic and detailed charter that a real engineering team could use to kick off this project. Include specific, actionable objectives and realistic risks. The stakeholders should have concrete role titles relevant to this type of project."""
REQUIREMENTS_PROMPT = """You are a senior technical writer with 15 years of experience creating project documentation.
Generate a Technical Requirements Document for the following project:
Project: {project_name}
Description: {description}
Create detailed functional and non-functional requirements that an engineering team would actually implement. Each requirement should be specific and testable. Use realistic requirement IDs (FR-001, NFR-001, etc.). Include at least 5 functional requirements and 3 non-functional requirements."""
ADR_PROMPT = """You are a senior technical writer with 15 years of experience creating project documentation.
Generate an Architecture Decision Record for the primary technology choice in the following project:
Project: {project_name}
Description: {description}
Focus on the most important architectural decision for this project (e.g., choice of framework, database, or deployment strategy). Provide at least 3 alternatives that were considered with detailed pros and cons. The status should be "accepted"."""
What makes these prompts effective?
- Each prompt starts by assigning a role ("senior technical writer"). This technique, called role prompting, helps the LLM produce more professional output.
- The {project_name} and {description} placeholders get filled in at runtime by Python's .format() method.
- Each prompt includes specific instructions about quantity and quality (e.g., "at least 5 functional requirements"). This prevents vague or minimal output from the model.
Now you need a function that ties everything together by calling generate_document once for each document type.
- Below the generate_document function, add the generate_all_documents function:
def generate_all_documents(project_name, description):
# Generate each document type using its prompt and schema
print("Generating Project Charter...")
charter = generate_document(
project_name, description, CHARTER_PROMPT, ProjectCharter
)
print("Generating Technical Requirements Document...")
requirements = generate_document(
project_name, description, REQUIREMENTS_PROMPT, TechnicalRequirementsDoc
)
print("Generating Architecture Decision Record...")
adr = generate_document(
project_name, description, ADR_PROMPT, ArchitectureDecisionRecord
)
# Return all documents in a dictionary
return {
"charter": charter,
"requirements": requirements,
"adr": adr,
}
What does this function do?
This function calls generate_document three times, once for each document type. Each call pairs the right prompt template with the right Pydantic schema class. The results come back as a dictionary so the rest of your tool can access each document by name.
- Save generator.py.
You will not see any output yet. The next chunk adds a test so you can run the generator.
Test the generator
Before wiring this into the full CLI, you should test the generator in isolation. A quick if __name__ block at the bottom of the file lets you run generator.py directly to confirm the API calls and schema validation are working.
- At the bottom of generator.py, add this test block:
if __name__ == "__main__":
# Quick test with a sample project
results = generate_all_documents(
"TaskFlow",
"A task management API built with FastAPI and PostgreSQL"
)
print("\nProject Charter objectives:", results["charter"].objectives)
print("Requirements count:", len(results["requirements"].functional_requirements))
print("ADR decision:", results["adr"].decision[:100])
What does this test do?
- It calls generate_all_documents with a sample project called "TaskFlow" to trigger all three API calls.
- It prints a few fields from each result so you can confirm that the structured output was parsed correctly into Pydantic objects.
- The if __name__ == "__main__" guard ensures this test only runs when you execute the file directly, not when other files import from it.
- Save generator.py.
- Run the test by executing this command in your terminal:
python generator.py
You should see three "Generating..." progress messages, followed by a list of charter objectives, a requirements count (at least 5), and the first 100 characters of the ADR decision. This may take 15-30 seconds as Gemini processes each document.
Seeing an error?
- If you see KeyError: 'GEMINI_API_KEY', your environment variable is not set. Run export GEMINI_API_KEY=your-api-key-here in the same terminal session before running the script.
- If you see ImportError: cannot import name 'ProjectCharter', make sure your schemas.py file is in the same folder as generator.py and contains all three schema classes from Step 2.
- If you see a ValidationError from Pydantic, the JSON returned by Gemini did not match your schema. Try running the script again. LLM outputs can vary between runs.
- help me debug an error when running generator.py
Awesome, I've got everything!
Double check you have saved generator.py.
I'd like to double check the full code
import os
from google import genai
from schemas import ProjectCharter, TechnicalRequirementsDoc, ArchitectureDecisionRecord
CHARTER_PROMPT = """You are a senior technical writer with 15 years of experience creating project documentation.
Generate a comprehensive Project Charter for the following project:
Project: {project_name}
Description: {description}
Create a realistic and detailed charter that a real engineering team could use to kick off this project. Include specific, actionable objectives and realistic risks. The stakeholders should have concrete role titles relevant to this type of project."""
REQUIREMENTS_PROMPT = """You are a senior technical writer with 15 years of experience creating project documentation.
Generate a Technical Requirements Document for the following project:
Project: {project_name}
Description: {description}
Create detailed functional and non-functional requirements that an engineering team would actually implement. Each requirement should be specific and testable. Use realistic requirement IDs (FR-001, NFR-001, etc.). Include at least 5 functional requirements and 3 non-functional requirements."""
ADR_PROMPT = """You are a senior technical writer with 15 years of experience creating project documentation.
Generate an Architecture Decision Record for the primary technology choice in the following project:
Project: {project_name}
Description: {description}
Focus on the most important architectural decision for this project (e.g., choice of framework, database, or deployment strategy). Provide at least 3 alternatives that were considered with detailed pros and cons. The status should be "accepted"."""
def generate_document(project_name, description, prompt_template, schema_class):
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
prompt = prompt_template.format(
project_name=project_name, description=description
)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=prompt,
config={
"response_mime_type": "application/json",
"response_json_schema": schema_class.model_json_schema(),
},
)
return schema_class.model_validate_json(response.text)
def generate_all_documents(project_name, description):
print("Generating Project Charter...")
charter = generate_document(
project_name, description, CHARTER_PROMPT, ProjectCharter
)
print("Generating Technical Requirements Document...")
requirements = generate_document(
project_name, description, REQUIREMENTS_PROMPT, TechnicalRequirementsDoc
)
print("Generating Architecture Decision Record...")
adr = generate_document(
project_name, description, ADR_PROMPT, ArchitectureDecisionRecord
)
return {
"charter": charter,
"requirements": requirements,
"adr": adr,
}
if __name__ == "__main__":
results = generate_all_documents(
"TaskFlow",
"A task management API built with FastAPI and PostgreSQL"
)
print("\nProject Charter objectives:", results["charter"].objectives)
print("Requirements count:", len(results["requirements"].functional_requirements))
print("ADR decision:", results["adr"].decision[:100])
Your generator is producing real, validated documents from a single project description. Next up, you will turn that structured data into clean Markdown files that are ready to share with a team.
Render Documents to Markdown
Your generator is returning fully structured Pydantic objects from the Gemini API. Every field is validated, every list is populated, and every priority level is constrained to the values you defined.
Structured JSON is great for machines, but nobody wants to read raw JSON in a project kickoff meeting. In this step, you'll convert those Pydantic objects into clean Markdown files that look like real documentation.
In this step, get ready to:
- Write Markdown rendering functions for all three document types.
- Add a file output function to save documents to a docs/ folder.
- Wire up the full CLI pipeline in main.py and generate your first set of documents.
Write the Markdown renderer
Each render function walks through a Pydantic model's fields and formats them into Markdown with headings, bullet lists, and tables. The pattern is the same for all three: build a list of strings, then join them with newlines.
- Open renderer.py in your editor.
- Add the os import and the Project Charter rendering function by pasting this code:
import os
def render_project_charter(charter):
lines = []
# Title and executive summary
lines.append(f"# Project Charter: {charter.project_name}")
lines.append("")
lines.append("## Executive Summary")
lines.append("")
lines.append(charter.executive_summary)
lines.append("")
# Objectives as a bullet list
lines.append("## Objectives")
lines.append("")
for obj in charter.objectives:
lines.append(f"- {obj}")
lines.append("")
# Scope split into in-scope and out-of-scope sections
lines.append("## Scope")
lines.append("")
lines.append("### In Scope")
lines.append("")
for item in charter.scope_in:
lines.append(f"- {item}")
lines.append("")
lines.append("### Out of Scope")
lines.append("")
for item in charter.scope_out:
lines.append(f"- {item}")
lines.append("")
# Stakeholders as a Markdown table
lines.append("## Stakeholders")
lines.append("")
lines.append("| Name | Role |")
lines.append("| --- | --- |")
for s in charter.stakeholders:
lines.append(f"| {s.name} | {s.role} |")
lines.append("")
# Timeline milestones as a table
lines.append("## Timeline")
lines.append("")
lines.append("| Milestone | Description | Target |")
lines.append("| --- | --- | --- |")
for m in charter.timeline:
lines.append(f"| {m.name} | {m.description} | {m.target_date} |")
lines.append("")
# Risks with bold descriptions and nested mitigations
lines.append("## Risks")
lines.append("")
for r in charter.risks:
lines.append(f"- **{r.description}**")
lines.append(f" - Mitigation: {r.mitigation}")
lines.append("")
return "\n".join(lines)
What does this code do?
- The function accepts a ProjectCharter Pydantic object and builds a list of Markdown-formatted strings.
- Simple fields like the executive summary become paragraphs under headings.
- Lists (objectives, scope items) become Markdown bullet points with - prefixes.
- Nested objects (stakeholders, milestones) become Markdown tables with | column | syntax.
- At the end, "\n".join(lines) combines everything into a single Markdown string.
You won't see any output yet. The next function renders the Technical Requirements Document.
- Below the render_project_charter function in renderer.py, add the requirements renderer by pasting this code:
def render_requirements_doc(req):
lines = []
# Title and overview
lines.append(f"# Technical Requirements: {req.project_name}")
lines.append("")
lines.append("## Overview")
lines.append("")
lines.append(req.overview)
lines.append("")
# Each functional requirement gets its own subsection
lines.append("## Functional Requirements")
lines.append("")
for r in req.functional_requirements:
lines.append(f"### {r.id}: {r.title}")
lines.append("")
lines.append(f"**Priority:** {r.priority}")
lines.append("")
lines.append(r.description)
lines.append("")
# Non-functional requirements follow the same pattern
lines.append("## Non-Functional Requirements")
lines.append("")
for r in req.non_functional_requirements:
lines.append(f"### {r.id}: {r.title}")
lines.append("")
lines.append(f"**Priority:** {r.priority}")
lines.append("")
lines.append(r.description)
lines.append("")
# Constraints and assumptions as bullet lists
lines.append("## Constraints")
lines.append("")
for c in req.constraints:
lines.append(f"- {c}")
lines.append("")
lines.append("## Assumptions")
lines.append("")
for a in req.assumptions:
lines.append(f"- {a}")
lines.append("")
return "\n".join(lines)
What does this code do?
- Each requirement becomes its own level-3 heading with the ID and title combined.
- The priority field (constrained to must-have, should-have, or nice-to-have by your Pydantic schema) renders as a bold label.
- Constraints and assumptions render as simple bullet lists at the end.
You won't see any output yet. The next function renders the Architecture Decision Record.
- Below the render_requirements_doc function, add the ADR renderer by pasting this code:
def render_adr(adr):
lines = []
# Title and status badge
lines.append(f"# ADR: {adr.title}")
lines.append("")
lines.append(f"**Status:** {adr.status}")
lines.append("")
# Context explains the problem being solved
lines.append("## Context")
lines.append("")
lines.append(adr.context)
lines.append("")
# The chosen decision
lines.append("## Decision")
lines.append("")
lines.append(adr.decision)
lines.append("")
# Each alternative gets its own subsection with pros/cons lists
lines.append("## Alternatives Considered")
lines.append("")
for alt in adr.alternatives:
lines.append(f"### {alt.name}")
lines.append("")
lines.append(alt.description)
lines.append("")
lines.append("**Pros:**")
lines.append("")
for p in alt.pros:
lines.append(f"- {p}")
lines.append("")
lines.append("**Cons:**")
lines.append("")
for c in alt.cons:
lines.append(f"- {c}")
lines.append("")
# Consequences of the chosen decision
lines.append("## Consequences")
lines.append("")
for c in adr.consequences:
lines.append(f"- {c}")
lines.append("")
return "\n".join(lines)
What does this code do?
- The status field (proposed, accepted, or deprecated) renders as a bold badge at the top of the document.
- Each alternative option gets its own subsection with separate pros and cons bullet lists, making trade-offs easy to scan.
- Consequences list both positive and negative outcomes of the chosen decision.
- Save renderer.py.
- Confirm your syntax is correct by running this command:
python -c "from renderer import render_project_charter, render_requirements_doc, render_adr; print('All renderers imported successfully')"
You should see All renderers imported successfully printed in your terminal.
Seeing an ImportError or SyntaxError?
- Check that your function names match exactly: render_project_charter, render_requirements_doc, and render_adr.
- Make sure all three functions are at the top level of renderer.py (not nested inside another function).
- Look for mismatched parentheses or missing closing quotes in your f-strings.
Help me debug my renderer.py import error.
Add file output
The render functions return Markdown strings, but you still need to write those strings to actual files on disk. This function creates a docs/ directory and saves each document as a separate .md file.
- At the bottom of renderer.py, add the save_documents function by pasting this code:
def save_documents(charter_md, requirements_md, adr_md, output_dir="docs"):
# Create the output directory if it doesn't exist
os.makedirs(output_dir, exist_ok=True)
# Map filenames to their rendered Markdown content
files = {
"project-charter.md": charter_md,
"requirements.md": requirements_md,
"adr.md": adr_md,
}
# Write each document to disk
for filename, content in files.items():
filepath = os.path.join(output_dir, filename)
with open(filepath, "w") as f:
f.write(content)
print(f" Saved {filepath}")
What does this code do?
- os.makedirs(output_dir, exist_ok=True) creates the docs/ folder if it doesn't already exist. The exist_ok=True flag prevents an error if the folder is already there.
- The files dictionary maps each filename to its rendered Markdown string, keeping the write logic clean.
- Each file gets a progress message printed so the user knows what was saved and where.
- Save renderer.py.
You won't see files written yet. The next substep wires up main.py to call these functions.
Awesome, I've got everything!
Great. Double check you've saved renderer.py before moving on.
I'd like to double check the full code
import os
def render_project_charter(charter):
lines = []
lines.append(f"# Project Charter: {charter.project_name}")
lines.append("")
lines.append("## Executive Summary")
lines.append("")
lines.append(charter.executive_summary)
lines.append("")
lines.append("## Objectives")
lines.append("")
for obj in charter.objectives:
lines.append(f"- {obj}")
lines.append("")
lines.append("## Scope")
lines.append("")
lines.append("### In Scope")
lines.append("")
for item in charter.scope_in:
lines.append(f"- {item}")
lines.append("")
lines.append("### Out of Scope")
lines.append("")
for item in charter.scope_out:
lines.append(f"- {item}")
lines.append("")
lines.append("## Stakeholders")
lines.append("")
lines.append("| Name | Role |")
lines.append("| --- | --- |")
for s in charter.stakeholders:
lines.append(f"| {s.name} | {s.role} |")
lines.append("")
lines.append("## Timeline")
lines.append("")
lines.append("| Milestone | Description | Target |")
lines.append("| --- | --- | --- |")
for m in charter.timeline:
lines.append(f"| {m.name} | {m.description} | {m.target_date} |")
lines.append("")
lines.append("## Risks")
lines.append("")
for r in charter.risks:
lines.append(f"- **{r.description}**")
lines.append(f" - Mitigation: {r.mitigation}")
lines.append("")
return "\n".join(lines)
def render_requirements_doc(req):
lines = []
lines.append(f"# Technical Requirements: {req.project_name}")
lines.append("")
lines.append("## Overview")
lines.append("")
lines.append(req.overview)
lines.append("")
lines.append("## Functional Requirements")
lines.append("")
for r in req.functional_requirements:
lines.append(f"### {r.id}: {r.title}")
lines.append("")
lines.append(f"**Priority:** {r.priority}")
lines.append("")
lines.append(r.description)
lines.append("")
lines.append("## Non-Functional Requirements")
lines.append("")
for r in req.non_functional_requirements:
lines.append(f"### {r.id}: {r.title}")
lines.append("")
lines.append(f"**Priority:** {r.priority}")
lines.append("")
lines.append(r.description)
lines.append("")
lines.append("## Constraints")
lines.append("")
for c in req.constraints:
lines.append(f"- {c}")
lines.append("")
lines.append("## Assumptions")
lines.append("")
for a in req.assumptions:
lines.append(f"- {a}")
lines.append("")
return "\n".join(lines)
def render_adr(adr):
lines = []
lines.append(f"# ADR: {adr.title}")
lines.append("")
lines.append(f"**Status:** {adr.status}")
lines.append("")
lines.append("## Context")
lines.append("")
lines.append(adr.context)
lines.append("")
lines.append("## Decision")
lines.append("")
lines.append(adr.decision)
lines.append("")
lines.append("## Alternatives Considered")
lines.append("")
for alt in adr.alternatives:
lines.append(f"### {alt.name}")
lines.append("")
lines.append(alt.description)
lines.append("")
lines.append("**Pros:**")
lines.append("")
for p in alt.pros:
lines.append(f"- {p}")
lines.append("")
lines.append("**Cons:**")
lines.append("")
for c in alt.cons:
lines.append(f"- {c}")
lines.append("")
lines.append("## Consequences")
lines.append("")
for c in adr.consequences:
lines.append(f"- {c}")
lines.append("")
return "\n".join(lines)
def save_documents(charter_md, requirements_md, adr_md, output_dir="docs"):
os.makedirs(output_dir, exist_ok=True)
files = {
"project-charter.md": charter_md,
"requirements.md": requirements_md,
"adr.md": adr_md,
}
for filename, content in files.items():
filepath = os.path.join(output_dir, filename)
with open(filepath, "w") as f:
f.write(content)
print(f" Saved {filepath}")
Wire up the CLI in main.py
You have a generator that produces Pydantic objects and a renderer that turns them into Markdown files. The last piece is a main.py that ties everything together into a user-facing tool.
- Open main.py in your editor.
- Replace the entire file contents with this code:
from generator import generate_all_documents
from renderer import render_project_charter, render_requirements_doc, render_adr, save_documents
def main():
# Print a header banner
print("=" * 50)
print(" Technical Document Generator")
print("=" * 50)
print()
# Collect project details from the user
project_name = input("Project name: ").strip()
if not project_name:
print("Error: Project name is required.")
return
description = input("Project description: ").strip()
if not description:
print("Error: Project description is required.")
return
print()
print(f"Generating documents for '{project_name}'...")
print()
# Call the generator to get structured Pydantic objects from Gemini
results = generate_all_documents(project_name, description)
print()
print("Rendering Markdown files...")
# Convert each Pydantic object into a Markdown string
charter_md = render_project_charter(results["charter"])
requirements_md = render_requirements_doc(results["requirements"])
adr_md = render_adr(results["adr"])
# Write the Markdown files to the docs/ folder
save_documents(charter_md, requirements_md, adr_md)
print()
print("Done! Your documents are in the docs/ folder:")
print(" - docs/project-charter.md")
print(" - docs/requirements.md")
print(" - docs/adr.md")
if __name__ == "__main__":
main()
What does this code do?
- The main() function is the entry point for the entire tool. It collects a project name and description from the user via input() prompts.
- generate_all_documents() sends three API calls to Gemini and returns a dictionary of validated Pydantic objects.
- Each Pydantic object is passed to its corresponding render function, which returns a Markdown string.
- save_documents() writes all three Markdown strings to files in the docs/ folder.
- Save main.py.
- Run the full tool by executing this command:
python main.py
- When prompted for Project name, enter a name like TaskFlow.
- When prompted for Project description, enter something like A task management API built with FastAPI and PostgreSQL.
You should see progress messages as each document is generated, followed by confirmation that three files were saved to docs/.
- Verify the files were created by running:
ls docs/
You should see project-charter.md, requirements.md, and adr.md listed.
- Open one of the generated files to inspect the Markdown output:
cat docs/project-charter.md
You should see a fully formatted Markdown document with headings, bullet lists, and tables. This is a real Project Charter that a team could use to kick off a project.
Seeing an error when running main.py?
- If you see KeyError: 'GEMINI_API_KEY', make sure your API key environment variable is still set in this terminal session. Re-run the export command from Step 1 if needed.
- If you see ModuleNotFoundError, make sure your virtual environment is activated and you're running the command from inside the docgen folder.
- If the API call takes a long time or times out, try running python main.py again. The free tier has rate limits, but three calls per run is well within them.
Help me debug my main.py errors.
Awesome, I've got everything!
Great. Double check you've saved main.py before moving on.
I'd like to double check the full code
from generator import generate_all_documents
from renderer import render_project_charter, render_requirements_doc, render_adr, save_documents
def main():
print("=" * 50)
print(" Technical Document Generator")
print("=" * 50)
print()
project_name = input("Project name: ").strip()
if not project_name:
print("Error: Project name is required.")
return
description = input("Project description: ").strip()
if not description:
print("Error: Project description is required.")
return
print()
print(f"Generating documents for '{project_name}'...")
print()
results = generate_all_documents(project_name, description)
print()
print("Rendering Markdown files...")
charter_md = render_project_charter(results["charter"])
requirements_md = render_requirements_doc(results["requirements"])
adr_md = render_adr(results["adr"])
save_documents(charter_md, requirements_md, adr_md)
print()
print("Done! Your documents are in the docs/ folder:")
print(" - docs/project-charter.md")
print(" - docs/requirements.md")
print(" - docs/adr.md")
if __name__ == "__main__":
main()
Secret mission
Add Interactive Refinement Mode
Your documents are generated, but what if the AI missed important details? In this secret mission, you'll add an interactive refinement mode that inspects the generated documents, asks targeted follow-up questions, and regenerates improved versions with the extra context.
Before you go
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally and uses the free Gemini API tier, so there are no ongoing costs.
Resources you used:
- The docgen project folder containing main.py, schemas.py, generator.py, and renderer.py.
- The Python virtual environment inside the project folder.
- The docs/ folder with generated Markdown files.
- The GEMINI_API_KEY environment variable set in your terminal session.
- Your Google AI Studio API key.
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with the tool further.
- Your project files and virtual environment will stay in place.
- The GEMINI_API_KEY environment variable will only persist for the current terminal session. You will need to set it again if you open a new terminal.
Pause - I'll come back to this later
Free up terminal resources while keeping your project files intact.
- Deactivate the virtual environment by running:
deactivate
- Your project files will remain on disk. When you come back, navigate to the docgen folder, reactivate the virtual environment, and re-export your GEMINI_API_KEY.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Deactivate the virtual environment if it is still active:
deactivate
- Navigate out of the project folder and delete it:
cd ..
rm -rf docgen
- If you want to revoke your API key, go to Google AI Studio and delete it from your API keys page.
Mission accomplished
That's a Wrap!
Nice work! You just built a real AI-powered documentation tool from scratch. A single project description now turns into a folder of structured, professional technical documents.
Here's what you learned:
- Defined Pydantic schemas that force the Gemini API to return structured, validated JSON instead of freeform text.
- Built a Python CLI tool that connects to the Gemini API and generates a Project Charter, Technical Requirements Document, and Architecture Decision Record from a single project description.
- Rendered structured data into clean Markdown files ready to use in a real project.
- Added an interactive refinement mode that asks follow-up questions and regenerates documents with additional context.
Ready to quiz yourself?