system-prompts-and-models-o.../salesflow-saas/backend/app/services/strategic_deals/ecosystem_mapper.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

569 lines
24 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.

"""
Ecosystem Mapper — Maps and analyzes B2B partner ecosystems in the Saudi market.
خريطة المنظومة: رسم وتحليل منظومة الشركاء في السوق السعودي
"""
import json
import logging
import uuid
from collections import defaultdict
from typing import Optional
from pydantic import BaseModel, Field
from sqlalchemy import select, func
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.ecosystem_mapper")
# ── Entity type definitions ─────────────────────────────────────────────────
ENTITY_TYPES = {
"agency": "وكالة",
"integrator": "مُدمج أنظمة",
"reseller": "موزع معتمد",
"consultant": "مستشار",
"distributor": "موزع",
"supplier": "مورد",
"customer": "عميل",
"competitor": "منافس",
}
LINK_TYPES = ("partner", "competitor", "vendor", "client", "referral", "subsidiary")
# ── Capability clusters for gap analysis ────────────────────────────────────
CAPABILITY_CLUSTERS = {
"تقنية": ["تطوير برمجيات", "حوسبة سحابية", "أمن سيبراني", "ذكاء اصطناعي", "تحليل بيانات"],
"تسويق": ["تسويق رقمي", "إعلان", "علاقات عامة", "إدارة محتوى", "سوشل ميديا"],
"عمليات": ["لوجستيات", "سلسلة إمداد", "إدارة مخازن", "نقل", "توزيع"],
"مالية": ["محاسبة", "تدقيق", "استشارات مالية", "تمويل", "إدارة مخاطر"],
"موارد بشرية": ["توظيف", "تدريب", "تطوير مهني", "رواتب", "شؤون موظفين"],
"قانونية": ["استشارات قانونية", "عقود", "ملكية فكرية", "امتثال", "تراخيص"],
"مبيعات": ["مبيعات مباشرة", "مبيعات قنوات", "تطوير أعمال", "إدارة حسابات", "عروض أسعار"],
}
# ── Models ──────────────────────────────────────────────────────────────────
class EcosystemEntity(BaseModel):
"""A node in the ecosystem graph representing a company or organization."""
id: str = Field(default_factory=lambda: str(uuid.uuid4()))
name: str
name_ar: str = ""
entity_type: str = "partner" # agency, integrator, reseller, consultant, distributor
industry: str = ""
city: str = ""
capabilities: list[str] = Field(default_factory=list)
relationship_strength: float = Field(0.0, ge=0.0, le=1.0)
partner_potential: float = Field(0.0, ge=0.0, le=1.0)
profile_id: Optional[str] = None
class Config:
json_schema_extra = {
"example": {
"name": "DataSphere Solutions",
"name_ar": "حلول داتا سفير",
"entity_type": "integrator",
"industry": "technology",
"city": "الرياض",
"capabilities": ["حوسبة سحابية", "أمن سيبراني"],
"relationship_strength": 0.8,
"partner_potential": 0.75,
}
}
class EcosystemLink(BaseModel):
"""An edge in the ecosystem graph representing a relationship."""
source_id: str
target_id: str
link_type: str = "partner" # partner, competitor, vendor, client
strength: float = Field(0.5, ge=0.0, le=1.0)
description_ar: str = ""
# ── Ecosystem Mapper Engine ─────────────────────────────────────────────────
class EcosystemMapper:
"""
Builds, analyzes, and visualizes B2B ecosystem maps.
Identifies gaps, suggests partners, and monitors ecosystem health.
بناء وتحليل وعرض خرائط منظومة الأعمال — تحديد الفجوات واقتراح الشركاء
"""
def __init__(self):
self.llm = get_llm()
# ── Build Map ───────────────────────────────────────────────────────────
async def build_map(
self,
tenant_id: str,
db: AsyncSession,
) -> dict:
"""
Build a complete ecosystem map from company profiles and deal history.
بناء خريطة منظومة كاملة من ملفات الشركات وتاريخ الصفقات
"""
result = await db.execute(
select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id)
)
profiles = result.scalars().all()
if not profiles:
logger.info("No profiles found for tenant %s", tenant_id)
return {"entities": [], "links": [], "stats": {}}
entities: list[EcosystemEntity] = []
links: list[EcosystemLink] = []
# Build entity nodes from profiles
entity_map: dict[str, EcosystemEntity] = {}
for profile in profiles:
entity_type = self._infer_entity_type(profile)
entity = EcosystemEntity(
name=profile.company_name or "",
name_ar=profile.company_name_ar if hasattr(profile, "company_name_ar") else "",
entity_type=entity_type,
industry=profile.industry or "",
city=profile.region or "",
capabilities=[c for c in (profile.capabilities or [])],
relationship_strength=float(profile.trust_score or 0.5),
partner_potential=0.0,
profile_id=str(profile.id),
)
entities.append(entity)
entity_map[str(profile.id)] = entity
# Infer links based on capability/need overlap and industry relationships
profile_list = list(profiles)
for i, prof_a in enumerate(profile_list):
for prof_b in profile_list[i + 1:]:
link_type, strength = self._infer_link(prof_a, prof_b)
if strength >= 0.2:
entity_a = entity_map.get(str(prof_a.id))
entity_b = entity_map.get(str(prof_b.id))
if entity_a and entity_b:
link = EcosystemLink(
source_id=entity_a.id,
target_id=entity_b.id,
link_type=link_type,
strength=round(strength, 4),
description_ar=self._link_description(
prof_a.company_name, prof_b.company_name, link_type
),
)
links.append(link)
# Compute partner potential for each entity
for entity in entities:
incoming = [lk for lk in links if lk.target_id == entity.id]
outgoing = [lk for lk in links if lk.source_id == entity.id]
partner_links = [
lk for lk in incoming + outgoing
if lk.link_type in ("partner", "referral")
]
if partner_links:
entity.partner_potential = round(
sum(lk.strength for lk in partner_links) / len(partner_links), 4
)
stats = {
"total_entities": len(entities),
"total_links": len(links),
"entity_types": defaultdict(int),
"link_types": defaultdict(int),
"avg_relationship_strength": 0.0,
}
for e in entities:
stats["entity_types"][e.entity_type] += 1
for lk in links:
stats["link_types"][lk.link_type] += 1
if entities:
stats["avg_relationship_strength"] = round(
sum(e.relationship_strength for e in entities) / len(entities), 4
)
stats["entity_types"] = dict(stats["entity_types"])
stats["link_types"] = dict(stats["link_types"])
logger.info(
"Built ecosystem map for tenant %s: %d entities, %d links",
tenant_id, len(entities), len(links),
)
return {
"entities": [e.model_dump() for e in entities],
"links": [lk.model_dump() for lk in links],
"stats": stats,
}
# ── Find Gaps ───────────────────────────────────────────────────────────
async def find_gaps(
self,
tenant_id: str,
db: AsyncSession,
) -> list[dict]:
"""
Identify underserved areas in the ecosystem where partners are missing.
تحديد المناطق غير المخدومة في المنظومة حيث ينقص الشركاء
"""
result = await db.execute(
select(CompanyProfile).where(CompanyProfile.tenant_id == tenant_id)
)
profiles = result.scalars().all()
if not profiles:
return []
# Collect all capabilities and all needs across the ecosystem
all_capabilities: set[str] = set()
all_needs: set[str] = set()
for profile in profiles:
for cap in (profile.capabilities or []):
all_capabilities.add(cap.lower().strip())
for need in (profile.needs or []):
all_needs.add(need.lower().strip())
# Gaps: needs that no one in the ecosystem can fulfill
unmet_needs = all_needs - all_capabilities
# Cluster-level gaps: entire capability clusters with low coverage
cluster_gaps: list[dict] = []
for cluster_name, cluster_caps in CAPABILITY_CLUSTERS.items():
cluster_lower = {c.lower() for c in cluster_caps}
covered = cluster_lower & all_capabilities
coverage = len(covered) / len(cluster_lower) if cluster_lower else 0
if coverage < 0.3:
cluster_gaps.append({
"gap_type": "cluster",
"cluster_name_ar": cluster_name,
"coverage": round(coverage, 4),
"missing_capabilities": list(cluster_lower - all_capabilities),
"recommendation_ar": f"المنظومة تفتقر لشركاء في مجال {cluster_name} — التغطية {coverage:.0%} فقط",
})
# Individual unmet needs
individual_gaps = [
{
"gap_type": "unmet_need",
"need": need,
"recommendation_ar": f"لا يوجد شريك يقدم: {need}",
}
for need in sorted(unmet_needs)[:20]
]
gaps = cluster_gaps + individual_gaps
logger.info(
"Found %d ecosystem gaps for tenant %s (%d cluster, %d individual)",
len(gaps), tenant_id, len(cluster_gaps), len(individual_gaps),
)
return gaps
# ── Suggest Partners ────────────────────────────────────────────────────
async def suggest_partners(
self,
gap_type: str,
tenant_id: str,
db: AsyncSession,
) -> list[EcosystemEntity]:
"""
Suggest potential partners to fill an ecosystem gap.
اقتراح شركاء محتملين لسد فجوة في المنظومة
"""
gaps = await self.find_gaps(tenant_id, db)
matching_gaps = [g for g in gaps if g.get("gap_type") == gap_type]
if not matching_gaps:
matching_gaps = gaps[:3]
gap_summary = json.dumps(matching_gaps[:5], ensure_ascii=False)
context = f"""فجوات المنظومة:
{gap_summary}
نوع الفجوة المطلوب: {gap_type}"""
system_prompt = """أنت مستشار تطوير أعمال سعودي. بناءً على فجوات المنظومة، اقترح شركاء محتملين.
Return JSON:
{
"suggestions": [
{
"name": "اسم النوع المقترح بالإنجليزي",
"name_ar": "اسم النوع المقترح بالعربي",
"entity_type": "agency/integrator/reseller/consultant/distributor",
"industry": "القطاع",
"capabilities": ["قدرة ١", "قدرة ٢"],
"rationale_ar": "سبب الاقتراح بالعربي",
"partner_potential": 0.0 to 1.0
}
]
}"""
try:
llm_response = await self.llm.complete(
system_prompt=system_prompt,
user_message=context,
json_mode=True,
temperature=0.4,
)
result = llm_response.parse_json() or {}
suggestions_data = result.get("suggestions", [])
except Exception as exc:
logger.warning("LLM partner suggestion failed: %s", exc)
suggestions_data = [
{
"name": f"Partner for {gap_type}",
"name_ar": f"شريك لسد فجوة {gap_type}",
"entity_type": "consultant",
"industry": "consulting",
"capabilities": [g.get("need", "") for g in matching_gaps if g.get("need")],
"rationale_ar": "اقتراح تلقائي بناءً على الفجوات المكتشفة",
"partner_potential": 0.5,
}
]
entities: list[EcosystemEntity] = []
for s in suggestions_data:
entity = EcosystemEntity(
name=s.get("name", ""),
name_ar=s.get("name_ar", ""),
entity_type=s.get("entity_type", "consultant"),
industry=s.get("industry", ""),
capabilities=s.get("capabilities", []),
relationship_strength=0.0,
partner_potential=min(1.0, max(0.0, float(s.get("partner_potential", 0.5)))),
)
entities.append(entity)
logger.info(
"Suggested %d partners for gap '%s' in tenant %s",
len(entities), gap_type, tenant_id,
)
return entities
# ── Get Clusters ────────────────────────────────────────────────────────
async def get_clusters(
self,
tenant_id: str,
db: AsyncSession,
) -> list[dict]:
"""
Identify clusters of related entities in the ecosystem.
تحديد تجمعات الكيانات المترابطة في المنظومة
"""
eco_map = await self.build_map(tenant_id, db)
entities = eco_map.get("entities", [])
links = eco_map.get("links", [])
if not entities:
return []
# Group entities by industry
industry_groups: dict[str, list[dict]] = defaultdict(list)
for entity in entities:
industry_groups[entity.get("industry", "other")].append(entity)
clusters: list[dict] = []
for industry, members in industry_groups.items():
if not members:
continue
member_ids = {m["id"] for m in members}
internal_links = [
lk for lk in links
if lk.get("source_id") in member_ids and lk.get("target_id") in member_ids
]
external_links = [
lk for lk in links
if (lk.get("source_id") in member_ids) != (lk.get("target_id") in member_ids)
]
avg_strength = 0.0
if internal_links:
avg_strength = sum(lk.get("strength", 0) for lk in internal_links) / len(internal_links)
all_caps: set[str] = set()
for m in members:
all_caps.update(m.get("capabilities", []))
clusters.append({
"cluster_name": industry,
"cluster_name_ar": ENTITY_TYPES.get(industry, industry),
"member_count": len(members),
"internal_links": len(internal_links),
"external_links": len(external_links),
"avg_internal_strength": round(avg_strength, 4),
"capabilities": sorted(all_caps),
"members": [{"id": m["id"], "name": m["name"]} for m in members],
})
clusters.sort(key=lambda c: c["member_count"], reverse=True)
logger.info("Identified %d clusters for tenant %s", len(clusters), tenant_id)
return clusters
# ── Ecosystem Health ────────────────────────────────────────────────────
async def get_ecosystem_health(
self,
tenant_id: str,
db: AsyncSession,
) -> dict:
"""
Calculate ecosystem health metrics: coverage, concentration, resilience.
حساب مؤشرات صحة المنظومة: التغطية والتركيز والمرونة
"""
eco_map = await self.build_map(tenant_id, db)
entities = eco_map.get("entities", [])
links = eco_map.get("links", [])
gaps = await self.find_gaps(tenant_id, db)
total_entities = len(entities)
total_links = len(links)
total_gaps = len(gaps)
if total_entities == 0:
return {
"overall_score": 0.0,
"coverage": 0.0,
"concentration_risk": 1.0,
"resilience": 0.0,
"diversity": 0.0,
"gap_count": 0,
"recommendations_ar": ["لا توجد بيانات كافية لتحليل صحة المنظومة"],
}
# Coverage: ratio of cluster gaps (lower = better coverage)
cluster_gaps = [g for g in gaps if g.get("gap_type") == "cluster"]
total_clusters = len(CAPABILITY_CLUSTERS)
coverage = 1.0 - (len(cluster_gaps) / total_clusters) if total_clusters > 0 else 0.0
# Concentration risk: how dependent the ecosystem is on few entities
type_counts = defaultdict(int)
for e in entities:
type_counts[e.get("entity_type", "unknown")] += 1
max_type_share = max(type_counts.values()) / total_entities if total_entities > 0 else 1.0
concentration_risk = max_type_share
# Diversity: number of distinct entity types / total possible
diversity = len(type_counts) / len(ENTITY_TYPES) if ENTITY_TYPES else 0.0
# Resilience: avg links per entity (more links = more resilient)
avg_links = total_links / total_entities if total_entities > 0 else 0.0
resilience = min(1.0, avg_links / 3.0) # 3+ links per entity = max resilience
# Overall health score
overall = round(
coverage * 0.35
+ (1.0 - concentration_risk) * 0.25
+ resilience * 0.25
+ diversity * 0.15,
4,
)
# Generate recommendations
recommendations_ar: list[str] = []
if coverage < 0.5:
recommendations_ar.append("تغطية المنظومة ضعيفة — يُنصح بإضافة شركاء في القطاعات الناقصة")
if concentration_risk > 0.6:
recommendations_ar.append("تركيز عالٍ على نوع واحد من الشركاء — يُنصح بالتنويع")
if resilience < 0.4:
recommendations_ar.append("مرونة المنظومة منخفضة — يُنصح بتعزيز الروابط بين الشركاء")
if diversity < 0.5:
recommendations_ar.append("تنوع أنواع الشركاء محدود — يُنصح بإضافة أنواع جديدة")
if total_gaps > 10:
recommendations_ar.append(f"يوجد {total_gaps} فجوة في المنظومة — يُنصح بمعالجة الفجوات الحرجة أولاً")
if not recommendations_ar:
recommendations_ar.append("المنظومة في حالة صحية جيدة — استمر في المراقبة الدورية")
health = {
"overall_score": overall,
"coverage": round(coverage, 4),
"concentration_risk": round(concentration_risk, 4),
"resilience": round(resilience, 4),
"diversity": round(diversity, 4),
"gap_count": total_gaps,
"total_entities": total_entities,
"total_links": total_links,
"entity_type_distribution": dict(type_counts),
"recommendations_ar": recommendations_ar,
}
logger.info(
"Ecosystem health for tenant %s: overall=%.2f coverage=%.2f risk=%.2f",
tenant_id, overall, coverage, concentration_risk,
)
return health
# ── Private Helpers ─────────────────────────────────────────────────────
def _infer_entity_type(self, profile: CompanyProfile) -> str:
"""Infer entity type from company profile characteristics."""
caps = {c.lower() for c in (profile.capabilities or [])}
industry = (profile.industry or "").lower()
if industry == "consulting" or "استشارات" in caps:
return "consultant"
if "توزيع" in caps or "distribution" in industry:
return "distributor"
if "تكامل" in caps or "integration" in industry or "تكامل أنظمة" in caps:
return "integrator"
if "إعادة بيع" in caps or "reselling" in industry:
return "reseller"
if industry in ("marketing", "media") or "تسويق" in caps:
return "agency"
return "partner"
def _infer_link(
self, prof_a: CompanyProfile, prof_b: CompanyProfile,
) -> tuple[str, float]:
"""Infer the link type and strength between two profiles."""
caps_a = {c.lower() for c in (prof_a.capabilities or [])}
caps_b = {c.lower() for c in (prof_b.capabilities or [])}
needs_a = {n.lower() for n in (prof_a.needs or [])}
needs_b = {n.lower() for n in (prof_b.needs or [])}
# Check if they are in the same industry (potential competitors)
same_industry = (prof_a.industry or "") == (prof_b.industry or "") and prof_a.industry
# Check vendor/client: A offers what B needs
a_serves_b = len(caps_a & needs_b)
b_serves_a = len(caps_b & needs_a)
if a_serves_b > 0 and b_serves_a > 0:
# Mutual exchange = partnership
strength = min(1.0, (a_serves_b + b_serves_a) / max(len(needs_a | needs_b), 1) * 2)
return "partner", round(strength, 4)
elif a_serves_b > 0:
strength = min(1.0, a_serves_b / max(len(needs_b), 1))
return "vendor", round(strength, 4)
elif b_serves_a > 0:
strength = min(1.0, b_serves_a / max(len(needs_a), 1))
return "client", round(strength, 4)
elif same_industry and caps_a & caps_b:
overlap = len(caps_a & caps_b) / max(len(caps_a | caps_b), 1)
return "competitor", round(overlap, 4)
else:
return "partner", 0.1
def _link_description(self, name_a: str, name_b: str, link_type: str) -> str:
"""Generate Arabic description for a link."""
descriptions = {
"partner": f"{name_a} و{name_b} شركاء محتملون",
"competitor": f"{name_a} و{name_b} في نفس المجال التنافسي",
"vendor": f"{name_a} مورد محتمل لـ{name_b}",
"client": f"{name_a} عميل محتمل لـ{name_b}",
"referral": f"{name_a} و{name_b} في شبكة إحالات مشتركة",
}
return descriptions.get(link_type, f"علاقة بين {name_a} و{name_b}")