Add Personal Brand Engine - 7 AI Agents Automation System

Complete AI-powered personal brand automation for Sami Assiri.\n\n7 agents: LinkedIn, Email, Social Media, WhatsApp, CV Optimizer, Content Strategist, Opportunity Scout.\nInfra: FastAPI + APScheduler + Docker + Ollama/Groq LLM + GitHub Pages landing page.\n83 files, ~10K lines. Cost: $0-5/month.
This commit is contained in:
VoXc2 2026-03-30 11:45:48 +03:00 committed by GitHub
parent f63beffbc6
commit 4bb2442313
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 10438 additions and 0 deletions

View File

@ -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

View File

@ -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

46
personal-brand-engine/.gitignore vendored Normal file
View File

@ -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

View File

@ -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')"

View File

@ -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

View File

@ -0,0 +1,68 @@
<div dir="rtl">
# محرك العلامة الشخصية
**نظام أتمتة العلامة الشخصية بالذكاء الاصطناعي مع 7 وكلاء مستقلين يعملون 24/7**
مبني لـ **سامي محمد العسيري** - مهندس خدمات ميدانية في METCO (أمن المطارات - Smiths Detection)، مطار الملك خالد الدولي، الرياض.
---
## الوكلاء السبعة
| الوكيل | المهمة | الجدول |
|--------|--------|--------|
| **وكيل لنكدإن** | نشر محتوى، تفاعل مع الشبكة، تحسين البروفايل | 3 منشورات/أسبوع، 3 تفاعلات/يوم |
| **وكيل الإيميل** | مراقبة البريد، تصنيف، صياغة ردود | كل 15 دقيقة |
| **وكيل التواصل الاجتماعي** | نشر تويتر، إعادة صياغة المحتوى | يومياً |
| **وكيل واتساب** | مساعد شخصي، ردود تلقائية، حجز مواعيد | يعمل دائماً |
| **محسن السيرة الذاتية** | تحديث السيفي، توليد PDF | شهرياً |
| **استراتيجي المحتوى** | تحليل الترندات، تقويم محتوى أسبوعي | أسبوعياً + يومياً |
| **بوت مراقبة الفرص** | يبحث عن وظائف، أخبار، أحداث مهنية | كل ساعتين + ملخص يومي |
## التشغيل السريع
</div>
```bash
# 1. الإعداد
cd personal-brand-engine
make setup
# 2. تعديل المفاتيح
nano .env
# 3. التشغيل
make up
# 4. تحميل نموذج الذكاء الاصطناعي
make pull-model
# 5. التحقق
make status
```
<div dir="rtl">
## التكلفة
| الخدمة | التكلفة |
|--------|---------|
| GitHub Pages (الصفحة الشخصية) | مجاني |
| Cal.com (حجز المواعيد) | مجاني |
| Groq API (الذكاء الاصطناعي) | مجاني |
| Ollama (ذكاء اصطناعي محلي) | مجاني |
| واتساب Meta Cloud API | مجاني (1000 محادثة/شهر) |
| **الإجمالي** | **$0-5/شهر** (السيرفر فقط) |
## الأوامر الأساسية
</div>
```bash
make help # عرض كل الأوامر
make up # تشغيل الخدمات
make down # إيقاف الخدمات
make logs # عرض السجلات
make status # فحص الحالة
```

View File

@ -0,0 +1,5 @@
"""Agents package -- autonomous agents for the personal brand engine."""
from agents.base_agent import BaseAgent
__all__ = ["BaseAgent"]

View File

@ -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()

View File

@ -0,0 +1,5 @@
"""Content Strategist agent -- plans content calendars and analyzes trends."""
from agents.content_strategist.agent import ContentStrategistAgent
__all__ = ["ContentStrategistAgent"]

View File

@ -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,
}

View File

@ -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

View File

@ -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}

View File

@ -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 []

View File

@ -0,0 +1,5 @@
"""CV Optimizer agent -- enhances and generates professional CVs."""
from agents.cv_optimizer.agent import CVOptimizerAgent
__all__ = ["CVOptimizerAgent"]

View File

@ -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,
}

View File

@ -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", []),
}

View File

