mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-08-16 21:24:09 +00:00
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:
parent
f63beffbc6
commit
4bb2442313
69
personal-brand-engine/.env.example
Normal file
69
personal-brand-engine/.env.example
Normal 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
|
||||
37
personal-brand-engine/.github/workflows/deploy-landing-page.yml
vendored
Normal file
37
personal-brand-engine/.github/workflows/deploy-landing-page.yml
vendored
Normal 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
46
personal-brand-engine/.gitignore
vendored
Normal 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
|
||||
56
personal-brand-engine/Makefile
Normal file
56
personal-brand-engine/Makefile
Normal 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')"
|
||||
118
personal-brand-engine/README.md
Normal file
118
personal-brand-engine/README.md
Normal 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
|
||||
68
personal-brand-engine/README_AR.md
Normal file
68
personal-brand-engine/README_AR.md
Normal 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 # فحص الحالة
|
||||
```
|
||||
5
personal-brand-engine/agents/__init__.py
Normal file
5
personal-brand-engine/agents/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Agents package -- autonomous agents for the personal brand engine."""
|
||||
|
||||
from agents.base_agent import BaseAgent
|
||||
|
||||
__all__ = ["BaseAgent"]
|
||||
135
personal-brand-engine/agents/base_agent.py
Normal file
135
personal-brand-engine/agents/base_agent.py
Normal 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()
|
||||
@ -0,0 +1,5 @@
|
||||
"""Content Strategist agent -- plans content calendars and analyzes trends."""
|
||||
|
||||
from agents.content_strategist.agent import ContentStrategistAgent
|
||||
|
||||
__all__ = ["ContentStrategistAgent"]
|
||||
157
personal-brand-engine/agents/content_strategist/agent.py
Normal file
157
personal-brand-engine/agents/content_strategist/agent.py
Normal 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,
|
||||
}
|
||||
@ -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
|
||||
@ -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}
|
||||
@ -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 []
|
||||
5
personal-brand-engine/agents/cv_optimizer/__init__.py
Normal file
5
personal-brand-engine/agents/cv_optimizer/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""CV Optimizer agent -- enhances and generates professional CVs."""
|
||||
|
||||
from agents.cv_optimizer.agent import CVOptimizerAgent
|
||||
|
||||
__all__ = ["CVOptimizerAgent"]
|
||||
132
personal-brand-engine/agents/cv_optimizer/agent.py
Normal file
132
personal-brand-engine/agents/cv_optimizer/agent.py
Normal 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,
|
||||
}
|
||||
186
personal-brand-engine/agents/cv_optimizer/formatter.py
Normal file
186
personal-brand-engine/agents/cv_optimizer/formatter.py
Normal 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", []),
|
||||
}
|
||||
@ -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 }} – الحالي</span>
|
||||
</div>
|
||||
<div class="exp-company">{{ current_company }} — {{ 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 }} — {{ 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 }} — {{ 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>
|
||||
@ -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 }} – Present</span>
|
||||
</div>
|
||||
<div class="exp-company">{{ current_company }} — {{ 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 & Volunteering</h2>
|
||||
{% for entry in leadership %}
|
||||
<div class="exp-item">
|
||||
<div class="exp-header">
|
||||
<span class="role">{{ entry.role }} — {{ 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 }} — {{ 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 & 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>
|
||||
164
personal-brand-engine/agents/cv_optimizer/updater.py
Normal file
164
personal-brand-engine/agents/cv_optimizer/updater.py
Normal 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": [],
|
||||
}
|
||||
5
personal-brand-engine/agents/email/__init__.py
Normal file
5
personal-brand-engine/agents/email/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Email management agent for Sami Assiri's inbox."""
|
||||
|
||||
from agents.email.agent import EmailAgent
|
||||
|
||||
__all__ = ["EmailAgent"]
|
||||
323
personal-brand-engine/agents/email/agent.py
Normal file
323
personal-brand-engine/agents/email/agent.py
Normal 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
|
||||
107
personal-brand-engine/agents/email/classifier.py
Normal file
107
personal-brand-engine/agents/email/classifier.py
Normal 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"
|
||||
191
personal-brand-engine/agents/email/prompts/reply_templates.yaml
Normal file
191
personal-brand-engine/agents/email/prompts/reply_templates.yaml
Normal 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
|
||||
180
personal-brand-engine/agents/email/responder.py
Normal file
180
personal-brand-engine/agents/email/responder.py
Normal 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"
|
||||
)
|
||||
5
personal-brand-engine/agents/linkedin/__init__.py
Normal file
5
personal-brand-engine/agents/linkedin/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""LinkedIn automation agent for personal brand management."""
|
||||
|
||||
from agents.linkedin.agent import LinkedInAgent
|
||||
|
||||
__all__ = ["LinkedInAgent"]
|
||||
219
personal-brand-engine/agents/linkedin/agent.py
Normal file
219
personal-brand-engine/agents/linkedin/agent.py
Normal 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
|
||||
179
personal-brand-engine/agents/linkedin/content_generator.py
Normal file
179
personal-brand-engine/agents/linkedin/content_generator.py
Normal 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
|
||||
210
personal-brand-engine/agents/linkedin/engagement.py
Normal file
210
personal-brand-engine/agents/linkedin/engagement.py
Normal 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
|
||||
182
personal-brand-engine/agents/linkedin/profile_optimizer.py
Normal file
182
personal-brand-engine/agents/linkedin/profile_optimizer.py
Normal 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)
|
||||
@ -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} هو مفتاح النجاح في هذا القطاع."
|
||||
- "كلام في الصميم. قطاع الطيران في المملكة يشهد نموًا كبيرًا وهذه النوعية من النقاشات مهمة جدًا."
|
||||
@ -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 #هندسة
|
||||
@ -0,0 +1,5 @@
|
||||
"""Opportunity Scout agent -- monitors the internet for career opportunities."""
|
||||
|
||||
from agents.opportunity_scout.agent import OpportunityScoutAgent
|
||||
|
||||
__all__ = ["OpportunityScoutAgent"]
|
||||
396
personal-brand-engine/agents/opportunity_scout/agent.py
Normal file
396
personal-brand-engine/agents/opportunity_scout/agent.py
Normal 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
|
||||
327
personal-brand-engine/agents/opportunity_scout/notifier.py
Normal file
327
personal-brand-engine/agents/opportunity_scout/notifier.py
Normal 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
|
||||
363
personal-brand-engine/agents/opportunity_scout/scanners.py
Normal file
363
personal-brand-engine/agents/opportunity_scout/scanners.py
Normal 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
|
||||
151
personal-brand-engine/agents/opportunity_scout/scorer.py
Normal file
151
personal-brand-engine/agents/opportunity_scout/scorer.py
Normal 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
|
||||
5
personal-brand-engine/agents/social_media/__init__.py
Normal file
5
personal-brand-engine/agents/social_media/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""Social media automation agent for personal brand management."""
|
||||
|
||||
from agents.social_media.agent import SocialMediaAgent
|
||||
|
||||
__all__ = ["SocialMediaAgent"]
|
||||
277
personal-brand-engine/agents/social_media/agent.py
Normal file
277
personal-brand-engine/agents/social_media/agent.py
Normal 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)}")
|
||||
179
personal-brand-engine/agents/social_media/content_repurposer.py
Normal file
179
personal-brand-engine/agents/social_media/content_repurposer.py
Normal 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)}")
|
||||
@ -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"]
|
||||
150
personal-brand-engine/agents/social_media/twitter.py
Normal file
150
personal-brand-engine/agents/social_media/twitter.py
Normal 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
|
||||
5
personal-brand-engine/agents/whatsapp/__init__.py
Normal file
5
personal-brand-engine/agents/whatsapp/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""WhatsApp automation agent for personal brand management."""
|
||||
|
||||
from agents.whatsapp.agent import WhatsAppAgent
|
||||
|
||||
__all__ = ["WhatsAppAgent"]
|
||||
202
personal-brand-engine/agents/whatsapp/agent.py
Normal file
202
personal-brand-engine/agents/whatsapp/agent.py
Normal 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
|
||||
@ -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!
|
||||
220
personal-brand-engine/agents/whatsapp/responder.py
Normal file
220
personal-brand-engine/agents/whatsapp/responder.py
Normal 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))
|
||||
246
personal-brand-engine/agents/whatsapp/webhook_handler.py
Normal file
246
personal-brand-engine/agents/whatsapp/webhook_handler.py
Normal 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)
|
||||
0
personal-brand-engine/api/__init__.py
Normal file
0
personal-brand-engine/api/__init__.py
Normal file
71
personal-brand-engine/api/main.py
Normal file
71
personal-brand-engine/api/main.py
Normal 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,
|
||||
)
|
||||
0
personal-brand-engine/api/routes/__init__.py
Normal file
0
personal-brand-engine/api/routes/__init__.py
Normal file
150
personal-brand-engine/api/routes/dashboard.py
Normal file
150
personal-brand-engine/api/routes/dashboard.py
Normal 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()
|
||||
16
personal-brand-engine/api/routes/health.py
Normal file
16
personal-brand-engine/api/routes/health.py
Normal 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(),
|
||||
}
|
||||
131
personal-brand-engine/api/routes/webhooks.py
Normal file
131
personal-brand-engine/api/routes/webhooks.py
Normal 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",
|
||||
)
|
||||
0
personal-brand-engine/config/__init__.py
Normal file
0
personal-brand-engine/config/__init__.py
Normal file
166
personal-brand-engine/config/brand_profile.yaml
Normal file
166
personal-brand-engine/config/brand_profile.yaml
Normal 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"
|
||||
89
personal-brand-engine/config/content_strategy.yaml
Normal file
89
personal-brand-engine/config/content_strategy.yaml
Normal 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
|
||||
68
personal-brand-engine/config/schedule.yaml
Normal file
68
personal-brand-engine/config/schedule.yaml
Normal 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"
|
||||
109
personal-brand-engine/config/settings.py
Normal file
109
personal-brand-engine/config/settings.py
Normal 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")
|
||||
41
personal-brand-engine/docker-compose.yml
Normal file
41
personal-brand-engine/docker-compose.yml
Normal 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:
|
||||
30
personal-brand-engine/docker/Dockerfile
Normal file
30
personal-brand-engine/docker/Dockerfile
Normal 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"]
|
||||
22
personal-brand-engine/docker/supervisord.conf
Normal file
22
personal-brand-engine/docker/supervisord.conf
Normal 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"
|
||||
325
personal-brand-engine/generated_cvs/Sami_Assiri_CV_2026.html
Normal file
325
personal-brand-engine/generated_cvs/Sami_Assiri_CV_2026.html
Normal 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 & gas projects at Samsung E&A. Demonstrated leadership as SPE Alasala Chapter President,
|
||||
scaling membership from 0 to 89 active participants and generating 50,000+ organic impressions.
|
||||
Combines technical depth in security systems maintenance with strong analytical capabilities in
|
||||
Python, SQL, Power BI, and advanced project management tools.
|
||||
</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 – Present</span>
|
||||
</div>
|
||||
<div class="exp-company">METCO – 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 – May 2025</span>
|
||||
</div>
|
||||
<div class="exp-company">Samsung E&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 & 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 & Organizations</h2>
|
||||
|
||||
<div class="exp-item">
|
||||
<div class="exp-header">
|
||||
<span class="role">President – SPE Alasala Chapter</span>
|
||||
<span class="period">Sep 2024 – 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 – Elite Engineers Club</span>
|
||||
<span class="period">2024 – 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 – May 2025</span>
|
||||
</div>
|
||||
<div class="edu-institution">Alasala Colleges – Dammam, Eastern Province, Saudi Arabia</div>
|
||||
<ul class="bullets">
|
||||
<li>Best Capstone Project Award (1st of 16 teams) – developed a biodegradable, thermally insulating green composite from sunflower waste</li>
|
||||
<li>Relevant coursework: HVAC Systems, Renewable Energy, Project Planning & Control, Data Analysis & Visualization, Asset Management</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- CERTIFICATIONS -->
|
||||
<section>
|
||||
<h2 class="section-title">Certifications & Training</h2>
|
||||
<ul class="plain-list">
|
||||
<li>MV Switchgears & Modular Power Systems – Training Workshop (Aug 2025)</li>
|
||||
<li>Earthing Systems – Training Workshop (Aug 2025)</li>
|
||||
<li>KNX System Fundamentals & Building Automation – eLearning (Oct 2024)</li>
|
||||
<li>Saudi Mechanical Code (SBC 501) – Saudi Council of Engineers (Jun 2024)</li>
|
||||
<li>BIM-Oriented Sustainable Design – Autodesk (May 2024)</li>
|
||||
<li>Emergency Lighting & Central Battery Systems – ABB (Apr 2024)</li>
|
||||
<li>Low Voltage Circuit Breakers (IEC Standards) – ABB (Apr 2024)</li>
|
||||
<li>ABB E-Design Certification – ABB (May 2024)</li>
|
||||
<li>Contract & Tendering Management – PMI (Jun 2024)</li>
|
||||
<li>Organizational Effectiveness & Excellence – EFQM (May 2024)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<!-- SKILLS -->
|
||||
<section>
|
||||
<h2 class="section-title">Technical & Professional Skills</h2>
|
||||
<div class="skills-grid">
|
||||
<div class="skill-category">
|
||||
<h4>Airport Security & Engineering</h4>
|
||||
<ul>
|
||||
<li>Smiths Detection Equipment (HI-SCAN, IONSCAN 600, CTX)</li>
|
||||
<li>Preventive & Corrective Maintenance</li>
|
||||
<li>System Calibration & Quality Assurance</li>
|
||||
<li>HVAC Systems | BIM | Digital Twin</li>
|
||||
<li>Asset Management Systems</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="skill-category">
|
||||
<h4>Data & 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 & Performance Tracking</li>
|
||||
<li>Risk Modeling & Forecasting</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="skill-category">
|
||||
<h4>Project Management</h4>
|
||||
<ul>
|
||||
<li>Primavera P6 & MS Project</li>
|
||||
<li>Monte Carlo Simulation (Risk Analysis)</li>
|
||||
<li>Cost Estimation & Budget Control</li>
|
||||
<li>Stakeholder Management & Communication</li>
|
||||
<li>Resource Optimization & Strategic Execution</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="skill-category">
|
||||
<h4>Leadership & Soft Skills</h4>
|
||||
<ul>
|
||||
<li>Strategic Communication & Negotiation</li>
|
||||
<li>Cross-Functional Collaboration</li>
|
||||
<li>Organizational Design & Talent Development</li>
|
||||
<li>Event Planning & Industry Engagement</li>
|
||||
<li>Automated Reporting & ETL Pipelines</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- AWARDS -->
|
||||
<section>
|
||||
<h2 class="section-title">Awards & Recognition</h2>
|
||||
<ul class="plain-list awards-list">
|
||||
<li>Best Capstone Project Award (1st of 16 teams) – Alasala Colleges (May 2025)</li>
|
||||
<li>SPE KSA Excellence Award – Impactful contributions to student engineering development (2025)</li>
|
||||
<li>Presidential Recognition – 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 – Assistant Professor, Mechanical Engineering, Alasala Colleges</p>
|
||||
<p>Khalifa – Assistant Director of Project Management, Samsung E&A</p>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
315
personal-brand-engine/landing_page/index.html
Normal file
315
personal-brand-engine/landing_page/index.html
Normal 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">🏆</div>
|
||||
<h3>Best Capstone Project</h3>
|
||||
<p>1st of 16 teams - Alasala Colleges (2025)</p>
|
||||
</div>
|
||||
<div class="award-card">
|
||||
<div class="award-icon">⭐</div>
|
||||
<h3>SPE KSA Excellence Award</h3>
|
||||
<p>Society of Petroleum Engineers (2025)</p>
|
||||
</div>
|
||||
<div class="award-card">
|
||||
<div class="award-icon">🏅</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">© 2026 سامي العسيري. جميع الحقوق محفوظة.</span>
|
||||
<span class="en" style="display:none;">© 2026 Sami Assiri. All rights reserved.</span>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
125
personal-brand-engine/landing_page/script.js
Normal file
125
personal-brand-engine/landing_page/script.js
Normal 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();
|
||||
});
|
||||
511
personal-brand-engine/landing_page/style.css
Normal file
511
personal-brand-engine/landing_page/style.css
Normal 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;
|
||||
}
|
||||
13
personal-brand-engine/landing_page/vcard/contact.vcf
Normal file
13
personal-brand-engine/landing_page/vcard/contact.vcf
Normal 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
|
||||
3
personal-brand-engine/llm/__init__.py
Normal file
3
personal-brand-engine/llm/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .client import LLMClient, get_llm_client
|
||||
|
||||
__all__ = ["LLMClient", "get_llm_client"]
|
||||
177
personal-brand-engine/llm/client.py
Normal file
177
personal-brand-engine/llm/client.py
Normal 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
|
||||
22
personal-brand-engine/pyproject.toml
Normal file
22
personal-brand-engine/pyproject.toml
Normal 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
|
||||
46
personal-brand-engine/requirements.txt
Normal file
46
personal-brand-engine/requirements.txt
Normal 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
|
||||
0
personal-brand-engine/scheduler/__init__.py
Normal file
0
personal-brand-engine/scheduler/__init__.py
Normal file
126
personal-brand-engine/scheduler/runner.py
Normal file
126
personal-brand-engine/scheduler/runner.py
Normal 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())
|
||||
93
personal-brand-engine/scheduler/tasks.py
Normal file
93
personal-brand-engine/scheduler/tasks.py
Normal 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()
|
||||
14
personal-brand-engine/storage/__init__.py
Normal file
14
personal-brand-engine/storage/__init__.py
Normal 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",
|
||||
]
|
||||
81
personal-brand-engine/storage/database.py
Normal file
81
personal-brand-engine/storage/database.py
Normal 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()
|
||||
193
personal-brand-engine/storage/models.py
Normal file
193
personal-brand-engine/storage/models.py
Normal 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}>"
|
||||
0
personal-brand-engine/tests/__init__.py
Normal file
0
personal-brand-engine/tests/__init__.py
Normal file
57
personal-brand-engine/tests/test_config.py
Normal file
57
personal-brand-engine/tests/test_config.py
Normal 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 == {}
|
||||
34
personal-brand-engine/tests/test_llm_client.py
Normal file
34
personal-brand-engine/tests/test_llm_client.py
Normal 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
|
||||
63
personal-brand-engine/tests/test_models.py
Normal file
63
personal-brand-engine/tests/test_models.py
Normal 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
|
||||
0
personal-brand-engine/utils/__init__.py
Normal file
0
personal-brand-engine/utils/__init__.py
Normal file
92
personal-brand-engine/utils/logger.py
Normal file
92
personal-brand-engine/utils/logger.py
Normal 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
|
||||
70
personal-brand-engine/utils/notifications.py
Normal file
70
personal-brand-engine/utils/notifications.py
Normal 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)
|
||||
128
personal-brand-engine/utils/rate_limiter.py
Normal file
128
personal-brand-engine/utils/rate_limiter.py
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user