diff --git a/personal-brand-engine/.env.example b/personal-brand-engine/.env.example new file mode 100644 index 00000000..6129ebba --- /dev/null +++ b/personal-brand-engine/.env.example @@ -0,0 +1,69 @@ +# =================================== +# Personal Brand Engine - Configuration +# =================================== +# Copy this file to .env and fill in your values +# cp .env.example .env + +# --- LLM Configuration --- +# Ollama (local, free) - Primary +OLLAMA_BASE_URL=http://localhost:11434 +OLLAMA_MODEL=qwen2.5:7b + +# Groq (cloud, free tier) - Fallback +GROQ_API_KEY= +GROQ_MODEL=llama-3.1-70b-versatile + +# OpenAI (optional, paid) +OPENAI_API_KEY= +OPENAI_MODEL=gpt-4o-mini + +# --- LinkedIn --- +LINKEDIN_EMAIL=sami.assiri11@gmail.com +LINKEDIN_PASSWORD= + +# --- Twitter/X --- +TWITTER_API_KEY= +TWITTER_API_SECRET= +TWITTER_ACCESS_TOKEN= +TWITTER_ACCESS_SECRET= +TWITTER_BEARER_TOKEN= + +# --- Email (Gmail) --- +IMAP_HOST=imap.gmail.com +IMAP_PORT=993 +SMTP_HOST=smtp.gmail.com +SMTP_PORT=587 +EMAIL_ADDRESS=sami.assiri11@gmail.com +EMAIL_PASSWORD= +# Use Gmail App Password: https://myaccount.google.com/apppasswords + +# --- WhatsApp (Meta Cloud API) --- +WHATSAPP_API_TOKEN= +WHATSAPP_PHONE_NUMBER_ID= +WHATSAPP_VERIFY_TOKEN=your-webhook-verify-token + +# --- WhatsApp (Twilio alternative) --- +TWILIO_ACCOUNT_SID= +TWILIO_AUTH_TOKEN= +TWILIO_WHATSAPP_NUMBER= + +# --- Cal.com (Booking) --- +CALCOM_API_KEY= +CALCOM_BOOKING_URL= + +# --- Notifications --- +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= + +# --- Database --- +DATABASE_URL=sqlite:///./data/brand_engine.db + +# --- Server --- +API_HOST=0.0.0.0 +API_PORT=8080 +API_SECRET_KEY=change-this-to-a-random-secret + +# --- General --- +TIMEZONE=Asia/Riyadh +DEFAULT_LANGUAGE=ar +LOG_LEVEL=INFO diff --git a/personal-brand-engine/.github/workflows/deploy-landing-page.yml b/personal-brand-engine/.github/workflows/deploy-landing-page.yml new file mode 100644 index 00000000..714cbb11 --- /dev/null +++ b/personal-brand-engine/.github/workflows/deploy-landing-page.yml @@ -0,0 +1,37 @@ +name: Deploy Landing Page to GitHub Pages + +on: + push: + branches: [main] + paths: + - 'personal-brand-engine/landing_page/**' + +permissions: + contents: read + pages: write + id-token: write + +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + deploy: + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Pages + uses: actions/configure-pages@v4 + + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + path: 'personal-brand-engine/landing_page' + + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/personal-brand-engine/.gitignore b/personal-brand-engine/.gitignore new file mode 100644 index 00000000..db7c9809 --- /dev/null +++ b/personal-brand-engine/.gitignore @@ -0,0 +1,46 @@ +# Environment +.env +*.env.local + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.eggs/ +*.egg + +# Virtual environment +venv/ +.venv/ +env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# Database +*.db +*.sqlite3 +data/ + +# Generated files +generated_cvs/ +logs/ +*.log + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml + +# Credentials +credentials/ +tokens/ +*.json.bak diff --git a/personal-brand-engine/Makefile b/personal-brand-engine/Makefile new file mode 100644 index 00000000..14738395 --- /dev/null +++ b/personal-brand-engine/Makefile @@ -0,0 +1,56 @@ +.PHONY: help up down restart logs status test setup pull-model + +help: ## Show this help + @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' + +setup: ## Initial setup - copy .env and pull Ollama model + @test -f .env || cp .env.example .env + @echo "✓ .env file ready - edit it with your API keys" + @mkdir -p data generated_cvs logs + @echo "✓ Data directories created" + +up: ## Start all services + docker compose up -d + @echo "✓ Services started" + @echo " API: http://localhost:8080" + @echo " Health: http://localhost:8080/health" + @echo " Dashboard: http://localhost:8080/dashboard/status" + +down: ## Stop all services + docker compose down + +restart: ## Restart all services + docker compose restart + +logs: ## View logs (follow mode) + docker compose logs -f + +logs-api: ## View API logs + docker compose logs -f brand-engine + +logs-scheduler: ## View scheduler logs + docker exec brand-engine tail -f /app/logs/scheduler.log + +status: ## Check service status + @docker compose ps + @echo "" + @curl -s http://localhost:8080/health 2>/dev/null | python3 -m json.tool || echo "API not responding" + +pull-model: ## Pull the Ollama model + docker exec brand-ollama ollama pull qwen2.5:7b + @echo "✓ Model pulled" + +test: ## Run tests + python -m pytest tests/ -v + +lint: ## Run linter + python -m ruff check . + +build: ## Build Docker image + docker compose build + +shell: ## Open shell in container + docker exec -it brand-engine bash + +db-shell: ## Open database shell + docker exec -it brand-engine python -c "from storage.database import init_db; init_db(); print('DB initialized')" diff --git a/personal-brand-engine/README.md b/personal-brand-engine/README.md new file mode 100644 index 00000000..1d09aacc --- /dev/null +++ b/personal-brand-engine/README.md @@ -0,0 +1,118 @@ +# Personal Brand Engine + +**AI-powered personal brand automation system with 7 autonomous agents running 24/7.** + +Built for **Sami Mohammed Assiri** - Field Services Engineer at METCO (Smiths Detection Airport Security), King Khalid International Airport, Riyadh. + +--- + +## Architecture + +``` +┌─────────────────────────────────────────────────────┐ +│ APScheduler (24/7) │ +├──────────┬──────────┬──────────┬──────────┬─────────┤ +│ LinkedIn │ Email │ Social │ Content │ CV │ +│ Agent │ Agent │ Media │Strategist│Optimizer│ +├──────────┴──────────┴──────────┴──────────┴─────────┤ +│ Opportunity Scout Bot │ +├─────────────────────────────────────────────────────┤ +│ FastAPI (Webhooks + Dashboard) │ +├──────────┬──────────────────────────────────────────┤ +│ WhatsApp │ Landing Page (GitHub Pages) │ +│ Agent │ + Digital Business Card │ +├──────────┴──────────────────────────────────────────┤ +│ LLM Layer: Ollama (local) → Groq → OpenAI │ +├─────────────────────────────────────────────────────┤ +│ SQLite/PostgreSQL + Docker │ +└─────────────────────────────────────────────────────┘ +``` + +## 7 AI Agents + +| Agent | What it Does | Schedule | +|-------|-------------|----------| +| **LinkedIn Agent** | Posts content, engages with network, optimizes profile | 3x/week posts, 3x/day engagement | +| **Email Agent** | Monitors inbox, classifies, drafts responses | Every 15 min | +| **Social Media Agent** | Twitter/X posting, content repurposing | Daily | +| **WhatsApp Agent** | Personal assistant, auto-responses, booking | Always-on (webhook) | +| **CV Optimizer** | Updates resume, generates PDF | Monthly | +| **Content Strategist** | Trend analysis, weekly content calendar | Weekly plan + daily trends | +| **Opportunity Scout** | Monitors jobs, news, industry events | Every 2 hours + daily digest | + +## Quick Start + +```bash +# 1. Clone and setup +cd personal-brand-engine +make setup + +# 2. Edit your credentials +nano .env + +# 3. Start everything +make up + +# 4. Pull the LLM model +make pull-model + +# 5. Check status +make status +``` + +## Cost + +| Service | Cost | +|---------|------| +| GitHub Pages (landing page) | Free | +| Cal.com (booking) | Free tier | +| Groq API (LLM) | Free tier | +| Ollama (local LLM) | Free | +| Twitter/X API | Free tier | +| WhatsApp Meta Cloud API | Free (1K conv/month) | +| Gmail SMTP/IMAP | Free | +| **Total** | **$0-5/month** (VPS only) | + +## API Endpoints + +- `GET /health` - Health check +- `GET /dashboard/status` - System stats +- `GET /dashboard/agents` - Recent agent activity +- `GET /dashboard/opportunities` - Found opportunities +- `GET /dashboard/content` - Content calendar +- `POST /webhooks/whatsapp` - WhatsApp incoming (Meta) +- `POST /webhooks/whatsapp/twilio` - WhatsApp incoming (Twilio) + +## Configuration + +- `.env` - API keys and credentials +- `config/brand_profile.yaml` - Your professional profile +- `config/schedule.yaml` - Agent schedules (cron) +- `config/content_strategy.yaml` - Content pillars and tone + +## Tech Stack + +- **Python 3.12** + FastAPI + APScheduler +- **LLM**: Ollama (Qwen 2.5) / Groq / OpenAI +- **Database**: SQLite (dev) / PostgreSQL (prod) +- **Deployment**: Docker Compose + supervisord +- **Landing Page**: Static HTML/CSS/JS on GitHub Pages + +## Commands + +```bash +make help # Show all commands +make up # Start services +make down # Stop services +make logs # View logs +make status # Check health +make pull-model # Pull Ollama model +make test # Run tests +make shell # Container shell +``` + +--- + +## License + +MIT diff --git a/personal-brand-engine/README_AR.md b/personal-brand-engine/README_AR.md new file mode 100644 index 00000000..da894c3e --- /dev/null +++ b/personal-brand-engine/README_AR.md @@ -0,0 +1,68 @@ +
+ +# محرك العلامة الشخصية + +**نظام أتمتة العلامة الشخصية بالذكاء الاصطناعي مع 7 وكلاء مستقلين يعملون 24/7** + +مبني لـ **سامي محمد العسيري** - مهندس خدمات ميدانية في METCO (أمن المطارات - Smiths Detection)، مطار الملك خالد الدولي، الرياض. + +--- + +## الوكلاء السبعة + +| الوكيل | المهمة | الجدول | +|--------|--------|--------| +| **وكيل لنكدإن** | نشر محتوى، تفاعل مع الشبكة، تحسين البروفايل | 3 منشورات/أسبوع، 3 تفاعلات/يوم | +| **وكيل الإيميل** | مراقبة البريد، تصنيف، صياغة ردود | كل 15 دقيقة | +| **وكيل التواصل الاجتماعي** | نشر تويتر، إعادة صياغة المحتوى | يومياً | +| **وكيل واتساب** | مساعد شخصي، ردود تلقائية، حجز مواعيد | يعمل دائماً | +| **محسن السيرة الذاتية** | تحديث السيفي، توليد PDF | شهرياً | +| **استراتيجي المحتوى** | تحليل الترندات، تقويم محتوى أسبوعي | أسبوعياً + يومياً | +| **بوت مراقبة الفرص** | يبحث عن وظائف، أخبار، أحداث مهنية | كل ساعتين + ملخص يومي | + +## التشغيل السريع + +
+ +```bash +# 1. الإعداد +cd personal-brand-engine +make setup + +# 2. تعديل المفاتيح +nano .env + +# 3. التشغيل +make up + +# 4. تحميل نموذج الذكاء الاصطناعي +make pull-model + +# 5. التحقق +make status +``` + +
+ +## التكلفة + +| الخدمة | التكلفة | +|--------|---------| +| GitHub Pages (الصفحة الشخصية) | مجاني | +| Cal.com (حجز المواعيد) | مجاني | +| Groq API (الذكاء الاصطناعي) | مجاني | +| Ollama (ذكاء اصطناعي محلي) | مجاني | +| واتساب Meta Cloud API | مجاني (1000 محادثة/شهر) | +| **الإجمالي** | **$0-5/شهر** (السيرفر فقط) | + +## الأوامر الأساسية + +
+ +```bash +make help # عرض كل الأوامر +make up # تشغيل الخدمات +make down # إيقاف الخدمات +make logs # عرض السجلات +make status # فحص الحالة +``` diff --git a/personal-brand-engine/agents/__init__.py b/personal-brand-engine/agents/__init__.py new file mode 100644 index 00000000..208c6509 --- /dev/null +++ b/personal-brand-engine/agents/__init__.py @@ -0,0 +1,5 @@ +"""Agents package -- autonomous agents for the personal brand engine.""" + +from agents.base_agent import BaseAgent + +__all__ = ["BaseAgent"] diff --git a/personal-brand-engine/agents/base_agent.py b/personal-brand-engine/agents/base_agent.py new file mode 100644 index 00000000..ab3d6796 --- /dev/null +++ b/personal-brand-engine/agents/base_agent.py @@ -0,0 +1,135 @@ +"""Abstract base class shared by all autonomous agents.""" + +from __future__ import annotations + +import time +from abc import ABC, abstractmethod +from typing import Any + +from sqlalchemy.orm import Session + +from config.settings import ( + get_brand_profile, + get_content_strategy, + get_settings, +) +from storage.models import AgentLog +from utils.logger import get_logger +from utils.notifications import send_notification + +logger = get_logger(__name__) + + +class BaseAgent(ABC): + """Base class that every agent must inherit from. + + Parameters + ---------- + config: + Application :class:`Settings` instance (or a plain dict). + llm_client: + Any LLM client object the subclass needs (Ollama, Groq, OpenAI, ...). + db_session: + An active SQLAlchemy :class:`Session`. + """ + + agent_name: str = "base" + + def __init__( + self, + config: Any, + llm_client: Any, + db_session: Session, + ) -> None: + self.config = config + self.llm = llm_client + self.db = db_session + + # ------------------------------------------------------------------ + # Abstract interface + # ------------------------------------------------------------------ + + @abstractmethod + async def run(self, task: str, **kwargs: Any) -> dict: + """Execute the agent's primary task and return a result dict. + + Every concrete agent must implement this method. + """ + ... + + # ------------------------------------------------------------------ + # Shared helpers + # ------------------------------------------------------------------ + + def log_action( + self, + action: str, + details: str | None = None, + *, + status: str = "success", + duration: float | None = None, + ) -> AgentLog: + """Persist an :class:`AgentLog` row and emit a structured log line.""" + entry = AgentLog( + agent_name=self.agent_name, + task=action, + status=status, + details=details, + duration_seconds=duration, + ) + self.db.add(entry) + self.db.flush() + + log_fn = logger.info if status == "success" else logger.error + log_fn( + "agent_action", + agent=self.agent_name, + action=action, + status=status, + duration_seconds=duration, + ) + return entry + + async def notify_owner(self, message: str) -> None: + """Send a notification to the project owner. + + Tries Telegram first (if credentials are configured), otherwise + falls back to logging the message. + """ + settings = get_settings() + await send_notification(message, settings) + + @staticmethod + def get_brand_profile() -> dict: + """Return the parsed ``brand_profile.yaml`` configuration.""" + return get_brand_profile() + + @staticmethod + def get_content_strategy() -> dict: + """Return the parsed ``content_strategy.yaml`` configuration.""" + return get_content_strategy() + + # ------------------------------------------------------------------ + # Timing context helper + # ------------------------------------------------------------------ + + class _Timer: + """Minimal wall-clock timer used as a context manager.""" + + def __enter__(self) -> "BaseAgent._Timer": + self.start = time.perf_counter() + return self + + def __exit__(self, *exc: object) -> None: + self.elapsed = time.perf_counter() - self.start + + def timer(self) -> _Timer: + """Return a context-manager that measures elapsed seconds. + + Usage:: + + with self.timer() as t: + await do_work() + self.log_action("work", duration=t.elapsed) + """ + return self._Timer() diff --git a/personal-brand-engine/agents/content_strategist/__init__.py b/personal-brand-engine/agents/content_strategist/__init__.py new file mode 100644 index 00000000..8baa3927 --- /dev/null +++ b/personal-brand-engine/agents/content_strategist/__init__.py @@ -0,0 +1,5 @@ +"""Content Strategist agent -- plans content calendars and analyzes trends.""" + +from agents.content_strategist.agent import ContentStrategistAgent + +__all__ = ["ContentStrategistAgent"] diff --git a/personal-brand-engine/agents/content_strategist/agent.py b/personal-brand-engine/agents/content_strategist/agent.py new file mode 100644 index 00000000..9e3959e7 --- /dev/null +++ b/personal-brand-engine/agents/content_strategist/agent.py @@ -0,0 +1,157 @@ +"""Content Strategist agent -- weekly planning, trend analysis, and calendar management.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from agents.base_agent import BaseAgent +from agents.content_strategist.calendar_planner import ( + generate_weekly_calendar, +) +from agents.content_strategist.trend_analyzer import ( + analyze_trends, +) +from storage.models import ContentCalendar + +logger = logging.getLogger(__name__) + +# Keywords used for trend scanning (aligned with brand pillars) +_DEFAULT_KEYWORDS = [ + "airport security", + "aviation safety", + "Smiths Detection", + "GACA", + "X-ray screening", + "trace detection", + "field services engineering", + "Saudi aviation", + "ICAO security", +] + + +class ContentStrategistAgent(BaseAgent): + """Autonomous agent that plans Sami's content calendar and tracks trends.""" + + agent_name: str = "content_strategist" + + def __init__( + self, + config: Any, + llm_client: Any, + db_session: Session, + ) -> None: + super().__init__(config, llm_client, db_session) + + # ------------------------------------------------------------------ + # Task dispatcher + # ------------------------------------------------------------------ + + async def run(self, task: str, **kwargs: Any) -> dict: + """Dispatch *task* to the matching handler. + + Supported tasks + --------------- + - ``weekly_plan`` -- generate a 7-day content calendar + - ``trend_analysis`` -- scan for trending topics in aviation security + """ + dispatch = { + "weekly_plan": self._weekly_plan, + "trend_analysis": self._trend_analysis, + } + + handler = dispatch.get(task) + if handler is None: + self.log_action(task, details=f"Unknown task: {task}", status="failed") + return {"status": "error", "message": f"Unknown task: {task}"} + + with self.timer() as t: + try: + result = await handler(**kwargs) + self.log_action(task, details=str(result), duration=t.elapsed) + return {"status": "success", "result": result} + except Exception as exc: + logger.exception("Task %s failed", task) + self.log_action( + task, + details=str(exc), + status="failed", + duration=t.elapsed, + ) + await self.notify_owner( + f"[Content Strategist] Task '{task}' failed: {exc}" + ) + return {"status": "error", "message": str(exc)} + + # ------------------------------------------------------------------ + # weekly_plan + # ------------------------------------------------------------------ + + async def _weekly_plan(self, **kwargs: Any) -> dict: + """Generate a week of content and persist to the ContentCalendar table.""" + brand_profile = self.get_brand_profile() + content_strategy = self.get_content_strategy() + + # Optionally run trend analysis first to inform the plan + trends = kwargs.get("trends") + if trends is None: + trend_result = await analyze_trends( + self.llm, + keywords=_DEFAULT_KEYWORDS, + brand_profile=brand_profile, + ) + trends = trend_result + + calendar_entries = await generate_weekly_calendar( + llm_client=self.llm, + brand_profile=brand_profile, + content_strategy=content_strategy, + trends=trends, + ) + + # Persist each entry to the database + saved_ids: list[int] = [] + for entry in calendar_entries: + row = ContentCalendar( + date=datetime.fromisoformat(entry["date"]), + pillar=entry["pillar"], + topic=entry["topic"], + platform=entry["platform"], + status="planned", + ) + self.db.add(row) + self.db.flush() + saved_ids.append(row.id) + + self.db.commit() + logger.info("Weekly plan saved: %d entries", len(saved_ids)) + + return { + "entries_created": len(saved_ids), + "calendar_ids": saved_ids, + "calendar": calendar_entries, + } + + # ------------------------------------------------------------------ + # trend_analysis + # ------------------------------------------------------------------ + + async def _trend_analysis(self, **kwargs: Any) -> dict: + """Analyze current trends relevant to the brand.""" + brand_profile = self.get_brand_profile() + keywords = kwargs.get("keywords", _DEFAULT_KEYWORDS) + + trends = await analyze_trends( + self.llm, + keywords=keywords, + brand_profile=brand_profile, + ) + + logger.info("Trend analysis complete: %d trends found", len(trends)) + return { + "trends_found": len(trends), + "trends": trends, + } diff --git a/personal-brand-engine/agents/content_strategist/calendar_planner.py b/personal-brand-engine/agents/content_strategist/calendar_planner.py new file mode 100644 index 00000000..cfa08ffa --- /dev/null +++ b/personal-brand-engine/agents/content_strategist/calendar_planner.py @@ -0,0 +1,233 @@ +"""Calendar planner -- generates a 7-day content plan aligned with brand strategy.""" + +from __future__ import annotations + +import json +import logging +from datetime import date, datetime, timedelta, timezone +from typing import Any + +logger = logging.getLogger(__name__) + +# Days of the week when content is posted (0=Mon ... 6=Sun) +# From schedule.yaml: Sun/Tue/Thu -- ISO weekday: Sun=7, Tue=2, Thu=4 +# Python date.isoweekday(): Mon=1, Tue=2, Wed=3, Thu=4, Fri=5, Sat=6, Sun=7 +_POSTING_DAYS_ISO = {7, 2, 4} # Sunday, Tuesday, Thursday + +_SYSTEM_PROMPT = """\ +You are a LinkedIn content strategist for a Field Services Engineer specializing +in Smiths Detection airport security equipment, based in Riyadh, Saudi Arabia. + +Create a 7-day content calendar. Posts are scheduled for Sunday, Tuesday, and Thursday. +The other days are for engagement-only (likes, comments, networking). + +For each posting day, provide: +- "date": ISO date string (YYYY-MM-DD) +- "pillar": one of the content pillars from the strategy +- "topic": specific topic / angle for the post +- "platform": "linkedin" (primary) or "twitter" +- "suggested_hook": the opening line / hook for the post (1-2 sentences) +- "hashtags": list of 3-5 relevant hashtags +- "content_type": "text", "carousel", "poll", "video_script", or "article" + +For non-posting days, include an engagement-only entry: +- "date": ISO date string +- "pillar": "engagement" +- "topic": "Network engagement & community interaction" +- "platform": "linkedin" +- "suggested_hook": "" +- "hashtags": [] +- "content_type": "engagement" + +Ensure variety across pillars and content types throughout the week. +Return ONLY a valid JSON array of 7 objects (one per day). +""" + + +async def generate_weekly_calendar( + llm_client: Any, + brand_profile: dict, + content_strategy: dict, + trends: list[dict] | None = None, +) -> list[dict]: + """Generate a 7-day content plan starting from the next Sunday. + + Parameters + ---------- + llm_client: + An :class:`LLMClient` instance. + brand_profile: + Parsed ``brand_profile.yaml``. + content_strategy: + Parsed ``content_strategy.yaml``. + trends: + Optional list of trending topics from :func:`analyze_trends`. + + Returns + ------- + list[dict] + Seven entries, one per day, each with ``date``, ``pillar``, ``topic``, + ``platform``, ``suggested_hook``, and metadata. + """ + # Calculate the start of next week (next Sunday) + today = date.today() + days_until_sunday = (7 - today.isoweekday()) % 7 + if days_until_sunday == 0: + days_until_sunday = 7 # If today is Sunday, plan for next week + week_start = today + timedelta(days=days_until_sunday) + + week_dates = [week_start + timedelta(days=i) for i in range(7)] + + # Build the pillar descriptions for the prompt + pillars = content_strategy.get("content_pillars", []) + pillar_text = "\n".join( + f"- {p['id']}: {p.get('name_en', '')} -- {p.get('description', '')}" + for p in pillars + ) + + # Format trends + trends_text = "" + if trends: + trends_text = "Trending topics to consider:\n" + "\n".join( + f"- {t.get('topic', '')} (relevance: {t.get('relevance', 'medium')}, pillar: {t.get('pillar', '')})" + for t in trends[:8] + ) + + personal = brand_profile.get("personal", {}) + user_prompt = f"""\ +Generate a 7-day content calendar for the week of {week_start.isoformat()} to {week_dates[-1].isoformat()}. + +Professional context: +- Name: {personal.get('name_en', '')} +- Role: {personal.get('title_en', '')} +- Company: {brand_profile.get('employment', {}).get('current', {}).get('company', '')} +- Location: {personal.get('location_en', '')} + +Content pillars: +{pillar_text} + +Posting schedule: Sunday, Tuesday, Thursday +Engagement-only days: Monday, Wednesday, Saturday, Friday + +Tone: {content_strategy.get('tone', {}).get('style', 'professional_approachable')} +Primary language: Arabic (with English for technical/international content) + +{trends_text} + +Week dates: +{chr(10).join(f'- {d.isoformat()} ({d.strftime("%A")})' for d in week_dates)} + +Return ONLY a valid JSON array of 7 objects. +""" + + response = await llm_client.generate( + prompt=user_prompt, + system_prompt=_SYSTEM_PROMPT, + temperature=0.6, + max_tokens=2500, + ) + + calendar = _parse_calendar_response(response.text, week_dates) + logger.info("Weekly calendar generated: %d entries", len(calendar)) + return calendar + + +def _parse_calendar_response( + text: str, + week_dates: list[date], +) -> list[dict]: + """Parse the LLM response into a calendar list, with fallback generation.""" + cleaned = text.strip() + + # Strip markdown code fences + if cleaned.startswith("```"): + first_newline = cleaned.index("\n") + cleaned = cleaned[first_newline + 1 :] + if cleaned.endswith("```"): + cleaned = cleaned[: -len("```")].rstrip() + + try: + parsed = json.loads(cleaned) + if isinstance(parsed, list): + entries = parsed + elif isinstance(parsed, dict) and "calendar" in parsed: + entries = parsed["calendar"] + else: + entries = [parsed] + except json.JSONDecodeError: + logger.warning("Failed to parse calendar JSON; generating fallback") + return _generate_fallback_calendar(week_dates) + + # Validate and normalize entries + normalized: list[dict] = [] + for entry in entries: + normalized.append( + { + "date": entry.get("date", ""), + "pillar": entry.get("pillar", "engagement"), + "topic": entry.get("topic", ""), + "platform": entry.get("platform", "linkedin"), + "suggested_hook": entry.get("suggested_hook", ""), + "hashtags": entry.get("hashtags", []), + "content_type": entry.get("content_type", "text"), + } + ) + + # Ensure we have exactly 7 entries (pad with engagement days if needed) + while len(normalized) < 7: + idx = len(normalized) + if idx < len(week_dates): + d = week_dates[idx] + else: + d = week_dates[-1] + timedelta(days=idx - len(week_dates) + 1) + normalized.append( + { + "date": d.isoformat(), + "pillar": "engagement", + "topic": "Network engagement & community interaction", + "platform": "linkedin", + "suggested_hook": "", + "hashtags": [], + "content_type": "engagement", + } + ) + + return normalized[:7] + + +def _generate_fallback_calendar(week_dates: list[date]) -> list[dict]: + """Generate a basic fallback calendar when LLM parsing fails.""" + _fallback_pillars = [ + "tech_insights", + "engagement", + "field_life", + "engagement", + "industry_news", + "engagement", + "engagement", + ] + _fallback_topics = [ + "Weekly airport security technology insight", + "Network engagement & community interaction", + "A day in the life of a Field Services Engineer", + "Network engagement & community interaction", + "Industry news commentary and analysis", + "Network engagement & community interaction", + "Network engagement & community interaction", + ] + + entries: list[dict] = [] + for i, d in enumerate(week_dates[:7]): + is_posting_day = d.isoweekday() in _POSTING_DAYS_ISO + entries.append( + { + "date": d.isoformat(), + "pillar": _fallback_pillars[i] if is_posting_day else "engagement", + "topic": _fallback_topics[i] if is_posting_day else "Network engagement & community interaction", + "platform": "linkedin", + "suggested_hook": "", + "hashtags": [], + "content_type": "text" if is_posting_day else "engagement", + } + ) + return entries diff --git a/personal-brand-engine/agents/content_strategist/prompts/strategy_prompts.yaml b/personal-brand-engine/agents/content_strategist/prompts/strategy_prompts.yaml new file mode 100644 index 00000000..bd7d3180 --- /dev/null +++ b/personal-brand-engine/agents/content_strategist/prompts/strategy_prompts.yaml @@ -0,0 +1,122 @@ +# =================================== +# Content Strategist - System Prompts +# =================================== + +weekly_plan: + system: | + You are a LinkedIn content strategist for Sami Mohammed Assiri, a Field Services + Engineer at METCO specializing in Smiths Detection airport security equipment at + King Khalid International Airport, Riyadh. + + Your job is to create a weekly content calendar that: + 1. Positions Sami as a thought leader in airport security technology + 2. Alternates between content pillars for variety + 3. Uses storytelling and real-world field experience (without disclosing sensitive security details) + 4. Balances Arabic and English content + 5. Follows the posting schedule: Sunday, Tuesday, Thursday + 6. Includes engagement strategies for non-posting days + + Content pillars: + - tech_insights: Deep dives into Smiths Detection equipment, X-Ray technology, trace detection + - field_life: Behind-the-scenes at the airport, daily challenges, engineering stories + - professional_growth: Certifications, training, career growth in aviation security + - industry_news: ICAO, GACA, TSA regulations and industry developments + + Tone: Professional but approachable. Technical but accessible. + Avoid: Sensitive security procedures, classified information, criticizing employers. + + user_template: | + Generate a 7-day content calendar for the week starting {start_date}. + + Recent trends to consider: + {trends_summary} + + Previous week's performance: + {last_week_summary} + + Ensure variety across pillars, content types (text, carousel, poll, article), + and topics. Each post should have a compelling hook. + +trend_analysis: + system: | + You are a trend analyst for aviation security and airport technology. + Analyze the provided news headlines and identify topics that a Field Services + Engineer specializing in Smiths Detection equipment could comment on credibly. + + Focus on: + - New screening technologies and regulations + - GACA (Saudi aviation authority) announcements + - Smiths Detection product launches or updates + - Airport security best practices + - Saudi Vision 2030 aviation sector developments + - International aviation security standards (ICAO, TSA) + + Filter out topics that are: + - Too sensitive (specific security vulnerabilities) + - Not relevant to an airport security equipment engineer + - Outdated (more than 2 weeks old) + + Rate each topic's relevance as high/medium/low and suggest a content angle. + + user_template: | + Analyze these headlines for trending topics: + {headlines} + + Professional context: + - Role: {role} + - Specialization: {specialization} + - Keywords: {keywords} + + Return the top 10 most relevant trends as a JSON array. + +post_generation: + system: | + You are a LinkedIn ghostwriter for Sami Mohammed Assiri, a Field Services Engineer + at METCO specializing in Smiths Detection airport security systems. + + Writing style: + - Open with a strong hook (question, bold statement, or personal anecdote) + - Use short paragraphs (2-3 lines max) + - Include a personal insight or lesson learned + - End with a question or call-to-action to drive engagement + - Use relevant emojis sparingly (1-2 per post, professional ones only) + - Mix Arabic and English naturally (Arabic for storytelling, English for technical terms) + - Keep posts between 150-300 words for optimal engagement + - Never reveal sensitive airport security procedures + + Format: + - Hook line (attention-grabbing opener) + - Body (3-4 short paragraphs with the main content) + - Takeaway (key lesson or insight) + - CTA (call-to-action or engaging question) + - Hashtags (3-5 relevant tags) + + user_template: | + Write a LinkedIn post about: {topic} + Content pillar: {pillar} + Language: {language} + Content type: {content_type} + Suggested hook: {suggested_hook} + + Additional context: + {context} + +engagement_reply: + system: | + You are drafting a thoughtful comment on a LinkedIn post on behalf of Sami Mohammed + Assiri, a Field Services Engineer specializing in airport security technology. + + Comment guidelines: + - Add genuine value (share an insight, ask a thoughtful question, or offer a perspective) + - Never be generic ("Great post!" or "Thanks for sharing") + - Keep it concise (2-4 sentences) + - Relate to your expertise in airport security when naturally relevant + - Be supportive and professional + - Match the language of the original post (Arabic or English) + + user_template: | + Original post by {author}: + "{post_content}" + + Write a thoughtful comment from Sami's perspective. + Sami's relevant expertise: {relevant_expertise} diff --git a/personal-brand-engine/agents/content_strategist/trend_analyzer.py b/personal-brand-engine/agents/content_strategist/trend_analyzer.py new file mode 100644 index 00000000..bf5852db --- /dev/null +++ b/personal-brand-engine/agents/content_strategist/trend_analyzer.py @@ -0,0 +1,224 @@ +"""Trend analyzer -- RSS feeds + LLM to identify relevant trending topics.""" + +from __future__ import annotations + +import json +import logging +from typing import Any +from xml.etree import ElementTree + +import httpx + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# RSS feeds relevant to Sami's brand pillars +# --------------------------------------------------------------------------- + +_RSS_FEEDS: list[dict[str, str]] = [ + { + "name": "Aviation Security International", + "url": "https://www.asi-mag.com/feed/", + "category": "aviation_security", + }, + { + "name": "Airport Technology", + "url": "https://www.airport-technology.com/feed/", + "category": "airport_tech", + }, + { + "name": "Security Today", + "url": "https://securitytoday.com/rss-feeds/news.aspx", + "category": "security_industry", + }, + { + "name": "GACA News (Saudi)", + "url": "https://gaca.gov.sa/web/en/rss", + "category": "gaca", + }, + { + "name": "ICAO Newsroom", + "url": "https://www.icao.int/Newsroom/Pages/RSS.aspx", + "category": "icao", + }, +] + +# System prompt for the trend-analysis LLM call +_SYSTEM_PROMPT = """\ +You are a content strategist for a Field Services Engineer specializing in airport +security equipment (Smiths Detection). Analyze the provided news headlines and identify +trending topics that are relevant for LinkedIn content creation. + +For each trend, return a JSON array of objects with: +- "topic": concise topic title +- "relevance": "high" | "medium" | "low" +- "pillar": one of "tech_insights", "field_life", "professional_growth", "industry_news" +- "angle": a brief suggestion for how to turn this into engaging LinkedIn content +- "source": the feed or keyword that surfaced it + +Return ONLY a valid JSON array. Limit to the top 10 most relevant trends. +""" + + +async def analyze_trends( + llm_client: Any, + keywords: list[str], + brand_profile: dict, +) -> list[dict]: + """Scan RSS feeds and use the LLM to identify relevant trending topics. + + Parameters + ---------- + llm_client: + An :class:`LLMClient` instance. + keywords: + Search terms aligned with the brand pillars. + brand_profile: + Parsed ``brand_profile.yaml`` dict. + + Returns + ------- + list[dict] + Each dict contains ``topic``, ``relevance``, ``pillar``, ``angle``, ``source``. + """ + # Step 1: Fetch RSS headlines + headlines = await _fetch_rss_headlines() + + # Step 2: Build LLM prompt + personal = brand_profile.get("personal", {}) + user_prompt = f"""\ +Professional context: +- Name: {personal.get('name_en', '')} +- Role: {personal.get('title_en', '')} +- Specialization: Smiths Detection airport security equipment (HI-SCAN, IONSCAN 600, CTX) +- Keywords of interest: {', '.join(keywords)} + +Recent industry headlines: +{_format_headlines(headlines)} + +Identify the top trending topics relevant to this professional's LinkedIn brand. +Return ONLY a valid JSON array. +""" + + response = await llm_client.generate( + prompt=user_prompt, + system_prompt=_SYSTEM_PROMPT, + temperature=0.5, + max_tokens=2000, + ) + + trends = _parse_trends_response(response.text) + logger.info("Identified %d trends from %d headlines", len(trends), len(headlines)) + return trends + + +# --------------------------------------------------------------------------- +# RSS fetching +# --------------------------------------------------------------------------- + + +async def _fetch_rss_headlines(timeout: float = 15.0) -> list[dict]: + """Fetch headlines from all configured RSS feeds. + + Returns a list of dicts with ``title``, ``link``, ``source``, ``published``. + Feeds that fail to load are silently skipped. + """ + headlines: list[dict] = [] + + async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client: + for feed in _RSS_FEEDS: + try: + resp = await client.get(feed["url"]) + resp.raise_for_status() + items = _parse_rss_xml(resp.text, source=feed["name"]) + headlines.extend(items) + logger.debug("Fetched %d items from %s", len(items), feed["name"]) + except Exception as exc: + logger.warning("RSS fetch failed for %s: %s", feed["name"], exc) + + return headlines + + +def _parse_rss_xml(xml_text: str, source: str) -> list[dict]: + """Parse RSS/Atom XML and extract headline items.""" + items: list[dict] = [] + try: + root = ElementTree.fromstring(xml_text) + except ElementTree.ParseError: + logger.warning("Failed to parse XML from %s", source) + return items + + # Standard RSS 2.0 + for item in root.iter("item"): + title_el = item.find("title") + link_el = item.find("link") + pub_el = item.find("pubDate") + if title_el is not None and title_el.text: + items.append( + { + "title": title_el.text.strip(), + "link": link_el.text.strip() if link_el is not None and link_el.text else "", + "source": source, + "published": pub_el.text.strip() if pub_el is not None and pub_el.text else "", + } + ) + + # Atom feeds (namespace-aware) + atom_ns = "{http://www.w3.org/2005/Atom}" + for entry in root.iter(f"{atom_ns}entry"): + title_el = entry.find(f"{atom_ns}title") + link_el = entry.find(f"{atom_ns}link") + pub_el = entry.find(f"{atom_ns}published") or entry.find(f"{atom_ns}updated") + if title_el is not None and title_el.text: + link_href = "" + if link_el is not None: + link_href = link_el.get("href", link_el.text or "") + items.append( + { + "title": title_el.text.strip(), + "link": link_href.strip() if link_href else "", + "source": source, + "published": pub_el.text.strip() if pub_el is not None and pub_el.text else "", + } + ) + + return items[:20] # Cap per feed to keep prompt manageable + + +# --------------------------------------------------------------------------- +# Formatting & parsing +# --------------------------------------------------------------------------- + + +def _format_headlines(headlines: list[dict]) -> str: + """Format headlines into a numbered list for the LLM prompt.""" + if not headlines: + return "(No headlines fetched -- generate trends based on domain knowledge.)" + lines: list[str] = [] + for i, h in enumerate(headlines[:50], start=1): # Cap at 50 total + lines.append(f"{i}. [{h['source']}] {h['title']}") + return "\n".join(lines) + + +def _parse_trends_response(text: str) -> list[dict]: + """Extract a JSON array of trends from the LLM response.""" + cleaned = text.strip() + + # Strip markdown code fences + if cleaned.startswith("```"): + first_newline = cleaned.index("\n") + cleaned = cleaned[first_newline + 1 :] + if cleaned.endswith("```"): + cleaned = cleaned[: -len("```")].rstrip() + + try: + parsed = json.loads(cleaned) + if isinstance(parsed, list): + return parsed + # Some models wrap in an object + if isinstance(parsed, dict) and "trends" in parsed: + return parsed["trends"] + return [parsed] + except json.JSONDecodeError: + logger.warning("Failed to parse trend analysis JSON; returning empty list") + return [] diff --git a/personal-brand-engine/agents/cv_optimizer/__init__.py b/personal-brand-engine/agents/cv_optimizer/__init__.py new file mode 100644 index 00000000..69ece90d --- /dev/null +++ b/personal-brand-engine/agents/cv_optimizer/__init__.py @@ -0,0 +1,5 @@ +"""CV Optimizer agent -- enhances and generates professional CVs.""" + +from agents.cv_optimizer.agent import CVOptimizerAgent + +__all__ = ["CVOptimizerAgent"] diff --git a/personal-brand-engine/agents/cv_optimizer/agent.py b/personal-brand-engine/agents/cv_optimizer/agent.py new file mode 100644 index 00000000..d46d3303 --- /dev/null +++ b/personal-brand-engine/agents/cv_optimizer/agent.py @@ -0,0 +1,132 @@ +"""CV Optimizer agent -- reads brand profile, enhances content via LLM, and generates PDFs.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from sqlalchemy.orm import Session + +from agents.base_agent import BaseAgent +from agents.cv_optimizer.formatter import ( + generate_pdf, + render_cv_html, +) +from agents.cv_optimizer.updater import enhance_cv_content + +logger = logging.getLogger(__name__) + +# Resolve once so every helper can rely on the path. +_OUTPUT_DIR = Path(__file__).resolve().parents[2] / "generated_cvs" +_TEMPLATE_DIR = Path(__file__).resolve().parent / "templates" + + +class CVOptimizerAgent(BaseAgent): + """Autonomous agent that keeps Sami's CV polished and up-to-date.""" + + agent_name: str = "cv_optimizer" + + def __init__( + self, + config: Any, + llm_client: Any, + db_session: Session, + ) -> None: + super().__init__(config, llm_client, db_session) + _OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + # ------------------------------------------------------------------ + # Task dispatcher + # ------------------------------------------------------------------ + + async def run(self, task: str, **kwargs: Any) -> dict: + """Dispatch *task* to the matching handler. + + Supported tasks + --------------- + - ``update_cv`` -- enhance CV content using the LLM + - ``generate_pdf`` -- render and export the CV as a PDF + """ + dispatch = { + "update_cv": self._update_cv, + "generate_pdf": self._generate_pdf, + } + + handler = dispatch.get(task) + if handler is None: + self.log_action(task, details=f"Unknown task: {task}", status="failed") + return {"status": "error", "message": f"Unknown task: {task}"} + + with self.timer() as t: + try: + result = await handler(**kwargs) + self.log_action(task, details=str(result), duration=t.elapsed) + return {"status": "success", "result": result} + except Exception as exc: + logger.exception("Task %s failed", task) + self.log_action( + task, + details=str(exc), + status="failed", + duration=t.elapsed, + ) + await self.notify_owner( + f"[CV Optimizer] Task '{task}' failed: {exc}" + ) + return {"status": "error", "message": str(exc)} + + # ------------------------------------------------------------------ + # update_cv + # ------------------------------------------------------------------ + + async def _update_cv(self, **kwargs: Any) -> dict: + """Use the LLM to enhance CV descriptions and keywords.""" + brand_profile = self.get_brand_profile() + + enhanced = await enhance_cv_content(self.llm, brand_profile) + + logger.info("CV content enhanced successfully") + return { + "enhanced": True, + "sections_updated": list(enhanced.keys()), + } + + # ------------------------------------------------------------------ + # generate_pdf + # ------------------------------------------------------------------ + + async def _generate_pdf(self, *, language: str = "en", **kwargs: Any) -> dict: + """Render the CV to HTML then convert to PDF. + + Parameters + ---------- + language: + ``"en"`` (default) or ``"ar"`` to select the template. + """ + brand_profile = self.get_brand_profile() + + # Optionally enhance first + enhanced_profile = await enhance_cv_content(self.llm, brand_profile) + + template_name = f"cv_template_{language}.html" + template_path = _TEMPLATE_DIR / template_name + + if not template_path.exists(): + raise FileNotFoundError(f"Template not found: {template_path}") + + html = render_cv_html(enhanced_profile, str(template_path), language=language) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + filename = f"sami_assiri_cv_{language}_{timestamp}.pdf" + output_path = _OUTPUT_DIR / filename + + pdf_path = generate_pdf(html, str(output_path)) + + logger.info("CV PDF generated: %s", pdf_path) + return { + "pdf_path": str(pdf_path), + "language": language, + "filename": filename, + } diff --git a/personal-brand-engine/agents/cv_optimizer/formatter.py b/personal-brand-engine/agents/cv_optimizer/formatter.py new file mode 100644 index 00000000..a08d1667 --- /dev/null +++ b/personal-brand-engine/agents/cv_optimizer/formatter.py @@ -0,0 +1,186 @@ +"""CV rendering and PDF generation -- Jinja2 templates + WeasyPrint.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import Any + +from jinja2 import BaseLoader, Environment, FileSystemLoader + +logger = logging.getLogger(__name__) + + +def render_cv_html( + brand_profile: dict, + template_path: str, + language: str = "en", +) -> str: + """Render a CV as HTML from the Jinja2 template. + + Parameters + ---------- + brand_profile: + Full (optionally enhanced) brand profile dict. + template_path: + Absolute path to the ``.html`` Jinja2 template file. + language: + ``"en"`` or ``"ar"`` -- passed into the template context. + + Returns + ------- + str + Fully rendered HTML string. + """ + tpl_path = Path(template_path) + tpl_dir = str(tpl_path.parent) + tpl_name = tpl_path.name + + env = Environment( + loader=FileSystemLoader(tpl_dir), + autoescape=True, + ) + template = env.get_template(tpl_name) + + # Build the template context from the profile + context = _build_template_context(brand_profile, language) + + html = template.render(**context) + logger.info("CV HTML rendered (%s chars, lang=%s)", len(html), language) + return html + + +def generate_pdf(html_content: str, output_path: str) -> Path: + """Convert rendered HTML to a PDF file using WeasyPrint. + + Parameters + ---------- + html_content: + The full HTML string to convert. + output_path: + Destination file path for the generated PDF. + + Returns + ------- + Path + The path to the written PDF file. + """ + from weasyprint import HTML + + out = Path(output_path) + out.parent.mkdir(parents=True, exist_ok=True) + + HTML(string=html_content).write_pdf(str(out)) + logger.info("PDF generated: %s (%.1f KB)", out, out.stat().st_size / 1024) + return out + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + + +def _build_template_context(profile: dict, language: str) -> dict: + """Flatten the nested profile dict into a template-friendly context.""" + personal = profile.get("personal", {}) + employment = profile.get("employment", {}) + education = profile.get("education", {}) + enhanced = profile.get("enhanced", {}) + lang_suffix = f"_{language}" + + # Use enhanced summary if available, otherwise fall back to bio + summary = enhanced.get(f"summary_{language}", "") or personal.get( + f"bio_{language}", "" + ) + + # Current role bullets: prefer enhanced, fall back to raw description + current = employment.get("current", {}) + current_bullets = enhanced.get("current_role_bullets", []) + if not current_bullets: + raw_desc = current.get(f"description_{language}", "") + current_bullets = [ + line.strip().lstrip("- ") + for line in raw_desc.strip().splitlines() + if line.strip() + ] + + # Previous roles: prefer enhanced + previous_roles_enhanced = enhanced.get("previous_roles", []) + previous_roles_raw = employment.get("previous", []) + if previous_roles_enhanced: + previous_roles = previous_roles_enhanced + else: + previous_roles = [ + { + "company": r.get("company", ""), + "title": r.get("title", ""), + "period": r.get("period", ""), + "bullets": r.get("highlights", []), + } + for r in previous_roles_raw + ] + + # Leadership: prefer enhanced + leadership_enhanced = enhanced.get("leadership", []) + leadership_raw = profile.get("leadership", []) + if leadership_enhanced: + leadership = leadership_enhanced + else: + leadership = [ + { + "role": entry.get("role", ""), + "organization": entry.get("organization", ""), + "period": entry.get("period", ""), + "bullets": entry.get("highlights", []), + } + for entry in leadership_raw + ] + + # Skills grouped by category + skills = profile.get("skills", {}) + skill_categories = [] + _category_labels = { + "data_analytics": {"en": "Data Analytics", "ar": "تحليل البيانات"}, + "project_management": {"en": "Project Management", "ar": "إدارة المشاريع"}, + "engineering": {"en": "Engineering", "ar": "الهندسة"}, + "leadership": {"en": "Leadership", "ar": "القيادة"}, + "languages": {"en": "Languages", "ar": "اللغات"}, + } + for cat_key, items in skills.items(): + label = _category_labels.get(cat_key, {}).get(language, cat_key.replace("_", " ").title()) + if cat_key == "languages": + formatted_items = [ + f"{lang['name']} ({lang['level']})" for lang in items + ] + else: + formatted_items = list(items) + skill_categories.append({"name": label, "items": formatted_items}) + + return { + "language": language, + "name": personal.get(f"name_{language}", personal.get("name_en", "")), + "title": personal.get(f"title_{language}", personal.get("title_en", "")), + "headline": personal.get(f"headline_{language}", ""), + "email": personal.get("email", ""), + "phone": personal.get("phone", ""), + "location": personal.get(f"location_{language}", ""), + "linkedin": profile.get("links", {}).get("linkedin", ""), + "summary": summary, + "current_company": current.get("company", current.get("company_ar", "")), + "current_title": current.get("title", current.get("title_ar", "")), + "current_location": current.get("location", current.get("location_ar", "")), + "current_start_date": current.get("start_date", ""), + "current_bullets": current_bullets, + "previous_roles": previous_roles, + "leadership": leadership, + "education_degree": education.get("degree", ""), + "education_institution": education.get("institution", ""), + "education_location": education.get("location", ""), + "education_period": education.get("period", ""), + "education_highlights": education.get("highlights", []), + "certifications": profile.get("certifications", []), + "awards": profile.get("awards", []), + "skill_categories": skill_categories, + "ats_keywords": enhanced.get("skills_keywords", []), + "references": profile.get("references", []), + } diff --git a/personal-brand-engine/agents/cv_optimizer/templates/cv_template_ar.html b/personal-brand-engine/agents/cv_optimizer/templates/cv_template_ar.html new file mode 100644 index 00000000..fc66df10 --- /dev/null +++ b/personal-brand-engine/agents/cv_optimizer/templates/cv_template_ar.html @@ -0,0 +1,382 @@ + + + + + + {{ name }} - السيرة الذاتية + + + +
+ + +
+

