53 Commits
Author SHA1 Message Date
armistace 3cd0654306 syntax fixes for retry logic 2026-02-03 12:21:17 +10:00
armistace a4fb413151 update git pull and provide retry logic 2026-02-03 11:15:23 +10:00
armistace 7b160be3b7 fix git pull technique 2025-12-24 12:12:12 +10:00
armistace 733241554f flip some prompting around
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 15m16s
2025-09-17 10:55:17 +10:00
armistace 92e9f3dcc2 flip some prompting around
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 8m7s
2025-09-17 09:33:53 +10:00
armistace 2fbe47e936 update to run at 3.15 am properly (life is UTC here)
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Successful in 2h25m18s
2025-07-24 08:56:39 +10:00
armistace f2b95935bb Prompt enhancement to produce content
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Successful in 5m14s
2025-06-24 13:05:15 +10:00
armistace bce439921f Generate tags around context
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Successful in 22m46s
2025-06-16 10:35:21 +10:00
armistace 2de2d0fe3a Merge pull request 'prompt enhancement' (#16) from prompt_fix into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Successful in 11m44s
Reviewed-on: #16
2025-06-06 12:04:44 +10:00
armistace cf795bbc35 prompt enhancement 2025-06-06 12:04:19 +10:00
armistace a6ed20451a Merge pull request 'pipeline_creation' (#15) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Successful in 25m13s
Reviewed-on: #15
2025-06-05 09:22:50 +10:00
armistace 7fd32b3024 improve notification prompt 2025-06-05 09:22:28 +10:00
armistace a88d233c6b remove tail and improve notification prompt 2025-06-05 09:22:19 +10:00
armistace 2abc39e3ac Merge pull request 'pipeline_creation' (#14) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Successful in 30m33s
Reviewed-on: #14
2025-06-05 08:43:23 +10:00
armistace f430998137 typo 2025-06-05 08:42:45 +10:00
armistace 8dceb79d91 remove repo reference 2025-06-05 08:41:32 +10:00
armistace 6c5b0f778d remove trailing slash 2025-06-05 08:41:32 +10:00
armistace 37ed8fd0f9 fix git for pipeline" 2025-06-05 08:40:59 +10:00
armistace 0594ea54aa remove repo reference
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 27m5s
2025-06-05 01:02:42 +10:00
armistace 60f7473297 remove trailing slash
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Has been cancelled
2025-06-05 01:00:58 +10:00
armistace ec69e8e4f7 Merge pull request 'do it right' (#13) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 8m58s
Reviewed-on: #13
2025-06-05 00:47:05 +10:00
armistace 62b1175aeb do it right 2025-06-05 00:46:45 +10:00
armistace 41f804a1eb Merge pull request 'pipeline_creation' (#12) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Has been cancelled
Reviewed-on: #12
2025-06-05 00:45:53 +10:00
armistace f50d076164 cleanup 2025-06-05 00:45:39 +10:00
armistace fc4f9c5053 dealing with pipeline weirdness 2025-06-05 00:44:57 +10:00
armistace e3262cd366 Merge pull request 'weird trailing newline"' (#11) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 10m26s
Reviewed-on: #11
2025-06-05 00:12:04 +10:00
armistace 341f3d8623 weird trailing newline"
"
2025-06-05 00:11:44 +10:00
armistace e2c29204fa Merge pull request 'pipeline_creation' (#10) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Has been cancelled
Reviewed-on: #10
2025-06-04 23:48:10 +10:00
armistace f0e6a0cb52 load_dotenv work different? 2025-06-04 23:47:53 +10:00
armistace 7f0b0376d1 load_dotenv work different? 2025-06-04 23:47:05 +10:00
armistace 44b5ea6a68 Merge pull request 'load_dotenv work different?' (#9) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 9m17s
Reviewed-on: #9
2025-06-04 22:55:26 +10:00
armistace a49457094d load_dotenv work different? 2025-06-04 22:54:09 +10:00
armistace 9296fda390 Merge pull request 'tail the .env so we can see it in pipelin' (#8) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 9m5s
Reviewed-on: #8
2025-06-04 22:44:14 +10:00
armistace bb0d9090f3 tail the .env so we can see it in pipelin 2025-06-04 22:43:53 +10:00
armistace 703a2384e7 Merge pull request 'sigh stray U' (#7) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 9m2s
Reviewed-on: #7
2025-06-04 22:30:36 +10:00
armistace 4b3f00c325 Merge branch 'master' into pipeline_creation 2025-06-04 22:29:42 +10:00
armistace 38dfe404d1 sigh stray U 2025-06-04 22:29:12 +10:00
armistace 347ac63f86 Merge pull request 'helps to install virtualenv' (#6) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 8m59s
Reviewed-on: #6
2025-06-04 22:17:46 +10:00
armistace 506758f67d helps to install virtualenv 2025-06-04 22:17:11 +10:00
armistace f0572ba9fb Merge pull request 'pipeline_creation' (#5) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 7m5s
Reviewed-on: #5
2025-06-04 22:08:28 +10:00
armistace 4686f3fae0 y in right place 2025-06-04 22:07:47 +10:00
armistace ea1c8cfb13 add y to apt call 2025-06-04 22:07:15 +10:00
armistace 9ca7578d28 Merge pull request 'pipeline_creation' (#4) from pipeline_creation into master
Create Blog Article if new notes exist / prepare_blog_drafts_and_push (push) Failing after 4m28s
Reviewed-on: #4
2025-06-04 22:02:00 +10:00
armistace 64b466c4ac load dotenv in main.py 2025-06-04 22:01:15 +10:00
armistace 49174de9ff correct pipeline titles 2025-06-04 21:59:33 +10:00
armistace 59f9f01c69 first cut at pipeline 2025-06-04 21:48:59 +10:00
armistace a7eae4b09f Merge pull request 'matrix_notifications' (#3) from matrix_notifications into master
Reviewed-on: #3
2025-06-04 21:34:12 +10:00
armistace c466b04a25 matrix notifications and config driven chroma 2025-06-04 21:32:51 +10:00
armistace 431e5c63aa first pass at docker run 2025-06-04 16:56:08 +10:00
armistace 6e117e3ce9 language cleanup for integration testing 2025-06-02 12:32:21 +10:00
armistace 9a9228bc07 Merge pull request 'repo_work_fix' (#2) from repo_work_fix into master
Reviewed-on: #2
2025-05-30 17:47:31 +10:00
armistace 2dd371408f trying for the hard fix 2025-05-30 17:25:13 +10:00
armistace 0005ad1fd3 hard reset for the repo work 2025-05-30 17:20:58 +10:00
13 changed files with 707 additions and 134 deletions
+56
View File
@@ -0,0 +1,56 @@
name: Create Blog Article if new notes exist
on:
schedule:
- cron: "15 18 * * *"
push:
branches:
- master
jobs:
prepare_blog_drafts_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Install dependencies
shell: bash
run: |
apt update && apt upgrade -y
apt install rustc cargo python-is-python3 pip python3-venv python3-virtualenv libmagic-dev git -y
virtualenv .venv
source .venv/bin/activate
pip install --upgrade pip
pip install -r requirements.txt
git config --global user.name "Blog Creator"
git config --global user.email "ridgway.infrastructure@gmail.com"
git config --global push.autoSetupRemote true
- name: Create .env
shell: bash
run: |
echo "TRILIUM_HOST=${{ vars.TRILIUM_HOST }}" > .env
echo "TRILIUM_PORT='${{ vars.TRILIUM_PORT }}'" >> .env
echo "TRILIUM_PROTOCOL='${{ vars.TRILIUM_PROTOCOL }}'" >> .env
echo "TRILIUM_PASS='${{ secrets.TRILIUM_PASS }}'" >> .env
echo "TRILIUM_TOKEN='${{ secrets.TRILIUM_TOKEN }}'" >> .env
echo "OLLAMA_PROTOCOL='${{ vars.OLLAMA_PROTOCOL }}'" >> .env
echo "OLLAMA_HOST='${{ vars.OLLAMA_HOST }}'" >> .env
echo "OLLAMA_PORT='${{ vars.OLLAMA_PORT }}'" >> .env
echo "EMBEDDING_MODEL='${{ vars.EMBEDDING_MODEL }}'" >> .env
echo "EDITOR_MODEL='${{ vars.EDITOR_MODEL }}'" >> .env
export PURE='["${{ vars.CONTENT_CREATOR_MODELS_1 }}", "${{ vars.CONTENT_CREATOR_MODELS_2 }}", "${{ vars.CONTENT_CREATOR_MODELS_3 }}", "${{ vars.CONTENT_CREATOR_MODELS_4 }}"]'
echo "CONTENT_CREATOR_MODELS='$PURE'" >> .env
echo "GIT_PROTOCOL='${{ vars.GIT_PROTOCOL }}'" >> .env
echo "GIT_REMOTE='${{ vars.GIT_REMOTE }}'" >> .env
echo "GIT_USER='${{ vars.GIT_USER }}'" >> .env
echo "GIT_PASS='${{ secrets.GIT_PASS }}'" >> .env
echo "N8N_SECRET='${{ secrets.N8N_SECRET }}'" >> .env
echo "N8N_WEBHOOK_URL='${{ vars.N8N_WEBHOOK_URL }}'" >> .env
echo "CHROMA_HOST='${{ vars.CHROMA_HOST }}'" >> .env
echo "CHROMA_PORT='${{ vars.CHROMA_PORT }}'" >> .env
- name: Create Blogs
shell: bash
run: |
source .venv/bin/activate
python src/main.py
+5
View File
@@ -3,3 +3,8 @@ __pycache__
.venv .venv
.aider* .aider*
.vscode .vscode
.zed
pyproject.toml
.ropeproject
generated_files/*
pyright*
+6 -2
View File
@@ -7,8 +7,12 @@ ENV PYTHONUNBUFFERED 1
ADD src/ /blog_creator ADD src/ /blog_creator
RUN apt-get update && apt-get install -y rustc cargo python-is-python3 pip python3.12-venv libmagic-dev RUN apt-get update && apt-get install -y rustc cargo python-is-python3 pip python3-venv libmagic-dev git
# Need to set up git here or we get funky errors
RUN git config --global user.name "Blog Creator"
RUN git config --global user.email "ridgway.infrastructure@gmail.com"
RUN git config --global push.autoSetupRemote true
#Get a python venv going as well cause safety
RUN python -m venv /opt/venv RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH" ENV PATH="/opt/venv/bin:$PATH"
+13 -4
View File
@@ -3,10 +3,19 @@
This creator requires you to use a working Trilium Instance and create a .env file with the following This creator requires you to use a working Trilium Instance and create a .env file with the following
``` ```
TRILIUM_HOST TRILIUM_HOST=
TRILIUM_PORT TRILIUM_PORT=
TRILIUM_PROTOCOL TRILIUM_PROTOCOL=
TRILIUM_PASS TRILIUM_PASS=
TRILIUM_TOKEN=
OLLAMA_PROTOCOL=
OLLAMA_HOST=
OLLAMA_PORT=11434
EMBEDDING_MODEL=
EDITOR_MODEL=
# This is expected in python list format example `[phi4-mini:latest, qwen3:1.7b, gemma3:latest]`
CONTENT_CREATOR_MODELS=
CHROMA_SERVER=<IP_ADDRESS>
``` ```
This container is going to be what I use to trigger a blog creation event This container is going to be what I use to trigger a blog creation event
+33
View File
@@ -1,3 +1,7 @@
networks:
net:
driver: bridge
services: services:
blog_creator: blog_creator:
build: build:
@@ -8,4 +12,33 @@ services:
- .env - .env
volumes: volumes:
- ./generated_files/:/blog_creator/generated_files - ./generated_files/:/blog_creator/generated_files
networks:
- net
chroma:
image: chromadb/chroma
container_name: chroma
volumes:
# Be aware that indexed data are located in "/chroma/chroma/"
# Default configuration for persist_directory in chromadb/config.py
# Read more about deployments: https://docs.trychroma.com/deployment
- chroma-data:/chroma/chroma
#command: "--host 0.0.0.0 --port 8000 --proxy-headers --log-config chromadb/log_config.yml --timeout-keep-alive 30"
environment:
- IS_PERSISTENT=TRUE
restart: unless-stopped # possible values are: "no", always", "on-failure", "unless-stopped"
ports:
- "8000:8000"
healthcheck:
# Adjust below to match your container port
test:
["CMD", "curl", "-f", "http://localhost:8000/api/v2/heartbeat"]
interval: 30s
timeout: 10s
retries: 3
networks:
- net
volumes:
chroma-data:
driver: local
+4
View File
@@ -2,3 +2,7 @@ ollama
trilium-py trilium-py
gitpython gitpython
PyGithub PyGithub
chromadb
langchain-ollama
PyJWT
dotenv
+354 -25
View File
@@ -1,44 +1,332 @@
import json
import os import os
from ollama import Client import random
import re import re
import string
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError
import chromadb
from langchain_ollama import ChatOllama
from ollama import Client
class OllamaGenerator: class OllamaGenerator:
def __init__(self, title: str, content: str, inner_title: str):
def __init__(self, title: str, content: str, model: str):
self.title = title self.title = title
self.inner_title = inner_title
self.content = content self.content = content
ollama_url = f"{os.environ["OLLAMA_PROTOCOL"]}://{os.environ["OLLAMA_HOST"]}:{os.environ["OLLAMA_PORT"]}" self.response = None
print("In Class")
print(os.environ["CONTENT_CREATOR_MODELS"])
try:
chroma_port = int(os.environ["CHROMA_PORT"])
except ValueError as e:
raise Exception(f"CHROMA_PORT is not an integer: {e}")
self.chroma = chromadb.HttpClient(
host=os.environ["CHROMA_HOST"], port=chroma_port
)
ollama_url = f"{os.environ['OLLAMA_PROTOCOL']}://{os.environ['OLLAMA_HOST']}:{os.environ['OLLAMA_PORT']}"
self.ollama_client = Client(host=ollama_url) self.ollama_client = Client(host=ollama_url)
self.ollama_model = model self.ollama_model = os.environ["EDITOR_MODEL"]
self.embed_model = os.environ["EMBEDDING_MODEL"]
def generate_markdown(self) -> str: self.agent_models = json.loads(os.environ["CONTENT_CREATOR_MODELS"])
self.llm = ChatOllama(
prompt = f""" model=self.ollama_model, temperature=0.6, top_p=0.5
You are a Software Developer and DevOps expert ) # This is the level head in the room
who has transistioned in Developer Relations self.prompt_inject = f"""
writing a 1000 word blog for other tech enthusiast. You are a journalist, Software Developer and DevOps expert
writing a 5000 word draft blog article for other tech enthusiasts.
You like to use almost no code examples and prefer to talk You like to use almost no code examples and prefer to talk
in a light comedic tone. You are also Australian in a light comedic tone. You are also Australian
As this person write this blog as a markdown document. As this person write this blog as a markdown document.
The title for the blog is {self.title}. The title for the blog is {self.inner_title}.
Do not output the title in the markdown. Do not output the title in the markdown.
The basis for the content of the blog is: The basis for the content of the blog is:
{self.content} <blog>{self.content}</blog>
Only output markdown DO NOT GENERATE AN EXPLANATION
""" """
try:
self.response = self.ollama_client.chat(model=self.ollama_model, def split_into_chunks(self, text, chunk_size=100):
"""Split text into chunks of size chunk_size"""
words = re.findall(r"\S+", text)
chunks = []
current_chunk = []
word_count = 0
for word in words:
current_chunk.append(word)
word_count += 1
if word_count >= chunk_size:
chunks.append(" ".join(current_chunk))
current_chunk = []
word_count = 0
if current_chunk:
chunks.append(" ".join(current_chunk))
return chunks
def generate_draft(self, model) -> str:
"""Generate a draft blog post using the specified model"""
def _generate():
# the idea behind this is to make the "creativity" random amongst the content creators
# contorlling temperature will allow cause the output to allow more "random" connections in sentences
# Controlling top_p will tighten or loosen the embedding connections made
# The result should be varied levels of "creativity" in the writing of the drafts
# for more see https://python.langchain.com/v0.2/api_reference/ollama/chat_models/langchain_ollama.chat_models.ChatOllama.html
temp = random.uniform(0.5, 1.0)
top_p = random.uniform(0.4, 0.8)
top_k = int(random.uniform(30, 80))
agent_llm = ChatOllama(
model=model, temperature=temp, top_p=top_p, top_k=top_k
)
messages = [ messages = [
{ (
'role': 'user', "system",
'content': f'{prompt}', "You are a creative writer specialising in writing about technology",
}, ),
]) ("human", self.prompt_inject),
]
response = agent_llm.invoke(messages)
return (
response.text if hasattr(response, "text") else str(response)
) # ['message']['content']
# the deepseek model returns <think> this removes those tabs from the output # Retry mechanism with 30-minute timeout
# return re.sub(r"<think|.\n\r+?|([^;]*)\/think>",'',self.response['message']['content']) timeout_seconds = 30 * 60 # 30 minutes
return self.response['message']['content'] max_retries = 3
for attempt in range(max_retries):
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_generate)
result = future.result(timeout=timeout_seconds)
return result
except TimeoutError:
print(
f"AI call timed out after {timeout_seconds} seconds on attempt {attempt + 1}"
)
if attempt < max_retries - 1:
print("Retrying...")
time.sleep(5) # Wait 5 seconds before retrying
continue
else:
raise Exception(
f"AI call failed to complete after {max_retries} attempts with {timeout_seconds} second timeouts"
)
except Exception as e:
if attempt < max_retries - 1:
print(f"Attempt {attempt + 1} failed with error: {e}. Retrying...")
time.sleep(5) # Wait 5 seconds before retrying
continue
else:
raise Exception(
f"Failed to generate blog draft after {max_retries} attempts: {e}"
)
def get_draft_embeddings(self, draft_chunks):
"""Get embeddings for the draft chunks"""
try:
# Handle empty draft chunks
if not draft_chunks:
print("Warning: No draft chunks to embed")
return []
embeds = self.ollama_client.embed(
model=self.embed_model, input=draft_chunks
)
embeddings = embeds.get("embeddings", [])
# Check if embeddings were generated successfully
if not embeddings:
print("Warning: No embeddings generated")
return []
return embeddings
except Exception as e:
print(f"Error generating embeddings: {e}")
return []
def id_generator(self, size=6, chars=string.ascii_uppercase + string.digits):
return "".join(random.choice(chars) for _ in range(size))
def load_to_vector_db(self):
"""Load the generated blog drafts into a vector database"""
collection_name = (
f"blog_{self.title.lower().replace(' ', '_')}_{self.id_generator()}"
)
collection = self.chroma.get_or_create_collection(
name=collection_name
) # , metadata={"hnsw:space": "cosine"})
# if any(collection.name == collectionname for collectionname in self.chroma.list_collections()):
# self.chroma.delete_collection("blog_creator")
for model in self.agent_models:
print(f"Generating draft from {model} for load into vector database")
try:
draft_content = self.generate_draft(model)
draft_chunks = self.split_into_chunks(draft_content)
# Skip if no content was generated
if not draft_chunks or all(
chunk.strip() == "" for chunk in draft_chunks
):
print(f"Skipping {model} - no content generated")
continue
print(f"generating embeds for {model}")
embeds = self.get_draft_embeddings(draft_chunks)
# Skip if no embeddings were generated
if not embeds:
print(f"Skipping {model} - no embeddings generated")
continue
# Ensure we have the same number of embeddings as chunks
if len(embeds) != len(draft_chunks):
print(
f"Warning: Mismatch between chunks ({len(draft_chunks)}) and embeddings ({len(embeds)}) for {model}"
)
# Truncate or pad to match
min_length = min(len(embeds), len(draft_chunks))
draft_chunks = draft_chunks[:min_length]
embeds = embeds[:min_length]
if min_length == 0:
print(f"Skipping {model} - no valid content/embeddings pairs")
continue
ids = [model + str(i) for i in range(len(draft_chunks))]
chunknumber = list(range(len(draft_chunks)))
metadata = [{"model_agent": model} for index in chunknumber]
print(f"loading into collection for {model}")
collection.add(
documents=draft_chunks,
embeddings=embeds,
ids=ids,
metadatas=metadata,
)
except Exception as e:
print(f"Error processing model {model}: {e}")
# Continue with other models rather than failing completely
continue
return collection
def generate_markdown(self) -> str:
prompt_human = f"""
You are an editor taking information from {len(self.agent_models)} Software
Developers and Data experts
writing a 5000 word blog article. You like when they use almost no code examples.
You are also Australian. The content may have light comedic elements,
you are more professional and will attempt to tone these down
As this person produce the final version of this blog as a markdown document
keeping in mind the context provided by the previous drafts.
You are to produce the content not placeholders for further editors
The title for the blog is {self.inner_title}.
Do not output the title in the markdown. Avoid repeated sentences
The basis for the content of the blog is:
<blog>{self.content}</blog>
"""
def _generate_final_document():
try:
embed_result = self.ollama_client.embed(
model=self.embed_model, input=prompt_human
)
query_embed = embed_result.get("embeddings", [])
if not query_embed:
print(
"Warning: Failed to generate query embeddings, using empty list"
)
query_embed = [[]] # Use a single empty embedding as fallback
except Exception as e:
print(f"Error generating query embeddings: {e}")
# Generate empty embeddings as fallback
query_embed = [[]] # Use a single empty embedding as fallback
collection = self.load_to_vector_db()
# Try to query the collection, with fallback for empty collections
try:
collection_query = collection.query(
query_embeddings=query_embed, n_results=100
)
print("Showing pertinent info from drafts used in final edited edition")
# Get documents with error handling
query_result = collection.query(
query_embeddings=query_embed, n_results=100
)
documents = query_result.get("documents", [])
if documents and len(documents) > 0 and len(documents[0]) > 0:
pertinent_draft_info = "\n\n".join(documents[0])
else:
print("Warning: No relevant documents found in collection")
pertinent_draft_info = "No relevant information found in drafts."
except Exception as query_error:
print(f"Error querying collection: {query_error}")
pertinent_draft_info = (
"No relevant information found in drafts due to query error."
)
# print(pertinent_draft_info)
prompt_system = f"""Generate the final, 5000 word, draft of the blog using this information from the drafts: <context>{pertinent_draft_info}</context>
- Only output in markdown, do not wrap in markdown tags, Only provide the draft not a commentary on the drafts in the context
"""
print("Generating final document")
messages = [
("system", prompt_system),
("human", prompt_human),
]
response = self.llm.invoke(messages)
return response.text if hasattr(response, "text") else str(response)
try:
# Retry mechanism with 30-minute timeout
timeout_seconds = 30 * 60 # 30 minutes
max_retries = 3
for attempt in range(max_retries):
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_generate_final_document)
self.response = future.result(timeout=timeout_seconds)
break # Success, exit the retry loop
except TimeoutError:
print(
f"AI call timed out after {timeout_seconds} seconds on attempt {attempt + 1}"
)
if attempt < max_retries - 1:
print("Retrying...")
time.sleep(5) # Wait 5 seconds before retrying
continue
else:
raise Exception(
f"AI call failed to complete after {max_retries} attempts with {timeout_seconds} second timeouts"
)
except Exception as e:
if attempt < max_retries - 1:
print(
f"Attempt {attempt + 1} failed with error: {e}. Retrying..."
)
time.sleep(5) # Wait 5 seconds before retrying
continue
else:
raise Exception(
f"Failed to generate markdown after {max_retries} attempts: {e}"
)
# self.response = self.ollama_client.chat(model=self.ollama_model,
# messages=[
# 'content': f'{prompt_enhanced}',
# },
# ])
# print ("Markdown Generated")
# print (self.response)
return self.response # ['message']['content']
except Exception as e: except Exception as e:
raise Exception(f"Failed to generate markdown: {e}") raise Exception(f"Failed to generate markdown: {e}")
@@ -47,3 +335,44 @@ class OllamaGenerator:
with open(filename, "w") as f: with open(filename, "w") as f:
f.write(self.generate_markdown()) f.write(self.generate_markdown())
def generate_system_message(self, prompt_system, prompt_human):
def _generate():
messages = [
("system", prompt_system),
("human", prompt_human),
]
response = self.llm.invoke(messages)
ai_message = response.text if hasattr(response, "text") else str(response)
return ai_message
# Retry mechanism with 30-minute timeout
timeout_seconds = 30 * 60 # 30 minutes
max_retries = 3
for attempt in range(max_retries):
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(_generate)
result = future.result(timeout=timeout_seconds)
return result
except TimeoutError:
print(
f"AI call timed out after {timeout_seconds} seconds on attempt {attempt + 1}"
)
if attempt < max_retries - 1:
print("Retrying...")
time.sleep(5) # Wait 5 seconds before retrying
continue
else:
raise Exception(
f"AI call failed to complete after {max_retries} attempts with {timeout_seconds} second timeouts"
)
except Exception as e:
if attempt < max_retries - 1:
print(f"Attempt {attempt + 1} failed with error: {e}. Retrying...")
time.sleep(5) # Wait 5 seconds before retrying
continue
else:
raise Exception(
f"Failed to generate system message after {max_retries} attempts: {e}"
)
+64 -6
View File
@@ -1,5 +1,13 @@
import ai_generators.ollama_md_generator as omg import ai_generators.ollama_md_generator as omg
import trilium.notes as tn import trilium.notes as tn
import repo_management.repo_manager as git_repo
from notifications.n8n import N8NWebhookJwt
import string,os
from datetime import datetime
from dotenv import load_dotenv
load_dotenv()
print(os.environ["CONTENT_CREATOR_MODELS"])
tril = tn.TrilumNotes() tril = tn.TrilumNotes()
@@ -7,16 +15,66 @@ tril.get_new_notes()
tril_notes = tril.get_notes_content() tril_notes = tril.get_notes_content()
def convert_to_lowercase_with_underscores(string): def convert_to_lowercase_with_underscores(s):
return string.lower().replace(" ", "_") allowed = set(string.ascii_letters + string.digits + ' ')
filtered_string = ''.join(c for c in s if c in allowed)
return filtered_string.lower().replace(" ", "_")
for note in tril_notes: for note in tril_notes:
print(tril_notes[note]['title']) print(tril_notes[note]['title'])
# print(tril_notes[note]['content']) # print(tril_notes[note]['content'])
print("Generating Document") print("Generating Document")
ai_gen = omg.OllamaGenerator(tril_notes[note]['title'],
tril_notes[note]['content'],
"deepseek-r1:7b")
os_friendly_title = convert_to_lowercase_with_underscores(tril_notes[note]['title']) os_friendly_title = convert_to_lowercase_with_underscores(tril_notes[note]['title'])
ai_gen.save_to_file(f"./generated_files/{os_friendly_title}.md") ai_gen = omg.OllamaGenerator(os_friendly_title,
tril_notes[note]['content'],
tril_notes[note]['title'])
blog_path = f"generated_files/{os_friendly_title}.md"
ai_gen.save_to_file(blog_path)
# Generate commit messages and push to repo
print("Generating Commit Message")
git_sytem_prompt = "You are a blog creator commiting a piece of content to a central git repo"
git_human_prompt = f"Generate a 5 word git commit message describing {ai_gen.response}. ONLY OUTPUT THE RESPONSE"
commit_message = ai_gen.generate_system_message(git_sytem_prompt, git_human_prompt)
git_user = os.environ["GIT_USER"]
git_pass = os.environ["GIT_PASS"]
repo_manager = git_repo.GitRepository("blog/", git_user, git_pass)
print("Pushing to Repo")
repo_manager.create_copy_commit_push(blog_path, os_friendly_title, commit_message)
# Generate notification for Matrix
print("Generating Notification Message")
git_branch_url = f'https://git.aridgwayweb.com/armistace/blog/src/branch/{os_friendly_title}/src/content/{os_friendly_title}.md'
n8n_system_prompt = f"You are a blog creator notifiying the final editor of the final creation of blog available at {git_branch_url}"
n8n_prompt_human = f"""
Generate an informal 100 word
summary describing {ai_gen.response}.
Don't address it or use names. ONLY OUTPUT THE RESPONSE.
ONLY OUTPUT IN PLAINTEXT STRIP ALL MARKDOWN
"""
notification_message = ai_gen.generate_system_message(n8n_system_prompt, n8n_prompt_human)
secret_key = os.environ['N8N_SECRET']
webhook_url = os.environ['N8N_WEBHOOK_URL']
notification_string = f"""
<h2>{tril_notes[note]['title']}</h2>
<h3>Summary</h3>
<p>{notification_message}</p>
<h3>Branch</h3>
<p>{os_friendly_title}</p>
<p><a href="{git_branch_url}">Link to Branch</a></p>
"""
payload = {
"message": f"{notification_string}",
"timestamp": datetime.now().isoformat()
}
webhook_client = N8NWebhookJwt(secret_key, webhook_url)
print("Notifying")
n8n_result = webhook_client.send_webhook(payload)
print(f"N8N response: {n8n_result['status']}")
View File
+45
View File
@@ -0,0 +1,45 @@
from datetime import datetime, timedelta
import jwt
import requests
from typing import Dict, Optional
class N8NWebhookJwt:
def __init__(self, secret_key: str, webhook_url: str):
self.secret_key = secret_key
self.webhook_url = webhook_url
self.token_expiration = datetime.now() + timedelta(hours=1)
def _generate_jwt_token(self, payload: Dict) -> str:
"""Generate JWT token with the given payload."""
# Include expiration time (optional)
payload["exp"] = self.token_expiration.timestamp()
encoded_jwt = jwt.encode(
payload,
self.secret_key,
algorithm="HS256",
)
return encoded_jwt #jwt.decode(encoded_jwt, self.secret_key, algorithms=['HS256'])
def send_webhook(self, payload: Dict) -> Dict:
"""Send a webhook request with JWT authentication."""
# Generate JWT token
token = self._generate_jwt_token(payload)
# Set headers with JWT token
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
# Send POST request
response = requests.post(
self.webhook_url,
json=payload,
headers=headers
)
# Handle response
if response.status_code == 200:
return {"status": "success", "response": response.json()}
else:
return {"status": "error", "response": response.status_code, "message": response.text}
-48
View File
@@ -1,48 +0,0 @@
import os
import sys
from git import Repo
# Set these variables accordingly
REPO_OWNER = "your_repo_owner"
REPO_NAME = "your_repo_name"
def clone_repo(repo_url, branch="main"):
Repo.clone_from(repo_url, ".", branch=branch)
def create_markdown_file(file_name, content):
with open(f"{file_name}.md", "w") as f:
f.write(content)
def commit_and_push(file_name, message):
repo = Repo(".")
repo.index.add([f"{file_name}.md"])
repo.index.commit(message)
repo.remote().push()
def create_new_branch(branch_name):
repo = Repo(".")
repo.create_head(branch_name).checkout()
repo.head.reference.set_tracking_url(f"https://your_git_server/{REPO_OWNER}/{REPO_NAME}.git/{branch_name}")
repo.remote().push()
if __name__ == "__main__":
if len(sys.argv) < 3:
print("Usage: python push_markdown.py <repo_url> <markdown_file_name>")
sys.exit(1)
repo_url = sys.argv[1]
file_name = sys.argv[2]
# Clone the repository
clone_repo(repo_url)
# Create a new Markdown file with content
create_markdown_file(file_name, "Hello, World!\n")
# Commit and push changes to the main branch
commit_and_push(file_name, f"Add {file_name}.md")
# Create a new branch named after the Markdown file
create_new_branch(file_name)
print(f"Successfully created '{file_name}' branch with '{file_name}.md'.")
+101 -28
View File
@@ -1,35 +1,108 @@
import os import os
from git import Git import shutil
from git.repo import BaseRepository from urllib.parse import quote
from git.exc import InvalidGitRepositoryError
from git.remote import RemoteAction
# Set the path to your blog repo here from git import Repo
blog_repo = "/path/to/your/blog/repo" from git.exc import GitCommandError
# Checkout a new branch and create a new file for our blog post
branch_name = "new-post" class GitRepository:
# This is designed to be transitory it will desctruvtively create the repo at repo_path
# if you have uncommited changes you can kiss them goodbye!
# Don't use the repo created by this function for dev -> its a tool!
# It is expected that when used you will add, commit, push, delete
def __init__(self, repo_path, username=None, password=None):
git_protocol = os.environ["GIT_PROTOCOL"]
git_remote = os.environ["GIT_REMOTE"]
# if username is not set we don't need parse to the url
if username == None or password == None:
remote = f"{git_protocol}://{git_remote}"
else:
# of course if it is we need to parse and escape it so that it
# can generate a url
git_user = quote(username)
git_password = quote(password)
remote = f"{git_protocol}://{git_user}:{git_password}@{git_remote}"
if os.path.exists(repo_path):
shutil.rmtree(repo_path)
self.repo_path = repo_path
print("Cloning Repo")
Repo.clone_from(remote, repo_path)
self.repo = Repo(repo_path)
self.username = username
self.password = password
def clone(self, remote_url, destination_path):
"""Clone a Git repository with authentication"""
try: try:
repo = Git(blog_repo) self.repo.clone(remote_url, destination_path)
repo.checkout("-b", branch_name, "origin/main") return True
with open("my-blog-post.md", "w") as f: except GitCommandError as e:
f.write(content) print(f"Cloning failed: {e}")
except InvalidGitRepositoryError: return False
# Handle repository errors gracefully
pass
# Add and commit the changes to Git def fetch(self, remote_name="origin", ref_name="main"):
repo.add("my-blog-post.md") """Fetch updates from a remote repository with authentication"""
repo.commit("-m", "Added new blog post about DevOps best practices.")
# Push the changes to Git and create a PR
repo.remote().push("refs/heads/{0}:refs/for/main".format(branch_name), "--set-upstream")
base_branch = "origin/main"
target_branch = "main"
pr_title = "DevOps best practices"
try: try:
repo.create_head("{0}-{1}", base=base_branch, message="{}".format(pr_title)) self.repo.remotes[remote_name].fetch(ref_name=ref_name)
except RemoteAction.GitExitStatus as e: return True
# Handle Git exit status errors gracefully except GitCommandError as e:
pass print(f"Fetching failed: {e}")
return False
def pull(self, remote_name="origin", ref_name="main"):
"""Pull updates from a remote repository with authentication"""
print("Pulling Latest Updates (if any)")
try:
self.repo.remotes[remote_name].pull(ref_name)
return True
except GitCommandError as e:
print(f"Pulling failed: {e}")
return False
def get_branches(self):
"""List all branches in the repository"""
return [branch.name for branch in self.repo.branches]
def add_and_commit(self, message=None):
"""Add and commit changes to the repository."""
try:
print("Commiting latest draft")
# Add all changes
self.repo.git.add(all=True)
# Commit with the provided message or a default
if message is None:
commit_message = "Added and committed new content"
else:
commit_message = message
self.repo.git.commit(message=commit_message)
return True
except GitCommandError as e:
print(f"Commit failed: {e}")
return False
def create_copy_commit_push(self, file_path, title, commit_message):
# Check if branch exists remotely
remote_branches = [
ref.name.split("/")[-1] for ref in self.repo.remotes.origin.refs
]
if title in remote_branches:
# Branch exists remotely, checkout and pull
self.repo.git.checkout(title)
self.pull(ref_name=title)
else:
# New branch, create from main
self.repo.git.checkout("-b", title, "origin/main")
# Ensure destination directory exists
dest_dir = f"{self.repo_path}src/content/"
os.makedirs(dest_dir, exist_ok=True)
# Copy file
shutil.copy(f"{file_path}", dest_dir)
# Commit and push
self.add_and_commit(commit_message)
self.repo.git.push("--set-upstream", "origin", title)
+6 -1
View File
@@ -18,9 +18,13 @@ class TrilumNotes:
print("Please run get_token and set your token") print("Please run get_token and set your token")
else: else:
self.ea = ETAPI(self.server_url, self.token) self.ea = ETAPI(self.server_url, self.token)
self.new_notes = None
self.note_content = None
def get_token(self): def get_token(self):
ea = ETAPI(self.server_url) ea = ETAPI(self.server_url)
if self.tril_pass == None:
raise ValueError("Trillium password can not be none")
token = ea.login(self.tril_pass) token = ea.login(self.tril_pass)
print(token) print(token)
print("I would recomend you update the env file with this tootsweet!") print("I would recomend you update the env file with this tootsweet!")
@@ -40,10 +44,11 @@ class TrilumNotes:
def get_notes_content(self): def get_notes_content(self):
content_dict = {} content_dict = {}
if self.new_notes is None:
raise ValueError("How did you do this? new_notes is None!")
for note in self.new_notes['results']: for note in self.new_notes['results']:
content_dict[note['noteId']] = {"title" : f"{note['title']}", content_dict[note['noteId']] = {"title" : f"{note['title']}",
"content" : f"{self._get_content(note['noteId'])}" "content" : f"{self._get_content(note['noteId'])}"
} }
self.note_content = content_dict self.note_content = content_dict
return content_dict return content_dict