feat: Complete code integration of modules (#18)
The complete code integration is done. Co-authored-by: Sagnik <sagnik7896@gmail.com> Reviewed-on: #18
This commit was merged in pull request #18.
This commit is contained in:
@@ -11,14 +11,23 @@ Routes:
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import Any
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.services.ad_network_service import (
|
||||
AdInsight,
|
||||
BidStrategyUpdate,
|
||||
BudgetUpdate,
|
||||
Platform,
|
||||
ad_network_service,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
@@ -91,6 +100,7 @@ def _sha256_hash(value: str) -> str:
|
||||
|
||||
class CampaignCreateRequest(BaseModel):
|
||||
name: str = Field(..., description="Campaign display name")
|
||||
platform: Platform = Field(default=Platform.META, description="Target ad network platform")
|
||||
objective: str = Field("OUTCOME_LEADS", description="Meta campaign objective enum")
|
||||
budget_daily: int = Field(..., gt=0, description="Daily budget in cents (AED × 100)")
|
||||
status: str = Field("PAUSED", description="Initial campaign status — start PAUSED for review")
|
||||
@@ -121,9 +131,55 @@ class MetaAuthRequest(BaseModel):
|
||||
short_lived_token: str = Field(..., description="Short-lived user access token from Meta OAuth")
|
||||
|
||||
|
||||
@router.get("/campaigns", summary="List unified campaign summaries for the Catalyst marketing tab")
|
||||
async def list_campaigns(platform: Platform | None = Query(default=None)) -> dict:
|
||||
campaigns = await ad_network_service.list_campaigns(platform=platform)
|
||||
insights = await ad_network_service.get_insights(platform=platform, days=7)
|
||||
rollup: dict[str, dict[str, float]] = {}
|
||||
for insight in insights:
|
||||
insight_campaign_id = insight.campaign_id if isinstance(insight, AdInsight) else insight.get("campaign_id")
|
||||
if not insight_campaign_id:
|
||||
continue
|
||||
spent = insight.spend if isinstance(insight, AdInsight) else float(insight.get("spend", 0))
|
||||
impressions = insight.impressions if isinstance(insight, AdInsight) else int(insight.get("impressions", 0))
|
||||
clicks = insight.clicks if isinstance(insight, AdInsight) else int(insight.get("clicks", 0))
|
||||
conversions = insight.conversions if isinstance(insight, AdInsight) else int(insight.get("conversions", 0))
|
||||
slot = rollup.setdefault(
|
||||
insight_campaign_id,
|
||||
{
|
||||
"spent": 0.0,
|
||||
"impressions": 0.0,
|
||||
"clicks": 0.0,
|
||||
"conversions": 0.0,
|
||||
},
|
||||
)
|
||||
slot["spent"] += spent
|
||||
slot["impressions"] += impressions
|
||||
slot["clicks"] += clicks
|
||||
slot["conversions"] += conversions
|
||||
data = [
|
||||
{
|
||||
"id": campaign.id,
|
||||
"name": campaign.name,
|
||||
"platform": campaign.platform.value,
|
||||
"status": campaign.status.value,
|
||||
"budget": campaign.daily_budget,
|
||||
"spent": round(rollup.get(campaign.id, {}).get("spent", campaign.spent), 2),
|
||||
"impressions": int(rollup.get(campaign.id, {}).get("impressions", 0)),
|
||||
"clicks": int(rollup.get(campaign.id, {}).get("clicks", 0)),
|
||||
"conversions": int(rollup.get(campaign.id, {}).get("conversions", 0)),
|
||||
"objective": campaign.objective,
|
||||
"bid_strategy": campaign.bid_strategy,
|
||||
}
|
||||
for campaign in campaigns
|
||||
]
|
||||
source = "ad_network_service_live" if platform else "ad_network_service_unified"
|
||||
return _ok(data, meta={"count": len(data), "source": source})
|
||||
|
||||
|
||||
# ── 1. POST /campaigns/create ─────────────────────────────────────────────────
|
||||
|
||||
@router.post("/campaigns/create", summary="Bulk-create Meta Marketing campaigns")
|
||||
@router.post("/campaigns/create", summary="Create Meta or Google marketing campaigns")
|
||||
async def create_campaigns(
|
||||
request: Request,
|
||||
payload: CampaignCreateRequest,
|
||||
@@ -134,6 +190,25 @@ async def create_campaigns(
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
if payload.platform == Platform.GOOGLE:
|
||||
campaign_id = f"google-camp-{uuid.uuid4().hex[:8]}"
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"create",
|
||||
f"Created Google Ads campaign '{payload.name}'.",
|
||||
payload.name,
|
||||
f"Budget: AED {payload.budget_daily / 100:.0f}/day",
|
||||
)
|
||||
return _ok(
|
||||
CampaignCreateResponse(
|
||||
campaign_id=campaign_id,
|
||||
name=payload.name,
|
||||
status=payload.status,
|
||||
created_at=datetime.utcnow().isoformat(),
|
||||
).model_dump(),
|
||||
meta={"platform": "google", "mode": "simulated_or_provider_managed"},
|
||||
)
|
||||
|
||||
_api, account_id = _get_sdk()
|
||||
|
||||
try:
|
||||
@@ -226,53 +301,55 @@ async def sync_creative(
|
||||
|
||||
# ── 3. GET /insights/realtime ─────────────────────────────────────────────────
|
||||
|
||||
@router.get("/insights/realtime", summary="Poll Meta Ads Insights API")
|
||||
@router.get("/insights/realtime", summary="Poll unified Meta and Google Ads insights")
|
||||
async def get_realtime_insights(
|
||||
date_preset: str = "last_7_days",
|
||||
level: str = "adset",
|
||||
campaign_id: str | None = None,
|
||||
platform: Platform | None = Query(default=None),
|
||||
days: int = Query(default=7, ge=1, le=90),
|
||||
) -> dict:
|
||||
"""
|
||||
Polls `AdAccount.get_insights()` for CTR, CPA, spend, impressions across Ad Sets.
|
||||
Supports `date_preset` (e.g. 'today', 'last_7_days', 'last_30_days') and
|
||||
`level` ('campaign', 'adset', 'ad').
|
||||
|
||||
Requires: META_ACCESS_TOKEN, META_AD_ACCOUNT_ID
|
||||
"""
|
||||
_api, account_id = _get_sdk()
|
||||
|
||||
try:
|
||||
from facebook_business.adobjects.adaccount import AdAccount # type: ignore
|
||||
from facebook_business.adobjects.adsinsights import AdsInsights # type: ignore
|
||||
|
||||
account = AdAccount(account_id)
|
||||
fields = [
|
||||
AdsInsights.Field.campaign_name,
|
||||
AdsInsights.Field.adset_name,
|
||||
AdsInsights.Field.spend,
|
||||
AdsInsights.Field.impressions,
|
||||
AdsInsights.Field.clicks,
|
||||
AdsInsights.Field.ctr,
|
||||
AdsInsights.Field.cpp, # cost per purchase (proxy for CPA)
|
||||
AdsInsights.Field.date_start,
|
||||
AdsInsights.Field.date_stop,
|
||||
]
|
||||
params = {
|
||||
"date_preset": date_preset,
|
||||
"level": level,
|
||||
}
|
||||
insights_cursor = account.get_insights(fields=fields, params=params)
|
||||
results = [dict(row) for row in insights_cursor]
|
||||
|
||||
return _ok(results, meta={
|
||||
"account_id": account_id,
|
||||
"date_preset": date_preset,
|
||||
"level": level,
|
||||
"count": len(results),
|
||||
})
|
||||
insights = await ad_network_service.get_insights(campaign_id=campaign_id, platform=platform, days=days)
|
||||
except Exception as exc:
|
||||
logger.error("Insights fetch failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
data = [item.model_dump() if isinstance(item, AdInsight) else item for item in insights]
|
||||
return _ok(data, meta={"count": len(data), "days": days, "platform": platform.value if platform else "all"})
|
||||
|
||||
|
||||
@router.put("/budget", summary="Update Meta or Google Ads budget and campaign status")
|
||||
async def update_campaign_budget(request: Request, payload: BudgetUpdate) -> dict:
|
||||
try:
|
||||
result = await ad_network_service.update_budget(payload)
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"budget_update",
|
||||
f"Updated {payload.platform.value} budget for {payload.campaign_id}.",
|
||||
payload.campaign_id,
|
||||
f"daily={payload.daily_budget} lifetime={payload.lifetime_budget}",
|
||||
)
|
||||
return _ok(result)
|
||||
except Exception as exc:
|
||||
logger.error("Budget update failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
@router.put("/bid-strategy", summary="Apply Meta or Google Ads bid strategy changes")
|
||||
async def update_bid_strategy(request: Request, payload: BidStrategyUpdate) -> dict:
|
||||
try:
|
||||
action = await ad_network_service.update_bid_strategy(payload)
|
||||
if hasattr(request.app.state, "broadcast_live_event"):
|
||||
await request.app.state.broadcast_live_event(
|
||||
"bid_strategy_update",
|
||||
f"Updated {payload.platform.value} bid strategy for {payload.campaign_id}.",
|
||||
payload.campaign_id,
|
||||
payload.strategy,
|
||||
)
|
||||
return _ok(action.model_dump())
|
||||
except Exception as exc:
|
||||
logger.error("Bid strategy update failed: %s", exc)
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc))
|
||||
|
||||
|
||||
# ── 4. POST /audiences/lookalike ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,630 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Literal
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
crm_router = APIRouter()
|
||||
analytics_router = APIRouter()
|
||||
|
||||
_CRM_SCHEMA_CACHE_KEY = "_crm_schema_ready"
|
||||
_KANBAN_STAGE_MAP = {
|
||||
"new": "New",
|
||||
"new_inquiries": "New",
|
||||
"qualifying": "Qualifying",
|
||||
"qualified": "Qualifying",
|
||||
"site_visit": "Site Visit",
|
||||
"site visit": "Site Visit",
|
||||
"negotiation": "Negotiation",
|
||||
"closed": "Closed",
|
||||
"closed_won": "Closed",
|
||||
"closed/won": "Closed",
|
||||
}
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _normalize_stage(value: str | None) -> str:
|
||||
if not value:
|
||||
return "New"
|
||||
return _KANBAN_STAGE_MAP.get(value.strip().lower(), value.strip())
|
||||
|
||||
|
||||
def _stage_key(value: str) -> str:
|
||||
stage = _normalize_stage(value)
|
||||
return stage.lower().replace(" ", "_")
|
||||
|
||||
|
||||
def _infer_qualification(score: int | None, source: str | None, notes: str | None) -> str:
|
||||
joined = f"{source or ''} {notes or ''}".lower()
|
||||
if score is None:
|
||||
return "UNKNOWN"
|
||||
if score >= 90 or "cash" in joined or "hnw" in joined or "family office" in joined:
|
||||
return "WHALE"
|
||||
if score >= 70:
|
||||
return "POTENTIAL"
|
||||
if score >= 45:
|
||||
return "HOT"
|
||||
return "TIRE_KICKER"
|
||||
|
||||
|
||||
async def _broadcast_crm_event(request: Request, payload: dict[str, Any]) -> None:
|
||||
broadcaster = getattr(request.app.state, "broadcast_crm_event", None)
|
||||
if broadcaster is not None:
|
||||
await broadcaster(payload)
|
||||
|
||||
|
||||
async def _get_pool(request: Request):
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
if pool is None:
|
||||
raise HTTPException(status_code=503, detail="Database unavailable.")
|
||||
return pool
|
||||
|
||||
|
||||
async def _ensure_schema(request: Request) -> None:
|
||||
if getattr(request.app.state, _CRM_SCHEMA_CACHE_KEY, False):
|
||||
return
|
||||
|
||||
pool = await _get_pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
email TEXT,
|
||||
phone TEXT,
|
||||
source TEXT NOT NULL DEFAULT 'website',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
qualification TEXT NOT NULL DEFAULT 'UNKNOWN',
|
||||
score INTEGER NOT NULL DEFAULT 0,
|
||||
kanban_status TEXT NOT NULL DEFAULT 'New',
|
||||
budget TEXT NOT NULL DEFAULT '',
|
||||
unit_interest TEXT NOT NULL DEFAULT '',
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS chat_logs (
|
||||
id TEXT PRIMARY KEY,
|
||||
lead_id TEXT NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||
sender TEXT NOT NULL,
|
||||
channel TEXT NOT NULL DEFAULT 'oracle',
|
||||
content TEXT NOT NULL,
|
||||
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_leads_stage ON leads (kanban_status)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_leads_score ON leads (score DESC)")
|
||||
await conn.execute("CREATE INDEX IF NOT EXISTS idx_chat_logs_lead_id ON chat_logs (lead_id, created_at DESC)")
|
||||
|
||||
setattr(request.app.state, _CRM_SCHEMA_CACHE_KEY, True)
|
||||
|
||||
|
||||
class LeadUpsertRequest(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=200)
|
||||
email: str | None = Field(default=None, max_length=255)
|
||||
phone: str | None = Field(default=None, max_length=64)
|
||||
source: str = Field(default="website", max_length=64)
|
||||
notes: str = Field(default="", max_length=5000)
|
||||
qualification: str | None = Field(default=None, max_length=64)
|
||||
score: int = Field(default=0, ge=0, le=100)
|
||||
kanban_status: str = Field(default="New", max_length=64)
|
||||
budget: str = Field(default="", max_length=255)
|
||||
unit_interest: str = Field(default="", max_length=255)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class KanbanMoveRequest(BaseModel):
|
||||
lead_id: str
|
||||
target_status: str
|
||||
|
||||
|
||||
class ChatLogCreateRequest(BaseModel):
|
||||
lead_id: str
|
||||
sender: Literal["lead", "oracle", "system", "broker"] = "oracle"
|
||||
channel: str = Field(default="oracle", max_length=64)
|
||||
content: str = Field(..., min_length=1, max_length=8000)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SyntheticSeedRequest(BaseModel):
|
||||
count: int = Field(default=100, ge=1, le=500)
|
||||
|
||||
|
||||
def _serialize_lead(row: Any) -> dict[str, Any]:
|
||||
score = int(row["score"] or 0)
|
||||
status_label = _normalize_stage(row["kanban_status"])
|
||||
qualification = row["qualification"] or _infer_qualification(score, row.get("source"), row.get("notes"))
|
||||
return {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"email": row["email"],
|
||||
"phone": row["phone"],
|
||||
"source": row["source"],
|
||||
"notes": row["notes"],
|
||||
"qualification": qualification,
|
||||
"score": score,
|
||||
"kanban_status": status_label,
|
||||
"stage": _stage_key(status_label),
|
||||
"budget": row["budget"],
|
||||
"unit_interest": row["unit_interest"],
|
||||
"metadata": row["metadata"] or {},
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updated_at": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_chat_log(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"id": row["id"],
|
||||
"lead_id": row["lead_id"],
|
||||
"sender": row["sender"],
|
||||
"channel": row["channel"],
|
||||
"content": row["content"],
|
||||
"metadata": row["metadata"] or {},
|
||||
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
def _build_synthetic_leads(count: int) -> list[dict[str, Any]]:
|
||||
first_names = ["Amina", "Omar", "Farah", "Rayan", "Maya", "Khalid", "Noor", "Zara", "Ibrahim", "Layla"]
|
||||
last_names = ["Rahman", "Al-Farsi", "Kapoor", "Haddad", "Mehta", "Nadeem", "Shaikh", "Rao", "Wilson", "Chen"]
|
||||
sources = ["website", "walkin", "whatsapp"]
|
||||
stages = ["New", "Qualifying", "Site Visit", "Negotiation", "Closed"]
|
||||
interests = ["2BHK Marina View", "3BHK Corner Unit", "Penthouse Sky Deck", "Investment Studio", "4BHK Sea View"]
|
||||
budgets = ["AED 2.4M", "AED 4.8M", "AED 7.2M", "AED 12M", "AED 18M"]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for idx in range(count):
|
||||
score = 35 + ((idx * 7) % 61)
|
||||
if idx % 12 == 0:
|
||||
score = 94
|
||||
name = f"{first_names[idx % len(first_names)]} {last_names[(idx * 3) % len(last_names)]}"
|
||||
source = sources[idx % len(sources)]
|
||||
notes = (
|
||||
"Cash-ready HNI buyer focusing on waterfront premium inventory."
|
||||
if score >= 90
|
||||
else "Follow-up required on payment plan and amenity preferences."
|
||||
)
|
||||
rows.append(
|
||||
{
|
||||
"id": str(uuid.uuid4()),
|
||||
"name": name,
|
||||
"email": f"{name.lower().replace(' ', '.')}@synthetic.velocity.local",
|
||||
"phone": f"+9715000{idx:05d}",
|
||||
"source": source,
|
||||
"notes": notes,
|
||||
"qualification": _infer_qualification(score, source, notes).upper(),
|
||||
"score": score,
|
||||
"kanban_status": stages[idx % len(stages)],
|
||||
"budget": budgets[idx % len(budgets)],
|
||||
"unit_interest": interests[idx % len(interests)],
|
||||
"metadata": {
|
||||
"synthetic": True,
|
||||
"campaign": "verification-seed",
|
||||
"batch": "sprint1-root-integration",
|
||||
},
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
@crm_router.get("/leads")
|
||||
async def list_leads(
|
||||
request: Request,
|
||||
kanban_status: str | None = None,
|
||||
qualification: str | None = None,
|
||||
search: str | None = Query(default=None, min_length=1),
|
||||
) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if kanban_status:
|
||||
params.append(_normalize_stage(kanban_status))
|
||||
clauses.append(f"kanban_status = ${len(params)}")
|
||||
if qualification:
|
||||
params.append(qualification.upper())
|
||||
clauses.append(f"qualification = ${len(params)}")
|
||||
if search:
|
||||
params.append(f"%{search.lower()}%")
|
||||
clauses.append(f"(LOWER(name) LIKE ${len(params)} OR LOWER(COALESCE(email, '')) LIKE ${len(params)} OR LOWER(COALESCE(phone, '')) LIKE ${len(params)})")
|
||||
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
query = f"""
|
||||
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
|
||||
budget, unit_interest, metadata, created_at, updated_at
|
||||
FROM leads
|
||||
{where}
|
||||
ORDER BY score DESC, updated_at DESC, created_at DESC
|
||||
"""
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(query, *params)
|
||||
leads = [_serialize_lead(row) for row in rows]
|
||||
return {"status": "ok", "data": leads, "meta": {"count": len(leads)}}
|
||||
|
||||
|
||||
@crm_router.post("/leads", status_code=status.HTTP_201_CREATED)
|
||||
async def create_lead(request: Request, payload: LeadUpsertRequest) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
lead_id = str(uuid.uuid4())
|
||||
qualification = (payload.qualification or _infer_qualification(payload.score, payload.source, payload.notes)).upper()
|
||||
stage = _normalize_stage(payload.kanban_status)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO leads (
|
||||
id, name, email, phone, source, notes, qualification, score, kanban_status,
|
||||
budget, unit_interest, metadata, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11, $12::jsonb, NOW(), NOW()
|
||||
)
|
||||
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
|
||||
budget, unit_interest, metadata, created_at, updated_at
|
||||
""",
|
||||
lead_id,
|
||||
payload.name,
|
||||
payload.email,
|
||||
payload.phone,
|
||||
payload.source,
|
||||
payload.notes,
|
||||
qualification,
|
||||
payload.score,
|
||||
stage,
|
||||
payload.budget,
|
||||
payload.unit_interest,
|
||||
json.dumps(payload.metadata),
|
||||
)
|
||||
data = _serialize_lead(row)
|
||||
await _broadcast_crm_event(request, {"type": "lead_created", "entity": "lead", "data": data})
|
||||
return {"status": "ok", "data": data}
|
||||
|
||||
|
||||
@crm_router.put("/leads/{lead_id}")
|
||||
async def update_lead(lead_id: str, request: Request, payload: LeadUpsertRequest) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
qualification = (payload.qualification or _infer_qualification(payload.score, payload.source, payload.notes)).upper()
|
||||
stage = _normalize_stage(payload.kanban_status)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE leads
|
||||
SET name = $2,
|
||||
email = $3,
|
||||
phone = $4,
|
||||
source = $5,
|
||||
notes = $6,
|
||||
qualification = $7,
|
||||
score = $8,
|
||||
kanban_status = $9,
|
||||
budget = $10,
|
||||
unit_interest = $11,
|
||||
metadata = $12::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
|
||||
budget, unit_interest, metadata, created_at, updated_at
|
||||
""",
|
||||
lead_id,
|
||||
payload.name,
|
||||
payload.email,
|
||||
payload.phone,
|
||||
payload.source,
|
||||
payload.notes,
|
||||
qualification,
|
||||
payload.score,
|
||||
stage,
|
||||
payload.budget,
|
||||
payload.unit_interest,
|
||||
json.dumps(payload.metadata),
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
|
||||
data = _serialize_lead(row)
|
||||
await _broadcast_crm_event(request, {"type": "lead_updated", "entity": "lead", "data": data})
|
||||
return {"status": "ok", "data": data}
|
||||
|
||||
|
||||
@crm_router.delete("/leads/{lead_id}")
|
||||
async def delete_lead(lead_id: str, request: Request) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute("DELETE FROM leads WHERE id = $1", lead_id)
|
||||
if result.endswith("0"):
|
||||
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
|
||||
await _broadcast_crm_event(request, {"type": "lead_deleted", "entity": "lead", "entity_id": lead_id})
|
||||
return {"status": "ok", "data": {"id": lead_id, "deleted": True}}
|
||||
|
||||
|
||||
@crm_router.post("/leads/seed-synthetic", status_code=status.HTTP_201_CREATED)
|
||||
async def seed_synthetic_leads(request: Request, payload: SyntheticSeedRequest) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
synthetic_rows = _build_synthetic_leads(payload.count)
|
||||
inserted = 0
|
||||
chat_logs_inserted = 0
|
||||
async with pool.acquire() as conn:
|
||||
for row in synthetic_rows:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO leads (
|
||||
id, name, email, phone, source, notes, qualification, score, kanban_status,
|
||||
budget, unit_interest, metadata, created_at, updated_at
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7, $8, $9,
|
||||
$10, $11, $12::jsonb, NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (id) DO NOTHING
|
||||
""",
|
||||
row["id"],
|
||||
row["name"],
|
||||
row["email"],
|
||||
row["phone"],
|
||||
row["source"],
|
||||
row["notes"],
|
||||
row["qualification"],
|
||||
row["score"],
|
||||
row["kanban_status"],
|
||||
row["budget"],
|
||||
row["unit_interest"],
|
||||
json.dumps(row["metadata"]),
|
||||
)
|
||||
inserted += 1
|
||||
for sender, channel, content in [
|
||||
("lead", "whatsapp", f"{row['name']} asked for availability on {row['unit_interest']}."),
|
||||
("oracle", "oracle", "Oracle generated a guided follow-up based on budget, stage, and source quality."),
|
||||
]:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_logs (id, lead_id, sender, channel, content, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
|
||||
""",
|
||||
str(uuid.uuid4()),
|
||||
row["id"],
|
||||
sender,
|
||||
channel,
|
||||
content,
|
||||
json.dumps({"synthetic": True}),
|
||||
)
|
||||
chat_logs_inserted += 1
|
||||
result = {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"seeded": inserted,
|
||||
"chat_logs_seeded": chat_logs_inserted,
|
||||
"batch": "sprint1-root-integration",
|
||||
},
|
||||
}
|
||||
await _broadcast_crm_event(
|
||||
request,
|
||||
{
|
||||
"type": "crm_seeded",
|
||||
"entity": "lead_batch",
|
||||
"data": result["data"],
|
||||
},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@crm_router.get("/leads/demographics")
|
||||
async def lead_demographics(request: Request) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
source_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT source, COUNT(*)::int AS lead_count, COALESCE(AVG(score), 0)::float AS avg_score
|
||||
FROM leads
|
||||
GROUP BY source
|
||||
ORDER BY lead_count DESC, source ASC
|
||||
"""
|
||||
)
|
||||
qualification_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT qualification, COUNT(*)::int AS lead_count
|
||||
FROM leads
|
||||
GROUP BY qualification
|
||||
ORDER BY lead_count DESC, qualification ASC
|
||||
"""
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"by_source": [dict(row) for row in source_rows],
|
||||
"by_qualification": [dict(row) for row in qualification_rows],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@crm_router.get("/leads/{lead_id}")
|
||||
async def get_lead(lead_id: str, request: Request) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
|
||||
budget, unit_interest, metadata, created_at, updated_at
|
||||
FROM leads
|
||||
WHERE id = $1
|
||||
""",
|
||||
lead_id,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Lead '{lead_id}' not found.")
|
||||
return {"status": "ok", "data": _serialize_lead(row)}
|
||||
|
||||
|
||||
@crm_router.get("/chat-logs")
|
||||
async def list_chat_logs(
|
||||
request: Request,
|
||||
lead_id: str | None = None,
|
||||
channel: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
clauses: list[str] = []
|
||||
params: list[Any] = []
|
||||
if lead_id:
|
||||
params.append(lead_id)
|
||||
clauses.append(f"lead_id = ${len(params)}")
|
||||
if channel:
|
||||
params.append(channel)
|
||||
clauses.append(f"channel = ${len(params)}")
|
||||
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
|
||||
query = f"""
|
||||
SELECT id, lead_id, sender, channel, content, metadata, created_at
|
||||
FROM chat_logs
|
||||
{where}
|
||||
ORDER BY created_at DESC
|
||||
"""
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(query, *params)
|
||||
data = [_serialize_chat_log(row) for row in rows]
|
||||
return {"status": "ok", "data": data, "meta": {"count": len(data)}}
|
||||
|
||||
|
||||
@crm_router.post("/chat-logs", status_code=status.HTTP_201_CREATED)
|
||||
async def create_chat_log(request: Request, payload: ChatLogCreateRequest) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
log_id = str(uuid.uuid4())
|
||||
async with pool.acquire() as conn:
|
||||
lead = await conn.fetchrow("SELECT id FROM leads WHERE id = $1", payload.lead_id)
|
||||
if lead is None:
|
||||
raise HTTPException(status_code=404, detail=f"Lead '{payload.lead_id}' not found.")
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
INSERT INTO chat_logs (id, lead_id, sender, channel, content, metadata, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
|
||||
RETURNING id, lead_id, sender, channel, content, metadata, created_at
|
||||
""",
|
||||
log_id,
|
||||
payload.lead_id,
|
||||
payload.sender,
|
||||
payload.channel,
|
||||
payload.content,
|
||||
json.dumps(payload.metadata),
|
||||
)
|
||||
data = _serialize_chat_log(row)
|
||||
await _broadcast_crm_event(request, {"type": "chat_log_created", "entity": "chat_log", "data": data})
|
||||
return {"status": "ok", "data": data}
|
||||
|
||||
|
||||
@crm_router.get("/kanban/board")
|
||||
async def get_kanban_board(request: Request) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
ordered_stages = ["New", "Qualifying", "Site Visit", "Negotiation", "Closed"]
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, name, email, phone, source, notes, qualification, score, kanban_status,
|
||||
budget, unit_interest, metadata, created_at, updated_at
|
||||
FROM leads
|
||||
ORDER BY score DESC, updated_at DESC, created_at DESC
|
||||
"""
|
||||
)
|
||||
leads = [_serialize_lead(row) for row in rows]
|
||||
grouped = {stage: [] for stage in ordered_stages}
|
||||
for lead in leads:
|
||||
grouped.setdefault(lead["kanban_status"], []).append(lead)
|
||||
board = [
|
||||
{
|
||||
"status": stage,
|
||||
"stage": _stage_key(stage),
|
||||
"count": len(grouped.get(stage, [])),
|
||||
"items": grouped.get(stage, []),
|
||||
}
|
||||
for stage in ordered_stages
|
||||
]
|
||||
return {"status": "ok", "data": board}
|
||||
|
||||
|
||||
@crm_router.put("/kanban/move")
|
||||
async def move_kanban_card(request: Request, payload: KanbanMoveRequest) -> dict[str, Any]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
stage = _normalize_stage(payload.target_status)
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE leads
|
||||
SET kanban_status = $2,
|
||||
qualification = CASE
|
||||
WHEN score >= 90 THEN 'WHALE'
|
||||
WHEN score >= 70 THEN 'POTENTIAL'
|
||||
WHEN score >= 45 THEN 'HOT'
|
||||
ELSE qualification
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, name, email, phone, source, notes, qualification, score, kanban_status,
|
||||
budget, unit_interest, metadata, created_at, updated_at
|
||||
""",
|
||||
payload.lead_id,
|
||||
stage,
|
||||
)
|
||||
if row is None:
|
||||
raise HTTPException(status_code=404, detail=f"Lead '{payload.lead_id}' not found.")
|
||||
data = _serialize_lead(row)
|
||||
await _broadcast_crm_event(
|
||||
request,
|
||||
{
|
||||
"type": "kanban_moved",
|
||||
"entity": "lead",
|
||||
"entity_id": payload.lead_id,
|
||||
"data": data,
|
||||
},
|
||||
)
|
||||
return {"status": "ok", "data": data}
|
||||
|
||||
|
||||
@analytics_router.get("/sentiment-scatter")
|
||||
async def sentiment_scatter(request: Request) -> list[dict[str, Any]]:
|
||||
await _ensure_schema(request)
|
||||
pool = await _get_pool(request)
|
||||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, name, score, qualification, kanban_status, source, notes, updated_at
|
||||
FROM leads
|
||||
WHERE score IS NOT NULL
|
||||
ORDER BY score DESC, updated_at DESC
|
||||
"""
|
||||
)
|
||||
points: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
score = int(row["score"] or 0)
|
||||
qualification = row["qualification"] or _infer_qualification(score, row["source"], row["notes"])
|
||||
points.append(
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"sentiment_score": max(0, min(100, int(score * 0.82) + 10)),
|
||||
"response_time_ms": max(120, 10000 - (score * 55)),
|
||||
"score": score,
|
||||
"qualification": qualification,
|
||||
"kanban_status": _normalize_stage(row["kanban_status"]),
|
||||
}
|
||||
)
|
||||
return points
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from backend.oracle.action_service import oracle_action_service
|
||||
from backend.oracle.persona_service import persona_service
|
||||
from backend.services.mcp_registry import mcp_registry
|
||||
from backend.services.nemoclaw_runtime import nemoclaw_runtime
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class WorkflowPreviewRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1, max_length=4096)
|
||||
tenant_id: str = "tenant_velocity"
|
||||
actor_role: str = "sales_director"
|
||||
|
||||
|
||||
class MCPExecuteRequest(BaseModel):
|
||||
tool_name: str = Field(..., min_length=1, max_length=128)
|
||||
query: str = Field(..., min_length=1, max_length=1024)
|
||||
|
||||
|
||||
class OracleWritebackRequest(BaseModel):
|
||||
action_id: str
|
||||
tenant_id: str = "tenant_velocity"
|
||||
actor_id: str = "oracle_operator"
|
||||
target_entity_type: str = Field(..., min_length=1, max_length=64)
|
||||
target_entity_id: str = Field(..., min_length=1, max_length=128)
|
||||
action_type: str = Field(default="lead_writeback", min_length=1, max_length=128)
|
||||
writeback_payload: dict = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/health")
|
||||
async def oracle_health() -> dict:
|
||||
return {
|
||||
"status": "ok",
|
||||
"persona": await persona_service.health(),
|
||||
"mcp_tools": mcp_registry.list_tools(),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/mcp/tools")
|
||||
async def oracle_mcp_tools() -> dict:
|
||||
return {"status": "ok", "data": mcp_registry.list_tools()}
|
||||
|
||||
|
||||
@router.post("/mcp/execute")
|
||||
async def oracle_mcp_execute(request: Request, payload: MCPExecuteRequest) -> dict:
|
||||
pool = getattr(request.app.state, "db_pool", None)
|
||||
result = await mcp_registry.execute(payload.tool_name, payload.query, crm_pool=pool)
|
||||
return {"status": "ok", "data": result}
|
||||
|
||||
|
||||
@router.post("/workflow/preview")
|
||||
async def workflow_preview(payload: WorkflowPreviewRequest) -> dict:
|
||||
persona_plan = await persona_service.plan_for_prompt(
|
||||
prompt=payload.prompt,
|
||||
tenant_id=payload.tenant_id,
|
||||
actor_role=payload.actor_role,
|
||||
)
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"persona_plan": persona_plan,
|
||||
"workflow": nemoclaw_runtime.build_workflow_dispatch(
|
||||
prompt=payload.prompt,
|
||||
tenant_id=payload.tenant_id,
|
||||
actor_role=payload.actor_role,
|
||||
component_templates=persona_plan["recommendedTemplates"],
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/actions")
|
||||
async def list_oracle_actions(status: str | None = None, limit: int = 50) -> dict:
|
||||
actions = await oracle_action_service.list_actions(status=status, limit=limit)
|
||||
return {"status": "ok", "data": actions, "meta": {"count": len(actions)}}
|
||||
|
||||
|
||||
@router.get("/actions/{action_id}")
|
||||
async def get_oracle_action(action_id: str) -> dict:
|
||||
action = await oracle_action_service.get_action(action_id)
|
||||
if not action:
|
||||
raise HTTPException(status_code=404, detail=f"Oracle action '{action_id}' not found.")
|
||||
return {"status": "ok", "data": action}
|
||||
|
||||
|
||||
@router.post("/actions/writeback")
|
||||
async def apply_oracle_writeback(request: Request, payload: OracleWritebackRequest) -> dict:
|
||||
result = await oracle_action_service.apply_writeback(payload.model_dump())
|
||||
if hasattr(request.app.state, "broadcast_crm_event"):
|
||||
await request.app.state.broadcast_crm_event(
|
||||
{
|
||||
"type": "oracle_writeback",
|
||||
"entity": payload.target_entity_type,
|
||||
"entity_id": payload.target_entity_id,
|
||||
"action_id": payload.action_id,
|
||||
"payload": result["resultPayload"],
|
||||
}
|
||||
)
|
||||
return {"status": "ok", "data": result}
|
||||
|
||||
@@ -12,7 +12,7 @@ import json
|
||||
import asyncio
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime
|
||||
from datetime import UTC, datetime
|
||||
from typing import Set
|
||||
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
@@ -21,10 +21,13 @@ from fastapi.staticfiles import StaticFiles
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from backend.api.routes_catalyst import router as catalyst_router
|
||||
from backend.api.routes_crm import crm_router, analytics_router
|
||||
from backend.api.routes_oracle import router as oracle_helper_router
|
||||
from backend.auth.dependencies import (
|
||||
create_access_token, verify_password, get_current_user
|
||||
)
|
||||
from backend.db.pool import create_pool, close_pool
|
||||
from backend.oracle.router_v1 import router as oracle_v1_router
|
||||
from backend.routers.cctv import router as cctv_router
|
||||
from backend.routers.scenes import router as scenes_router
|
||||
from backend.routers.videos import router as videos_router
|
||||
@@ -86,6 +89,10 @@ if os.path.isdir(ASSET_DIR):
|
||||
# ── Routers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
app.include_router(catalyst_router, prefix="/api/catalyst", tags=["Catalyst"])
|
||||
app.include_router(crm_router, prefix="/api", tags=["CRM"])
|
||||
app.include_router(analytics_router, prefix="/api/analytics", tags=["Analytics"])
|
||||
app.include_router(oracle_helper_router, prefix="/api/oracle", tags=["Oracle"])
|
||||
app.include_router(oracle_v1_router, prefix="/api/oracle/v1", tags=["Oracle V1"])
|
||||
app.include_router(sentinel_router, prefix="/api/sentinel", tags=["Sentinel"])
|
||||
app.include_router(cctv_router, prefix="/api/cctv", tags=["CCTV"])
|
||||
app.include_router(scenes_router, prefix="/api/scenes", tags=["Scenes"])
|
||||
@@ -165,6 +172,30 @@ class _CatalystManager:
|
||||
_catalyst_mgr = _CatalystManager()
|
||||
|
||||
|
||||
class _CRMManager:
|
||||
def __init__(self) -> None:
|
||||
self.active: Set[WebSocket] = set()
|
||||
|
||||
async def connect(self, ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
self.active.add(ws)
|
||||
|
||||
def disconnect(self, ws: WebSocket) -> None:
|
||||
self.active.discard(ws)
|
||||
|
||||
async def broadcast(self, payload: dict) -> None:
|
||||
dead: Set[WebSocket] = set()
|
||||
for ws in self.active:
|
||||
try:
|
||||
await ws.send_text(json.dumps(payload))
|
||||
except Exception:
|
||||
dead.add(ws)
|
||||
self.active -= dead
|
||||
|
||||
|
||||
_crm_mgr = _CRMManager()
|
||||
|
||||
|
||||
@app.websocket("/ws/catalyst")
|
||||
async def catalyst_ws(ws: WebSocket) -> None:
|
||||
await _catalyst_mgr.connect(ws)
|
||||
@@ -176,13 +207,31 @@ async def catalyst_ws(ws: WebSocket) -> None:
|
||||
_catalyst_mgr.disconnect(ws)
|
||||
|
||||
|
||||
@app.websocket("/ws/crm")
|
||||
async def crm_ws(ws: WebSocket) -> None:
|
||||
await _crm_mgr.connect(ws)
|
||||
await _crm_mgr.broadcast(
|
||||
{
|
||||
"type": "crm_presence",
|
||||
"connected_clients": len(_crm_mgr.active),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
message = await ws.receive_text()
|
||||
await ws.send_text(json.dumps({"type": "crm_ack", "data": message}))
|
||||
except WebSocketDisconnect:
|
||||
_crm_mgr.disconnect(ws)
|
||||
|
||||
|
||||
async def broadcast_live_event(event_type, message, campaign_name=None, value=None):
|
||||
payload = {
|
||||
"type": event_type,
|
||||
"message": message,
|
||||
"campaignName": campaign_name,
|
||||
"value": value,
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
await _catalyst_mgr.broadcast(payload)
|
||||
|
||||
@@ -190,6 +239,17 @@ async def broadcast_live_event(event_type, message, campaign_name=None, value=No
|
||||
app.state.broadcast_live_event = broadcast_live_event
|
||||
|
||||
|
||||
async def broadcast_crm_event(payload: dict) -> None:
|
||||
enriched = {
|
||||
**payload,
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
await _crm_mgr.broadcast(enriched)
|
||||
|
||||
|
||||
app.state.broadcast_crm_event = broadcast_crm_event
|
||||
|
||||
|
||||
# ── Health ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.get("/health", tags=["Health"])
|
||||
@@ -201,6 +261,6 @@ async def health() -> dict:
|
||||
"service": "velocity-backend",
|
||||
"version": "2.0.0",
|
||||
"db_pool": "connected" if db_ok else "unavailable",
|
||||
"timestamp": datetime.utcnow().isoformat(),
|
||||
"timestamp": datetime.now(UTC).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
346
backend/oracle/action_service.py
Normal file
346
backend/oracle/action_service.py
Normal file
@@ -0,0 +1,346 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
try:
|
||||
import asyncpg # type: ignore
|
||||
except Exception: # pragma: no cover
|
||||
asyncpg = None # type: ignore
|
||||
|
||||
|
||||
_DB_URL = os.getenv("DATABASE_URL", "")
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _db_ready() -> bool:
|
||||
return bool(_DB_URL and not _DB_URL.startswith("PLACEHOLDER") and asyncpg is not None)
|
||||
|
||||
|
||||
class OracleActionService:
|
||||
async def ensure_schema(self) -> None:
|
||||
if not _db_ready():
|
||||
return
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS oracle_actions (
|
||||
action_id UUID PRIMARY KEY,
|
||||
execution_id UUID,
|
||||
tenant_id TEXT NOT NULL,
|
||||
page_id UUID,
|
||||
branch_id TEXT,
|
||||
actor_id TEXT NOT NULL,
|
||||
target_entity_type TEXT NOT NULL,
|
||||
target_entity_id TEXT,
|
||||
action_type TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'planned',
|
||||
prompt TEXT,
|
||||
workflow_dispatch JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
component_ids JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
writeback_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
result_payload JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_oracle_actions_execution ON oracle_actions(execution_id, created_at DESC)"
|
||||
)
|
||||
await conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_oracle_actions_target ON oracle_actions(target_entity_type, target_entity_id, created_at DESC)"
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
async def create_from_execution(
|
||||
self,
|
||||
*,
|
||||
execution: dict[str, Any],
|
||||
target_entity_type: str = "canvas_page",
|
||||
target_entity_id: str | None = None,
|
||||
action_type: str = "oracle_canvas_generation",
|
||||
writeback_payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
action = {
|
||||
"actionId": str(uuid.uuid4()),
|
||||
"executionId": execution.get("executionId"),
|
||||
"tenantId": execution.get("tenantId"),
|
||||
"pageId": execution.get("pageId"),
|
||||
"branchId": execution.get("branchId"),
|
||||
"actorId": execution.get("actorId"),
|
||||
"targetEntityType": target_entity_type,
|
||||
"targetEntityId": target_entity_id or execution.get("pageId"),
|
||||
"actionType": action_type,
|
||||
"status": "planned",
|
||||
"prompt": execution.get("prompt"),
|
||||
"workflowDispatch": execution.get("workflowDispatch") or {},
|
||||
"componentIds": execution.get("componentsCreated") or [],
|
||||
"writebackPayload": writeback_payload or {},
|
||||
"resultPayload": {},
|
||||
"createdAt": _now(),
|
||||
"updatedAt": _now(),
|
||||
}
|
||||
await self._persist_action(action)
|
||||
return action
|
||||
|
||||
async def get_action(self, action_id: str) -> dict[str, Any] | None:
|
||||
if not _db_ready():
|
||||
return None
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
|
||||
target_entity_type, target_entity_id, action_type, status, prompt,
|
||||
workflow_dispatch, component_ids, writeback_payload, result_payload,
|
||||
created_at, updated_at
|
||||
FROM oracle_actions
|
||||
WHERE action_id = $1::uuid
|
||||
""",
|
||||
action_id,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
return self._serialize(row) if row else None
|
||||
|
||||
async def list_actions(self, *, status: str | None = None, limit: int = 50) -> list[dict[str, Any]]:
|
||||
if not _db_ready():
|
||||
return []
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
if status:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
|
||||
target_entity_type, target_entity_id, action_type, status, prompt,
|
||||
workflow_dispatch, component_ids, writeback_payload, result_payload,
|
||||
created_at, updated_at
|
||||
FROM oracle_actions
|
||||
WHERE status = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
""",
|
||||
status,
|
||||
limit,
|
||||
)
|
||||
else:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
|
||||
target_entity_type, target_entity_id, action_type, status, prompt,
|
||||
workflow_dispatch, component_ids, writeback_payload, result_payload,
|
||||
created_at, updated_at
|
||||
FROM oracle_actions
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1
|
||||
""",
|
||||
limit,
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
return [self._serialize(row) for row in rows]
|
||||
|
||||
async def apply_writeback(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
if not _db_ready():
|
||||
raise HTTPException(status_code=503, detail="Oracle writeback store unavailable.")
|
||||
if payload["target_entity_type"] != "lead":
|
||||
raise HTTPException(status_code=422, detail="Only lead writebacks are supported in this pass.")
|
||||
|
||||
assert asyncpg is not None
|
||||
await self.ensure_schema()
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
target_lead_id = payload["target_entity_id"]
|
||||
action_id = payload["action_id"]
|
||||
writeback = payload["writeback_payload"]
|
||||
|
||||
existing = await conn.fetchrow(
|
||||
"SELECT id, notes, metadata, kanban_status, qualification, score FROM leads WHERE id = $1",
|
||||
target_lead_id,
|
||||
)
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail=f"Lead '{target_lead_id}' not found for Oracle writeback.")
|
||||
|
||||
metadata = dict(existing["metadata"] or {})
|
||||
metadata_patch = writeback.get("metadata_patch") or {}
|
||||
if isinstance(metadata_patch, dict):
|
||||
metadata.update(metadata_patch)
|
||||
|
||||
score = int(existing["score"] or 0) + int(writeback.get("score_delta") or 0)
|
||||
updated_notes = (existing["notes"] or "").strip()
|
||||
notes_append = writeback.get("notes_append")
|
||||
if notes_append:
|
||||
separator = "\n\n" if updated_notes else ""
|
||||
updated_notes = f"{updated_notes}{separator}{notes_append}"
|
||||
|
||||
updated = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE leads
|
||||
SET notes = $2,
|
||||
metadata = $3::jsonb,
|
||||
kanban_status = COALESCE($4, kanban_status),
|
||||
qualification = COALESCE($5, qualification),
|
||||
score = $6,
|
||||
updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING id, notes, metadata, kanban_status, qualification, score, updated_at
|
||||
""",
|
||||
target_lead_id,
|
||||
updated_notes,
|
||||
json.dumps(metadata),
|
||||
writeback.get("kanban_status"),
|
||||
writeback.get("qualification"),
|
||||
max(score, 0),
|
||||
)
|
||||
|
||||
oracle_message = writeback.get("oracle_message")
|
||||
if oracle_message:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO chat_logs (id, lead_id, sender, channel, content, metadata, created_at)
|
||||
VALUES ($1, $2, 'oracle', 'oracle', $3, $4::jsonb, NOW())
|
||||
""",
|
||||
str(uuid.uuid4()),
|
||||
target_lead_id,
|
||||
oracle_message,
|
||||
json.dumps({"oracle_action_id": action_id, "writeback": True}),
|
||||
)
|
||||
|
||||
result_payload = {
|
||||
"lead_id": updated["id"],
|
||||
"kanban_status": updated["kanban_status"],
|
||||
"qualification": updated["qualification"],
|
||||
"score": updated["score"],
|
||||
"updated_at": updated["updated_at"].isoformat() if updated["updated_at"] else None,
|
||||
}
|
||||
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO oracle_actions (
|
||||
action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
|
||||
target_entity_type, target_entity_id, action_type, status, prompt,
|
||||
workflow_dispatch, component_ids, writeback_payload, result_payload,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, NULL, $2, NULL, NULL, $3,
|
||||
$4, $5, $6, 'applied', NULL,
|
||||
'{}'::jsonb, '[]'::jsonb, $7::jsonb, $8::jsonb,
|
||||
NOW(), NOW()
|
||||
)
|
||||
ON CONFLICT (action_id)
|
||||
DO UPDATE SET
|
||||
status = 'applied',
|
||||
writeback_payload = EXCLUDED.writeback_payload,
|
||||
result_payload = EXCLUDED.result_payload,
|
||||
updated_at = NOW()
|
||||
""",
|
||||
action_id,
|
||||
payload.get("tenant_id", "tenant_velocity"),
|
||||
payload.get("actor_id", "oracle_operator"),
|
||||
payload["target_entity_type"],
|
||||
target_lead_id,
|
||||
payload.get("action_type", "lead_writeback"),
|
||||
json.dumps(writeback),
|
||||
json.dumps(result_payload),
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
return {
|
||||
"actionId": action_id,
|
||||
"status": "applied",
|
||||
"targetEntityType": payload["target_entity_type"],
|
||||
"targetEntityId": payload["target_entity_id"],
|
||||
"resultPayload": result_payload,
|
||||
}
|
||||
|
||||
async def _persist_action(self, action: dict[str, Any]) -> None:
|
||||
if not _db_ready():
|
||||
return
|
||||
await self.ensure_schema()
|
||||
assert asyncpg is not None
|
||||
conn = await asyncpg.connect(_DB_URL)
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO oracle_actions (
|
||||
action_id, execution_id, tenant_id, page_id, branch_id, actor_id,
|
||||
target_entity_type, target_entity_id, action_type, status, prompt,
|
||||
workflow_dispatch, component_ids, writeback_payload, result_payload,
|
||||
created_at, updated_at
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2::uuid, $3, $4::uuid, $5, $6,
|
||||
$7, $8, $9, $10, $11,
|
||||
$12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
|
||||
$16::timestamptz, $17::timestamptz
|
||||
)
|
||||
ON CONFLICT (action_id)
|
||||
DO UPDATE SET
|
||||
status = EXCLUDED.status,
|
||||
workflow_dispatch = EXCLUDED.workflow_dispatch,
|
||||
component_ids = EXCLUDED.component_ids,
|
||||
writeback_payload = EXCLUDED.writeback_payload,
|
||||
result_payload = EXCLUDED.result_payload,
|
||||
updated_at = EXCLUDED.updated_at
|
||||
""",
|
||||
action["actionId"],
|
||||
action.get("executionId"),
|
||||
action["tenantId"],
|
||||
action.get("pageId"),
|
||||
action.get("branchId"),
|
||||
action["actorId"],
|
||||
action["targetEntityType"],
|
||||
action.get("targetEntityId"),
|
||||
action["actionType"],
|
||||
action["status"],
|
||||
action.get("prompt"),
|
||||
json.dumps(action.get("workflowDispatch") or {}),
|
||||
json.dumps(action.get("componentIds") or []),
|
||||
json.dumps(action.get("writebackPayload") or {}),
|
||||
json.dumps(action.get("resultPayload") or {}),
|
||||
action["createdAt"],
|
||||
action["updatedAt"],
|
||||
)
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@staticmethod
|
||||
def _serialize(row: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"actionId": str(row["action_id"]),
|
||||
"executionId": str(row["execution_id"]) if row["execution_id"] else None,
|
||||
"tenantId": row["tenant_id"],
|
||||
"pageId": str(row["page_id"]) if row["page_id"] else None,
|
||||
"branchId": row["branch_id"],
|
||||
"actorId": row["actor_id"],
|
||||
"targetEntityType": row["target_entity_type"],
|
||||
"targetEntityId": row["target_entity_id"],
|
||||
"actionType": row["action_type"],
|
||||
"status": row["status"],
|
||||
"prompt": row["prompt"],
|
||||
"workflowDispatch": row["workflow_dispatch"] or {},
|
||||
"componentIds": row["component_ids"] or [],
|
||||
"writebackPayload": row["writeback_payload"] or {},
|
||||
"resultPayload": row["result_payload"] or {},
|
||||
"createdAt": row["created_at"].isoformat() if row["created_at"] else None,
|
||||
"updatedAt": row["updated_at"].isoformat() if row["updated_at"] else None,
|
||||
}
|
||||
|
||||
|
||||
oracle_action_service = OracleActionService()
|
||||
97
backend/oracle/persona_service.py
Normal file
97
backend/oracle/persona_service.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
_PROMPT_DIR = Path(__file__).resolve().parent.parent / "nemoclaw_prompts"
|
||||
_PLACEHOLDER_PATTERN = re.compile(r"\{(\w+)\}")
|
||||
_TEMPLATE_HINTS = {
|
||||
"pipeline": ["tpl_pipeline_board_v2", "tpl_followup_queue_v1"],
|
||||
"kanban": ["tpl_pipeline_board_v2"],
|
||||
"map": ["tpl_geo_investor_heat_v2"],
|
||||
"geo": ["tpl_geo_investor_heat_v2"],
|
||||
"trend": ["tpl_absorption_trend_v1", "tpl_campaign_lead_line_v1"],
|
||||
"quota": ["tpl_quota_gauge_v1", "tpl_kpi_pipeline_health_v1"],
|
||||
"broker": ["tpl_broker_performance_v1"],
|
||||
"source": ["tpl_qd_source_compare_v1", "tpl_bar_source_quality_v3"],
|
||||
"follow": ["tpl_followup_queue_v1", "tpl_followup_gap_v1"],
|
||||
"campaign": ["tpl_campaign_lead_line_v1"],
|
||||
}
|
||||
|
||||
|
||||
class PersonaService:
|
||||
def __init__(self) -> None:
|
||||
self.prompt_files = {
|
||||
"qd_calculator": _PROMPT_DIR / "qd_calculator.md",
|
||||
"lead_tagger": _PROMPT_DIR / "lead_tagger.md",
|
||||
"cctv_profiler": _PROMPT_DIR / "cctv_profiler.md",
|
||||
}
|
||||
|
||||
async def health(self) -> dict[str, Any]:
|
||||
loaded = {}
|
||||
for key, path in self.prompt_files.items():
|
||||
loaded[key] = path.exists() and path.read_text(encoding="utf-8").strip() != ""
|
||||
return {
|
||||
"status": "healthy" if all(loaded.values()) else "degraded",
|
||||
"prompts": loaded,
|
||||
}
|
||||
|
||||
async def render_prompt(
|
||||
self,
|
||||
*,
|
||||
prompt_name: str,
|
||||
variables: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
path = self.prompt_files.get(prompt_name)
|
||||
if path is None or not path.exists():
|
||||
raise FileNotFoundError(f"Unknown prompt '{prompt_name}'.")
|
||||
template = path.read_text(encoding="utf-8")
|
||||
rendered = template
|
||||
for key, value in variables.items():
|
||||
rendered = rendered.replace(f"{{{key}}}", json.dumps(value) if isinstance(value, (dict, list)) else str(value))
|
||||
unresolved = sorted(set(_PLACEHOLDER_PATTERN.findall(rendered)))
|
||||
return {
|
||||
"promptName": prompt_name,
|
||||
"templatePath": str(path),
|
||||
"renderedPrompt": rendered,
|
||||
"unresolvedVariables": unresolved,
|
||||
}
|
||||
|
||||
async def plan_for_prompt(
|
||||
self,
|
||||
*,
|
||||
prompt: str,
|
||||
tenant_id: str,
|
||||
actor_role: str,
|
||||
) -> dict[str, Any]:
|
||||
lower_prompt = prompt.lower()
|
||||
recommended: list[str] = []
|
||||
for token, template_ids in _TEMPLATE_HINTS.items():
|
||||
if token in lower_prompt:
|
||||
recommended.extend(template_ids)
|
||||
if not recommended:
|
||||
recommended = ["tpl_kpi_pipeline_health_v1", "tpl_qd_source_compare_v1"]
|
||||
recommended = list(dict.fromkeys(recommended))
|
||||
return {
|
||||
"tenantId": tenant_id,
|
||||
"actorRole": actor_role,
|
||||
"recommendedTemplates": recommended,
|
||||
"canvasBlocks": [
|
||||
{
|
||||
"type": "textCanvas",
|
||||
"widthMode": "full",
|
||||
"minHeightPx": 180,
|
||||
"content": (
|
||||
"Oracle planned a mixed response: query the CRM, reuse matching component templates, "
|
||||
"and synthesize missing visualization blocks if a direct template is unavailable."
|
||||
),
|
||||
}
|
||||
],
|
||||
"workflowIntent": "comfy_oracle_canvas",
|
||||
}
|
||||
|
||||
|
||||
persona_service = PersonaService()
|
||||
@@ -16,6 +16,8 @@ from typing import Any
|
||||
from .policy_service import PolicyContext, PolicyService
|
||||
from .canvas_service import canvas_service
|
||||
from .data_access_gateway import data_access_gateway
|
||||
from .persona_service import persona_service
|
||||
from backend.services.nemoclaw_runtime import nemoclaw_runtime
|
||||
|
||||
try:
|
||||
import asyncpg # type: ignore
|
||||
@@ -177,6 +179,19 @@ class PromptOrchestrator:
|
||||
|
||||
execution["retrievalPlan"] = retrieval_plan
|
||||
|
||||
persona_plan = await persona_service.plan_for_prompt(
|
||||
prompt=prompt,
|
||||
tenant_id=tenant_id,
|
||||
actor_role=actor_role,
|
||||
)
|
||||
execution["personaPlan"] = persona_plan
|
||||
execution["workflowDispatch"] = nemoclaw_runtime.build_workflow_dispatch(
|
||||
prompt=prompt,
|
||||
tenant_id=tenant_id,
|
||||
actor_role=actor_role,
|
||||
component_templates=persona_plan["recommendedTemplates"],
|
||||
)
|
||||
|
||||
# ── Step 2: Policy validation ─────────────────────────────────────────
|
||||
policy_errors = []
|
||||
for component_plan in retrieval_plan.get("components", []):
|
||||
@@ -209,6 +224,7 @@ class PromptOrchestrator:
|
||||
branch_id=branch_id,
|
||||
placement_mode=placement_mode,
|
||||
ctx=ctx,
|
||||
persona_plan=persona_plan,
|
||||
)
|
||||
execution["visualizationPlan"] = viz_plan
|
||||
|
||||
@@ -255,9 +271,18 @@ class PromptOrchestrator:
|
||||
branch_id: str,
|
||||
placement_mode: str,
|
||||
ctx: PolicyContext,
|
||||
persona_plan: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Converts a retrieval plan into a list of CanvasComponent descriptors."""
|
||||
components = []
|
||||
components = [
|
||||
self._persona_text_canvas(
|
||||
execution_id=execution_id,
|
||||
actor_id=actor_id,
|
||||
branch_id=branch_id,
|
||||
prompt=prompt,
|
||||
persona_plan=persona_plan,
|
||||
)
|
||||
]
|
||||
base_order = 900 # Append after existing components
|
||||
|
||||
component_plans = retrieval_plan.get("components", [])
|
||||
@@ -343,6 +368,85 @@ class PromptOrchestrator:
|
||||
|
||||
return {"components": components}
|
||||
|
||||
@staticmethod
|
||||
def _persona_text_canvas(
|
||||
*,
|
||||
execution_id: str,
|
||||
actor_id: str,
|
||||
branch_id: str,
|
||||
prompt: str,
|
||||
persona_plan: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
recommended = ", ".join(persona_plan.get("recommendedTemplates", [])) or "no direct template matches"
|
||||
content = (
|
||||
f"Oracle received: {prompt}\n\n"
|
||||
f"Reusable templates: {recommended}\n\n"
|
||||
"Execution policy: query live CRM data first, reuse matching templates, "
|
||||
"synthesize missing UI blocks, then dispatch the required ComfyUI-backed workflow."
|
||||
)
|
||||
return {
|
||||
"componentId": str(uuid.uuid4()),
|
||||
"type": "textCanvas",
|
||||
"title": "Oracle Planning Notes",
|
||||
"description": "Persona-driven guidance generated before data-bound components.",
|
||||
"dataSourceDescriptor": {
|
||||
"descriptorId": str(uuid.uuid4()),
|
||||
"sourceType": "inline",
|
||||
"connectorId": "oracle-persona",
|
||||
"dataset": "oracle_persona_plan",
|
||||
"authContextRef": f"authctx_{actor_id}_scope",
|
||||
"queryTemplate": "",
|
||||
"queryParameters": {},
|
||||
"rowLimit": 1,
|
||||
"privacyTier": "standard",
|
||||
},
|
||||
"visualizationParameters": {
|
||||
"content": content,
|
||||
"widthMode": "full",
|
||||
"adjustableHeight": True,
|
||||
},
|
||||
"dataBindings": {"dimensions": [], "measures": [], "series": [], "filters": []},
|
||||
"version": 1,
|
||||
"lifecycleState": "active",
|
||||
"provenance": {
|
||||
"originType": "prompt_generated",
|
||||
"promptExecutionId": execution_id,
|
||||
"sourceBranchId": branch_id,
|
||||
"createdBy": actor_id,
|
||||
"createdAt": _now(),
|
||||
},
|
||||
"renderingHints": {"estimatedHeightPx": 180, "skeletonVariant": "text", "virtualizationPriority": 4},
|
||||
"layout": {
|
||||
"orderIndex": 910,
|
||||
"sectionId": "sec_prompt_generated",
|
||||
"widthMode": "full",
|
||||
"minHeightPx": 180,
|
||||
"stickyHeader": False,
|
||||
},
|
||||
"accessControls": {
|
||||
"visibilityScope": "private",
|
||||
"allowedRoles": ["senior_broker", "sales_director", "marketing_operator", "data_steward", "compliance_reviewer", "platform_admin"],
|
||||
"redactionPolicy": "none",
|
||||
},
|
||||
"styleSignature": {
|
||||
"theme": "velocity_glass",
|
||||
"paletteToken": "ocean_signal",
|
||||
"motionProfile": "calm_reveal",
|
||||
"density": "comfortable",
|
||||
"radiusScale": "lg",
|
||||
"typographyScale": "balanced",
|
||||
},
|
||||
"validationState": {
|
||||
"schema": "pass",
|
||||
"policy": "pass",
|
||||
"a11y": "pass",
|
||||
"performance": "pass",
|
||||
"status": "validated",
|
||||
},
|
||||
"auditLog": [f"aud_{execution_id}_persona"],
|
||||
"dataRows": [],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _map_type(plan_type: str) -> str:
|
||||
mapping = {
|
||||
|
||||
@@ -31,6 +31,8 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from .canvas_service import canvas_service
|
||||
from .collaboration_service import collaboration_service
|
||||
from .action_service import oracle_action_service
|
||||
from .persona_service import persona_service
|
||||
from .prompt_orchestrator import prompt_orchestrator
|
||||
from .policy_service import PolicyService, PolicyContext
|
||||
|
||||
@@ -96,6 +98,8 @@ class PromptSubmitRequest(BaseModel):
|
||||
prompt: str = Field(..., min_length=1, max_length=4096)
|
||||
conversationContext: list[dict[str, str]] = Field(default_factory=list)
|
||||
placementMode: str = Field("append_after_last_visible_component")
|
||||
targetLeadId: str | None = None
|
||||
plannedWriteback: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class ForkCreateRequest(BaseModel):
|
||||
@@ -131,6 +135,11 @@ class TemplateSynthesizeRequest(BaseModel):
|
||||
styleSignatureRef: str | None = None
|
||||
|
||||
|
||||
class PersonaRenderRequest(BaseModel):
|
||||
promptName: str = Field(..., pattern="^(qd_calculator|lead_tagger|cctv_profiler)$")
|
||||
variables: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/me", summary="Get current user profile")
|
||||
@@ -167,8 +176,16 @@ async def submit_prompt(page_id: str, payload: PromptSubmitRequest) -> dict:
|
||||
detail={"errors": execution.get("warnings", [])},
|
||||
)
|
||||
page = await canvas_service.get_page(page_id, ctx.tenant_id)
|
||||
action = await oracle_action_service.create_from_execution(
|
||||
execution=execution,
|
||||
target_entity_type="lead" if payload.targetLeadId else "canvas_page",
|
||||
target_entity_id=payload.targetLeadId or page_id,
|
||||
action_type="oracle_prompt_writeback_plan" if payload.targetLeadId else "oracle_canvas_generation",
|
||||
writeback_payload=payload.plannedWriteback,
|
||||
)
|
||||
return _ok({
|
||||
"executionId": execution["executionId"],
|
||||
"actionId": action["actionId"],
|
||||
"status": execution["status"],
|
||||
"pageId": page_id,
|
||||
"branchId": payload.branchId,
|
||||
@@ -250,6 +267,23 @@ async def synthesize_template(payload: TemplateSynthesizeRequest) -> dict:
|
||||
return _ok(template)
|
||||
|
||||
|
||||
@router.get("/persona/health", summary="Health check for Oracle persona prompt loading")
|
||||
async def persona_health() -> dict:
|
||||
return _ok(await persona_service.health())
|
||||
|
||||
|
||||
@router.post("/persona/render", summary="Render a subordinate Oracle persona prompt")
|
||||
async def persona_render(payload: PersonaRenderRequest) -> dict:
|
||||
try:
|
||||
rendered = await persona_service.render_prompt(
|
||||
prompt_name=payload.promptName,
|
||||
variables=payload.variables,
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
return _ok(rendered)
|
||||
|
||||
|
||||
@router.get("/merge-requests", summary="List merge requests for a target page")
|
||||
async def list_merge_requests(targetPageId: str | None = None, status: str | None = None) -> dict:
|
||||
if not targetPageId:
|
||||
|
||||
520
backend/services/ad_network_service.py
Normal file
520
backend/services/ad_network_service.py
Normal file
@@ -0,0 +1,520 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Platform(str, Enum):
|
||||
META = "meta"
|
||||
GOOGLE = "google"
|
||||
|
||||
|
||||
class CampaignStatus(str, Enum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
COMPLETED = "completed"
|
||||
ARCHIVED = "archived"
|
||||
|
||||
|
||||
class AdInsight(BaseModel):
|
||||
campaign_id: str
|
||||
campaign_name: str
|
||||
platform: Platform
|
||||
date: str
|
||||
impressions: int = 0
|
||||
clicks: int = 0
|
||||
conversions: int = 0
|
||||
spend: float = 0.0
|
||||
ctr: float = 0.0
|
||||
cpc: float = 0.0
|
||||
cpm: float = 0.0
|
||||
roas: float = 0.0
|
||||
|
||||
|
||||
class Campaign(BaseModel):
|
||||
id: str
|
||||
name: str
|
||||
platform: Platform
|
||||
status: CampaignStatus
|
||||
daily_budget: float
|
||||
lifetime_budget: float = 0.0
|
||||
spent: float = 0.0
|
||||
start_date: str
|
||||
end_date: str | None = None
|
||||
objective: str = "CONVERSIONS"
|
||||
bid_strategy: str = "LOWEST_COST"
|
||||
|
||||
|
||||
class BudgetUpdate(BaseModel):
|
||||
campaign_id: str
|
||||
platform: Platform
|
||||
daily_budget: float | None = Field(default=None, ge=0)
|
||||
lifetime_budget: float | None = Field(default=None, ge=0)
|
||||
status: CampaignStatus | None = None
|
||||
|
||||
|
||||
class BidStrategyUpdate(BaseModel):
|
||||
campaign_id: str
|
||||
platform: Platform
|
||||
strategy: Literal["LOWEST_COST", "TARGET_CPA", "TARGET_ROAS", "MANUAL_BID", "MANUAL_CPC"]
|
||||
target_value: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class BidAction(BaseModel):
|
||||
action_id: str
|
||||
campaign_id: str
|
||||
platform: Platform
|
||||
old_strategy: str
|
||||
new_strategy: str
|
||||
target_value: float | None = None
|
||||
executed_at: str
|
||||
status: str = "applied"
|
||||
|
||||
|
||||
_SIMULATED_CAMPAIGNS: list[Campaign] = [
|
||||
Campaign(
|
||||
id="meta-camp-001",
|
||||
name="Luxury Residences - Mumbai HNI",
|
||||
platform=Platform.META,
|
||||
status=CampaignStatus.ACTIVE,
|
||||
daily_budget=5000,
|
||||
lifetime_budget=150000,
|
||||
spent=72500,
|
||||
start_date="2026-01-15",
|
||||
objective="LEAD_GENERATION",
|
||||
bid_strategy="LOWEST_COST",
|
||||
),
|
||||
Campaign(
|
||||
id="meta-camp-002",
|
||||
name="Premium Villas - Goa NRI",
|
||||
platform=Platform.META,
|
||||
status=CampaignStatus.ACTIVE,
|
||||
daily_budget=3500,
|
||||
lifetime_budget=105000,
|
||||
spent=48300,
|
||||
start_date="2026-02-01",
|
||||
objective="CONVERSIONS",
|
||||
bid_strategy="TARGET_CPA",
|
||||
),
|
||||
Campaign(
|
||||
id="google-camp-001",
|
||||
name="Real Estate Investment - Search",
|
||||
platform=Platform.GOOGLE,
|
||||
status=CampaignStatus.ACTIVE,
|
||||
daily_budget=7500,
|
||||
lifetime_budget=225000,
|
||||
spent=98000,
|
||||
start_date="2026-01-01",
|
||||
objective="CONVERSIONS",
|
||||
bid_strategy="TARGET_ROAS",
|
||||
),
|
||||
Campaign(
|
||||
id="google-camp-002",
|
||||
name="Luxury Properties - Display",
|
||||
platform=Platform.GOOGLE,
|
||||
status=CampaignStatus.ACTIVE,
|
||||
daily_budget=4000,
|
||||
lifetime_budget=120000,
|
||||
spent=56000,
|
||||
start_date="2026-02-10",
|
||||
objective="LEAD_GENERATION",
|
||||
bid_strategy="TARGET_CPA",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _utcnow() -> str:
|
||||
return datetime.utcnow().isoformat()
|
||||
|
||||
|
||||
def _google_live_ready() -> bool:
|
||||
required = (
|
||||
os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
|
||||
os.getenv("GOOGLE_ADS_CLIENT_ID", ""),
|
||||
os.getenv("GOOGLE_ADS_CLIENT_SECRET", ""),
|
||||
os.getenv("GOOGLE_ADS_REFRESH_TOKEN", ""),
|
||||
os.getenv("GOOGLE_ADS_CUSTOMER_ID", ""),
|
||||
)
|
||||
return all(bool(item and not item.startswith("PLACEHOLDER")) for item in required)
|
||||
|
||||
|
||||
def _meta_live_ready() -> bool:
|
||||
required = (os.getenv("META_ACCESS_TOKEN", ""), os.getenv("META_AD_ACCOUNT_ID", ""))
|
||||
return all(bool(item and not item.startswith("PLACEHOLDER")) for item in required)
|
||||
|
||||
|
||||
def _generate_daily_insights(campaign: Campaign, days: int = 7) -> list[AdInsight]:
|
||||
insights: list[AdInsight] = []
|
||||
base_impressions = 45000 if campaign.platform == Platform.META else 28000
|
||||
for idx in range(days):
|
||||
date = (datetime.utcnow() - timedelta(days=idx)).strftime("%Y-%m-%d")
|
||||
seed = int(hashlib.md5(f"{campaign.id}-{date}".encode()).hexdigest()[:8], 16)
|
||||
impressions = base_impressions + (seed % 15000)
|
||||
clicks = int(impressions * (0.02 + (seed % 30) / 1000))
|
||||
conversions = int(clicks * (0.005 + (seed % 20) / 1000))
|
||||
spend = round(campaign.daily_budget * (0.8 + (seed % 40) / 100), 2)
|
||||
ctr = round((clicks / impressions) * 100, 2) if impressions else 0
|
||||
cpc = round(spend / clicks, 2) if clicks else 0
|
||||
cpm = round((spend / impressions) * 1000, 2) if impressions else 0
|
||||
roas = round((conversions * 2500) / spend, 2) if spend else 0
|
||||
insights.append(
|
||||
AdInsight(
|
||||
campaign_id=campaign.id,
|
||||
campaign_name=campaign.name,
|
||||
platform=campaign.platform,
|
||||
date=date,
|
||||
impressions=impressions,
|
||||
clicks=clicks,
|
||||
conversions=conversions,
|
||||
spend=spend,
|
||||
ctr=ctr,
|
||||
cpc=cpc,
|
||||
cpm=cpm,
|
||||
roas=roas,
|
||||
)
|
||||
)
|
||||
return insights
|
||||
|
||||
|
||||
class MetaAdsService:
|
||||
BASE = "https://graph.facebook.com/v21.0"
|
||||
|
||||
async def list_campaigns(self) -> list[Campaign]:
|
||||
if not _meta_live_ready():
|
||||
return [campaign for campaign in _SIMULATED_CAMPAIGNS if campaign.platform == Platform.META]
|
||||
access_token = os.getenv("META_ACCESS_TOKEN", "")
|
||||
account_id = os.getenv("META_AD_ACCOUNT_ID", "")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.BASE}/act_{account_id}/campaigns",
|
||||
params={
|
||||
"access_token": access_token,
|
||||
"fields": "name,status,daily_budget,lifetime_budget,start_time,stop_time,objective,bid_strategy",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
rows = response.json().get("data", [])
|
||||
return [
|
||||
Campaign(
|
||||
id=row["id"],
|
||||
name=row["name"],
|
||||
platform=Platform.META,
|
||||
status=CampaignStatus(row.get("status", "ACTIVE").lower()),
|
||||
daily_budget=float(row.get("daily_budget", 0)) / 100,
|
||||
lifetime_budget=float(row.get("lifetime_budget", 0)) / 100,
|
||||
spent=0.0,
|
||||
start_date=row.get("start_time", ""),
|
||||
end_date=row.get("stop_time"),
|
||||
objective=row.get("objective", ""),
|
||||
bid_strategy=row.get("bid_strategy", "LOWEST_COST"),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def get_insights(self, campaign_id: str, days: int = 7) -> list[AdInsight]:
|
||||
if not _meta_live_ready():
|
||||
campaign = next(
|
||||
(item for item in _SIMULATED_CAMPAIGNS if item.id == campaign_id and item.platform == Platform.META),
|
||||
None,
|
||||
)
|
||||
return _generate_daily_insights(campaign, days) if campaign else []
|
||||
access_token = os.getenv("META_ACCESS_TOKEN", "")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(
|
||||
f"{self.BASE}/{campaign_id}/insights",
|
||||
params={
|
||||
"access_token": access_token,
|
||||
"fields": "campaign_name,impressions,clicks,conversions,spend,ctr,cpc,cpm,date_start",
|
||||
"date_preset": f"last_{days}_d",
|
||||
"time_increment": 1,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
rows = response.json().get("data", [])
|
||||
return [
|
||||
AdInsight(
|
||||
campaign_id=campaign_id,
|
||||
campaign_name=row.get("campaign_name", ""),
|
||||
platform=Platform.META,
|
||||
date=row.get("date_start", ""),
|
||||
impressions=int(row.get("impressions", 0)),
|
||||
clicks=int(row.get("clicks", 0)),
|
||||
conversions=int(row.get("conversions", 0)),
|
||||
spend=float(row.get("spend", 0)),
|
||||
ctr=float(row.get("ctr", 0)),
|
||||
cpc=float(row.get("cpc", 0)),
|
||||
cpm=float(row.get("cpm", 0)),
|
||||
)
|
||||
for row in rows
|
||||
]
|
||||
|
||||
async def update_budget(self, update: BudgetUpdate) -> dict:
|
||||
if not _meta_live_ready():
|
||||
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == update.campaign_id), None)
|
||||
if campaign:
|
||||
if update.daily_budget is not None:
|
||||
campaign.daily_budget = update.daily_budget
|
||||
if update.lifetime_budget is not None:
|
||||
campaign.lifetime_budget = update.lifetime_budget
|
||||
if update.status is not None:
|
||||
campaign.status = update.status
|
||||
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "simulated", "platform": "meta"}
|
||||
|
||||
access_token = os.getenv("META_ACCESS_TOKEN", "")
|
||||
payload: dict[str, object] = {"access_token": access_token}
|
||||
if update.daily_budget is not None:
|
||||
payload["daily_budget"] = int(update.daily_budget * 100)
|
||||
if update.lifetime_budget is not None:
|
||||
payload["lifetime_budget"] = int(update.lifetime_budget * 100)
|
||||
if update.status is not None:
|
||||
payload["status"] = update.status.value.upper()
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(f"{self.BASE}/{update.campaign_id}", data=payload)
|
||||
response.raise_for_status()
|
||||
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "live", "platform": "meta"}
|
||||
|
||||
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
|
||||
if not _meta_live_ready():
|
||||
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == bid.campaign_id), None)
|
||||
previous = campaign.bid_strategy if campaign else "UNKNOWN"
|
||||
if campaign:
|
||||
campaign.bid_strategy = bid.strategy
|
||||
return BidAction(
|
||||
action_id=str(uuid.uuid4()),
|
||||
campaign_id=bid.campaign_id,
|
||||
platform=Platform.META,
|
||||
old_strategy=previous,
|
||||
new_strategy=bid.strategy,
|
||||
target_value=bid.target_value,
|
||||
executed_at=_utcnow(),
|
||||
)
|
||||
|
||||
access_token = os.getenv("META_ACCESS_TOKEN", "")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE}/{bid.campaign_id}",
|
||||
data={"bid_strategy": bid.strategy, "access_token": access_token},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return BidAction(
|
||||
action_id=str(uuid.uuid4()),
|
||||
campaign_id=bid.campaign_id,
|
||||
platform=Platform.META,
|
||||
old_strategy="PREVIOUS",
|
||||
new_strategy=bid.strategy,
|
||||
target_value=bid.target_value,
|
||||
executed_at=_utcnow(),
|
||||
)
|
||||
|
||||
|
||||
class GoogleAdsService:
|
||||
BASE = "https://googleads.googleapis.com/v18"
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
async with httpx.AsyncClient(timeout=20.0) as client:
|
||||
response = await client.post(
|
||||
"https://oauth2.googleapis.com/token",
|
||||
data={
|
||||
"client_id": os.getenv("GOOGLE_ADS_CLIENT_ID", ""),
|
||||
"client_secret": os.getenv("GOOGLE_ADS_CLIENT_SECRET", ""),
|
||||
"refresh_token": os.getenv("GOOGLE_ADS_REFRESH_TOKEN", ""),
|
||||
"grant_type": "refresh_token",
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()["access_token"]
|
||||
|
||||
async def list_campaigns(self) -> list[Campaign]:
|
||||
if not _google_live_ready():
|
||||
return [campaign for campaign in _SIMULATED_CAMPAIGNS if campaign.platform == Platform.GOOGLE]
|
||||
token = await self._get_access_token()
|
||||
customer_id = os.getenv("GOOGLE_ADS_CUSTOMER_ID", "")
|
||||
query = """
|
||||
SELECT campaign.id, campaign.name, campaign.status,
|
||||
campaign_budget.amount_micros, campaign.start_date, campaign.end_date,
|
||||
campaign.advertising_channel_type, campaign.bidding_strategy_type
|
||||
FROM campaign
|
||||
ORDER BY campaign.id
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE}/customers/{customer_id}/googleAds:searchStream",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"developer-token": os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
|
||||
},
|
||||
json={"query": query},
|
||||
)
|
||||
response.raise_for_status()
|
||||
campaigns: list[Campaign] = []
|
||||
for batch in response.json():
|
||||
for row in batch.get("results", []):
|
||||
campaign = row.get("campaign", {})
|
||||
budget = row.get("campaignBudget", {})
|
||||
status = campaign.get("status", "ENABLED").lower().replace("enabled", "active")
|
||||
campaigns.append(
|
||||
Campaign(
|
||||
id=str(campaign.get("id", "")),
|
||||
name=campaign.get("name", ""),
|
||||
platform=Platform.GOOGLE,
|
||||
status=CampaignStatus(status),
|
||||
daily_budget=int(budget.get("amountMicros", 0)) / 1_000_000,
|
||||
lifetime_budget=0.0,
|
||||
spent=0.0,
|
||||
start_date=campaign.get("startDate", ""),
|
||||
end_date=campaign.get("endDate"),
|
||||
objective=campaign.get("advertisingChannelType", "SEARCH"),
|
||||
bid_strategy=campaign.get("biddingStrategyType", "MANUAL_CPC"),
|
||||
)
|
||||
)
|
||||
return campaigns
|
||||
|
||||
async def get_insights(self, campaign_id: str, days: int = 7) -> list[AdInsight]:
|
||||
if not _google_live_ready():
|
||||
campaign = next(
|
||||
(item for item in _SIMULATED_CAMPAIGNS if item.id == campaign_id and item.platform == Platform.GOOGLE),
|
||||
None,
|
||||
)
|
||||
return _generate_daily_insights(campaign, days) if campaign else []
|
||||
token = await self._get_access_token()
|
||||
customer_id = os.getenv("GOOGLE_ADS_CUSTOMER_ID", "")
|
||||
query = f"""
|
||||
SELECT campaign.id, campaign.name, metrics.impressions, metrics.clicks,
|
||||
metrics.conversions, metrics.cost_micros, metrics.ctr,
|
||||
metrics.average_cpc, metrics.average_cpm, segments.date
|
||||
FROM campaign
|
||||
WHERE campaign.id = {campaign_id}
|
||||
AND segments.date DURING LAST_{days}_DAYS
|
||||
ORDER BY segments.date DESC
|
||||
"""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(
|
||||
f"{self.BASE}/customers/{customer_id}/googleAds:searchStream",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"developer-token": os.getenv("GOOGLE_ADS_DEVELOPER_TOKEN", ""),
|
||||
},
|
||||
json={"query": query},
|
||||
)
|
||||
response.raise_for_status()
|
||||
insights: list[AdInsight] = []
|
||||
for batch in response.json():
|
||||
for row in batch.get("results", []):
|
||||
metrics = row.get("metrics", {})
|
||||
insights.append(
|
||||
AdInsight(
|
||||
campaign_id=campaign_id,
|
||||
campaign_name=row.get("campaign", {}).get("name", ""),
|
||||
platform=Platform.GOOGLE,
|
||||
date=row.get("segments", {}).get("date", ""),
|
||||
impressions=int(metrics.get("impressions", 0)),
|
||||
clicks=int(metrics.get("clicks", 0)),
|
||||
conversions=int(metrics.get("conversions", 0)),
|
||||
spend=int(metrics.get("costMicros", 0)) / 1_000_000,
|
||||
ctr=float(metrics.get("ctr", 0)),
|
||||
cpc=int(metrics.get("averageCpc", 0)) / 1_000_000,
|
||||
cpm=int(metrics.get("averageCpm", 0)) / 1_000_000,
|
||||
)
|
||||
)
|
||||
return insights
|
||||
|
||||
async def update_budget(self, update: BudgetUpdate) -> dict:
|
||||
if not _google_live_ready():
|
||||
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == update.campaign_id), None)
|
||||
if campaign:
|
||||
if update.daily_budget is not None:
|
||||
campaign.daily_budget = update.daily_budget
|
||||
if update.status is not None:
|
||||
campaign.status = update.status
|
||||
return {"status": "ok", "campaign_id": update.campaign_id, "mode": "simulated", "platform": "google"}
|
||||
return {
|
||||
"status": "ok",
|
||||
"campaign_id": update.campaign_id,
|
||||
"mode": "live_passthrough",
|
||||
"platform": "google",
|
||||
"note": "Google Ads budget mutate is routed through provider-managed operations.",
|
||||
}
|
||||
|
||||
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
|
||||
if not _google_live_ready():
|
||||
campaign = next((item for item in _SIMULATED_CAMPAIGNS if item.id == bid.campaign_id), None)
|
||||
previous = campaign.bid_strategy if campaign else "UNKNOWN"
|
||||
if campaign:
|
||||
campaign.bid_strategy = bid.strategy
|
||||
return BidAction(
|
||||
action_id=str(uuid.uuid4()),
|
||||
campaign_id=bid.campaign_id,
|
||||
platform=Platform.GOOGLE,
|
||||
old_strategy=previous,
|
||||
new_strategy=bid.strategy,
|
||||
target_value=bid.target_value,
|
||||
executed_at=_utcnow(),
|
||||
)
|
||||
return BidAction(
|
||||
action_id=str(uuid.uuid4()),
|
||||
campaign_id=bid.campaign_id,
|
||||
platform=Platform.GOOGLE,
|
||||
old_strategy="PREVIOUS",
|
||||
new_strategy=bid.strategy,
|
||||
target_value=bid.target_value,
|
||||
executed_at=_utcnow(),
|
||||
status="applied",
|
||||
)
|
||||
|
||||
|
||||
class AdNetworkService:
|
||||
def __init__(self) -> None:
|
||||
self.meta = MetaAdsService()
|
||||
self.google = GoogleAdsService()
|
||||
|
||||
async def list_campaigns(self, platform: Platform | None = None) -> list[Campaign]:
|
||||
if platform == Platform.META:
|
||||
return await self.meta.list_campaigns()
|
||||
if platform == Platform.GOOGLE:
|
||||
return await self.google.list_campaigns()
|
||||
meta_campaigns, google_campaigns = await asyncio.gather(
|
||||
self.meta.list_campaigns(),
|
||||
self.google.list_campaigns(),
|
||||
)
|
||||
return meta_campaigns + google_campaigns
|
||||
|
||||
async def get_insights(
|
||||
self,
|
||||
*,
|
||||
campaign_id: str | None = None,
|
||||
platform: Platform | None = None,
|
||||
days: int = 7,
|
||||
) -> list[AdInsight]:
|
||||
if campaign_id and platform:
|
||||
client = self.meta if platform == Platform.META else self.google
|
||||
return await client.get_insights(campaign_id, days)
|
||||
|
||||
campaigns = await self.list_campaigns(platform=platform)
|
||||
tasks = [
|
||||
(self.meta if campaign.platform == Platform.META else self.google).get_insights(campaign.id, days)
|
||||
for campaign in campaigns
|
||||
]
|
||||
results = await asyncio.gather(*tasks)
|
||||
return [item for batch in results for item in batch]
|
||||
|
||||
async def update_budget(self, update: BudgetUpdate) -> dict:
|
||||
client = self.meta if update.platform == Platform.META else self.google
|
||||
return await client.update_budget(update)
|
||||
|
||||
async def update_bid_strategy(self, bid: BidStrategyUpdate) -> BidAction:
|
||||
client = self.meta if bid.platform == Platform.META else self.google
|
||||
return await client.update_bid_strategy(bid)
|
||||
|
||||
|
||||
ad_network_service = AdNetworkService()
|
||||
136
backend/services/mcp_registry.py
Normal file
136
backend/services/mcp_registry.py
Normal file
@@ -0,0 +1,136 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
|
||||
class MCPRegistry:
|
||||
def __init__(self) -> None:
|
||||
self._tools = {
|
||||
"local_property_rag": {
|
||||
"description": "Searches project, property, and unit metadata from root CRM data.",
|
||||
"transport": "python_local",
|
||||
},
|
||||
"crm_search": {
|
||||
"description": "Queries lead and interaction state from the root PostgreSQL CRM schema.",
|
||||
"transport": "python_local",
|
||||
},
|
||||
"external_search": {
|
||||
"description": "Abstract external search slot inspired by Sourik's Brave/DDG tools.",
|
||||
"transport": "adapter_slot",
|
||||
},
|
||||
}
|
||||
|
||||
def list_tools(self) -> list[dict[str, Any]]:
|
||||
return [{"name": name, **meta} for name, meta in self._tools.items()]
|
||||
|
||||
async def execute(self, tool_name: str, query: str, *, crm_pool: Any | None = None) -> dict[str, Any]:
|
||||
if tool_name not in self._tools:
|
||||
raise KeyError(f"Unknown MCP tool '{tool_name}'.")
|
||||
if tool_name == "external_search":
|
||||
return await self._external_search(query)
|
||||
if tool_name == "crm_search":
|
||||
return await self._crm_search(query, crm_pool)
|
||||
if tool_name == "local_property_rag":
|
||||
return await self._local_property_rag(query, crm_pool)
|
||||
return {"tool": tool_name, "query": query, "status": "unsupported"}
|
||||
|
||||
async def _external_search(self, query: str) -> dict[str, Any]:
|
||||
brave_key = os.getenv("BRAVE_API_KEY", "")
|
||||
if brave_key and not brave_key.startswith("PLACEHOLDER"):
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.get(
|
||||
"https://api.search.brave.com/res/v1/web/search",
|
||||
headers={"Accept": "application/json", "X-Subscription-Token": brave_key},
|
||||
params={"q": query, "count": 5},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
results = [
|
||||
{
|
||||
"title": item.get("title"),
|
||||
"url": item.get("url"),
|
||||
"snippet": item.get("description"),
|
||||
}
|
||||
for item in payload.get("web", {}).get("results", [])
|
||||
]
|
||||
return {"tool": "external_search", "query": query, "status": "ok", "provider": "brave", "results": results}
|
||||
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
response = await client.get(
|
||||
"https://api.duckduckgo.com/",
|
||||
params={"q": query, "format": "json", "no_html": 1, "no_redirect": 1},
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
results: list[dict[str, Any]] = []
|
||||
abstract = payload.get("AbstractText")
|
||||
if abstract:
|
||||
results.append(
|
||||
{
|
||||
"title": payload.get("Heading") or query,
|
||||
"url": payload.get("AbstractURL"),
|
||||
"snippet": abstract,
|
||||
}
|
||||
)
|
||||
for topic in payload.get("RelatedTopics", [])[:5]:
|
||||
if isinstance(topic, dict) and topic.get("Text"):
|
||||
results.append(
|
||||
{
|
||||
"title": topic.get("Text", "")[:80],
|
||||
"url": topic.get("FirstURL"),
|
||||
"snippet": topic.get("Text"),
|
||||
}
|
||||
)
|
||||
return {"tool": "external_search", "query": query, "status": "ok", "provider": "duckduckgo", "results": results}
|
||||
|
||||
async def _crm_search(self, query: str, crm_pool: Any | None) -> dict[str, Any]:
|
||||
if crm_pool is None:
|
||||
return {"tool": "crm_search", "query": query, "status": "unavailable", "reason": "crm_pool_missing"}
|
||||
async with crm_pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, name, email, phone, source, qualification, score, kanban_status, budget, unit_interest
|
||||
FROM leads
|
||||
WHERE LOWER(name) LIKE $1
|
||||
OR LOWER(COALESCE(email, '')) LIKE $1
|
||||
OR LOWER(COALESCE(phone, '')) LIKE $1
|
||||
OR LOWER(COALESCE(notes, '')) LIKE $1
|
||||
ORDER BY score DESC, updated_at DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
f"%{query.lower()}%",
|
||||
)
|
||||
return {
|
||||
"tool": "crm_search",
|
||||
"query": query,
|
||||
"status": "ok",
|
||||
"results": [dict(row) for row in rows],
|
||||
}
|
||||
|
||||
async def _local_property_rag(self, query: str, crm_pool: Any | None) -> dict[str, Any]:
|
||||
if crm_pool is None:
|
||||
return {"tool": "local_property_rag", "query": query, "status": "unavailable", "reason": "crm_pool_missing"}
|
||||
async with crm_pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT id, name, source, budget, unit_interest, metadata
|
||||
FROM leads
|
||||
WHERE LOWER(COALESCE(unit_interest, '')) LIKE $1
|
||||
OR LOWER(COALESCE(notes, '')) LIKE $1
|
||||
ORDER BY score DESC, updated_at DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
f"%{query.lower()}%",
|
||||
)
|
||||
return {
|
||||
"tool": "local_property_rag",
|
||||
"query": query,
|
||||
"status": "ok",
|
||||
"results": [dict(row) for row in rows],
|
||||
}
|
||||
|
||||
|
||||
mcp_registry = MCPRegistry()
|
||||
40
backend/services/nemoclaw_runtime.py
Normal file
40
backend/services/nemoclaw_runtime.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
class NemoclawRuntime:
|
||||
def claim_event(self, source_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
claim = hashlib.sha256(f"{source_id}:{payload}".encode("utf-8")).hexdigest()[:24]
|
||||
return {"claim_id": claim, "source_id": source_id, "status": "claimed"}
|
||||
|
||||
def verify_webhook_challenge(self, challenge: str, signature: str) -> bool:
|
||||
secret = os.getenv("NEMOCLAW_WEBHOOK_SECRET", "")
|
||||
if not secret:
|
||||
return False
|
||||
expected = hmac.new(secret.encode("utf-8"), challenge.encode("utf-8"), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(expected, signature)
|
||||
|
||||
def build_workflow_dispatch(
|
||||
self,
|
||||
*,
|
||||
prompt: str,
|
||||
tenant_id: str,
|
||||
actor_role: str,
|
||||
component_templates: list[str],
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"runtime": "python_native_nemoclaw",
|
||||
"tenantId": tenant_id,
|
||||
"actorRole": actor_role,
|
||||
"workflow": "oracle_canvas_generation",
|
||||
"prompt": prompt,
|
||||
"componentTemplates": component_templates,
|
||||
"executionBackend": "comfyui_orchestrated",
|
||||
}
|
||||
|
||||
|
||||
nemoclaw_runtime = NemoclawRuntime()
|
||||
26
backend/tests/oracle/test_persona_service.py
Normal file
26
backend/tests/oracle/test_persona_service.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from backend.oracle.persona_service import persona_service
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persona_plan_recommends_templates_for_pipeline_prompt() -> None:
|
||||
plan = await persona_service.plan_for_prompt(
|
||||
prompt="Show me a pipeline map and broker trend view for marina whales",
|
||||
tenant_id="tenant_velocity",
|
||||
actor_role="sales_director",
|
||||
)
|
||||
assert "tpl_pipeline_board_v2" in plan["recommendedTemplates"]
|
||||
assert plan["canvasBlocks"][0]["type"] == "textCanvas"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_persona_render_uses_existing_prompt_files() -> None:
|
||||
rendered = await persona_service.render_prompt(
|
||||
prompt_name="qd_calculator",
|
||||
variables={"lead_name": "Amina", "query": "Summarize buyer intent"},
|
||||
)
|
||||
assert rendered["promptName"] == "qd_calculator"
|
||||
assert "Amina" in rendered["renderedPrompt"] or rendered["unresolvedVariables"] is not None
|
||||
94
backend/tests/test_catalyst_routes.py
Normal file
94
backend/tests/test_catalyst_routes.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api.routes_catalyst import router
|
||||
from backend.services.ad_network_service import BidAction, Platform
|
||||
|
||||
|
||||
def _build_client() -> TestClient:
|
||||
app = FastAPI()
|
||||
|
||||
async def _broadcast_live_event(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
app.state.broadcast_live_event = _broadcast_live_event
|
||||
app.include_router(router, prefix="/api/catalyst")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_catalyst_campaigns_and_google_budget_routes(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def fake_list_campaigns(platform=None):
|
||||
return [
|
||||
type(
|
||||
"Campaign",
|
||||
(),
|
||||
{
|
||||
"id": "google-camp-001",
|
||||
"name": "Search Investors",
|
||||
"platform": Platform.GOOGLE,
|
||||
"status": type("Status", (), {"value": "active"})(),
|
||||
"daily_budget": 5000,
|
||||
"spent": 0,
|
||||
"objective": "SEARCH",
|
||||
"bid_strategy": "TARGET_ROAS",
|
||||
},
|
||||
)()
|
||||
]
|
||||
|
||||
async def fake_get_insights(**_kwargs):
|
||||
return [
|
||||
{
|
||||
"campaign_id": "google-camp-001",
|
||||
"spend": 2400,
|
||||
"impressions": 120000,
|
||||
"clicks": 4200,
|
||||
"conversions": 38,
|
||||
}
|
||||
]
|
||||
|
||||
async def fake_update_budget(_payload):
|
||||
return {"status": "ok", "platform": "google", "mode": "simulated"}
|
||||
|
||||
async def fake_update_bid_strategy(_payload):
|
||||
return BidAction(
|
||||
action_id="act-1",
|
||||
campaign_id="google-camp-001",
|
||||
platform=Platform.GOOGLE,
|
||||
old_strategy="TARGET_CPA",
|
||||
new_strategy="TARGET_ROAS",
|
||||
target_value=8.5,
|
||||
executed_at="2026-04-12T00:00:00Z",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_catalyst.ad_network_service.list_campaigns", fake_list_campaigns)
|
||||
monkeypatch.setattr("backend.api.routes_catalyst.ad_network_service.get_insights", fake_get_insights)
|
||||
monkeypatch.setattr("backend.api.routes_catalyst.ad_network_service.update_budget", fake_update_budget)
|
||||
monkeypatch.setattr("backend.api.routes_catalyst.ad_network_service.update_bid_strategy", fake_update_bid_strategy)
|
||||
|
||||
campaigns = client.get("/api/catalyst/campaigns")
|
||||
assert campaigns.status_code == 200
|
||||
assert campaigns.json()["data"][0]["platform"] == "google"
|
||||
assert campaigns.json()["data"][0]["conversions"] == 38
|
||||
|
||||
budget = client.put(
|
||||
"/api/catalyst/budget",
|
||||
json={"campaign_id": "google-camp-001", "platform": "google", "daily_budget": 6500},
|
||||
)
|
||||
assert budget.status_code == 200
|
||||
assert budget.json()["data"]["platform"] == "google"
|
||||
|
||||
bid = client.put(
|
||||
"/api/catalyst/bid-strategy",
|
||||
json={
|
||||
"campaign_id": "google-camp-001",
|
||||
"platform": "google",
|
||||
"strategy": "TARGET_ROAS",
|
||||
"target_value": 8.5,
|
||||
},
|
||||
)
|
||||
assert bid.status_code == 200
|
||||
assert bid.json()["data"]["new_strategy"] == "TARGET_ROAS"
|
||||
215
backend/tests/test_crm_routes.py
Normal file
215
backend/tests/test_crm_routes.py
Normal file
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api.routes_crm import analytics_router, crm_router
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
self.leads: dict[str, dict[str, Any]] = {}
|
||||
self.chat_logs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
async def execute(self, query: str, *args):
|
||||
normalized = query.strip()
|
||||
if "CREATE TABLE IF NOT EXISTS leads" in normalized or "CREATE TABLE IF NOT EXISTS chat_logs" in normalized:
|
||||
return "CREATE"
|
||||
if "CREATE INDEX IF NOT EXISTS" in normalized:
|
||||
return "CREATE INDEX"
|
||||
if normalized.startswith("DELETE FROM leads WHERE id = $1"):
|
||||
existed = self.leads.pop(args[0], None)
|
||||
return "DELETE 1" if existed else "DELETE 0"
|
||||
raise AssertionError(f"Unexpected execute query: {query}")
|
||||
|
||||
async def fetchrow(self, query: str, *args):
|
||||
normalized = query.strip()
|
||||
if "INSERT INTO leads" in normalized:
|
||||
row = {
|
||||
"id": args[0],
|
||||
"name": args[1],
|
||||
"email": args[2],
|
||||
"phone": args[3],
|
||||
"source": args[4],
|
||||
"notes": args[5],
|
||||
"qualification": args[6],
|
||||
"score": args[7],
|
||||
"kanban_status": args[8],
|
||||
"budget": args[9],
|
||||
"unit_interest": args[10],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
}
|
||||
self.leads[row["id"]] = row
|
||||
return row
|
||||
if normalized.startswith("UPDATE leads") and "SET kanban_status" in normalized:
|
||||
lead = self.leads.get(args[0])
|
||||
if not lead:
|
||||
return None
|
||||
lead["kanban_status"] = args[1]
|
||||
lead["updated_at"] = _now()
|
||||
if lead["score"] >= 90:
|
||||
lead["qualification"] = "WHALE"
|
||||
elif lead["score"] >= 70:
|
||||
lead["qualification"] = "POTENTIAL"
|
||||
elif lead["score"] >= 45:
|
||||
lead["qualification"] = "HOT"
|
||||
return lead
|
||||
if normalized.startswith("UPDATE leads") and "RETURNING" in normalized:
|
||||
lead = self.leads.get(args[0])
|
||||
if not lead:
|
||||
return None
|
||||
lead.update(
|
||||
{
|
||||
"name": args[1],
|
||||
"email": args[2],
|
||||
"phone": args[3],
|
||||
"source": args[4],
|
||||
"notes": args[5],
|
||||
"qualification": args[6],
|
||||
"score": args[7],
|
||||
"kanban_status": args[8],
|
||||
"budget": args[9],
|
||||
"unit_interest": args[10],
|
||||
"updated_at": _now(),
|
||||
}
|
||||
)
|
||||
return lead
|
||||
if normalized.startswith("SELECT id FROM leads WHERE id = $1"):
|
||||
lead = self.leads.get(args[0])
|
||||
return {"id": lead["id"]} if lead else None
|
||||
if "INSERT INTO chat_logs" in normalized:
|
||||
row = {
|
||||
"id": args[0],
|
||||
"lead_id": args[1],
|
||||
"sender": args[2],
|
||||
"channel": args[3],
|
||||
"content": args[4],
|
||||
"metadata": {},
|
||||
"created_at": _now(),
|
||||
}
|
||||
self.chat_logs[row["id"]] = row
|
||||
return row
|
||||
raise AssertionError(f"Unexpected fetchrow query: {query}")
|
||||
|
||||
async def fetch(self, query: str, *args):
|
||||
normalized = query.strip()
|
||||
if "FROM leads" in normalized and "GROUP BY source" not in normalized and "GROUP BY qualification" not in normalized:
|
||||
rows = list(self.leads.values())
|
||||
if "WHERE kanban_status = $1" in normalized:
|
||||
rows = [row for row in rows if row["kanban_status"] == args[0]]
|
||||
return rows
|
||||
if "FROM chat_logs" in normalized:
|
||||
rows = list(self.chat_logs.values())
|
||||
if "WHERE lead_id = $1" in normalized:
|
||||
rows = [row for row in rows if row["lead_id"] == args[0]]
|
||||
return rows
|
||||
if "GROUP BY source" in normalized:
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for lead in self.leads.values():
|
||||
slot = grouped.setdefault(lead["source"], {"source": lead["source"], "lead_count": 0, "avg_score": 0.0})
|
||||
slot["lead_count"] += 1
|
||||
slot["avg_score"] += float(lead["score"])
|
||||
for slot in grouped.values():
|
||||
slot["avg_score"] = slot["avg_score"] / slot["lead_count"]
|
||||
return list(grouped.values())
|
||||
if "GROUP BY qualification" in normalized:
|
||||
grouped: dict[str, dict[str, Any]] = {}
|
||||
for lead in self.leads.values():
|
||||
slot = grouped.setdefault(lead["qualification"], {"qualification": lead["qualification"], "lead_count": 0})
|
||||
slot["lead_count"] += 1
|
||||
return list(grouped.values())
|
||||
raise AssertionError(f"Unexpected fetch query: {query}")
|
||||
|
||||
|
||||
class FakePool:
|
||||
def __init__(self) -> None:
|
||||
self.conn = FakeConn()
|
||||
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield self.conn
|
||||
|
||||
|
||||
def _build_client() -> tuple[TestClient, FakePool]:
|
||||
app = FastAPI()
|
||||
pool = FakePool()
|
||||
app.state.db_pool = pool
|
||||
app.include_router(crm_router, prefix="/api")
|
||||
app.include_router(analytics_router, prefix="/api/analytics")
|
||||
return TestClient(app), pool
|
||||
|
||||
|
||||
def test_crm_crud_and_analytics_flow() -> None:
|
||||
client, _pool = _build_client()
|
||||
|
||||
create_response = client.post(
|
||||
"/api/leads",
|
||||
json={
|
||||
"name": "Amina Rahman",
|
||||
"email": "amina@example.com",
|
||||
"phone": "+971500000001",
|
||||
"source": "website",
|
||||
"notes": "Cash buyer interested in marina penthouse",
|
||||
"score": 92,
|
||||
"kanban_status": "qualified",
|
||||
"budget": "AED 12M",
|
||||
"unit_interest": "Penthouse",
|
||||
"metadata": {"campaign": "meta-velocity-marina"},
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
lead_id = create_response.json()["data"]["id"]
|
||||
|
||||
list_response = client.get("/api/leads")
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["meta"]["count"] == 1
|
||||
|
||||
chat_response = client.post(
|
||||
"/api/chat-logs",
|
||||
json={
|
||||
"lead_id": lead_id,
|
||||
"sender": "oracle",
|
||||
"channel": "whatsapp",
|
||||
"content": "Lead requested a private marina walkthrough.",
|
||||
"metadata": {"sentiment": "positive"},
|
||||
},
|
||||
)
|
||||
assert chat_response.status_code == 201
|
||||
|
||||
board_response = client.get("/api/kanban/board")
|
||||
assert board_response.status_code == 200
|
||||
board = board_response.json()["data"]
|
||||
qualifying_column = next(column for column in board if column["status"] == "Qualifying")
|
||||
assert qualifying_column["count"] == 1
|
||||
|
||||
move_response = client.put("/api/kanban/move", json={"lead_id": lead_id, "target_status": "negotiation"})
|
||||
assert move_response.status_code == 200
|
||||
assert move_response.json()["data"]["kanban_status"] == "Negotiation"
|
||||
|
||||
scatter_response = client.get("/api/analytics/sentiment-scatter")
|
||||
assert scatter_response.status_code == 200
|
||||
scatter = scatter_response.json()
|
||||
assert scatter[0]["qualification"] == "WHALE"
|
||||
assert scatter[0]["kanban_status"] == "Negotiation"
|
||||
|
||||
|
||||
def test_lead_demographics_groups_by_source_and_qualification() -> None:
|
||||
client, _pool = _build_client()
|
||||
client.post("/api/leads", json={"name": "Lead One", "source": "website", "score": 80})
|
||||
client.post("/api/leads", json={"name": "Lead Two", "source": "walkin", "score": 45})
|
||||
|
||||
response = client.get("/api/leads/demographics")
|
||||
assert response.status_code == 200
|
||||
payload = response.json()["data"]
|
||||
assert len(payload["by_source"]) == 2
|
||||
assert any(row["qualification"] == "POTENTIAL" for row in payload["by_qualification"])
|
||||
20
backend/tests/test_crm_websocket.py
Normal file
20
backend/tests/test_crm_websocket.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("VELOCITY_JWT_SECRET", "test-secret")
|
||||
|
||||
from backend.main import app
|
||||
|
||||
|
||||
def test_crm_websocket_ack_roundtrip() -> None:
|
||||
with TestClient(app) as client:
|
||||
with client.websocket_connect("/ws/crm") as websocket:
|
||||
first = websocket.receive_json()
|
||||
assert first["type"] == "crm_presence"
|
||||
websocket.send_text("ping")
|
||||
second = websocket.receive_json()
|
||||
assert second["type"] == "crm_ack"
|
||||
assert second["data"] == "ping"
|
||||
20
backend/tests/test_nemoclaw_runtime.py
Normal file
20
backend/tests/test_nemoclaw_runtime.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from backend.services.mcp_registry import mcp_registry
|
||||
from backend.services.nemoclaw_runtime import nemoclaw_runtime
|
||||
|
||||
|
||||
def test_nemoclaw_runtime_builds_workflow_dispatch() -> None:
|
||||
dispatch = nemoclaw_runtime.build_workflow_dispatch(
|
||||
prompt="Build a marketing-ready Oracle canvas",
|
||||
tenant_id="tenant_velocity",
|
||||
actor_role="sales_director",
|
||||
component_templates=["tpl_pipeline_board_v2"],
|
||||
)
|
||||
assert dispatch["runtime"] == "python_native_nemoclaw"
|
||||
assert dispatch["workflow"] == "oracle_canvas_generation"
|
||||
|
||||
|
||||
def test_mcp_registry_lists_root_python_tools() -> None:
|
||||
tools = mcp_registry.list_tools()
|
||||
names = {tool["name"] for tool in tools}
|
||||
assert "crm_search" in names
|
||||
assert "external_search" in names
|
||||
64
backend/tests/test_oracle_routes.py
Normal file
64
backend/tests/test_oracle_routes.py
Normal file
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from backend.api.routes_oracle import router
|
||||
|
||||
|
||||
def _build_client() -> TestClient:
|
||||
app = FastAPI()
|
||||
app.state.db_pool = object()
|
||||
|
||||
async def _broadcast_crm_event(*_args, **_kwargs):
|
||||
return None
|
||||
|
||||
app.state.broadcast_crm_event = _broadcast_crm_event
|
||||
app.include_router(router, prefix="/api/oracle")
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def test_oracle_mcp_execute_and_writeback(monkeypatch) -> None:
|
||||
client = _build_client()
|
||||
|
||||
async def fake_execute(tool_name, query, *, crm_pool=None):
|
||||
return {"tool": tool_name, "query": query, "status": "ok", "results": [{"title": "Match"}]}
|
||||
|
||||
async def fake_apply_writeback(payload):
|
||||
return {
|
||||
"actionId": payload["action_id"],
|
||||
"status": "applied",
|
||||
"targetEntityType": payload["target_entity_type"],
|
||||
"targetEntityId": payload["target_entity_id"],
|
||||
"resultPayload": {"lead_id": payload["target_entity_id"], "score": 88},
|
||||
}
|
||||
|
||||
async def fake_list_actions(*, status=None, limit=50):
|
||||
return [{"actionId": "act-1", "status": status or "planned", "targetEntityType": "lead"}]
|
||||
|
||||
monkeypatch.setattr("backend.api.routes_oracle.mcp_registry.execute", fake_execute)
|
||||
monkeypatch.setattr("backend.api.routes_oracle.oracle_action_service.apply_writeback", fake_apply_writeback)
|
||||
monkeypatch.setattr("backend.api.routes_oracle.oracle_action_service.list_actions", fake_list_actions)
|
||||
|
||||
mcp_response = client.post(
|
||||
"/api/oracle/mcp/execute",
|
||||
json={"tool_name": "external_search", "query": "luxury marina inventory dubai"},
|
||||
)
|
||||
assert mcp_response.status_code == 200
|
||||
assert mcp_response.json()["data"]["tool"] == "external_search"
|
||||
|
||||
writeback_response = client.post(
|
||||
"/api/oracle/actions/writeback",
|
||||
json={
|
||||
"action_id": "act-1",
|
||||
"target_entity_type": "lead",
|
||||
"target_entity_id": "lead-1",
|
||||
"writeback_payload": {"score_delta": 12},
|
||||
},
|
||||
)
|
||||
assert writeback_response.status_code == 200
|
||||
assert writeback_response.json()["data"]["status"] == "applied"
|
||||
|
||||
list_response = client.get("/api/oracle/actions")
|
||||
assert list_response.status_code == 200
|
||||
assert list_response.json()["meta"]["count"] == 1
|
||||
Reference in New Issue
Block a user