system-prompts-and-models-o.../salesflow-saas/backend/app/services/strategic_deals/acquisition_scouting.py
Claude aeedd20081
feat: Complete Layer 3 — Strategic Growth OS (final layer)
All 4 layers of Dealix are now fully built:

Strategic Growth OS (2,715 lines):
- acquisition_scouting.py (494): Target sourcing, scoring, Arabic briefs, watchlist
- ecosystem_mapper.py (568): Partner landscape, gap detection, cluster analysis
- strategic_simulator.py (596): 7 scenario types with financial modeling, sensitivity
- roi_engine.py (484): NPV-based ROI, Saudi market benchmarks, annual projection
- portfolio_intelligence.py (573): Vertical analysis, pattern detection, quarterly reports

Updated __init__.py with 12 new exports.

PROJECT STATUS: 100% COMPLETE
- Layer 0: Core Platform 
- Layer 1: Sales OS 
- Layer 2: Deal Exchange OS 
- Layer 3: Strategic Growth OS 
- Frontend: 37 components 
- Governance: Full stack 

https://claude.ai/code/session_01LsnvBa7HwF5hs99VZbgLGj
2026-04-11 10:56:56 +00:00

495 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Acquisition Scouting Engine — AI-powered M&A target identification for Saudi B2B.
محرك استكشاف الاستحواذ: تحديد أهداف الاندماج والاستحواذ بالذكاء الاصطناعي للسوق السعودي
"""
import json
import logging
import uuid
from datetime import datetime, timezone
from typing import Optional
from pydantic import BaseModel, Field
from sqlalchemy import select, and_, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.strategic_deal import CompanyProfile
from app.services.llm.provider import get_llm
logger = logging.getLogger("dealix.strategic_deals.acquisition_scouting")
# ── Saudi sector synergy map ────────────────────────────────────────────────
SECTOR_SYNERGY = {
"technology": ["consulting", "telecom", "media", "education"],
"construction": ["real_estate", "manufacturing", "energy", "logistics"],
"real_estate": ["construction", "finance", "tourism"],
"retail": ["wholesale", "logistics", "food_beverage", "marketing"],
"healthcare": ["technology", "manufacturing", "consulting"],
"finance": ["technology", "real_estate", "consulting"],
"logistics": ["retail", "wholesale", "manufacturing", "food_beverage"],
"energy": ["construction", "manufacturing", "technology"],
"food_beverage": ["logistics", "retail", "agriculture", "tourism"],
"consulting": ["technology", "finance", "healthcare", "education"],
"manufacturing": ["construction", "wholesale", "logistics", "energy"],
"marketing": ["technology", "media", "retail", "telecom"],
"telecom": ["technology", "media", "consulting"],
"education": ["technology", "consulting", "media"],
"tourism": ["food_beverage", "real_estate", "marketing"],
"media": ["marketing", "technology", "telecom", "tourism"],
"agriculture": ["food_beverage", "logistics", "manufacturing"],
"automotive": ["manufacturing", "logistics", "finance"],
"government": ["technology", "consulting", "construction"],
"wholesale": ["retail", "manufacturing", "logistics"],
}
# ── Valid status transitions ────────────────────────────────────────────────
VALID_STATUSES = ("scouted", "qualified", "briefed", "intro_sent", "in_discussion")
STATUS_TRANSITIONS = {
"scouted": ["qualified", "briefed"],
"qualified": ["briefed", "intro_sent"],
"briefed": ["intro_sent", "in_discussion"],
"intro_sent": ["in_discussion"],
"in_discussion": [],
}
# ── Models ──────────────────────────────────────────────────────────────────
class AcquisitionTarget(BaseModel):
"""Represents a scouted M&A target with strategic scoring."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
company_name: str
company_name_ar: str = ""
industry: str = ""
city: str = ""
strategic_fit_score: float = Field(0.0, ge=0.0, le=1.0)
market_adjacency: float = Field(0.0, ge=0.0, le=1.0)
size_fit: float = Field(0.0, ge=0.0, le=1.0)
estimated_value_sar: float = 0.0
growth_signals: list[str] = Field(default_factory=list)
risk_factors: list[str] = Field(default_factory=list)
brief: str = ""
status: str = "scouted"
tenant_id: Optional[str] = None
scouted_at: str = Field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
class Config:
json_schema_extra = {
"example": {
"company_name": "TechVision Co",
"company_name_ar": "شركة تك فيجن",
"industry": "technology",
"city": "الرياض",
"strategic_fit_score": 0.85,
"market_adjacency": 0.7,
"size_fit": 0.6,
"estimated_value_sar": 5_000_000.0,
"growth_signals": ["نمو الإيرادات ٣٠٪ سنوياً", "توسع في ٣ مدن جديدة"],
"risk_factors": ["اعتماد كبير على عميل واحد"],
"status": "scouted",
}
}
class AcquisitionCriteria(BaseModel):
"""Filter criteria for scouting acquisition targets."""
industries: list[str] = Field(default_factory=list)
cities: list[str] = Field(default_factory=list)
min_revenue_sar: float = 0.0
max_revenue_sar: float = 0.0
min_employees: int = 0
max_employees: int = 0
required_capabilities: list[str] = Field(default_factory=list)
exclude_ids: list[str] = Field(default_factory=list)
min_strategic_fit: float = 0.3
# ── Engine ──────────────────────────────────────────────────────────────────
class AcquisitionScoutingEngine:
"""
AI-powered acquisition target scouting engine.
Identifies, scores, and briefs potential M&A targets in the Saudi market.
محرك استكشاف أهداف الاستحواذ بالذكاء الاصطناعي — يحدد ويقيّم ويلخص أهداف الاندماج والاستحواذ
"""
def __init__(self):
self.llm = get_llm()
self._watchlists: dict[str, list[AcquisitionTarget]] = {}
# ── Scout ───────────────────────────────────────────────────────────────
async def scout(
self,
criteria: AcquisitionCriteria,
tenant_id: str,
db: AsyncSession,
) -> list[AcquisitionTarget]:
"""
Scout potential acquisition targets matching criteria from the company pool.
استكشاف أهداف الاستحواذ المحتملة التي تطابق المعايير من قاعدة الشركات
"""
query = select(CompanyProfile).where(
CompanyProfile.tenant_id == tenant_id,
CompanyProfile.is_verified == True, # noqa: E712
)
result = await db.execute(query)
all_profiles = result.scalars().all()
if not all_profiles:
logger.info("No company profiles found for tenant %s", tenant_id)
return []
targets: list[AcquisitionTarget] = []
for profile in all_profiles:
if str(profile.id) in criteria.exclude_ids:
continue
# Industry filter
if criteria.industries and (profile.industry or "") not in criteria.industries:
adjacent = set()
for ind in criteria.industries:
adjacent.update(SECTOR_SYNERGY.get(ind, []))
if (profile.industry or "") not in adjacent:
continue
# City filter
if criteria.cities and (profile.region or "") not in criteria.cities:
continue
# Revenue filter
revenue = float(profile.annual_revenue_sar or 0)
if criteria.min_revenue_sar > 0 and revenue < criteria.min_revenue_sar:
continue
if criteria.max_revenue_sar > 0 and revenue > criteria.max_revenue_sar:
continue
# Employee count filter
emp = int(profile.employee_count or 0)
if criteria.min_employees > 0 and emp < criteria.min_employees:
continue
if criteria.max_employees > 0 and emp > criteria.max_employees:
continue
# Capability filter
if criteria.required_capabilities:
profile_caps = {c.lower() for c in (profile.capabilities or [])}
required = {c.lower() for c in criteria.required_capabilities}
if not required & profile_caps:
continue
# Build raw target
target = AcquisitionTarget(
company_name=profile.company_name or "",
company_name_ar=profile.company_name_ar if hasattr(profile, "company_name_ar") else "",
industry=profile.industry or "",
city=profile.region or "",
estimated_value_sar=self._estimate_value(profile),
status="scouted",
tenant_id=tenant_id,
)
targets.append(target)
# Score all targets using LLM for strategic fit
if targets:
acquirer_profile = await self._get_acquirer_profile(tenant_id, db)
scored = []
for target in targets:
scored_target = await self.score_target(target, acquirer_profile, db)
if scored_target.strategic_fit_score >= criteria.min_strategic_fit:
scored.append(scored_target)
targets = sorted(scored, key=lambda t: t.strategic_fit_score, reverse=True)
# Persist to watchlist
self._watchlists.setdefault(tenant_id, []).extend(targets)
logger.info(
"Scouted %d acquisition targets for tenant %s (from %d candidates)",
len(targets), tenant_id, len(all_profiles),
)
return targets
# ── Score Target ────────────────────────────────────────────────────────
async def score_target(
self,
target: AcquisitionTarget,
acquirer_twin: Optional[CompanyProfile],
db: AsyncSession,
) -> AcquisitionTarget:
"""
Score a target against the acquirer's strategic profile.
تقييم هدف الاستحواذ مقابل الملف الاستراتيجي للمستحوذ
"""
acquirer_industry = acquirer_twin.industry if acquirer_twin else "unknown"
acquirer_caps = acquirer_twin.capabilities if acquirer_twin else []
acquirer_revenue = float(acquirer_twin.annual_revenue_sar or 0) if acquirer_twin else 0
acquirer_name = acquirer_twin.company_name if acquirer_twin else "الشركة المستحوذة"
# Market adjacency score
target.market_adjacency = self._compute_adjacency(acquirer_industry, target.industry)
# Size fit — ideal ratio between 0.05 and 0.5 of acquirer
if acquirer_revenue > 0 and target.estimated_value_sar > 0:
ratio = target.estimated_value_sar / acquirer_revenue
if 0.05 <= ratio <= 0.5:
target.size_fit = 1.0
elif 0.01 <= ratio < 0.05 or 0.5 < ratio <= 1.0:
target.size_fit = 0.6
else:
target.size_fit = 0.3
else:
target.size_fit = 0.5
# Use LLM for strategic fit, growth signals, and risk factors
context = f"""المستحوذ: {acquirer_name}
قطاع المستحوذ: {acquirer_industry}
قدرات المستحوذ: {', '.join(acquirer_caps or ['غير محدد'])}
إيرادات المستحوذ: {acquirer_revenue:,.0f} ريال
الهدف: {target.company_name}
قطاع الهدف: {target.industry}
مدينة الهدف: {target.city}
القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال"""
system_prompt = """أنت مستشار اندماج واستحواذ سعودي خبير. قيّم هذا الهدف الاستحواذي.
Return JSON:
{
"strategic_fit_score": 0.0 to 1.0,
"growth_signals": ["إشارة نمو ١ بالعربي", "إشارة نمو ٢"],
"risk_factors": ["عامل خطر ١ بالعربي", "عامل خطر ٢"],
"rationale_ar": "سبب التوصية بالعربي"
}"""
try:
llm_response = await self.llm.complete(
system_prompt=system_prompt,
user_message=context,
json_mode=True,
temperature=0.3,
)
result = llm_response.parse_json() or {}
target.strategic_fit_score = min(1.0, max(0.0, float(result.get("strategic_fit_score", 0.5))))
target.growth_signals = result.get("growth_signals", [])
target.risk_factors = result.get("risk_factors", [])
# Blend LLM fit with computed adjacency and size fit
blended = (
target.strategic_fit_score * 0.5
+ target.market_adjacency * 0.3
+ target.size_fit * 0.2
)
target.strategic_fit_score = round(min(1.0, blended), 4)
except Exception as exc:
logger.warning("LLM scoring failed for target %s: %s", target.company_name, exc)
target.strategic_fit_score = round(
target.market_adjacency * 0.6 + target.size_fit * 0.4, 4
)
target.growth_signals = ["لم يتم التحليل — يتطلب مراجعة يدوية"]
target.risk_factors = ["لم يتم التحليل — يتطلب مراجعة يدوية"]
logger.info(
"Scored target %s: fit=%.2f adjacency=%.2f size=%.2f",
target.company_name, target.strategic_fit_score,
target.market_adjacency, target.size_fit,
)
return target
# ── Generate Brief ──────────────────────────────────────────────────────
async def generate_brief(
self,
target_id: str,
db: AsyncSession,
) -> str:
"""
Generate a detailed Arabic acquisition brief for a scouted target.
إنشاء ملخص استحواذ تفصيلي بالعربي لهدف مُستكشَف
"""
target = self._find_target(target_id)
if not target:
raise ValueError(f"Target {target_id} not found in watchlist")
context = f"""الشركة المستهدفة: {target.company_name} ({target.company_name_ar})
القطاع: {target.industry}
المدينة: {target.city}
القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال سعودي
درجة الملاءمة الاستراتيجية: {target.strategic_fit_score:.0%}
درجة القرب السوقي: {target.market_adjacency:.0%}
ملاءمة الحجم: {target.size_fit:.0%}
إشارات النمو: {', '.join(target.growth_signals)}
عوامل الخطر: {', '.join(target.risk_factors)}"""
system_prompt = """أنت مستشار اندماج واستحواذ سعودي. اكتب ملخص استحواذ تنفيذي شامل بالعربي.
يجب أن يشمل الملخص:
١. نظرة عامة على الشركة المستهدفة
٢. المبرر الاستراتيجي للاستحواذ
٣. تحليل نقاط القوة والفرص
٤. المخاطر الرئيسية واستراتيجيات التخفيف
٥. التقييم المبدئي والهيكل المقترح
٦. الخطوات التالية الموصى بها
٧. الجدول الزمني المتوقع
اكتب الملخص بأسلوب تنفيذي رسمي مناسب لعرضه على مجلس الإدارة."""
try:
llm_response = await self.llm.complete(
system_prompt=system_prompt,
user_message=context,
temperature=0.3,
)
brief_text = llm_response.content.strip()
except Exception as exc:
logger.error("Brief generation failed for target %s: %s", target_id, exc)
brief_text = (
f"ملخص استحواذ — {target.company_name}\n"
f"القطاع: {target.industry} | المدينة: {target.city}\n"
f"القيمة التقديرية: {target.estimated_value_sar:,.0f} ريال\n"
f"درجة الملاءمة: {target.strategic_fit_score:.0%}\n"
f"إشارات النمو: {', '.join(target.growth_signals)}\n"
f"عوامل الخطر: {', '.join(target.risk_factors)}\n"
f"الحالة: يتطلب تحليل يدوي إضافي"
)
target.brief = brief_text
target.status = "briefed"
logger.info("Generated acquisition brief for target %s", target_id)
return brief_text
# ── Get Watchlist ───────────────────────────────────────────────────────
async def get_watchlist(
self,
tenant_id: str,
db: AsyncSession,
) -> list[AcquisitionTarget]:
"""
Retrieve the current acquisition watchlist for a tenant.
استرجاع قائمة مراقبة الاستحواذ الحالية للمستأجر
"""
watchlist = self._watchlists.get(tenant_id, [])
logger.info("Retrieved watchlist for tenant %s: %d targets", tenant_id, len(watchlist))
return sorted(watchlist, key=lambda t: t.strategic_fit_score, reverse=True)
# ── Update Status ───────────────────────────────────────────────────────
async def update_status(
self,
target_id: str,
status: str,
db: AsyncSession,
) -> AcquisitionTarget:
"""
Advance a target through the acquisition pipeline.
تقديم هدف عبر مسار الاستحواذ
"""
if status not in VALID_STATUSES:
raise ValueError(
f"Invalid status '{status}'. Must be one of: {', '.join(VALID_STATUSES)}"
)
target = self._find_target(target_id)
if not target:
raise ValueError(f"Target {target_id} not found in watchlist")
allowed = STATUS_TRANSITIONS.get(target.status, [])
if status != target.status and status not in allowed:
raise ValueError(
f"Cannot transition from '{target.status}' to '{status}'. "
f"Allowed transitions: {', '.join(allowed) if allowed else 'none (terminal state)'}"
)
old_status = target.status
target.status = status
logger.info(
"Updated target %s status: %s -> %s",
target_id, old_status, status,
)
return target
# ── Private Helpers ─────────────────────────────────────────────────────
def _compute_adjacency(self, acquirer_industry: str, target_industry: str) -> float:
"""Compute market adjacency between two industries."""
if not acquirer_industry or not target_industry:
return 0.3
if acquirer_industry == target_industry:
return 1.0
synergies = SECTOR_SYNERGY.get(acquirer_industry, [])
if target_industry in synergies:
return 0.7
# Check reverse
reverse = SECTOR_SYNERGY.get(target_industry, [])
if acquirer_industry in reverse:
return 0.6
return 0.2
def _estimate_value(self, profile: CompanyProfile) -> float:
"""Rough valuation heuristic: revenue * multiplier based on industry."""
revenue = float(profile.annual_revenue_sar or 0)
if revenue <= 0:
emp = int(profile.employee_count or 0)
revenue = emp * 120_000 # SAR 120k per employee as a rough proxy
multipliers = {
"technology": 5.0,
"healthcare": 4.0,
"finance": 3.5,
"consulting": 3.0,
"education": 3.0,
"media": 3.0,
"telecom": 3.5,
"retail": 2.0,
"wholesale": 1.5,
"construction": 2.0,
"real_estate": 2.5,
"manufacturing": 2.0,
"logistics": 2.5,
"food_beverage": 2.0,
"energy": 3.0,
"marketing": 2.5,
"tourism": 2.0,
"agriculture": 1.5,
"automotive": 2.0,
"government": 1.0,
}
industry = profile.industry or ""
mult = multipliers.get(industry, 2.0)
return round(revenue * mult, 2)
async def _get_acquirer_profile(
self, tenant_id: str, db: AsyncSession,
) -> Optional[CompanyProfile]:
"""Get the primary company profile for the tenant (acquirer)."""
result = await db.execute(
select(CompanyProfile)
.where(
CompanyProfile.tenant_id == tenant_id,
CompanyProfile.is_verified == True, # noqa: E712
)
.order_by(CompanyProfile.created_at)
.limit(1)
)
return result.scalar_one_or_none()
def _find_target(self, target_id: str) -> Optional[AcquisitionTarget]:
"""Search all watchlists for a target by ID."""
for targets in self._watchlists.values():
for t in targets:
if t.id == target_id:
return t
return None