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 }} - السيرة الذاتية
+
+
+
+
+
+
+
+
+
+
+ الملخص المهني
+ {{ summary }}
+
+
+
+
+ الخبرة العملية
+
+
+
+
+
{{ current_company }} — {{ current_location }}
+
+ {% for bullet in current_bullets %}
+ {{ bullet }}
+ {% endfor %}
+
+
+
+
+ {% for role in previous_roles %}
+
+
+
{{ role.company }}
+
+ {% for bullet in role.bullets %}
+ {{ bullet }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+ {% if leadership %}
+
+ القيادة والعمل التطوعي
+ {% for entry in leadership %}
+
+
+
+ {% for bullet in entry.bullets %}
+ {{ bullet }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+ التعليم
+
+ {{ education_institution }} — {{ education_location }}
+ {% if education_highlights %}
+
+ {% for h in education_highlights %}
+ {{ h }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+ {% if certifications %}
+
+ الشهادات المهنية
+
+ {% for cert in certifications %}
+ {{ cert }}
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ {% if skill_categories %}
+
+ المهارات
+
+ {% for cat in skill_categories %}
+
+
{{ cat.name }}
+
+ {% for item in cat.items %}
+ {{ item }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+ {% endif %}
+
+
+ {% if awards %}
+
+ الجوائز والتكريمات
+
+ {% for award in awards %}
+ {{ award }}
+ {% endfor %}
+
+
+ {% 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
+
+
+
+
+
+
+
+
+
+
+ Professional Summary
+ {{ summary }}
+
+
+
+
+ Work Experience
+
+
+
+
+
{{ current_company }} — {{ current_location }}
+
+ {% for bullet in current_bullets %}
+ {{ bullet }}
+ {% endfor %}
+
+
+
+
+ {% for role in previous_roles %}
+
+
+
{{ role.company }}
+
+ {% for bullet in role.bullets %}
+ {{ bullet }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+
+
+ {% if leadership %}
+
+ Leadership & Volunteering
+ {% for entry in leadership %}
+
+
+
+ {% for bullet in entry.bullets %}
+ {{ bullet }}
+ {% endfor %}
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+ Education
+
+ {{ education_institution }} — {{ education_location }}
+ {% if education_highlights %}
+
+ {% for h in education_highlights %}
+ {{ h }}
+ {% endfor %}
+
+ {% endif %}
+
+
+
+ {% if certifications %}
+
+ Certifications
+
+ {% for cert in certifications %}
+ {{ cert }}
+ {% endfor %}
+
+
+ {% 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
+
+ {% for award in awards %}
+ {{ award }}
+ {% endfor %}
+
+
+ {% 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
+
+
+
+
+
+
+
+
+
+ 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
+
+
+
+
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
+
+
+
+
+
+
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
+
+
+
+
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
+
+
+
+
+
+
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
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ EN
+
+
+
+
+
+
+
+
+
+ سامي محمد العسيري
+ Sami Mohammed Assiri
+
+
+ مهندس خدمات ميدانية | متخصص أمن المطارات
+ Field Services Engineer | Airport Security Specialist
+
+
+ METCO - خدمات الشرق الأوسط | مطار الملك خالد الدولي
+ METCO - Middle East Services | King Khalid International Airport
+
+
+ Smiths Detection
+ Mechanical Engineering
+ Python & Analytics
+ Ex-Samsung E&A
+
+
+
+
+
+
+
+
+
+
+
+
+ نبذة عني
+ About Me
+
+
+
+ مهندس ميكانيكي وخدمات ميدانية في شركة METCO (خدمات الشرق الأوسط) بمطار الملك خالد الدولي بالرياض.
+ متخصص في صيانة وتشغيل أنظمة أمن المطارات من Smiths Detection، بما في ذلك أجهزة الأشعة السينية (HI-SCAN)
+ وأجهزة كشف المتفجرات (IONSCAN 600) وأنظمة الفحص المتقدمة (CTX).
+
+ سابقاً في Samsung E&A حيث طورت لوحات بيانات بايثون وأتمتت تخطيط المشاريع.
+ رئيس فرع SPE الأصالة - نقلت الفرع من 0 إلى 89 عضو فعال. حاصل على 10+ شهادات مهنية.
+
+
+ Field Services Engineer at METCO (Middle East Services) stationed at King Khalid International Airport, Riyadh.
+ Specialized in 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 and automated project planning
+ for multi-billion-dollar oil & gas projects. As President of SPE Alasala Chapter, scaled membership
+ from 0 to 89 active participants. 10+ professional certifications.
+
+
+
+
+
+
+
+
+
+ الخبرات
+ Experience
+
+
+
+
+
+
+
+ الحالي
+ Current
+
+
Field Services Engineer
+
METCO - Middle East Services
+
+ مطار الملك خالد الدولي، الرياض
+ King Khalid International Airport, Riyadh
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+ المهارات
+ Skills
+
+
+
+
+ أمن المطارات والهندسة
+ Airport Security & Engineering
+
+
+ Smiths Detection
+ HI-SCAN X-Ray
+ IONSCAN 600
+ CTX Systems
+ HVAC
+ BIM
+
+
+
+
+ البيانات والتحليلات
+ Data & Analytics
+
+
+ Python
+ SQL
+ Power BI
+ Pandas
+ Plotly/Dash
+ Excel
+
+
+
+
+ إدارة المشاريع
+ Project Management
+
+
+ Primavera P6
+ MS Project
+ Monte Carlo
+ Risk Analysis
+ Cost Control
+
+
+
+
+ الشهادات
+ Certifications
+
+
+ ABB E-Design
+ KNX Systems
+ SBC 501
+ PMI
+ EFQM
+ Autodesk BIM
+
+
+
+
+
+
+
+
+
+
+ الجوائز
+ Awards
+
+
+
+
🏆
+
Best Capstone Project
+
1st of 16 teams - Alasala Colleges (2025)
+
+
+
⭐
+
SPE KSA Excellence Award
+
Society of Petroleum Engineers (2025)
+
+
+
🏅
+
Presidential Recognition
+
SPE International (2025)
+
+
+
+
+
+
+
+
+
+ احجز موعد
+ Book a Meeting
+
+
+ تبي تتواصل معي؟ احجز موعد مباشرة من هنا
+ Want to connect? Book a meeting directly here
+
+
+
+
+
+
+
+
+
+
+
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