Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions ai_poem_generator/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
__pycache__
.pytest_cache
42 changes: 42 additions & 0 deletions ai_poem_generator/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Project Title: GenAI Poem Generator

## 🛠️ Description
The **GenAI Poem Generator** is a creative Python mini-project that uses a **Generative AI model** to compose short poems based on a user-provided theme.
It leverages the **TinyLlama-1.1B-Chat** model from Hugging Face to create vivid, imaginative poetry in various styles (haiku, sonnet, free verse, etc.).
The project also includes an **offline fallback poet**, so users can still generate poems without requiring model downloads or internet access.

This project was created as part of an open-source contribution to the repository [`ndleah/python-mini-project`](https://github.com/ndleah/python-mini-project).

---

## ⚙️ Installation / Requirements
To run this project, make sure you have Python 3.8+ installed.
Install dependencies by running:

```bash
pip install -r requirements.txt

**Required Libraries:**

- transformers

- torch

- pytest

**Usage**

After installing the dependencies, navigate to the project directory and run:

**python main.py "first rain on campus" --form haiku --lines 3**

**Example Output:**

Rain's first glee,
Cushions feet in fields,
Lilies unfurled from dorms.


You can customize the poem’s form and number of lines:

**python main.py "evening coffee" --form sonnet --lines 4**
25 changes: 25 additions & 0 deletions ai_poem_generator/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import argparse
from poem import PoemConfig, generate_poem

def parse_args():
p = argparse.ArgumentParser(
description="Generate a short AI poem in the terminal."
)
p.add_argument("theme", help="Theme/topic of the poem, e.g., 'first rain on campus'")
p.add_argument("--form", default="free", help="Style hint: free | haiku | sonnet | limerick")
p.add_argument("--lines", type=int, default=4, help="Number of lines to return")
p.add_argument("--max-new-tokens", type=int, default=80, help="Upper bound on model generation length")
return p.parse_args()

def main():
args = parse_args()
poem = generate_poem(PoemConfig(
theme=args.theme,
form=args.form,
lines=args.lines,
max_new_tokens=args.max_new_tokens,
))
print(poem)

if __name__ == "__main__":
main()
120 changes: 120 additions & 0 deletions ai_poem_generator/poem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
from typing import List
from dataclasses import dataclass


_pipeline = None
_tokenizer = None

@dataclass
class PoemConfig:
theme: str
form: str = "free"
lines: int = 4
max_new_tokens: int = 80

def _get_generator():
"""
Load a small, instruction-tuned chat model.
Choose ONE model_id below that fits your disk/RAM:
- "TinyLlama/TinyLlama-1.1B-Chat-v1.0" (lightest good option)
- "Qwen/Qwen2.5-0.5B-Instruct" (also light + coherent)
"""
global _pipeline, _tokenizer
if _pipeline is None:
from transformers import pipeline, AutoTokenizer, AutoModelForCausalLM
model_id = "TinyLlama/TinyLlama-1.1B-Chat-v1.0"
_tokenizer = AutoTokenizer.from_pretrained(model_id, use_fast=True)
model = AutoModelForCausalLM.from_pretrained(model_id)
_pipeline = pipeline(
"text-generation",
model=model,
tokenizer=_tokenizer,
device_map=None,
)
return _pipeline, _tokenizer

def build_prompt(theme: str, form: str, lines: int) -> str:
"""
Build a chat-style prompt via the model's chat template.
We ask the model to output ONLY poem lines.
"""
form = (form or "free").strip().lower()
user_msg = (
f"Write a {form} poem about '{theme}'. "
f"Use vivid imagery and concrete nouns. "
f"Output exactly {lines} short lines with no title or extra text."
)
return user_msg

def _fallback_poem_lines(theme: str, form: str, lines: int) -> str:
"""
Simple deterministic backup generator so the CLI never returns empty.
Not 'AI', but keeps UX solid and tests happy if the LM stalls.
"""
nouns = ["pavement", "backpack", "lamp post", "notebook", "window", "bicycle", "leaf"]
senses = ["rain-smell", "footsteps", "chalk-dust", "neon", "mud print", "quiet hum", "shadows"]
pieces = []
# lightweight, theme-aware lines
root = theme.split()[0] if theme else "dawn"
for i in range(lines):
n = nouns[i % len(nouns)]
s = senses[(i * 2 + 1) % len(senses)]
pieces.append(f"{root} on {n}, {s}")
return "\n".join(pieces)

def _postprocess(generated: str, lines: int) -> str:
"""Keep only the first N clean, non-instruction lines."""
bad_snippets = ("Theme:", "Form:", "Rules:", "Begin:", "poem", "instruction", "Return exactly")
# normalize and split
raw = [ln.strip() for ln in generated.splitlines()]
cleaned = []
for ln in raw:
if not ln:
continue
# drop obvious echoes of instructions
low = ln.lower()
if any(bad.lower() in low for bad in bad_snippets):
continue
# strip list markers like "1) ", "1. ", "- "
ln = ln.lstrip("-.0123456789) ")
if ln:
cleaned.append(ln)
if len(cleaned) >= lines:
break
# pad if model under-produces
while len(cleaned) < lines:
cleaned.append("...")
return "\n".join(cleaned[:lines])

def generate_poem(cfg: PoemConfig) -> str:
generator, tok = _get_generator()

# Build chat-formatted prompt
chat = [
{"role": "system", "content": "You are a concise poetry assistant. Only output poem lines."},
{"role": "user", "content": build_prompt(cfg.theme, cfg.form, cfg.lines)},
]
prompt = tok.apply_chat_template(chat, tokenize=False, add_generation_prompt=True)

# Use mild sampling to avoid stalls; don't set eos_token_id so it doesn't stop instantly
res = generator(
prompt,
max_new_tokens=max(48, cfg.max_new_tokens),
do_sample=True,
temperature=0.8,
top_p=0.9,
top_k=50,
num_return_sequences=1,
return_full_text=False, # don't echo the prompt
no_repeat_ngram_size=3,
repetition_penalty=1.08,
pad_token_id=tok.eos_token_id,
)
text = res[0]["generated_text"]
out = _postprocess(text, cfg.lines)

# If, for any reason, lines came back short, fall back (never print blanks)
nonempty = [ln for ln in out.splitlines() if ln.strip()]
if len(nonempty) < cfg.lines:
return _fallback_poem_lines(cfg.theme, cfg.form, cfg.lines)
return out
3 changes: 3 additions & 0 deletions ai_poem_generator/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
transformers>=4.41
torch
pytest
37 changes: 37 additions & 0 deletions ai_poem_generator/test_poem.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import types
from poem import build_prompt, PoemConfig, generate_poem

class _FakePipe:
def __call__(self, prompt, **kwargs):
# Return deterministic multi-line continuation for tests
return [{
"generated_text": "glittering streets\nreflections ripple softly\nmidnight hum returns\nlights fade into dawn\n"
}]

def test_build_prompt_mentions_theme_and_lines():
prompt = build_prompt("ocean breeze", "haiku", 3)
assert "ocean breeze" in prompt
assert "exactly 3" in prompt

def test_generate_poem_line_count(monkeypatch):
# Monkeypatch the loader to avoid downloading models in CI
import poem
def _fake_get_generator():
def apply_chat_template(chat, tokenize=False, add_generation_prompt=True):
# Simple mock that concatenates messages
parts = []
for msg in chat:
parts.append(f"{msg['role']}: {msg['content']}")
return "\n".join(parts)

tok = types.SimpleNamespace(
eos_token_id=50256, # gpt2 eos
apply_chat_template=apply_chat_template
)
return _FakePipe(), tok
monkeypatch.setattr(poem, "_get_generator", _fake_get_generator)
out = generate_poem(PoemConfig(theme="city rain", form="free", lines=4))
lines = [ln for ln in out.splitlines() if ln.strip()]
assert len(lines) == 4
# Check content is non-empty and textual
assert all(isinstance(ln, str) and len(ln) > 0 for ln in lines)