@ -0,0 +1,382 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ name }} - السيرة الذاتية</title>
<style>
/* ── Reset & Base ──────────────────────────────────────────── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Segoe UI", "Tahoma", "Noto Naskh Arabic", "Traditional Arabic", "Arial", sans-serif;
font-size: 10.5pt;
line-height: 1.6;
color: #1a1a1a;
background: #fff;
direction: rtl;
}
.page {
max-width: 210mm;
margin: 0 auto;
padding: 20mm 18mm;
}
a { color: #1a73a7; text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Header ────────────────────────────────────────────────── */
.header {
text-align: center;
border-bottom: 2px solid #1a73a7;
padding-bottom: 12px;
margin-bottom: 16px;
}
.header h1 {
font-size: 24pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 2px;
letter-spacing: 0.5px;
}
.header .title {
font-size: 12pt;
color: #1a73a7;
font-weight: 500;
margin-bottom: 8px;
}
.contact-row {
font-size: 9.5pt;
color: #444;
}
.contact-row span { margin: 0 6px; }
.contact-row .sep { color: #bbb; }
/* ── Section headings ──────────────────────────────────────── */
.section-title {
font-size: 12pt;
font-weight: 700;
color: #1a73a7;
letter-spacing: 0.5px;
border-bottom: 1px solid #dce6f0;
padding-bottom: 3px;
margin-top: 16px;
margin-bottom: 8px;
}
/* ── Summary ───────────────────────────────────────────────── */
.summary {
text-align: justify;
margin-bottom: 4px;
}
/* ── Experience items ──────────────────────────────────────── */
.exp-item { margin-bottom: 12px; }
.exp-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 3px;
}
.exp-header .role {
font-weight: 700;
font-size: 10.5pt;
}
.exp-header .period {
font-size: 9.5pt;
color: #666;
white-space: nowrap;
}
.exp-company {
font-size: 10pt;
color: #444;
margin-bottom: 4px;
}
ul.bullets {
list-style-type: none;
padding-right: 14px;
padding-left: 0;
}
ul.bullets li {
position: relative;
padding-right: 10px;
padding-left: 0;
margin-bottom: 2px;
}
ul.bullets li::before {
content: "\25AA";
position: absolute;
right: 0;
color: #1a73a7;
font-size: 8pt;
top: 4px;
}
/* ── Skills two-column layout ─────────────────────────────── */
.skills-grid {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
}
.skill-category {
width: calc(50% - 12px);
margin-bottom: 6px;
}
.skill-category h4 {
font-size: 10pt;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.skill-category ul {
list-style: none;
padding: 0;
}
.skill-category ul li {
font-size: 9.5pt;
color: #444;
padding: 1px 0;
}
/* ── Certifications & Awards ──────────────────────────────── */
ul.plain-list {
list-style: none;
padding-right: 0;
padding-left: 0;
}
ul.plain-list li {
padding: 2px 14px 2px 0;
position: relative;
font-size: 9.5pt;
}
ul.plain-list li::before {
content: "\25AA";
position: absolute;
right: 0;
color: #1a73a7;
font-size: 8pt;
top: 4px;
}
/* ── Education ─────────────────────────────────────────────── */
.edu-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.edu-header .degree {
font-weight: 700;
font-size: 10.5pt;
}
.edu-header .period {
font-size: 9.5pt;
color: #666;
}
.edu-institution {
font-size: 10pt;
color: #444;
margin-bottom: 4px;
}
/* ── References ────────────────────────────────────────────── */
.references p {
font-size: 9.5pt;
margin-bottom: 2px;
}
/* ── ATS keyword block (hidden visually, readable by ATS) ── */
.ats-keywords {
font-size: 0;
color: #fff;
line-height: 0;
overflow: hidden;
height: 0;
}
/* ── Print styles ──────────────────────────────────────────── */
@media print {
body { font-size: 10pt; }
.page { padding: 12mm 15mm; }
}
</style>
</head>
<body>
<div class="page">
<!-- ═══ HEADER ═══ -->
<header class="header">
<h1>{{ name }}</h1>
<div class="title">{{ title }}</div>
<div class="contact-row">
<span>{{ email }}</span>
<span class="sep">|</span>
<span>{{ phone }}</span>
<span class="sep">|</span>
<span>{{ location }}</span>
{% if linkedin %}
<span class="sep">|</span>
<span><a href="{{ linkedin }}">LinkedIn</a></span>
{% endif %}
</div>
</header>
<!-- ═══ الملخص المهني ═══ -->
<section>
<h2 class="section-title">الملخص المهني</h2>
<p class="summary">{{ summary }}</p>
</section>
<!-- ═══ الخبرة العملية ═══ -->
<section>
<h2 class="section-title">الخبرة العملية</h2>
<!-- الوظيفة الحالية -->
<div class="exp-item">
<div class="exp-header">
<span class="role">{{ current_title }}</span>
<span class="period">{{ current_start_date }} &ndash; الحالي</span>
</div>
<div class="exp-company">{{ current_company }} &mdash; {{ current_location }}</div>
<ul class="bullets">
{% for bullet in current_bullets %}
<li>{{ bullet }}</li>
{% endfor %}
</ul>
</div>
<!-- الوظائف السابقة -->
{% for role in previous_roles %}
<div class="exp-item">
<div class="exp-header">
<span class="role">{{ role.title }}</span>
<span class="period">{{ role.period }}</span>
</div>
<div class="exp-company">{{ role.company }}</div>
<ul class="bullets">
{% for bullet in role.bullets %}
<li>{{ bullet }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</section>
<!-- ═══ القيادة والعمل التطوعي ═══ -->
{% if leadership %}
<section>
<h2 class="section-title">القيادة والعمل التطوعي</h2>
{% for entry in leadership %}
<div class="exp-item">
<div class="exp-header">
<span class="role">{{ entry.role }} &mdash; {{ entry.organization }}</span>
<span class="period">{{ entry.period }}</span>
</div>
<ul class="bullets">
{% for bullet in entry.bullets %}
<li>{{ bullet }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</section>
{% endif %}
<!-- ═══ التعليم ═══ -->
<section>
<h2 class="section-title">التعليم</h2>
<div class="edu-header">
<span class="degree">{{ education_degree }}</span>
<span class="period">{{ education_period }}</span>
</div>
<div class="edu-institution">{{ education_institution }} &mdash; {{ education_location }}</div>
{% if education_highlights %}
<ul class="bullets">
{% for h in education_highlights %}
<li>{{ h }}</li>
{% endfor %}
</ul>
{% endif %}
</section>
<!-- ═══ الشهادات المهنية ═══ -->
{% if certifications %}
<section>
<h2 class="section-title">الشهادات المهنية</h2>
<ul class="plain-list">
{% for cert in certifications %}
<li>{{ cert }}</li>
{% endfor %}
</ul>
</section>
{% endif %}
<!-- ═══ المهارات ═══ -->
{% if skill_categories %}
<section>
<h2 class="section-title">المهارات</h2>
<div class="skills-grid">
{% for cat in skill_categories %}
<div class="skill-category">
<h4>{{ cat.name }}</h4>
<ul>
{% for item in cat.items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- ═══ الجوائز والتكريمات ═══ -->
{% if awards %}
<section>
<h2 class="section-title">الجوائز والتكريمات</h2>
<ul class="plain-list">
{% for award in awards %}
<li>{{ award }}</li>
{% endfor %}
</ul>
</section>
{% endif %}
<!-- ═══ المراجع ═══ -->
{% if references %}
<section class="references">
<h2 class="section-title">المراجع</h2>
{% for ref in references %}
<p>{{ ref }}</p>
{% endfor %}
</section>
{% endif %}
<!-- ATS keyword block -->
{% if ats_keywords %}
<div class="ats-keywords">{{ ats_keywords | join(', ') }}</div>
{% endif %}
</div>
</body>
</html>

View File

@ -0,0 +1,379 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ name }} - CV</title>
<style>
/* ── Reset & Base ──────────────────────────────────────────── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
font-size: 10.5pt;
line-height: 1.45;
color: #1a1a1a;
background: #fff;
}
.page {
max-width: 210mm;
margin: 0 auto;
padding: 20mm 18mm;
}
a { color: #1a73a7; text-decoration: none; }
a:hover { text-decoration: underline; }
/* ── Header ────────────────────────────────────────────────── */
.header {
text-align: center;
border-bottom: 2px solid #1a73a7;
padding-bottom: 12px;
margin-bottom: 16px;
}
.header h1 {
font-size: 22pt;
font-weight: 700;
color: #1a1a1a;
margin-bottom: 2px;
letter-spacing: 0.5px;
}
.header .title {
font-size: 11.5pt;
color: #1a73a7;
font-weight: 500;
margin-bottom: 8px;
}
.contact-row {
font-size: 9.5pt;
color: #444;
}
.contact-row span { margin: 0 6px; }
.contact-row .sep { color: #bbb; }
/* ── Section headings ──────────────────────────────────────── */
.section-title {
font-size: 12pt;
font-weight: 700;
color: #1a73a7;
text-transform: uppercase;
letter-spacing: 0.8px;
border-bottom: 1px solid #dce6f0;
padding-bottom: 3px;
margin-top: 16px;
margin-bottom: 8px;
}
/* ── Summary ───────────────────────────────────────────────── */
.summary {
text-align: justify;
margin-bottom: 4px;
}
/* ── Experience items ──────────────────────────────────────── */
.exp-item { margin-bottom: 12px; }
.exp-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 3px;
}
.exp-header .role {
font-weight: 700;
font-size: 10.5pt;
}
.exp-header .period {
font-size: 9.5pt;
color: #666;
white-space: nowrap;
}
.exp-company {
font-size: 10pt;
color: #444;
margin-bottom: 4px;
}
ul.bullets {
list-style-type: none;
padding-left: 14px;
}
ul.bullets li {
position: relative;
padding-left: 10px;
margin-bottom: 2px;
}
ul.bullets li::before {
content: "\25AA";
position: absolute;
left: 0;
color: #1a73a7;
font-size: 8pt;
top: 2px;
}
/* ── Skills two-column layout ─────────────────────────────── */
.skills-grid {
display: flex;
flex-wrap: wrap;
gap: 8px 24px;
}
.skill-category {
width: calc(50% - 12px);
margin-bottom: 6px;
}
.skill-category h4 {
font-size: 10pt;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.skill-category ul {
list-style: none;
padding: 0;
}
.skill-category ul li {
font-size: 9.5pt;
color: #444;
padding: 1px 0;
}
/* ── Certifications & Awards ──────────────────────────────── */
ul.plain-list {
list-style: none;
padding-left: 0;
}
ul.plain-list li {
padding: 2px 0 2px 14px;
position: relative;
font-size: 9.5pt;
}
ul.plain-list li::before {
content: "\25AA";
position: absolute;
left: 0;
color: #1a73a7;
font-size: 8pt;
top: 4px;
}
/* ── Education ─────────────────────────────────────────────── */
.edu-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.edu-header .degree {
font-weight: 700;
font-size: 10.5pt;
}
.edu-header .period {
font-size: 9.5pt;
color: #666;
}
.edu-institution {
font-size: 10pt;
color: #444;
margin-bottom: 4px;
}
/* ── References ────────────────────────────────────────────── */
.references p {
font-size: 9.5pt;
margin-bottom: 2px;
}
/* ── ATS keyword block (hidden visually, readable by ATS) ── */
.ats-keywords {
font-size: 0;
color: #fff;
line-height: 0;
overflow: hidden;
height: 0;
}
/* ── Print styles ──────────────────────────────────────────── */
@media print {
body { font-size: 10pt; }
.page { padding: 12mm 15mm; }
}
</style>
</head>
<body>
<div class="page">
<!-- ═══ HEADER ═══ -->
<header class="header">
<h1>{{ name }}</h1>
<div class="title">{{ title }}</div>
<div class="contact-row">
<span>{{ email }}</span>
<span class="sep">|</span>
<span>{{ phone }}</span>
<span class="sep">|</span>
<span>{{ location }}</span>
{% if linkedin %}
<span class="sep">|</span>
<span><a href="{{ linkedin }}">LinkedIn</a></span>
{% endif %}
</div>
</header>
<!-- ═══ PROFESSIONAL SUMMARY ═══ -->
<section>
<h2 class="section-title">Professional Summary</h2>
<p class="summary">{{ summary }}</p>
</section>
<!-- ═══ WORK EXPERIENCE ═══ -->
<section>
<h2 class="section-title">Work Experience</h2>
<!-- Current Role -->
<div class="exp-item">
<div class="exp-header">
<span class="role">{{ current_title }}</span>
<span class="period">{{ current_start_date }} &ndash; Present</span>
</div>
<div class="exp-company">{{ current_company }} &mdash; {{ current_location }}</div>
<ul class="bullets">
{% for bullet in current_bullets %}
<li>{{ bullet }}</li>
{% endfor %}
</ul>
</div>
<!-- Previous Roles -->
{% for role in previous_roles %}
<div class="exp-item">
<div class="exp-header">
<span class="role">{{ role.title }}</span>
<span class="period">{{ role.period }}</span>
</div>
<div class="exp-company">{{ role.company }}</div>
<ul class="bullets">
{% for bullet in role.bullets %}
<li>{{ bullet }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</section>
<!-- ═══ LEADERSHIP ═══ -->
{% if leadership %}
<section>
<h2 class="section-title">Leadership &amp; Volunteering</h2>
{% for entry in leadership %}
<div class="exp-item">
<div class="exp-header">
<span class="role">{{ entry.role }} &mdash; {{ entry.organization }}</span>
<span class="period">{{ entry.period }}</span>
</div>
<ul class="bullets">
{% for bullet in entry.bullets %}
<li>{{ bullet }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</section>
{% endif %}
<!-- ═══ EDUCATION ═══ -->
<section>
<h2 class="section-title">Education</h2>
<div class="edu-header">
<span class="degree">{{ education_degree }}</span>
<span class="period">{{ education_period }}</span>
</div>
<div class="edu-institution">{{ education_institution }} &mdash; {{ education_location }}</div>
{% if education_highlights %}
<ul class="bullets">
{% for h in education_highlights %}
<li>{{ h }}</li>
{% endfor %}
</ul>
{% endif %}
</section>
<!-- ═══ CERTIFICATIONS ═══ -->
{% if certifications %}
<section>
<h2 class="section-title">Certifications</h2>
<ul class="plain-list">
{% for cert in certifications %}
<li>{{ cert }}</li>
{% endfor %}
</ul>
</section>
{% endif %}
<!-- ═══ SKILLS ═══ -->
{% if skill_categories %}
<section>
<h2 class="section-title">Skills</h2>
<div class="skills-grid">
{% for cat in skill_categories %}
<div class="skill-category">
<h4>{{ cat.name }}</h4>
<ul>
{% for item in cat.items %}
<li>{{ item }}</li>
{% endfor %}
</ul>
</div>
{% endfor %}
</div>
</section>
{% endif %}
<!-- ═══ AWARDS ═══ -->
{% if awards %}
<section>
<h2 class="section-title">Awards &amp; Recognition</h2>
<ul class="plain-list">
{% for award in awards %}
<li>{{ award }}</li>
{% endfor %}
</ul>
</section>
{% endif %}
<!-- ═══ REFERENCES ═══ -->
{% if references %}
<section class="references">
<h2 class="section-title">References</h2>
{% for ref in references %}
<p>{{ ref }}</p>
{% endfor %}
</section>
{% endif %}
<!-- ATS keyword block -->
{% if ats_keywords %}
<div class="ats-keywords">{{ ats_keywords | join(', ') }}</div>
{% endif %}
</div>
</body>
</html>

View File

@ -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": [],
}

View File

@ -0,0 +1,5 @@
"""Email management agent for Sami Assiri's inbox."""
from agents.email.agent import EmailAgent
__all__ = ["EmailAgent"]

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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"
)

View File

@ -0,0 +1,5 @@
"""LinkedIn automation agent for personal brand management."""
from agents.linkedin.agent import LinkedInAgent
__all__ = ["LinkedInAgent"]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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} هو مفتاح النجاح في هذا القطاع."
- "كلام في الصميم. قطاع الطيران في المملكة يشهد نموًا كبيرًا وهذه النوعية من النقاشات مهمة جدًا."

View File

@ -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 #هندسة

View File

@ -0,0 +1,5 @@
"""Opportunity Scout agent -- monitors the internet for career opportunities."""
from agents.opportunity_scout.agent import OpportunityScoutAgent
__all__ = ["OpportunityScoutAgent"]

View File

@ -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

View File

@ -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

View File

@ -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'<a[^>]+href="([^"]*(?:job|career|position|opening)[^"]*)"[^>]*>'
r"(.*?)</a>",
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 <source> 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

View File

@ -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": <float 0.0-1.0>, "explanation": "<one-sentence reason>"}}
"""
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

View File

@ -0,0 +1,5 @@
"""Social media automation agent for personal brand management."""
from agents.social_media.agent import SocialMediaAgent
__all__ = ["SocialMediaAgent"]

View File

@ -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)}")

View File

@ -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)}")

View File

@ -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"]

View File

@ -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

View File

@ -0,0 +1,5 @@
"""WhatsApp automation agent for personal brand management."""
from agents.whatsapp.agent import WhatsAppAgent
__all__ = ["WhatsAppAgent"]

View File

@ -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

View File

@ -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!

View File

@ -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))

View File

@ -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)

View File

View File

@ -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,
)

View File

@ -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()

View File

@ -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(),
}

View File

@ -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="<Response></Response>", 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"""<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Message>{response_text}</Message>
</Response>"""
return Response(content=twiml, media_type="application/xml")
except Exception as e:
logger.error("Twilio webhook error: %s", e)
return Response(
content="<Response><Message>عذراً، حدث خطأ. يرجى المحاولة لاحقاً.</Message></Response>",
media_type="application/xml",
)

View File

View File

@ -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"

View File

@ -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

View File

@ -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"

View File

@ -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")

View File

@ -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:

View File

@ -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"]

View File

@ -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"

View File

@ -0,0 +1,325 @@
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sami Mohammed Assiri - CV</title>
<style>
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
font-size: 10.5pt;
line-height: 1.5;
color: #1a1a1a;
background: #fff;
}
.page {
max-width: 210mm;
margin: 0 auto;
padding: 18mm 16mm;
}
a { color: #0a66c2; text-decoration: none; }
/* ── Header ── */
.header {
text-align: center;
border-bottom: 2.5px solid #0a66c2;
padding-bottom: 10px;
margin-bottom: 14px;
}
.header h1 {
font-size: 24pt;
font-weight: 700;
color: #1a1a1a;
letter-spacing: 1px;
text-transform: uppercase;
margin-bottom: 4px;
}
.header .title {
font-size: 11pt;
color: #0a66c2;
font-weight: 600;
margin-bottom: 8px;
}
.contact-row {
font-size: 9pt;
color: #555;
line-height: 1.6;
}
.contact-row .sep { color: #ccc; margin: 0 5px; }
/* ── Sections ── */
.section-title {
font-size: 11pt;
font-weight: 700;
color: #0a66c2;
text-transform: uppercase;
letter-spacing: 1px;
border-bottom: 1px solid #dce6f0;
padding-bottom: 3px;
margin-top: 14px;
margin-bottom: 7px;
}
.summary {
text-align: justify;
font-size: 10pt;
line-height: 1.55;
}
/* ── Experience ── */
.exp-item { margin-bottom: 10px; }
.exp-header {
display: flex;
justify-content: space-between;
align-items: baseline;
}
.exp-header .role { font-weight: 700; font-size: 10.5pt; }
.exp-header .period { font-size: 9pt; color: #666; white-space: nowrap; }
.exp-company { font-size: 9.5pt; color: #444; font-style: italic; margin-bottom: 3px; }
ul.bullets { list-style: none; padding-left: 12px; }
ul.bullets li { position: relative; padding-left: 10px; margin-bottom: 1.5px; font-size: 9.5pt; }
ul.bullets li::before {
content: "\25AA"; position: absolute; left: 0; color: #0a66c2; font-size: 7pt; top: 3px;
}
/* ── Skills Grid ── */
.skills-grid { display: flex; flex-wrap: wrap; gap: 6px 20px; }
.skill-category { width: calc(50% - 10px); margin-bottom: 4px; }
.skill-category h4 { font-size: 9.5pt; font-weight: 600; color: #333; margin-bottom: 1px; }
.skill-category ul { list-style: none; padding: 0; }
.skill-category li { font-size: 9pt; color: #444; padding: 0.5px 0; }
/* ── Plain Lists ── */
ul.plain-list { list-style: none; padding: 0; columns: 2; column-gap: 20px; }
ul.plain-list li { padding: 1.5px 0 1.5px 12px; position: relative; font-size: 9pt; break-inside: avoid; }
ul.plain-list li::before { content: "\25AA"; position: absolute; left: 0; color: #0a66c2; font-size: 7pt; top: 3px; }
.awards-list { columns: 1; }
/* ── Education ── */
.edu-header { display: flex; justify-content: space-between; align-items: baseline; }
.edu-header .degree { font-weight: 700; font-size: 10.5pt; }
.edu-header .period { font-size: 9pt; color: #666; }
.edu-institution { font-size: 9.5pt; color: #444; font-style: italic; margin-bottom: 3px; }
.references p { font-size: 9pt; margin-bottom: 1.5px; color: #444; }
@media print {
body { font-size: 10pt; }
.page { padding: 10mm 14mm; }
.section-title { margin-top: 10px; }
}
</style>
</head>
<body>
<div class="page">
<header class="header">
<h1>Sami Mohammed Assiri</h1>
<div class="title">Field Services Engineer | Airport Security Systems | Smiths Detection Specialist</div>
<div class="contact-row">
<span>sami.assiri11@gmail.com</span>
<span class="sep">|</span>
<span>+966 597 788 539</span>
<span class="sep">|</span>
<span>Riyadh, Saudi Arabia</span>
<span class="sep">|</span>
<span><a href="https://www.linkedin.com/in/sami-assiri-a300622b2/">linkedin.com/in/sami-assiri</a></span>
</div>
</header>
<!-- PROFESSIONAL SUMMARY -->
<section>
<h2 class="section-title">Professional Summary</h2>
<p class="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 &amp; gas projects at Samsung E&amp;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.
</p>
</section>
<!-- WORK EXPERIENCE -->
<section>
<h2 class="section-title">Work Experience</h2>
<div class="exp-item">
<div class="exp-header">
<span class="role">Field Services Engineer</span>
<span class="period">Jan 2026 &ndash; Present</span>
</div>
<div class="exp-company">METCO &ndash; Middle East Services | King Khalid International Airport, Riyadh</div>
<ul class="bullets">
<li>Execute preventive and corrective maintenance on Smiths Detection airport security equipment serving 30M+ annual passengers</li>
<li>Operate and calibrate HI-SCAN X-ray screening systems ensuring 99.9% uptime for passenger and baggage inspection</li>
<li>Maintain IONSCAN 600 trace detection systems for explosive and narcotic identification at security checkpoints</li>
<li>Service CTX advanced computed tomography inspection systems for hold baggage screening</li>
<li>Perform system calibration, quality assurance testing, and compliance verification per GACA and ICAO standards</li>
<li>Troubleshoot and resolve complex equipment malfunctions, minimizing operational disruptions to airport security</li>
</ul>
</div>
<div class="exp-item">
<div class="exp-header">
<span class="role">Planning Engineer Intern</span>
<span class="period">Feb 2025 &ndash; May 2025</span>
</div>
<div class="exp-company">Samsung E&amp;A Saudi Arabia | Dammam, Eastern Province</div>
<ul class="bullets">
<li>Engineered Python-powered dashboards and automated Gantt chart modules, reducing reporting preparation time by 75%</li>
<li>Modeled and scheduled 4,327 activities in Primavera P6, applying Monte Carlo simulations to deliver accurate P-80 risk envelopes for high-value oil &amp; gas projects</li>
<li>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</li>
<li>Produced data-driven quarterly performance and market intelligence reports for 8+ major projects, providing actionable insights for strategic planning and investment decisions</li>
<li>Facilitated 14+ high-level meetings and technical workshops with Aramco and global EPC contractors, aligning planning, risk, and cost strategies across teams</li>
<li>Supported asset management and digital transformation initiatives, integrating advanced analytics and visualization tools to enhance operational decision-making</li>
</ul>
</div>
</section>
<!-- LEADERSHIP -->
<section>
<h2 class="section-title">Leadership &amp; Organizations</h2>
<div class="exp-item">
<div class="exp-header">
<span class="role">President &ndash; SPE Alasala Chapter</span>
<span class="period">Sep 2024 &ndash; Present</span>
</div>
<div class="exp-company">Society of Petroleum Engineers (SPE) | Dammam</div>
<ul class="bullets">
<li>Revitalized a dormant student chapter, scaling active membership from 0 to 89 members within one semester through strategic outreach</li>
<li>Directed marketing campaigns generating 50,000+ organic impressions across social platforms</li>
<li>Built partnerships with Aramco, Saudi Council of Engineers, and multiple EPC firms aligned with Saudi Vision 2030</li>
<li>Organized 6+ technical workshops, industry visits, and career development sessions</li>
<li>Secured invitation to the 2025 MENA PetroBowl qualifiers</li>
</ul>
</div>
<div class="exp-item">
<div class="exp-header">
<span class="role">Founder &ndash; Elite Engineers Club</span>
<span class="period">2024 &ndash; May 2025</span>
</div>
<div class="exp-company">Alasala Colleges | Dammam</div>
<ul class="bullets">
<li>Founded a multidisciplinary engineering hub attracting 40+ students from mechanical, electrical, and civil engineering</li>
<li>Negotiated accreditation with EduStation and Saudi Council of Engineers for industry-aligned training programs</li>
<li>Secured industry sponsorships and introduced data-driven impact tracking tools</li>
</ul>
</div>
</section>
<!-- EDUCATION -->
<section>
<h2 class="section-title">Education</h2>
<div class="edu-header">
<span class="degree">Bachelor of Science in Mechanical Engineering</span>
<span class="period">Mar 2019 &ndash; May 2025</span>
</div>
<div class="edu-institution">Alasala Colleges &ndash; Dammam, Eastern Province, Saudi Arabia</div>
<ul class="bullets">
<li>Best Capstone Project Award (1st of 16 teams) &ndash; developed a biodegradable, thermally insulating green composite from sunflower waste</li>
<li>Relevant coursework: HVAC Systems, Renewable Energy, Project Planning &amp; Control, Data Analysis &amp; Visualization, Asset Management</li>
</ul>
</section>
<!-- CERTIFICATIONS -->
<section>
<h2 class="section-title">Certifications &amp; Training</h2>
<ul class="plain-list">
<li>MV Switchgears &amp; Modular Power Systems &ndash; Training Workshop (Aug 2025)</li>
<li>Earthing Systems &ndash; Training Workshop (Aug 2025)</li>
<li>KNX System Fundamentals &amp; Building Automation &ndash; eLearning (Oct 2024)</li>
<li>Saudi Mechanical Code (SBC 501) &ndash; Saudi Council of Engineers (Jun 2024)</li>
<li>BIM-Oriented Sustainable Design &ndash; Autodesk (May 2024)</li>
<li>Emergency Lighting &amp; Central Battery Systems &ndash; ABB (Apr 2024)</li>
<li>Low Voltage Circuit Breakers (IEC Standards) &ndash; ABB (Apr 2024)</li>
<li>ABB E-Design Certification &ndash; ABB (May 2024)</li>
<li>Contract &amp; Tendering Management &ndash; PMI (Jun 2024)</li>
<li>Organizational Effectiveness &amp; Excellence &ndash; EFQM (May 2024)</li>
</ul>
</section>
<!-- SKILLS -->
<section>
<h2 class="section-title">Technical &amp; Professional Skills</h2>
<div class="skills-grid">
<div class="skill-category">
<h4>Airport Security &amp; Engineering</h4>
<ul>
<li>Smiths Detection Equipment (HI-SCAN, IONSCAN 600, CTX)</li>
<li>Preventive &amp; Corrective Maintenance</li>
<li>System Calibration &amp; Quality Assurance</li>
<li>HVAC Systems | BIM | Digital Twin</li>
<li>Asset Management Systems</li>
</ul>
</div>
<div class="skill-category">
<h4>Data &amp; Analytics</h4>
<ul>
<li>Python (Pandas, NumPy, Plotly, Dash)</li>
<li>SQL | Power BI | Jupyter Notebook</li>
<li>Advanced Excel (Automation, Reporting)</li>
<li>KPI Development &amp; Performance Tracking</li>
<li>Risk Modeling &amp; Forecasting</li>
</ul>
</div>
<div class="skill-category">
<h4>Project Management</h4>
<ul>
<li>Primavera P6 &amp; MS Project</li>
<li>Monte Carlo Simulation (Risk Analysis)</li>
<li>Cost Estimation &amp; Budget Control</li>
<li>Stakeholder Management &amp; Communication</li>
<li>Resource Optimization &amp; Strategic Execution</li>
</ul>
</div>
<div class="skill-category">
<h4>Leadership &amp; Soft Skills</h4>
<ul>
<li>Strategic Communication &amp; Negotiation</li>
<li>Cross-Functional Collaboration</li>
<li>Organizational Design &amp; Talent Development</li>
<li>Event Planning &amp; Industry Engagement</li>
<li>Automated Reporting &amp; ETL Pipelines</li>
</ul>
</div>
</div>
</section>
<!-- AWARDS -->
<section>
<h2 class="section-title">Awards &amp; Recognition</h2>
<ul class="plain-list awards-list">
<li>Best Capstone Project Award (1st of 16 teams) &ndash; Alasala Colleges (May 2025)</li>
<li>SPE KSA Excellence Award &ndash; Impactful contributions to student engineering development (2025)</li>
<li>Presidential Recognition &ndash; SPE International, for revitalizing the Alasala Chapter (2025)</li>
</ul>
</section>
<!-- REFERENCES -->
<section class="references">
<h2 class="section-title">References</h2>
<p>Dr. Saeed AlNoman &ndash; Assistant Professor, Mechanical Engineering, Alasala Colleges</p>
<p>Khalifa &ndash; Assistant Director of Project Management, Samsung E&amp;A</p>
</section>
</div>
</body>
</html>

View File

@ -0,0 +1,315 @@
<!DOCTYPE html>
<html lang="ar" dir="rtl">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sami Assiri | سامي العسيري - Field Services Engineer</title>
<meta name="description" content="Sami Mohammed Assiri - Field Services Engineer at METCO | Smiths Detection Airport Security Specialist | Mechanical Engineer">
<meta name="keywords" content="Sami Assiri, سامي العسيري, Field Services Engineer, METCO, Smiths Detection, Airport Security, Mechanical Engineer">
<!-- Open Graph -->
<meta property="og:title" content="Sami Assiri | سامي العسيري">
<meta property="og:description" content="Field Services Engineer - Airport Security Technology Specialist">
<meta property="og:type" content="profile">
<meta property="og:locale" content="ar_SA">
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cairo:wght@300;400;600;700&family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Language Toggle -->
<button class="lang-toggle" onclick="toggleLanguage()" aria-label="Switch Language">
<span id="lang-btn-text">EN</span>
</button>
<!-- Hero Section -->
<header class="hero">
<div class="hero-bg"></div>
<div class="container">
<div class="profile-card">
<div class="avatar">
<div class="avatar-placeholder">SA</div>
</div>
<h1 class="name">
<span class="ar">سامي محمد العسيري</span>
<span class="en" style="display:none;">Sami Mohammed Assiri</span>
</h1>
<p class="title">
<span class="ar">مهندس خدمات ميدانية | متخصص أمن المطارات</span>
<span class="en" style="display:none;">Field Services Engineer | Airport Security Specialist</span>
</p>
<p class="company">
<span class="ar">METCO - خدمات الشرق الأوسط | مطار الملك خالد الدولي</span>
<span class="en" style="display:none;">METCO - Middle East Services | King Khalid International Airport</span>
</p>
<div class="badges">
<span class="badge">Smiths Detection</span>
<span class="badge">Mechanical Engineering</span>
<span class="badge">Python & Analytics</span>
<span class="badge">Ex-Samsung E&A</span>
</div>
</div>
</div>
</header>
<!-- Contact Actions -->
<section class="actions">
<div class="container">
<div class="action-grid">
<a href="mailto:sami.assiri11@gmail.com" class="action-btn primary">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
<span class="ar">راسلني</span>
<span class="en" style="display:none;">Email Me</span>
</a>
<a href="tel:+966597788539" class="action-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
<span class="ar">اتصل</span>
<span class="en" style="display:none;">Call</span>
</a>
<a href="https://www.linkedin.com/in/sami-assiri-a300622b2/" target="_blank" class="action-btn linkedin">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
LinkedIn
</a>
<a href="#" onclick="downloadVCard()" class="action-btn">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg>
<span class="ar">حفظ جهة الاتصال</span>
<span class="en" style="display:none;">Save Contact</span>
</a>
</div>
</div>
</section>
<!-- About Section -->
<section class="about">
<div class="container">
<h2>
<span class="ar">نبذة عني</span>
<span class="en" style="display:none;">About Me</span>
</h2>
<p class="about-text">
<span class="ar">
مهندس ميكانيكي وخدمات ميدانية في شركة METCO (خدمات الشرق الأوسط) بمطار الملك خالد الدولي بالرياض.
متخصص في صيانة وتشغيل أنظمة أمن المطارات من Smiths Detection، بما في ذلك أجهزة الأشعة السينية (HI-SCAN)
وأجهزة كشف المتفجرات (IONSCAN 600) وأنظمة الفحص المتقدمة (CTX).
<br><br>
سابقاً في Samsung E&A حيث طورت لوحات بيانات بايثون وأتمتت تخطيط المشاريع.
رئيس فرع SPE الأصالة - نقلت الفرع من 0 إلى 89 عضو فعال. حاصل على 10+ شهادات مهنية.
</span>
<span class="en" style="display:none;">
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.
<br><br>
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.
</span>
</p>
</div>
</section>
<!-- Experience Section -->
<section class="experience">
<div class="container">
<h2>
<span class="ar">الخبرات</span>
<span class="en" style="display:none;">Experience</span>
</h2>
<div class="timeline">
<div class="timeline-item current">
<div class="timeline-marker"></div>
<div class="timeline-content">
<div class="timeline-badge">
<span class="ar">الحالي</span>
<span class="en" style="display:none;">Current</span>
</div>
<h3>Field Services Engineer</h3>
<p class="company-name">METCO - Middle East Services</p>
<p class="location">
<span class="ar">مطار الملك خالد الدولي، الرياض</span>
<span class="en" style="display:none;">King Khalid International Airport, Riyadh</span>
</p>
<p class="period">Jan 2026 - Present</p>
<ul>
<li>Smiths Detection airport security equipment maintenance & operation</li>
<li>HI-SCAN X-Ray | IONSCAN 600 | CTX Systems</li>
<li>Preventive & corrective maintenance, system calibration</li>
</ul>
</div>
</div>
<div class="timeline-item">
<div class="timeline-marker"></div>
<div class="timeline-content">
<h3>Planning Engineer Intern</h3>
<p class="company-name">Samsung E&A Saudi Arabia</p>
<p class="period">Feb 2025 - May 2025</p>
<ul>
<li>Python dashboards reducing reporting time by 75%</li>
<li>4,327 activities modeled in Primavera P6</li>
<li>Analytics for multi-billion-dollar Aramco projects</li>
</ul>
</div>
</div>
<div class="timeline-item">
<div class="timeline-marker"></div>
<div class="timeline-content">
<h3>President - SPE Alasala Chapter</h3>
<p class="company-name">Society of Petroleum Engineers</p>
<p class="period">Sep 2024 - Present</p>
<ul>
<li>Scaled from 0 to 89 active members</li>
<li>50,000+ organic social impressions</li>
<li>Partnerships with Aramco & Saudi Council of Engineers</li>
</ul>
</div>
</div>
</div>
</div>
</section>
<!-- Skills Section -->
<section class="skills">
<div class="container">
<h2>
<span class="ar">المهارات</span>
<span class="en" style="display:none;">Skills</span>
</h2>
<div class="skills-grid">
<div class="skill-category">
<h3>
<span class="ar">أمن المطارات والهندسة</span>
<span class="en" style="display:none;">Airport Security & Engineering</span>
</h3>
<div class="skill-tags">
<span>Smiths Detection</span>
<span>HI-SCAN X-Ray</span>
<span>IONSCAN 600</span>
<span>CTX Systems</span>
<span>HVAC</span>
<span>BIM</span>
</div>
</div>
<div class="skill-category">
<h3>
<span class="ar">البيانات والتحليلات</span>
<span class="en" style="display:none;">Data & Analytics</span>
</h3>
<div class="skill-tags">
<span>Python</span>
<span>SQL</span>
<span>Power BI</span>
<span>Pandas</span>
<span>Plotly/Dash</span>
<span>Excel</span>
</div>
</div>
<div class="skill-category">
<h3>
<span class="ar">إدارة المشاريع</span>
<span class="en" style="display:none;">Project Management</span>
</h3>
<div class="skill-tags">
<span>Primavera P6</span>
<span>MS Project</span>
<span>Monte Carlo</span>
<span>Risk Analysis</span>
<span>Cost Control</span>
</div>
</div>
<div class="skill-category">
<h3>
<span class="ar">الشهادات</span>
<span class="en" style="display:none;">Certifications</span>
</h3>
<div class="skill-tags">
<span>ABB E-Design</span>
<span>KNX Systems</span>
<span>SBC 501</span>
<span>PMI</span>
<span>EFQM</span>
<span>Autodesk BIM</span>
</div>
</div>
</div>
</div>
</section>
<!-- Awards Section -->
<section class="awards">
<div class="container">
<h2>
<span class="ar">الجوائز</span>
<span class="en" style="display:none;">Awards</span>
</h2>
<div class="awards-grid">
<div class="award-card">
<div class="award-icon">&#127942;</div>
<h3>Best Capstone Project</h3>
<p>1st of 16 teams - Alasala Colleges (2025)</p>
</div>
<div class="award-card">
<div class="award-icon">&#11088;</div>
<h3>SPE KSA Excellence Award</h3>
<p>Society of Petroleum Engineers (2025)</p>
</div>
<div class="award-card">
<div class="award-icon">&#127941;</div>
<h3>Presidential Recognition</h3>
<p>SPE International (2025)</p>
</div>
</div>
</div>
</section>
<!-- Book a Meeting Section -->
<section class="booking">
<div class="container">
<h2>
<span class="ar">احجز موعد</span>
<span class="en" style="display:none;">Book a Meeting</span>
</h2>
<p class="booking-desc">
<span class="ar">تبي تتواصل معي؟ احجز موعد مباشرة من هنا</span>
<span class="en" style="display:none;">Want to connect? Book a meeting directly here</span>
</p>
<div id="cal-embed">
<!-- Cal.com embed will be inserted here -->
<a href="mailto:sami.assiri11@gmail.com" class="booking-fallback">
<span class="ar">راسلني على الإيميل لحجز موعد</span>
<span class="en" style="display:none;">Email me to schedule a meeting</span>
</a>
</div>
</div>
</section>
<!-- Footer -->
<footer>
<div class="container">
<div class="social-links">
<a href="https://www.linkedin.com/in/sami-assiri-a300622b2/" target="_blank" aria-label="LinkedIn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433c-1.144 0-2.063-.926-2.063-2.065 0-1.138.92-2.063 2.063-2.063 1.14 0 2.064.925 2.064 2.063 0 1.139-.925 2.065-2.064 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z"/></svg>
</a>
<a href="mailto:sami.assiri11@gmail.com" aria-label="Email">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/></svg>
</a>
<a href="tel:+966597788539" aria-label="Phone">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72c.127.96.361 1.903.7 2.81a2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
</a>
</div>
<p class="footer-text">
<span class="ar">&copy; 2026 سامي العسيري. جميع الحقوق محفوظة.</span>
<span class="en" style="display:none;">&copy; 2026 Sami Assiri. All rights reserved.</span>
</p>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>

View File

@ -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 = `
<iframe
src="https://cal.com/${calUsername}?embed=true&theme=dark"
style="width:100%;height:400px;border:none;border-radius:12px;"
loading="lazy"
></iframe>
`;
}
}
/**
* 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();
});

View File

@ -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;
}

View File

@ -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

View File

@ -0,0 +1,3 @@
from .client import LLMClient, get_llm_client
__all__ = ["LLMClient", "get_llm_client"]

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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())

View File

@ -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()

View File

@ -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",
]

View File

@ -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()

View File

@ -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"<Post id={self.id} platform={self.platform!r} status={self.status!r}>"
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"<Email id={self.id} from={self.from_addr!r} status={self.status!r}>"
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"<Contact id={self.id} name={self.name!r}>"
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"<AgentLog id={self.id} agent={self.agent_name!r} status={self.status!r}>"
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"<ContentCalendar id={self.id} date={self.date} topic={self.topic!r}>"
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"<Opportunity id={self.id} title={self.title!r} source={self.source!r}>"

View File

View File

@ -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 == {}

View File

@ -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

View File

@ -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

View File

View File

@ -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

View File

@ -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)

View File

@ -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