new site
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
"""Service modules for external API integrations."""
|
||||
|
||||
from .supabase import SupabaseService
|
||||
from .apify import ApifyService
|
||||
from .firecrawl import FirecrawlService
|
||||
from .haiku import HaikuService
|
||||
from .images import ImageService
|
||||
from .tags import TagAggregationService
|
||||
|
||||
__all__ = [
|
||||
"SupabaseService",
|
||||
"ApifyService",
|
||||
"FirecrawlService",
|
||||
"HaikuService",
|
||||
"ImageService",
|
||||
"TagAggregationService",
|
||||
]
|
||||
@@ -0,0 +1,224 @@
|
||||
"""
|
||||
Apify service for Google Maps scraping.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, Dict, Any
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import get_settings, APIFY_GOOGLE_MAPS_ACTOR, SEARCH_QUERIES
|
||||
from ..models.schemas import ApifyGoogleMapsResult
|
||||
from ..utils.retry import retry_with_backoff
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ApifyService:
|
||||
"""Service for interacting with Apify Google Maps scraper."""
|
||||
|
||||
BASE_URL = "https://api.apify.com/v2"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Apify client."""
|
||||
settings = get_settings()
|
||||
self.token = settings.apify_token
|
||||
self.headers = {"Authorization": f"Bearer {self.token}"}
|
||||
|
||||
def _get_search_queries(self, city: str, language: str = "de") -> List[str]:
|
||||
"""
|
||||
Generate search queries for a city.
|
||||
|
||||
Args:
|
||||
city: City name
|
||||
language: Language code for queries
|
||||
|
||||
Returns:
|
||||
List of search query strings
|
||||
"""
|
||||
templates = SEARCH_QUERIES.get(language, SEARCH_QUERIES["en"])
|
||||
return [template.format(city=city) for template in templates]
|
||||
|
||||
@retry_with_backoff(max_attempts=3, exceptions=(httpx.HTTPError,))
|
||||
def run_google_maps_scraper(
|
||||
self,
|
||||
search_queries: List[str],
|
||||
max_results_per_query: int = 20,
|
||||
language: str = "de",
|
||||
country: str = "DE",
|
||||
) -> str:
|
||||
"""
|
||||
Start a Google Maps scraper run.
|
||||
|
||||
Args:
|
||||
search_queries: List of search queries
|
||||
max_results_per_query: Max results per query
|
||||
language: Language for results
|
||||
country: Country code for search
|
||||
|
||||
Returns:
|
||||
Run ID for tracking
|
||||
"""
|
||||
url = f"{self.BASE_URL}/acts/{APIFY_GOOGLE_MAPS_ACTOR}/runs"
|
||||
|
||||
payload = {
|
||||
"searchStringsArray": search_queries,
|
||||
"maxCrawledPlaces": max_results_per_query * len(search_queries),
|
||||
"language": language,
|
||||
"countryCode": country,
|
||||
"maxReviews": 0, # Don't fetch reviews to save credits
|
||||
"maxImages": 0, # Don't fetch images
|
||||
"includeWebResults": False,
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
response = client.post(url, json=payload, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
run_id = data["data"]["id"]
|
||||
logger.info(f"Started Apify run: {run_id}")
|
||||
return run_id
|
||||
|
||||
def wait_for_run(
|
||||
self, run_id: str, timeout: int = 300, poll_interval: int = 10
|
||||
) -> str:
|
||||
"""
|
||||
Wait for an Apify run to complete.
|
||||
|
||||
Args:
|
||||
run_id: Run ID to wait for
|
||||
timeout: Maximum wait time in seconds
|
||||
poll_interval: Time between status checks
|
||||
|
||||
Returns:
|
||||
Final status of the run
|
||||
"""
|
||||
url = f"{self.BASE_URL}/actor-runs/{run_id}"
|
||||
start_time = time.time()
|
||||
|
||||
while True:
|
||||
if time.time() - start_time > timeout:
|
||||
raise TimeoutError(f"Apify run {run_id} timed out after {timeout}s")
|
||||
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
response = client.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
status = data["data"]["status"]
|
||||
logger.debug(f"Run {run_id} status: {status}")
|
||||
|
||||
if status in ("SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"):
|
||||
return status
|
||||
|
||||
time.sleep(poll_interval)
|
||||
|
||||
def get_run_results(self, run_id: str) -> List[ApifyGoogleMapsResult]:
|
||||
"""
|
||||
Get results from a completed Apify run.
|
||||
|
||||
Args:
|
||||
run_id: Run ID to get results for
|
||||
|
||||
Returns:
|
||||
List of Google Maps results
|
||||
"""
|
||||
url = f"{self.BASE_URL}/actor-runs/{run_id}/dataset/items"
|
||||
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
response = client.get(url, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
results = []
|
||||
for item in data:
|
||||
try:
|
||||
result = ApifyGoogleMapsResult(
|
||||
title=item.get("title", ""),
|
||||
address=item.get("address"),
|
||||
phone=item.get("phone"),
|
||||
website=item.get("website"),
|
||||
rating=item.get("totalScore"),
|
||||
reviewsCount=item.get("reviewsCount"),
|
||||
placeId=item.get("placeId"),
|
||||
url=item.get("url"),
|
||||
location=item.get("location"),
|
||||
)
|
||||
results.append(result)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to parse result: {e}")
|
||||
|
||||
logger.info(f"Retrieved {len(results)} results from run {run_id}")
|
||||
return results
|
||||
|
||||
def scrape_city(
|
||||
self,
|
||||
city_name: str,
|
||||
language: str = "de",
|
||||
country: str = "DE",
|
||||
max_per_query: int = 20,
|
||||
) -> List[ApifyGoogleMapsResult]:
|
||||
"""
|
||||
Scrape Google Maps for Odoo partners in a city.
|
||||
|
||||
This is the main high-level method that combines query generation,
|
||||
running the scraper, and retrieving results.
|
||||
|
||||
Args:
|
||||
city_name: Name of the city to scrape
|
||||
language: Language code
|
||||
country: Country code
|
||||
max_per_query: Max results per search query
|
||||
|
||||
Returns:
|
||||
List of Google Maps results
|
||||
"""
|
||||
queries = self._get_search_queries(city_name, language)
|
||||
logger.info(f"Scraping {city_name} with {len(queries)} queries")
|
||||
|
||||
# Start the run
|
||||
run_id = self.run_google_maps_scraper(
|
||||
search_queries=queries,
|
||||
max_results_per_query=max_per_query,
|
||||
language=language,
|
||||
country=country,
|
||||
)
|
||||
|
||||
# Wait for completion
|
||||
status = self.wait_for_run(run_id)
|
||||
|
||||
if status != "SUCCEEDED":
|
||||
raise RuntimeError(f"Apify run failed with status: {status}")
|
||||
|
||||
# Get results
|
||||
return self.get_run_results(run_id)
|
||||
|
||||
def deduplicate_results(
|
||||
self, results: List[ApifyGoogleMapsResult]
|
||||
) -> List[ApifyGoogleMapsResult]:
|
||||
"""
|
||||
Remove duplicate results based on place ID.
|
||||
|
||||
Args:
|
||||
results: List of results to deduplicate
|
||||
|
||||
Returns:
|
||||
Deduplicated list
|
||||
"""
|
||||
seen_place_ids = set()
|
||||
unique_results = []
|
||||
|
||||
for result in results:
|
||||
if result.placeId and result.placeId not in seen_place_ids:
|
||||
seen_place_ids.add(result.placeId)
|
||||
unique_results.append(result)
|
||||
elif not result.placeId:
|
||||
# Keep results without place ID but log warning
|
||||
logger.warning(f"Result without place ID: {result.title}")
|
||||
unique_results.append(result)
|
||||
|
||||
logger.info(
|
||||
f"Deduplicated {len(results)} results to {len(unique_results)} unique"
|
||||
)
|
||||
return unique_results
|
||||
@@ -0,0 +1,177 @@
|
||||
"""
|
||||
Firecrawl service for website scraping and enrichment.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
|
||||
import httpx
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.schemas import FirecrawlResult
|
||||
from ..utils.retry import retry_with_backoff
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FirecrawlService:
|
||||
"""Service for scraping websites using Firecrawl."""
|
||||
|
||||
BASE_URL = "https://api.firecrawl.dev/v1"
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Firecrawl client."""
|
||||
settings = get_settings()
|
||||
self.api_key = settings.firecrawl_api_key
|
||||
self.headers = {
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
@retry_with_backoff(max_attempts=3, exceptions=(httpx.HTTPError,))
|
||||
def scrape_url(
|
||||
self,
|
||||
url: str,
|
||||
formats: Optional[List[str]] = None,
|
||||
include_images: bool = True,
|
||||
) -> FirecrawlResult:
|
||||
"""
|
||||
Scrape a single URL.
|
||||
|
||||
Args:
|
||||
url: URL to scrape
|
||||
formats: Output formats (default: markdown)
|
||||
include_images: Whether to extract image URLs
|
||||
|
||||
Returns:
|
||||
Scraped content
|
||||
"""
|
||||
if formats is None:
|
||||
formats = ["markdown"]
|
||||
|
||||
endpoint = f"{self.BASE_URL}/scrape"
|
||||
|
||||
payload = {
|
||||
"url": url,
|
||||
"formats": formats,
|
||||
}
|
||||
|
||||
with httpx.Client(timeout=60.0) as client:
|
||||
response = client.post(endpoint, json=payload, headers=self.headers)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
raise RuntimeError(f"Firecrawl scrape failed: {data.get('error')}")
|
||||
|
||||
result_data = data.get("data", {})
|
||||
|
||||
# Extract images from metadata if available
|
||||
images = []
|
||||
if include_images:
|
||||
metadata = result_data.get("metadata", {})
|
||||
# Try to get og:image and other image sources
|
||||
if metadata.get("ogImage"):
|
||||
images.append(metadata["ogImage"])
|
||||
if metadata.get("logo"):
|
||||
images.append(metadata["logo"])
|
||||
|
||||
return FirecrawlResult(
|
||||
markdown=result_data.get("markdown"),
|
||||
metadata=result_data.get("metadata"),
|
||||
images=images,
|
||||
)
|
||||
|
||||
def extract_company_info(
|
||||
self, url: str
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Extract structured company information from a website.
|
||||
|
||||
Args:
|
||||
url: Company website URL
|
||||
|
||||
Returns:
|
||||
Dictionary with extracted information
|
||||
"""
|
||||
try:
|
||||
result = self.scrape_url(url, formats=["markdown"], include_images=True)
|
||||
|
||||
info = {
|
||||
"markdown": result.markdown,
|
||||
"images": result.images or [],
|
||||
"logo_url": None,
|
||||
"email": None,
|
||||
"title": None,
|
||||
"description": None,
|
||||
}
|
||||
|
||||
# Extract metadata
|
||||
if result.metadata:
|
||||
info["title"] = result.metadata.get("title")
|
||||
info["description"] = result.metadata.get("description")
|
||||
if result.metadata.get("ogImage"):
|
||||
info["logo_url"] = result.metadata["ogImage"]
|
||||
|
||||
# Try to find email in markdown content
|
||||
if result.markdown:
|
||||
import re
|
||||
email_pattern = r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"
|
||||
emails = re.findall(email_pattern, result.markdown)
|
||||
if emails:
|
||||
# Filter out common non-company emails
|
||||
company_emails = [
|
||||
e for e in emails
|
||||
if not any(
|
||||
domain in e.lower()
|
||||
for domain in ["example.com", "test.com", "email.com"]
|
||||
)
|
||||
]
|
||||
if company_emails:
|
||||
info["email"] = company_emails[0]
|
||||
|
||||
return info
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to scrape {url}: {e}")
|
||||
return {
|
||||
"markdown": None,
|
||||
"images": [],
|
||||
"logo_url": None,
|
||||
"email": None,
|
||||
"title": None,
|
||||
"description": None,
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
def batch_scrape(
|
||||
self, urls: List[str], batch_size: int = 5
|
||||
) -> Dict[str, FirecrawlResult]:
|
||||
"""
|
||||
Scrape multiple URLs in batches.
|
||||
|
||||
Args:
|
||||
urls: List of URLs to scrape
|
||||
batch_size: Number of concurrent scrapes
|
||||
|
||||
Returns:
|
||||
Dictionary mapping URLs to results
|
||||
"""
|
||||
results = {}
|
||||
|
||||
for i in range(0, len(urls), batch_size):
|
||||
batch = urls[i : i + batch_size]
|
||||
logger.info(f"Scraping batch {i // batch_size + 1} ({len(batch)} URLs)")
|
||||
|
||||
for url in batch:
|
||||
try:
|
||||
results[url] = self.scrape_url(url)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to scrape {url}: {e}")
|
||||
results[url] = FirecrawlResult(
|
||||
markdown=None,
|
||||
metadata={"error": str(e)},
|
||||
images=[],
|
||||
)
|
||||
|
||||
return results
|
||||
@@ -0,0 +1,359 @@
|
||||
"""
|
||||
Claude Haiku service for content generation.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from anthropic import Anthropic
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.schemas import HaikuTagsResponse, HaikuContentResponse, TagCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HaikuService:
|
||||
"""Service for AI content generation using Claude Haiku."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Anthropic client."""
|
||||
settings = get_settings()
|
||||
self.client = Anthropic(api_key=settings.anthropic_api_key)
|
||||
self.model = "claude-3-5-haiku-20241022"
|
||||
|
||||
def _call_haiku(
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
max_tokens: int = 4096,
|
||||
) -> str:
|
||||
"""
|
||||
Make a call to Claude Haiku.
|
||||
|
||||
Args:
|
||||
system_prompt: System instructions
|
||||
user_prompt: User message
|
||||
max_tokens: Maximum response tokens
|
||||
|
||||
Returns:
|
||||
Response text
|
||||
"""
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
return response.content[0].text
|
||||
|
||||
def extract_tags(
|
||||
self,
|
||||
company_name: str,
|
||||
website_markdown: Optional[str],
|
||||
address: Optional[str] = None,
|
||||
) -> HaikuTagsResponse:
|
||||
"""
|
||||
Extract tags from company information.
|
||||
|
||||
Args:
|
||||
company_name: Company name
|
||||
website_markdown: Scraped website content
|
||||
address: Company address
|
||||
|
||||
Returns:
|
||||
Extracted tags with confidence
|
||||
"""
|
||||
system_prompt = """You are an expert at analyzing Odoo partner companies.
|
||||
Extract relevant tags from the provided company information.
|
||||
|
||||
Respond with valid JSON only, no other text. Use this exact structure:
|
||||
{
|
||||
"services": ["list of services they offer"],
|
||||
"industries": ["list of industries they serve"],
|
||||
"modules": ["list of Odoo modules they specialize in"],
|
||||
"partner_level": "Ready Partner|Silver Partner|Gold Partner|Platinum Partner or null",
|
||||
"confidence": 0.0 to 1.0
|
||||
}
|
||||
|
||||
Valid services: Implementation, Customization, Training, Support, Migration, Integration, Consulting, Hosting, Development
|
||||
Valid industries: Manufacturing, Retail, E-commerce, Healthcare, Education, Finance, Logistics, Construction, Food & Beverage, Professional Services
|
||||
Valid modules: Sales, CRM, Inventory, Accounting, Manufacturing, Website, E-commerce, HR, Project, Purchase, Point of Sale
|
||||
|
||||
Only include tags you can confidently identify from the content."""
|
||||
|
||||
user_prompt = f"""Company: {company_name}
|
||||
Address: {address or "Not provided"}
|
||||
|
||||
Website content:
|
||||
{website_markdown[:8000] if website_markdown else "No website content available"}
|
||||
|
||||
Extract tags for this Odoo partner company."""
|
||||
|
||||
try:
|
||||
response = self._call_haiku(system_prompt, user_prompt, max_tokens=1024)
|
||||
# Parse JSON response
|
||||
data = json.loads(response)
|
||||
return HaikuTagsResponse(**data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse tags response: {e}")
|
||||
return HaikuTagsResponse(confidence=0.0)
|
||||
except Exception as e:
|
||||
logger.error(f"Tag extraction failed: {e}")
|
||||
return HaikuTagsResponse(confidence=0.0)
|
||||
|
||||
def generate_company_content(
|
||||
self,
|
||||
company_name: str,
|
||||
city_name: str,
|
||||
country_name: str,
|
||||
website_markdown: Optional[str],
|
||||
services: List[str],
|
||||
industries: List[str],
|
||||
modules: List[str],
|
||||
partner_level: Optional[str] = None,
|
||||
language: str = "de",
|
||||
) -> HaikuContentResponse:
|
||||
"""
|
||||
Generate SEO-optimized company page content.
|
||||
|
||||
Args:
|
||||
company_name: Company name
|
||||
city_name: City name
|
||||
country_name: Country name
|
||||
website_markdown: Scraped website content
|
||||
services: List of service tags
|
||||
industries: List of industry tags
|
||||
modules: List of module tags
|
||||
partner_level: Partner certification level
|
||||
language: Output language code
|
||||
|
||||
Returns:
|
||||
Generated content
|
||||
"""
|
||||
lang_instructions = {
|
||||
"de": "Write all content in German (Deutsch).",
|
||||
"en": "Write all content in English.",
|
||||
"es": "Write all content in Spanish (Español).",
|
||||
"fr": "Write all content in French (Français).",
|
||||
"pt": "Write all content in Portuguese (Português).",
|
||||
"ar": "Write all content in Arabic (العربية).",
|
||||
}
|
||||
|
||||
system_prompt = f"""You are an expert SEO content writer for Odoo partner directories.
|
||||
{lang_instructions.get(language, lang_instructions["en"])}
|
||||
|
||||
Create engaging, informative content about an Odoo partner company.
|
||||
The content should be at least 800 words and SEO-optimized.
|
||||
|
||||
Respond with valid JSON only:
|
||||
{{
|
||||
"description": "2-3 sentence company description",
|
||||
"content_html": "Full HTML content with headings, paragraphs, lists",
|
||||
"meta_title": "SEO title (50-60 chars)",
|
||||
"meta_description": "SEO meta description (150-160 chars)"
|
||||
}}
|
||||
|
||||
Include:
|
||||
- Company overview
|
||||
- Services offered
|
||||
- Industries served
|
||||
- Odoo modules expertise
|
||||
- Why choose this partner
|
||||
- Location information
|
||||
|
||||
Use semantic HTML: <h2>, <h3>, <p>, <ul>, <li>, <strong>"""
|
||||
|
||||
tags_info = f"""
|
||||
Services: {", ".join(services) if services else "Various Odoo services"}
|
||||
Industries: {", ".join(industries) if industries else "Multiple industries"}
|
||||
Modules: {", ".join(modules) if modules else "Various Odoo modules"}
|
||||
Partner Level: {partner_level or "Odoo Partner"}"""
|
||||
|
||||
user_prompt = f"""Create content for:
|
||||
|
||||
Company: {company_name}
|
||||
Location: {city_name}, {country_name}
|
||||
{tags_info}
|
||||
|
||||
Website information:
|
||||
{website_markdown[:6000] if website_markdown else "Limited information available"}
|
||||
|
||||
Generate comprehensive SEO content for this Odoo partner's directory page."""
|
||||
|
||||
try:
|
||||
response = self._call_haiku(system_prompt, user_prompt, max_tokens=4096)
|
||||
data = json.loads(response)
|
||||
return HaikuContentResponse(**data)
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to parse content response: {e}")
|
||||
# Return fallback content
|
||||
return self._generate_fallback_content(
|
||||
company_name, city_name, country_name, services, language
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Content generation failed: {e}")
|
||||
return self._generate_fallback_content(
|
||||
company_name, city_name, country_name, services, language
|
||||
)
|
||||
|
||||
def _generate_fallback_content(
|
||||
self,
|
||||
company_name: str,
|
||||
city_name: str,
|
||||
country_name: str,
|
||||
services: List[str],
|
||||
language: str,
|
||||
) -> HaikuContentResponse:
|
||||
"""Generate basic fallback content when AI generation fails."""
|
||||
services_text = ", ".join(services) if services else "Odoo implementation"
|
||||
|
||||
if language == "de":
|
||||
description = f"{company_name} ist ein Odoo Partner in {city_name}, {country_name}."
|
||||
content = f"""<h2>Über {company_name}</h2>
|
||||
<p>{company_name} ist ein zertifizierter Odoo Partner mit Sitz in {city_name}.
|
||||
Das Unternehmen bietet professionelle Odoo-Dienstleistungen für Unternehmen in der Region.</p>
|
||||
|
||||
<h3>Dienstleistungen</h3>
|
||||
<p>Als Odoo Partner bietet {company_name} folgende Leistungen: {services_text}.</p>
|
||||
|
||||
<h3>Standort</h3>
|
||||
<p>{company_name} befindet sich in {city_name}, {country_name} und betreut Kunden in der gesamten Region.</p>"""
|
||||
meta_title = f"{company_name} - Odoo Partner in {city_name}"
|
||||
meta_desc = f"{company_name} ist Ihr Odoo Partner in {city_name}. Professionelle ERP-Beratung und Implementierung."
|
||||
else:
|
||||
description = f"{company_name} is an Odoo partner located in {city_name}, {country_name}."
|
||||
content = f"""<h2>About {company_name}</h2>
|
||||
<p>{company_name} is a certified Odoo partner based in {city_name}.
|
||||
The company provides professional Odoo services for businesses in the region.</p>
|
||||
|
||||
<h3>Services</h3>
|
||||
<p>As an Odoo partner, {company_name} offers: {services_text}.</p>
|
||||
|
||||
<h3>Location</h3>
|
||||
<p>{company_name} is located in {city_name}, {country_name} and serves clients throughout the region.</p>"""
|
||||
meta_title = f"{company_name} - Odoo Partner in {city_name}"
|
||||
meta_desc = f"{company_name} is your Odoo partner in {city_name}. Professional ERP consulting and implementation."
|
||||
|
||||
return HaikuContentResponse(
|
||||
description=description,
|
||||
content_html=content,
|
||||
meta_title=meta_title[:60],
|
||||
meta_description=meta_desc[:160],
|
||||
)
|
||||
|
||||
def generate_city_hub_content(
|
||||
self,
|
||||
city_name: str,
|
||||
country_name: str,
|
||||
company_count: int,
|
||||
top_services: List[str],
|
||||
language: str = "de",
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Generate city hub page content.
|
||||
|
||||
Args:
|
||||
city_name: City name
|
||||
country_name: Country name
|
||||
company_count: Number of companies in city
|
||||
top_services: Most common services
|
||||
language: Output language
|
||||
|
||||
Returns:
|
||||
Dictionary with content sections
|
||||
"""
|
||||
lang_instructions = {
|
||||
"de": "Write in German.",
|
||||
"en": "Write in English.",
|
||||
}
|
||||
|
||||
system_prompt = f"""You are an SEO expert writing city hub pages for an Odoo partner directory.
|
||||
{lang_instructions.get(language, "Write in English.")}
|
||||
|
||||
Create compelling content (1000+ words) about finding Odoo partners in this city.
|
||||
|
||||
Return valid JSON:
|
||||
{{
|
||||
"intro": "Introduction paragraph about Odoo partners in this city",
|
||||
"content_html": "Full HTML content about Odoo services in this city",
|
||||
"meta_title": "SEO title (50-60 chars)",
|
||||
"meta_description": "Meta description (150-160 chars)"
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""City: {city_name}, {country_name}
|
||||
Number of Odoo Partners: {company_count}
|
||||
Top Services: {", ".join(top_services)}
|
||||
|
||||
Create engaging city hub content for this Odoo partner directory."""
|
||||
|
||||
try:
|
||||
response = self._call_haiku(system_prompt, user_prompt)
|
||||
return json.loads(response)
|
||||
except Exception as e:
|
||||
logger.error(f"City hub content generation failed: {e}")
|
||||
return {
|
||||
"intro": f"Find Odoo partners in {city_name}.",
|
||||
"content_html": f"<p>Discover {company_count} Odoo partners in {city_name}, {country_name}.</p>",
|
||||
"meta_title": f"Odoo Partners in {city_name}",
|
||||
"meta_description": f"Find {company_count} certified Odoo partners in {city_name}, {country_name}.",
|
||||
}
|
||||
|
||||
def generate_country_hub_content(
|
||||
self,
|
||||
country_name: str,
|
||||
city_count: int,
|
||||
company_count: int,
|
||||
top_cities: List[str],
|
||||
language: str = "de",
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
Generate country hub page content.
|
||||
|
||||
Args:
|
||||
country_name: Country name
|
||||
city_count: Number of cities with partners
|
||||
company_count: Total companies in country
|
||||
top_cities: Cities with most partners
|
||||
language: Output language
|
||||
|
||||
Returns:
|
||||
Dictionary with content sections
|
||||
"""
|
||||
lang_instructions = {
|
||||
"de": "Write in German.",
|
||||
"en": "Write in English.",
|
||||
}
|
||||
|
||||
system_prompt = f"""You are an SEO expert writing country hub pages for an Odoo partner directory.
|
||||
{lang_instructions.get(language, "Write in English.")}
|
||||
|
||||
Create comprehensive content (1200+ words) about Odoo partners in this country.
|
||||
|
||||
Return valid JSON:
|
||||
{{
|
||||
"intro": "Introduction about Odoo partners in this country",
|
||||
"content_html": "Full HTML content about the Odoo ecosystem in this country",
|
||||
"meta_title": "SEO title (50-60 chars)",
|
||||
"meta_description": "Meta description (150-160 chars)"
|
||||
}}"""
|
||||
|
||||
user_prompt = f"""Country: {country_name}
|
||||
Number of Cities: {city_count}
|
||||
Total Odoo Partners: {company_count}
|
||||
Top Cities: {", ".join(top_cities)}
|
||||
|
||||
Create engaging country hub content for this Odoo partner directory."""
|
||||
|
||||
try:
|
||||
response = self._call_haiku(system_prompt, user_prompt)
|
||||
return json.loads(response)
|
||||
except Exception as e:
|
||||
logger.error(f"Country hub content generation failed: {e}")
|
||||
return {
|
||||
"intro": f"Find Odoo partners across {country_name}.",
|
||||
"content_html": f"<p>Discover {company_count} Odoo partners in {city_count} cities across {country_name}.</p>",
|
||||
"meta_title": f"Odoo Partners in {country_name}",
|
||||
"meta_description": f"Find {company_count} certified Odoo partners in {city_count} cities across {country_name}.",
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
Image processing service for company logos and images.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
from urllib.parse import urlparse
|
||||
import hashlib
|
||||
|
||||
import httpx
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
from ..config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageService:
|
||||
"""Service for downloading and processing images."""
|
||||
|
||||
# Standard sizes for company images
|
||||
SIZES = {
|
||||
"thumbnail": (150, 150),
|
||||
"medium": (300, 300),
|
||||
"large": (600, 600),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize image service."""
|
||||
settings = get_settings()
|
||||
self.images_dir = settings.images_dir
|
||||
self.images_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _get_image_hash(self, url: str) -> str:
|
||||
"""Generate a short hash for an image URL."""
|
||||
return hashlib.md5(url.encode()).hexdigest()[:12]
|
||||
|
||||
def _get_image_path(
|
||||
self, company_slug: str, image_type: str = "logo", size: str = "medium"
|
||||
) -> Path:
|
||||
"""
|
||||
Get the path for storing a company image.
|
||||
|
||||
Args:
|
||||
company_slug: Company slug for filename
|
||||
image_type: Type of image (logo, photo)
|
||||
size: Size variant (thumbnail, medium, large)
|
||||
|
||||
Returns:
|
||||
Path to image file
|
||||
"""
|
||||
filename = f"{company_slug}_{image_type}_{size}.webp"
|
||||
return self.images_dir / filename
|
||||
|
||||
def download_image(self, url: str) -> Optional[bytes]:
|
||||
"""
|
||||
Download an image from URL.
|
||||
|
||||
Args:
|
||||
url: Image URL
|
||||
|
||||
Returns:
|
||||
Image bytes or None if download failed
|
||||
"""
|
||||
try:
|
||||
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
||||
response = client.get(url)
|
||||
response.raise_for_status()
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if not content_type.startswith("image/"):
|
||||
logger.warning(f"URL is not an image: {url}")
|
||||
return None
|
||||
|
||||
return response.content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to download image from {url}: {e}")
|
||||
return None
|
||||
|
||||
def process_image(
|
||||
self,
|
||||
image_data: bytes,
|
||||
size: Tuple[int, int],
|
||||
quality: int = 85,
|
||||
) -> Optional[bytes]:
|
||||
"""
|
||||
Process and convert an image to WebP format.
|
||||
|
||||
Args:
|
||||
image_data: Raw image bytes
|
||||
size: Target size (width, height)
|
||||
quality: WebP quality (1-100)
|
||||
|
||||
Returns:
|
||||
Processed image bytes or None if processing failed
|
||||
"""
|
||||
try:
|
||||
# Open image
|
||||
img = Image.open(io.BytesIO(image_data))
|
||||
|
||||
# Convert to RGB if necessary (for WebP)
|
||||
if img.mode in ("RGBA", "P"):
|
||||
# Create white background for transparency
|
||||
background = Image.new("RGB", img.size, (255, 255, 255))
|
||||
if img.mode == "P":
|
||||
img = img.convert("RGBA")
|
||||
background.paste(img, mask=img.split()[-1])
|
||||
img = background
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
|
||||
# Resize maintaining aspect ratio
|
||||
img.thumbnail(size, Image.Resampling.LANCZOS)
|
||||
|
||||
# Save to WebP
|
||||
output = io.BytesIO()
|
||||
img.save(output, format="WEBP", quality=quality, method=6)
|
||||
return output.getvalue()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process image: {e}")
|
||||
return None
|
||||
|
||||
def save_company_logo(
|
||||
self,
|
||||
company_slug: str,
|
||||
logo_url: str,
|
||||
sizes: Optional[list] = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Download and save company logo in multiple sizes.
|
||||
|
||||
Args:
|
||||
company_slug: Company slug for filename
|
||||
logo_url: URL of the logo image
|
||||
sizes: List of size names to generate (default: all)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping size names to file paths
|
||||
"""
|
||||
if sizes is None:
|
||||
sizes = list(self.SIZES.keys())
|
||||
|
||||
saved_paths = {}
|
||||
|
||||
# Download original
|
||||
image_data = self.download_image(logo_url)
|
||||
if not image_data:
|
||||
return saved_paths
|
||||
|
||||
# Process and save each size
|
||||
for size_name in sizes:
|
||||
if size_name not in self.SIZES:
|
||||
continue
|
||||
|
||||
size = self.SIZES[size_name]
|
||||
processed = self.process_image(image_data, size)
|
||||
|
||||
if processed:
|
||||
path = self._get_image_path(company_slug, "logo", size_name)
|
||||
path.write_bytes(processed)
|
||||
saved_paths[size_name] = str(path)
|
||||
logger.debug(f"Saved {size_name} logo: {path}")
|
||||
|
||||
return saved_paths
|
||||
|
||||
def get_logo_urls(self, company_slug: str, base_url: str = "") -> dict:
|
||||
"""
|
||||
Get URLs for company logo images.
|
||||
|
||||
Args:
|
||||
company_slug: Company slug
|
||||
base_url: Base URL for the images (e.g., /static/images/companies/)
|
||||
|
||||
Returns:
|
||||
Dictionary mapping size names to URLs
|
||||
"""
|
||||
urls = {}
|
||||
for size_name in self.SIZES:
|
||||
path = self._get_image_path(company_slug, "logo", size_name)
|
||||
if path.exists():
|
||||
relative_path = path.relative_to(self.images_dir)
|
||||
urls[size_name] = f"{base_url}{relative_path}"
|
||||
return urls
|
||||
|
||||
def cleanup_company_images(self, company_slug: str) -> int:
|
||||
"""
|
||||
Remove all images for a company.
|
||||
|
||||
Args:
|
||||
company_slug: Company slug
|
||||
|
||||
Returns:
|
||||
Number of files removed
|
||||
"""
|
||||
removed = 0
|
||||
for file in self.images_dir.glob(f"{company_slug}_*"):
|
||||
file.unlink()
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
def get_storage_stats(self) -> dict:
|
||||
"""
|
||||
Get storage statistics for the images directory.
|
||||
|
||||
Returns:
|
||||
Dictionary with stats
|
||||
"""
|
||||
total_size = 0
|
||||
file_count = 0
|
||||
|
||||
for file in self.images_dir.glob("*.webp"):
|
||||
total_size += file.stat().st_size
|
||||
file_count += 1
|
||||
|
||||
return {
|
||||
"file_count": file_count,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
"directory": str(self.images_dir),
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
"""
|
||||
Supabase database service for Odoo Directory.
|
||||
Handles all database operations.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Optional, List, Dict, Any
|
||||
from uuid import UUID
|
||||
|
||||
from supabase import create_client, Client
|
||||
|
||||
from ..config import get_settings
|
||||
from ..models.schemas import (
|
||||
Region,
|
||||
Country,
|
||||
City,
|
||||
Tag,
|
||||
Company,
|
||||
CompanyTag,
|
||||
CityTag,
|
||||
CountryTag,
|
||||
ExecutionPlan,
|
||||
PlanStep,
|
||||
TagCategory,
|
||||
StepStatus,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SupabaseService:
|
||||
"""Service for Supabase database operations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize Supabase client."""
|
||||
settings = get_settings()
|
||||
self.client: Client = create_client(
|
||||
settings.supabase_url, settings.supabase_key
|
||||
)
|
||||
|
||||
# ==================== Region Operations ====================
|
||||
|
||||
def create_region(self, region: Region) -> Region:
|
||||
"""Create a new region."""
|
||||
data = {"name": region.name, "slug": region.slug}
|
||||
result = self.client.table("regions").insert(data).execute()
|
||||
return Region(**result.data[0])
|
||||
|
||||
def get_region_by_slug(self, slug: str) -> Optional[Region]:
|
||||
"""Get region by slug."""
|
||||
result = (
|
||||
self.client.table("regions").select("*").eq("slug", slug).execute()
|
||||
)
|
||||
if result.data:
|
||||
return Region(**result.data[0])
|
||||
return None
|
||||
|
||||
def list_regions(self) -> List[Region]:
|
||||
"""List all regions."""
|
||||
result = self.client.table("regions").select("*").execute()
|
||||
return [Region(**r) for r in result.data]
|
||||
|
||||
# ==================== Country Operations ====================
|
||||
|
||||
def create_country(self, country: Country) -> Country:
|
||||
"""Create a new country."""
|
||||
data = {
|
||||
"region_id": str(country.region_id) if country.region_id else None,
|
||||
"name": country.name,
|
||||
"name_local": country.name_local,
|
||||
"code": country.code,
|
||||
"slug": country.slug,
|
||||
"language": country.language,
|
||||
}
|
||||
result = self.client.table("countries").insert(data).execute()
|
||||
return Country(**result.data[0])
|
||||
|
||||
def get_country_by_code(self, code: str) -> Optional[Country]:
|
||||
"""Get country by ISO code."""
|
||||
result = (
|
||||
self.client.table("countries")
|
||||
.select("*")
|
||||
.eq("code", code.upper())
|
||||
.execute()
|
||||
)
|
||||
if result.data:
|
||||
return Country(**result.data[0])
|
||||
return None
|
||||
|
||||
def get_country_by_slug(self, slug: str) -> Optional[Country]:
|
||||
"""Get country by slug."""
|
||||
result = (
|
||||
self.client.table("countries").select("*").eq("slug", slug).execute()
|
||||
)
|
||||
if result.data:
|
||||
return Country(**result.data[0])
|
||||
return None
|
||||
|
||||
def list_countries(self, region_id: Optional[UUID] = None) -> List[Country]:
|
||||
"""List countries, optionally filtered by region."""
|
||||
query = self.client.table("countries").select("*")
|
||||
if region_id:
|
||||
query = query.eq("region_id", str(region_id))
|
||||
result = query.execute()
|
||||
return [Country(**c) for c in result.data]
|
||||
|
||||
# ==================== City Operations ====================
|
||||
|
||||
def create_city(self, city: City) -> City:
|
||||
"""Create a new city."""
|
||||
data = {
|
||||
"country_id": str(city.country_id) if city.country_id else None,
|
||||
"name": city.name,
|
||||
"name_local": city.name_local,
|
||||
"slug": city.slug,
|
||||
"state": city.state,
|
||||
"population": city.population,
|
||||
"latitude": city.latitude,
|
||||
"longitude": city.longitude,
|
||||
}
|
||||
result = self.client.table("cities").insert(data).execute()
|
||||
return City(**result.data[0])
|
||||
|
||||
def get_city_by_slug(
|
||||
self, slug: str, country_id: Optional[UUID] = None
|
||||
) -> Optional[City]:
|
||||
"""Get city by slug, optionally filtered by country."""
|
||||
query = self.client.table("cities").select("*").eq("slug", slug)
|
||||
if country_id:
|
||||
query = query.eq("country_id", str(country_id))
|
||||
result = query.execute()
|
||||
if result.data:
|
||||
return City(**result.data[0])
|
||||
return None
|
||||
|
||||
def list_cities(self, country_id: Optional[UUID] = None) -> List[City]:
|
||||
"""List cities, optionally filtered by country."""
|
||||
query = self.client.table("cities").select("*")
|
||||
if country_id:
|
||||
query = query.eq("country_id", str(country_id))
|
||||
result = query.execute()
|
||||
return [City(**c) for c in result.data]
|
||||
|
||||
# ==================== Tag Operations ====================
|
||||
|
||||
def create_tag(self, tag: Tag) -> Tag:
|
||||
"""Create a new tag."""
|
||||
data = {
|
||||
"category": tag.category.value,
|
||||
"name": tag.name,
|
||||
"slug": tag.slug,
|
||||
"description": tag.description,
|
||||
}
|
||||
result = self.client.table("tags").insert(data).execute()
|
||||
return Tag(**result.data[0])
|
||||
|
||||
def get_tag_by_slug(self, slug: str) -> Optional[Tag]:
|
||||
"""Get tag by slug."""
|
||||
result = self.client.table("tags").select("*").eq("slug", slug).execute()
|
||||
if result.data:
|
||||
return Tag(**result.data[0])
|
||||
return None
|
||||
|
||||
def list_tags(self, category: Optional[TagCategory] = None) -> List[Tag]:
|
||||
"""List tags, optionally filtered by category."""
|
||||
query = self.client.table("tags").select("*")
|
||||
if category:
|
||||
query = query.eq("category", category.value)
|
||||
result = query.execute()
|
||||
return [Tag(**t) for t in result.data]
|
||||
|
||||
def get_or_create_tag(
|
||||
self, name: str, category: TagCategory, description: Optional[str] = None
|
||||
) -> Tag:
|
||||
"""Get existing tag or create new one."""
|
||||
slug = name.lower().replace(" ", "-").replace("&", "and")
|
||||
existing = self.get_tag_by_slug(slug)
|
||||
if existing:
|
||||
return existing
|
||||
return self.create_tag(
|
||||
Tag(category=category, name=name, slug=slug, description=description)
|
||||
)
|
||||
|
||||
# ==================== Company Operations ====================
|
||||
|
||||
def create_company(self, company: Company) -> Company:
|
||||
"""Create a new company."""
|
||||
data = {
|
||||
"city_id": str(company.city_id) if company.city_id else None,
|
||||
"name": company.name,
|
||||
"slug": company.slug,
|
||||
"google_place_id": company.google_place_id,
|
||||
"address": company.address,
|
||||
"phone": company.phone,
|
||||
"website": company.website,
|
||||
"email": company.email,
|
||||
"rating": company.rating,
|
||||
"review_count": company.review_count,
|
||||
"latitude": company.latitude,
|
||||
"longitude": company.longitude,
|
||||
"status": company.status,
|
||||
}
|
||||
result = self.client.table("companies").insert(data).execute()
|
||||
return Company(**result.data[0])
|
||||
|
||||
def update_company(self, company_id: UUID, updates: Dict[str, Any]) -> Company:
|
||||
"""Update a company."""
|
||||
result = (
|
||||
self.client.table("companies")
|
||||
.update(updates)
|
||||
.eq("id", str(company_id))
|
||||
.execute()
|
||||
)
|
||||
return Company(**result.data[0])
|
||||
|
||||
def get_company_by_id(self, company_id: UUID) -> Optional[Company]:
|
||||
"""Get company by ID."""
|
||||
result = (
|
||||
self.client.table("companies")
|
||||
.select("*")
|
||||
.eq("id", str(company_id))
|
||||
.execute()
|
||||
)
|
||||
if result.data:
|
||||
return Company(**result.data[0])
|
||||
return None
|
||||
|
||||
def get_company_by_place_id(self, place_id: str) -> Optional[Company]:
|
||||
"""Get company by Google Place ID (for deduplication)."""
|
||||
result = (
|
||||
self.client.table("companies")
|
||||
.select("*")
|
||||
.eq("google_place_id", place_id)
|
||||
.execute()
|
||||
)
|
||||
if result.data:
|
||||
return Company(**result.data[0])
|
||||
return None
|
||||
|
||||
def list_companies(
|
||||
self,
|
||||
city_id: Optional[UUID] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> List[Company]:
|
||||
"""List companies with optional filters."""
|
||||
query = self.client.table("companies").select("*")
|
||||
if city_id:
|
||||
query = query.eq("city_id", str(city_id))
|
||||
if status:
|
||||
query = query.eq("status", status)
|
||||
query = query.range(offset, offset + limit - 1)
|
||||
result = query.execute()
|
||||
return [Company(**c) for c in result.data]
|
||||
|
||||
def count_companies(
|
||||
self, city_id: Optional[UUID] = None, status: Optional[str] = None
|
||||
) -> int:
|
||||
"""Count companies with optional filters."""
|
||||
query = self.client.table("companies").select("id", count="exact")
|
||||
if city_id:
|
||||
query = query.eq("city_id", str(city_id))
|
||||
if status:
|
||||
query = query.eq("status", status)
|
||||
result = query.execute()
|
||||
return result.count or 0
|
||||
|
||||
# ==================== Company Tag Operations ====================
|
||||
|
||||
def add_company_tag(
|
||||
self, company_id: UUID, tag_id: UUID, confidence: float = 1.0
|
||||
) -> CompanyTag:
|
||||
"""Add a tag to a company."""
|
||||
data = {
|
||||
"company_id": str(company_id),
|
||||
"tag_id": str(tag_id),
|
||||
"confidence": confidence,
|
||||
}
|
||||
result = self.client.table("company_tags").insert(data).execute()
|
||||
return CompanyTag(**result.data[0])
|
||||
|
||||
def get_company_tags(self, company_id: UUID) -> List[Tag]:
|
||||
"""Get all tags for a company."""
|
||||
result = (
|
||||
self.client.table("company_tags")
|
||||
.select("tag_id, tags(*)")
|
||||
.eq("company_id", str(company_id))
|
||||
.execute()
|
||||
)
|
||||
tags = []
|
||||
for item in result.data:
|
||||
if item.get("tags"):
|
||||
tags.append(Tag(**item["tags"]))
|
||||
return tags
|
||||
|
||||
def remove_company_tags(self, company_id: UUID) -> None:
|
||||
"""Remove all tags from a company."""
|
||||
self.client.table("company_tags").delete().eq(
|
||||
"company_id", str(company_id)
|
||||
).execute()
|
||||
|
||||
# ==================== City Tag Aggregation ====================
|
||||
|
||||
def upsert_city_tag(self, city_id: UUID, tag_id: UUID, count: int) -> CityTag:
|
||||
"""Upsert city tag aggregation."""
|
||||
data = {
|
||||
"city_id": str(city_id),
|
||||
"tag_id": str(tag_id),
|
||||
"company_count": count,
|
||||
}
|
||||
result = (
|
||||
self.client.table("city_tags")
|
||||
.upsert(data, on_conflict="city_id,tag_id")
|
||||
.execute()
|
||||
)
|
||||
return CityTag(**result.data[0])
|
||||
|
||||
def get_city_tags(self, city_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Get all tag aggregations for a city with tag details."""
|
||||
result = (
|
||||
self.client.table("city_tags")
|
||||
.select("*, tags(*)")
|
||||
.eq("city_id", str(city_id))
|
||||
.execute()
|
||||
)
|
||||
return result.data
|
||||
|
||||
# ==================== Country Tag Aggregation ====================
|
||||
|
||||
def upsert_country_tag(
|
||||
self, country_id: UUID, tag_id: UUID, company_count: int, city_count: int
|
||||
) -> CountryTag:
|
||||
"""Upsert country tag aggregation."""
|
||||
data = {
|
||||
"country_id": str(country_id),
|
||||
"tag_id": str(tag_id),
|
||||
"company_count": company_count,
|
||||
"city_count": city_count,
|
||||
}
|
||||
result = (
|
||||
self.client.table("country_tags")
|
||||
.upsert(data, on_conflict="country_id,tag_id")
|
||||
.execute()
|
||||
)
|
||||
return CountryTag(**result.data[0])
|
||||
|
||||
def get_country_tags(self, country_id: UUID) -> List[Dict[str, Any]]:
|
||||
"""Get all tag aggregations for a country with tag details."""
|
||||
result = (
|
||||
self.client.table("country_tags")
|
||||
.select("*, tags(*)")
|
||||
.eq("country_id", str(country_id))
|
||||
.execute()
|
||||
)
|
||||
return result.data
|
||||
|
||||
# ==================== Execution Plan Operations ====================
|
||||
|
||||
def create_execution_plan(self, plan: ExecutionPlan) -> ExecutionPlan:
|
||||
"""Create a new execution plan."""
|
||||
data = {
|
||||
"name": plan.name,
|
||||
"country_code": plan.country_code,
|
||||
"cities": plan.cities,
|
||||
"status": plan.status.value,
|
||||
}
|
||||
result = self.client.table("execution_plans").insert(data).execute()
|
||||
return ExecutionPlan(**result.data[0])
|
||||
|
||||
def update_execution_plan(
|
||||
self, plan_id: UUID, updates: Dict[str, Any]
|
||||
) -> ExecutionPlan:
|
||||
"""Update an execution plan."""
|
||||
result = (
|
||||
self.client.table("execution_plans")
|
||||
.update(updates)
|
||||
.eq("id", str(plan_id))
|
||||
.execute()
|
||||
)
|
||||
return ExecutionPlan(**result.data[0])
|
||||
|
||||
def get_execution_plan(self, plan_id: UUID) -> Optional[ExecutionPlan]:
|
||||
"""Get execution plan by ID."""
|
||||
result = (
|
||||
self.client.table("execution_plans")
|
||||
.select("*")
|
||||
.eq("id", str(plan_id))
|
||||
.execute()
|
||||
)
|
||||
if result.data:
|
||||
plan = ExecutionPlan(**result.data[0])
|
||||
# Load steps
|
||||
steps_result = (
|
||||
self.client.table("plan_steps")
|
||||
.select("*")
|
||||
.eq("plan_id", str(plan_id))
|
||||
.execute()
|
||||
)
|
||||
plan.steps = [PlanStep(**s) for s in steps_result.data]
|
||||
return plan
|
||||
return None
|
||||
|
||||
def list_execution_plans(
|
||||
self, status: Optional[StepStatus] = None
|
||||
) -> List[ExecutionPlan]:
|
||||
"""List execution plans."""
|
||||
query = self.client.table("execution_plans").select("*")
|
||||
if status:
|
||||
query = query.eq("status", status.value)
|
||||
result = query.execute()
|
||||
return [ExecutionPlan(**p) for p in result.data]
|
||||
|
||||
# ==================== Plan Step Operations ====================
|
||||
|
||||
def create_plan_step(self, step: PlanStep) -> PlanStep:
|
||||
"""Create a new plan step."""
|
||||
data = {
|
||||
"plan_id": str(step.plan_id) if step.plan_id else None,
|
||||
"step_name": step.step_name,
|
||||
"city_id": str(step.city_id) if step.city_id else None,
|
||||
"status": step.status.value,
|
||||
"items_processed": step.items_processed,
|
||||
"items_total": step.items_total,
|
||||
}
|
||||
result = self.client.table("plan_steps").insert(data).execute()
|
||||
return PlanStep(**result.data[0])
|
||||
|
||||
def update_plan_step(self, step_id: UUID, updates: Dict[str, Any]) -> PlanStep:
|
||||
"""Update a plan step."""
|
||||
result = (
|
||||
self.client.table("plan_steps")
|
||||
.update(updates)
|
||||
.eq("id", str(step_id))
|
||||
.execute()
|
||||
)
|
||||
return PlanStep(**result.data[0])
|
||||
|
||||
def get_plan_steps(
|
||||
self, plan_id: UUID, step_name: Optional[str] = None
|
||||
) -> List[PlanStep]:
|
||||
"""Get steps for a plan."""
|
||||
query = (
|
||||
self.client.table("plan_steps").select("*").eq("plan_id", str(plan_id))
|
||||
)
|
||||
if step_name:
|
||||
query = query.eq("step_name", step_name)
|
||||
result = query.execute()
|
||||
return [PlanStep(**s) for s in result.data]
|
||||
|
||||
# ==================== Error Logging ====================
|
||||
|
||||
def log_error(
|
||||
self,
|
||||
entity_type: str,
|
||||
entity_id: Optional[UUID],
|
||||
operation: str,
|
||||
error_message: str,
|
||||
details: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Log an error to the database."""
|
||||
data = {
|
||||
"entity_type": entity_type,
|
||||
"entity_id": str(entity_id) if entity_id else None,
|
||||
"operation": operation,
|
||||
"error_message": error_message,
|
||||
"details": details or {},
|
||||
}
|
||||
try:
|
||||
self.client.table("error_logs").insert(data).execute()
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to log error to database: {e}")
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Tag aggregation service for cities and countries.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Any
|
||||
from uuid import UUID
|
||||
from collections import defaultdict
|
||||
|
||||
from .supabase import SupabaseService
|
||||
from ..models.schemas import TagCategory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TagAggregationService:
|
||||
"""Service for aggregating tags across cities and countries."""
|
||||
|
||||
def __init__(self, db: SupabaseService = None):
|
||||
"""
|
||||
Initialize tag aggregation service.
|
||||
|
||||
Args:
|
||||
db: Supabase service instance (creates new one if not provided)
|
||||
"""
|
||||
self.db = db or SupabaseService()
|
||||
|
||||
def aggregate_city_tags(self, city_id: UUID) -> Dict[str, int]:
|
||||
"""
|
||||
Aggregate all company tags for a city.
|
||||
|
||||
Counts how many companies have each tag and stores in city_tags table.
|
||||
|
||||
Args:
|
||||
city_id: City UUID
|
||||
|
||||
Returns:
|
||||
Dictionary mapping tag slugs to company counts
|
||||
"""
|
||||
logger.info(f"Aggregating tags for city {city_id}")
|
||||
|
||||
# Get all companies in the city
|
||||
companies = self.db.list_companies(city_id=city_id, limit=1000)
|
||||
|
||||
# Count tags across all companies
|
||||
tag_counts = defaultdict(int)
|
||||
tag_id_map = {}
|
||||
|
||||
for company in companies:
|
||||
company_tags = self.db.get_company_tags(company.id)
|
||||
for tag in company_tags:
|
||||
tag_counts[tag.slug] += 1
|
||||
tag_id_map[tag.slug] = tag.id
|
||||
|
||||
# Update city_tags table
|
||||
for slug, count in tag_counts.items():
|
||||
tag_id = tag_id_map[slug]
|
||||
self.db.upsert_city_tag(city_id, tag_id, count)
|
||||
|
||||
logger.info(f"Aggregated {len(tag_counts)} tags for city {city_id}")
|
||||
return dict(tag_counts)
|
||||
|
||||
def aggregate_country_tags(self, country_id: UUID) -> Dict[str, Dict[str, int]]:
|
||||
"""
|
||||
Aggregate all city tags for a country.
|
||||
|
||||
Rolls up city-level tag counts to country level.
|
||||
|
||||
Args:
|
||||
country_id: Country UUID
|
||||
|
||||
Returns:
|
||||
Dictionary mapping tag slugs to {company_count, city_count}
|
||||
"""
|
||||
logger.info(f"Aggregating tags for country {country_id}")
|
||||
|
||||
# Get all cities in the country
|
||||
cities = self.db.list_cities(country_id=country_id)
|
||||
|
||||
# Aggregate across all cities
|
||||
tag_stats = defaultdict(lambda: {"company_count": 0, "city_count": 0})
|
||||
tag_id_map = {}
|
||||
|
||||
for city in cities:
|
||||
city_tags = self.db.get_city_tags(city.id)
|
||||
|
||||
for ct in city_tags:
|
||||
tag_data = ct.get("tags", {})
|
||||
tag_slug = tag_data.get("slug")
|
||||
tag_id = tag_data.get("id")
|
||||
|
||||
if tag_slug and tag_id:
|
||||
company_count = ct.get("company_count", 0)
|
||||
tag_stats[tag_slug]["company_count"] += company_count
|
||||
tag_stats[tag_slug]["city_count"] += 1
|
||||
tag_id_map[tag_slug] = tag_id
|
||||
|
||||
# Update country_tags table
|
||||
for slug, stats in tag_stats.items():
|
||||
tag_id = tag_id_map[slug]
|
||||
self.db.upsert_country_tag(
|
||||
country_id, tag_id, stats["company_count"], stats["city_count"]
|
||||
)
|
||||
|
||||
logger.info(f"Aggregated {len(tag_stats)} tags for country {country_id}")
|
||||
return dict(tag_stats)
|
||||
|
||||
def get_city_tags_by_category(
|
||||
self, city_id: UUID
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get city tags organized by category.
|
||||
|
||||
Args:
|
||||
city_id: City UUID
|
||||
|
||||
Returns:
|
||||
Dictionary with categories as keys and lists of tag info
|
||||
"""
|
||||
city_tags = self.db.get_city_tags(city_id)
|
||||
|
||||
result = {
|
||||
"services": [],
|
||||
"industries": [],
|
||||
"modules": [],
|
||||
"partner_levels": [],
|
||||
}
|
||||
|
||||
for ct in city_tags:
|
||||
tag_data = ct.get("tags", {})
|
||||
category = tag_data.get("category")
|
||||
company_count = ct.get("company_count", 0)
|
||||
|
||||
tag_info = {
|
||||
"name": tag_data.get("name"),
|
||||
"slug": tag_data.get("slug"),
|
||||
"count": company_count,
|
||||
}
|
||||
|
||||
if category == "service":
|
||||
result["services"].append(tag_info)
|
||||
elif category == "industry":
|
||||
result["industries"].append(tag_info)
|
||||
elif category == "module":
|
||||
result["modules"].append(tag_info)
|
||||
elif category == "partner_level":
|
||||
result["partner_levels"].append(tag_info)
|
||||
|
||||
# Sort each category by count (descending)
|
||||
for key in result:
|
||||
result[key].sort(key=lambda x: x["count"], reverse=True)
|
||||
|
||||
return result
|
||||
|
||||
def get_country_tags_by_category(
|
||||
self, country_id: UUID
|
||||
) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""
|
||||
Get country tags organized by category.
|
||||
|
||||
Args:
|
||||
country_id: Country UUID
|
||||
|
||||
Returns:
|
||||
Dictionary with categories as keys and lists of tag info
|
||||
"""
|
||||
country_tags = self.db.get_country_tags(country_id)
|
||||
|
||||
result = {
|
||||
"services": [],
|
||||
"industries": [],
|
||||
"modules": [],
|
||||
"partner_levels": [],
|
||||
}
|
||||
|
||||
for ct in country_tags:
|
||||
tag_data = ct.get("tags", {})
|
||||
category = tag_data.get("category")
|
||||
|
||||
tag_info = {
|
||||
"name": tag_data.get("name"),
|
||||
"slug": tag_data.get("slug"),
|
||||
"company_count": ct.get("company_count", 0),
|
||||
"city_count": ct.get("city_count", 0),
|
||||
}
|
||||
|
||||
if category == "service":
|
||||
result["services"].append(tag_info)
|
||||
elif category == "industry":
|
||||
result["industries"].append(tag_info)
|
||||
elif category == "module":
|
||||
result["modules"].append(tag_info)
|
||||
elif category == "partner_level":
|
||||
result["partner_levels"].append(tag_info)
|
||||
|
||||
# Sort each category by company count (descending)
|
||||
for key in result:
|
||||
result[key].sort(key=lambda x: x["company_count"], reverse=True)
|
||||
|
||||
return result
|
||||
|
||||
def export_city_tags_json(self, city_id: UUID) -> Dict[str, Any]:
|
||||
"""
|
||||
Export city tags as JSON for frontend filtering.
|
||||
|
||||
Args:
|
||||
city_id: City UUID
|
||||
|
||||
Returns:
|
||||
JSON-serializable dictionary
|
||||
"""
|
||||
tags_by_category = self.get_city_tags_by_category(city_id)
|
||||
|
||||
return {
|
||||
"filters": {
|
||||
"services": [
|
||||
{"value": t["slug"], "label": t["name"], "count": t["count"]}
|
||||
for t in tags_by_category["services"]
|
||||
],
|
||||
"industries": [
|
||||
{"value": t["slug"], "label": t["name"], "count": t["count"]}
|
||||
for t in tags_by_category["industries"]
|
||||
],
|
||||
"modules": [
|
||||
{"value": t["slug"], "label": t["name"], "count": t["count"]}
|
||||
for t in tags_by_category["modules"]
|
||||
],
|
||||
"partner_levels": [
|
||||
{"value": t["slug"], "label": t["name"], "count": t["count"]}
|
||||
for t in tags_by_category["partner_levels"]
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
def export_country_tags_json(self, country_id: UUID) -> Dict[str, Any]:
|
||||
"""
|
||||
Export country tags as JSON for frontend filtering.
|
||||
|
||||
Args:
|
||||
country_id: Country UUID
|
||||
|
||||
Returns:
|
||||
JSON-serializable dictionary
|
||||
"""
|
||||
tags_by_category = self.get_country_tags_by_category(country_id)
|
||||
|
||||
return {
|
||||
"filters": {
|
||||
"services": [
|
||||
{
|
||||
"value": t["slug"],
|
||||
"label": t["name"],
|
||||
"company_count": t["company_count"],
|
||||
"city_count": t["city_count"],
|
||||
}
|
||||
for t in tags_by_category["services"]
|
||||
],
|
||||
"industries": [
|
||||
{
|
||||
"value": t["slug"],
|
||||
"label": t["name"],
|
||||
"company_count": t["company_count"],
|
||||
"city_count": t["city_count"],
|
||||
}
|
||||
for t in tags_by_category["industries"]
|
||||
],
|
||||
"modules": [
|
||||
{
|
||||
"value": t["slug"],
|
||||
"label": t["name"],
|
||||
"company_count": t["company_count"],
|
||||
"city_count": t["city_count"],
|
||||
}
|
||||
for t in tags_by_category["modules"]
|
||||
],
|
||||
"partner_levels": [
|
||||
{
|
||||
"value": t["slug"],
|
||||
"label": t["name"],
|
||||
"company_count": t["company_count"],
|
||||
"city_count": t["city_count"],
|
||||
}
|
||||
for t in tags_by_category["partner_levels"]
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
def refresh_all_aggregations(self, country_id: UUID) -> None:
|
||||
"""
|
||||
Refresh all tag aggregations for a country.
|
||||
|
||||
First aggregates all city tags, then rolls up to country level.
|
||||
|
||||
Args:
|
||||
country_id: Country UUID
|
||||
"""
|
||||
logger.info(f"Refreshing all aggregations for country {country_id}")
|
||||
|
||||
# Get all cities
|
||||
cities = self.db.list_cities(country_id=country_id)
|
||||
|
||||
# Aggregate each city
|
||||
for city in cities:
|
||||
self.aggregate_city_tags(city.id)
|
||||
|
||||
# Aggregate country
|
||||
self.aggregate_country_tags(country_id)
|
||||
|
||||
logger.info(f"Completed aggregation refresh for country {country_id}")
|
||||
Reference in New Issue
Block a user