Compare commits
13
Commits
15359e2ae3
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afc2f36e13 | ||
|
|
1d18a37fa7 | ||
|
|
a1a534a86c | ||
|
|
c86d6e4e4d | ||
|
|
5e014f50a5 | ||
|
|
d488c2be1d | ||
|
|
6d2b043eca | ||
|
|
fa611192c8
|
||
|
|
e69b83694c
|
||
|
|
1781a1dbf5 | ||
|
|
64acdb29b2
|
||
|
|
d148b95534 | ||
|
|
9171248ae6
|
@@ -15,12 +15,12 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
apt update && apt upgrade -y
|
apt update -qq
|
||||||
apt install rustc cargo python-is-python3 pip python3-venv python3-virtualenv libmagic-dev git -y
|
apt install -y -qq python-is-python3 pip python3-venv libmagic-dev git
|
||||||
virtualenv .venv
|
python -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip -q
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt --prefer-binary -q
|
||||||
git config --global user.name "Blog Creator"
|
git config --global user.name "Blog Creator"
|
||||||
git config --global user.email "ridgway.infrastructure@gmail.com"
|
git config --global user.email "ridgway.infrastructure@gmail.com"
|
||||||
git config --global push.autoSetupRemote true
|
git config --global push.autoSetupRemote true
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
ollama
|
ollama
|
||||||
|
httpx
|
||||||
trilium-py
|
trilium-py
|
||||||
gitpython
|
gitpython
|
||||||
PyGithub
|
PyGithub
|
||||||
|
|||||||
@@ -17,14 +17,19 @@ journalist draft is chunked, embedded, and stored in a collection; the editor
|
|||||||
receives the top-N most relevant chunks as context.
|
receives the top-N most relevant chunks as context.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import random
|
import random
|
||||||
import re
|
import re
|
||||||
import string
|
import string
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import chromadb # noqa: F811
|
||||||
|
|
||||||
import chromadb
|
|
||||||
from crewai.flow.flow import Flow, listen, start
|
from crewai.flow.flow import Flow, listen, start
|
||||||
from ollama import Client
|
from ollama import Client
|
||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict
|
||||||
@@ -85,7 +90,21 @@ class BlogFlow(Flow[BlogFlowState]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_chroma_client() -> chromadb.HttpClient:
|
def _get_chroma_client() -> "chromadb.HttpClient":
|
||||||
|
# Lazily import chromadb here rather than at module level.
|
||||||
|
# chromadb unconditionally loads hnswlib (a native C++ library
|
||||||
|
# compiled with AVX instructions) even when using HttpClient
|
||||||
|
# to talk to an external ChromaDB server. Deferring the import
|
||||||
|
# avoids "Illegal instruction" (SIGILL) crashes in environments
|
||||||
|
# that lack AVX support (e.g. act, older CI runners).
|
||||||
|
try:
|
||||||
|
import chromadb
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"chromadb is required for the editor phase but could not be "
|
||||||
|
f"imported: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
chroma_port = int(os.environ["CHROMA_PORT"])
|
chroma_port = int(os.environ["CHROMA_PORT"])
|
||||||
return chromadb.HttpClient(host=os.environ["CHROMA_HOST"], port=chroma_port)
|
return chromadb.HttpClient(host=os.environ["CHROMA_HOST"], port=chroma_port)
|
||||||
|
|
||||||
@@ -127,7 +146,7 @@ class BlogFlow(Flow[BlogFlowState]):
|
|||||||
print(f"Error generating embeddings: {exc}")
|
print(f"Error generating embeddings: {exc}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
def _load_drafts_to_vector_db(self, drafts: list[str]) -> chromadb.Collection:
|
def _load_drafts_to_vector_db(self, drafts: list[str]) -> "chromadb.Collection":
|
||||||
"""Load journalist drafts into a new ChromaDB collection and return it."""
|
"""Load journalist drafts into a new ChromaDB collection and return it."""
|
||||||
chroma = self._get_chroma_client()
|
chroma = self._get_chroma_client()
|
||||||
collection_name = (
|
collection_name = (
|
||||||
@@ -165,7 +184,7 @@ class BlogFlow(Flow[BlogFlowState]):
|
|||||||
return collection
|
return collection
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _query_vector_db(collection: chromadb.Collection, query_text: str) -> str:
|
def _query_vector_db(collection: "chromadb.Collection", query_text: str) -> str:
|
||||||
"""Query the ChromaDB collection and return the most relevant
|
"""Query the ChromaDB collection and return the most relevant
|
||||||
document chunks joined as a single string."""
|
document chunks joined as a single string."""
|
||||||
ollama_client = BlogFlow._get_ollama_client()
|
ollama_client = BlogFlow._get_ollama_client()
|
||||||
|
|||||||
@@ -140,8 +140,8 @@ class OllamaGenerator:
|
|||||||
)
|
)
|
||||||
return response["message"]["content"]
|
return response["message"]["content"]
|
||||||
|
|
||||||
# Retry mechanism with 30-minute timeout (same as the original)
|
# Retry mechanism with 60-minute timeout (bumped for large context models)
|
||||||
timeout_seconds = 30 * 60
|
timeout_seconds = 60 * 60
|
||||||
max_retries = 3
|
max_retries = 3
|
||||||
|
|
||||||
for attempt in range(max_retries):
|
for attempt in range(max_retries):
|
||||||
|
|||||||
@@ -4,17 +4,25 @@ Custom CrewAI tool that wraps Ollama's native web search API.
|
|||||||
This tool allows CrewAI agents to perform web searches using an Ollama
|
This tool allows CrewAI agents to perform web searches using an Ollama
|
||||||
subscription instead of third-party services like Serper or EXA.
|
subscription instead of third-party services like Serper or EXA.
|
||||||
|
|
||||||
|
Uses direct HTTP requests via httpx with explicit Authorization: Bearer
|
||||||
|
header to ensure the OLLAMA_API_KEY is properly passed to the Ollama cloud
|
||||||
|
API endpoint (https://ollama.com/api/web_search).
|
||||||
|
|
||||||
Requires:
|
Requires:
|
||||||
- Ollama Python library: pip install ollama
|
- httpx library: pip install httpx (already a transitive dependency of crewai)
|
||||||
- OLLAMA_API_KEY environment variable set with your Ollama API key
|
- OLLAMA_API_KEY environment variable set with your Ollama API key
|
||||||
|
|
||||||
|
Reference: https://docs.ollama.com/capabilities/web-search
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
|
|
||||||
import ollama
|
import httpx
|
||||||
from crewai.tools import BaseTool
|
from crewai.tools import BaseTool
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
OLLAMA_WEB_SEARCH_URL = "https://ollama.com/api/web_search"
|
||||||
|
|
||||||
|
|
||||||
class OllamaWebSearchInput(BaseModel):
|
class OllamaWebSearchInput(BaseModel):
|
||||||
"""Input schema for OllamaWebSearchTool."""
|
"""Input schema for OllamaWebSearchTool."""
|
||||||
@@ -42,6 +50,10 @@ class OllamaWebSearchTool(BaseTool):
|
|||||||
The tool requires an Ollama subscription and the OLLAMA_API_KEY environment
|
The tool requires an Ollama subscription and the OLLAMA_API_KEY environment
|
||||||
variable to be set.
|
variable to be set.
|
||||||
|
|
||||||
|
Authentication is handled by sending the OLLAMA_API_KEY as a Bearer token
|
||||||
|
in the Authorization header, as documented at:
|
||||||
|
https://docs.ollama.com/capabilities/web-search
|
||||||
|
|
||||||
Example usage:
|
Example usage:
|
||||||
from ai_generators.tools.ollama_web_search_tool import OllamaWebSearchTool
|
from ai_generators.tools.ollama_web_search_tool import OllamaWebSearchTool
|
||||||
|
|
||||||
@@ -65,6 +77,9 @@ class OllamaWebSearchTool(BaseTool):
|
|||||||
"""
|
"""
|
||||||
Execute a web search and return formatted results.
|
Execute a web search and return formatted results.
|
||||||
|
|
||||||
|
Makes a POST request to https://ollama.com/api/web_search with the
|
||||||
|
OLLAMA_API_KEY as a Bearer token in the Authorization header.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
query: The search query string
|
query: The search query string
|
||||||
max_results: Maximum number of results to return (1-10)
|
max_results: Maximum number of results to return (1-10)
|
||||||
@@ -72,16 +87,40 @@ class OllamaWebSearchTool(BaseTool):
|
|||||||
Returns:
|
Returns:
|
||||||
Formatted string with search results, each containing title, URL, and content
|
Formatted string with search results, each containing title, URL, and content
|
||||||
"""
|
"""
|
||||||
|
api_key = os.environ.get("OLLAMA_API_KEY")
|
||||||
|
if not api_key:
|
||||||
|
return (
|
||||||
|
"Error: OLLAMA_API_KEY environment variable is not set. "
|
||||||
|
"Please set your Ollama API key."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Ensure API key is set
|
response = httpx.post(
|
||||||
if not os.environ.get("OLLAMA_API_KEY"):
|
OLLAMA_WEB_SEARCH_URL,
|
||||||
return "Error: OLLAMA_API_KEY environment variable is not set. Please set your Ollama API key."
|
json={"query": query, "max_results": max_results},
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {api_key}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
timeout=60.0,
|
||||||
|
)
|
||||||
|
|
||||||
# Perform the web search
|
# Raise for HTTP errors so we can catch them with specific messages
|
||||||
response = ollama.web_search(query=query, max_results=max_results)
|
if response.status_code == 401:
|
||||||
|
return (
|
||||||
|
"Authentication error: OLLAMA_API_KEY was rejected. "
|
||||||
|
"Your key may be invalid or expired. Please verify it at "
|
||||||
|
"https://ollama.com/settings/keys"
|
||||||
|
)
|
||||||
|
if response.status_code == 429:
|
||||||
|
return (
|
||||||
|
"Rate limit exceeded: Too many search requests. "
|
||||||
|
"Please wait a moment and try again."
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
# Extract and format results
|
data = response.json()
|
||||||
results = response.get("results", [])
|
results = data.get("results", [])
|
||||||
|
|
||||||
if not results:
|
if not results:
|
||||||
return f"No search results found for query: '{query}'"
|
return f"No search results found for query: '{query}'"
|
||||||
@@ -98,27 +137,20 @@ class OllamaWebSearchTool(BaseTool):
|
|||||||
|
|
||||||
return "\n".join(formatted_results)
|
return "\n".join(formatted_results)
|
||||||
|
|
||||||
except Exception as exc:
|
except httpx.TimeoutException:
|
||||||
return f"Error performing web search: {exc}"
|
|
||||||
|
|
||||||
def _handle_exception(self, exc: Exception) -> str:
|
|
||||||
"""Handle exceptions gracefully and return a user-friendly error message."""
|
|
||||||
error_message = str(exc)
|
|
||||||
|
|
||||||
# Check for common error types
|
|
||||||
if "authentication" in error_message.lower() or "401" in error_message:
|
|
||||||
return (
|
return (
|
||||||
"Authentication error: Your OLLAMA_API_KEY may be invalid or expired. "
|
"Timeout error: The web search request timed out. "
|
||||||
"Please check your API key and ensure it's set correctly in the environment."
|
"Please try again with a simpler query."
|
||||||
)
|
)
|
||||||
elif "rate limit" in error_message.lower() or "429" in error_message:
|
except httpx.ConnectError:
|
||||||
return "Rate limit exceeded: Too many search requests. Please wait a moment and try again."
|
|
||||||
elif (
|
|
||||||
"network" in error_message.lower() or "connection" in error_message.lower()
|
|
||||||
):
|
|
||||||
return (
|
return (
|
||||||
"Network error: Unable to connect to Ollama's web search service. "
|
"Network error: Unable to connect to Ollama's web search service. "
|
||||||
"Please check your internet connection and try again."
|
"Please check your internet connection and try again."
|
||||||
)
|
)
|
||||||
else:
|
except httpx.HTTPStatusError as exc:
|
||||||
return f"Search failed: {error_message}"
|
return (
|
||||||
|
f"HTTP error {exc.response.status_code} from Ollama web search API: "
|
||||||
|
f"{exc.response.text}"
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return f"Error performing web search: {exc}"
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ class GitRepository:
|
|||||||
print(f"Cloning failed: {e}")
|
print(f"Cloning failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def fetch(self, remote_name="origin", ref_name="main"):
|
def fetch(self, remote_name="origin", ref_name="master"):
|
||||||
"""Fetch updates from a remote repository with authentication"""
|
"""Fetch updates from a remote repository with authentication"""
|
||||||
try:
|
try:
|
||||||
self.repo.remotes[remote_name].fetch(ref_name=ref_name)
|
self.repo.remotes[remote_name].fetch(ref_name=ref_name)
|
||||||
@@ -51,7 +51,7 @@ class GitRepository:
|
|||||||
print(f"Fetching failed: {e}")
|
print(f"Fetching failed: {e}")
|
||||||
return False
|
return False
|
||||||
|
|
||||||
def pull(self, remote_name="origin", ref_name="main"):
|
def pull(self, remote_name="origin", ref_name="master"):
|
||||||
"""Pull updates from a remote repository with authentication"""
|
"""Pull updates from a remote repository with authentication"""
|
||||||
print("Pulling Latest Updates (if any)")
|
print("Pulling Latest Updates (if any)")
|
||||||
try:
|
try:
|
||||||
@@ -93,8 +93,8 @@ class GitRepository:
|
|||||||
self.repo.git.checkout(title)
|
self.repo.git.checkout(title)
|
||||||
self.pull(ref_name=title)
|
self.pull(ref_name=title)
|
||||||
else:
|
else:
|
||||||
# New branch, create from main
|
# New branch, create from master
|
||||||
self.repo.git.checkout("-b", title, "origin/main")
|
self.repo.git.checkout("-b", title, "origin/master")
|
||||||
|
|
||||||
# Ensure destination directory exists
|
# Ensure destination directory exists
|
||||||
dest_dir = f"{self.repo_path}src/content/"
|
dest_dir = f"{self.repo_path}src/content/"
|
||||||
|
|||||||
Reference in New Issue
Block a user