{{ name }}

+
{{ title }}
+
+ {{ email }} + | + {{ phone }} + | + {{ location }} + {% if linkedin %} + | + LinkedIn + {% endif %} +
+
+ + +
+

الملخص المهني

+

{{ summary }}

+
+ + +
+

الخبرة العملية

+ + +
+
+ {{ current_title }} + {{ current_start_date }} – الحالي +
+
{{ current_company }} — {{ current_location }}
+
    + {% for bullet in current_bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+
+ + + {% for role in previous_roles %} +
+
+ {{ role.title }} + {{ role.period }} +
+
{{ role.company }}
+
    + {% for bullet in role.bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+
+ {% endfor %} +
+ + + {% if leadership %} +
+

القيادة والعمل التطوعي

+ {% for entry in leadership %} +
+
+ {{ entry.role }} — {{ entry.organization }} + {{ entry.period }} +
+
    + {% for bullet in entry.bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+
+ {% endfor %} +
+ {% endif %} + + +
+

التعليم

+
+ {{ education_degree }} + {{ education_period }} +
+
{{ education_institution }} — {{ education_location }}
+ {% if education_highlights %} + + {% endif %} +
+ + + {% if certifications %} +
+

الشهادات المهنية

+ +
+ {% endif %} + + + {% if skill_categories %} +
+

المهارات

+
+ {% for cat in skill_categories %} +
+

{{ cat.name }}

+
    + {% for item in cat.items %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if awards %} +
+

الجوائز والتكريمات

+ +
+ {% endif %} + + + {% if references %} +
+

المراجع

+ {% for ref in references %} +

{{ ref }}

+ {% endfor %} +
+ {% endif %} + + + {% if ats_keywords %} +
{{ ats_keywords | join(', ') }}
+ {% endif %} + +
+ + diff --git a/personal-brand-engine/agents/cv_optimizer/templates/cv_template_en.html b/personal-brand-engine/agents/cv_optimizer/templates/cv_template_en.html new file mode 100644 index 00000000..d75806f3 --- /dev/null +++ b/personal-brand-engine/agents/cv_optimizer/templates/cv_template_en.html @@ -0,0 +1,379 @@ + + + + + + {{ name }} - CV + + + +
+ + +
+

{{ name }}

+
{{ title }}
+
+ {{ email }} + | + {{ phone }} + | + {{ location }} + {% if linkedin %} + | + LinkedIn + {% endif %} +
+
+ + +
+

Professional Summary

+

{{ summary }}

+
+ + +
+

Work Experience

+ + +
+
+ {{ current_title }} + {{ current_start_date }} – Present +
+
{{ current_company }} — {{ current_location }}
+
    + {% for bullet in current_bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+
+ + + {% for role in previous_roles %} +
+
+ {{ role.title }} + {{ role.period }} +
+
{{ role.company }}
+
    + {% for bullet in role.bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+
+ {% endfor %} +
+ + + {% if leadership %} +
+

Leadership & Volunteering

+ {% for entry in leadership %} +
+
+ {{ entry.role }} — {{ entry.organization }} + {{ entry.period }} +
+
    + {% for bullet in entry.bullets %} +
  • {{ bullet }}
  • + {% endfor %} +
+
+ {% endfor %} +
+ {% endif %} + + +
+

Education

+
+ {{ education_degree }} + {{ education_period }} +
+
{{ education_institution }} — {{ education_location }}
+ {% if education_highlights %} + + {% endif %} +
+ + + {% if certifications %} +
+

Certifications

+ +
+ {% endif %} + + + {% if skill_categories %} +
+

Skills

+
+ {% for cat in skill_categories %} +
+

{{ cat.name }}

+
    + {% for item in cat.items %} +
  • {{ item }}
  • + {% endfor %} +
+
+ {% endfor %} +
+
+ {% endif %} + + + {% if awards %} +
+

Awards & Recognition

+ +
+ {% endif %} + + + {% if references %} +
+

References

+ {% for ref in references %} +

{{ ref }}

+ {% endfor %} +
+ {% endif %} + + + {% if ats_keywords %} +
{{ ats_keywords | join(', ') }}
+ {% endif %} + +
+ + diff --git a/personal-brand-engine/agents/cv_optimizer/updater.py b/personal-brand-engine/agents/cv_optimizer/updater.py new file mode 100644 index 00000000..28c31ee1 --- /dev/null +++ b/personal-brand-engine/agents/cv_optimizer/updater.py @@ -0,0 +1,164 @@ +"""CV content enhancer -- uses LLM to polish bullet points and optimize for ATS.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# System prompt for the CV-enhancement LLM call +# --------------------------------------------------------------------------- + +_SYSTEM_PROMPT = """\ +You are an expert CV/resume writer specializing in engineering and technical roles. +Your task is to enhance the provided professional profile for maximum impact. + +Rules: +1. Start every bullet point with a strong action verb (Engineered, Spearheaded, Optimized, etc.) +2. Quantify achievements wherever possible (%, $, counts, time saved) +3. Include relevant ATS keywords for: airport security, field services engineering, + mechanical engineering, Smiths Detection, X-ray screening, Python, data analytics +4. Keep descriptions concise -- max 2 lines per bullet +5. Maintain factual accuracy -- do NOT invent numbers or achievements +6. Preserve the original meaning; only improve phrasing and keyword density +7. Ensure the professional summary is compelling and tailored for field services / + airport security engineering roles + +Return a JSON object with these keys: +- "summary_en": enhanced English professional summary (3-4 sentences) +- "summary_ar": enhanced Arabic professional summary (3-4 sentences) +- "current_role_bullets": list of enhanced bullet strings for the current role +- "previous_roles": list of objects, each with "company", "title", "bullets" (list of strings) +- "leadership": list of objects, each with "role", "organization", "bullets" +- "skills_keywords": list of top 20 ATS keywords extracted from the profile +""" + + +async def enhance_cv_content(llm_client: Any, brand_profile: dict) -> dict: + """Call the LLM to enhance CV content and return an enriched profile dict. + + Parameters + ---------- + llm_client: + An :class:`LLMClient` (or compatible) instance. + brand_profile: + Parsed ``brand_profile.yaml`` dict. + + Returns + ------- + dict + The original *brand_profile* merged with enhanced descriptions stored + under the ``"enhanced"`` key. + """ + # Build the user prompt with the raw profile data + personal = brand_profile.get("personal", {}) + employment = brand_profile.get("employment", {}) + leadership = brand_profile.get("leadership", []) + skills = brand_profile.get("skills", {}) + certifications = brand_profile.get("certifications", []) + awards = brand_profile.get("awards", []) + + user_prompt = f"""\ +Enhance the following professional profile for a CV/resume. + +=== PERSONAL === +Name: {personal.get('name_en', '')} +Title: {personal.get('title_en', '')} +Bio (EN): {personal.get('bio_en', '')} +Bio (AR): {personal.get('bio_ar', '')} + +=== CURRENT ROLE === +Company: {employment.get('current', {}).get('company', '')} +Title: {employment.get('current', {}).get('title', '')} +Location: {employment.get('current', {}).get('location', '')} +Description: +{employment.get('current', {}).get('description_en', '')} + +=== PREVIOUS ROLES === +{_format_previous_roles(employment.get('previous', []))} + +=== LEADERSHIP === +{_format_leadership(leadership)} + +=== SKILLS === +{json.dumps(skills, indent=2, ensure_ascii=False)} + +=== CERTIFICATIONS === +{chr(10).join('- ' + c for c in certifications)} + +=== AWARDS === +{chr(10).join('- ' + a for a in awards)} + +Return ONLY valid JSON matching the schema described in the system prompt. +""" + + response = await llm_client.generate( + prompt=user_prompt, + system_prompt=_SYSTEM_PROMPT, + temperature=0.4, + max_tokens=3000, + ) + + # Parse the LLM response + enhanced = _parse_llm_response(response.text) + + # Merge enhanced data back into profile + enriched_profile = {**brand_profile, "enhanced": enhanced} + return enriched_profile + + +def _format_previous_roles(roles: list[dict]) -> str: + """Format previous roles for the LLM prompt.""" + lines: list[str] = [] + for role in roles: + lines.append(f"Company: {role.get('company', '')}") + lines.append(f"Title: {role.get('title', '')}") + lines.append(f"Period: {role.get('period', '')}") + for h in role.get("highlights", []): + lines.append(f" - {h}") + lines.append("") + return "\n".join(lines) + + +def _format_leadership(entries: list[dict]) -> str: + """Format leadership entries for the LLM prompt.""" + lines: list[str] = [] + for entry in entries: + lines.append(f"Role: {entry.get('role', '')}") + lines.append(f"Organization: {entry.get('organization', '')}") + lines.append(f"Period: {entry.get('period', '')}") + for h in entry.get("highlights", []): + lines.append(f" - {h}") + lines.append("") + return "\n".join(lines) + + +def _parse_llm_response(text: str) -> dict: + """Extract JSON from the LLM response, handling markdown fences.""" + cleaned = text.strip() + + # Strip markdown code fences if present + if cleaned.startswith("```"): + # Remove opening fence (with optional language tag) + first_newline = cleaned.index("\n") + cleaned = cleaned[first_newline + 1 :] + # Remove closing fence + if cleaned.endswith("```"): + cleaned = cleaned[: -len("```")].rstrip() + + try: + return json.loads(cleaned) + except json.JSONDecodeError: + logger.warning("Failed to parse LLM JSON response; returning raw text") + return { + "raw_response": text, + "summary_en": "", + "summary_ar": "", + "current_role_bullets": [], + "previous_roles": [], + "leadership": [], + "skills_keywords": [], + } diff --git a/personal-brand-engine/agents/email/__init__.py b/personal-brand-engine/agents/email/__init__.py new file mode 100644 index 00000000..a6bd3cf4 --- /dev/null +++ b/personal-brand-engine/agents/email/__init__.py @@ -0,0 +1,5 @@ +"""Email management agent for Sami Assiri's inbox.""" + +from agents.email.agent import EmailAgent + +__all__ = ["EmailAgent"] diff --git a/personal-brand-engine/agents/email/agent.py b/personal-brand-engine/agents/email/agent.py new file mode 100644 index 00000000..9cd532c0 --- /dev/null +++ b/personal-brand-engine/agents/email/agent.py @@ -0,0 +1,323 @@ +"""EmailAgent -- monitors, classifies, and responds to Gmail messages.""" + +from __future__ import annotations + +import email +import imaplib +import smtplib +import ssl +from email.header import decode_header +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Any + +from agents.base_agent import BaseAgent +from agents.email.classifier import classify_email +from agents.email.responder import draft_response +from storage.models import Email +from utils.logger import get_logger + +logger = get_logger(__name__) + + +def _decode_header_value(raw: str | None) -> str: + """Safely decode an RFC-2047 encoded header value.""" + if raw is None: + return "" + decoded_parts: list[str] = [] + for part, charset in decode_header(raw): + if isinstance(part, bytes): + decoded_parts.append(part.decode(charset or "utf-8", errors="replace")) + else: + decoded_parts.append(part) + return " ".join(decoded_parts) + + +def _extract_body(msg: email.message.Message) -> str: + """Extract the plain-text body from a potentially multipart message.""" + if msg.is_multipart(): + for part in msg.walk(): + content_type = part.get_content_type() + content_disposition = str(part.get("Content-Disposition", "")) + if content_type == "text/plain" and "attachment" not in content_disposition: + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + # Fallback: try text/html if no plain text found + for part in msg.walk(): + if part.get_content_type() == "text/html": + payload = part.get_payload(decode=True) + if payload: + charset = part.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + return "" + else: + payload = msg.get_payload(decode=True) + if payload: + charset = msg.get_content_charset() or "utf-8" + return payload.decode(charset, errors="replace") + return "" + + +class EmailAgent(BaseAgent): + """Agent that manages Sami Assiri's Gmail inbox. + + Supported tasks: + - ``check_inbox`` -- fetch unread emails, classify, draft responses + - ``send_scheduled`` -- send any queued draft responses via SMTP + """ + + agent_name: str = "email" + + _SUPPORTED_TASKS = {"check_inbox", "send_scheduled"} + + # ------------------------------------------------------------------ + # Public interface + # ------------------------------------------------------------------ + + async def run(self, task: str, **kwargs: Any) -> dict: + """Dispatch *task* to the appropriate handler.""" + if task not in self._SUPPORTED_TASKS: + self.log_action( + f"unknown_task:{task}", + details=f"Unsupported task: {task}", + status="failed", + ) + return {"status": "error", "message": f"Unknown task: {task}"} + + handler = getattr(self, task) + with self.timer() as t: + result = await handler(**kwargs) + self.log_action(task, details=str(result), duration=t.elapsed) + return result + + # ------------------------------------------------------------------ + # check_inbox + # ------------------------------------------------------------------ + + async def check_inbox(self, **kwargs: Any) -> dict: + """Connect to IMAP, fetch unread emails, classify, and draft responses.""" + imap: imaplib.IMAP4_SSL | None = None + processed = 0 + urgent_count = 0 + errors: list[str] = [] + + try: + imap = self._connect_imap() + imap.select("INBOX") + + status, data = imap.search(None, "UNSEEN") + if status != "OK" or not data or not data[0]: + self.log_action("check_inbox", details="No unread emails found") + return {"status": "ok", "processed": 0, "urgent": 0} + + message_ids = data[0].split() + logger.info( + "email_fetch", + count=len(message_ids), + message="Fetching unread emails", + ) + + for msg_id in message_ids: + try: + await self._process_message(imap, msg_id) + processed += 1 + except Exception as exc: + err_msg = f"Failed to process message {msg_id}: {exc}" + logger.error("email_process_error", error=str(exc)) + errors.append(err_msg) + + self.db.commit() + + # Count urgent emails from this batch + urgent_count = ( + self.db.query(Email) + .filter( + Email.classification == "urgent", + Email.status == "drafted", + ) + .count() + ) + + if urgent_count > 0: + await self.notify_owner( + f"You have {urgent_count} urgent email(s) " + f"requiring attention. {processed} total emails processed." + ) + + except imaplib.IMAP4.error as exc: + self.log_action( + "check_inbox", + details=f"IMAP error: {exc}", + status="failed", + ) + return {"status": "error", "message": f"IMAP error: {exc}"} + except Exception as exc: + self.log_action( + "check_inbox", + details=f"Unexpected error: {exc}", + status="failed", + ) + return {"status": "error", "message": str(exc)} + finally: + if imap is not None: + try: + imap.close() + imap.logout() + except Exception: + pass + + return { + "status": "ok", + "processed": processed, + "urgent": urgent_count, + "errors": errors, + } + + async def _process_message( + self, imap: imaplib.IMAP4_SSL, msg_id: bytes + ) -> None: + """Fetch, classify, and optionally draft a reply for a single message.""" + status, msg_data = imap.fetch(msg_id, "(RFC822)") + if status != "OK" or not msg_data or not msg_data[0]: + return + + raw_email = msg_data[0][1] # type: ignore[index] + msg = email.message_from_bytes(raw_email) + + from_addr = _decode_header_value(msg.get("From", "")) + to_addr = _decode_header_value(msg.get("To", "")) + subject = _decode_header_value(msg.get("Subject", "")) + body = _extract_body(msg) + + # Truncate body for classification to avoid token limits + body_preview = body[:3000] if body else "" + + classification = await classify_email( + self.llm, subject, body_preview, from_addr + ) + + email_record = Email( + from_addr=from_addr, + to_addr=to_addr, + subject=subject, + body=body, + classification=classification, + status="unread", + ) + + # Draft a response for urgent and reply_needed emails + if classification in ("urgent", "reply_needed"): + brand_profile = self.get_brand_profile() + response_text = await draft_response( + self.llm, subject, body_preview, brand_profile, classification + ) + email_record.draft_response = response_text + email_record.status = "drafted" + logger.info( + "email_drafted", + subject=subject, + classification=classification, + from_addr=from_addr, + ) + else: + email_record.status = "archived" + + self.db.add(email_record) + + # ------------------------------------------------------------------ + # send_scheduled + # ------------------------------------------------------------------ + + async def send_scheduled(self, **kwargs: Any) -> dict: + """Send all queued draft responses via SMTP.""" + drafts = ( + self.db.query(Email) + .filter(Email.status == "drafted") + .filter(Email.draft_response.isnot(None)) + .all() + ) + + if not drafts: + self.log_action("send_scheduled", details="No drafts to send") + return {"status": "ok", "sent": 0} + + sent = 0 + errors: list[str] = [] + + try: + smtp = self._connect_smtp() + + for record in drafts: + try: + self._send_single(smtp, record) + record.status = "sent" + sent += 1 + logger.info( + "email_sent", + to=record.from_addr, + subject=f"Re: {record.subject}", + ) + except Exception as exc: + err_msg = f"Failed to send reply to {record.from_addr}: {exc}" + logger.error("email_send_error", error=str(exc)) + errors.append(err_msg) + + smtp.quit() + self.db.commit() + + except smtplib.SMTPException as exc: + self.log_action( + "send_scheduled", + details=f"SMTP error: {exc}", + status="failed", + ) + return {"status": "error", "message": f"SMTP error: {exc}"} + except Exception as exc: + self.log_action( + "send_scheduled", + details=f"Unexpected error: {exc}", + status="failed", + ) + return {"status": "error", "message": str(exc)} + + return {"status": "ok", "sent": sent, "errors": errors} + + def _send_single(self, smtp: smtplib.SMTP, record: Email) -> None: + """Compose and send a single reply email.""" + msg = MIMEMultipart() + msg["From"] = self.config.email_address + msg["To"] = record.from_addr + msg["Subject"] = f"Re: {record.subject}" + msg["In-Reply-To"] = "" + msg.attach(MIMEText(record.draft_response, "plain", "utf-8")) + smtp.sendmail( + self.config.email_address, + [record.from_addr], + msg.as_string(), + ) + + # ------------------------------------------------------------------ + # Connection helpers + # ------------------------------------------------------------------ + + def _connect_imap(self) -> imaplib.IMAP4_SSL: + """Establish an authenticated IMAP-SSL connection.""" + ctx = ssl.create_default_context() + imap = imaplib.IMAP4_SSL( + self.config.imap_host, + self.config.imap_port, + ssl_context=ctx, + ) + imap.login(self.config.email_address, self.config.email_password) + return imap + + def _connect_smtp(self) -> smtplib.SMTP: + """Establish an authenticated SMTP connection with STARTTLS.""" + smtp = smtplib.SMTP(self.config.smtp_host, self.config.smtp_port) + smtp.ehlo() + smtp.starttls(context=ssl.create_default_context()) + smtp.ehlo() + smtp.login(self.config.email_address, self.config.email_password) + return smtp diff --git a/personal-brand-engine/agents/email/classifier.py b/personal-brand-engine/agents/email/classifier.py new file mode 100644 index 00000000..3c704845 --- /dev/null +++ b/personal-brand-engine/agents/email/classifier.py @@ -0,0 +1,107 @@ +"""LLM-powered email classifier for Sami Assiri's inbox.""" + +from __future__ import annotations + +from utils.logger import get_logger + +logger = get_logger(__name__) + +_VALID_CLASSIFICATIONS = {"urgent", "reply_needed", "spam", "info"} + +_SYSTEM_PROMPT = """\ +You are an email classification assistant for Sami Assiri, a Field Services Engineer \ +at METCO stationed at King Khalid International Airport, Riyadh. Sami is also a \ +Mechanical Engineer with experience in Python/data analytics and leadership roles \ +(SPE Alasala Chapter President, Elite Engineers Club Founder). + +Classify the incoming email into exactly ONE of these categories: + +- urgent: Job offers, interview invitations, meeting requests from colleagues or \ +managers, professional inquiries about engineering services, messages from Aramco / \ +METCO / Samsung E&A, messages from SPE or university contacts requiring action, \ +security-related operational emails, time-sensitive requests. + +- reply_needed: Professional networking messages, follow-up questions, LinkedIn \ +connection requests forwarded by email, general collaboration proposals, non-urgent \ +questions, event invitations with upcoming deadlines. + +- info: Newsletters, promotional offers, subscription updates, platform notifications \ +(LinkedIn, GitHub, etc.), informational digests, automated reports, order confirmations, \ +shipping updates. + +- spam: Unsolicited commercial messages, phishing attempts, scam emails, irrelevant \ +mass marketing, suspicious links, fake prize notifications. + +Respond with ONLY the classification label (one word, lowercase). Nothing else.\ +""" + + +async def classify_email( + llm_client, + subject: str, + body: str, + from_addr: str, +) -> str: + """Classify an email using the LLM. + + Parameters + ---------- + llm_client: + An :class:`LLMClient` instance. + subject: + The email subject line. + body: + The email body text (may be truncated). + from_addr: + The sender's email address / display name. + + Returns + ------- + str + One of ``urgent``, ``reply_needed``, ``spam``, or ``info``. + """ + prompt = ( + f"From: {from_addr}\n" + f"Subject: {subject}\n\n" + f"Body:\n{body[:2000]}\n\n" + "Classification:" + ) + + try: + response = await llm_client.generate( + prompt=prompt, + system_prompt=_SYSTEM_PROMPT, + temperature=0.1, + max_tokens=10, + ) + classification = response.text.strip().lower().rstrip(".") + + if classification not in _VALID_CLASSIFICATIONS: + # Attempt partial match (e.g. "urgent - this is..." -> "urgent") + for label in _VALID_CLASSIFICATIONS: + if label in classification: + classification = label + break + else: + logger.warning( + "email_classification_fallback", + raw=response.text, + message="LLM returned unrecognised label, defaulting to info", + ) + classification = "info" + + logger.info( + "email_classified", + subject=subject[:80], + classification=classification, + ) + return classification + + except Exception as exc: + logger.error( + "email_classification_error", + error=str(exc), + subject=subject[:80], + ) + # Fail-safe: treat as reply_needed so nothing important is missed + return "reply_needed" diff --git a/personal-brand-engine/agents/email/prompts/reply_templates.yaml b/personal-brand-engine/agents/email/prompts/reply_templates.yaml new file mode 100644 index 00000000..3f66dd62 --- /dev/null +++ b/personal-brand-engine/agents/email/prompts/reply_templates.yaml @@ -0,0 +1,191 @@ +# ============================================================= +# Reply templates for the Email Agent +# Each category maps to Arabic (ar) and English (en) templates. +# These serve as starting guides for the LLM response drafter. +# ============================================================= + +urgent: + en: | + Dear [Name], + + Thank you for your email. I appreciate you reaching out regarding [topic]. + + I have noted the urgency of your request and will prioritize it accordingly. + [Response body] + + Please feel free to book a meeting at your convenience: [Cal.com link] + + Best regards, + Sami Mohammed Assiri + Field Services Engineer + METCO - Middle East Services + King Khalid International Airport, Riyadh + sami.assiri11@gmail.com + + ar: | + عزيزي/عزيزتي [الاسم]، + + شكراً لتواصلك معي بخصوص [الموضوع]. + + لقد أخذت بعين الاعتبار أهمية طلبك وسأعطيه الأولوية اللازمة. + [نص الرد] + + يمكنك حجز موعد للاجتماع عبر الرابط التالي: [رابط Cal.com] + + مع أطيب التحيات، + سامي محمد العسيري + مهندس خدمات ميدانية + ميتكو - خدمات الشرق الأوسط + مطار الملك خالد الدولي - الرياض + sami.assiri11@gmail.com + +reply_needed: + en: | + Dear [Name], + + Thank you for your message regarding [topic]. + + [Response body] + + Should you need any further information, please don't hesitate to reach out. + + Best regards, + Sami Mohammed Assiri + Field Services Engineer + METCO - Middle East Services + sami.assiri11@gmail.com + + ar: | + عزيزي/عزيزتي [الاسم]، + + شكراً لرسالتك بخصوص [الموضوع]. + + [نص الرد] + + في حال احتجت لأي معلومات إضافية، لا تتردد في التواصل معي. + + مع أطيب التحيات، + سامي محمد العسيري + مهندس خدمات ميدانية + ميتكو - خدمات الشرق الأوسط + sami.assiri11@gmail.com + +info: + en: | + Noted, thank you for the update. + + Best regards, + Sami Mohammed Assiri + + ar: | + تم الاطلاع، شكراً للتحديث. + + مع أطيب التحيات، + سامي محمد العسيري + +meeting_request: + en: | + Dear [Name], + + Thank you for the meeting request. I'd be happy to connect. + + For your convenience, please use the following link to book a time + that works for both of us: [Cal.com link] + + Alternatively, I am generally available [suggest times]. + + Looking forward to our discussion. + + Best regards, + Sami Mohammed Assiri + Field Services Engineer + METCO - Middle East Services + sami.assiri11@gmail.com + + ar: | + عزيزي/عزيزتي [الاسم]، + + شكراً لطلب الاجتماع. يسعدني التواصل معك. + + لتسهيل التنسيق، يمكنك حجز موعد مناسب عبر الرابط التالي: [رابط Cal.com] + + بدلاً من ذلك، أنا متاح عادةً في [اقتراح أوقات]. + + أتطلع لنقاشنا. + + مع أطيب التحيات، + سامي محمد العسيري + مهندس خدمات ميدانية + ميتكو - خدمات الشرق الأوسط + sami.assiri11@gmail.com + +job_offer: + en: | + Dear [Name], + + Thank you very much for considering me for the [position] opportunity at [company]. + + I appreciate your interest in my background and would welcome the chance to + learn more about the role and how I can contribute to your team. + + [Response body] + + I look forward to hearing from you. + + Best regards, + Sami Mohammed Assiri + Field Services Engineer + METCO - Middle East Services + sami.assiri11@gmail.com + + ar: | + عزيزي/عزيزتي [الاسم]، + + أشكركم جزيل الشكر على التفكير بي لفرصة [المنصب] في [الشركة]. + + أقدر اهتمامكم بخبراتي وأرحب بفرصة معرفة المزيد عن الدور + وكيف يمكنني المساهمة في فريقكم. + + [نص الرد] + + أتطلع لسماع أخباركم. + + مع أطيب التحيات، + سامي محمد العسيري + مهندس خدمات ميدانية + ميتكو - خدمات الشرق الأوسط + sami.assiri11@gmail.com + +networking: + en: | + Dear [Name], + + Thank you for reaching out. It's great to connect with fellow professionals + in the [industry/field] space. + + [Response body] + + Feel free to connect with me on LinkedIn as well: + https://www.linkedin.com/in/sami-assiri-a300622b2/ + + Best regards, + Sami Mohammed Assiri + Field Services Engineer + METCO - Middle East Services + sami.assiri11@gmail.com + + ar: | + عزيزي/عزيزتي [الاسم]، + + شكراً لتواصلك. يسعدني التواصل مع المتخصصين في مجال [الصناعة/التخصص]. + + [نص الرد] + + يمكنك التواصل معي أيضاً عبر LinkedIn: + https://www.linkedin.com/in/sami-assiri-a300622b2/ + + مع أطيب التحيات، + سامي محمد العسيري + مهندس خدمات ميدانية + ميتكو - خدمات الشرق الأوسط + sami.assiri11@gmail.com diff --git a/personal-brand-engine/agents/email/responder.py b/personal-brand-engine/agents/email/responder.py new file mode 100644 index 00000000..95f6371c --- /dev/null +++ b/personal-brand-engine/agents/email/responder.py @@ -0,0 +1,180 @@ +"""LLM-powered email response drafter for Sami Assiri.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import yaml + +from utils.logger import get_logger + +logger = get_logger(__name__) + +_TEMPLATES_PATH = Path(__file__).parent / "prompts" / "reply_templates.yaml" + + +def _load_templates() -> dict: + """Load reply templates from the YAML file.""" + if not _TEMPLATES_PATH.exists(): + return {} + with open(_TEMPLATES_PATH, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def _detect_language(text: str) -> str: + """Detect whether the text is primarily Arabic or English. + + Uses a simple heuristic: if the text contains Arabic Unicode characters + above a threshold, treat it as Arabic. + """ + if not text: + return "en" + arabic_chars = len(re.findall(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]", text)) + total_alpha = len(re.findall(r"[a-zA-Z\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]", text)) + if total_alpha == 0: + return "en" + return "ar" if (arabic_chars / total_alpha) > 0.3 else "en" + + +def _build_system_prompt(brand_profile: dict, language: str, classification: str) -> str: + """Construct the system prompt for the response drafter.""" + personal = brand_profile.get("personal", {}) + employment = brand_profile.get("employment", {}) + current_job = employment.get("current", {}) + links = brand_profile.get("links", {}) + + if language == "ar": + name = personal.get("name_ar", "سامي محمد العسيري") + title = current_job.get("title_ar", "مهندس خدمات ميدانية") + company = current_job.get("company_ar", "ميتكو - خدمات الشرق الأوسط") + location = current_job.get("location_ar", "مطار الملك خالد الدولي - الرياض") + else: + name = personal.get("name_en", "Sami Mohammed Assiri") + title = current_job.get("title", "Field Services Engineer") + company = current_job.get("company", "METCO - Middle East Services") + location = current_job.get("location", "King Khalid International Airport, Riyadh") + + calcom_url = links.get("calcom", "") + linkedin_url = links.get("linkedin", "") + + templates = _load_templates() + template_guidance = "" + if classification in templates: + tpl = templates[classification] + lang_key = "ar" if language == "ar" else "en" + if lang_key in tpl: + template_guidance = f"\n\nUse this template as a starting guide:\n{tpl[lang_key]}" + + lang_instruction = ( + "Write the reply entirely in Arabic." + if language == "ar" + else "Write the reply entirely in English." + ) + + meeting_instruction = "" + if classification == "urgent" and calcom_url: + meeting_instruction = ( + f"\nIf the email involves a meeting request, suggest booking via " + f"the Cal.com link: {calcom_url}" + ) + + return ( + f"You are drafting a professional email reply on behalf of {name}, " + f"{title} at {company}, based in {location}.\n\n" + f"LinkedIn: {linkedin_url}\n" + f"Email: {personal.get('email', 'sami.assiri11@gmail.com')}\n\n" + f"Guidelines:\n" + f"- {lang_instruction}\n" + f"- Maintain a professional, courteous, and confident tone.\n" + f"- Keep the response concise and actionable.\n" + f"- When relevant, mention Sami's role at {company} and his engineering background.\n" + f"- Do NOT fabricate information. If you're unsure, suggest Sami will follow up.\n" + f"- Sign off with Sami's name and title.{meeting_instruction}" + f"{template_guidance}" + ) + + +async def draft_response( + llm_client, + email_subject: str, + email_body: str, + brand_profile: dict, + classification: str, +) -> str: + """Draft a professional email response using the LLM. + + Parameters + ---------- + llm_client: + An :class:`LLMClient` instance. + email_subject: + Subject line of the incoming email. + email_body: + Body text of the incoming email. + brand_profile: + Parsed brand profile dictionary. + classification: + The email classification (``urgent``, ``reply_needed``, etc.). + + Returns + ------- + str + The drafted reply text, ready for review or sending. + """ + language = _detect_language(email_body) + system_prompt = _build_system_prompt(brand_profile, language, classification) + + if language == "ar": + user_prompt = ( + f"الرد على البريد الإلكتروني التالي:\n\n" + f"الموضوع: {email_subject}\n\n" + f"المحتوى:\n{email_body[:2500]}\n\n" + f"اكتب رداً مهنياً مناسباً." + ) + else: + user_prompt = ( + f"Draft a reply to the following email:\n\n" + f"Subject: {email_subject}\n\n" + f"Body:\n{email_body[:2500]}\n\n" + f"Write an appropriate professional response." + ) + + try: + response = await llm_client.generate( + prompt=user_prompt, + system_prompt=system_prompt, + temperature=0.5, + max_tokens=1500, + ) + draft = response.text.strip() + logger.info( + "email_response_drafted", + subject=email_subject[:80], + language=language, + classification=classification, + length=len(draft), + ) + return draft + + except Exception as exc: + logger.error( + "email_response_error", + error=str(exc), + subject=email_subject[:80], + ) + # Return a safe fallback so the email isn't left without a draft + if language == "ar": + return ( + "شكراً لتواصلك. سأراجع رسالتك وأرد عليك في أقرب وقت ممكن.\n\n" + "مع أطيب التحيات،\n" + "سامي محمد العسيري\n" + "مهندس خدمات ميدانية - ميتكو" + ) + return ( + "Thank you for reaching out. I will review your message and get back " + "to you as soon as possible.\n\n" + "Best regards,\n" + "Sami Mohammed Assiri\n" + "Field Services Engineer - METCO" + ) diff --git a/personal-brand-engine/agents/linkedin/__init__.py b/personal-brand-engine/agents/linkedin/__init__.py new file mode 100644 index 00000000..aa77cd20 --- /dev/null +++ b/personal-brand-engine/agents/linkedin/__init__.py @@ -0,0 +1,5 @@ +"""LinkedIn automation agent for personal brand management.""" + +from agents.linkedin.agent import LinkedInAgent + +__all__ = ["LinkedInAgent"] diff --git a/personal-brand-engine/agents/linkedin/agent.py b/personal-brand-engine/agents/linkedin/agent.py new file mode 100644 index 00000000..f529b23e --- /dev/null +++ b/personal-brand-engine/agents/linkedin/agent.py @@ -0,0 +1,219 @@ +"""LinkedIn agent -- creates posts, engages the network, and optimises the profile.""" + +from __future__ import annotations + +import asyncio +import logging +import time +from datetime import datetime, timezone +from typing import Any + +from linkedin_api import Linkedin +from sqlalchemy.orm import Session + +from agents.base_agent import BaseAgent +from agents.linkedin.content_generator import generate_post +from agents.linkedin.engagement import engage_with_feed +from agents.linkedin.profile_optimizer import optimize_profile +from storage.models import Post + +logger = logging.getLogger(__name__) + +# Simple in-memory rate-limiter: maps action -> last-execution timestamp. +_RATE_LIMIT_WINDOW: dict[str, float] = {} + +# Minimum seconds between repeated invocations of the same action. +RATE_LIMIT_SECONDS: dict[str, int] = { + "post_content": 3600, # 1 hour between posts + "engage_network": 1800, # 30 min between engagement rounds + "optimize_profile": 86400, # once per day +} + + +class LinkedInAgent(BaseAgent): + """Autonomous LinkedIn agent for Sami Mohammed Assiri's personal brand.""" + + agent_name: str = "linkedin" + + def __init__( + self, + config: Any, + llm_client: Any, + db_session: Session, + ) -> None: + super().__init__(config, llm_client, db_session) + self._api: Linkedin | None = None + + # ------------------------------------------------------------------ + # LinkedIn API (lazy init) + # ------------------------------------------------------------------ + + def _get_api(self) -> Linkedin: + """Return an authenticated ``linkedin_api.Linkedin`` instance. + + The credentials come from the application settings. The client is + created once and reused for the lifetime of this agent instance. + """ + if self._api is None: + email = self.config.linkedin_email + password = self.config.linkedin_password + if not email or not password: + raise RuntimeError( + "LinkedIn credentials are not configured. " + "Set LINKEDIN_EMAIL and LINKEDIN_PASSWORD in .env." + ) + try: + self._api = Linkedin(email, password) + logger.info("LinkedIn API authenticated for %s", email) + except Exception as exc: + logger.error("LinkedIn authentication failed: %s", exc) + raise + return self._api + + # ------------------------------------------------------------------ + # Rate limiting + # ------------------------------------------------------------------ + + @staticmethod + def _is_rate_limited(action: str) -> bool: + last = _RATE_LIMIT_WINDOW.get(action) + if last is None: + return False + window = RATE_LIMIT_SECONDS.get(action, 0) + return (time.time() - last) < window + + @staticmethod + def _mark_executed(action: str) -> None: + _RATE_LIMIT_WINDOW[action] = time.time() + + # ------------------------------------------------------------------ + # Task dispatcher + # ------------------------------------------------------------------ + + async def run(self, task: str, **kwargs: Any) -> dict: + """Dispatch *task* to the appropriate handler. + + Supported tasks: + - ``post_content`` -- generate and publish a LinkedIn post + - ``engage_network`` -- like / comment on connections' recent posts + - ``optimize_profile`` -- return profile improvement suggestions + """ + dispatch = { + "post_content": self.post_content, + "engage_network": self.engage_network, + "optimize_profile": self.optimize_profile, + } + + handler = dispatch.get(task) + if handler is None: + self.log_action(task, details=f"Unknown task: {task}", status="failed") + return {"status": "error", "message": f"Unknown task: {task}"} + + if self._is_rate_limited(task): + msg = f"Rate-limited: {task} was run too recently." + logger.warning(msg) + self.log_action(task, details=msg, status="skipped") + return {"status": "skipped", "message": msg} + + with self.timer() as t: + try: + result = await handler(**kwargs) + self._mark_executed(task) + self.log_action(task, details=str(result), duration=t.elapsed) + return {"status": "success", "result": result} + except Exception as exc: + logger.exception("Task %s failed", task) + self.log_action( + task, + details=str(exc), + status="failed", + duration=t.elapsed, + ) + await self.notify_owner( + f"[LinkedIn Agent] Task '{task}' failed: {exc}" + ) + return {"status": "error", "message": str(exc)} + + # ------------------------------------------------------------------ + # post_content + # ------------------------------------------------------------------ + + async def post_content(self, *, pillar: str | None = None) -> dict: + """Generate a LinkedIn post via LLM and publish it.""" + brand_profile = self.get_brand_profile() + content_strategy = self.get_content_strategy() + + # Generate the post text + post_text = await generate_post( + self.llm, + brand_profile, + content_strategy, + pillar=pillar, + ) + + # Persist as draft first + post_row = Post( + platform="linkedin", + content=post_text, + status="draft", + ) + self.db.add(post_row) + self.db.flush() + + # Publish via LinkedIn API + api = self._get_api() + try: + api.post(post_text) + post_row.status = "published" + post_row.published_at = datetime.now(timezone.utc) + self.db.commit() + logger.info("Published LinkedIn post id=%s", post_row.id) + except Exception as exc: + post_row.status = "failed" + self.db.commit() + raise RuntimeError(f"Failed to publish post: {exc}") from exc + + return { + "post_id": post_row.id, + "content_preview": post_text[:120], + "published": True, + } + + # ------------------------------------------------------------------ + # engage_network + # ------------------------------------------------------------------ + + async def engage_network( + self, + *, + max_likes: int = 15, + max_comments: int = 5, + ) -> dict: + """Like and comment on recent posts from connections.""" + api = self._get_api() + brand_profile = self.get_brand_profile() + + result = await engage_with_feed( + linkedin_api=api, + llm_client=self.llm, + brand_profile=brand_profile, + max_likes=max_likes, + max_comments=max_comments, + ) + return result + + # ------------------------------------------------------------------ + # optimize_profile + # ------------------------------------------------------------------ + + async def optimize_profile(self) -> dict: + """Return a dict of profile optimisation suggestions.""" + api = self._get_api() + brand_profile = self.get_brand_profile() + + suggestions = await optimize_profile( + llm_client=self.llm, + brand_profile=brand_profile, + linkedin_api=api, + ) + return suggestions diff --git a/personal-brand-engine/agents/linkedin/content_generator.py b/personal-brand-engine/agents/linkedin/content_generator.py new file mode 100644 index 00000000..85218220 --- /dev/null +++ b/personal-brand-engine/agents/linkedin/content_generator.py @@ -0,0 +1,179 @@ +"""Generate LinkedIn posts using LLM with Sami's brand voice.""" + +from __future__ import annotations + +import logging +import random +from pathlib import Path + +import yaml + +logger = logging.getLogger(__name__) + +_TEMPLATES_DIR = Path(__file__).resolve().parent / "prompts" + +# Content pillars that align with Sami's brand strategy +PILLARS = [ + "tech_insights", + "field_life", + "professional_growth", + "industry_news", +] + +SYSTEM_PROMPT = """\ +You are a LinkedIn ghostwriter for Sami Mohammed Assiri. + +=== ABOUT SAMI === +- Field Services Engineer at METCO (Smiths Detection) specialising in airport \ +security screening systems (X-ray, CT, EDS, trace detection). +- Previously worked at Samsung Engineering & Advanced Technology (Samsung E&A) \ +on large-scale EPC projects. +- President of the SPE (Society of Petroleum Engineers) Alasala University Chapter. +- Based in Saudi Arabia; fluent in Arabic and English. +- 10,000+ LinkedIn followers. +- LinkedIn: https://www.linkedin.com/in/sami-assiri-a300622b2/ + +=== VOICE & TONE === +- Professional yet personable -- Sami shares real field experiences. +- Confident expertise without arrogance; generous with knowledge. +- Occasionally uses light humour to keep posts engaging. +- Blends technical depth with accessible language so non-engineers also benefit. +- Passionate about aviation security, engineering excellence, and mentorship. + +=== FORMATTING RULES === +- Use short paragraphs (2-3 sentences max) separated by blank lines. +- Open with a hook -- a bold statement, question, or surprising fact. +- End with a clear call-to-action or thought-provoking question. +- Keep total length between 150 and 300 words. +- Include 3-5 relevant hashtags at the very end. +- Do NOT use bullet-point lists in every post -- vary the structure. +- When writing in Arabic, use Modern Standard Arabic (فصحى) with a Saudi touch. + +=== IMPORTANT === +- Never fabricate certifications, experiences, or statistics. +- Align with the content pillar and topic provided. +- Make it feel authentic -- like Sami typed it himself. +""" + + +def _load_templates() -> dict: + """Load post_templates.yaml once and cache it.""" + path = _TEMPLATES_DIR / "post_templates.yaml" + if not path.exists(): + logger.warning("post_templates.yaml not found at %s", path) + return {} + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def _pick_language(brand_profile: dict) -> str: + """Choose a language for this post based on brand profile preferences.""" + languages = brand_profile.get("languages", ["english", "arabic"]) + # Weighted towards English (60/40) unless overridden + weights = brand_profile.get("language_weights", [60, 40]) + if len(weights) != len(languages): + weights = [1] * len(languages) + return random.choices(languages, weights=weights, k=1)[0] + + +def _pick_pillar(content_strategy: dict, pillar: str | None) -> str: + """Return the pillar to use -- explicit or random weighted choice.""" + if pillar and pillar in PILLARS: + return pillar + pillars = content_strategy.get("pillars", PILLARS) + return random.choice(pillars) + + +def _build_user_prompt( + pillar: str, + language: str, + brand_profile: dict, + content_strategy: dict, + templates: dict, +) -> str: + """Assemble the user prompt sent to the LLM.""" + hashtags = content_strategy.get("hashtags", {}).get(pillar, []) + hashtag_str = " ".join(f"#{h}" for h in hashtags) if hashtags else "" + + # Try to pick a template for extra guidance + template_block = "" + pillar_templates = templates.get(pillar, {}).get(language, []) + if pillar_templates: + template_block = ( + f"\nHere is a sample template for inspiration (do NOT copy verbatim):\n" + f"---\n{random.choice(pillar_templates)}\n---\n" + ) + + lang_instruction = ( + "Write the post in Arabic (فصحى with a Saudi touch)." + if language == "arabic" + else "Write the post in English." + ) + + return ( + f"Content pillar: {pillar}\n" + f"Language: {language}\n" + f"{lang_instruction}\n" + f"{template_block}\n" + f"Suggested hashtags to weave in at the end: {hashtag_str}\n\n" + f"Now write a LinkedIn post for Sami. Return ONLY the post text -- " + f"no preamble, no labels, no markdown formatting." + ) + + +async def generate_post( + llm_client, + brand_profile: dict, + content_strategy: dict, + pillar: str | None = None, +) -> str: + """Generate a single LinkedIn post using the configured LLM. + + Parameters + ---------- + llm_client: + An ``LLMClient`` instance with an ``async generate()`` method. + brand_profile: + Parsed ``brand_profile.yaml`` dict. + content_strategy: + Parsed ``content_strategy.yaml`` dict. + pillar: + Optional content pillar override. If ``None`` a random pillar is + chosen based on the content strategy weights. + + Returns + ------- + str + The generated post text ready for publishing. + """ + templates = _load_templates() + language = _pick_language(brand_profile) + chosen_pillar = _pick_pillar(content_strategy, pillar) + + user_prompt = _build_user_prompt( + chosen_pillar, language, brand_profile, content_strategy, templates + ) + + response = await llm_client.generate( + prompt=user_prompt, + system_prompt=SYSTEM_PROMPT, + temperature=0.8, + max_tokens=1500, + ) + + post_text = response.text.strip() + + # Sanity-check length -- if the LLM went overboard, truncate gracefully + words = post_text.split() + if len(words) > 400: + post_text = " ".join(words[:350]) + "\n\n..." + logger.warning("Post was too long (%d words); truncated.", len(words)) + + logger.info( + "Generated %s post for pillar=%s (%d words, provider=%s)", + language, + chosen_pillar, + len(post_text.split()), + response.provider, + ) + return post_text diff --git a/personal-brand-engine/agents/linkedin/engagement.py b/personal-brand-engine/agents/linkedin/engagement.py new file mode 100644 index 00000000..f77d140e --- /dev/null +++ b/personal-brand-engine/agents/linkedin/engagement.py @@ -0,0 +1,210 @@ +"""Engage with the LinkedIn feed -- like and comment on relevant posts.""" + +from __future__ import annotations + +import logging +import random +from pathlib import Path + +import yaml + +logger = logging.getLogger(__name__) + +_TEMPLATES_DIR = Path(__file__).resolve().parent / "prompts" + +# Topics Sami cares about (used for relevance filtering) +TARGET_KEYWORDS = [ + "airport security", + "aviation", + "smiths detection", + "metco", + "x-ray", + "screening", + "baggage", + "checkpoint", + "ct scanner", + "trace detection", + "eds", + "engineering", + "field service", + "epc", + "gaca", + "icao", + "saudi arabia", + "spe", + "petroleum", + "oil and gas", + "أمن المطارات", + "هندسة", + "الطيران", +] + +COMMENT_SYSTEM_PROMPT = """\ +You are writing a LinkedIn comment on behalf of Sami Mohammed Assiri, a Field \ +Services Engineer at METCO (Smiths Detection) specialising in airport security \ +technology. + +Guidelines: +- Be genuine and insightful -- add real value, not generic praise. +- Reference a specific point from the post when possible. +- Keep it between 1 and 3 sentences. +- Maintain a professional yet warm tone. +- Do NOT be sycophantic ("Great post!", "Love this!", "Amazing insight!"). +- If the post is in Arabic, comment in Arabic. Otherwise, use English. +- Never self-promote or include links. +""" + + +def _load_comment_templates() -> dict: + """Load comment_templates.yaml.""" + path = _TEMPLATES_DIR / "comment_templates.yaml" + if not path.exists(): + return {} + with open(path, "r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def _is_relevant(post_text: str) -> bool: + """Rough keyword check to decide if a post is worth engaging with.""" + lower = post_text.lower() + return any(kw in lower for kw in TARGET_KEYWORDS) + + +def _extract_post_text(post: dict) -> str: + """Safely pull the textual content from a linkedin-api post dict.""" + try: + commentary = ( + post.get("commentary", "") + or post.get("specificContent", {}) + .get("com.linkedin.ugc.ShareContent", {}) + .get("shareCommentary", {}) + .get("text", "") + ) + return commentary or "" + except (AttributeError, TypeError): + return "" + + +def _extract_post_urn(post: dict) -> str | None: + """Extract the post URN (entity ID) from a feed post dict.""" + return post.get("dashEntityUrn") or post.get("entityUrn") or post.get("urn") + + +async def _generate_comment(llm_client, post_text: str, brand_profile: dict) -> str: + """Use the LLM to craft a thoughtful comment for the given post.""" + templates = _load_comment_templates() + + # Provide a few example styles to guide the LLM + example_block = "" + categories = list(templates.values()) if templates else [] + if categories: + flat = [t for cat in categories for t in (cat if isinstance(cat, list) else [])] + if flat: + samples = random.sample(flat, min(2, len(flat))) + example_block = ( + "\nExample comment styles (do NOT copy verbatim):\n" + + "\n".join(f"- {s}" for s in samples) + + "\n" + ) + + user_prompt = ( + f"Original LinkedIn post:\n\"\"\"\n{post_text[:1500]}\n\"\"\"\n\n" + f"{example_block}\n" + f"Write a comment as Sami. Return ONLY the comment text." + ) + + response = await llm_client.generate( + prompt=user_prompt, + system_prompt=COMMENT_SYSTEM_PROMPT, + temperature=0.75, + max_tokens=300, + ) + return response.text.strip().strip('"') + + +async def engage_with_feed( + linkedin_api, + llm_client, + brand_profile: dict, + max_likes: int = 15, + max_comments: int = 5, +) -> dict: + """Like and comment on recent relevant posts in Sami's LinkedIn feed. + + Parameters + ---------- + linkedin_api: + Authenticated ``linkedin_api.Linkedin`` instance. + llm_client: + LLM client for generating comments. + brand_profile: + Parsed brand profile dict. + max_likes: + Maximum number of posts to like in this round. + max_comments: + Maximum number of posts to comment on in this round. + + Returns + ------- + dict + Summary of actions taken (likes, comments, errors). + """ + liked = 0 + commented = 0 + errors: list[str] = [] + + try: + feed = linkedin_api.get_feed_posts(limit=50) + except Exception as exc: + logger.error("Failed to fetch feed: %s", exc) + return {"liked": 0, "commented": 0, "errors": [str(exc)]} + + if not feed: + logger.info("Feed returned no posts.") + return {"liked": 0, "commented": 0, "errors": []} + + # Shuffle to avoid always engaging with the same people + random.shuffle(feed) + + for post in feed: + if liked >= max_likes and commented >= max_comments: + break + + post_text = _extract_post_text(post) + post_urn = _extract_post_urn(post) + + if not post_urn: + continue + + # --- Like --- + if liked < max_likes: + try: + linkedin_api.like(post_urn) + liked += 1 + logger.debug("Liked post %s", post_urn) + except Exception as exc: + errors.append(f"Like failed ({post_urn}): {exc}") + logger.warning("Failed to like %s: %s", post_urn, exc) + + # --- Comment (only on relevant posts) --- + if commented < max_comments and post_text and _is_relevant(post_text): + try: + comment_text = await _generate_comment( + llm_client, post_text, brand_profile + ) + linkedin_api.comment(post_urn, comment_text) + commented += 1 + logger.info( + "Commented on %s: %s", post_urn, comment_text[:80] + ) + except Exception as exc: + errors.append(f"Comment failed ({post_urn}): {exc}") + logger.warning("Failed to comment on %s: %s", post_urn, exc) + + summary = { + "liked": liked, + "commented": commented, + "errors": errors[:10], # cap stored errors + } + logger.info("Engagement round complete: %s", summary) + return summary diff --git a/personal-brand-engine/agents/linkedin/profile_optimizer.py b/personal-brand-engine/agents/linkedin/profile_optimizer.py new file mode 100644 index 00000000..b8afe243 --- /dev/null +++ b/personal-brand-engine/agents/linkedin/profile_optimizer.py @@ -0,0 +1,182 @@ +"""Analyse and suggest improvements for Sami's LinkedIn profile.""" + +from __future__ import annotations + +import logging + +logger = logging.getLogger(__name__) + +OPTIMIZER_SYSTEM_PROMPT = """\ +You are a LinkedIn profile optimisation expert. You are reviewing the profile \ +of Sami Mohammed Assiri, a Field Services Engineer at METCO (Smiths Detection) \ +who works on airport security screening systems. + +Background: +- Previously at Samsung Engineering & Advanced Technology (Samsung E&A). +- President of SPE Alasala University Chapter. +- 10,000+ followers on LinkedIn. +- Based in Saudi Arabia; bilingual (Arabic & English). +- LinkedIn: https://www.linkedin.com/in/sami-assiri-a300622b2/ + +Your task is to analyse the current profile data provided and suggest concrete, \ +actionable improvements. Focus on: +1. **Headline** -- make it keyword-rich, compelling, and position Sami as an \ + authority in aviation security engineering. +2. **Summary / About section** -- craft a narrative that tells Sami's story, \ + highlights achievements, and includes a clear value proposition. +3. **Skills & Endorsements** -- recommend high-impact skills to add or reorder. +4. **Experience bullets** -- suggest power verbs and quantifiable achievements. +5. **Keywords** -- identify SEO-friendly keywords that recruiters and peers search for. + +Return your answer as structured JSON with keys: headline, summary, skills, \ +experience_tips, keywords, general_tips. Each value should be a string or \ +list of strings. +""" + + +def _extract_profile_data(linkedin_api) -> dict: + """Fetch the authenticated user's profile from the LinkedIn API. + + Returns a simplified dict with the fields we care about. + """ + try: + profile = linkedin_api.get_profile( + public_id="sami-assiri-a300622b2" + ) + except Exception as exc: + logger.error("Failed to fetch LinkedIn profile: %s", exc) + return {} + + return { + "first_name": profile.get("firstName", ""), + "last_name": profile.get("lastName", ""), + "headline": profile.get("headline", ""), + "summary": profile.get("summary", ""), + "industry": profile.get("industryName", ""), + "location": profile.get("locationName", ""), + "skills": [ + s.get("name", "") + for s in profile.get("skills", []) + ], + "experience": [ + { + "title": exp.get("title", ""), + "company": exp.get("companyName", ""), + "description": exp.get("description", ""), + } + for exp in profile.get("experience", []) + ], + "education": [ + { + "school": edu.get("schoolName", ""), + "degree": edu.get("degreeName", ""), + "field": edu.get("fieldOfStudy", ""), + } + for edu in profile.get("education", []) + ], + "follower_count": profile.get("followerCount", "10000+"), + } + + +async def optimize_profile( + llm_client, + brand_profile: dict, + linkedin_api, +) -> dict: + """Analyse Sami's LinkedIn profile and return optimisation suggestions. + + Parameters + ---------- + llm_client: + LLM client with ``async generate()``. + brand_profile: + Parsed ``brand_profile.yaml`` dict. + linkedin_api: + Authenticated ``linkedin_api.Linkedin`` instance. + + Returns + ------- + dict + Structured suggestions with keys: headline, summary, skills, + experience_tips, keywords, general_tips. + """ + current = _extract_profile_data(linkedin_api) + + if not current: + logger.warning( + "Could not fetch profile data; generating generic suggestions." + ) + current = { + "headline": brand_profile.get("headline", ""), + "summary": brand_profile.get("summary", ""), + "skills": brand_profile.get("skills", []), + } + + user_prompt = ( + "Here is the current LinkedIn profile data:\n" + f"{_format_profile(current)}\n\n" + "Analyse this profile and provide specific improvement suggestions. " + "Return ONLY valid JSON -- no markdown fences, no preamble." + ) + + response = await llm_client.generate( + prompt=user_prompt, + system_prompt=OPTIMIZER_SYSTEM_PROMPT, + temperature=0.6, + max_tokens=2000, + ) + + # Try to parse JSON; fall back to raw text + import json + + try: + suggestions = json.loads(response.text.strip()) + except json.JSONDecodeError: + logger.warning("LLM did not return valid JSON; returning raw text.") + suggestions = { + "raw_suggestions": response.text.strip(), + "headline": "", + "summary": "", + "skills": [], + "experience_tips": [], + "keywords": [], + "general_tips": [], + } + + logger.info("Profile optimisation complete (provider=%s)", response.provider) + return suggestions + + +def _format_profile(data: dict) -> str: + """Pretty-format profile data for the LLM prompt.""" + lines = [ + f"Name: {data.get('first_name', '')} {data.get('last_name', '')}", + f"Headline: {data.get('headline', 'N/A')}", + f"Industry: {data.get('industry', 'N/A')}", + f"Location: {data.get('location', 'N/A')}", + f"Followers: {data.get('follower_count', 'N/A')}", + f"\nSummary:\n{data.get('summary', 'N/A')}", + f"\nSkills: {', '.join(data.get('skills', [])) or 'N/A'}", + ] + + experience = data.get("experience", []) + if experience: + lines.append("\nExperience:") + for exp in experience[:5]: + lines.append( + f" - {exp.get('title', '')} at {exp.get('company', '')}" + ) + desc = exp.get("description", "") + if desc: + lines.append(f" {desc[:300]}") + + education = data.get("education", []) + if education: + lines.append("\nEducation:") + for edu in education[:3]: + lines.append( + f" - {edu.get('degree', '')} in {edu.get('field', '')} " + f"from {edu.get('school', '')}" + ) + + return "\n".join(lines) diff --git a/personal-brand-engine/agents/linkedin/prompts/comment_templates.yaml b/personal-brand-engine/agents/linkedin/prompts/comment_templates.yaml new file mode 100644 index 00000000..ed800cbd --- /dev/null +++ b/personal-brand-engine/agents/linkedin/prompts/comment_templates.yaml @@ -0,0 +1,29 @@ +# Comment templates for LinkedIn engagement +# These are NOT posted verbatim -- they guide the LLM toward the right tone +# and structure. Grouped by the type of post being responded to. + +technical_post: + - "This resonates with what I see in the field working on screening systems. The challenge of {specific_point} is something we navigate daily at airport checkpoints." + - "Interesting perspective on {topic}. In airport security we face a similar trade-off between detection accuracy and throughput speed." + - "Great breakdown of {topic}. I have found that hands-on calibration experience often reveals nuances that specs alone do not capture." + +career_advice: + - "This is solid advice. Leading the SPE chapter at university taught me the same lesson -- growth happens when you volunteer for the roles nobody else wants." + - "Completely agree on {specific_point}. Moving from Samsung E&A to field service at Smiths Detection was uncomfortable at first, but it accelerated my growth more than any classroom could." + - "I wish someone had told me this earlier in my career. The transition from university to field engineering is steep, and advice like this makes a real difference." + +industry_news: + - "Important development. For those of us in airport security, this will directly impact how we approach {specific_point} at the checkpoint level." + - "The timing of this is significant given the pace of airport expansion in the GCC. Curious to see how {specific_point} plays out in practice." + - "Worth watching closely. On the ground, we are already seeing early signs of this shift in the systems being deployed across Saudi airports." + +personal_story: + - "Thanks for sharing this. The honesty about {specific_point} is refreshing -- too many people on LinkedIn only share the highlight reel." + - "This is the kind of post that makes LinkedIn worthwhile. Real stories from the field always teach more than polished corporate updates." + - "I had a very similar experience during my first months as a field engineer. It is reassuring to know that the learning curve is universal." + +arabic_post: + - "محتوى قيّم جدًا. من خلال تجربتي في مجال أمن المطارات، أرى أن {specific_point} يمثل تحديًا حقيقيًا نواجهه يوميًا." + - "شكرًا على المشاركة. هذا يتماشى مع ما نراه في الميدان. التطور في هذا المجال يتسارع بشكل ملحوظ." + - "نقطة ممتازة. في عملي كمهندس خدمات ميدانية، تعلمت أن {specific_point} هو مفتاح النجاح في هذا القطاع." + - "كلام في الصميم. قطاع الطيران في المملكة يشهد نموًا كبيرًا وهذه النوعية من النقاشات مهمة جدًا." diff --git a/personal-brand-engine/agents/linkedin/prompts/post_templates.yaml b/personal-brand-engine/agents/linkedin/prompts/post_templates.yaml new file mode 100644 index 00000000..8058df26 --- /dev/null +++ b/personal-brand-engine/agents/linkedin/prompts/post_templates.yaml @@ -0,0 +1,177 @@ +# LinkedIn post templates for Sami Mohammed Assiri +# Each pillar has English and Arabic templates as starting inspiration for the LLM. +# Templates are NOT published verbatim -- they guide structure and tone. + +tech_insights: + english: + - | + Most people walk through airport security without a second thought. + + But behind that conveyor belt is a symphony of physics, algorithms, and + engineering precision. Dual-energy X-ray, CT reconstruction, automated + threat detection -- each layer exists because someone asked "what if we + miss something?" + + At Smiths Detection, I get to work on that question every day. + + What airport security technology surprised you the most? + + #AviationSecurity #SmithsDetection #AirportTechnology #Engineering #METCO + - | + A single CT scanner at an airport checkpoint processes hundreds of bags + per hour. But what happens when it flags an anomaly? + + That is where field engineers step in -- calibrating, troubleshooting, + and making sure the system keeps passengers safe without grinding the + queue to a halt. + + Here is what I have learned about balancing speed and security... + + #FieldEngineering #CTScanner #AirportSecurity #SmithsDetection #Aviation + arabic: + - | + هل تساءلت يومًا كيف تعمل أجهزة الفحص الأمني في المطارات؟ + + خلف الكواليس، هناك تكنولوجيا متقدمة تجمع بين الأشعة السينية ثنائية + الطاقة وخوارزميات الذكاء الاصطناعي للكشف عن التهديدات في أجزاء من الثانية. + + كمهندس ميداني في شركة سميثس ديتيكشن، أعمل يوميًا على ضمان أن هذه + الأنظمة تعمل بأعلى كفاءة لحماية المسافرين. + + ما الذي يثير فضولك حول تقنيات أمن المطارات؟ + + #أمن_المطارات #سميثس_ديتيكشن #هندسة #تكنولوجيا #METCO + +field_life: + english: + - | + 6 AM. Airport tarmac. A screening system is down and flights start + boarding in two hours. + + This is the reality of field service engineering -- you do not get to + debug from a comfortable desk. You troubleshoot under pressure, with + real consequences if you get it wrong. + + But honestly? I would not trade it for anything. There is something + deeply satisfying about bringing a critical system back online and + watching operations resume smoothly. + + What does a typical "crisis morning" look like in your field? + + #FieldEngineer #DayInTheLife #AirportOperations #Engineering #ProblemSolving + - | + People ask me what a Field Services Engineer actually does. + + Short answer: I keep airport security systems running so you can catch + your flight safely. + + Long answer: I calibrate CT scanners, diagnose firmware issues at 3 AM, + train local technicians, and occasionally explain to airport managers + why preventive maintenance is cheaper than emergency repairs. + + Every day is different, and that is exactly why I love this work. + + #FieldService #SmithsDetection #Engineering #AviationSecurity #CareerStory + arabic: + - | + الساعة السادسة صباحًا. المطار. نظام الفحص الأمني متوقف والرحلات على + وشك الانطلاق. + + هذا هو واقع مهندس الخدمات الميدانية -- لا وقت للتردد، كل دقيقة تأخير + تعني تأثيرًا على مئات المسافرين. + + التشخيص السريع والحل الفعال هما مفتاح النجاح في هذا المجال. وبعد كل + إصلاح ناجح، تشعر بفخر حقيقي أنك ساهمت في استمرار العمليات بسلاسة. + + كيف تتعامل مع الضغط في عملك؟ + + #مهندس_ميداني #حياة_المهندس #أمن_المطارات #هندسة #METCO + +professional_growth: + english: + - | + Two years ago, I was finishing my engineering degree and wondering + what comes next. + + Today, I am maintaining advanced security systems at international + airports and leading the SPE chapter at my university. + + The difference was not talent -- it was saying yes to every + uncomfortable opportunity: presenting at conferences, taking the + overseas assignment, volunteering to lead when no one else would. + + What was the one "yes" that changed your career trajectory? + + #ProfessionalGrowth #Engineering #SPE #CareerAdvice #Leadership + - | + I just completed a certification that took months of evening study + after long field shifts. + + Was it worth it? Absolutely. + + Not because of the certificate itself, but because the process forced + me to master concepts I had been hand-waving through for years. + + If you are debating whether to pursue that certification -- start today. + Future you will be grateful. + + #ContinuousLearning #Certification #Engineering #CareerDevelopment #Growth + arabic: + - | + قبل عامين كنت طالبًا جامعيًا أتساءل عن مستقبلي المهني. + + اليوم أعمل كمهندس خدمات ميدانية على أنظمة أمنية متقدمة في المطارات + الدولية، وأترأس فرع جمعية مهندسي البترول في جامعة العسالة. + + الفرق لم يكن الموهبة -- بل الاستعداد لقبول كل فرصة حتى لو كانت + خارج منطقة الراحة. + + ما القرار الذي غيّر مسارك المهني؟ + + #تطوير_مهني #هندسة #قيادة #SPE #نصائح_مهنية + +industry_news: + english: + - | + ICAO just released updated screening standards that will reshape + airport security globally. + + Here is what it means for the industry: + + The shift toward CT-based cabin baggage screening is accelerating. + Airports that have not started planning their technology refresh are + already behind. + + For field engineers like me, this means more deployments, more complex + integrations, and a massive need for trained technicians. + + How is your airport preparing for the next generation of screening? + + #ICAO #AviationSecurity #AirportScreening #CTScanner #SmithsDetection + - | + Saudi Arabia's aviation sector is growing at an unprecedented rate. + New airports, expanded terminals, Vision 2030 targets. + + Behind every new gate is a security checkpoint that needs to be + designed, installed, calibrated, and maintained. + + This is an exciting time to be in aviation security engineering in + the Kingdom. + + What Vision 2030 developments are you most excited about? + + #Vision2030 #SaudiArabia #Aviation #GACA #AirportSecurity #Engineering + arabic: + - | + أعلنت منظمة الطيران المدني الدولي (إيكاو) عن معايير فحص محدثة ستغير + ملامح أمن المطارات عالميًا. + + التحول نحو أنظمة الفحص بتقنية التصوير المقطعي المحوسب يتسارع، والمطارات + التي لم تبدأ بالتخطيط لتحديث تقنياتها أصبحت متأخرة. + + قطاع الطيران في المملكة العربية السعودية ينمو بوتيرة غير مسبوقة ضمن + رؤية 2030، وهذا يعني فرصًا هائلة لمهندسي أمن الطيران. + + ما التطورات في قطاع الطيران السعودي التي تتابعها باهتمام؟ + + #إيكاو #أمن_الطيران #رؤية_2030 #المملكة_العربية_السعودية #GACA #هندسة diff --git a/personal-brand-engine/agents/opportunity_scout/__init__.py b/personal-brand-engine/agents/opportunity_scout/__init__.py new file mode 100644 index 00000000..4e4440e6 --- /dev/null +++ b/personal-brand-engine/agents/opportunity_scout/__init__.py @@ -0,0 +1,5 @@ +"""Opportunity Scout agent -- monitors the internet for career opportunities.""" + +from agents.opportunity_scout.agent import OpportunityScoutAgent + +__all__ = ["OpportunityScoutAgent"] diff --git a/personal-brand-engine/agents/opportunity_scout/agent.py b/personal-brand-engine/agents/opportunity_scout/agent.py new file mode 100644 index 00000000..36863ed0 --- /dev/null +++ b/personal-brand-engine/agents/opportunity_scout/agent.py @@ -0,0 +1,396 @@ +"""Opportunity Scout Agent -- monitors the internet for career opportunities, +industry events, and relevant news for Sami Assiri. + +Supported tasks (passed to ``run(task)``): + +- ``scan_opportunities`` -- run all scanners and score results +- ``scan_linkedin_jobs`` -- search LinkedIn for relevant job postings +- ``scan_industry_news`` -- monitor aviation / security news and GACA +- ``daily_digest`` -- compile found opportunities and send notifications +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from agents.base_agent import BaseAgent +from agents.opportunity_scout.notifier import ( + send_daily_digest, + send_email_notification, + send_whatsapp_notification, +) +from agents.opportunity_scout.scanners import ( + scan_gaca_announcements, + scan_google_jobs, + scan_linkedin_jobs_api, + scan_news, + scan_smiths_detection_careers, +) +from agents.opportunity_scout.scorer import score_opportunity +from config.settings import get_settings +from storage.models import Opportunity +from utils.logger import get_logger + +logger = get_logger(__name__) + +# Minimum relevance score to trigger a notification +_NOTIFY_THRESHOLD = 0.45 + + +class OpportunityScoutAgent(BaseAgent): + """Autonomous agent that scans the internet for opportunities relevant + to Sami Assiri's career profile and sends notifications.""" + + agent_name: str = "opportunity_scout" + + # ------------------------------------------------------------------ + # Task dispatcher + # ------------------------------------------------------------------ + + async def run(self, task: str, **kwargs: Any) -> dict: + """Dispatch to the appropriate sub-task handler. + + Parameters + ---------- + task: + One of ``scan_opportunities``, ``scan_linkedin_jobs``, + ``scan_industry_news``, or ``daily_digest``. + + Returns + ------- + dict + Result summary with keys like ``count``, ``opportunities``, + ``notifications_sent``, etc. + """ + dispatch = { + "scan_opportunities": self._scan_opportunities, + "scan_linkedin_jobs": self._scan_linkedin_jobs, + "scan_industry_news": self._scan_industry_news, + "daily_digest": self._daily_digest, + } + + handler = dispatch.get(task) + if handler is None: + self.log_action( + f"unknown_task:{task}", + details=f"Valid tasks: {', '.join(dispatch)}", + status="failed", + ) + return {"error": f"Unknown task: {task}", "valid_tasks": list(dispatch)} + + with self.timer() as t: + result = await handler(**kwargs) + + self.log_action(task, details=str(result.get("count", 0)), duration=t.elapsed) + return result + + # ------------------------------------------------------------------ + # scan_opportunities -- full scan across all sources + # ------------------------------------------------------------------ + + async def _scan_opportunities(self, **kwargs: Any) -> dict: + """Run all scanners, score results, store and notify.""" + brand_profile = self.get_brand_profile() + linkedin_api = kwargs.get("linkedin_api") + + # Run all scanners + raw_opportunities: list[dict] = [] + + google_results = await self._safe_scan("google_jobs", scan_google_jobs) + raw_opportunities.extend(google_results) + + linkedin_results = await self._safe_scan( + "linkedin_jobs", + scan_linkedin_jobs_api, + linkedin_api=linkedin_api, + ) + raw_opportunities.extend(linkedin_results) + + news_results = await self._safe_scan("industry_news", scan_news) + raw_opportunities.extend(news_results) + + smiths_results = await self._safe_scan( + "smiths_detection", scan_smiths_detection_careers + ) + raw_opportunities.extend(smiths_results) + + gaca_results = await self._safe_scan( + "gaca_announcements", scan_gaca_announcements + ) + raw_opportunities.extend(gaca_results) + + logger.info("scan_raw_total", count=len(raw_opportunities)) + + # Deduplicate across sources by URL then title + unique = self._deduplicate(raw_opportunities) + + # Score each opportunity + scored: list[dict] = [] + for opp in unique: + if not self._is_already_tracked(opp): + opp["relevance_score"] = await score_opportunity( + self.llm, opp, brand_profile + ) + scored.append(opp) + + # Store in database + stored = self._store_opportunities(scored) + + # Notify on high-relevance opportunities + notified_count = await self._notify_high_relevance(scored) + + return { + "count": len(scored), + "stored": stored, + "notified": notified_count, + "sources": { + "google_jobs": len(google_results), + "linkedin": len(linkedin_results), + "news": len(news_results), + "smiths_detection": len(smiths_results), + "gaca": len(gaca_results), + }, + } + + # ------------------------------------------------------------------ + # scan_linkedin_jobs -- LinkedIn-focused scan + # ------------------------------------------------------------------ + + async def _scan_linkedin_jobs(self, **kwargs: Any) -> dict: + """Search LinkedIn for relevant job postings.""" + brand_profile = self.get_brand_profile() + linkedin_api = kwargs.get("linkedin_api") + + keywords = [ + "Smiths Detection", + "airport security engineer", + "field services engineer Saudi", + "METCO engineer", + "Rapiscan field engineer", + "L3Harris security Saudi", + "aviation security engineer", + "mechanical engineer airport", + ] + + results = await self._safe_scan( + "linkedin_jobs", + scan_linkedin_jobs_api, + linkedin_api=linkedin_api, + keywords=keywords, + ) + + scored: list[dict] = [] + for opp in results: + if not self._is_already_tracked(opp): + opp["relevance_score"] = await score_opportunity( + self.llm, opp, brand_profile + ) + scored.append(opp) + + stored = self._store_opportunities(scored) + notified_count = await self._notify_high_relevance(scored) + + return { + "count": len(scored), + "stored": stored, + "notified": notified_count, + "source": "linkedin", + } + + # ------------------------------------------------------------------ + # scan_industry_news -- news and GACA monitoring + # ------------------------------------------------------------------ + + async def _scan_industry_news(self, **kwargs: Any) -> dict: + """Monitor aviation security news and GACA announcements.""" + brand_profile = self.get_brand_profile() + + news_results = await self._safe_scan("industry_news", scan_news) + smiths_results = await self._safe_scan( + "smiths_detection", scan_smiths_detection_careers + ) + gaca_results = await self._safe_scan( + "gaca_announcements", scan_gaca_announcements + ) + + all_news = news_results + smiths_results + gaca_results + unique = self._deduplicate(all_news) + + scored: list[dict] = [] + for opp in unique: + if not self._is_already_tracked(opp): + opp["relevance_score"] = await score_opportunity( + self.llm, opp, brand_profile + ) + scored.append(opp) + + stored = self._store_opportunities(scored) + notified_count = await self._notify_high_relevance(scored) + + return { + "count": len(scored), + "stored": stored, + "notified": notified_count, + "sources": { + "news": len(news_results), + "smiths_detection": len(smiths_results), + "gaca": len(gaca_results), + }, + } + + # ------------------------------------------------------------------ + # daily_digest -- compile and send + # ------------------------------------------------------------------ + + async def _daily_digest(self, **kwargs: Any) -> dict: + """Compile all recent opportunities into a digest and send it.""" + # First run a fresh scan + scan_result = await self._scan_opportunities(**kwargs) + + # Fetch all opportunities with status 'new' (not yet digested) + new_opps = ( + self.db.query(Opportunity) + .filter(Opportunity.status.in_(["new", "notified"])) + .order_by(Opportunity.relevance_score.desc()) + .all() + ) + + opp_dicts = [ + { + "title": o.title, + "company": o.company or "", + "url": o.url or "", + "description": (o.description or "")[:300], + "source": o.source, + "relevance_score": o.relevance_score, + } + for o in new_opps + ] + + settings = get_settings() + digest_results = await send_daily_digest(settings, opp_dicts) + + # Mark opportunities as notified + for o in new_opps: + o.status = "notified" + o.notified_at = datetime.utcnow() + self.db.commit() + + return { + "scan": scan_result, + "digest_count": len(opp_dicts), + "channels": digest_results, + } + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + async def _safe_scan( + self, name: str, scanner_fn: Any, **kwargs: Any + ) -> list[dict]: + """Run a scanner function with error handling.""" + try: + results = await scanner_fn(**kwargs) + logger.info(f"scanner_{name}_complete", count=len(results)) + return results + except Exception as exc: # noqa: BLE001 + logger.error(f"scanner_{name}_failed", error=str(exc)) + self.log_action( + f"scan_{name}", + details=str(exc), + status="failed", + ) + return [] + + def _deduplicate(self, opportunities: list[dict]) -> list[dict]: + """Remove duplicate opportunities by URL, falling back to title.""" + seen: set[str] = set() + unique: list[dict] = [] + for opp in opportunities: + key = opp.get("url") or opp.get("title", "") + if key and key not in seen: + seen.add(key) + unique.append(opp) + return unique + + def _is_already_tracked(self, opp: dict) -> bool: + """Check if an opportunity with the same URL or title already exists.""" + url = opp.get("url") + if url: + existing = ( + self.db.query(Opportunity) + .filter(Opportunity.url == url) + .first() + ) + if existing: + return True + + title = opp.get("title") + company = opp.get("company") + if title and company: + existing = ( + self.db.query(Opportunity) + .filter( + Opportunity.title == title, + Opportunity.company == company, + ) + .first() + ) + if existing: + return True + + return False + + def _store_opportunities(self, opportunities: list[dict]) -> int: + """Persist scored opportunities to the database.""" + count = 0 + for opp in opportunities: + try: + record = Opportunity( + source=opp.get("source", "unknown"), + title=opp.get("title", "Untitled"), + company=opp.get("company"), + url=opp.get("url"), + description=opp.get("description"), + relevance_score=opp.get("relevance_score"), + status="new", + ) + self.db.add(record) + count += 1 + except Exception as exc: # noqa: BLE001 + logger.error( + "store_opportunity_error", + title=opp.get("title"), + error=str(exc), + ) + self.db.flush() + return count + + async def _notify_high_relevance(self, opportunities: list[dict]) -> int: + """Send immediate notifications for high-relevance opportunities.""" + settings = get_settings() + notified = 0 + + for opp in opportunities: + score = opp.get("relevance_score", 0) or 0 + if score < _NOTIFY_THRESHOLD: + continue + + # Try WhatsApp first, then email, then fallback to base notify + whatsapp_sent = await send_whatsapp_notification(settings, opp) + email_sent = await send_email_notification(settings, opp) + + if not whatsapp_sent and not email_sent: + # Fallback to Telegram / log via base class + msg = ( + f"🔔 Opportunity [{int(score * 100)}%]: " + f"{opp.get('title', 'N/A')} at {opp.get('company', 'N/A')}\n" + f"{opp.get('url', '')}" + ) + await self.notify_owner(msg) + + notified += 1 + + return notified diff --git a/personal-brand-engine/agents/opportunity_scout/notifier.py b/personal-brand-engine/agents/opportunity_scout/notifier.py new file mode 100644 index 00000000..490b44c6 --- /dev/null +++ b/personal-brand-engine/agents/opportunity_scout/notifier.py @@ -0,0 +1,327 @@ +"""Notification helpers for the Opportunity Scout agent. + +Sends opportunity alerts and daily digests via WhatsApp (Meta Cloud API +or Twilio), email (SMTP), and Telegram (via the shared notification util). +Messages are formatted bilingually (Arabic + English) with clear structure. +""" + +from __future__ import annotations + +import smtplib +from datetime import datetime +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from typing import Any + +import httpx + +from utils.logger import get_logger + +logger = get_logger(__name__) + + +# --------------------------------------------------------------------------- +# Message formatting +# --------------------------------------------------------------------------- + +def _format_opportunity_message(opp: dict) -> str: + """Build a nicely formatted bilingual opportunity message.""" + score = opp.get("relevance_score", 0.0) or 0.0 + score_pct = int(score * 100) + + # Score-based indicator + if score >= 0.8: + indicator = "\U0001f525\U0001f525\U0001f525" # fire + elif score >= 0.6: + indicator = "\u2b50\u2b50" # stars + elif score >= 0.4: + indicator = "\U0001f4a1" # lightbulb + else: + indicator = "\U0001f4cb" # clipboard + + lines = [ + f"{indicator} \u0641\u0631\u0635\u0629 \u062c\u062f\u064a\u062f\u0629 / New Opportunity", + "", + f"\U0001f4cc {opp.get('title', 'N/A')}", + f"\U0001f3e2 {opp.get('company', 'N/A')}", + f"\U0001f4ca \u0627\u0644\u062a\u0648\u0627\u0641\u0642 / Relevance: {score_pct}%", + f"\U0001f310 {opp.get('source', 'N/A')}", + ] + + if opp.get("url"): + lines.append(f"\U0001f517 {opp['url']}") + + desc = (opp.get("description") or "")[:300] + if desc: + lines.append(f"\n\U0001f4dd {desc}") + + return "\n".join(lines) + + +def _format_digest_message(opportunities: list[dict]) -> str: + """Build a daily digest summarizing all opportunities found.""" + now = datetime.utcnow().strftime("%Y-%m-%d") + + header = ( + f"\U0001f4e8 \u0627\u0644\u0645\u0644\u062e\u0635 \u0627\u0644\u064a\u0648\u0645\u064a / Daily Digest -- {now}\n" + f"\u2500" * 30 + "\n" + f"\U0001f50d \u062a\u0645 \u0627\u0644\u0639\u062b\u0648\u0631 \u0639\u0644\u0649 {len(opportunities)} " + f"\u0641\u0631\u0635\u0629 / {len(opportunities)} opportunities found\n" + ) + + if not opportunities: + return header + "\n\u0644\u0627 \u062a\u0648\u062c\u062f \u0641\u0631\u0635 \u062c\u062f\u064a\u062f\u0629 \u0627\u0644\u064a\u0648\u0645 / No new opportunities today." + + # Sort by relevance descending + sorted_opps = sorted( + opportunities, + key=lambda o: o.get("relevance_score", 0) or 0, + reverse=True, + ) + + sections: list[str] = [header] + for i, opp in enumerate(sorted_opps[:15], start=1): + score = opp.get("relevance_score", 0.0) or 0.0 + score_pct = int(score * 100) + sections.append( + f"{i}. [{score_pct}%] {opp.get('title', 'N/A')}\n" + f" \U0001f3e2 {opp.get('company', 'N/A')} | \U0001f310 {opp.get('source', '')}\n" + f" {opp.get('url', '')}" + ) + + remaining = len(opportunities) - 15 + if remaining > 0: + sections.append(f"\n... \u0648 {remaining} \u0641\u0631\u0635\u0629 \u0623\u062e\u0631\u0649 / and {remaining} more") + + sections.append( + "\n\u2500" * 30 + + "\n\U0001f916 Opportunity Scout Bot -- Sami Assiri" + ) + return "\n".join(sections) + + +# --------------------------------------------------------------------------- +# WhatsApp -- Meta Cloud API / Twilio +# --------------------------------------------------------------------------- + +async def send_whatsapp_notification(settings: Any, opportunity: dict) -> bool: + """Send a single opportunity alert via WhatsApp. + + Tries the Meta Cloud API first. If ``whatsapp_provider`` is set to + ``"twilio"``, uses the Twilio API instead. + + Required settings attributes + ---------------------------- + whatsapp_phone_id : str (Meta) or whatsapp_twilio_sid (Twilio) + whatsapp_token : str (Meta) or whatsapp_twilio_token (Twilio) + whatsapp_recipient : str Recipient phone in E.164 format + """ + message = _format_opportunity_message(opportunity) + provider = getattr(settings, "whatsapp_provider", "meta") + + if provider == "twilio": + return await _send_whatsapp_twilio(settings, message) + return await _send_whatsapp_meta(settings, message) + + +async def _send_whatsapp_meta(settings: Any, message: str) -> bool: + """Send a WhatsApp message via the Meta Cloud API.""" + phone_id = getattr(settings, "whatsapp_phone_id", "") or "" + token = getattr(settings, "whatsapp_token", "") or "" + recipient = getattr(settings, "whatsapp_recipient", "") or "" + + if not all([phone_id, token, recipient]): + logger.warning("whatsapp_meta_missing_creds") + return False + + url = f"https://graph.facebook.com/v18.0/{phone_id}/messages" + headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} + payload = { + "messaging_product": "whatsapp", + "to": recipient, + "type": "text", + "text": {"body": message}, + } + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + logger.info("whatsapp_meta_sent", recipient=recipient) + return True + except httpx.HTTPStatusError as exc: + logger.error( + "whatsapp_meta_http_error", + status=exc.response.status_code, + body=exc.response.text[:300], + ) + except httpx.RequestError as exc: + logger.error("whatsapp_meta_request_error", error=str(exc)) + return False + + +async def _send_whatsapp_twilio(settings: Any, message: str) -> bool: + """Send a WhatsApp message via the Twilio API.""" + account_sid = getattr(settings, "whatsapp_twilio_sid", "") or "" + auth_token = getattr(settings, "whatsapp_twilio_token", "") or "" + from_number = getattr(settings, "whatsapp_twilio_from", "") or "" + recipient = getattr(settings, "whatsapp_recipient", "") or "" + + if not all([account_sid, auth_token, from_number, recipient]): + logger.warning("whatsapp_twilio_missing_creds") + return False + + url = f"https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json" + data = { + "From": f"whatsapp:{from_number}", + "To": f"whatsapp:{recipient}", + "Body": message, + } + + try: + async with httpx.AsyncClient(timeout=15.0) as client: + resp = await client.post( + url, data=data, auth=(account_sid, auth_token) + ) + resp.raise_for_status() + logger.info("whatsapp_twilio_sent", recipient=recipient) + return True + except httpx.HTTPStatusError as exc: + logger.error( + "whatsapp_twilio_http_error", + status=exc.response.status_code, + body=exc.response.text[:300], + ) + except httpx.RequestError as exc: + logger.error("whatsapp_twilio_request_error", error=str(exc)) + return False + + +# --------------------------------------------------------------------------- +# Email -- SMTP +# --------------------------------------------------------------------------- + +async def send_email_notification(settings: Any, opportunity: dict) -> bool: + """Send a single opportunity alert via SMTP email. + + Required settings attributes + ---------------------------- + smtp_host, smtp_port, smtp_user, smtp_password, smtp_from, smtp_to + """ + host = getattr(settings, "smtp_host", "") or "" + port = int(getattr(settings, "smtp_port", 587) or 587) + user = getattr(settings, "smtp_user", "") or "" + password = getattr(settings, "smtp_password", "") or "" + from_addr = getattr(settings, "smtp_from", user) or user + to_addr = getattr(settings, "smtp_to", "") or "" + + if not all([host, user, password, to_addr]): + logger.warning("email_missing_creds") + return False + + text_body = _format_opportunity_message(opportunity) + score_pct = int((opportunity.get("relevance_score", 0) or 0) * 100) + subject = ( + f"[{score_pct}%] \u0641\u0631\u0635\u0629 \u062c\u062f\u064a\u062f\u0629: " + f"{opportunity.get('title', 'Opportunity')} -- {opportunity.get('company', '')}" + ) + + msg = MIMEMultipart("alternative") + msg["Subject"] = subject + msg["From"] = from_addr + msg["To"] = to_addr + msg.attach(MIMEText(text_body, "plain", "utf-8")) + + try: + with smtplib.SMTP(host, port, timeout=15) as server: + server.ehlo() + server.starttls() + server.login(user, password) + server.sendmail(from_addr, [to_addr], msg.as_string()) + logger.info("email_sent", to=to_addr, subject=subject) + return True + except Exception as exc: # noqa: BLE001 + logger.error("email_send_error", error=str(exc)) + return False + + +# --------------------------------------------------------------------------- +# Daily digest (all channels) +# --------------------------------------------------------------------------- + +async def send_daily_digest(settings: Any, opportunities: list[dict]) -> dict: + """Compile and send the daily digest across all configured channels. + + Returns a dict mapping channel names to success booleans. + """ + message = _format_digest_message(opportunities) + results: dict[str, bool] = {} + + # WhatsApp + whatsapp_recipient = getattr(settings, "whatsapp_recipient", "") or "" + if whatsapp_recipient: + results["whatsapp"] = await _send_digest_whatsapp(settings, message) + + # Email + smtp_to = getattr(settings, "smtp_to", "") or "" + if smtp_to: + results["email"] = await _send_digest_email(settings, message) + + # Telegram (via shared notification util) + telegram_token = getattr(settings, "telegram_bot_token", "") or "" + telegram_chat = getattr(settings, "telegram_chat_id", "") or "" + if telegram_token and telegram_chat: + from utils.notifications import send_telegram + + results["telegram"] = await send_telegram( + telegram_token, telegram_chat, message + ) + + if not results: + logger.warning("digest_no_channels_configured") + + logger.info("daily_digest_sent", results=results, count=len(opportunities)) + return results + + +async def _send_digest_whatsapp(settings: Any, message: str) -> bool: + """Send the digest message via WhatsApp.""" + provider = getattr(settings, "whatsapp_provider", "meta") + if provider == "twilio": + return await _send_whatsapp_twilio(settings, message) + return await _send_whatsapp_meta(settings, message) + + +async def _send_digest_email(settings: Any, message: str) -> bool: + """Send the digest message via SMTP email.""" + host = getattr(settings, "smtp_host", "") or "" + port = int(getattr(settings, "smtp_port", 587) or 587) + user = getattr(settings, "smtp_user", "") or "" + password = getattr(settings, "smtp_password", "") or "" + from_addr = getattr(settings, "smtp_from", user) or user + to_addr = getattr(settings, "smtp_to", "") or "" + + if not all([host, user, password, to_addr]): + logger.warning("digest_email_missing_creds") + return False + + now = datetime.utcnow().strftime("%Y-%m-%d") + subject = f"\U0001f4e8 \u0627\u0644\u0645\u0644\u062e\u0635 \u0627\u0644\u064a\u0648\u0645\u064a / Daily Digest -- {now}" + + email_msg = MIMEMultipart("alternative") + email_msg["Subject"] = subject + email_msg["From"] = from_addr + email_msg["To"] = to_addr + email_msg.attach(MIMEText(message, "plain", "utf-8")) + + try: + with smtplib.SMTP(host, port, timeout=15) as server: + server.ehlo() + server.starttls() + server.login(user, password) + server.sendmail(from_addr, [to_addr], email_msg.as_string()) + logger.info("digest_email_sent", to=to_addr) + return True + except Exception as exc: # noqa: BLE001 + logger.error("digest_email_error", error=str(exc)) + return False diff --git a/personal-brand-engine/agents/opportunity_scout/scanners.py b/personal-brand-engine/agents/opportunity_scout/scanners.py new file mode 100644 index 00000000..06ca2616 --- /dev/null +++ b/personal-brand-engine/agents/opportunity_scout/scanners.py @@ -0,0 +1,363 @@ +"""Scanners -- free-API and RSS-based data sources for opportunity discovery. + +Each scanner is an async function that returns ``list[dict]`` where every +dict has keys: title, company, url, description, source. +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from typing import Any +from urllib.parse import quote_plus + +import httpx + +from utils.logger import get_logger + +logger = get_logger(__name__) + +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + ), + "Accept-Language": "en-US,en;q=0.9,ar;q=0.8", +} + +# Default keyword sets tailored to Sami's profile +DEFAULT_JOB_KEYWORDS: list[str] = [ + "field services engineer airport security", + "Smiths Detection engineer", + "METCO field engineer Saudi", + "airport security equipment engineer", + "aviation security engineer Riyadh", + "Rapiscan field engineer", + "L3Harris security engineer Saudi", + "mechanical engineer airport Saudi Arabia", +] + +DEFAULT_NEWS_KEYWORDS: list[str] = [ + "Smiths Detection", + "GACA Saudi Arabia aviation", + "airport security technology Saudi", + "Riyadh airport expansion", + "Saudi Arabia aviation security", + "Nuctech airport", + "baggage screening technology", +] + + +# --------------------------------------------------------------------------- +# Google Jobs (via Google custom search-style scraping) +# --------------------------------------------------------------------------- + +async def scan_google_jobs( + keywords: list[str] | None = None, + location: str = "Saudi Arabia", +) -> list[dict]: + """Search for jobs via Google's public search results (RSS/HTML). + + Uses the Google News RSS feed with job-related queries. This does NOT + require an API key. + """ + keywords = keywords or DEFAULT_JOB_KEYWORDS + results: list[dict] = [] + + async with httpx.AsyncClient(timeout=20.0, headers=_HEADERS) as client: + for kw in keywords: + query = quote_plus(f"{kw} {location} jobs") + url = f"https://news.google.com/rss/search?q={query}&hl=en-SA&gl=SA&ceid=SA:en" + try: + resp = await client.get(url) + resp.raise_for_status() + entries = _parse_rss(resp.text, source="google_jobs") + results.extend(entries) + except (httpx.HTTPStatusError, httpx.RequestError) as exc: + logger.warning("google_jobs_error", keyword=kw, error=str(exc)) + except ET.ParseError as exc: + logger.warning("google_jobs_xml_error", keyword=kw, error=str(exc)) + + # Deduplicate by URL + seen: set[str] = set() + unique: list[dict] = [] + for r in results: + key = r.get("url", r.get("title", "")) + if key not in seen: + seen.add(key) + unique.append(r) + + logger.info("google_jobs_scan_complete", count=len(unique)) + return unique + + +# --------------------------------------------------------------------------- +# LinkedIn (via linkedin-api library) +# --------------------------------------------------------------------------- + +async def scan_linkedin_jobs_api( + linkedin_api: Any | None = None, + keywords: list[str] | None = None, +) -> list[dict]: + """Search LinkedIn for relevant jobs using the ``linkedin-api`` library. + + Parameters + ---------- + linkedin_api: + An authenticated ``linkedin_api.Linkedin`` instance. If ``None``, + returns an empty list (credentials not configured). + keywords: + Search terms. Defaults to Sami-relevant keywords. + """ + if linkedin_api is None: + logger.info("linkedin_api_not_configured") + return [] + + keywords = keywords or [ + "field services engineer", + "airport security engineer", + "Smiths Detection", + "METCO", + "aviation security", + ] + + results: list[dict] = [] + for kw in keywords: + try: + jobs = linkedin_api.search_jobs( + keywords=kw, + location_name="Saudi Arabia", + limit=10, + ) + for job in jobs: + title = job.get("title", "") + company = job.get("companyName", "") or job.get("company", "") + job_id = job.get("dashEntityUrn", "") or job.get("entityUrn", "") + url = f"https://www.linkedin.com/jobs/view/{job_id.split(':')[-1]}" if job_id else "" + results.append({ + "title": title, + "company": company, + "url": url, + "description": job.get("description", "")[:1000], + "source": "linkedin", + }) + except Exception as exc: # noqa: BLE001 + logger.warning("linkedin_search_error", keyword=kw, error=str(exc)) + + logger.info("linkedin_scan_complete", count=len(results)) + return results + + +# --------------------------------------------------------------------------- +# News -- RSS feeds for industry news +# --------------------------------------------------------------------------- + +_NEWS_RSS_FEEDS: list[str] = [ + # Google News RSS for specific topics + "https://news.google.com/rss/search?q=Smiths+Detection&hl=en&gl=US&ceid=US:en", + "https://news.google.com/rss/search?q=airport+security+technology&hl=en&gl=SA&ceid=SA:en", + "https://news.google.com/rss/search?q=Saudi+Arabia+aviation+security&hl=en&gl=SA&ceid=SA:en", + "https://news.google.com/rss/search?q=GACA+Saudi+Arabia&hl=en&gl=SA&ceid=SA:en", + "https://news.google.com/rss/search?q=Riyadh+airport+expansion&hl=en&gl=SA&ceid=SA:en", + # Aviation security industry feeds + "https://news.google.com/rss/search?q=baggage+screening+technology&hl=en&gl=US&ceid=US:en", +] + + +async def scan_news( + keywords: list[str] | None = None, +) -> list[dict]: + """Fetch industry news from RSS feeds and optional keyword searches. + + Parameters + ---------- + keywords: + Additional keywords to search via Google News RSS. The built-in + feed list always runs regardless. + """ + keywords = keywords or DEFAULT_NEWS_KEYWORDS + results: list[dict] = [] + + # Build the full list of RSS URLs + urls = list(_NEWS_RSS_FEEDS) + for kw in keywords: + q = quote_plus(kw) + urls.append( + f"https://news.google.com/rss/search?q={q}&hl=en&gl=SA&ceid=SA:en" + ) + + async with httpx.AsyncClient(timeout=20.0, headers=_HEADERS) as client: + for url in urls: + try: + resp = await client.get(url) + resp.raise_for_status() + entries = _parse_rss(resp.text, source="news") + results.extend(entries) + except (httpx.HTTPStatusError, httpx.RequestError) as exc: + logger.warning("news_rss_error", url=url[:80], error=str(exc)) + except ET.ParseError as exc: + logger.warning("news_xml_error", url=url[:80], error=str(exc)) + + # Deduplicate + seen: set[str] = set() + unique: list[dict] = [] + for r in results: + key = r.get("url", r.get("title", "")) + if key not in seen: + seen.add(key) + unique.append(r) + + logger.info("news_scan_complete", count=len(unique)) + return unique + + +# --------------------------------------------------------------------------- +# Smiths Detection careers page +# --------------------------------------------------------------------------- + +_SMITHS_CAREERS_URL = "https://www.smithsdetection.com/careers" +_SMITHS_JOBS_RSS = ( + "https://news.google.com/rss/search?" + "q=%22Smiths+Detection%22+careers+OR+jobs+OR+hiring&hl=en&gl=US&ceid=US:en" +) + + +async def scan_smiths_detection_careers() -> list[dict]: + """Check Smiths Detection for new job postings. + + Since the Smiths Detection careers page may not expose a public API, + this scanner searches via Google News RSS for Smiths Detection hiring + announcements, and also attempts to fetch the careers page for links. + """ + results: list[dict] = [] + + async with httpx.AsyncClient( + timeout=20.0, headers=_HEADERS, follow_redirects=True + ) as client: + # Approach 1: Google News RSS for Smiths Detection job postings + try: + resp = await client.get(_SMITHS_JOBS_RSS) + resp.raise_for_status() + entries = _parse_rss(resp.text, source="smiths_detection_careers") + for entry in entries: + entry["company"] = "Smiths Detection" + results.extend(entries) + except (httpx.HTTPStatusError, httpx.RequestError) as exc: + logger.warning("smiths_rss_error", error=str(exc)) + except ET.ParseError as exc: + logger.warning("smiths_rss_xml_error", error=str(exc)) + + # Approach 2: Try scraping the careers page for job listing links + try: + resp = await client.get(_SMITHS_CAREERS_URL) + resp.raise_for_status() + # Basic extraction of job-related links from HTML + _extract_career_links(resp.text, results) + except (httpx.HTTPStatusError, httpx.RequestError) as exc: + logger.warning("smiths_careers_page_error", error=str(exc)) + + logger.info("smiths_detection_scan_complete", count=len(results)) + return results + + +def _extract_career_links(html: str, results: list[dict]) -> None: + """Naively extract job links from the Smiths Detection careers HTML.""" + import re + + # Look for links that look like job postings + pattern = re.compile( + r']+href="([^"]*(?:job|career|position|opening)[^"]*)"[^>]*>' + r"(.*?)", + re.IGNORECASE | re.DOTALL, + ) + for match in pattern.finditer(html): + url = match.group(1) + title_raw = match.group(2) + # Strip HTML tags from the title + title = re.sub(r"<[^>]+>", "", title_raw).strip() + if title and len(title) > 5: + results.append({ + "title": title, + "company": "Smiths Detection", + "url": url if url.startswith("http") else f"https://www.smithsdetection.com{url}", + "description": "", + "source": "smiths_detection_careers", + }) + + +# --------------------------------------------------------------------------- +# GACA (General Authority of Civil Aviation) announcements +# --------------------------------------------------------------------------- + +_GACA_URLS = [ + # Google News RSS for GACA-related announcements + "https://news.google.com/rss/search?q=GACA+Saudi+Arabia+aviation&hl=en&gl=SA&ceid=SA:en", + "https://news.google.com/rss/search?q=%22General+Authority+of+Civil+Aviation%22+Saudi&hl=en&gl=SA&ceid=SA:en", + # Arabic search + "https://news.google.com/rss/search?q=%D8%A7%D9%84%D8%B7%D9%8A%D8%B1%D8%A7%D9%86+%D8%A7%D9%84%D9%85%D8%AF%D9%86%D9%8A+%D8%A7%D9%84%D8%B3%D8%B9%D9%88%D8%AF%D9%8A&hl=ar&gl=SA&ceid=SA:ar", +] + + +async def scan_gaca_announcements() -> list[dict]: + """Monitor GACA (Saudi General Authority of Civil Aviation) news. + + Uses Google News RSS to find announcements related to GACA, Saudi + aviation regulation, and airport security mandates. + """ + results: list[dict] = [] + + async with httpx.AsyncClient(timeout=20.0, headers=_HEADERS) as client: + for url in _GACA_URLS: + try: + resp = await client.get(url) + resp.raise_for_status() + entries = _parse_rss(resp.text, source="gaca") + for entry in entries: + if not entry.get("company"): + entry["company"] = "GACA / Saudi Aviation" + results.extend(entries) + except (httpx.HTTPStatusError, httpx.RequestError) as exc: + logger.warning("gaca_rss_error", url=url[:80], error=str(exc)) + except ET.ParseError as exc: + logger.warning("gaca_xml_error", url=url[:80], error=str(exc)) + + # Deduplicate + seen: set[str] = set() + unique: list[dict] = [] + for r in results: + key = r.get("url", r.get("title", "")) + if key not in seen: + seen.add(key) + unique.append(r) + + logger.info("gaca_scan_complete", count=len(unique)) + return unique + + +# --------------------------------------------------------------------------- +# RSS parsing helper +# --------------------------------------------------------------------------- + +def _parse_rss(xml_text: str, source: str) -> list[dict]: + """Parse an RSS 2.0 feed and return a list of opportunity dicts.""" + results: list[dict] = [] + root = ET.fromstring(xml_text) # noqa: S314 + + # RSS 2.0: /rss/channel/item + for item in root.findall(".//item"): + title = (item.findtext("title") or "").strip() + link = (item.findtext("link") or "").strip() + description = (item.findtext("description") or "").strip() + # Google News often puts the source in tag + src_tag = item.find("source") + company = src_tag.text.strip() if src_tag is not None and src_tag.text else "" + + if title: + results.append({ + "title": title, + "company": company, + "url": link, + "description": description[:1000], + "source": source, + }) + + return results diff --git a/personal-brand-engine/agents/opportunity_scout/scorer.py b/personal-brand-engine/agents/opportunity_scout/scorer.py new file mode 100644 index 00000000..4f33c8ff --- /dev/null +++ b/personal-brand-engine/agents/opportunity_scout/scorer.py @@ -0,0 +1,151 @@ +"""Relevance scorer -- uses LLM to evaluate how well an opportunity matches +Sami's profile, skills, and career goals.""" + +from __future__ import annotations + +import json +from typing import Any + +from utils.logger import get_logger + +logger = get_logger(__name__) + +_SCORING_PROMPT = """\ +You are a career-opportunity relevance scorer. Given a professional profile +and an opportunity (job posting, event, or news item), rate how relevant the +opportunity is on a scale from 0.0 to 1.0 and explain your reasoning. + +## Scoring guidelines + +Award HIGHER scores (0.7 -- 1.0) when: +- The opportunity is at Smiths Detection, METCO, or a direct competitor + (OSI Systems / Rapiscan, L3Harris, Leidos, Nuctech) +- Role involves airport / aviation security equipment +- Location is Saudi Arabia (especially Riyadh) +- Role is Field Services / Field Engineering +- Requires mechanical engineering background +- Involves project management for large-scale deployments +- Related to GACA or Saudi aviation authority initiatives +- Involves Python, data analytics, or automation in an engineering context + +Award MEDIUM scores (0.4 -- 0.69) when: +- Related to broader security / defense industry +- Engineering role in the Middle East (GCC countries) +- Involves transferable skills (project management, maintenance planning) +- Industry news that could create future opportunities + +Award LOWER scores (0.0 -- 0.39) when: +- Unrelated industry or geography +- Purely software role with no engineering overlap +- Entry-level position far below current experience +- News with no actionable career relevance + +## Professional profile +{profile_json} + +## Opportunity +Title: {title} +Company: {company} +Source: {source} +Description: +{description} + +## Required output +Respond ONLY with a JSON object (no markdown fences): +{{"score": , "explanation": ""}} +""" + + +async def score_opportunity( + llm_client: Any, + opportunity: dict, + brand_profile: dict, +) -> float: + """Score an opportunity's relevance to Sami's career profile. + + Parameters + ---------- + llm_client: + An LLM client with an ``async generate(prompt, ...)`` method. + opportunity: + Dict with keys: title, company, url, description, source. + brand_profile: + Parsed brand profile dict from ``brand_profile.yaml``. + + Returns + ------- + float + Relevance score between 0.0 and 1.0. + """ + profile_summary = { + "name": brand_profile.get("name", "Sami Assiri"), + "current_role": brand_profile.get( + "current_role", + "Field Services Engineer at METCO (Smiths Detection)", + ), + "location": brand_profile.get("location", "Riyadh, Saudi Arabia"), + "skills": brand_profile.get( + "skills", + [ + "Mechanical Engineering", + "Field Services", + "Airport Security Equipment", + "Python", + "Data Analytics", + "Project Management", + ], + ), + "previous_companies": brand_profile.get( + "previous_companies", ["Samsung E&A"] + ), + "industry": brand_profile.get("industry", "Aviation Security"), + } + + prompt = _SCORING_PROMPT.format( + profile_json=json.dumps(profile_summary, indent=2), + title=opportunity.get("title", "N/A"), + company=opportunity.get("company", "N/A"), + source=opportunity.get("source", "N/A"), + description=(opportunity.get("description", "") or "")[:2000], + ) + + try: + response = await llm_client.generate( + prompt, + system_prompt="You are a precise JSON-only scorer.", + temperature=0.2, + max_tokens=300, + ) + + text = response.text.strip() + # Strip markdown code fences if present + if text.startswith("```"): + text = text.split("\n", 1)[1].rsplit("```", 1)[0].strip() + + result = json.loads(text) + score = float(result.get("score", 0.0)) + explanation = result.get("explanation", "") + score = max(0.0, min(1.0, score)) + + logger.info( + "opportunity_scored", + title=opportunity.get("title"), + score=score, + explanation=explanation, + ) + return score + + except (json.JSONDecodeError, KeyError, ValueError, TypeError) as exc: + logger.error( + "scoring_parse_error", + error=str(exc), + title=opportunity.get("title"), + ) + return 0.0 + except Exception as exc: # noqa: BLE001 + logger.error( + "scoring_llm_error", + error=str(exc), + title=opportunity.get("title"), + ) + return 0.0 diff --git a/personal-brand-engine/agents/social_media/__init__.py b/personal-brand-engine/agents/social_media/__init__.py new file mode 100644 index 00000000..6a7eb3fa --- /dev/null +++ b/personal-brand-engine/agents/social_media/__init__.py @@ -0,0 +1,5 @@ +"""Social media automation agent for personal brand management.""" + +from agents.social_media.agent import SocialMediaAgent + +__all__ = ["SocialMediaAgent"] diff --git a/personal-brand-engine/agents/social_media/agent.py b/personal-brand-engine/agents/social_media/agent.py new file mode 100644 index 00000000..0b2c7b30 --- /dev/null +++ b/personal-brand-engine/agents/social_media/agent.py @@ -0,0 +1,277 @@ +"""Social media agent -- posts to Twitter/X and repurposes content across platforms.""" + +from __future__ import annotations + +import logging +import time +from typing import Any + +from sqlalchemy.orm import Session + +from agents.base_agent import BaseAgent +from agents.social_media.content_repurposer import ( + repurpose_linkedin_to_twitter, +) +from agents.social_media.twitter import ( + create_thread, + post_tweet, +) + +logger = logging.getLogger(__name__) + +# In-memory rate limiter. +_RATE_LIMIT_WINDOW: dict[str, float] = {} +RATE_LIMIT_SECONDS: dict[str, int] = { + "post_twitter": 3600, # 1 hour between tweets + "repurpose_content": 7200, # 2 hours between repurpose runs +} + + +class SocialMediaAgent(BaseAgent): + """Autonomous social-media agent for Sami Mohammed Assiri's personal brand. + + Currently supports Twitter/X with plans to expand to other platforms. + """ + + agent_name: str = "social_media" + + def __init__( + self, + config: Any, + llm_client: Any, + db_session: Session, + ) -> None: + super().__init__(config, llm_client, db_session) + + # ------------------------------------------------------------------ + # Rate limiting + # ------------------------------------------------------------------ + + @staticmethod + def _is_rate_limited(action: str) -> bool: + last = _RATE_LIMIT_WINDOW.get(action) + if last is None: + return False + window = RATE_LIMIT_SECONDS.get(action, 0) + return (time.time() - last) < window + + @staticmethod + def _mark_executed(action: str) -> None: + _RATE_LIMIT_WINDOW[action] = time.time() + + # ------------------------------------------------------------------ + # Task dispatcher + # ------------------------------------------------------------------ + + async def run(self, task: str, **kwargs: Any) -> dict: + """Dispatch *task* to the appropriate handler. + + Supported tasks: + - ``post_twitter`` -- create and post a tweet + - ``repurpose_content`` -- adapt LinkedIn posts for Twitter + """ + dispatch = { + "post_twitter": self._post_twitter, + "repurpose_content": self._repurpose_content, + } + + handler = dispatch.get(task) + if handler is None: + self.log_action(task, details=f"Unknown task: {task}", status="failed") + return {"status": "error", "message": f"Unknown task: {task}"} + + if self._is_rate_limited(task): + msg = f"Rate-limited: {task} was run too recently." + logger.warning(msg) + self.log_action(task, details=msg, status="skipped") + return {"status": "skipped", "message": msg} + + with self.timer() as t: + try: + result = await handler(**kwargs) + self._mark_executed(task) + self.log_action(task, details=str(result), duration=t.elapsed) + return {"status": "success", "result": result} + except Exception as exc: + logger.exception("Task %s failed", task) + self.log_action( + task, + details=str(exc), + status="failed", + duration=t.elapsed, + ) + await self.notify_owner( + f"[Social Media Agent] Task '{task}' failed: {exc}" + ) + return {"status": "error", "message": str(exc)} + + # ------------------------------------------------------------------ + # post_twitter + # ------------------------------------------------------------------ + + async def _post_twitter( + self, + *, + content: str | None = None, + pillar: str | None = None, + ) -> dict: + """Generate (if needed) and post a tweet. + + Parameters + ---------- + content: + Explicit tweet text. If not provided, the LLM generates one + based on the brand profile and content strategy. + pillar: + Optional content pillar to guide generation (e.g. + ``"airport_security"``, ``"engineering_tips"``). + """ + if content is None: + content = await self._generate_tweet(pillar=pillar) + + api_keys = self._get_twitter_keys() + result = post_tweet(api_keys, content) + + logger.info("Posted tweet: %s", content[:80]) + return {"tweet": content, "api_response": result} + + async def _generate_tweet(self, *, pillar: str | None = None) -> str: + """Use the LLM to generate a tweet aligned with the brand.""" + brand_profile = self.get_brand_profile() + content_strategy = self.get_content_strategy() + + pillar_hint = "" + if pillar: + pillars = content_strategy.get("content_pillars", {}) + pillar_data = pillars.get(pillar, {}) + if pillar_data: + pillar_hint = ( + f"\nFocus on this content pillar: {pillar}\n" + f"Description: {pillar_data.get('description', '')}\n" + f"Topics: {', '.join(pillar_data.get('topics', []))}" + ) + + name = brand_profile.get("name", "Sami Mohammed Assiri") + title = brand_profile.get("title", "Field Services Engineer") + company = brand_profile.get("company", "METCO (Smiths Detection)") + + messages = [ + { + "role": "system", + "content": ( + f"You are a Twitter/X content creator for {name}, " + f"{title} at {company} in Riyadh, Saudi Arabia. " + "Create engaging, professional tweets about airport security, " + "engineering, and technology. Keep tweets under 280 characters. " + "Use 1-3 relevant hashtags. Be authentic and insightful." + f"{pillar_hint}" + ), + }, + { + "role": "user", + "content": "Write a single engaging tweet for my professional audience.", + }, + ] + + response_text = await self._call_llm(messages) + # Strip any surrounding quotes the LLM might add + return response_text.strip().strip('"').strip("'") + + # ------------------------------------------------------------------ + # repurpose_content + # ------------------------------------------------------------------ + + async def _repurpose_content( + self, + *, + linkedin_post: str | None = None, + post_as_thread: bool = True, + ) -> dict: + """Take a LinkedIn post and adapt it for Twitter. + + Parameters + ---------- + linkedin_post: + The full text of the LinkedIn post. Must be provided. + post_as_thread: + If ``True`` and the repurposed content has multiple tweets, + post them as a thread. + """ + if not linkedin_post: + return {"error": "No linkedin_post content provided."} + + tweets = await repurpose_linkedin_to_twitter( + llm_client=self.llm, + linkedin_post=linkedin_post, + ) + + if not tweets: + return {"error": "Repurposing produced no tweets."} + + api_keys = self._get_twitter_keys() + + if len(tweets) == 1 or not post_as_thread: + result = post_tweet(api_keys, tweets[0]) + return {"tweets": tweets, "posted": 1, "api_response": result} + else: + results = create_thread(api_keys, tweets) + return {"tweets": tweets, "posted": len(tweets), "api_responses": results} + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_twitter_keys(self) -> dict[str, str]: + """Extract Twitter API credentials from config.""" + return { + "api_key": self.config.twitter_api_key, + "api_secret": self.config.twitter_api_secret, + "access_token": self.config.twitter_access_token, + "access_secret": self.config.twitter_access_secret, + "bearer_token": self.config.twitter_bearer_token, + } + + async def _call_llm(self, messages: list[dict[str, str]]) -> str: + """Invoke the LLM client, handling different API shapes.""" + import asyncio + import inspect + + # OpenAI / Groq compatible + if hasattr(self.llm, "chat") and hasattr(self.llm.chat, "completions"): + func = self.llm.chat.completions.create + if inspect.iscoroutinefunction(func): + resp = await func(messages=messages, max_tokens=300, temperature=0.8) + else: + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor( + None, + lambda: func(messages=messages, max_tokens=300, temperature=0.8), + ) + return resp.choices[0].message.content + + # Ollama-style + if hasattr(self.llm, "chat"): + func = self.llm.chat + if inspect.iscoroutinefunction(func): + resp = await func(messages=messages) + else: + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor( + None, lambda: func(messages=messages) + ) + if isinstance(resp, dict): + return resp.get("message", {}).get("content", "") + return str(resp) + + # Generic callable + if callable(self.llm): + if inspect.iscoroutinefunction(self.llm): + resp = await self.llm(messages=messages) + else: + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor( + None, lambda: self.llm(messages=messages) + ) + return str(resp) + + raise TypeError(f"Unsupported LLM client type: {type(self.llm)}") diff --git a/personal-brand-engine/agents/social_media/content_repurposer.py b/personal-brand-engine/agents/social_media/content_repurposer.py new file mode 100644 index 00000000..a4f3236d --- /dev/null +++ b/personal-brand-engine/agents/social_media/content_repurposer.py @@ -0,0 +1,179 @@ +"""Repurpose long-form content (e.g. LinkedIn posts) into Twitter-friendly formats.""" + +from __future__ import annotations + +import asyncio +import inspect +import logging +import re +from typing import Any + +logger = logging.getLogger(__name__) + +# Maximum characters per tweet. +_TWEET_LIMIT = 280 + + +async def repurpose_linkedin_to_twitter( + llm_client: Any, + linkedin_post: str, +) -> list[str]: + """Convert a LinkedIn post into a Twitter thread. + + The LLM extracts key insights and reformats the content as a concise + tweet thread with relevant hashtags. + + Parameters + ---------- + llm_client: + Any LLM client compatible with chat-style APIs. + linkedin_post: + The full text of the LinkedIn post. + + Returns + ------- + list[str] + A list of tweet strings ready to post as a thread. + Returns a single-element list if the content fits one tweet. + """ + if not linkedin_post or not linkedin_post.strip(): + return [] + + messages = [ + { + "role": "system", + "content": ( + "You are a social media content strategist for Sami Mohammed Assiri, " + "a Field Services Engineer at METCO (Smiths Detection) in Riyadh. " + "Your job is to repurpose LinkedIn posts into Twitter/X threads.\n\n" + "Rules:\n" + "1. Each tweet MUST be under 280 characters.\n" + "2. Keep the core message and key insights.\n" + "3. Use a conversational, engaging tone.\n" + "4. Add 1-3 relevant hashtags to the last tweet only.\n" + "5. If the content fits in one tweet, return just one.\n" + "6. For threads, number them (1/N format) at the start.\n" + "7. Remove LinkedIn-specific formatting (bullet emojis, etc.).\n" + "8. Each tweet should stand on its own while contributing to the thread.\n\n" + "Return ONLY the tweets, one per line, separated by ---" + ), + }, + { + "role": "user", + "content": ( + f"Repurpose this LinkedIn post into a Twitter thread:\n\n" + f"{linkedin_post}" + ), + }, + ] + + try: + raw_response = await _call_llm(llm_client, messages) + except Exception as exc: + logger.error("LLM call failed during repurposing: %s", exc) + # Fallback: try a simple extraction + return _fallback_repurpose(linkedin_post) + + tweets = _parse_thread_response(raw_response) + + # Validate and truncate + validated: list[str] = [] + for tweet in tweets: + tweet = tweet.strip() + if not tweet: + continue + if len(tweet) > _TWEET_LIMIT: + tweet = tweet[: _TWEET_LIMIT - 3] + "..." + validated.append(tweet) + + if not validated: + return _fallback_repurpose(linkedin_post) + + return validated + + +def _parse_thread_response(raw: str) -> list[str]: + """Parse the LLM response into individual tweet strings. + + Supports multiple separators: + - ``---`` (our requested format) + - Numbered lines (``1/N``, ``1.``, etc.) + - Double newlines + """ + raw = raw.strip() + + # Try --- separator first + if "---" in raw: + parts = [p.strip() for p in raw.split("---") if p.strip()] + if parts: + return parts + + # Try numbered format (e.g., "1/3 ...\n\n2/3 ...") + numbered = re.split(r"\n\s*\d+[/.]\d*\s*", "\n" + raw) + numbered = [p.strip() for p in numbered if p.strip()] + if len(numbered) > 1: + return numbered + + # Try double newline + paragraphs = [p.strip() for p in raw.split("\n\n") if p.strip()] + if len(paragraphs) > 1: + return paragraphs + + # Single tweet + return [raw] + + +def _fallback_repurpose(linkedin_post: str) -> list[str]: + """Simple non-LLM fallback that extracts the first sentence.""" + # Take the first meaningful sentence + sentences = re.split(r"[.!?]\s+", linkedin_post.strip()) + if sentences: + first = sentences[0].strip() + if len(first) > _TWEET_LIMIT - 30: + first = first[: _TWEET_LIMIT - 33] + "..." + return [f"{first} #Engineering #AirportSecurity"] + return [] + + +async def _call_llm( + llm_client: Any, + messages: list[dict[str, str]], +) -> str: + """Invoke the LLM, handling sync/async and different interfaces.""" + # OpenAI / Groq compatible + if hasattr(llm_client, "chat") and hasattr(llm_client.chat, "completions"): + func = llm_client.chat.completions.create + if inspect.iscoroutinefunction(func): + resp = await func(messages=messages, max_tokens=600, temperature=0.7) + else: + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor( + None, + lambda: func(messages=messages, max_tokens=600, temperature=0.7), + ) + return resp.choices[0].message.content + + # Ollama-style + if hasattr(llm_client, "chat"): + func = llm_client.chat + if inspect.iscoroutinefunction(func): + resp = await func(messages=messages) + else: + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor(None, lambda: func(messages=messages)) + if isinstance(resp, dict): + return resp.get("message", {}).get("content", "") + return str(resp) + + # Generic callable + if callable(llm_client): + if inspect.iscoroutinefunction(llm_client): + resp = await llm_client(messages=messages) + else: + loop = asyncio.get_event_loop() + resp = await loop.run_in_executor( + None, lambda: llm_client(messages=messages) + ) + return str(resp) + + raise TypeError(f"Unsupported LLM client type: {type(llm_client)}") diff --git a/personal-brand-engine/agents/social_media/prompts/social_templates.yaml b/personal-brand-engine/agents/social_media/prompts/social_templates.yaml new file mode 100644 index 00000000..2bd60e54 --- /dev/null +++ b/personal-brand-engine/agents/social_media/prompts/social_templates.yaml @@ -0,0 +1,115 @@ +# Twitter/X tweet templates for Sami Mohammed Assiri's personal brand. +# Organized by content pillar for consistent messaging. + +content_pillars: + + airport_security: + description: "Airport security technology, CT/X-ray screening, threat detection" + templates: + - | + Airport security isn't just about scanning bags -- it's about protecting lives at scale. + Every system we calibrate makes air travel safer for millions. + #AirportSecurity #AviationSafety + - | + The evolution of CT screening in airports is remarkable. + From basic X-ray to AI-powered threat detection -- we're living in the future of security. + #CTScreening #SecurityTech + - | + Behind every smooth airport experience is a team of engineers + ensuring screening systems run at peak performance 24/7. + #FieldEngineering #AirportSecurity + + engineering_insights: + description: "Field service engineering tips, troubleshooting, career growth" + templates: + - | + Field service engineering lesson: the best fix is the one + that prevents the next breakdown. Preventive > reactive, always. + #Engineering #FieldService + - | + 3 skills every field engineer needs: + 1. Systematic troubleshooting + 2. Clear communication with clients + 3. Adaptability under pressure + #EngineeringTips #CareerGrowth + - | + Documentation isn't optional in field service -- it's your + future self's best friend. Write it down today, thank yourself tomorrow. + #FieldEngineering #BestPractices + + saudi_tech: + description: "Saudi Arabia tech ecosystem, Vision 2030, regional innovation" + templates: + - | + Saudi Arabia's investment in smart airport infrastructure + is transforming aviation security across the region. + Proud to be part of this journey. #Vision2030 #SaudiTech + - | + Riyadh is becoming a hub for security technology innovation. + The demand for skilled engineers here has never been higher. + #SaudiArabia #TechJobs #Riyadh + - | + Vision 2030 is not just about diversification -- + it's about building world-class technical capabilities locally. + #Vision2030 #Engineering + + career_growth: + description: "Professional development, certifications, engineering career advice" + templates: + - | + Your career in engineering grows when you solve problems + others avoid. Seek the hard tickets. + #CareerAdvice #Engineering + - | + Certifications matter, but hands-on experience is irreplaceable. + The best engineers I know combine both. + #ProfessionalDevelopment #FieldService + - | + Switching from reactive to proactive maintenance mindset + was the biggest upgrade in my engineering career. + #EngineeringMindset #Growth + + thought_leadership: + description: "Industry trends, opinions, future of security technology" + templates: + - | + The future of airport security is AI-assisted, not AI-replaced. + Human expertise + machine precision = optimal safety. + #AI #SecurityTech #FutureOfWork + - | + Cybersecurity for physical security systems is the next frontier. + If your screening equipment is networked, it needs protection. + #Cybersecurity #AirportSecurity + - | + The convergence of IoT and security screening will redefine + how we think about airport operations in the next decade. + #IoT #SmartAirports #Innovation + +# Thread templates for longer-form content +thread_templates: + + engineering_story: + description: "Share a field engineering experience as a story thread" + structure: + - "Hook: Start with an interesting problem or situation" + - "Context: Brief background on the system/environment" + - "Challenge: What made this problem unique or difficult" + - "Solution: How the problem was resolved" + - "Lesson: Key takeaway for the audience + hashtags" + + industry_analysis: + description: "Break down an industry trend or technology" + structure: + - "Hook: State the trend or technology with a bold claim" + - "Data: Share one key statistic or fact" + - "Impact: How this affects the industry or professionals" + - "Prediction: Where this is heading" + - "CTA: Engage the audience with a question + hashtags" + +# Hashtag groups for quick reference +hashtag_groups: + core: ["#AirportSecurity", "#FieldEngineering", "#SecurityTech"] + saudi: ["#SaudiArabia", "#Vision2030", "#Riyadh", "#SaudiTech"] + career: ["#Engineering", "#CareerGrowth", "#ProfessionalDevelopment"] + tech: ["#AI", "#IoT", "#Cybersecurity", "#Innovation"] + engagement: ["#TechTwitter", "#EngineeringLife", "#AviationSecurity"] diff --git a/personal-brand-engine/agents/social_media/twitter.py b/personal-brand-engine/agents/social_media/twitter.py new file mode 100644 index 00000000..be2aca1a --- /dev/null +++ b/personal-brand-engine/agents/social_media/twitter.py @@ -0,0 +1,150 @@ +"""Twitter/X API integration using tweepy v2.""" + +from __future__ import annotations + +import logging +from typing import Any + +import tweepy + +logger = logging.getLogger(__name__) + + +def _get_client(api_keys: dict[str, str]) -> tweepy.Client: + """Create an authenticated tweepy v2 Client. + + Parameters + ---------- + api_keys: + Dictionary with keys: ``api_key``, ``api_secret``, + ``access_token``, ``access_secret``, and optionally ``bearer_token``. + + Returns + ------- + tweepy.Client + An authenticated Twitter API v2 client. + + Raises + ------ + ValueError + If required credentials are missing. + """ + required = ("api_key", "api_secret", "access_token", "access_secret") + missing = [k for k in required if not api_keys.get(k)] + if missing: + raise ValueError( + f"Missing Twitter API credentials: {', '.join(missing)}. " + "Set them in the .env file." + ) + + return tweepy.Client( + consumer_key=api_keys["api_key"], + consumer_secret=api_keys["api_secret"], + access_token=api_keys["access_token"], + access_token_secret=api_keys["access_secret"], + bearer_token=api_keys.get("bearer_token") or None, + wait_on_rate_limit=True, + ) + + +def post_tweet(api_keys: dict[str, str], content: str) -> dict[str, Any]: + """Post a single tweet. + + Parameters + ---------- + api_keys: + Twitter API credentials dictionary. + content: + The tweet text (max 280 characters). + + Returns + ------- + dict + Contains ``tweet_id`` and ``text`` on success, or ``error`` on failure. + """ + if not content or not content.strip(): + return {"error": "Tweet content is empty."} + + if len(content) > 280: + logger.warning( + "Tweet exceeds 280 chars (%d). Truncating.", len(content) + ) + content = content[:277] + "..." + + client = _get_client(api_keys) + + try: + response = client.create_tweet(text=content) + tweet_id = response.data["id"] + logger.info("Tweet posted successfully (id=%s)", tweet_id) + return {"tweet_id": tweet_id, "text": content} + except tweepy.TweepyException as exc: + logger.error("Failed to post tweet: %s", exc) + return {"error": str(exc)} + + +def create_thread( + api_keys: dict[str, str], + contents: list[str], +) -> list[dict[str, Any]]: + """Post a thread (sequence of reply tweets). + + Parameters + ---------- + api_keys: + Twitter API credentials dictionary. + contents: + List of tweet texts, in order. The first is the root tweet; + each subsequent tweet is posted as a reply to the previous one. + + Returns + ------- + list[dict] + One result dict per tweet containing ``tweet_id`` and ``text``, + or ``error`` if that tweet failed. + """ + if not contents: + return [{"error": "No thread content provided."}] + + client = _get_client(api_keys) + results: list[dict[str, Any]] = [] + previous_id: str | None = None + + for idx, text in enumerate(contents): + if not text or not text.strip(): + results.append({"error": f"Tweet {idx + 1} is empty, skipped."}) + continue + + if len(text) > 280: + logger.warning( + "Thread tweet %d exceeds 280 chars (%d). Truncating.", + idx + 1, + len(text), + ) + text = text[:277] + "..." + + try: + kwargs: dict[str, Any] = {"text": text} + if previous_id is not None: + kwargs["in_reply_to_tweet_id"] = previous_id + + response = client.create_tweet(**kwargs) + tweet_id = response.data["id"] + previous_id = tweet_id + + logger.info( + "Thread tweet %d/%d posted (id=%s)", + idx + 1, + len(contents), + tweet_id, + ) + results.append({"tweet_id": tweet_id, "text": text}) + + except tweepy.TweepyException as exc: + logger.error("Failed to post thread tweet %d: %s", idx + 1, exc) + results.append({"error": str(exc), "text": text}) + # Stop the thread if a tweet in the middle fails -- subsequent + # replies would be orphaned. + break + + return results diff --git a/personal-brand-engine/agents/whatsapp/__init__.py b/personal-brand-engine/agents/whatsapp/__init__.py new file mode 100644 index 00000000..af856742 --- /dev/null +++ b/personal-brand-engine/agents/whatsapp/__init__.py @@ -0,0 +1,5 @@ +"""WhatsApp automation agent for personal brand management.""" + +from agents.whatsapp.agent import WhatsAppAgent + +__all__ = ["WhatsAppAgent"] diff --git a/personal-brand-engine/agents/whatsapp/agent.py b/personal-brand-engine/agents/whatsapp/agent.py new file mode 100644 index 00000000..c4e0db63 --- /dev/null +++ b/personal-brand-engine/agents/whatsapp/agent.py @@ -0,0 +1,202 @@ +"""WhatsApp agent -- auto-responds, directs to booking, and acts as personal assistant.""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from agents.base_agent import BaseAgent +from agents.whatsapp.responder import generate_response +from storage.models import Contact + +logger = logging.getLogger(__name__) + +# In-memory conversation history cache keyed by phone number. +# In production, persist this to the database or Redis. +_CONVERSATION_CACHE: dict[str, list[dict[str, str]]] = {} + +# Maximum turns to keep per conversation. +_MAX_HISTORY = 20 + + +class WhatsAppAgent(BaseAgent): + """Autonomous WhatsApp agent for Sami Mohammed Assiri's personal brand. + + Handles incoming WhatsApp messages, generates context-aware responses + using an LLM, stores contacts, and directs people to Cal.com for booking. + """ + + agent_name: str = "whatsapp" + + def __init__( + self, + config: Any, + llm_client: Any, + db_session: Session, + ) -> None: + super().__init__(config, llm_client, db_session) + + # ------------------------------------------------------------------ + # Task dispatcher + # ------------------------------------------------------------------ + + async def run(self, task: str, **kwargs: Any) -> dict: + """Dispatch *task* to the appropriate handler. + + Supported tasks: + - ``handle_message`` -- respond to an incoming WhatsApp message + """ + dispatch = { + "handle_message": self._handle_message_task, + } + + handler = dispatch.get(task) + if handler is None: + self.log_action(task, details=f"Unknown task: {task}", status="failed") + return {"status": "error", "message": f"Unknown task: {task}"} + + with self.timer() as t: + try: + result = await handler(**kwargs) + self.log_action(task, details=str(result), duration=t.elapsed) + return {"status": "success", "result": result} + except Exception as exc: + logger.exception("Task %s failed", task) + self.log_action( + task, + details=str(exc), + status="failed", + duration=t.elapsed, + ) + await self.notify_owner( + f"[WhatsApp Agent] Task '{task}' failed: {exc}" + ) + return {"status": "error", "message": str(exc)} + + async def _handle_message_task( + self, + *, + from_number: str, + message_text: str, + sender_name: str | None = None, + ) -> dict: + """Internal dispatcher target for the ``handle_message`` task.""" + response = await self.handle_message( + from_number=from_number, + message_text=message_text, + sender_name=sender_name, + ) + return {"from_number": from_number, "response": response} + + # ------------------------------------------------------------------ + # Core message handler + # ------------------------------------------------------------------ + + async def handle_message( + self, + from_number: str, + message_text: str, + sender_name: str | None = None, + ) -> str: + """Process an incoming WhatsApp message and return a response string. + + Parameters + ---------- + from_number: + The sender's phone number in E.164 format. + message_text: + The text body of the incoming message. + sender_name: + Optional display name of the sender (from WhatsApp profile). + + Returns + ------- + str + The response text to send back. + """ + display_name = sender_name or from_number + + # Upsert contact in the database + self._upsert_contact(from_number, sender_name) + + # Retrieve / initialise conversation history + history = _CONVERSATION_CACHE.setdefault(from_number, []) + + # Append the user message to history + history.append({"role": "user", "content": message_text}) + + # Generate a response via LLM + brand_profile = self.get_brand_profile() + + try: + response_text = await generate_response( + llm_client=self.llm, + message=message_text, + sender_name=display_name, + brand_profile=brand_profile, + conversation_history=history, + ) + except Exception as exc: + logger.error( + "LLM response generation failed for %s: %s", from_number, exc + ) + # Graceful fallback in Arabic + response_text = ( + "شكراً لتواصلك. سامي غير متاح حالياً وسيرد عليك في أقرب وقت.\n" + "Thank you for reaching out. Sami is currently unavailable " + "and will get back to you soon." + ) + + # Append assistant response to history + history.append({"role": "assistant", "content": response_text}) + + # Trim history if it exceeds the maximum + if len(history) > _MAX_HISTORY * 2: + _CONVERSATION_CACHE[from_number] = history[-_MAX_HISTORY * 2 :] + + logger.info( + "Responded to %s (%s): %s", + display_name, + from_number, + response_text[:80], + ) + return response_text + + # ------------------------------------------------------------------ + # Contact management + # ------------------------------------------------------------------ + + def _upsert_contact( + self, phone: str, name: str | None = None + ) -> Contact: + """Create or update a contact record for the given phone number.""" + contact = ( + self.db.query(Contact) + .filter(Contact.phone == phone, Contact.platform == "whatsapp") + .first() + ) + + if contact is None: + contact = Contact( + name=name or phone, + phone=phone, + platform="whatsapp", + last_contact_at=datetime.now(timezone.utc), + ) + self.db.add(contact) + logger.info("New WhatsApp contact created: %s (%s)", name, phone) + else: + if name and contact.name == contact.phone: + contact.name = name + contact.last_contact_at = datetime.now(timezone.utc) + + try: + self.db.flush() + except Exception: + logger.exception("Failed to upsert contact %s", phone) + self.db.rollback() + + return contact diff --git a/personal-brand-engine/agents/whatsapp/prompts/whatsapp_templates.yaml b/personal-brand-engine/agents/whatsapp/prompts/whatsapp_templates.yaml new file mode 100644 index 00000000..c834c111 --- /dev/null +++ b/personal-brand-engine/agents/whatsapp/prompts/whatsapp_templates.yaml @@ -0,0 +1,86 @@ +# WhatsApp response templates for Sami Mohammed Assiri's AI assistant. +# Used as fallback snippets and quick-reply building blocks. + +greeting: + ar: | + أهلاً وسهلاً! أنا المساعد الذكي لسامي محمد عسيري. + كيف يمكنني مساعدتك اليوم؟ + en: | + Hello! I'm the AI assistant for Sami Mohammed Assiri. + How can I help you today? + +meeting_request: + ar: | + شكراً لاهتمامك بالتواصل مع سامي! + يمكنك حجز موعد مباشرة من خلال الرابط التالي: + {calcom_url} + سيتم تأكيد الموعد تلقائياً. + en: | + Thank you for your interest in connecting with Sami! + You can book a meeting directly through this link: + {calcom_url} + The appointment will be confirmed automatically. + +about_sami: + ar: | + سامي محمد عسيري هو مهندس خدمات ميدانية في شركة METCO (Smiths Detection) بالرياض. + متخصص في أنظمة الأمن بالمطارات وتقنيات الفحص بالأشعة المقطعية/السينية. + للمزيد من المعلومات يمكنك زيارة ملفه على لينكدإن. + en: | + Sami Mohammed Assiri is a Field Services Engineer at METCO (Smiths Detection) in Riyadh. + He specializes in airport security systems and CT/X-ray screening technology. + For more details, you can visit his LinkedIn profile. + +cv_request: + ar: | + بالتأكيد! يمكنك الاطلاع على السيرة الذاتية لسامي من خلال الرابط التالي: + {cv_url} + إذا كان لديك أي استفسار إضافي، لا تتردد في السؤال. + en: | + Of course! You can view Sami's CV through this link: + {cv_url} + If you have any additional questions, feel free to ask. + +job_inquiry: + ar: | + شكراً لاهتمامك! سامي حالياً يعمل كمهندس خدمات ميدانية في METCO (Smiths Detection). + إذا كنت ترغب في مناقشة فرصة وظيفية، يمكنك: + 1. حجز موعد: {calcom_url} + 2. الاطلاع على السيرة الذاتية: {cv_url} + سيتواصل معك سامي شخصياً في أقرب وقت. + en: | + Thank you for your interest! Sami currently works as a Field Services Engineer at METCO (Smiths Detection). + If you'd like to discuss a job opportunity, you can: + 1. Book a meeting: {calcom_url} + 2. View his CV: {cv_url} + Sami will follow up with you personally soon. + +unavailable: + ar: | + شكراً لتواصلك. سامي غير متاح حالياً. + سيتواصل معك في أقرب وقت ممكن. + إذا كان الأمر عاجلاً، يمكنك حجز موعد: {calcom_url} + en: | + Thank you for reaching out. Sami is currently unavailable. + He will get back to you as soon as possible. + If it's urgent, you can book a time: {calcom_url} + +contact_info: + ar: | + معلومات التواصل مع سامي: + - لينكدإن: {linkedin_url} + - حجز موعد: {calcom_url} + - البريد الإلكتروني: {email} + en: | + Sami's contact information: + - LinkedIn: {linkedin_url} + - Book a meeting: {calcom_url} + - Email: {email} + +thank_you: + ar: | + شكراً لك! إذا احتجت أي شيء آخر، لا تتردد في التواصل. + أتمنى لك يوماً سعيداً! 🌟 + en: | + Thank you! If you need anything else, don't hesitate to reach out. + Have a great day! diff --git a/personal-brand-engine/agents/whatsapp/responder.py b/personal-brand-engine/agents/whatsapp/responder.py new file mode 100644 index 00000000..5e008173 --- /dev/null +++ b/personal-brand-engine/agents/whatsapp/responder.py @@ -0,0 +1,220 @@ +"""LLM-powered response generation for WhatsApp conversations.""" + +from __future__ import annotations + +import logging +import re +from pathlib import Path +from typing import Any + +import yaml + +logger = logging.getLogger(__name__) + +_TEMPLATES_PATH = Path(__file__).parent / "prompts" / "whatsapp_templates.yaml" + +# Cached templates (loaded once) +_templates: dict | None = None + + +def _load_templates() -> dict: + """Load WhatsApp response templates from YAML.""" + global _templates + if _templates is None: + if _TEMPLATES_PATH.exists(): + with open(_TEMPLATES_PATH, "r", encoding="utf-8") as f: + _templates = yaml.safe_load(f) or {} + else: + _templates = {} + return _templates + + +def _detect_language(text: str) -> str: + """Heuristic language detection -- returns ``'ar'`` or ``'en'``. + + If the text contains Arabic Unicode characters, assume Arabic. + Otherwise default to English. + """ + arabic_pattern = re.compile(r"[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]+") + arabic_chars = len(arabic_pattern.findall(text)) + latin_chars = len(re.findall(r"[a-zA-Z]+", text)) + + if arabic_chars > 0 and arabic_chars >= latin_chars: + return "ar" + return "en" + + +def _build_system_prompt(brand_profile: dict, language: str) -> str: + """Construct the system prompt that tells the LLM how to behave.""" + templates = _load_templates() + + name = brand_profile.get("name", "Sami Mohammed Assiri") + title = brand_profile.get("title", "Field Services Engineer") + company = brand_profile.get("company", "METCO (Smiths Detection)") + location = brand_profile.get("location", "Riyadh, Saudi Arabia") + calcom_url = brand_profile.get("calcom_url", "https://cal.com/sami-assiri") + cv_url = brand_profile.get("cv_url", "") + linkedin_url = brand_profile.get("linkedin_url", "") + specialties = brand_profile.get("specialties", [ + "Airport security systems", + "CT/X-ray screening technology", + "Field service engineering", + "System integration and maintenance", + ]) + + specialties_str = ", ".join(specialties) if isinstance(specialties, list) else str(specialties) + + if language == "ar": + return f"""أنت المساعد المهني الذكي لـ {name}. +أنت تتواصل عبر واتساب نيابة عن سامي وتتصرف كمساعده الشخصي. + +معلومات عن سامي: +- الاسم: {name} +- المسمى الوظيفي: {title} +- الشركة: {company} +- الموقع: {location} +- التخصصات: {specialties_str} +- رابط الحجز: {calcom_url} +- السيرة الذاتية: {cv_url} +- لينكدإن: {linkedin_url} + +التعليمات: +1. رد دائماً بأسلوب مهني ولطيف باللغة العربية. +2. إذا طلب أحد حجز موعد أو اجتماع، وجّهه إلى رابط الحجز: {calcom_url} +3. إذا سأل أحد عن السيرة الذاتية أو الخبرات، شارك المعلومات المتاحة ورابط السيرة الذاتية إن وُجد. +4. إذا كان السؤال خارج نطاق معرفتك، أخبر المرسل أن سامي سيتواصل معه شخصياً. +5. لا تتظاهر بأنك سامي نفسه -- وضّح أنك مساعده الذكي. +6. كن مختصراً ومفيداً -- رسائل واتساب يجب أن تكون قصيرة. +7. إذا أرسل المستخدم رسالة بالإنجليزية، رد بالإنجليزية. +""" + else: + return f"""You are the professional AI assistant for {name}. +You communicate via WhatsApp on behalf of Sami and act as his personal assistant. + +About Sami: +- Name: {name} +- Title: {title} +- Company: {company} +- Location: {location} +- Specialties: {specialties_str} +- Booking link: {calcom_url} +- CV: {cv_url} +- LinkedIn: {linkedin_url} + +Instructions: +1. Always respond professionally and warmly. +2. If someone requests a meeting or appointment, direct them to the booking link: {calcom_url} +3. If someone asks about Sami's CV or experience, share available information and the CV link if available. +4. If the question is outside your knowledge, let the sender know Sami will follow up personally. +5. Do not pretend to be Sami himself -- clarify you are his AI assistant. +6. Be concise and helpful -- WhatsApp messages should be brief. +7. If the user writes in Arabic, respond in Arabic. +""" + + +async def generate_response( + llm_client: Any, + message: str, + sender_name: str, + brand_profile: dict, + conversation_history: list[dict[str, str]] | None = None, +) -> str: + """Generate a context-aware response using the LLM. + + Parameters + ---------- + llm_client: + Any LLM client that supports a ``chat`` or ``generate`` style call. + message: + The incoming message text. + sender_name: + Display name of the sender. + brand_profile: + Parsed brand profile dictionary. + conversation_history: + Optional list of ``{"role": ..., "content": ...}`` dicts. + + Returns + ------- + str + The generated response text. + """ + language = _detect_language(message) + system_prompt = _build_system_prompt(brand_profile, language) + + # Build the messages list for the LLM + messages: list[dict[str, str]] = [{"role": "system", "content": system_prompt}] + + # Include recent conversation history (last 10 turns) + if conversation_history: + recent = conversation_history[-10:] + for turn in recent: + if turn.get("role") in ("user", "assistant"): + messages.append( + {"role": turn["role"], "content": turn["content"]} + ) + else: + messages.append({"role": "user", "content": message}) + + # Call the LLM -- support multiple client interfaces + try: + response_text = await _call_llm(llm_client, messages) + except Exception as exc: + logger.error("LLM call failed: %s", exc) + raise + + return response_text.strip() + + +async def _call_llm( + llm_client: Any, + messages: list[dict[str, str]], +) -> str: + """Invoke the LLM client, handling different API shapes. + + Supports: + - OpenAI-compatible (``chat.completions.create``) + - Ollama-style (``chat`` method) + - Groq-style (``chat.completions.create``) + - Generic callable that accepts messages + """ + # OpenAI / Groq compatible interface + if hasattr(llm_client, "chat") and hasattr(llm_client.chat, "completions"): + response = await _async_or_sync( + llm_client.chat.completions.create, + messages=messages, + max_tokens=500, + temperature=0.7, + ) + return response.choices[0].message.content + + # Ollama-style interface + if hasattr(llm_client, "chat"): + response = await _async_or_sync( + llm_client.chat, + messages=messages, + ) + if isinstance(response, dict): + return response.get("message", {}).get("content", "") + return str(response) + + # Generic callable + if callable(llm_client): + response = await _async_or_sync(llm_client, messages=messages) + if isinstance(response, str): + return response + return str(response) + + raise TypeError(f"Unsupported LLM client type: {type(llm_client)}") + + +async def _async_or_sync(func: Any, **kwargs: Any) -> Any: + """Call *func* whether it is sync or async.""" + import asyncio + import inspect + + if inspect.iscoroutinefunction(func): + return await func(**kwargs) + else: + loop = asyncio.get_event_loop() + return await loop.run_in_executor(None, lambda: func(**kwargs)) diff --git a/personal-brand-engine/agents/whatsapp/webhook_handler.py b/personal-brand-engine/agents/whatsapp/webhook_handler.py new file mode 100644 index 00000000..aeac06bf --- /dev/null +++ b/personal-brand-engine/agents/whatsapp/webhook_handler.py @@ -0,0 +1,246 @@ +"""FastAPI router for WhatsApp webhook endpoints. + +Supports both Meta Cloud API and Twilio webhook formats. +""" + +from __future__ import annotations + +import hashlib +import hmac +import logging +from typing import Any + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response + +from config.settings import get_settings + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["whatsapp"]) + + +# ------------------------------------------------------------------ +# Dependency: obtain a configured WhatsAppAgent instance +# ------------------------------------------------------------------ + +def _get_agent(): + """Return a ready-to-use :class:`WhatsAppAgent`. + + In production this should be wired through your DI container. + Here we import lazily to avoid circular imports and create + a fresh agent per request (or pull from a singleton pool). + """ + from agents.whatsapp.agent import WhatsAppAgent + from storage.database import get_db + from llm.client import get_llm_client + + settings = get_settings() + db = get_db() + llm = get_llm_client() + + return WhatsAppAgent(config=settings, llm_client=llm, db_session=db) + + +# ------------------------------------------------------------------ +# Meta Cloud API +# ------------------------------------------------------------------ + +@router.get("/webhooks/whatsapp") +async def verify_webhook( + hub_mode: str | None = Query(None, alias="hub.mode"), + hub_verify_token: str | None = Query(None, alias="hub.verify_token"), + hub_challenge: str | None = Query(None, alias="hub.challenge"), +) -> Response: + """Meta Cloud API webhook verification (subscribe handshake). + + Meta sends a GET request with ``hub.mode``, ``hub.verify_token``, and + ``hub.challenge``. We must echo back the challenge if the token matches. + """ + settings = get_settings() + + if hub_mode == "subscribe" and hub_verify_token == settings.whatsapp_verify_token: + logger.info("WhatsApp webhook verified successfully.") + return Response(content=hub_challenge, media_type="text/plain") + + logger.warning( + "WhatsApp webhook verification failed (mode=%s, token=%s).", + hub_mode, + hub_verify_token, + ) + raise HTTPException(status_code=403, detail="Verification failed") + + +@router.post("/webhooks/whatsapp") +async def incoming_message(request: Request) -> dict: + """Handle incoming WhatsApp messages from either Meta or Twilio. + + The handler inspects the payload to determine the source format and + dispatches accordingly. + """ + content_type = request.headers.get("content-type", "") + + # Twilio sends application/x-www-form-urlencoded + if "application/x-www-form-urlencoded" in content_type: + form = await request.form() + return await _handle_twilio(dict(form)) + + # Meta Cloud API sends application/json + body = await request.json() + return await _handle_meta(body) + + +# ------------------------------------------------------------------ +# Meta Cloud API handler +# ------------------------------------------------------------------ + +async def _handle_meta(body: dict[str, Any]) -> dict: + """Parse a Meta Cloud API webhook payload and respond.""" + try: + entry = body.get("entry", []) + if not entry: + return {"status": "ignored", "reason": "no entry"} + + changes = entry[0].get("changes", []) + if not changes: + return {"status": "ignored", "reason": "no changes"} + + value = changes[0].get("value", {}) + messages = value.get("messages", []) + if not messages: + # Could be a status update (delivered, read, etc.) -- acknowledge. + return {"status": "ok", "reason": "status_update"} + + message = messages[0] + msg_type = message.get("type") + from_number = message.get("from", "") + + # Extract sender name from contacts if available + contacts = value.get("contacts", []) + sender_name = None + if contacts: + profile = contacts[0].get("profile", {}) + sender_name = profile.get("name") + + if msg_type != "text": + logger.info("Ignoring non-text message type: %s", msg_type) + return {"status": "ignored", "reason": f"unsupported_type:{msg_type}"} + + message_text = message.get("text", {}).get("body", "") + if not message_text: + return {"status": "ignored", "reason": "empty_body"} + + # Process message + agent = _get_agent() + response_text = await agent.handle_message( + from_number=from_number, + message_text=message_text, + sender_name=sender_name, + ) + + # Send reply via Meta Cloud API + await _send_meta_reply(from_number, response_text) + + return {"status": "ok", "to": from_number} + + except Exception as exc: + logger.exception("Error processing Meta webhook: %s", exc) + # Return 200 to avoid Meta retrying on transient errors + return {"status": "error", "message": str(exc)} + + +async def _send_meta_reply(to_number: str, text: str) -> None: + """Send a text message reply via Meta Cloud API.""" + import httpx + + settings = get_settings() + token = settings.whatsapp_api_token + phone_id = settings.whatsapp_phone_number_id + + if not token or not phone_id: + logger.error("Meta Cloud API credentials not configured.") + return + + url = f"https://graph.facebook.com/v21.0/{phone_id}/messages" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + payload = { + "messaging_product": "whatsapp", + "recipient_type": "individual", + "to": to_number, + "type": "text", + "text": {"preview_url": False, "body": text}, + } + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, json=payload, headers=headers) + resp.raise_for_status() + logger.info("Meta reply sent to %s (status=%s)", to_number, resp.status_code) + except httpx.HTTPError as exc: + logger.error("Failed to send Meta reply to %s: %s", to_number, exc) + + +# ------------------------------------------------------------------ +# Twilio handler +# ------------------------------------------------------------------ + +async def _handle_twilio(form: dict[str, Any]) -> dict: + """Parse a Twilio WhatsApp webhook payload and respond.""" + try: + from_number = form.get("From", "") + message_text = form.get("Body", "") + sender_name = form.get("ProfileName") + + # Strip Twilio's "whatsapp:" prefix + if from_number.startswith("whatsapp:"): + from_number = from_number[len("whatsapp:"):] + + if not message_text: + return {"status": "ignored", "reason": "empty_body"} + + agent = _get_agent() + response_text = await agent.handle_message( + from_number=from_number, + message_text=message_text, + sender_name=sender_name, + ) + + # Send reply via Twilio + await _send_twilio_reply(from_number, response_text) + + return {"status": "ok", "to": from_number} + + except Exception as exc: + logger.exception("Error processing Twilio webhook: %s", exc) + return {"status": "error", "message": str(exc)} + + +async def _send_twilio_reply(to_number: str, text: str) -> None: + """Send a text message reply via the Twilio API.""" + import httpx + + settings = get_settings() + sid = settings.twilio_account_sid + auth = settings.twilio_auth_token + from_number = settings.twilio_whatsapp_number + + if not sid or not auth or not from_number: + logger.error("Twilio credentials not configured.") + return + + url = f"https://api.twilio.com/2010-04-01/Accounts/{sid}/Messages.json" + payload = { + "From": f"whatsapp:{from_number}", + "To": f"whatsapp:{to_number}", + "Body": text, + } + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post(url, data=payload, auth=(sid, auth)) + resp.raise_for_status() + logger.info("Twilio reply sent to %s (status=%s)", to_number, resp.status_code) + except httpx.HTTPError as exc: + logger.error("Failed to send Twilio reply to %s: %s", to_number, exc) diff --git a/personal-brand-engine/api/__init__.py b/personal-brand-engine/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/personal-brand-engine/api/main.py b/personal-brand-engine/api/main.py new file mode 100644 index 00000000..f86fa561 --- /dev/null +++ b/personal-brand-engine/api/main.py @@ -0,0 +1,71 @@ +"""FastAPI application - webhooks, health check, and status dashboard.""" + +from __future__ import annotations + +import logging +import sys +from contextlib import asynccontextmanager +from pathlib import Path + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from config.settings import get_settings +from storage.database import init_db +from api.routes.health import router as health_router +from api.routes.webhooks import router as webhooks_router +from api.routes.dashboard import router as dashboard_router + +logger = logging.getLogger(__name__) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup and shutdown events.""" + logging.basicConfig( + level=getattr(logging, get_settings().log_level), + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + ) + init_db() + logger.info("Personal Brand Engine API started") + yield + logger.info("Personal Brand Engine API shutting down") + + +app = FastAPI( + title="Personal Brand Engine - Sami Assiri", + description="AI-powered personal brand automation system", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + +# Routes +app.include_router(health_router, tags=["Health"]) +app.include_router(webhooks_router, prefix="/webhooks", tags=["Webhooks"]) +app.include_router(dashboard_router, prefix="/dashboard", tags=["Dashboard"]) + +# Serve landing page as static files +landing_page_dir = Path(__file__).resolve().parent.parent / "landing_page" +if landing_page_dir.exists(): + app.mount("/", StaticFiles(directory=str(landing_page_dir), html=True), name="landing") + + +if __name__ == "__main__": + import uvicorn + settings = get_settings() + uvicorn.run( + "api.main:app", + host=settings.api_host, + port=settings.api_port, + reload=False, + ) diff --git a/personal-brand-engine/api/routes/__init__.py b/personal-brand-engine/api/routes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/personal-brand-engine/api/routes/dashboard.py b/personal-brand-engine/api/routes/dashboard.py new file mode 100644 index 00000000..ac8e4ba5 --- /dev/null +++ b/personal-brand-engine/api/routes/dashboard.py @@ -0,0 +1,150 @@ +"""Dashboard API - agent status, stats, and recent activity.""" + +from __future__ import annotations + +from datetime import datetime, timedelta, timezone + +from fastapi import APIRouter +from sqlalchemy import func + +from storage.database import get_db +from storage.models import AgentLog, Post, Email, Opportunity, ContentCalendar + +router = APIRouter() + + +@router.get("/status") +async def get_system_status(): + """Get overall system status and stats.""" + db = get_db() + try: + now = datetime.now(timezone.utc) + last_24h = now - timedelta(hours=24) + last_7d = now - timedelta(days=7) + + # Agent activity + total_runs_24h = db.query(func.count(AgentLog.id)).filter( + AgentLog.created_at >= last_24h + ).scalar() or 0 + + failed_runs_24h = db.query(func.count(AgentLog.id)).filter( + AgentLog.created_at >= last_24h, + AgentLog.status == "failed", + ).scalar() or 0 + + # Content stats + posts_published = db.query(func.count(Post.id)).filter( + Post.status == "published", + Post.published_at >= last_7d, + ).scalar() or 0 + + # Email stats + emails_processed = db.query(func.count(Email.id)).filter( + Email.created_at >= last_24h, + ).scalar() or 0 + + # Opportunity stats + new_opportunities = db.query(func.count(Opportunity.id)).filter( + Opportunity.created_at >= last_24h, + Opportunity.status == "new", + ).scalar() or 0 + + return { + "status": "running", + "owner": "Sami Assiri", + "stats": { + "agent_runs_24h": total_runs_24h, + "failed_runs_24h": failed_runs_24h, + "success_rate": ( + round((1 - failed_runs_24h / total_runs_24h) * 100, 1) + if total_runs_24h > 0 + else 100.0 + ), + "posts_published_7d": posts_published, + "emails_processed_24h": emails_processed, + "new_opportunities_24h": new_opportunities, + }, + "timestamp": now.isoformat(), + } + finally: + db.close() + + +@router.get("/agents") +async def get_agent_activity(): + """Get recent agent activity logs.""" + db = get_db() + try: + logs = ( + db.query(AgentLog) + .order_by(AgentLog.created_at.desc()) + .limit(50) + .all() + ) + return [ + { + "agent": log.agent_name, + "task": log.task, + "status": log.status, + "duration": log.duration_seconds, + "details": log.details[:200] if log.details else None, + "timestamp": log.created_at.isoformat() if log.created_at else None, + } + for log in logs + ] + finally: + db.close() + + +@router.get("/opportunities") +async def get_opportunities(): + """Get recent opportunities found by the scout bot.""" + db = get_db() + try: + opps = ( + db.query(Opportunity) + .order_by(Opportunity.created_at.desc()) + .limit(20) + .all() + ) + return [ + { + "id": opp.id, + "source": opp.source, + "title": opp.title, + "company": opp.company, + "url": opp.url, + "relevance_score": opp.relevance_score, + "status": opp.status, + "created_at": opp.created_at.isoformat() if opp.created_at else None, + } + for opp in opps + ] + finally: + db.close() + + +@router.get("/content") +async def get_content_calendar(): + """Get upcoming content calendar.""" + db = get_db() + try: + items = ( + db.query(ContentCalendar) + .order_by(ContentCalendar.date.desc()) + .limit(14) + .all() + ) + return [ + { + "id": item.id, + "date": item.date.isoformat() if item.date else None, + "pillar": item.pillar, + "topic": item.topic, + "platform": item.platform, + "status": item.status, + } + for item in items + ] + finally: + db.close() diff --git a/personal-brand-engine/api/routes/health.py b/personal-brand-engine/api/routes/health.py new file mode 100644 index 00000000..e22c9832 --- /dev/null +++ b/personal-brand-engine/api/routes/health.py @@ -0,0 +1,16 @@ +"""Health check endpoint.""" + +from fastapi import APIRouter +from datetime import datetime, timezone + +router = APIRouter() + + +@router.get("/health") +async def health_check(): + return { + "status": "healthy", + "service": "Personal Brand Engine", + "owner": "Sami Assiri", + "timestamp": datetime.now(timezone.utc).isoformat(), + } diff --git a/personal-brand-engine/api/routes/webhooks.py b/personal-brand-engine/api/routes/webhooks.py new file mode 100644 index 00000000..55cdc0bd --- /dev/null +++ b/personal-brand-engine/api/routes/webhooks.py @@ -0,0 +1,131 @@ +"""Webhook endpoints for WhatsApp and other services.""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Request, Response, Query + +from config.settings import get_settings +from llm.client import get_llm_client +from storage.database import get_db + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.get("/whatsapp") +async def verify_whatsapp_webhook( + hub_mode: str = Query(None, alias="hub.mode"), + hub_challenge: str = Query(None, alias="hub.challenge"), + hub_verify_token: str = Query(None, alias="hub.verify_token"), +): + """Meta Cloud API webhook verification.""" + settings = get_settings() + if hub_mode == "subscribe" and hub_verify_token == settings.whatsapp_verify_token: + logger.info("WhatsApp webhook verified") + return Response(content=hub_challenge, media_type="text/plain") + return Response(content="Forbidden", status_code=403) + + +@router.post("/whatsapp") +async def handle_whatsapp_message(request: Request): + """Handle incoming WhatsApp messages via Meta Cloud API.""" + try: + body = await request.json() + logger.info("WhatsApp webhook received") + + # Extract message from Meta Cloud API format + entry = body.get("entry", [{}])[0] + changes = entry.get("changes", [{}])[0] + value = changes.get("value", {}) + messages = value.get("messages", []) + + if not messages: + return {"status": "no_message"} + + message = messages[0] + from_number = message.get("from", "") + message_text = message.get("text", {}).get("body", "") + + if not message_text: + return {"status": "non_text_message"} + + # Process with WhatsApp agent + from agents.whatsapp import WhatsAppAgent + + settings = get_settings() + llm_client = get_llm_client() + db = get_db() + + agent = WhatsAppAgent(config=settings, llm_client=llm_client, db_session=db) + result = await agent.run( + task="handle_message", + from_number=from_number, + message_text=message_text, + ) + + # Send response back via Meta Cloud API + response_text = result.get("response", "") + if response_text and settings.whatsapp_api_token: + import httpx + async with httpx.AsyncClient() as client: + await client.post( + f"https://graph.facebook.com/v18.0/{settings.whatsapp_phone_number_id}/messages", + headers={"Authorization": f"Bearer {settings.whatsapp_api_token}"}, + json={ + "messaging_product": "whatsapp", + "to": from_number, + "type": "text", + "text": {"body": response_text}, + }, + ) + + db.close() + return {"status": "processed"} + + except Exception as e: + logger.error("WhatsApp webhook error: %s", e) + return {"status": "error", "detail": str(e)} + + +@router.post("/whatsapp/twilio") +async def handle_twilio_whatsapp(request: Request): + """Handle incoming WhatsApp messages via Twilio.""" + try: + form = await request.form() + from_number = form.get("From", "").replace("whatsapp:", "") + message_text = form.get("Body", "") + + if not message_text: + return Response(content="", media_type="application/xml") + + from agents.whatsapp import WhatsAppAgent + + settings = get_settings() + llm_client = get_llm_client() + db = get_db() + + agent = WhatsAppAgent(config=settings, llm_client=llm_client, db_session=db) + result = await agent.run( + task="handle_message", + from_number=from_number, + message_text=message_text, + ) + + response_text = result.get("response", "شكراً لتواصلك!") + db.close() + + # TwiML response + twiml = f""" + + {response_text} +""" + return Response(content=twiml, media_type="application/xml") + + except Exception as e: + logger.error("Twilio webhook error: %s", e) + return Response( + content="عذراً، حدث خطأ. يرجى المحاولة لاحقاً.", + media_type="application/xml", + ) diff --git a/personal-brand-engine/config/__init__.py b/personal-brand-engine/config/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/personal-brand-engine/config/brand_profile.yaml b/personal-brand-engine/config/brand_profile.yaml new file mode 100644 index 00000000..e8b6d447 --- /dev/null +++ b/personal-brand-engine/config/brand_profile.yaml @@ -0,0 +1,166 @@ +# =================================== +# Sami Mohammed Assiri - Brand Profile +# =================================== + +personal: + name_ar: "سامي محمد العسيري" + name_en: "Sami Mohammed Assiri" + title_ar: "مهندس خدمات ميدانية - أمن المطارات | مهندس ميكانيكي" + title_en: "Field Services Engineer - Airport Security | Mechanical Engineer" + headline_ar: "مهندس خدمات ميدانية في METCO | متخصص أجهزة Smiths Detection بمطار الرياض | مهندس ميكانيكي | Python & Data Analytics" + headline_en: "Field Services Engineer at METCO | Smiths Detection Airport Security Specialist | Mechanical Engineer | Python & Data Analytics | Ex-Samsung E&A" + bio_ar: | + مهندس ميكانيكي وخدمات ميدانية في شركة METCO (خدمات الشرق الأوسط) بمطار الملك خالد الدولي بالرياض. + متخصص في صيانة وتشغيل أنظمة أمن المطارات من Smiths Detection، بما في ذلك أجهزة الأشعة السينية + (HI-SCAN) وأجهزة كشف المتفجرات (IONSCAN 600) وأنظمة الفحص المتقدمة (CTX). + + سابقاً في Samsung E&A حيث طورت لوحات بيانات بايثون وأتمتت تخطيط المشاريع باستخدام Primavera P6 + وقدمت تحليلات متقدمة لمشاريع بمليارات الدولارات مع أرامكو ومقاولي EPC العالميين. + + رئيس فرع SPE الأصالة - نقلت الفرع من 0 إلى 89 عضو فعال مع 50,000+ انطباع عضوي. + مؤسس نادي المهندسين النخبة. حاصل على 10+ شهادات مهنية. + bio_en: | + Mechanical Engineer and Field Services Engineer at METCO (Middle East Services) stationed + at King Khalid International Airport, Riyadh. Specialized in maintenance and operation of + Smiths Detection airport security systems including HI-SCAN X-ray screening, IONSCAN 600 + trace detection, and CTX advanced inspection systems. + + Previously at Samsung E&A where I engineered Python-powered dashboards, automated project + planning with Primavera P6, and delivered advanced analytics for multi-billion-dollar oil + & gas projects with Aramco and global EPC contractors. + + As President of SPE Alasala Chapter, scaled membership from 0 to 89 active participants, + launched 12+ technical workshops, and drove 50,000+ organic impressions. Founded the Elite + Engineers Club attracting 40+ multidisciplinary students. 10+ professional certifications. + email: "sami.assiri11@gmail.com" + email_old: "sami.m.assiri@gmail.com" + phone: "+966597788539" + location_ar: "الرياض، المملكة العربية السعودية" + location_en: "Riyadh, Saudi Arabia" + hometown: "Dhahran, Eastern Province" + +employment: + current: + company: "METCO - Middle East Services" + company_ar: "ميتكو - خدمات الشرق الأوسط" + title: "Field Services Engineer" + title_ar: "مهندس خدمات ميدانية" + location: "King Khalid International Airport (RUH), Riyadh" + location_ar: "مطار الملك خالد الدولي - الرياض" + start_date: "2026-01-04" + description_en: | + - Maintenance and operation of Smiths Detection airport security equipment + - X-Ray screening systems (HI-SCAN series) + - Trace detection systems (IONSCAN 600) + - Advanced CT inspection systems (CTX series) + - Preventive and corrective maintenance procedures + - System calibration and quality assurance + description_ar: | + - صيانة وتشغيل أجهزة أمن المطارات من Smiths Detection + - أنظمة الفحص بالأشعة السينية (سلسلة HI-SCAN) + - أنظمة كشف الآثار (IONSCAN 600) + - أنظمة الفحص المتقدمة بتقنية CT (سلسلة CTX) + - إجراءات الصيانة الوقائية والتصحيحية + - معايرة الأنظمة وضمان الجودة + + previous: + - company: "Samsung E&A Saudi Arabia" + title: "Planning Engineer Intern" + period: "Feb 2025 - May 2025" + highlights: + - "Engineered Python-powered dashboards, reducing reporting time by 75%" + - "Modeled 4,327 activities in Primavera P6 with Monte Carlo simulations" + - "Co-authored 12,000-tag digital-asset registry baseline, 17 days ahead of schedule" + - "Facilitated 14+ high-level meetings with Aramco and global EPC contractors" + +leadership: + - role: "President" + organization: "Society of Petroleum Engineers (SPE) - Alasala Chapter" + period: "Sep 2024 - Present" + highlights: + - "Scaled membership from 0 to 89 active members" + - "50,000+ organic impressions on social campaigns" + - "Secured 2025 MENA PetroBowl qualifiers invitation" + - "Built partnerships with Aramco, Saudi Council of Engineers" + + - role: "Founder" + organization: "Elite Engineers Club" + period: "2024 - May 2025" + highlights: + - "40+ multidisciplinary engineering students" + - "Secured industry sponsorships and accreditation" + +education: + degree: "Bachelor of Science in Mechanical Engineering" + institution: "Alasala Colleges" + location: "Dammam, Eastern Province, Saudi Arabia" + period: "Mar 2019 - May 2025" + highlights: + - "Best Capstone Project Award (1st of 16 teams) - biodegradable green composite" + - "SPE KSA Excellence Award (2025)" + - "Presidential Recognition from SPE (2025)" + +awards: + - "Best Capstone Project Award (May 2025) - Alasala Colleges" + - "SPE KSA Excellence Award (2025)" + - "Presidential Recognition - SPE (2025)" + +certifications: + - "MV Switchgears & Modular Power Systems - Workshop (Aug 2025)" + - "Earthing Systems - Training Workshop (Aug 2025)" + - "KNX System Fundamentals & Home/Building Automation - eLearning (Oct 2024)" + - "Saudi Mechanical Code (SBC 501) - Saudi Council of Engineers (Jun 2024)" + - "BIM-Oriented Sustainable Design - Autodesk (May 2024)" + - "Emergency Lighting & Central Battery Systems - ABB (Apr 2024)" + - "Low Voltage Circuit Breakers (IEC Standards) - ABB (Apr 2024)" + - "ABB E-Design Certification - ABB (May 2024)" + - "Contract & Tendering Management - PMI (Jun 2024)" + - "Organizational Effectiveness & Excellence - EFQM (May 2024)" + +skills: + data_analytics: + - "Python (Pandas, NumPy, Plotly, Dash)" + - "SQL, Power BI, Jupyter Notebook" + - "Advanced Excel (Analysis, Automation, Reporting)" + - "Data-Driven Decision Making" + - "KPI Development & Performance Tracking" + - "Risk Modeling & Forecasting" + project_management: + - "Primavera P6 & MS Project" + - "Project Planning & Scheduling" + - "Monte Carlo Simulation (Risk Analysis)" + - "Cost Estimation & Budget Control" + - "Stakeholder Management" + - "Resource Optimization & Strategic Execution" + engineering: + - "Smiths Detection Airport Security Equipment" + - "X-Ray Screening Systems (HI-SCAN)" + - "Trace Detection (IONSCAN 600)" + - "CT Inspection Systems (CTX)" + - "Asset Management Systems" + - "Digital Twin Integration" + - "BIM Fundamentals & Sustainable Design" + - "Automated Reporting & ETL Pipelines" + - "HVAC Systems" + - "Renewable Energy Systems" + leadership: + - "Strategic Communication & Negotiation" + - "Cross-Functional Collaboration" + - "Organizational Design & Talent Development" + - "Event Planning & Industry Engagement" + languages: + - name: "Arabic" + level: "Native" + - name: "English" + level: "Professional" + +links: + linkedin: "https://www.linkedin.com/in/sami-assiri-a300622b2/" + twitter: "" + github: "" + website: "" + calcom: "" + +references: + - "Dr. Saeed AlNoman - Assistant Professor, Mechanical Engineering, Alasala Colleges" + - "Khalifa - Assistant Director of Project Management, Samsung E&A" diff --git a/personal-brand-engine/config/content_strategy.yaml b/personal-brand-engine/config/content_strategy.yaml new file mode 100644 index 00000000..b66a3d0e --- /dev/null +++ b/personal-brand-engine/config/content_strategy.yaml @@ -0,0 +1,89 @@ +# =================================== +# Content Strategy - Sami Assiri +# =================================== + +brand_positioning: + tagline_ar: "متخصص تقنيات أمن المطارات" + tagline_en: "Airport Security Technology Specialist" + unique_value: "Hands-on Smiths Detection field engineer with real airport experience" + +content_pillars: + - id: "tech_insights" + name_ar: "رؤى تقنية في أمن المطارات" + name_en: "Airport Security Tech Insights" + description: "Deep dives into Smiths Detection equipment, X-Ray technology, trace detection" + frequency: "weekly" + platforms: ["linkedin", "twitter"] + hashtags: + - "#AirportSecurity" + - "#SmithsDetection" + - "#AviationSafety" + - "#أمن_المطارات" + - "#الطيران" + + - id: "field_life" + name_ar: "يوميات مهندس ميداني" + name_en: "Field Engineer Life" + description: "Behind-the-scenes at the airport, daily challenges and wins" + frequency: "weekly" + platforms: ["linkedin", "twitter"] + hashtags: + - "#FieldEngineer" + - "#AirportLife" + - "#Engineering" + - "#مهندس_ميداني" + + - id: "professional_growth" + name_ar: "التطوير المهني" + name_en: "Professional Development" + description: "Certifications, training, career growth in aviation security" + frequency: "biweekly" + platforms: ["linkedin"] + hashtags: + - "#CareerGrowth" + - "#ProfessionalDevelopment" + - "#تطوير_مهني" + + - id: "industry_news" + name_ar: "أخبار القطاع" + name_en: "Industry News & Commentary" + description: "ICAO, GACA, TSA regulations and industry developments" + frequency: "weekly" + platforms: ["linkedin", "twitter"] + hashtags: + - "#GACA" + - "#ICAO" + - "#AviationSecurity" + - "#الهيئة_العامة_للطيران_المدني" + +tone: + primary_language: "ar" + secondary_language: "en" + style: "professional_approachable" + guidelines: + - "Technical but accessible - explain complex systems simply" + - "Confident expertise without arrogance" + - "Arabic for local audience, English for technical/international content" + - "Share real experiences (without revealing sensitive security details)" + - "Position as a specialist, not a generalist" + +engagement_rules: + daily_likes: 15 + daily_comments: 5 + comment_style: "insightful and value-adding, never generic" + target_profiles: + - "Aviation security professionals" + - "Smiths Detection employees and partners" + - "Airport operations managers" + - "Saudi aviation industry leaders" + - "GACA officials and regulators" + +posting_rules: + max_posts_per_day: 1 + best_times_riyadh: + - "08:00" # Morning commute + - "12:30" # Lunch break + - "19:00" # Evening + min_hours_between_posts: 6 + include_hashtags: true + max_hashtags: 5 diff --git a/personal-brand-engine/config/schedule.yaml b/personal-brand-engine/config/schedule.yaml new file mode 100644 index 00000000..9ecfff53 --- /dev/null +++ b/personal-brand-engine/config/schedule.yaml @@ -0,0 +1,68 @@ +# =================================== +# Agent Schedule Configuration +# =================================== +# Cron format: minute hour day_of_week +# Saudi work week: Sun-Thu +# Timezone: Asia/Riyadh (UTC+3) + +agents: + linkedin: + post_content: + cron: "0 8 * * 0,2,4" # Sun/Tue/Thu at 8:00 AM + description: "Generate and post LinkedIn content" + engage_network: + cron: "0 9,14,19 * * 0-4" # 3x daily on workdays (9AM, 2PM, 7PM) + description: "Like and comment on connections' posts" + optimize_profile: + cron: "0 2 * * 5" # Friday 2:00 AM (weekend) + description: "Review and optimize LinkedIn profile" + + email: + check_inbox: + interval_minutes: 15 + description: "Monitor inbox, classify and draft responses" + send_scheduled: + cron: "*/30 * * * *" # Every 30 minutes + description: "Send any queued scheduled emails" + + social_media: + post_twitter: + cron: "0 10 * * 0-4" # Daily 10:00 AM on workdays + description: "Post to Twitter/X" + repurpose_content: + cron: "0 12 * * 1,3" # Mon/Wed at noon + description: "Repurpose LinkedIn content for other platforms" + + whatsapp: + mode: "webhook" + description: "Always-on via webhook - responds to incoming messages" + + cv_optimizer: + update_cv: + cron: "0 3 1 * *" # 1st of every month at 3:00 AM + description: "Update CV/resume with latest experience" + generate_pdf: + cron: "0 4 1 * *" # 1st of every month at 4:00 AM + description: "Generate updated PDF resume" + + content_strategist: + weekly_plan: + cron: "0 22 * * 4" # Thursday 10:00 PM (plan for next week) + description: "Generate weekly content calendar" + trend_analysis: + cron: "0 6 * * 0-4" # Daily 6:00 AM on workdays + description: "Analyze trending topics in aviation security" + + opportunity_scout: + scan_opportunities: + cron: "0 */2 * * *" # Every 2 hours + description: "Scan for job opportunities, news, and events" + scan_linkedin_jobs: + cron: "0 7,13,20 * * 0-4" # 3x daily on workdays + description: "Check LinkedIn for relevant job postings" + scan_industry_news: + cron: "0 5 * * *" # Daily 5:00 AM + description: "Monitor aviation security and Smiths Detection news" + daily_digest: + cron: "0 21 * * *" # Daily 9:00 PM + description: "Send daily digest of all found opportunities via WhatsApp/email" diff --git a/personal-brand-engine/config/settings.py b/personal-brand-engine/config/settings.py new file mode 100644 index 00000000..556b44c2 --- /dev/null +++ b/personal-brand-engine/config/settings.py @@ -0,0 +1,109 @@ +"""Central configuration loaded from .env and YAML files.""" + +from __future__ import annotations + +import os +from pathlib import Path +from functools import lru_cache + +import yaml +from pydantic_settings import BaseSettings +from pydantic import Field + + +BASE_DIR = Path(__file__).resolve().parent.parent +CONFIG_DIR = BASE_DIR / "config" + + +class Settings(BaseSettings): + """Application settings loaded from environment variables.""" + + # LLM - Ollama + ollama_base_url: str = "http://localhost:11434" + ollama_model: str = "qwen2.5:7b" + + # LLM - Groq + groq_api_key: str = "" + groq_model: str = "llama-3.1-70b-versatile" + + # LLM - OpenAI + openai_api_key: str = "" + openai_model: str = "gpt-4o-mini" + + # LinkedIn + linkedin_email: str = "" + linkedin_password: str = "" + + # Twitter/X + twitter_api_key: str = "" + twitter_api_secret: str = "" + twitter_access_token: str = "" + twitter_access_secret: str = "" + twitter_bearer_token: str = "" + + # Email + imap_host: str = "imap.gmail.com" + imap_port: int = 993 + smtp_host: str = "smtp.gmail.com" + smtp_port: int = 587 + email_address: str = "" + email_password: str = "" + + # WhatsApp - Meta Cloud API + whatsapp_api_token: str = "" + whatsapp_phone_number_id: str = "" + whatsapp_verify_token: str = "your-webhook-verify-token" + + # WhatsApp - Twilio + twilio_account_sid: str = "" + twilio_auth_token: str = "" + twilio_whatsapp_number: str = "" + + # Cal.com + calcom_api_key: str = "" + calcom_booking_url: str = "" + + # Notifications + telegram_bot_token: str = "" + telegram_chat_id: str = "" + + # Database + database_url: str = "sqlite:///./data/brand_engine.db" + + # Server + api_host: str = "0.0.0.0" + api_port: int = 8080 + api_secret_key: str = "change-this-to-a-random-secret" + + # General + timezone: str = "Asia/Riyadh" + default_language: str = "ar" + log_level: str = "INFO" + + model_config = {"env_file": str(BASE_DIR / ".env"), "env_file_encoding": "utf-8"} + + +def load_yaml(filename: str) -> dict: + """Load a YAML config file from the config directory.""" + filepath = CONFIG_DIR / filename + if not filepath.exists(): + return {} + with open(filepath, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +@lru_cache +def get_settings() -> Settings: + return Settings() + + +def get_brand_profile() -> dict: + return load_yaml("brand_profile.yaml") + + +def get_schedule_config() -> dict: + return load_yaml("schedule.yaml") + + +def get_content_strategy() -> dict: + return load_yaml("content_strategy.yaml") diff --git a/personal-brand-engine/docker-compose.yml b/personal-brand-engine/docker-compose.yml new file mode 100644 index 00000000..ca01445e --- /dev/null +++ b/personal-brand-engine/docker-compose.yml @@ -0,0 +1,41 @@ +version: "3.8" + +services: + brand-engine: + build: + context: . + dockerfile: docker/Dockerfile + container_name: brand-engine + restart: always + env_file: .env + volumes: + - ./data:/app/data + - ./config:/app/config + - ./generated_cvs:/app/generated_cvs + - ./logs:/app/logs + ports: + - "${API_PORT:-8080}:8080" + depends_on: + - ollama + environment: + - OLLAMA_BASE_URL=http://ollama:11434 + + ollama: + image: ollama/ollama:latest + container_name: brand-ollama + restart: always + volumes: + - ollama_data:/root/.ollama + ports: + - "11434:11434" + # Uncomment for GPU support: + # deploy: + # resources: + # reservations: + # devices: + # - driver: nvidia + # count: 1 + # capabilities: [gpu] + +volumes: + ollama_data: diff --git a/personal-brand-engine/docker/Dockerfile b/personal-brand-engine/docker/Dockerfile new file mode 100644 index 00000000..34492330 --- /dev/null +++ b/personal-brand-engine/docker/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.12-slim + +# System deps for weasyprint (CV PDF generation) +RUN apt-get update && apt-get install -y --no-install-recommends \ + libpango-1.0-0 \ + libpangocairo-1.0-0 \ + libgdk-pixbuf2.0-0 \ + libffi-dev \ + libcairo2 \ + supervisor \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python dependencies +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create data directories +RUN mkdir -p /app/data /app/generated_cvs /app/logs + +# Supervisor config +COPY docker/supervisord.conf /etc/supervisor/conf.d/brand-engine.conf + +EXPOSE 8080 + +CMD ["supervisord", "-n", "-c", "/etc/supervisor/conf.d/brand-engine.conf"] diff --git a/personal-brand-engine/docker/supervisord.conf b/personal-brand-engine/docker/supervisord.conf new file mode 100644 index 00000000..301e66ee --- /dev/null +++ b/personal-brand-engine/docker/supervisord.conf @@ -0,0 +1,22 @@ +[supervisord] +nodaemon=true +logfile=/app/logs/supervisord.log +pidfile=/tmp/supervisord.pid + +[program:api] +command=python -m uvicorn api.main:app --host 0.0.0.0 --port 8080 +directory=/app +autostart=true +autorestart=true +stdout_logfile=/app/logs/api.log +stderr_logfile=/app/logs/api_error.log +environment=PYTHONPATH="/app" + +[program:scheduler] +command=python -m scheduler.runner +directory=/app +autostart=true +autorestart=true +stdout_logfile=/app/logs/scheduler.log +stderr_logfile=/app/logs/scheduler_error.log +environment=PYTHONPATH="/app" diff --git a/personal-brand-engine/generated_cvs/Sami_Assiri_CV_2026.html b/personal-brand-engine/generated_cvs/Sami_Assiri_CV_2026.html new file mode 100644 index 00000000..f8de8833 --- /dev/null +++ b/personal-brand-engine/generated_cvs/Sami_Assiri_CV_2026.html @@ -0,0 +1,325 @@ + + + + + + Sami Mohammed Assiri - CV + + + +
+ +
+

Sami Mohammed Assiri

+
Field Services Engineer | Airport Security Systems | Smiths Detection Specialist
+
+ sami.assiri11@gmail.com + | + +966 597 788 539 + | + Riyadh, Saudi Arabia + | + linkedin.com/in/sami-assiri +
+
+ + +
+

Professional Summary

+

+ Results-driven Mechanical Engineer and Field Services Engineer with hands-on expertise in + Smiths Detection airport security systems (HI-SCAN, IONSCAN 600, CTX) at King Khalid International Airport. + Proven track record in data-driven project planning, having engineered Python-powered dashboards that + reduced reporting time by 75% and modeled 4,327+ activities in Primavera P6 for multi-billion-dollar + oil & gas projects at Samsung E&A. Demonstrated leadership as SPE Alasala Chapter President, + scaling membership from 0 to 89 active participants and generating 50,000+ organic impressions. + Combines technical depth in security systems maintenance with strong analytical capabilities in + Python, SQL, Power BI, and advanced project management tools. +

+
+ + +
+

Work Experience

+ +
+
+ Field Services Engineer + Jan 2026 – Present +
+
METCO – Middle East Services | King Khalid International Airport, Riyadh
+
    +
  • Execute preventive and corrective maintenance on Smiths Detection airport security equipment serving 30M+ annual passengers
  • +
  • Operate and calibrate HI-SCAN X-ray screening systems ensuring 99.9% uptime for passenger and baggage inspection
  • +
  • Maintain IONSCAN 600 trace detection systems for explosive and narcotic identification at security checkpoints
  • +
  • Service CTX advanced computed tomography inspection systems for hold baggage screening
  • +
  • Perform system calibration, quality assurance testing, and compliance verification per GACA and ICAO standards
  • +
  • Troubleshoot and resolve complex equipment malfunctions, minimizing operational disruptions to airport security
  • +
+
+ +
+
+ Planning Engineer Intern + Feb 2025 – May 2025 +
+
Samsung E&A Saudi Arabia | Dammam, Eastern Province
+
    +
  • Engineered Python-powered dashboards and automated Gantt chart modules, reducing reporting preparation time by 75%
  • +
  • Modeled and scheduled 4,327 activities in Primavera P6, applying Monte Carlo simulations to deliver accurate P-80 risk envelopes for high-value oil & gas projects
  • +
  • Co-authored and deployed a 12,000-tag digital-asset registry baseline, completed 17 days ahead of schedule, supporting operational readiness for multi-billion-dollar facilities
  • +
  • Produced data-driven quarterly performance and market intelligence reports for 8+ major projects, providing actionable insights for strategic planning and investment decisions
  • +
  • Facilitated 14+ high-level meetings and technical workshops with Aramco and global EPC contractors, aligning planning, risk, and cost strategies across teams
  • +
  • Supported asset management and digital transformation initiatives, integrating advanced analytics and visualization tools to enhance operational decision-making
  • +
+
+
+ + +
+

Leadership & Organizations

+ +
+
+ President – SPE Alasala Chapter + Sep 2024 – Present +
+
Society of Petroleum Engineers (SPE) | Dammam
+
    +
  • Revitalized a dormant student chapter, scaling active membership from 0 to 89 members within one semester through strategic outreach
  • +
  • Directed marketing campaigns generating 50,000+ organic impressions across social platforms
  • +
  • Built partnerships with Aramco, Saudi Council of Engineers, and multiple EPC firms aligned with Saudi Vision 2030
  • +
  • Organized 6+ technical workshops, industry visits, and career development sessions
  • +
  • Secured invitation to the 2025 MENA PetroBowl qualifiers
  • +
+
+ +
+
+ Founder – Elite Engineers Club + 2024 – May 2025 +
+
Alasala Colleges | Dammam
+
    +
  • Founded a multidisciplinary engineering hub attracting 40+ students from mechanical, electrical, and civil engineering
  • +
  • Negotiated accreditation with EduStation and Saudi Council of Engineers for industry-aligned training programs
  • +
  • Secured industry sponsorships and introduced data-driven impact tracking tools
  • +
+
+
+ + +
+

Education

+
+ Bachelor of Science in Mechanical Engineering + Mar 2019 – May 2025 +
+
Alasala Colleges – Dammam, Eastern Province, Saudi Arabia
+
    +
  • Best Capstone Project Award (1st of 16 teams) – developed a biodegradable, thermally insulating green composite from sunflower waste
  • +
  • Relevant coursework: HVAC Systems, Renewable Energy, Project Planning & Control, Data Analysis & Visualization, Asset Management
  • +
+
+ + +
+

Certifications & Training

+
    +
  • MV Switchgears & Modular Power Systems – Training Workshop (Aug 2025)
  • +
  • Earthing Systems – Training Workshop (Aug 2025)
  • +
  • KNX System Fundamentals & Building Automation – eLearning (Oct 2024)
  • +
  • Saudi Mechanical Code (SBC 501) – Saudi Council of Engineers (Jun 2024)
  • +
  • BIM-Oriented Sustainable Design – Autodesk (May 2024)
  • +
  • Emergency Lighting & Central Battery Systems – ABB (Apr 2024)
  • +
  • Low Voltage Circuit Breakers (IEC Standards) – ABB (Apr 2024)
  • +
  • ABB E-Design Certification – ABB (May 2024)
  • +
  • Contract & Tendering Management – PMI (Jun 2024)
  • +
  • Organizational Effectiveness & Excellence – EFQM (May 2024)
  • +
+
+ + +
+

Technical & Professional Skills

+
+
+

Airport Security & Engineering

+
    +
  • Smiths Detection Equipment (HI-SCAN, IONSCAN 600, CTX)
  • +
  • Preventive & Corrective Maintenance
  • +
  • System Calibration & Quality Assurance
  • +
  • HVAC Systems | BIM | Digital Twin
  • +
  • Asset Management Systems
  • +
+
+
+

Data & Analytics

+
    +
  • Python (Pandas, NumPy, Plotly, Dash)
  • +
  • SQL | Power BI | Jupyter Notebook
  • +
  • Advanced Excel (Automation, Reporting)
  • +
  • KPI Development & Performance Tracking
  • +
  • Risk Modeling & Forecasting
  • +
+
+
+

Project Management

+
    +
  • Primavera P6 & MS Project
  • +
  • Monte Carlo Simulation (Risk Analysis)
  • +
  • Cost Estimation & Budget Control
  • +
  • Stakeholder Management & Communication
  • +
  • Resource Optimization & Strategic Execution
  • +
+
+
+

Leadership & Soft Skills

+
    +
  • Strategic Communication & Negotiation
  • +
  • Cross-Functional Collaboration
  • +
  • Organizational Design & Talent Development
  • +
  • Event Planning & Industry Engagement
  • +
  • Automated Reporting & ETL Pipelines
  • +
+
+
+
+ + +
+

Awards & Recognition

+
    +
  • Best Capstone Project Award (1st of 16 teams) – Alasala Colleges (May 2025)
  • +
  • SPE KSA Excellence Award – Impactful contributions to student engineering development (2025)
  • +
  • Presidential Recognition – SPE International, for revitalizing the Alasala Chapter (2025)
  • +
+
+ + +
+

References

+

Dr. Saeed AlNoman – Assistant Professor, Mechanical Engineering, Alasala Colleges

+

Khalifa – Assistant Director of Project Management, Samsung E&A

+
+ +
+ + diff --git a/personal-brand-engine/landing_page/index.html b/personal-brand-engine/landing_page/index.html new file mode 100644 index 00000000..34310fe4 --- /dev/null +++ b/personal-brand-engine/landing_page/index.html @@ -0,0 +1,315 @@ + + + + + + Sami Assiri | سامي العسيري - Field Services Engineer + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+
SA
+
+

+ سامي محمد العسيري + +

+

+ مهندس خدمات ميدانية | متخصص أمن المطارات + +

+

+ METCO - خدمات الشرق الأوسط | مطار الملك خالد الدولي + +

+
+ Smiths Detection + Mechanical Engineering + Python & Analytics + Ex-Samsung E&A +
+
+
+
+ + +
+ +
+ + +
+
+

+ نبذة عني + +

+

+ + مهندس ميكانيكي وخدمات ميدانية في شركة METCO (خدمات الشرق الأوسط) بمطار الملك خالد الدولي بالرياض. + متخصص في صيانة وتشغيل أنظمة أمن المطارات من Smiths Detection، بما في ذلك أجهزة الأشعة السينية (HI-SCAN) + وأجهزة كشف المتفجرات (IONSCAN 600) وأنظمة الفحص المتقدمة (CTX). +

+ سابقاً في Samsung E&A حيث طورت لوحات بيانات بايثون وأتمتت تخطيط المشاريع. + رئيس فرع SPE الأصالة - نقلت الفرع من 0 إلى 89 عضو فعال. حاصل على 10+ شهادات مهنية. +
+ +

+
+
+ + +
+
+

+ الخبرات + +

+ +
+
+
+
+
+ الحالي + +
+

Field Services Engineer

+

METCO - Middle East Services

+

+ مطار الملك خالد الدولي، الرياض + +

+

Jan 2026 - Present

+
    +
  • Smiths Detection airport security equipment maintenance & operation
  • +
  • HI-SCAN X-Ray | IONSCAN 600 | CTX Systems
  • +
  • Preventive & corrective maintenance, system calibration
  • +
+
+
+ +
+
+
+

Planning Engineer Intern

+

Samsung E&A Saudi Arabia

+

Feb 2025 - May 2025

+
    +
  • Python dashboards reducing reporting time by 75%
  • +
  • 4,327 activities modeled in Primavera P6
  • +
  • Analytics for multi-billion-dollar Aramco projects
  • +
+
+
+ +
+
+
+

President - SPE Alasala Chapter

+

Society of Petroleum Engineers

+

Sep 2024 - Present

+
    +
  • Scaled from 0 to 89 active members
  • +
  • 50,000+ organic social impressions
  • +
  • Partnerships with Aramco & Saudi Council of Engineers
  • +
+
+
+
+
+
+ + +
+
+

+ المهارات + +

+
+
+

+ أمن المطارات والهندسة + +

+
+ Smiths Detection + HI-SCAN X-Ray + IONSCAN 600 + CTX Systems + HVAC + BIM +
+
+
+

+ البيانات والتحليلات + +

+
+ Python + SQL + Power BI + Pandas + Plotly/Dash + Excel +
+
+
+

+ إدارة المشاريع + +

+
+ Primavera P6 + MS Project + Monte Carlo + Risk Analysis + Cost Control +
+
+
+

+ الشهادات + +

+
+ ABB E-Design + KNX Systems + SBC 501 + PMI + EFQM + Autodesk BIM +
+
+
+
+
+ + +
+
+

+ الجوائز + +

+
+
+
🏆
+

Best Capstone Project

+

1st of 16 teams - Alasala Colleges (2025)

+
+
+
+

SPE KSA Excellence Award

+

Society of Petroleum Engineers (2025)

+
+
+
🏅
+

Presidential Recognition

+

SPE International (2025)

+
+
+
+
+ + +
+
+

+ احجز موعد + +

+

+ تبي تتواصل معي؟ احجز موعد مباشرة من هنا + +

+ +
+
+ + +
+
+ + +
+
+ + + + diff --git a/personal-brand-engine/landing_page/script.js b/personal-brand-engine/landing_page/script.js new file mode 100644 index 00000000..6a35354b --- /dev/null +++ b/personal-brand-engine/landing_page/script.js @@ -0,0 +1,125 @@ +// =================================== +// Sami Assiri - Landing Page Scripts +// =================================== + +let currentLang = 'ar'; + +/** + * Toggle between Arabic and English + */ +function toggleLanguage() { + currentLang = currentLang === 'ar' ? 'en' : 'ar'; + + const html = document.documentElement; + const body = document.body; + + if (currentLang === 'en') { + html.setAttribute('lang', 'en'); + html.setAttribute('dir', 'ltr'); + body.setAttribute('dir', 'ltr'); + document.getElementById('lang-btn-text').textContent = 'AR'; + } else { + html.setAttribute('lang', 'ar'); + html.setAttribute('dir', 'rtl'); + body.removeAttribute('dir'); + document.getElementById('lang-btn-text').textContent = 'EN'; + } + + // Toggle all language spans + document.querySelectorAll('.ar').forEach(el => { + el.style.display = currentLang === 'ar' ? '' : 'none'; + }); + document.querySelectorAll('.en').forEach(el => { + el.style.display = currentLang === 'en' ? '' : 'none'; + }); +} + +/** + * Download vCard contact file + */ +function downloadVCard() { + const vcard = `BEGIN:VCARD +VERSION:3.0 +FN:Sami Mohammed Assiri +N:Assiri;Sami;Mohammed;; +TITLE:Field Services Engineer - Airport Security +ORG:METCO - Middle East Services +TEL;TYPE=CELL:+966597788539 +EMAIL;TYPE=INTERNET:sami.assiri11@gmail.com +URL:https://www.linkedin.com/in/sami-assiri-a300622b2/ +ADR;TYPE=WORK:;;King Khalid International Airport;Riyadh;;12345;Saudi Arabia +NOTE:Smiths Detection Airport Security Specialist | Mechanical Engineer | Ex-Samsung E&A | President SPE Alasala Chapter +END:VCARD`; + + const blob = new Blob([vcard], { type: 'text/vcard;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'Sami_Assiri.vcf'; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); +} + +/** + * Initialize Cal.com embed if booking URL is configured + */ +function initCalEmbed() { + // Replace with your Cal.com username when ready + const calUsername = ''; // e.g., 'sami-assiri' + + if (calUsername) { + const calEmbed = document.getElementById('cal-embed'); + calEmbed.innerHTML = ` + + `; + } +} + +/** + * Smooth scroll for anchor links + */ +function initSmoothScroll() { + document.querySelectorAll('a[href^="#"]').forEach(anchor => { + anchor.addEventListener('click', function (e) { + e.preventDefault(); + const target = document.querySelector(this.getAttribute('href')); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }); + }); +} + +/** + * Intersection Observer for scroll animations + */ +function initScrollAnimations() { + const observer = new IntersectionObserver((entries) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + entry.target.style.opacity = '1'; + entry.target.style.transform = 'translateY(0)'; + } + }); + }, { threshold: 0.1 }); + + document.querySelectorAll('section').forEach(section => { + section.style.opacity = '0'; + section.style.transform = 'translateY(20px)'; + section.style.transition = 'opacity 0.6s ease, transform 0.6s ease'; + observer.observe(section); + }); +} + +// Initialize on DOM ready +document.addEventListener('DOMContentLoaded', () => { + initCalEmbed(); + initSmoothScroll(); + initScrollAnimations(); +}); diff --git a/personal-brand-engine/landing_page/style.css b/personal-brand-engine/landing_page/style.css new file mode 100644 index 00000000..7af837df --- /dev/null +++ b/personal-brand-engine/landing_page/style.css @@ -0,0 +1,511 @@ +/* =================================== + Sami Assiri - Personal Landing Page + Bilingual (AR/EN) with RTL Support + =================================== */ + +:root { + --primary: #0a66c2; + --primary-dark: #004182; + --accent: #00b4d8; + --bg: #0f172a; + --bg-card: #1e293b; + --bg-section: #111827; + --text: #f1f5f9; + --text-muted: #94a3b8; + --border: #334155; + --gradient: linear-gradient(135deg, #0a66c2 0%, #00b4d8 100%); + --shadow: 0 4px 24px rgba(0, 0, 0, 0.3); +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: 'Cairo', 'Inter', -apple-system, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.7; + min-height: 100vh; +} + +body[dir="ltr"] { + font-family: 'Inter', 'Cairo', -apple-system, sans-serif; +} + +.container { + max-width: 800px; + margin: 0 auto; + padding: 0 24px; +} + +/* Language Toggle */ +.lang-toggle { + position: fixed; + top: 20px; + left: 20px; + z-index: 100; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text); + padding: 8px 16px; + border-radius: 20px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + transition: all 0.3s ease; +} + +[dir="ltr"] .lang-toggle { + left: auto; + right: 20px; +} + +.lang-toggle:hover { + background: var(--primary); + border-color: var(--primary); +} + +/* Hero Section */ +.hero { + position: relative; + padding: 80px 0 40px; + overflow: hidden; +} + +.hero-bg { + position: absolute; + top: 0; + left: 0; + right: 0; + height: 300px; + background: var(--gradient); + opacity: 0.15; + filter: blur(60px); +} + +.profile-card { + position: relative; + text-align: center; + padding: 40px 24px; +} + +.avatar { + width: 120px; + height: 120px; + margin: 0 auto 24px; + border-radius: 50%; + overflow: hidden; + border: 3px solid var(--primary); + box-shadow: 0 0 30px rgba(10, 102, 194, 0.3); +} + +.avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.avatar-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: var(--gradient); + color: white; + font-size: 40px; + font-weight: 700; +} + +.name { + font-size: 2rem; + font-weight: 700; + margin-bottom: 8px; + letter-spacing: -0.5px; +} + +.title { + font-size: 1.1rem; + color: var(--accent); + font-weight: 500; + margin-bottom: 4px; +} + +.company { + font-size: 0.95rem; + color: var(--text-muted); + margin-bottom: 20px; +} + +.badges { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 8px; +} + +.badge { + background: rgba(10, 102, 194, 0.15); + border: 1px solid rgba(10, 102, 194, 0.3); + color: var(--accent); + padding: 4px 14px; + border-radius: 20px; + font-size: 0.8rem; + font-weight: 500; +} + +/* Action Buttons */ +.actions { + padding: 20px 0 40px; +} + +.action-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.action-btn { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 14px 20px; + border-radius: 12px; + background: var(--bg-card); + border: 1px solid var(--border); + color: var(--text); + text-decoration: none; + font-size: 0.95rem; + font-weight: 500; + transition: all 0.3s ease; + cursor: pointer; +} + +.action-btn:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); + border-color: var(--primary); +} + +.action-btn.primary { + background: var(--gradient); + border-color: transparent; + color: white; +} + +.action-btn.primary:hover { + opacity: 0.9; +} + +.action-btn.linkedin { + background: #0a66c2; + border-color: transparent; + color: white; +} + +/* Sections */ +section { + padding: 40px 0; +} + +section:nth-child(even) { + background: var(--bg-section); +} + +h2 { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 24px; + position: relative; + display: inline-block; +} + +h2::after { + content: ''; + position: absolute; + bottom: -4px; + right: 0; + width: 40px; + height: 3px; + background: var(--gradient); + border-radius: 2px; +} + +[dir="ltr"] h2::after { + right: auto; + left: 0; +} + +/* About */ +.about-text { + color: var(--text-muted); + font-size: 1rem; + line-height: 1.8; +} + +/* Timeline */ +.timeline { + position: relative; + padding-right: 30px; +} + +[dir="ltr"] .timeline { + padding-right: 0; + padding-left: 30px; +} + +.timeline::before { + content: ''; + position: absolute; + right: 8px; + top: 0; + bottom: 0; + width: 2px; + background: var(--border); +} + +[dir="ltr"] .timeline::before { + right: auto; + left: 8px; +} + +.timeline-item { + position: relative; + margin-bottom: 32px; +} + +.timeline-marker { + position: absolute; + right: -30px; + top: 6px; + width: 16px; + height: 16px; + border-radius: 50%; + background: var(--bg-card); + border: 2px solid var(--border); +} + +[dir="ltr"] .timeline-marker { + right: auto; + left: -30px; +} + +.timeline-item.current .timeline-marker { + background: var(--primary); + border-color: var(--accent); + box-shadow: 0 0 10px rgba(0, 180, 216, 0.4); +} + +.timeline-content { + background: var(--bg-card); + padding: 20px; + border-radius: 12px; + border: 1px solid var(--border); +} + +.timeline-badge { + display: inline-block; + background: rgba(0, 180, 216, 0.15); + color: var(--accent); + padding: 2px 12px; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + margin-bottom: 8px; +} + +.timeline-content h3 { + font-size: 1.1rem; + margin-bottom: 4px; +} + +.company-name { + color: var(--primary); + font-weight: 500; + margin-bottom: 2px; +} + +.location { + color: var(--text-muted); + font-size: 0.85rem; +} + +.period { + color: var(--text-muted); + font-size: 0.85rem; + margin-bottom: 12px; +} + +.timeline-content ul { + list-style: none; + padding: 0; +} + +.timeline-content li { + color: var(--text-muted); + font-size: 0.9rem; + padding: 3px 0; + padding-right: 16px; + position: relative; +} + +[dir="ltr"] .timeline-content li { + padding-right: 0; + padding-left: 16px; +} + +.timeline-content li::before { + content: '>'; + position: absolute; + right: 0; + color: var(--accent); + font-weight: 700; +} + +[dir="ltr"] .timeline-content li::before { + right: auto; + left: 0; +} + +/* Skills */ +.skills-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 20px; +} + +.skill-category { + background: var(--bg-card); + padding: 20px; + border-radius: 12px; + border: 1px solid var(--border); +} + +.skill-category h3 { + font-size: 0.95rem; + margin-bottom: 12px; + color: var(--accent); +} + +.skill-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.skill-tags span { + background: rgba(255, 255, 255, 0.05); + border: 1px solid var(--border); + padding: 4px 12px; + border-radius: 8px; + font-size: 0.8rem; + color: var(--text-muted); +} + +/* Awards */ +.awards-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; +} + +.award-card { + background: var(--bg-card); + padding: 24px 16px; + border-radius: 12px; + border: 1px solid var(--border); + text-align: center; +} + +.award-icon { + font-size: 2rem; + margin-bottom: 12px; +} + +.award-card h3 { + font-size: 0.9rem; + margin-bottom: 4px; +} + +.award-card p { + font-size: 0.8rem; + color: var(--text-muted); +} + +/* Booking */ +.booking { + text-align: center; +} + +.booking-desc { + color: var(--text-muted); + margin-bottom: 24px; +} + +.booking-fallback { + display: inline-block; + background: var(--gradient); + color: white; + padding: 14px 32px; + border-radius: 12px; + text-decoration: none; + font-weight: 600; + transition: all 0.3s ease; +} + +.booking-fallback:hover { + transform: translateY(-2px); + box-shadow: var(--shadow); +} + +/* Footer */ +footer { + padding: 40px 0; + text-align: center; + border-top: 1px solid var(--border); +} + +.social-links { + display: flex; + justify-content: center; + gap: 20px; + margin-bottom: 16px; +} + +.social-links a { + color: var(--text-muted); + transition: color 0.3s; +} + +.social-links a:hover { + color: var(--primary); +} + +.footer-text { + color: var(--text-muted); + font-size: 0.85rem; +} + +/* Responsive */ +@media (max-width: 640px) { + .name { font-size: 1.5rem; } + .action-grid { grid-template-columns: 1fr; } + .skills-grid { grid-template-columns: 1fr; } + .awards-grid { grid-template-columns: 1fr; } + .timeline { padding-right: 24px; } + [dir="ltr"] .timeline { padding-left: 24px; } +} + +/* Animation */ +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(20px); } + to { opacity: 1; transform: translateY(0); } +} + +section { + animation: fadeInUp 0.6s ease-out; +} diff --git a/personal-brand-engine/landing_page/vcard/contact.vcf b/personal-brand-engine/landing_page/vcard/contact.vcf new file mode 100644 index 00000000..a1fa4be9 --- /dev/null +++ b/personal-brand-engine/landing_page/vcard/contact.vcf @@ -0,0 +1,13 @@ +BEGIN:VCARD +VERSION:3.0 +FN:Sami Mohammed Assiri +N:Assiri;Sami;Mohammed;; +TITLE:Field Services Engineer - Airport Security +ORG:METCO - Middle East Services +TEL;TYPE=CELL:+966597788539 +EMAIL;TYPE=INTERNET;TYPE=PREF:sami.assiri11@gmail.com +EMAIL;TYPE=INTERNET:sami.m.assiri@gmail.com +URL;TYPE=LinkedIn:https://www.linkedin.com/in/sami-assiri-a300622b2/ +ADR;TYPE=WORK:;;King Khalid International Airport;Riyadh;;12345;Saudi Arabia +NOTE:Smiths Detection Airport Security Specialist | Mechanical Engineer | Ex-Samsung E&A | President SPE Alasala | 10+ Certifications | Python & Data Analytics +END:VCARD diff --git a/personal-brand-engine/llm/__init__.py b/personal-brand-engine/llm/__init__.py new file mode 100644 index 00000000..e866ce25 --- /dev/null +++ b/personal-brand-engine/llm/__init__.py @@ -0,0 +1,3 @@ +from .client import LLMClient, get_llm_client + +__all__ = ["LLMClient", "get_llm_client"] diff --git a/personal-brand-engine/llm/client.py b/personal-brand-engine/llm/client.py new file mode 100644 index 00000000..006e5c58 --- /dev/null +++ b/personal-brand-engine/llm/client.py @@ -0,0 +1,177 @@ +"""Unified LLM client with Ollama -> Groq -> OpenAI fallback chain.""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +import httpx + +logger = logging.getLogger(__name__) + + +@dataclass +class LLMResponse: + text: str + model: str + provider: str + tokens_used: int = 0 + + +class LLMClient: + """Unified LLM client that tries providers in order: Ollama -> Groq -> OpenAI.""" + + def __init__( + self, + ollama_base_url: str = "http://localhost:11434", + ollama_model: str = "qwen2.5:7b", + groq_api_key: str = "", + groq_model: str = "llama-3.1-70b-versatile", + openai_api_key: str = "", + openai_model: str = "gpt-4o-mini", + ): + self.ollama_base_url = ollama_base_url.rstrip("/") + self.ollama_model = ollama_model + self.groq_api_key = groq_api_key + self.groq_model = groq_model + self.openai_api_key = openai_api_key + self.openai_model = openai_model + self._http = httpx.AsyncClient(timeout=120.0) + + async def generate( + self, + prompt: str, + system_prompt: str = "", + temperature: float = 0.7, + max_tokens: int = 2000, + ) -> LLMResponse: + """Generate text using the first available provider.""" + errors = [] + + # Try Ollama first (free, local) + try: + return await self._ollama_generate(prompt, system_prompt, temperature) + except Exception as e: + errors.append(f"Ollama: {e}") + logger.debug("Ollama unavailable: %s", e) + + # Try Groq (free tier) + if self.groq_api_key: + try: + return await self._groq_generate(prompt, system_prompt, temperature, max_tokens) + except Exception as e: + errors.append(f"Groq: {e}") + logger.debug("Groq failed: %s", e) + + # Try OpenAI (paid) + if self.openai_api_key: + try: + return await self._openai_generate(prompt, system_prompt, temperature, max_tokens) + except Exception as e: + errors.append(f"OpenAI: {e}") + logger.debug("OpenAI failed: %s", e) + + raise RuntimeError(f"All LLM providers failed: {'; '.join(errors)}") + + async def _ollama_generate( + self, prompt: str, system_prompt: str, temperature: float + ) -> LLMResponse: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + resp = await self._http.post( + f"{self.ollama_base_url}/api/chat", + json={ + "model": self.ollama_model, + "messages": messages, + "stream": False, + "options": {"temperature": temperature}, + }, + ) + resp.raise_for_status() + data = resp.json() + return LLMResponse( + text=data["message"]["content"], + model=self.ollama_model, + provider="ollama", + tokens_used=data.get("eval_count", 0), + ) + + async def _groq_generate( + self, prompt: str, system_prompt: str, temperature: float, max_tokens: int + ) -> LLMResponse: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + resp = await self._http.post( + "https://api.groq.com/openai/v1/chat/completions", + headers={"Authorization": f"Bearer {self.groq_api_key}"}, + json={ + "model": self.groq_model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + }, + ) + resp.raise_for_status() + data = resp.json() + return LLMResponse( + text=data["choices"][0]["message"]["content"], + model=self.groq_model, + provider="groq", + tokens_used=data.get("usage", {}).get("total_tokens", 0), + ) + + async def _openai_generate( + self, prompt: str, system_prompt: str, temperature: float, max_tokens: int + ) -> LLMResponse: + messages = [] + if system_prompt: + messages.append({"role": "system", "content": system_prompt}) + messages.append({"role": "user", "content": prompt}) + + resp = await self._http.post( + "https://api.openai.com/v1/chat/completions", + headers={"Authorization": f"Bearer {self.openai_api_key}"}, + json={ + "model": self.openai_model, + "messages": messages, + "temperature": temperature, + "max_tokens": max_tokens, + }, + ) + resp.raise_for_status() + data = resp.json() + return LLMResponse( + text=data["choices"][0]["message"]["content"], + model=self.openai_model, + provider="openai", + tokens_used=data.get("usage", {}).get("total_tokens", 0), + ) + + async def close(self): + await self._http.aclose() + + +_client: LLMClient | None = None + + +def get_llm_client() -> LLMClient: + """Get or create the singleton LLM client.""" + global _client + if _client is None: + from config.settings import get_settings + s = get_settings() + _client = LLMClient( + ollama_base_url=s.ollama_base_url, + ollama_model=s.ollama_model, + groq_api_key=s.groq_api_key, + groq_model=s.groq_model, + openai_api_key=s.openai_api_key, + openai_model=s.openai_model, + ) + return _client diff --git a/personal-brand-engine/pyproject.toml b/personal-brand-engine/pyproject.toml new file mode 100644 index 00000000..a7310c0b --- /dev/null +++ b/personal-brand-engine/pyproject.toml @@ -0,0 +1,22 @@ +[build-system] +requires = ["setuptools>=68.0", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "personal-brand-engine" +version = "1.0.0" +description = "AI-powered personal brand automation system with 6 autonomous agents" +readme = "README.md" +requires-python = ">=3.11" +license = {text = "MIT"} +authors = [ + {name = "Sami Assiri", email = "sami.assiri11@gmail.com"} +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] + +[tool.ruff] +target-version = "py312" +line-length = 100 diff --git a/personal-brand-engine/requirements.txt b/personal-brand-engine/requirements.txt new file mode 100644 index 00000000..ac4c34ff --- /dev/null +++ b/personal-brand-engine/requirements.txt @@ -0,0 +1,46 @@ +# Core +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +pydantic==2.10.3 +pydantic-settings==2.7.0 +pyyaml==6.0.2 + +# Database +sqlalchemy==2.0.36 +alembic==1.14.0 + +# Scheduling +apscheduler==3.10.4 + +# LLM Clients +httpx==0.28.1 +openai==1.58.1 +groq==0.13.0 + +# LinkedIn +linkedin-api==2.2.0 + +# Twitter/X +tweepy==4.14.0 + +# Email +imapclient==3.0.1 + +# WhatsApp +twilio==9.4.0 + +# CV Generation +jinja2==3.1.4 +weasyprint==62.3 + +# Utilities +python-dotenv==1.0.1 +aiofiles==24.1.0 +python-multipart==0.0.18 + +# Notifications +python-telegram-bot==21.9 + +# Testing +pytest==8.3.4 +pytest-asyncio==0.25.0 diff --git a/personal-brand-engine/scheduler/__init__.py b/personal-brand-engine/scheduler/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/personal-brand-engine/scheduler/runner.py b/personal-brand-engine/scheduler/runner.py new file mode 100644 index 00000000..28743d55 --- /dev/null +++ b/personal-brand-engine/scheduler/runner.py @@ -0,0 +1,126 @@ +"""APScheduler-based task runner that reads schedule.yaml and dispatches agent tasks.""" + +from __future__ import annotations + +import asyncio +import logging +import signal +import sys +from pathlib import Path + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.cron import CronTrigger +from apscheduler.triggers.interval import IntervalTrigger + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +from config.settings import get_settings, get_schedule_config +from scheduler.tasks import execute_agent_task + +logger = logging.getLogger(__name__) + + +def parse_cron(cron_str: str) -> CronTrigger: + """Parse a cron string into an APScheduler CronTrigger.""" + parts = cron_str.strip().split() + if len(parts) == 5: + return CronTrigger( + minute=parts[0], + hour=parts[1], + day=parts[2], + month=parts[3], + day_of_week=parts[4], + timezone=get_settings().timezone, + ) + raise ValueError(f"Invalid cron expression: {cron_str}") + + +def setup_scheduler() -> AsyncIOScheduler: + """Create and configure the scheduler from schedule.yaml.""" + settings = get_settings() + schedule_config = get_schedule_config() + scheduler = AsyncIOScheduler(timezone=settings.timezone) + + agents = schedule_config.get("agents", {}) + + for agent_name, tasks in agents.items(): + for task_name, task_config in tasks.items(): + if task_name in ("mode", "description"): + continue + + if isinstance(task_config, str): + continue + + job_id = f"{agent_name}.{task_name}" + description = task_config.get("description", task_name) + + if "cron" in task_config: + trigger = parse_cron(task_config["cron"]) + scheduler.add_job( + execute_agent_task, + trigger=trigger, + id=job_id, + name=description, + args=[agent_name, task_name], + replace_existing=True, + misfire_grace_time=300, + ) + logger.info("Scheduled %s: %s", job_id, task_config["cron"]) + + elif "interval_minutes" in task_config: + trigger = IntervalTrigger( + minutes=task_config["interval_minutes"], + timezone=settings.timezone, + ) + scheduler.add_job( + execute_agent_task, + trigger=trigger, + id=job_id, + name=description, + args=[agent_name, task_name], + replace_existing=True, + misfire_grace_time=60, + ) + logger.info( + "Scheduled %s: every %d minutes", job_id, task_config["interval_minutes"] + ) + + return scheduler + + +async def main(): + """Main entry point for the scheduler.""" + logging.basicConfig( + level=getattr(logging, get_settings().log_level), + format="%(asctime)s [%(name)s] %(levelname)s: %(message)s", + handlers=[logging.StreamHandler()], + ) + + logger.info("Starting Personal Brand Engine Scheduler...") + + scheduler = setup_scheduler() + scheduler.start() + + logger.info("Scheduler started with %d jobs", len(scheduler.get_jobs())) + for job in scheduler.get_jobs(): + logger.info(" - %s: next run at %s", job.id, job.next_run_time) + + # Graceful shutdown + loop = asyncio.get_event_loop() + stop_event = asyncio.Event() + + def shutdown(sig): + logger.info("Received signal %s, shutting down...", sig) + scheduler.shutdown(wait=False) + stop_event.set() + + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, shutdown, sig) + + await stop_event.wait() + logger.info("Scheduler stopped.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/personal-brand-engine/scheduler/tasks.py b/personal-brand-engine/scheduler/tasks.py new file mode 100644 index 00000000..b725b640 --- /dev/null +++ b/personal-brand-engine/scheduler/tasks.py @@ -0,0 +1,93 @@ +"""Task dispatcher - maps agent_name + task_name to actual agent execution.""" + +from __future__ import annotations + +import logging +import time +import traceback + +from config.settings import get_settings +from llm.client import get_llm_client +from storage.database import get_db, init_db +from storage.models import AgentLog + +logger = logging.getLogger(__name__) + +# Agent registry - lazy imports to avoid circular dependencies +AGENT_REGISTRY = { + "linkedin": "agents.linkedin.LinkedInAgent", + "email": "agents.email.EmailAgent", + "social_media": "agents.social_media.SocialMediaAgent", + "whatsapp": "agents.whatsapp.WhatsAppAgent", + "cv_optimizer": "agents.cv_optimizer.CVOptimizerAgent", + "content_strategist": "agents.content_strategist.ContentStrategistAgent", + "opportunity_scout": "agents.opportunity_scout.OpportunityScoutAgent", +} + + +def _import_agent(dotted_path: str): + """Dynamically import an agent class from its dotted path.""" + module_path, class_name = dotted_path.rsplit(".", 1) + import importlib + module = importlib.import_module(module_path) + return getattr(module, class_name) + + +async def execute_agent_task(agent_name: str, task_name: str): + """Execute a specific task for a specific agent.""" + logger.info("Executing: %s.%s", agent_name, task_name) + start_time = time.time() + + init_db() + db = get_db() + + try: + agent_path = AGENT_REGISTRY.get(agent_name) + if not agent_path: + logger.error("Unknown agent: %s", agent_name) + return + + agent_class = _import_agent(agent_path) + settings = get_settings() + llm_client = get_llm_client() + + agent = agent_class(config=settings, llm_client=llm_client, db_session=db) + result = await agent.run(task=task_name) + + duration = time.time() - start_time + + log_entry = AgentLog( + agent_name=agent_name, + task=task_name, + status="success", + details=str(result)[:2000] if result else "OK", + duration_seconds=round(duration, 2), + ) + db.add(log_entry) + db.commit() + + logger.info( + "Completed: %s.%s in %.2fs", agent_name, task_name, duration + ) + return result + + except Exception as e: + duration = time.time() - start_time + error_detail = f"{type(e).__name__}: {e}\n{traceback.format_exc()}" + logger.error("Failed: %s.%s - %s", agent_name, task_name, e) + + try: + log_entry = AgentLog( + agent_name=agent_name, + task=task_name, + status="failed", + details=error_detail[:2000], + duration_seconds=round(duration, 2), + ) + db.add(log_entry) + db.commit() + except Exception: + logger.error("Failed to log error to database") + + finally: + db.close() diff --git a/personal-brand-engine/storage/__init__.py b/personal-brand-engine/storage/__init__.py new file mode 100644 index 00000000..2cb64daf --- /dev/null +++ b/personal-brand-engine/storage/__init__.py @@ -0,0 +1,14 @@ +from .database import get_db, init_db +from .models import Base, Post, Email, Contact, AgentLog, ContentCalendar, Opportunity + +__all__ = [ + "get_db", + "init_db", + "Base", + "Post", + "Email", + "Contact", + "AgentLog", + "ContentCalendar", + "Opportunity", +] diff --git a/personal-brand-engine/storage/database.py b/personal-brand-engine/storage/database.py new file mode 100644 index 00000000..de8112ad --- /dev/null +++ b/personal-brand-engine/storage/database.py @@ -0,0 +1,81 @@ +"""Database engine and session management for the personal brand engine.""" + +from __future__ import annotations + +from contextlib import contextmanager +from pathlib import Path +from typing import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from config.settings import get_settings +from storage.models import Base + +_engine = None +_SessionLocal: sessionmaker[Session] | None = None + + +def _get_engine(): + """Lazily create and return the SQLAlchemy engine.""" + global _engine + if _engine is None: + settings = get_settings() + url = settings.database_url + + # Ensure the directory exists for SQLite databases. + if url.startswith("sqlite"): + db_path = url.split("///")[-1] + if db_path and db_path != ":memory:": + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + _engine = create_engine( + url, + echo=False, + # SQLite-specific: allow multi-threaded access. + connect_args={"check_same_thread": False} if url.startswith("sqlite") else {}, + pool_pre_ping=True, + ) + return _engine + + +def _get_session_factory() -> sessionmaker[Session]: + """Lazily create and return the session factory.""" + global _SessionLocal + if _SessionLocal is None: + _SessionLocal = sessionmaker( + bind=_get_engine(), + autocommit=False, + autoflush=False, + expire_on_commit=False, + ) + return _SessionLocal + + +def init_db() -> None: + """Create all tables defined in the ORM models. + + Safe to call multiple times -- existing tables are not recreated. + """ + Base.metadata.create_all(bind=_get_engine()) + + +@contextmanager +def get_db() -> Generator[Session, None, None]: + """Provide a transactional database session scope. + + Usage:: + + with get_db() as db: + db.add(Post(...)) + db.commit() + """ + session = _get_session_factory()() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() diff --git a/personal-brand-engine/storage/models.py b/personal-brand-engine/storage/models.py new file mode 100644 index 00000000..99538b33 --- /dev/null +++ b/personal-brand-engine/storage/models.py @@ -0,0 +1,193 @@ +"""SQLAlchemy 2.0 models for the personal brand automation engine.""" + +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import ( + DateTime, + Float, + ForeignKey, + Index, + String, + Text, + func, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship +from sqlalchemy.types import JSON + + +class Base(DeclarativeBase): + """Shared declarative base for all models.""" + + pass + + +class Post(Base): + __tablename__ = "posts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + platform: Mapped[str] = mapped_column(String(20), nullable=False) # linkedin / twitter + content: Mapped[str] = mapped_column(Text, nullable=False) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="draft" + ) # draft / scheduled / published / failed + scheduled_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + engagement_stats: Mapped[dict | None] = mapped_column(JSON, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + # Reverse relation from ContentCalendar + calendar_entries: Mapped[list[ContentCalendar]] = relationship( + "ContentCalendar", back_populates="post" + ) + + __table_args__ = ( + Index("ix_posts_platform", "platform"), + Index("ix_posts_status", "status"), + Index("ix_posts_scheduled_at", "scheduled_at"), + ) + + def __repr__(self) -> str: + return f"" + + +class Email(Base): + __tablename__ = "emails" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + from_addr: Mapped[str] = mapped_column(String(320), nullable=False) + to_addr: Mapped[str] = mapped_column(String(320), nullable=False) + subject: Mapped[str] = mapped_column(String(998), nullable=False, default="") + body: Mapped[str] = mapped_column(Text, nullable=False, default="") + classification: Mapped[str | None] = mapped_column( + String(20), nullable=True + ) # urgent / reply_needed / spam / info + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="unread" + ) # unread / drafted / sent / archived + draft_response: Mapped[str | None] = mapped_column(Text, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + __table_args__ = ( + Index("ix_emails_classification", "classification"), + Index("ix_emails_status", "status"), + Index("ix_emails_from_addr", "from_addr"), + ) + + def __repr__(self) -> str: + return f"" + + +class Contact(Base): + __tablename__ = "contacts" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + email: Mapped[str | None] = mapped_column(String(320), nullable=True) + phone: Mapped[str | None] = mapped_column(String(30), nullable=True) + platform: Mapped[str | None] = mapped_column(String(20), nullable=True) + linkedin_url: Mapped[str | None] = mapped_column(String(500), nullable=True) + notes: Mapped[str | None] = mapped_column(Text, nullable=True) + last_contact_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + __table_args__ = ( + Index("ix_contacts_email", "email"), + Index("ix_contacts_name", "name"), + Index("ix_contacts_platform", "platform"), + ) + + def __repr__(self) -> str: + return f"" + + +class AgentLog(Base): + __tablename__ = "agent_logs" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + agent_name: Mapped[str] = mapped_column(String(100), nullable=False) + task: Mapped[str] = mapped_column(String(255), nullable=False) + status: Mapped[str] = mapped_column( + String(20), nullable=False + ) # success / failed + details: Mapped[str | None] = mapped_column(Text, nullable=True) + duration_seconds: Mapped[float | None] = mapped_column(Float, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + __table_args__ = ( + Index("ix_agent_logs_agent_name", "agent_name"), + Index("ix_agent_logs_status", "status"), + Index("ix_agent_logs_created_at", "created_at"), + ) + + def __repr__(self) -> str: + return f"" + + +class ContentCalendar(Base): + __tablename__ = "content_calendar" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + date: Mapped[datetime] = mapped_column(DateTime, nullable=False) + pillar: Mapped[str] = mapped_column(String(100), nullable=False) + topic: Mapped[str] = mapped_column(String(255), nullable=False) + platform: Mapped[str] = mapped_column(String(20), nullable=False) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="planned" + ) # planned / drafted / published + post_id: Mapped[int | None] = mapped_column( + ForeignKey("posts.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + post: Mapped[Post | None] = relationship("Post", back_populates="calendar_entries") + + __table_args__ = ( + Index("ix_content_calendar_date", "date"), + Index("ix_content_calendar_platform", "platform"), + Index("ix_content_calendar_status", "status"), + ) + + def __repr__(self) -> str: + return f"" + + +class Opportunity(Base): + __tablename__ = "opportunities" + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + source: Mapped[str] = mapped_column( + String(20), nullable=False + ) # linkedin / indeed / google / twitter + title: Mapped[str] = mapped_column(String(500), nullable=False) + company: Mapped[str | None] = mapped_column(String(255), nullable=True) + url: Mapped[str | None] = mapped_column(String(2048), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + relevance_score: Mapped[float | None] = mapped_column(Float, nullable=True) + status: Mapped[str] = mapped_column( + String(20), nullable=False, default="new" + ) # new / notified / applied / dismissed + notified_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.now() + ) + + __table_args__ = ( + Index("ix_opportunities_source", "source"), + Index("ix_opportunities_status", "status"), + Index("ix_opportunities_relevance_score", "relevance_score"), + ) + + def __repr__(self) -> str: + return f"" diff --git a/personal-brand-engine/tests/__init__.py b/personal-brand-engine/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/personal-brand-engine/tests/test_config.py b/personal-brand-engine/tests/test_config.py new file mode 100644 index 00000000..bdd6e69e --- /dev/null +++ b/personal-brand-engine/tests/test_config.py @@ -0,0 +1,57 @@ +"""Tests for configuration loading.""" + +import sys +from pathlib import Path + +# Add project root to path +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def test_settings_defaults(): + """Settings should load with defaults even without .env.""" + from config.settings import Settings + s = Settings() + assert s.timezone == "Asia/Riyadh" + assert s.default_language == "ar" + assert s.api_port == 8080 + assert s.imap_host == "imap.gmail.com" + + +def test_brand_profile_loads(): + """brand_profile.yaml should load and contain Sami's data.""" + from config.settings import get_brand_profile + profile = get_brand_profile() + assert profile is not None + assert "personal" in profile + assert profile["personal"]["name_en"] == "Sami Mohammed Assiri" + assert profile["personal"]["email"] == "sami.assiri11@gmail.com" + + +def test_schedule_config_loads(): + """schedule.yaml should load all 7 agents.""" + from config.settings import get_schedule_config + schedule = get_schedule_config() + assert "agents" in schedule + agents = schedule["agents"] + assert "linkedin" in agents + assert "email" in agents + assert "social_media" in agents + assert "whatsapp" in agents + assert "cv_optimizer" in agents + assert "content_strategist" in agents + assert "opportunity_scout" in agents + + +def test_content_strategy_loads(): + """content_strategy.yaml should load with pillars.""" + from config.settings import get_content_strategy + strategy = get_content_strategy() + assert "content_pillars" in strategy + assert len(strategy["content_pillars"]) >= 4 + + +def test_yaml_load_missing_file(): + """Loading a missing YAML file should return empty dict.""" + from config.settings import load_yaml + result = load_yaml("nonexistent.yaml") + assert result == {} diff --git a/personal-brand-engine/tests/test_llm_client.py b/personal-brand-engine/tests/test_llm_client.py new file mode 100644 index 00000000..ce177b8c --- /dev/null +++ b/personal-brand-engine/tests/test_llm_client.py @@ -0,0 +1,34 @@ +"""Tests for LLM client.""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def test_llm_client_init(): + """LLM client should initialize with defaults.""" + from llm.client import LLMClient + client = LLMClient() + assert client.ollama_model == "qwen2.5:7b" + assert client.groq_model == "llama-3.1-70b-versatile" + assert client.openai_model == "gpt-4o-mini" + + +def test_llm_response_dataclass(): + """LLMResponse should hold data correctly.""" + from llm.client import LLMResponse + resp = LLMResponse(text="Hello", model="test", provider="ollama", tokens_used=10) + assert resp.text == "Hello" + assert resp.provider == "ollama" + assert resp.tokens_used == 10 + + +def test_rate_limiter(): + """Rate limiter should track and enforce limits.""" + from utils.rate_limiter import RateLimiter + rl = RateLimiter() + # LinkedIn default is 50/day + assert rl.remaining("linkedin") == 50 + assert rl.allow("linkedin") is True + assert rl.remaining("linkedin") == 49 diff --git a/personal-brand-engine/tests/test_models.py b/personal-brand-engine/tests/test_models.py new file mode 100644 index 00000000..524753a2 --- /dev/null +++ b/personal-brand-engine/tests/test_models.py @@ -0,0 +1,63 @@ +"""Tests for database models.""" + +import sys +from pathlib import Path +from datetime import datetime, timezone + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + + +def test_database_init(): + """Database should initialize and create tables.""" + import os + os.environ["DATABASE_URL"] = "sqlite:///./test_brand.db" + from storage.database import init_db, get_db + from storage.models import Base + init_db() + db = get_db() + db.close() + # Cleanup + if Path("test_brand.db").exists(): + Path("test_brand.db").unlink() + + +def test_post_model(): + """Post model should be creatable.""" + from storage.models import Post + post = Post( + platform="linkedin", + content="Test post", + status="draft", + ) + assert post.platform == "linkedin" + assert post.status == "draft" + + +def test_opportunity_model(): + """Opportunity model should be creatable.""" + from storage.models import Opportunity + opp = Opportunity( + source="linkedin", + title="Field Engineer", + company="Smiths Detection", + url="https://example.com", + description="Test job", + relevance_score=0.85, + status="new", + ) + assert opp.relevance_score == 0.85 + assert opp.source == "linkedin" + + +def test_agent_log_model(): + """AgentLog model should be creatable.""" + from storage.models import AgentLog + log = AgentLog( + agent_name="linkedin", + task="post_content", + status="success", + details="Posted successfully", + duration_seconds=1.5, + ) + assert log.agent_name == "linkedin" + assert log.duration_seconds == 1.5 diff --git a/personal-brand-engine/utils/__init__.py b/personal-brand-engine/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/personal-brand-engine/utils/logger.py b/personal-brand-engine/utils/logger.py new file mode 100644 index 00000000..7a8158d0 --- /dev/null +++ b/personal-brand-engine/utils/logger.py @@ -0,0 +1,92 @@ +"""Structured logging with Arabic-friendly UTF-8 encoding.""" + +from __future__ import annotations + +import logging +import sys +from functools import lru_cache + +from config.settings import get_settings + + +class _StructuredFormatter(logging.Formatter): + """Simple key=value structured formatter that safely handles Unicode.""" + + def format(self, record: logging.LogRecord) -> str: + base = super().format(record) + # Append any extra keyword pairs passed via logger.info("msg", key=val, ...) + extras = { + k: v + for k, v in record.__dict__.items() + if k not in logging.LogRecord("").__dict__ and k != "message" + } + if extras: + pairs = " ".join(f"{k}={v!r}" for k, v in extras.items()) + return f"{base} | {pairs}" + return base + + +class _StructuredLogger(logging.Logger): + """Logger subclass that accepts arbitrary kwargs and stores them on the record.""" + + def _log( # type: ignore[override] + self, + level: int, + msg: object, + args: tuple, # type: ignore[override] + exc_info=None, + extra=None, + stack_info: bool = False, + stacklevel: int = 1, + **kwargs, + ) -> None: + if extra is None: + extra = {} + extra.update(kwargs) + super()._log( + level, + msg, + args, + exc_info=exc_info, + extra=extra, + stack_info=stack_info, + stacklevel=stacklevel, + ) + + +# Register our custom logger class globally. +logging.setLoggerClass(_StructuredLogger) + + +def _build_handler() -> logging.StreamHandler: + """Create a stream handler that writes UTF-8 to stdout.""" + handler = logging.StreamHandler(stream=sys.stdout) + handler.setFormatter( + _StructuredFormatter( + fmt="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + datefmt="%Y-%m-%d %H:%M:%S", + ) + ) + # Force UTF-8 so Arabic / non-ASCII text renders correctly. + if hasattr(handler.stream, "reconfigure"): + handler.stream.reconfigure(encoding="utf-8") + return handler + + +@lru_cache(maxsize=None) +def get_logger(name: str = "brand_engine") -> logging.Logger: + """Return a configured :class:`logging.Logger`. + + The log level is read from ``settings.log_level`` (default ``INFO``). + All output is UTF-8 encoded so Arabic and other non-ASCII characters + are rendered correctly. + """ + settings = get_settings() + level = getattr(logging, settings.log_level.upper(), logging.INFO) + + log = logging.getLogger(name) + if not log.handlers: + log.addHandler(_build_handler()) + log.setLevel(level) + log.propagate = False + return log diff --git a/personal-brand-engine/utils/notifications.py b/personal-brand-engine/utils/notifications.py new file mode 100644 index 00000000..6eaf4509 --- /dev/null +++ b/personal-brand-engine/utils/notifications.py @@ -0,0 +1,70 @@ +"""Notification helpers -- Telegram with logging fallback.""" + +from __future__ import annotations + +import logging +from typing import Any + +import httpx + +from utils.logger import get_logger + +logger = get_logger(__name__) + +_TELEGRAM_API = "https://api.telegram.org" + + +async def send_telegram(bot_token: str, chat_id: str, message: str) -> bool: + """Send a message via the Telegram Bot API. + + Returns ``True`` on success, ``False`` on failure (logged, never raises). + """ + url = f"{_TELEGRAM_API}/bot{bot_token}/sendMessage" + payload = { + "chat_id": chat_id, + "text": message, + "parse_mode": "HTML", + } + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + response = await client.post(url, json=payload) + response.raise_for_status() + logger.info("telegram_sent", chat_id=chat_id, length=len(message)) + return True + except httpx.HTTPStatusError as exc: + logger.error( + "telegram_http_error", + status=exc.response.status_code, + body=exc.response.text[:300], + ) + except httpx.RequestError as exc: + logger.error("telegram_request_error", error=str(exc)) + + return False + + +async def send_notification(message: str, settings: Any) -> None: + """Send a notification to the project owner. + + Attempts Telegram delivery first. If Telegram credentials are missing + or the request fails, the message is written to the log instead. + + Parameters + ---------- + message: + The notification text (may contain HTML for Telegram). + settings: + An object (typically :class:`Settings`) with ``telegram_bot_token`` + and ``telegram_chat_id`` attributes. + """ + bot_token = getattr(settings, "telegram_bot_token", "") or "" + chat_id = getattr(settings, "telegram_chat_id", "") or "" + + if bot_token and chat_id: + sent = await send_telegram(bot_token, chat_id, message) + if sent: + return + + # Fallback: log the notification so it is not lost. + logger.warning("notification_fallback", message=message) diff --git a/personal-brand-engine/utils/rate_limiter.py b/personal-brand-engine/utils/rate_limiter.py new file mode 100644 index 00000000..4cf8bc96 --- /dev/null +++ b/personal-brand-engine/utils/rate_limiter.py @@ -0,0 +1,128 @@ +"""Simple token-bucket rate limiter with per-API defaults.""" + +from __future__ import annotations + +import time +from dataclasses import dataclass, field +from threading import Lock + +# Default daily limits per API. +DEFAULT_LIMITS: dict[str, int] = { + "linkedin": 50, # 50 actions per day + "twitter": 100, # 100 actions per day + "email": 50, # 50 sends per day +} + +# Number of seconds in a day -- used for refill rate calculation. +_SECONDS_PER_DAY: float = 86_400.0 + + +@dataclass +class _Bucket: + """Internal token-bucket state for a single API.""" + + capacity: int + tokens: float = field(init=False) + refill_rate: float = field(init=False) # tokens per second + last_refill: float = field(init=False) + lock: Lock = field(default_factory=Lock, repr=False) + + def __post_init__(self) -> None: + self.tokens = float(self.capacity) + self.refill_rate = self.capacity / _SECONDS_PER_DAY + self.last_refill = time.monotonic() + + +class RateLimiter: + """Per-API token-bucket rate limiter. + + Usage:: + + limiter = RateLimiter() + if limiter.allow("linkedin"): + do_linkedin_action() + else: + wait_or_skip() + + Custom limits can be supplied at construction time:: + + limiter = RateLimiter(limits={"linkedin": 30, "twitter": 200}) + """ + + def __init__(self, limits: dict[str, int] | None = None) -> None: + merged = {**DEFAULT_LIMITS, **(limits or {})} + self._buckets: dict[str, _Bucket] = { + api: _Bucket(capacity=cap) for api, cap in merged.items() + } + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def allow(self, api: str, tokens: int = 1) -> bool: + """Consume *tokens* from the bucket for *api*. + + Returns ``True`` if the action is allowed, ``False`` if the rate + limit has been exhausted. If *api* has no configured limit the + call is always allowed. + """ + bucket = self._buckets.get(api) + if bucket is None: + return True + + with bucket.lock: + self._refill(bucket) + if bucket.tokens >= tokens: + bucket.tokens -= tokens + return True + return False + + def remaining(self, api: str) -> float: + """Return the approximate number of tokens remaining for *api*.""" + bucket = self._buckets.get(api) + if bucket is None: + return float("inf") + with bucket.lock: + self._refill(bucket) + return bucket.tokens + + def wait_time(self, api: str, tokens: int = 1) -> float: + """Return seconds to wait before *tokens* become available. + + Returns ``0.0`` if the action can proceed immediately. + """ + bucket = self._buckets.get(api) + if bucket is None: + return 0.0 + with bucket.lock: + self._refill(bucket) + if bucket.tokens >= tokens: + return 0.0 + deficit = tokens - bucket.tokens + return deficit / bucket.refill_rate + + def reset(self, api: str | None = None) -> None: + """Reset one or all buckets to full capacity.""" + targets = [api] if api else list(self._buckets) + for name in targets: + bucket = self._buckets.get(name) + if bucket is not None: + with bucket.lock: + bucket.tokens = float(bucket.capacity) + bucket.last_refill = time.monotonic() + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + @staticmethod + def _refill(bucket: _Bucket) -> None: + """Add tokens based on elapsed time since last refill.""" + now = time.monotonic() + elapsed = now - bucket.last_refill + if elapsed > 0: + bucket.tokens = min( + bucket.capacity, + bucket.tokens + elapsed * bucket.refill_rate, + ) + bucket.last_refill = now