mirror of
https://github.com/x1xhlol/system-prompts-and-models-of-ai-tools.git
synced 2026-06-20 00:09:33 +00:00
Merge pull request #28 from VoXc2/sync/dealix-full-complete
feat(dealix): sync full local Dealix package to ai-company
This commit is contained in:
commit
265f1c6185
84
.github/workflows/dealix-api-ci.yml
vendored
84
.github/workflows/dealix-api-ci.yml
vendored
@ -1,5 +1,6 @@
|
||||
# Canonical CI for the Dealix API package (monorepo).
|
||||
# GitHub only loads workflows from the repository root .github/workflows/.
|
||||
# Three jobs so branch protection can require: pytest, smoke_inprocess, launch_readiness.
|
||||
name: Dealix API CI
|
||||
|
||||
on:
|
||||
@ -18,8 +19,17 @@ defaults:
|
||||
run:
|
||||
working-directory: dealix
|
||||
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
|
||||
jobs:
|
||||
test:
|
||||
pytest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@ -41,37 +51,53 @@ jobs:
|
||||
run: python -m compileall api auto_client_acquisition
|
||||
|
||||
- name: Tests
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: pytest -q --no-cov
|
||||
|
||||
- name: In-process API smoke
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: python scripts/smoke_inprocess.py
|
||||
# tests/unit expects alternate package facades; run canonical integration tests only.
|
||||
run: pytest -q --no-cov --ignore=tests/unit
|
||||
|
||||
- name: Embeddings pipeline placeholder
|
||||
run: python scripts/embeddings_pipeline_placeholder.py
|
||||
|
||||
- name: Deterministic eval smoke
|
||||
env:
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: python scripts/run_evals.py
|
||||
|
||||
smoke_inprocess:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: dealix/requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio httpx aiosqlite
|
||||
|
||||
- name: In-process API smoke
|
||||
run: python scripts/smoke_inprocess.py
|
||||
|
||||
launch_readiness:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: dealix/requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio httpx aiosqlite
|
||||
|
||||
- name: Launch readiness (GO_PRIVATE_BETA gate)
|
||||
run: python scripts/launch_readiness_check.py
|
||||
|
||||
35
.github/workflows/dealix-staging-smoke.yml
vendored
35
.github/workflows/dealix-staging-smoke.yml
vendored
@ -1,4 +1,4 @@
|
||||
# Manual smoke against a deployed Dealix staging URL (secrets in GitHub only).
|
||||
# Manual smoke + launch readiness against a deployed Dealix staging URL (secrets in GitHub only).
|
||||
name: Dealix staging smoke
|
||||
|
||||
on:
|
||||
@ -17,17 +17,46 @@ jobs:
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: pip
|
||||
cache-dependency-path: dealix/requirements.txt
|
||||
|
||||
- name: Install httpx
|
||||
run: pip install httpx
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements.txt
|
||||
pip install pytest pytest-asyncio httpx aiosqlite
|
||||
|
||||
- name: Run staging smoke
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
STAGING_API_KEY: ${{ secrets.STAGING_API_KEY }}
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: |
|
||||
if [ -z "$STAGING_BASE_URL" ]; then
|
||||
echo "STAGING_BASE_URL secret not set — skipping."
|
||||
exit 0
|
||||
fi
|
||||
python scripts/smoke_staging.py --base-url "$STAGING_BASE_URL"
|
||||
|
||||
- name: Launch readiness (expect PAID_BETA_READY)
|
||||
env:
|
||||
STAGING_BASE_URL: ${{ secrets.STAGING_BASE_URL }}
|
||||
APP_ENV: test
|
||||
APP_DEBUG: "false"
|
||||
ANTHROPIC_API_KEY: test-anthropic-key
|
||||
DEEPSEEK_API_KEY: test-deepseek-key
|
||||
GROQ_API_KEY: test-groq-key
|
||||
GLM_API_KEY: test-glm-key
|
||||
GOOGLE_API_KEY: test-google-key
|
||||
run: |
|
||||
if [ -z "$STAGING_BASE_URL" ]; then
|
||||
echo "STAGING_BASE_URL secret not set — skipping launch readiness."
|
||||
exit 0
|
||||
fi
|
||||
python scripts/launch_readiness_check.py --base-url "$STAGING_BASE_URL"
|
||||
|
||||
@ -22,7 +22,7 @@ from api.routers import (
|
||||
autonomous,
|
||||
business,
|
||||
command_center,
|
||||
connector_catalog,
|
||||
connector_router,
|
||||
customer_ops,
|
||||
customer_success,
|
||||
data,
|
||||
@ -46,9 +46,8 @@ from api.routers import (
|
||||
pricing,
|
||||
prospect,
|
||||
public,
|
||||
revenue,
|
||||
revenue_company_os,
|
||||
revenue_launch,
|
||||
revenue,
|
||||
revenue_os,
|
||||
sales,
|
||||
sectors,
|
||||
@ -147,6 +146,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(pricing.router)
|
||||
app.include_router(prospect.router)
|
||||
app.include_router(autonomous.router)
|
||||
app.include_router(autonomous_service_operator.router)
|
||||
app.include_router(data.router)
|
||||
app.include_router(outreach.router)
|
||||
app.include_router(revenue.router)
|
||||
@ -156,30 +156,28 @@ def create_app() -> FastAPI:
|
||||
app.include_router(dominance.router)
|
||||
app.include_router(full_os.router)
|
||||
app.include_router(customer_success.router)
|
||||
app.include_router(customer_ops.router)
|
||||
app.include_router(ecosystem.router)
|
||||
app.include_router(command_center.router)
|
||||
app.include_router(revenue_os.router)
|
||||
app.include_router(v3.router)
|
||||
app.include_router(innovation.router)
|
||||
app.include_router(business.router)
|
||||
app.include_router(personal_operator.router)
|
||||
app.include_router(growth_operator.router)
|
||||
app.include_router(platform_services.router)
|
||||
app.include_router(intelligence_layer.router)
|
||||
app.include_router(growth_operator.router)
|
||||
app.include_router(security_curator.router)
|
||||
app.include_router(growth_curator.router)
|
||||
app.include_router(meeting_intelligence.router)
|
||||
app.include_router(model_router.router)
|
||||
app.include_router(connector_catalog.router)
|
||||
app.include_router(connector_router.router)
|
||||
app.include_router(agent_observability.router)
|
||||
app.include_router(targeting_os.router)
|
||||
app.include_router(service_tower.router)
|
||||
app.include_router(service_excellence.router)
|
||||
app.include_router(launch_ops.router)
|
||||
app.include_router(revenue_launch.router)
|
||||
app.include_router(autonomous_service_operator.router)
|
||||
app.include_router(revenue_company_os.router)
|
||||
app.include_router(customer_ops.router)
|
||||
app.include_router(business.router)
|
||||
app.include_router(personal_operator.router)
|
||||
app.include_router(public.router)
|
||||
app.include_router(admin.router)
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Agent Observability router — trace events + safety/tone evals."""
|
||||
"""Agent observability demo endpoints — evals and trace shapes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,45 +6,36 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.agent_observability import (
|
||||
build_trace_event,
|
||||
run_eval_pack,
|
||||
safety_eval,
|
||||
saudi_tone_eval,
|
||||
)
|
||||
from auto_client_acquisition.agent_observability.safety_eval import evaluate_safety
|
||||
from auto_client_acquisition.agent_observability.saudi_tone_eval import evaluate_saudi_tone
|
||||
from auto_client_acquisition.agent_observability.trace_events import build_trace_event
|
||||
|
||||
router = APIRouter(prefix="/api/v1/agent-observability", tags=["agent-observability"])
|
||||
router = APIRouter(prefix="/api/v1/agent-observability", tags=["agent_observability"])
|
||||
|
||||
|
||||
@router.get("/demo")
|
||||
async def demo() -> dict[str, Any]:
|
||||
return {"ok": True, "message_ar": "تتبع وتقييم — اربط Langfuse في staging للإنتاج.", "demo": True}
|
||||
|
||||
|
||||
@router.post("/eval/safety")
|
||||
async def eval_safety(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return evaluate_safety(str(payload.get("text_ar") or ""))
|
||||
|
||||
|
||||
@router.post("/eval/saudi-tone")
|
||||
async def eval_saudi_tone(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return evaluate_saudi_tone(str(payload.get("text_ar") or ""))
|
||||
|
||||
|
||||
@router.post("/trace/build")
|
||||
async def trace_build(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async def trace_build(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_trace_event(
|
||||
workflow_name=payload.get("workflow_name", "unknown"),
|
||||
agent_name=payload.get("agent_name", "unknown"),
|
||||
status=payload.get("status", "started"),
|
||||
user_id=payload.get("user_id"),
|
||||
company_id=payload.get("company_id"),
|
||||
tool=payload.get("tool"),
|
||||
policy_result=payload.get("policy_result"),
|
||||
risk_level=payload.get("risk_level"),
|
||||
approval_status=payload.get("approval_status"),
|
||||
latency_ms=float(payload.get("latency_ms", 0)),
|
||||
cost_estimate=float(payload.get("cost_estimate", 0)),
|
||||
payload=payload.get("payload"),
|
||||
output=payload.get("output"),
|
||||
workflow_name=str(payload.get("workflow_name") or "demo"),
|
||||
agent_name=str(payload.get("agent_name") or "dealix"),
|
||||
action_type=str(payload.get("action_type") or "draft"),
|
||||
policy_result=str(payload.get("policy_result") or "approval_required"),
|
||||
tool_called=payload.get("tool_called"),
|
||||
outcome=payload.get("outcome"),
|
||||
metadata=payload.get("metadata") if isinstance(payload.get("metadata"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/safety/eval")
|
||||
async def safety_eval_endpoint(text: str = Body(..., embed=True)) -> dict[str, Any]:
|
||||
return safety_eval(text)
|
||||
|
||||
|
||||
@router.post("/tone/eval")
|
||||
async def tone_eval(text: str = Body(..., embed=True)) -> dict[str, Any]:
|
||||
return saudi_tone_eval(text)
|
||||
|
||||
|
||||
@router.get("/evals/run")
|
||||
async def evals_run() -> dict[str, Any]:
|
||||
return run_eval_pack()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Autonomous Service Operator router — chat + decisions + sessions + bundles."""
|
||||
"""Autonomous Service Operator — /api/v1/operator (deterministic MVP)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -7,298 +7,131 @@ from typing import Any
|
||||
from fastapi import APIRouter, Body, HTTPException
|
||||
|
||||
from auto_client_acquisition.autonomous_service_operator import (
|
||||
OperatorMemory,
|
||||
add_agency_client,
|
||||
build_agency_dashboard,
|
||||
build_approval_card,
|
||||
build_ceo_command_center,
|
||||
build_client_dashboard,
|
||||
build_co_branded_proof_pack,
|
||||
build_executive_daily_brief,
|
||||
build_intake_questions_for_intent,
|
||||
build_new_session,
|
||||
build_revenue_risks_summary,
|
||||
build_service_pipeline,
|
||||
build_session_context,
|
||||
build_upsell_card,
|
||||
classify_intent,
|
||||
dispatch_proof_pack,
|
||||
handle_message,
|
||||
list_bundles,
|
||||
list_agency_revenue_share,
|
||||
plan_tool_action,
|
||||
process_approval_decision,
|
||||
recommend_bundle,
|
||||
recommend_upsell_after_service,
|
||||
render_approval_card_for_whatsapp,
|
||||
render_card_for_whatsapp,
|
||||
render_daily_brief_for_whatsapp,
|
||||
transition_session,
|
||||
validate_intake_completeness,
|
||||
approval_manager as am,
|
||||
agency_mode,
|
||||
client_mode,
|
||||
conversation_router,
|
||||
executive_mode,
|
||||
intake_collector,
|
||||
proof_pack_dispatcher,
|
||||
self_growth_mode,
|
||||
service_bundles,
|
||||
service_delivery_mode,
|
||||
session_state as ss,
|
||||
tool_action_planner,
|
||||
upsell_engine,
|
||||
whatsapp_renderer,
|
||||
workflow_runner as wr,
|
||||
)
|
||||
from auto_client_acquisition.service_excellence.service_scoring import calculate_service_excellence_score
|
||||
|
||||
router = APIRouter(prefix="/api/v1/operator", tags=["autonomous-service-operator"])
|
||||
|
||||
# Process-level memory (demo). Production = Redis/Supabase.
|
||||
_MEMORY = OperatorMemory()
|
||||
router = APIRouter(prefix="/api/v1/operator", tags=["autonomous_service_operator"])
|
||||
|
||||
|
||||
def _mode_profile(mode: str) -> dict[str, Any]:
|
||||
m = (mode or "client").strip().lower()
|
||||
if m == "executive":
|
||||
return executive_mode.mode_profile()
|
||||
if m in ("agency_partner", "agency"):
|
||||
return agency_mode.mode_profile()
|
||||
if m in ("self_growth", "self-growth"):
|
||||
return self_growth_mode.mode_profile()
|
||||
if m in ("service_delivery", "delivery"):
|
||||
return service_delivery_mode.mode_profile()
|
||||
return client_mode.mode_profile()
|
||||
|
||||
|
||||
# ── Chat ─────────────────────────────────────────────────────
|
||||
@router.post("/chat/message")
|
||||
async def chat_message(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Send a message to the operator. Classifies intent + recommends action."""
|
||||
return handle_message(
|
||||
message=payload.get("message", ""),
|
||||
customer_id=payload.get("customer_id"),
|
||||
has_contact_list=bool(payload.get("has_contact_list", False)),
|
||||
is_agency=bool(payload.get("is_agency", False)),
|
||||
is_local_business=bool(payload.get("is_local_business", False)),
|
||||
budget_sar=int(payload.get("budget_sar", 1000)),
|
||||
)
|
||||
async def operator_chat_message(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
msg = str(body.get("message") or "").strip()
|
||||
if not msg:
|
||||
raise HTTPException(status_code=400, detail="message_required")
|
||||
sid = str(body.get("session_id") or ss.new_session_id())
|
||||
ss.touch_session(sid)
|
||||
mode = str(body.get("mode") or "client")
|
||||
result = conversation_router.handle_message(sid, msg, mode=mode)
|
||||
result["mode_profile"] = _mode_profile(mode)
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/chat/decision")
|
||||
async def chat_decision(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Process an approval/edit/skip decision on an action card."""
|
||||
card = payload.get("card") or build_approval_card(
|
||||
action_type="example",
|
||||
title_ar="فعل مثال",
|
||||
summary_ar="مثال",
|
||||
)
|
||||
return process_approval_decision(
|
||||
card,
|
||||
decision=payload.get("decision", "skip"),
|
||||
decided_by=payload.get("decided_by", "user"),
|
||||
note=payload.get("note", ""),
|
||||
)
|
||||
async def operator_chat_decision(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
sid = str(body.get("session_id") or "").strip()
|
||||
dec = str(body.get("decision") or "").strip()
|
||||
if not sid or not dec:
|
||||
raise HTTPException(status_code=400, detail="session_id_and_decision_required")
|
||||
updated = am.apply_decision(sid, dec)
|
||||
return {"session": updated, "demo": True}
|
||||
|
||||
|
||||
@router.post("/chat/classify")
|
||||
async def chat_classify(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return classify_intent(payload.get("message", ""))
|
||||
@router.get("/session/{session_id}")
|
||||
async def operator_get_session(session_id: str) -> dict[str, Any]:
|
||||
s = ss.get_session(session_id)
|
||||
if not s:
|
||||
raise HTTPException(status_code=404, detail="session_not_found")
|
||||
return {**s, "demo": True}
|
||||
|
||||
|
||||
# ── Sessions ─────────────────────────────────────────────────
|
||||
@router.post("/sessions/new")
|
||||
async def sessions_new(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
session = build_new_session(customer_id=payload.get("customer_id"))
|
||||
_MEMORY.upsert_session(session)
|
||||
return session.to_dict()
|
||||
@router.get("/cards/pending")
|
||||
async def operator_cards_pending() -> dict[str, Any]:
|
||||
return {"pending": ss.list_sessions_with_pending(), "demo": True}
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}")
|
||||
async def sessions_get(session_id: str) -> dict[str, Any]:
|
||||
session = _MEMORY.get_session(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
return session.to_dict()
|
||||
|
||||
|
||||
@router.post("/sessions/{session_id}/transition")
|
||||
async def sessions_transition(
|
||||
session_id: str,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
) -> dict[str, Any]:
|
||||
session = _MEMORY.get_session(session_id)
|
||||
if session is None:
|
||||
raise HTTPException(status_code=404, detail="session not found")
|
||||
transition_session(
|
||||
session,
|
||||
new_state=payload.get("new_state", "new"),
|
||||
note=payload.get("note", ""),
|
||||
)
|
||||
return session.to_dict()
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/context")
|
||||
async def sessions_context(session_id: str) -> dict[str, Any]:
|
||||
return build_session_context(memory=_MEMORY, session_id=session_id)
|
||||
|
||||
|
||||
# ── Cards / Approvals ────────────────────────────────────────
|
||||
@router.post("/cards/approval")
|
||||
async def cards_approval(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_approval_card(
|
||||
action_type=payload.get("action_type", "unknown"),
|
||||
title_ar=payload.get("title_ar", ""),
|
||||
summary_ar=payload.get("summary_ar", ""),
|
||||
risk_level=payload.get("risk_level", "low"),
|
||||
why_now_ar=payload.get("why_now_ar", ""),
|
||||
recommended_action_ar=payload.get("recommended_action_ar", ""),
|
||||
expected_impact_sar=float(payload.get("expected_impact_sar", 0)),
|
||||
service_id=payload.get("service_id"),
|
||||
customer_id=payload.get("customer_id"),
|
||||
action_id=payload.get("action_id"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cards/whatsapp/render")
|
||||
async def cards_whatsapp_render(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
kind = payload.get("kind", "card")
|
||||
if kind == "approval":
|
||||
return render_approval_card_for_whatsapp(payload.get("card") or {})
|
||||
if kind == "daily_brief":
|
||||
return render_daily_brief_for_whatsapp(payload.get("brief") or {})
|
||||
return render_card_for_whatsapp(payload.get("card") or {})
|
||||
|
||||
|
||||
# ── Intake ───────────────────────────────────────────────────
|
||||
@router.get("/intake/questions/{intent}")
|
||||
async def intake_questions(intent: str) -> dict[str, Any]:
|
||||
return build_intake_questions_for_intent(intent)
|
||||
|
||||
|
||||
@router.post("/intake/validate")
|
||||
async def intake_validate(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return validate_intake_completeness(
|
||||
payload.get("intent", "ask_services"),
|
||||
payload.get("payload") or {},
|
||||
)
|
||||
|
||||
|
||||
# ── Service workflow ─────────────────────────────────────────
|
||||
@router.post("/service/start")
|
||||
async def service_start(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_service_pipeline(
|
||||
service_id=payload.get("service_id", ""),
|
||||
customer_id=payload.get("customer_id", ""),
|
||||
async def operator_service_start(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
sid = str(body.get("session_id") or ss.new_session_id())
|
||||
svc_id = str(body.get("service_id") or "").strip()
|
||||
if not svc_id:
|
||||
raise HTTPException(status_code=400, detail="service_id_required")
|
||||
ss.touch_session(sid)
|
||||
wr.advance(sid, "start_service")
|
||||
intake = intake_collector.intake_questions(svc_id)
|
||||
am.set_pending_approval(
|
||||
sid,
|
||||
{
|
||||
"title_ar": f"بدء خدمة: {svc_id}",
|
||||
"buttons_ar": ["موافقة", "تعديل", "تخطي"],
|
||||
"service_id": svc_id,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"session_id": sid,
|
||||
"intake": intake,
|
||||
"excellence": calculate_service_excellence_score(svc_id),
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/tools/plan")
|
||||
async def tools_plan(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return plan_tool_action(
|
||||
tool=payload.get("tool", ""),
|
||||
payload=payload.get("payload"),
|
||||
customer_id=payload.get("customer_id"),
|
||||
context=payload.get("context"),
|
||||
)
|
||||
|
||||
|
||||
# ── Proof + Upsell ───────────────────────────────────────────
|
||||
@router.post("/proof-pack/dispatch")
|
||||
async def proof_pack_dispatch(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return dispatch_proof_pack(
|
||||
service_id=payload.get("service_id", ""),
|
||||
customer_id=payload.get("customer_id"),
|
||||
channel=payload.get("channel", "email"),
|
||||
metrics=payload.get("metrics"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upsell/recommend")
|
||||
async def upsell_recommend(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return recommend_upsell_after_service(
|
||||
completed_service_id=payload.get("completed_service_id", ""),
|
||||
pilot_metrics=payload.get("pilot_metrics"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/upsell/card")
|
||||
async def upsell_card(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_upsell_card(
|
||||
completed_service_id=payload.get("completed_service_id", ""),
|
||||
pilot_metrics=payload.get("pilot_metrics"),
|
||||
)
|
||||
|
||||
|
||||
# ── Bundles ──────────────────────────────────────────────────
|
||||
@router.get("/bundles")
|
||||
async def bundles() -> dict[str, Any]:
|
||||
return list_bundles()
|
||||
|
||||
|
||||
@router.post("/bundles/recommend")
|
||||
async def bundles_recommend(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return recommend_bundle(
|
||||
intent=payload.get("intent"),
|
||||
has_contact_list=bool(payload.get("has_contact_list", False)),
|
||||
is_agency=bool(payload.get("is_agency", False)),
|
||||
is_local_business=bool(payload.get("is_local_business", False)),
|
||||
budget_sar=int(payload.get("budget_sar", 1000)),
|
||||
)
|
||||
|
||||
|
||||
# ── Modes ────────────────────────────────────────────────────
|
||||
@router.post("/mode/ceo")
|
||||
async def mode_ceo(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_ceo_command_center(
|
||||
company_name=payload.get("company_name", ""),
|
||||
sector=payload.get("sector", "saas"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mode/ceo/daily-brief")
|
||||
async def mode_ceo_daily(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_executive_daily_brief(
|
||||
company_name=payload.get("company_name", ""),
|
||||
sector=payload.get("sector", "saas"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mode/ceo/risks")
|
||||
async def mode_ceo_risks() -> dict[str, Any]:
|
||||
return build_revenue_risks_summary()
|
||||
|
||||
|
||||
@router.post("/mode/client")
|
||||
async def mode_client(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_client_dashboard(
|
||||
customer_id=payload.get("customer_id", ""),
|
||||
company_name=payload.get("company_name", ""),
|
||||
active_services=payload.get("active_services") or [],
|
||||
open_actions=int(payload.get("open_actions", 0)),
|
||||
proof_pack_due=bool(payload.get("proof_pack_due", False)),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mode/agency")
|
||||
async def mode_agency(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_agency_dashboard(
|
||||
agency_id=payload.get("agency_id", "agency_demo"),
|
||||
agency_name=payload.get("agency_name", ""),
|
||||
clients=payload.get("clients") or [],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mode/agency/add-client")
|
||||
async def mode_agency_add_client(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return add_agency_client(
|
||||
agency_id=payload.get("agency_id", "agency_demo"),
|
||||
client_company_name=payload.get("client_company_name", ""),
|
||||
sector=payload.get("sector", ""),
|
||||
monthly_subscription_sar=int(payload.get("monthly_subscription_sar", 0)),
|
||||
revenue_share_pct=int(payload.get("revenue_share_pct", 20)),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/mode/agency/revenue-share")
|
||||
async def mode_agency_revenue_share(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return list_agency_revenue_share(clients=payload.get("clients") or [])
|
||||
|
||||
|
||||
@router.post("/mode/agency/co-branded-proof")
|
||||
async def mode_agency_co_branded_proof(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_co_branded_proof_pack(
|
||||
agency_name=payload.get("agency_name", ""),
|
||||
client_company_name=payload.get("client_company_name", ""),
|
||||
metrics=payload.get("metrics"),
|
||||
)
|
||||
|
||||
|
||||
# ── Demos ────────────────────────────────────────────────────
|
||||
@router.get("/whatsapp/daily-brief/demo")
|
||||
async def whatsapp_daily_brief_demo() -> dict[str, Any]:
|
||||
brief = build_executive_daily_brief(company_name="Acme")
|
||||
return render_daily_brief_for_whatsapp(brief)
|
||||
@router.post("/service/continue")
|
||||
async def operator_service_continue(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
sid = str(body.get("session_id") or "").strip()
|
||||
event = str(body.get("event") or "draft_ready").strip()
|
||||
if not sid:
|
||||
raise HTTPException(status_code=400, detail="session_id_required")
|
||||
ss.touch_session(sid)
|
||||
return {"session": wr.advance(sid, event), "demo": True}
|
||||
|
||||
|
||||
@router.get("/proof-pack/demo")
|
||||
async def proof_pack_demo() -> dict[str, Any]:
|
||||
return dispatch_proof_pack(
|
||||
service_id="first_10_opportunities_sprint",
|
||||
customer_id="demo",
|
||||
metrics={"opportunities_generated": 10, "drafts_approved": 6,
|
||||
"meetings_drafted": 2, "pipeline_influenced_sar": 30000,
|
||||
"risks_blocked": 3},
|
||||
)
|
||||
async def operator_proof_pack_demo(service_id: str = "first_10_opportunities") -> dict[str, Any]:
|
||||
return proof_pack_dispatcher.build_proof_pack(service_id)
|
||||
|
||||
|
||||
@router.get("/whatsapp/daily-brief")
|
||||
async def operator_whatsapp_daily_brief() -> dict[str, Any]:
|
||||
return whatsapp_renderer.render_daily_brief_stub()
|
||||
|
||||
|
||||
@router.get("/bundles")
|
||||
async def operator_bundles() -> dict[str, Any]:
|
||||
return service_bundles.list_bundles()
|
||||
|
||||
|
||||
@router.get("/tools/matrix")
|
||||
async def operator_tools_matrix() -> dict[str, Any]:
|
||||
return tool_action_planner.list_tool_matrix()
|
||||
|
||||
|
||||
@router.get("/upsell")
|
||||
async def operator_upsell(service_id: str = "first_10_opportunities") -> dict[str, Any]:
|
||||
return upsell_engine.suggest_upsell(service_id)
|
||||
|
||||
16
dealix/api/routers/connector_router.py
Normal file
16
dealix/api/routers/connector_router.py
Normal file
@ -0,0 +1,16 @@
|
||||
"""Connector catalog HTTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from auto_client_acquisition.connectors.connector_catalog import build_connector_catalog
|
||||
|
||||
router = APIRouter(prefix="/api/v1/connectors", tags=["connectors"])
|
||||
|
||||
|
||||
@router.get("/catalog")
|
||||
async def catalog() -> dict[str, Any]:
|
||||
return build_connector_catalog()
|
||||
@ -1,208 +1,50 @@
|
||||
"""Customer Ops router — onboarding + connectors + support + SLA + incidents."""
|
||||
"""Customer ops API — onboarding, SLA, connectors (deterministic)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.customer_ops import (
|
||||
SUPPORT_PRIORITIES,
|
||||
SUPPORTED_CONNECTORS,
|
||||
build_at_risk_alert,
|
||||
build_connector_setup_summary,
|
||||
build_customer_success_plan,
|
||||
build_first_response_template,
|
||||
build_incident_response_plan,
|
||||
build_onboarding_checklist,
|
||||
build_sla_health_report,
|
||||
build_weekly_check_in,
|
||||
classify_sla_breach,
|
||||
classify_ticket_priority,
|
||||
record_sla_event,
|
||||
route_ticket,
|
||||
triage_incident,
|
||||
update_connector_status,
|
||||
update_onboarding_step,
|
||||
)
|
||||
from auto_client_acquisition.customer_ops.connector_setup_status import build_connector_status
|
||||
from auto_client_acquisition.customer_ops.customer_success_cadence import build_weekly_cadence
|
||||
from auto_client_acquisition.customer_ops.incident_router import build_incident_playbook, classify_incident
|
||||
from auto_client_acquisition.customer_ops.onboarding_checklist import build_onboarding_checklist
|
||||
from auto_client_acquisition.customer_ops.sla_tracker import build_sla_summary
|
||||
from auto_client_acquisition.customer_ops.support_ticket_router import route_ticket
|
||||
|
||||
router = APIRouter(prefix="/api/v1/customer-ops", tags=["customer-ops"])
|
||||
|
||||
|
||||
# ── Onboarding ───────────────────────────────────────────────
|
||||
@router.post("/onboarding/checklist")
|
||||
async def onboarding_checklist(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_onboarding_checklist(
|
||||
customer_id=payload.get("customer_id", ""),
|
||||
company_name=payload.get("company_name", ""),
|
||||
bundle_id=payload.get("bundle_id"),
|
||||
)
|
||||
@router.get("/onboarding/checklist")
|
||||
async def onboarding_checklist(service_id: str | None = None) -> dict[str, object]:
|
||||
return build_onboarding_checklist(service_id)
|
||||
|
||||
|
||||
@router.post("/onboarding/update-step")
|
||||
async def onboarding_update_step(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return update_onboarding_step(
|
||||
payload.get("checklist") or {},
|
||||
step_id=payload.get("step_id", ""),
|
||||
completed=bool(payload.get("completed", True)),
|
||||
notes=payload.get("notes", ""),
|
||||
)
|
||||
@router.get("/support/sla")
|
||||
async def support_sla() -> dict[str, object]:
|
||||
return build_sla_summary()
|
||||
|
||||
|
||||
@router.get("/onboarding/checklist/demo")
|
||||
async def onboarding_checklist_demo() -> dict[str, Any]:
|
||||
return build_onboarding_checklist(
|
||||
customer_id="demo", company_name="شركة نمو للتدريب",
|
||||
bundle_id="growth_starter",
|
||||
)
|
||||
@router.get("/connectors/status")
|
||||
async def connectors_status() -> dict[str, object]:
|
||||
return build_connector_status()
|
||||
|
||||
|
||||
# ── Connectors ───────────────────────────────────────────────
|
||||
@router.get("/connectors/catalog")
|
||||
async def connectors_catalog() -> dict[str, Any]:
|
||||
return {
|
||||
"total": len(SUPPORTED_CONNECTORS),
|
||||
"connectors": [dict(c) for c in SUPPORTED_CONNECTORS],
|
||||
}
|
||||
@router.get("/success/cadence")
|
||||
async def success_cadence() -> dict[str, object]:
|
||||
return build_weekly_cadence()
|
||||
|
||||
|
||||
@router.post("/connectors/summary")
|
||||
async def connectors_summary(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_connector_setup_summary(
|
||||
customer_id=payload.get("customer_id", ""),
|
||||
statuses=payload.get("statuses"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/connectors/update")
|
||||
async def connectors_update(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
statuses = payload.get("statuses") or {}
|
||||
try:
|
||||
return {"statuses": update_connector_status(
|
||||
statuses,
|
||||
connector_key=payload.get("connector_key", ""),
|
||||
state=payload.get("state", "not_started"),
|
||||
notes=payload.get("notes", ""),
|
||||
)}
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@router.get("/connectors/demo")
|
||||
async def connectors_demo() -> dict[str, Any]:
|
||||
return build_connector_setup_summary(
|
||||
customer_id="demo",
|
||||
statuses={
|
||||
"gmail": {"state": "connected_draft_only"},
|
||||
"google_calendar": {"state": "connected_draft_only"},
|
||||
"moyasar": {"state": "configuring"},
|
||||
"whatsapp_cloud": {"state": "not_started"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── Support ──────────────────────────────────────────────────
|
||||
@router.get("/support/priorities")
|
||||
async def support_priorities() -> dict[str, Any]:
|
||||
return {"priorities": [dict(p) for p in SUPPORT_PRIORITIES]}
|
||||
|
||||
|
||||
@router.post("/support/classify")
|
||||
async def support_classify(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return classify_ticket_priority(payload.get("text", ""))
|
||||
@router.get("/incidents/playbook")
|
||||
async def incidents_playbook() -> dict[str, object]:
|
||||
return build_incident_playbook()
|
||||
|
||||
|
||||
@router.post("/support/route")
|
||||
async def support_route(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return route_ticket(
|
||||
text=payload.get("text", ""),
|
||||
customer_id=payload.get("customer_id", ""),
|
||||
contact_email=payload.get("contact_email", ""),
|
||||
)
|
||||
async def support_route(payload: dict[str, object] = Body(default_factory=dict)) -> dict[str, object]:
|
||||
issue = str(payload.get("issue_ar") or "")
|
||||
return route_ticket(issue)
|
||||
|
||||
|
||||
@router.get("/support/first-response/{priority}")
|
||||
async def support_first_response(priority: str) -> dict[str, Any]:
|
||||
return build_first_response_template(priority)
|
||||
|
||||
|
||||
# ── SLA ──────────────────────────────────────────────────────
|
||||
@router.post("/sla/event")
|
||||
async def sla_event(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return record_sla_event(
|
||||
ticket_id=payload.get("ticket_id", ""),
|
||||
priority=payload.get("priority", "P3"),
|
||||
event=payload.get("event", "opened"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@router.post("/sla/classify-breach")
|
||||
async def sla_classify_breach(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return classify_sla_breach(
|
||||
priority=payload.get("priority", "P3"),
|
||||
minutes_to_first_response=payload.get("minutes_to_first_response"),
|
||||
hours_to_resolve=payload.get("hours_to_resolve"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sla/health-report")
|
||||
async def sla_health_report(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_sla_health_report(tickets=payload.get("tickets") or [])
|
||||
|
||||
|
||||
@router.get("/sla/health-report/demo")
|
||||
async def sla_health_report_demo() -> dict[str, Any]:
|
||||
return build_sla_health_report(tickets=[
|
||||
{"priority": "P0", "first_response_min": 12, "resolution_hours": 2.5},
|
||||
{"priority": "P1", "first_response_min": 90, "resolution_hours": 18},
|
||||
{"priority": "P2", "first_response_min": 600, "resolution_hours": 70},
|
||||
{"priority": "P3", "first_response_min": 1200, "resolution_hours": 100},
|
||||
])
|
||||
|
||||
|
||||
# ── Incidents ────────────────────────────────────────────────
|
||||
@router.post("/incidents/triage")
|
||||
async def incidents_triage(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return triage_incident(
|
||||
title=payload.get("title", ""),
|
||||
description=payload.get("description", ""),
|
||||
affected_customers=int(payload.get("affected_customers", 1)),
|
||||
has_data_leak=bool(payload.get("has_data_leak", False)),
|
||||
has_unauthorized_send=bool(payload.get("has_unauthorized_send", False)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/incidents/response-plan/{severity}")
|
||||
async def incidents_response_plan(severity: str) -> dict[str, Any]:
|
||||
return build_incident_response_plan(severity=severity)
|
||||
|
||||
|
||||
# ── Customer Success ─────────────────────────────────────────
|
||||
@router.post("/cs/weekly-check-in")
|
||||
async def cs_weekly_check_in(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_weekly_check_in(
|
||||
customer_id=payload.get("customer_id", ""),
|
||||
company_name=payload.get("company_name", ""),
|
||||
metrics=payload.get("metrics"),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cs/at-risk-alert")
|
||||
async def cs_at_risk_alert(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_at_risk_alert(
|
||||
customer_id=payload.get("customer_id", ""),
|
||||
days_inactive=int(payload.get("days_inactive", 0)),
|
||||
drafts_pending=int(payload.get("drafts_pending", 0)),
|
||||
last_proof_pack_days_ago=int(payload.get("last_proof_pack_days_ago", 0)),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cs/success-plan")
|
||||
async def cs_success_plan(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_customer_success_plan(
|
||||
customer_id=payload.get("customer_id", ""),
|
||||
bundle_id=payload.get("bundle_id", "growth_starter"),
|
||||
)
|
||||
@router.get("/incidents/classify")
|
||||
async def incidents_classify(severity: str = "P3") -> dict[str, object]:
|
||||
return classify_incident(severity)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Growth Curator router — message grading + weekly curator report."""
|
||||
"""Growth curator API — grading and weekly report."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,95 +6,33 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.growth_curator import (
|
||||
build_weekly_curator_report,
|
||||
detect_duplicates,
|
||||
grade_message,
|
||||
inventory_skills,
|
||||
recommend_next_mission,
|
||||
suggest_improvement,
|
||||
)
|
||||
from auto_client_acquisition.growth_curator.curator_report import build_weekly_curator_report
|
||||
from auto_client_acquisition.growth_curator.message_curator import grade_message
|
||||
from auto_client_acquisition.growth_curator.mission_curator import curate_missions_weekly
|
||||
from auto_client_acquisition.growth_curator.skill_inventory import list_skill_inventory
|
||||
|
||||
router = APIRouter(prefix="/api/v1/growth-curator", tags=["growth-curator"])
|
||||
|
||||
|
||||
@router.get("/skills/inventory")
|
||||
async def skills_inventory() -> dict[str, Any]:
|
||||
return inventory_skills()
|
||||
|
||||
|
||||
@router.post("/messages/grade")
|
||||
async def messages_grade(
|
||||
message: str = Body(..., embed=True),
|
||||
sector: str | None = Body(default=None, embed=True),
|
||||
channel: str = Body(default="whatsapp", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return grade_message(message, sector=sector, channel=channel).to_dict()
|
||||
|
||||
|
||||
@router.post("/messages/improve")
|
||||
async def messages_improve(
|
||||
message: str = Body(..., embed=True),
|
||||
sector: str | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return suggest_improvement(message, sector=sector)
|
||||
|
||||
|
||||
@router.post("/messages/duplicates")
|
||||
async def messages_duplicates(
|
||||
messages: list[str] = Body(..., embed=True),
|
||||
threshold: float = Body(default=0.85, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
pairs = detect_duplicates(messages, threshold=threshold)
|
||||
return {
|
||||
"pairs": [{"i": i, "j": j, "similarity": s} for i, j, s in pairs],
|
||||
"count": len(pairs),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/missions/next")
|
||||
async def missions_next(
|
||||
history: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
growth_brain: dict[str, Any] | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return recommend_next_mission(history, growth_brain=growth_brain)
|
||||
|
||||
|
||||
@router.post("/report/weekly")
|
||||
async def report_weekly(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_weekly_curator_report(
|
||||
messages=payload.get("messages", []),
|
||||
playbooks=payload.get("playbooks", []),
|
||||
missions=payload.get("missions", []),
|
||||
sector=payload.get("sector"),
|
||||
)
|
||||
router = APIRouter(prefix="/api/v1/growth-curator", tags=["growth_curator"])
|
||||
|
||||
|
||||
@router.get("/report/demo")
|
||||
async def report_demo() -> dict[str, Any]:
|
||||
"""Demo curator report with a small synthetic dataset."""
|
||||
return build_weekly_curator_report(
|
||||
messages=[
|
||||
{"id": "m1", "text": "هلا أحمد، لاحظت توسعكم في المبيعات. يناسبك أعرض لك Pilot 7 أيام؟"},
|
||||
{"id": "m2", "text": "هلا محمد، لاحظت توسعكم في المبيعات. يناسبك أعرض لك Pilot 7 أيام؟"},
|
||||
{"id": "m3", "text": "آخر فرصة! ضمان 100% نتائج مضمونة!"},
|
||||
{"id": "m4", "text": "Hi"},
|
||||
],
|
||||
playbooks=[
|
||||
{"id": "pb1", "title": "Warm B2B intro - training", "used_count": 20,
|
||||
"accept_count": 12, "replied_count": 8, "meeting_count": 4, "deal_count": 2,
|
||||
"sectors": "training"},
|
||||
{"id": "pb2", "title": "Warm B2B intro - training-ksa", "used_count": 8,
|
||||
"accept_count": 4, "replied_count": 2, "meeting_count": 1, "deal_count": 0,
|
||||
"sectors": "training"},
|
||||
{"id": "pb3", "title": "Cold call SaaS", "used_count": 50,
|
||||
"accept_count": 5, "replied_count": 2, "meeting_count": 0, "deal_count": 0,
|
||||
"sectors": "saas"},
|
||||
],
|
||||
missions=[
|
||||
{"mission_id": "first_10_opportunities", "opportunities_generated": 10,
|
||||
"drafts_approved": 4, "meetings_booked": 2, "revenue_influenced_sar": 18000,
|
||||
"time_to_value_minutes": 8, "risks_blocked": 2},
|
||||
],
|
||||
sector="training",
|
||||
return build_weekly_curator_report()
|
||||
|
||||
|
||||
@router.post("/messages/grade")
|
||||
async def messages_grade(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return grade_message(
|
||||
str(payload.get("message_ar") or ""),
|
||||
sector=str(payload.get("sector") or ""),
|
||||
channel=str(payload.get("channel") or "whatsapp"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/skills/demo")
|
||||
async def skills_demo() -> dict[str, Any]:
|
||||
return list_skill_inventory()
|
||||
|
||||
|
||||
@router.get("/missions/curate/demo")
|
||||
async def missions_curate_demo() -> dict[str, Any]:
|
||||
return curate_missions_weekly()
|
||||
|
||||
@ -1,260 +1,38 @@
|
||||
"""
|
||||
Growth Operator router — Arabic Growth Operator endpoints.
|
||||
Growth Operator — thin product-facing aliases over innovation + business.
|
||||
|
||||
Approval-first: every outbound is draft. Nothing is sent / charged /
|
||||
scheduled live from this router; that happens in dedicated send / billing
|
||||
/ calendar services after explicit user approval.
|
||||
لا يكرر منطق ten-in-ten؛ يعرّف مسارات متوقعة في وثائق الـ beta والـ smoke.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Query
|
||||
from fastapi import APIRouter
|
||||
|
||||
from auto_client_acquisition.growth_operator import (
|
||||
build_calendar_draft,
|
||||
build_meeting_agenda,
|
||||
build_moyasar_payment_link_draft,
|
||||
build_post_meeting_followup,
|
||||
build_weekly_proof_pack,
|
||||
contactability_summary,
|
||||
dedupe_contacts,
|
||||
draft_arabic_message,
|
||||
draft_followup,
|
||||
draft_objection_response,
|
||||
draft_partner_outreach,
|
||||
list_missions,
|
||||
partner_scorecard,
|
||||
profile_from_dict,
|
||||
recommend_top_10,
|
||||
run_mission,
|
||||
score_contactability,
|
||||
suggest_partner_types,
|
||||
summarize_import,
|
||||
)
|
||||
from auto_client_acquisition.business.proof_pack import build_demo_proof_pack
|
||||
from auto_client_acquisition.innovation.growth_missions import list_growth_missions
|
||||
|
||||
router = APIRouter(prefix="/api/v1/growth-operator", tags=["growth-operator"])
|
||||
log = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/v1/growth-operator", tags=["growth_operator"])
|
||||
|
||||
|
||||
# ── 1. Contacts: import preview ─────────────────────────────────
|
||||
@router.post("/contacts/import-preview")
|
||||
async def contacts_import_preview(
|
||||
contacts: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
channel: str = Body(default="whatsapp", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Preview import: dedupe + source classify + contactability summary."""
|
||||
deduped = dedupe_contacts(contacts)
|
||||
return {
|
||||
"import_summary": summarize_import(contacts),
|
||||
"contactability": contactability_summary(deduped, channel=channel),
|
||||
"policy_note_ar": (
|
||||
"العميل يرفع أرقام مملوكة/مصرح بها. لا cold WhatsApp بدون lawful basis."
|
||||
),
|
||||
"approval_required": True,
|
||||
"approval_status": "pending_approval",
|
||||
}
|
||||
|
||||
|
||||
# ── 2. Targeting: top-10 ────────────────────────────────────────
|
||||
@router.post("/targets/top-10")
|
||||
async def targets_top_10(
|
||||
contacts: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
sector_hint: str = Body(default="", embed=True),
|
||||
channel: str = Body(default="whatsapp", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Rank uploaded contacts → top-10 safe + Why-Now."""
|
||||
return recommend_top_10(contacts, sector_hint=sector_hint, channel=channel)
|
||||
|
||||
|
||||
# ── 3. Messages: draft / followup / objection ──────────────────
|
||||
@router.post("/messages/draft")
|
||||
async def messages_draft(
|
||||
contact: dict[str, Any] = Body(..., embed=True),
|
||||
profile: dict[str, Any] | None = Body(default=None, embed=True),
|
||||
goal_ar: str = Body(default="تشغيل نمو B2B بلا إرسال عشوائي", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Saudi-tone Arabic outreach draft (always pending_approval)."""
|
||||
return draft_arabic_message(contact, profile=profile, goal_ar=goal_ar)
|
||||
|
||||
|
||||
@router.post("/messages/followup")
|
||||
async def messages_followup(
|
||||
contact: dict[str, Any] = Body(..., embed=True),
|
||||
days_since_last: int = Body(default=3, embed=True),
|
||||
last_outcome: str = Body(default="no_reply", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return draft_followup(
|
||||
contact, days_since_last=days_since_last, last_outcome=last_outcome,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/messages/objection-response")
|
||||
async def messages_objection_response(
|
||||
objection_id: str = Body(..., embed=True),
|
||||
contact: dict[str, Any] | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return draft_objection_response(objection_id, contact=contact)
|
||||
|
||||
|
||||
# ── 4. Partners: suggest / outreach / scorecard ────────────────
|
||||
@router.post("/partners/suggest")
|
||||
async def partners_suggest(
|
||||
sector: str = Body(default="", embed=True),
|
||||
customer_size: str = Body(default="smb", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return suggest_partner_types(sector=sector, customer_size=customer_size)
|
||||
|
||||
|
||||
@router.post("/partners/outreach")
|
||||
async def partners_outreach(
|
||||
partner_type_key: str = Body(..., embed=True),
|
||||
partner_name: str = Body(default="", embed=True),
|
||||
customer_name: str = Body(default="Dealix", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return draft_partner_outreach(
|
||||
partner_type_key=partner_type_key,
|
||||
partner_name=partner_name,
|
||||
customer_name=customer_name,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/partners/scorecard")
|
||||
async def partners_scorecard(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return partner_scorecard(
|
||||
partner_id=payload.get("partner_id", "unknown"),
|
||||
intros_made=int(payload.get("intros_made", 0)),
|
||||
deals_influenced=int(payload.get("deals_influenced", 0)),
|
||||
revenue_share_paid_sar=float(payload.get("revenue_share_paid_sar", 0)),
|
||||
relationship_age_months=int(payload.get("relationship_age_months", 0)),
|
||||
)
|
||||
|
||||
|
||||
# ── 5. Meetings: agenda / calendar draft / followup ────────────
|
||||
@router.post("/meetings/draft")
|
||||
async def meetings_draft(
|
||||
contact_name: str = Body(..., embed=True),
|
||||
company: str = Body(..., embed=True),
|
||||
contact_email: str | None = Body(default=None, embed=True),
|
||||
purpose_ar: str = Body(default="اكتشاف وتأهيل أولي", embed=True),
|
||||
duration_minutes: int = Body(default=20, embed=True),
|
||||
proposed_start_iso: str | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Build agenda + calendar draft (NOT created live)."""
|
||||
agenda = build_meeting_agenda(
|
||||
contact_name=contact_name,
|
||||
company=company,
|
||||
purpose_ar=purpose_ar,
|
||||
duration_minutes=duration_minutes,
|
||||
)
|
||||
cal_draft = build_calendar_draft(
|
||||
contact_email=contact_email,
|
||||
contact_name=contact_name,
|
||||
company=company,
|
||||
proposed_start_iso=proposed_start_iso,
|
||||
duration_minutes=duration_minutes,
|
||||
)
|
||||
return {"agenda": agenda, "calendar_draft": cal_draft}
|
||||
|
||||
|
||||
@router.post("/meetings/post-followup")
|
||||
async def meetings_post_followup(
|
||||
contact_name: str = Body(..., embed=True),
|
||||
company: str = Body(..., embed=True),
|
||||
summary_ar: str = Body(..., embed=True),
|
||||
next_step_ar: str = Body(default="أرسل recap + pilot offer", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return build_post_meeting_followup(
|
||||
contact_name=contact_name,
|
||||
company=company,
|
||||
summary_ar=summary_ar,
|
||||
next_step_ar=next_step_ar,
|
||||
)
|
||||
|
||||
|
||||
# ── 6. Payment offer (Moyasar payment-link draft) ─────────────
|
||||
@router.post("/payment-offer/draft")
|
||||
async def payment_offer_draft(
|
||||
plan_key: str = Body(..., embed=True),
|
||||
customer_id: str = Body(..., embed=True),
|
||||
contact_email: str | None = Body(default=None, embed=True),
|
||||
custom_amount_sar: float | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return build_moyasar_payment_link_draft(
|
||||
plan_key=plan_key,
|
||||
customer_id=customer_id,
|
||||
contact_email=contact_email,
|
||||
custom_amount_sar=custom_amount_sar,
|
||||
)
|
||||
|
||||
|
||||
# ── 7. Missions ────────────────────────────────────────────────
|
||||
@router.get("/missions")
|
||||
async def missions_list() -> dict[str, Any]:
|
||||
return list_missions()
|
||||
async def missions() -> dict[str, Any]:
|
||||
"""نفس محتوى ``GET /api/v1/innovation/growth-missions`` مع تسمية منتجية."""
|
||||
data = list_growth_missions()
|
||||
if isinstance(data, dict):
|
||||
out = dict(data)
|
||||
out["canonical_route"] = "/api/v1/innovation/growth-missions"
|
||||
return out
|
||||
return {"missions": data, "canonical_route": "/api/v1/innovation/growth-missions"}
|
||||
|
||||
|
||||
@router.post("/missions/{mission_id}/run")
|
||||
async def missions_run(
|
||||
mission_id: str,
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
return run_mission(mission_id, payload=payload)
|
||||
|
||||
|
||||
# ── 8. Proof Pack demo ─────────────────────────────────────────
|
||||
@router.get("/proof-pack/demo")
|
||||
async def proof_pack_demo(
|
||||
customer_id: str = Query(default="demo"),
|
||||
customer_name: str = Query(default="Demo Saudi B2B Co."),
|
||||
) -> dict[str, Any]:
|
||||
return build_weekly_proof_pack(
|
||||
customer_id=customer_id,
|
||||
customer_name=customer_name,
|
||||
week_label="W18-2026",
|
||||
plan_cost_weekly_sar=750,
|
||||
opportunities_discovered=42,
|
||||
messages_drafted=38,
|
||||
messages_approved=33,
|
||||
messages_sent=33,
|
||||
replies_received=11,
|
||||
positive_replies=4,
|
||||
meetings_booked=3,
|
||||
meetings_held=2,
|
||||
proposals_sent=1,
|
||||
deals_won=0,
|
||||
pipeline_added_sar=185_000,
|
||||
revenue_won_sar=0,
|
||||
risky_drafts_blocked=5,
|
||||
revenue_leaks_recovered=2,
|
||||
avg_response_minutes=42,
|
||||
best_message_subject="ملاحظة على توسعكم في الرياض",
|
||||
best_message_reply_rate=0.18,
|
||||
)
|
||||
|
||||
|
||||
# ── 9. Single-contact contactability ─────────────────────────
|
||||
@router.post("/contactability/score")
|
||||
async def contactability_score_single(
|
||||
contact: dict[str, Any] = Body(..., embed=True),
|
||||
channel: str = Body(default="whatsapp", embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return score_contactability(contact, channel=channel)
|
||||
|
||||
|
||||
# ── 10. Profile ────────────────────────────────────────────────
|
||||
@router.post("/profile")
|
||||
async def profile_set(
|
||||
profile: dict[str, Any] = Body(..., embed=True),
|
||||
) -> dict[str, Any]:
|
||||
p = profile_from_dict(profile)
|
||||
return {
|
||||
"profile": p.to_dict(),
|
||||
"is_specialized": p.is_specialized(),
|
||||
"missing_fields_ar": (
|
||||
[] if p.is_specialized() else
|
||||
["sector", "city", "offer_one_liner", "ideal_customer"]
|
||||
),
|
||||
}
|
||||
async def proof_pack_demo() -> dict[str, Any]:
|
||||
"""نفس ``GET /api/v1/business/proof-pack/demo`` — مسار موحّد للعرض في الـ beta."""
|
||||
pack = build_demo_proof_pack()
|
||||
if isinstance(pack, dict):
|
||||
out = dict(pack)
|
||||
out["canonical_route"] = "/api/v1/business/proof-pack/demo"
|
||||
return out
|
||||
return {"pack": pack, "canonical_route": "/api/v1/business/proof-pack/demo"}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Intelligence Layer router — growth brain + missions + DNA + simulator + brief."""
|
||||
"""Intelligence layer API — deterministic JSON; optional ten-in-ten bridge."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,135 +6,117 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.intelligence_layer import (
|
||||
DecisionMemory,
|
||||
analyze_competitive_move,
|
||||
build_board_brief,
|
||||
build_command_feed_demo,
|
||||
build_growth_brain,
|
||||
build_revenue_dna_demo,
|
||||
compute_trust_score,
|
||||
extract_revenue_dna,
|
||||
learn_from_decision,
|
||||
list_intel_missions,
|
||||
recommend_missions,
|
||||
simulate_opportunity,
|
||||
)
|
||||
from auto_client_acquisition.innovation.ten_in_ten import build_ten_opportunities
|
||||
from auto_client_acquisition.intelligence_layer.action_graph import build_action_graph_trace
|
||||
from auto_client_acquisition.intelligence_layer.board_brief import build_board_brief
|
||||
from auto_client_acquisition.intelligence_layer.competitive_moves import build_competitive_moves
|
||||
from auto_client_acquisition.intelligence_layer.decision_memory import list_decisions, record_decision
|
||||
from auto_client_acquisition.intelligence_layer.growth_brain import build_growth_profile
|
||||
from auto_client_acquisition.intelligence_layer.intel_command_feed import build_intel_command_feed
|
||||
from auto_client_acquisition.intelligence_layer.mission_engine import get_mission, list_mission_catalog
|
||||
from auto_client_acquisition.intelligence_layer.opportunity_simulator import simulate_opportunities
|
||||
from auto_client_acquisition.intelligence_layer.revenue_dna import build_revenue_dna
|
||||
from auto_client_acquisition.intelligence_layer.trust_score import compute_trust_score
|
||||
|
||||
router = APIRouter(prefix="/api/v1/intelligence", tags=["intelligence-layer"])
|
||||
|
||||
# Per-customer in-memory decision memory (demo; production = Supabase)
|
||||
_MEMORY: dict[str, DecisionMemory] = {}
|
||||
router = APIRouter(prefix="/api/v1/intelligence", tags=["intelligence_layer"])
|
||||
|
||||
|
||||
def _memory_for(customer_id: str) -> DecisionMemory:
|
||||
if customer_id not in _MEMORY:
|
||||
_MEMORY[customer_id] = DecisionMemory(customer_id=customer_id)
|
||||
return _MEMORY[customer_id]
|
||||
@router.post("/growth-profile")
|
||||
async def growth_profile(company: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_growth_profile(company or {})
|
||||
|
||||
|
||||
# ── Growth Brain ──────────────────────────────────────────────
|
||||
@router.post("/growth-brain/build")
|
||||
async def growth_brain_build(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
brain = build_growth_brain(payload)
|
||||
return {**brain.to_dict(), "ready_for_autopilot": brain.is_ready_for_autopilot()}
|
||||
@router.get("/command-feed")
|
||||
async def intel_command_feed() -> dict[str, Any]:
|
||||
return build_intel_command_feed()
|
||||
|
||||
|
||||
# ── Command Feed ──────────────────────────────────────────────
|
||||
@router.get("/command-feed/demo")
|
||||
async def command_feed_demo() -> dict[str, Any]:
|
||||
return build_command_feed_demo()
|
||||
async def intel_command_feed_demo() -> dict[str, Any]:
|
||||
"""Alias of ``GET /command-feed`` for product/docs compatibility."""
|
||||
return build_intel_command_feed()
|
||||
|
||||
|
||||
# ── Missions ──────────────────────────────────────────────────
|
||||
@router.get("/missions")
|
||||
async def missions_list() -> dict[str, Any]:
|
||||
return list_intel_missions()
|
||||
@router.post("/missions/first-10-opportunities")
|
||||
async def missions_first_10_opportunities(
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
"""Thin wrapper around innovation ``build_ten_opportunities`` — no duplicate logic."""
|
||||
return build_ten_opportunities(payload or None)
|
||||
|
||||
|
||||
@router.post("/missions/recommend")
|
||||
async def missions_recommend(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
brain_payload = payload.get("growth_brain") or payload
|
||||
brain = build_growth_brain(brain_payload) if brain_payload else None
|
||||
return recommend_missions(brain, limit=int(payload.get("limit", 3)))
|
||||
@router.get("/missions/catalog")
|
||||
async def missions_catalog() -> dict[str, Any]:
|
||||
"""Mission engine metadata + pointer to innovation missions."""
|
||||
return list_mission_catalog()
|
||||
|
||||
|
||||
@router.get("/missions/{mission_id}")
|
||||
async def mission_detail(mission_id: str) -> dict[str, Any]:
|
||||
return get_mission(mission_id)
|
||||
|
||||
|
||||
@router.post("/action-graph/demo")
|
||||
async def action_graph_demo(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_action_graph_trace(payload or {})
|
||||
|
||||
|
||||
@router.get("/decision-memory/demo")
|
||||
async def decision_memory_demo() -> dict[str, Any]:
|
||||
return list_decisions(limit=20)
|
||||
|
||||
|
||||
@router.post("/decision-memory/record")
|
||||
async def decision_memory_record(entry: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return record_decision(entry or {})
|
||||
|
||||
|
||||
# ── Trust Score ───────────────────────────────────────────────
|
||||
@router.post("/trust-score")
|
||||
async def trust_score(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return compute_trust_score(
|
||||
source_quality=payload.get("source_quality", "unknown"),
|
||||
opt_in=bool(payload.get("opt_in", False)),
|
||||
channel=payload.get("channel", "whatsapp"),
|
||||
message_text=payload.get("message_text", ""),
|
||||
frequency_count_this_week=int(payload.get("frequency_count_this_week", 0)),
|
||||
weekly_cap=int(payload.get("weekly_cap", 2)),
|
||||
approval_status=payload.get("approval_status", "pending"),
|
||||
)
|
||||
|
||||
|
||||
# ── Revenue DNA ───────────────────────────────────────────────
|
||||
@router.get("/revenue-dna/demo")
|
||||
async def revenue_dna_demo() -> dict[str, Any]:
|
||||
return build_revenue_dna_demo()
|
||||
async def trust_score(signals: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return compute_trust_score(signals or {})
|
||||
|
||||
|
||||
@router.post("/revenue-dna")
|
||||
async def revenue_dna_post(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return extract_revenue_dna(
|
||||
customer_id=payload.get("customer_id", "unknown"),
|
||||
won_deals=payload.get("won_deals", []),
|
||||
replies=payload.get("replies", []),
|
||||
objections=payload.get("objections", []),
|
||||
)
|
||||
async def revenue_dna(context: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_revenue_dna(context or {})
|
||||
|
||||
|
||||
# ── Opportunity Simulator ─────────────────────────────────────
|
||||
@router.post("/simulate-opportunity")
|
||||
async def simulate_opportunity_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return simulate_opportunity(
|
||||
target_count=int(payload.get("target_count", 100)),
|
||||
sector=payload.get("sector", "saas"),
|
||||
avg_deal_value_sar=float(payload.get("avg_deal_value_sar", 25_000)),
|
||||
channel=payload.get("channel", "whatsapp"),
|
||||
cold_pct=float(payload.get("cold_pct", 0)),
|
||||
quality_lift=float(payload.get("quality_lift", 1.0)),
|
||||
)
|
||||
@router.post("/opportunity-simulator")
|
||||
async def opportunity_simulator(inputs: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return simulate_opportunities(inputs or {})
|
||||
|
||||
|
||||
# ── Competitive Moves ─────────────────────────────────────────
|
||||
@router.post("/competitive-move/analyze")
|
||||
async def competitive_move_analyze(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return analyze_competitive_move(
|
||||
competitor_name=payload.get("competitor_name", "?"),
|
||||
move_type=payload.get("move_type", "new_offer"),
|
||||
payload=payload.get("payload", {}),
|
||||
)
|
||||
@router.post("/board-brief")
|
||||
async def board_brief(snapshot: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_board_brief(snapshot or {})
|
||||
|
||||
|
||||
# ── Board Brief ───────────────────────────────────────────────
|
||||
@router.get("/board-brief/demo")
|
||||
async def board_brief_demo() -> dict[str, Any]:
|
||||
return build_board_brief()
|
||||
@router.get("/competitive-moves")
|
||||
async def competitive_moves(sector: str | None = None) -> dict[str, Any]:
|
||||
return build_competitive_moves(sector)
|
||||
|
||||
|
||||
# ── Decision Memory ───────────────────────────────────────────
|
||||
@router.post("/decisions/record")
|
||||
async def decisions_record(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
customer_id = payload.get("customer_id", "demo")
|
||||
mem = _memory_for(customer_id)
|
||||
return learn_from_decision(
|
||||
memory=mem,
|
||||
decision=payload.get("decision", "skip"),
|
||||
action_type=payload.get("action_type", "send_whatsapp"),
|
||||
channel=payload.get("channel", "whatsapp"),
|
||||
sector=payload.get("sector"),
|
||||
tone=payload.get("tone"),
|
||||
objection_id=payload.get("objection_id"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/decisions/preferences")
|
||||
async def decisions_preferences(customer_id: str) -> dict[str, Any]:
|
||||
mem = _memory_for(customer_id)
|
||||
return {"customer_id": customer_id, "preferences": mem.preferences()}
|
||||
@router.post("/bundle")
|
||||
async def intelligence_bundle(
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Single round-trip for demos. Optional ``include_ten_in_ten`` merges
|
||||
``build_ten_opportunities`` without exposing a duplicate HTTP path.
|
||||
"""
|
||||
company = payload.get("company") if isinstance(payload.get("company"), dict) else {}
|
||||
out: dict[str, Any] = {
|
||||
"growth_profile": build_growth_profile(company),
|
||||
"intel_command_feed": build_intel_command_feed({"append_custom": payload.get("extra_card")}),
|
||||
"trust_score": compute_trust_score(payload.get("trust_signals") if isinstance(payload.get("trust_signals"), dict) else {}),
|
||||
"revenue_dna": build_revenue_dna(payload.get("revenue_context") if isinstance(payload.get("revenue_context"), dict) else {}),
|
||||
"opportunity_simulator": simulate_opportunities(
|
||||
payload.get("simulator") if isinstance(payload.get("simulator"), dict) else {}
|
||||
),
|
||||
"board_brief": build_board_brief(payload.get("board") if isinstance(payload.get("board"), dict) else {}),
|
||||
"competitive_moves": build_competitive_moves(str(payload.get("sector") or "") or None),
|
||||
}
|
||||
if payload.get("include_ten_in_ten"):
|
||||
ten_payload = payload.get("ten_in_ten") if isinstance(payload.get("ten_in_ten"), dict) else company
|
||||
out["ten_in_ten"] = build_ten_opportunities(ten_payload)
|
||||
return out
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Launch Ops router — Private Beta + Demo + Outreach + Go/No-Go + Scorecard."""
|
||||
"""Launch ops API — private beta, demo, outreach, go/no-go."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,130 +6,40 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.launch_ops import (
|
||||
build_12_min_demo_flow,
|
||||
build_close_script,
|
||||
build_daily_launch_scorecard,
|
||||
build_discovery_questions,
|
||||
build_first_20_segments,
|
||||
build_followup_message,
|
||||
build_launch_readiness,
|
||||
build_objection_responses,
|
||||
build_outreach_message,
|
||||
build_private_beta_offer,
|
||||
build_private_beta_safety_notes,
|
||||
build_reply_handlers,
|
||||
build_weekly_launch_scorecard,
|
||||
decide_go_no_go,
|
||||
private_beta_faq,
|
||||
record_launch_event,
|
||||
)
|
||||
from auto_client_acquisition.launch_ops.demo_flow import build_demo_script
|
||||
from auto_client_acquisition.launch_ops.go_no_go import evaluate_go_no_go
|
||||
from auto_client_acquisition.launch_ops.launch_scorecard import build_launch_scorecard
|
||||
from auto_client_acquisition.launch_ops.outreach_messages import build_first_twenty_outreach
|
||||
from auto_client_acquisition.launch_ops.private_beta import build_private_beta_offer
|
||||
|
||||
router = APIRouter(prefix="/api/v1/launch", tags=["launch-ops"])
|
||||
router = APIRouter(prefix="/api/v1/launch", tags=["launch_ops"])
|
||||
|
||||
|
||||
# ── Private Beta ─────────────────────────────────────────────
|
||||
@router.get("/private-beta/offer")
|
||||
async def private_beta_offer() -> dict[str, Any]:
|
||||
return {
|
||||
"offer": build_private_beta_offer(),
|
||||
"safety": build_private_beta_safety_notes(),
|
||||
"faq": private_beta_faq(),
|
||||
}
|
||||
async def launch_private_beta_offer() -> dict[str, Any]:
|
||||
return build_private_beta_offer()
|
||||
|
||||
|
||||
# ── Demo flow ────────────────────────────────────────────────
|
||||
@router.get("/demo/flow")
|
||||
async def demo_flow() -> dict[str, Any]:
|
||||
return {
|
||||
"flow": build_12_min_demo_flow(),
|
||||
"discovery_questions": build_discovery_questions(),
|
||||
"objections": build_objection_responses(),
|
||||
"close": build_close_script(),
|
||||
}
|
||||
@router.get("/demo-script")
|
||||
async def launch_demo_script() -> dict[str, Any]:
|
||||
return build_demo_script()
|
||||
|
||||
|
||||
# ── Outreach ─────────────────────────────────────────────────
|
||||
@router.get("/outreach/first-20")
|
||||
async def outreach_first_20() -> dict[str, Any]:
|
||||
segments = build_first_20_segments()
|
||||
sample_messages = {
|
||||
s["id"]: build_outreach_message(s["id"])
|
||||
for s in segments["segments"]
|
||||
}
|
||||
return {
|
||||
**segments,
|
||||
"sample_messages": sample_messages,
|
||||
"reply_handlers": build_reply_handlers(),
|
||||
}
|
||||
async def launch_outreach_first_20() -> dict[str, Any]:
|
||||
return build_first_twenty_outreach()
|
||||
|
||||
|
||||
@router.post("/outreach/message")
|
||||
async def outreach_message(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_outreach_message(
|
||||
segment_id=payload.get("segment_id", ""),
|
||||
name=payload.get("name", "[الاسم]"),
|
||||
)
|
||||
@router.get("/go-no-go")
|
||||
async def launch_go_no_go_get() -> dict[str, Any]:
|
||||
return evaluate_go_no_go(None)
|
||||
|
||||
|
||||
@router.post("/outreach/followup")
|
||||
async def outreach_followup(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_followup_message(
|
||||
segment_id=payload.get("segment_id", ""),
|
||||
step=int(payload.get("step", 1)),
|
||||
name=payload.get("name", "[الاسم]"),
|
||||
)
|
||||
|
||||
|
||||
# ── Go / No-Go ───────────────────────────────────────────────
|
||||
@router.post("/go-no-go")
|
||||
async def go_no_go(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return decide_go_no_go(statuses=payload.get("statuses"))
|
||||
async def launch_go_no_go_post(flags: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return evaluate_go_no_go(flags or {})
|
||||
|
||||
|
||||
@router.get("/readiness")
|
||||
async def readiness() -> dict[str, Any]:
|
||||
"""Readiness with all gates assumed False (use POST /go-no-go for real status)."""
|
||||
return build_launch_readiness(statuses={})
|
||||
|
||||
|
||||
# ── Scorecard ────────────────────────────────────────────────
|
||||
@router.post("/scorecard/event")
|
||||
async def scorecard_event(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return record_launch_event(
|
||||
event_type=payload.get("event_type", ""),
|
||||
customer_id=payload.get("customer_id"),
|
||||
notes=payload.get("notes"),
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
|
||||
|
||||
@router.post("/scorecard/daily")
|
||||
async def scorecard_daily(
|
||||
events: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return build_daily_launch_scorecard(events=events)
|
||||
|
||||
|
||||
@router.post("/scorecard/weekly")
|
||||
async def scorecard_weekly(
|
||||
events: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return build_weekly_launch_scorecard(events=events)
|
||||
|
||||
|
||||
@router.get("/scorecard/demo")
|
||||
async def scorecard_demo() -> dict[str, Any]:
|
||||
"""Demo scorecard with synthetic events."""
|
||||
demo_events = [
|
||||
{"event_type": "outreach_sent"} for _ in range(15)
|
||||
] + [
|
||||
{"event_type": "reply_received"} for _ in range(4)
|
||||
] + [
|
||||
{"event_type": "demo_booked"} for _ in range(2)
|
||||
] + [
|
||||
{"event_type": "blocked_action"} for _ in range(6)
|
||||
]
|
||||
return build_daily_launch_scorecard(events=demo_events)
|
||||
@router.get("/scorecard")
|
||||
async def launch_scorecard() -> dict[str, Any]:
|
||||
return build_launch_scorecard()
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Meeting Intelligence router — pre-meeting brief, transcript summary, follow-up."""
|
||||
"""Meeting intelligence API — text in, Arabic briefs out (no Calendar insert)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,65 +6,32 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.meeting_intelligence import (
|
||||
build_post_meeting_followup,
|
||||
build_pre_meeting_brief,
|
||||
compute_deal_risk,
|
||||
extract_objections,
|
||||
parse_transcript_entries,
|
||||
summarize_meeting,
|
||||
)
|
||||
from auto_client_acquisition.meeting_intelligence.followup_builder import build_post_meeting_followup
|
||||
from auto_client_acquisition.meeting_intelligence.meeting_brief import build_pre_meeting_brief
|
||||
from auto_client_acquisition.meeting_intelligence.objection_extractor import extract_objections
|
||||
from auto_client_acquisition.meeting_intelligence.transcript_parser import summarize_transcript_text
|
||||
|
||||
router = APIRouter(prefix="/api/v1/meeting-intelligence", tags=["meeting-intelligence"])
|
||||
|
||||
|
||||
@router.post("/brief")
|
||||
async def brief(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_pre_meeting_brief(
|
||||
company=payload.get("company"),
|
||||
contact=payload.get("contact"),
|
||||
opportunity=payload.get("opportunity"),
|
||||
sector=payload.get("sector"),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/brief/demo")
|
||||
async def brief_demo() -> dict[str, Any]:
|
||||
return build_pre_meeting_brief(
|
||||
company={"name": "شركة نمو للتدريب", "sector": "training"},
|
||||
contact={"name": "أحمد", "role": "مدير المبيعات"},
|
||||
opportunity={"expected_value_sar": 18000},
|
||||
sector="training",
|
||||
)
|
||||
router = APIRouter(prefix="/api/v1/meeting-intelligence", tags=["meeting_intelligence"])
|
||||
|
||||
|
||||
@router.post("/transcript/summarize")
|
||||
async def transcript_summarize(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
parsed = parse_transcript_entries(payload.get("entries") or payload.get("text", ""))
|
||||
summary = summarize_meeting(parsed)
|
||||
objections = extract_objections(
|
||||
" ".join(t["text"] for t in parsed.get("speaker_turns", []))
|
||||
)
|
||||
return {"parsed": parsed, "summary": summary, "objections": objections}
|
||||
async def transcript_summarize(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
text = str(payload.get("text") or "")
|
||||
base = summarize_transcript_text(text)
|
||||
base["objections"] = extract_objections(text)
|
||||
return base
|
||||
|
||||
|
||||
@router.post("/followup/draft")
|
||||
async def followup_draft(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_post_meeting_followup(
|
||||
summary=payload.get("summary"),
|
||||
next_steps=payload.get("next_steps", []),
|
||||
contact_name=payload.get("contact_name", ""),
|
||||
company_name=payload.get("company_name", ""),
|
||||
objections=payload.get("objections", []),
|
||||
)
|
||||
async def followup_draft(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
summary = str(payload.get("summary_ar") or "")
|
||||
steps = payload.get("next_steps") if isinstance(payload.get("next_steps"), list) else None
|
||||
return build_post_meeting_followup(summary, steps)
|
||||
|
||||
|
||||
@router.post("/deal-risk")
|
||||
async def deal_risk(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return compute_deal_risk(
|
||||
objections=payload.get("objections", []),
|
||||
next_step_set=bool(payload.get("next_step_set", False)),
|
||||
decision_maker_present=bool(payload.get("decision_maker_present", False)),
|
||||
days_since_last_touch=int(payload.get("days_since_last_touch", 0)),
|
||||
expected_value_sar=float(payload.get("expected_value_sar", 0)),
|
||||
)
|
||||
@router.post("/brief/pre-meeting")
|
||||
async def pre_meeting_brief(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
company = payload.get("company") if isinstance(payload.get("company"), dict) else {}
|
||||
contact = payload.get("contact") if isinstance(payload.get("contact"), dict) else {}
|
||||
opportunity = payload.get("opportunity") if isinstance(payload.get("opportunity"), dict) else {}
|
||||
return build_pre_meeting_brief(company, contact, opportunity)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Model Router router — task routing + provider registry + cost class."""
|
||||
"""Model routing API — configuration hints only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,57 +6,22 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.model_router import (
|
||||
ALL_PROVIDERS,
|
||||
ALL_TASK_TYPES,
|
||||
build_usage_demo,
|
||||
classify_cost,
|
||||
route_task,
|
||||
)
|
||||
from auto_client_acquisition.model_router.provider_registry import list_providers
|
||||
from auto_client_acquisition.model_router.task_router import list_tasks, route_task
|
||||
|
||||
router = APIRouter(prefix="/api/v1/model-router", tags=["model-router"])
|
||||
|
||||
|
||||
@router.get("/providers")
|
||||
async def providers() -> dict[str, Any]:
|
||||
return {
|
||||
"total": len(ALL_PROVIDERS),
|
||||
"providers": [p.to_dict() for p in ALL_PROVIDERS],
|
||||
}
|
||||
router = APIRouter(prefix="/api/v1/model-router", tags=["model_router"])
|
||||
|
||||
|
||||
@router.get("/tasks")
|
||||
async def tasks() -> dict[str, Any]:
|
||||
return {"total": len(ALL_TASK_TYPES), "tasks": list(ALL_TASK_TYPES)}
|
||||
return list_tasks()
|
||||
|
||||
|
||||
@router.post("/route")
|
||||
async def route(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
decision = route_task(
|
||||
payload.get("task_type", "low_cost_bulk"),
|
||||
requires_arabic=bool(payload.get("requires_arabic", False)),
|
||||
requires_vision=bool(payload.get("requires_vision", False)),
|
||||
sensitivity=payload.get("sensitivity", "low"),
|
||||
expected_input_tokens=int(payload.get("expected_input_tokens", 0)),
|
||||
expected_output_tokens=int(payload.get("expected_output_tokens", 0)),
|
||||
bulk=bool(payload.get("bulk", False)),
|
||||
primary_provider=payload.get("primary_provider"),
|
||||
)
|
||||
return decision.to_dict()
|
||||
async def route(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return route_task(str(payload.get("task_type") or ""))
|
||||
|
||||
|
||||
@router.post("/cost-class")
|
||||
async def cost_class(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return {
|
||||
"cost_class": classify_cost(
|
||||
task_type=payload.get("task_type", "low_cost_bulk"),
|
||||
expected_input_tokens=int(payload.get("expected_input_tokens", 0)),
|
||||
expected_output_tokens=int(payload.get("expected_output_tokens", 0)),
|
||||
bulk=bool(payload.get("bulk", False)),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/usage/demo")
|
||||
async def usage_demo() -> dict[str, Any]:
|
||||
return build_usage_demo()
|
||||
@router.get("/providers")
|
||||
async def providers() -> dict[str, Any]:
|
||||
return list_providers()
|
||||
|
||||
@ -1,203 +1,185 @@
|
||||
"""Platform Services router — channel registry + events + inbox + policy + proof."""
|
||||
"""Platform Services API — Growth Control Tower (no live external sends)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Query
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.platform_services import (
|
||||
ALL_CHANNELS,
|
||||
POLICY_RULES,
|
||||
SELLABLE_SERVICES,
|
||||
build_card_from_event,
|
||||
build_demo_feed,
|
||||
build_demo_platform_proof,
|
||||
build_proof_summary,
|
||||
evaluate_action,
|
||||
get_channel,
|
||||
invoke_tool,
|
||||
list_services,
|
||||
make_event,
|
||||
resolve_identity,
|
||||
event_to_inbox_card,
|
||||
execute_tool,
|
||||
get_action_ledger,
|
||||
get_service_catalog,
|
||||
list_channels,
|
||||
validate_event,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.action_ledger import ActionLedger
|
||||
from auto_client_acquisition.platform_services.channel_registry import channels_summary
|
||||
from auto_client_acquisition.innovation.proof_ledger import build_demo_proof_ledger
|
||||
from auto_client_acquisition.platform_services.contact_import_preview import build_import_preview
|
||||
from auto_client_acquisition.platform_services.identity_resolution import resolve_identity_demo
|
||||
from auto_client_acquisition.platform_services.inbox_feed import build_inbox_feed
|
||||
from auto_client_acquisition.platform_services.lead_form_ingest import ingest_lead_form
|
||||
from auto_client_acquisition.platform_services.proof_overview import build_proof_overview
|
||||
|
||||
router = APIRouter(prefix="/api/v1/platform", tags=["platform-services"])
|
||||
|
||||
_LEDGER = ActionLedger()
|
||||
router = APIRouter(prefix="/api/v1/platform", tags=["platform_services"])
|
||||
|
||||
|
||||
@router.get("/service-catalog")
|
||||
async def service_catalog() -> dict[str, Any]:
|
||||
return get_service_catalog()
|
||||
|
||||
|
||||
# ── Catalog ────────────────────────────────────────────────────
|
||||
@router.get("/services/catalog")
|
||||
async def services_catalog() -> dict[str, Any]:
|
||||
return list_services()
|
||||
async def services_catalog_alias() -> dict[str, Any]:
|
||||
"""Alias path for product docs compatibility."""
|
||||
return get_service_catalog()
|
||||
|
||||
|
||||
@router.get("/channels")
|
||||
async def channels() -> dict[str, Any]:
|
||||
return {
|
||||
"summary": channels_summary(),
|
||||
"channels": [
|
||||
{
|
||||
"key": c.key, "label_ar": c.label_ar, "label_en": c.label_en,
|
||||
"capabilities": list(c.capabilities), "beta_status": c.beta_status,
|
||||
"required_permissions": list(c.required_permissions),
|
||||
"allowed_actions": list(c.allowed_actions),
|
||||
"blocked_actions": list(c.blocked_actions),
|
||||
"risk_level": c.risk_level, "notes_ar": c.notes_ar,
|
||||
}
|
||||
for c in ALL_CHANNELS
|
||||
],
|
||||
}
|
||||
return list_channels()
|
||||
|
||||
|
||||
@router.get("/channels/{channel_key}")
|
||||
async def channel_detail(channel_key: str) -> dict[str, Any]:
|
||||
c = get_channel(channel_key)
|
||||
if c is None:
|
||||
return {"error": f"unknown channel: {channel_key}"}
|
||||
return {
|
||||
"key": c.key, "label_ar": c.label_ar, "label_en": c.label_en,
|
||||
"capabilities": list(c.capabilities), "beta_status": c.beta_status,
|
||||
"required_permissions": list(c.required_permissions),
|
||||
"allowed_actions": list(c.allowed_actions),
|
||||
"blocked_actions": list(c.blocked_actions),
|
||||
"risk_level": c.risk_level, "notes_ar": c.notes_ar,
|
||||
}
|
||||
@router.post("/events/validate")
|
||||
async def events_validate(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return validate_event(payload or {})
|
||||
|
||||
|
||||
# ── Policy ─────────────────────────────────────────────────────
|
||||
@router.get("/policy/rules")
|
||||
async def policy_rules() -> dict[str, Any]:
|
||||
return {"count": len(POLICY_RULES), "rules": POLICY_RULES}
|
||||
|
||||
|
||||
@router.post("/actions/evaluate")
|
||||
async def actions_evaluate(
|
||||
action: str = Body(..., embed=True),
|
||||
context: dict[str, Any] = Body(default_factory=dict, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
d = evaluate_action(action=action, context=context)
|
||||
return {
|
||||
"decision": d.decision,
|
||||
"matched_rule_id": d.matched_rule_id,
|
||||
"reasons_ar": d.reasons_ar,
|
||||
"suggested_next_action_ar": d.suggested_next_action_ar,
|
||||
}
|
||||
@router.post("/events/ingest")
|
||||
async def events_ingest(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
"""Validate normalized event and return inbox card — no persistence."""
|
||||
v = validate_event(payload or {})
|
||||
if not v["valid"]:
|
||||
return {"ok": False, "errors": v["errors"], "approval_required": True}
|
||||
ev = v.get("normalized") or {}
|
||||
return {"ok": True, "event": ev, "card": event_to_inbox_card(ev), "approval_required": True}
|
||||
|
||||
|
||||
@router.post("/actions/approve")
|
||||
async def actions_approve(
|
||||
customer_id: str = Body(..., embed=True),
|
||||
action_type: str = Body(..., embed=True),
|
||||
channel: str = Body(..., embed=True),
|
||||
actor: str = Body(default="user", embed=True),
|
||||
payload: dict[str, Any] = Body(default_factory=dict, embed=True),
|
||||
correlation_id: str | None = Body(default=None, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
entry = _LEDGER.append(
|
||||
customer_id=customer_id,
|
||||
action_type=action_type,
|
||||
channel=channel,
|
||||
stage="approved",
|
||||
actor=actor,
|
||||
payload=payload,
|
||||
correlation_id=correlation_id,
|
||||
async def actions_approve(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
"""Record human approval/rejection in the in-memory action ledger — no live side effects."""
|
||||
ledger = get_action_ledger()
|
||||
action_id = str(payload.get("action_id") or payload.get("request_id") or "unspecified")
|
||||
actor = str(payload.get("actor") or "operator")
|
||||
approved = payload.get("approved")
|
||||
is_approved = True if approved is None else bool(approved)
|
||||
entry = ledger.append_decision(
|
||||
tool="human_approval",
|
||||
outcome="approved" if is_approved else "rejected",
|
||||
detail={
|
||||
"action_id": action_id,
|
||||
"actor": actor,
|
||||
"notes": payload.get("notes"),
|
||||
},
|
||||
)
|
||||
return {"approved": True, "entry": entry.to_dict()}
|
||||
|
||||
|
||||
@router.get("/ledger/summary")
|
||||
async def ledger_summary(customer_id: str = Query(...)) -> dict[str, Any]:
|
||||
return _LEDGER.summary(customer_id=customer_id)
|
||||
|
||||
|
||||
# ── Events + Inbox ─────────────────────────────────────────────
|
||||
@router.post("/events/ingest")
|
||||
async def events_ingest(
|
||||
event_type: str = Body(..., embed=True),
|
||||
channel: str = Body(..., embed=True),
|
||||
customer_id: str = Body(..., embed=True),
|
||||
payload: dict[str, Any] = Body(default_factory=dict, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
evt = make_event(
|
||||
event_type=event_type, channel=channel,
|
||||
customer_id=customer_id, payload=payload,
|
||||
)
|
||||
except ValueError as exc:
|
||||
return {"error": str(exc)}
|
||||
card = build_card_from_event(evt)
|
||||
return {
|
||||
"event": evt.to_dict(),
|
||||
"card": card.to_dict() if card else None,
|
||||
"actionable": card is not None,
|
||||
"ok": True,
|
||||
"ledger_entry": entry,
|
||||
"detail_ar": "سُجّل القرار في دفتر MVP — لا يُطلق إرسالاً أو دفعاً تلقائياً من هذا المسار.",
|
||||
"approval_required": False,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/actions/evaluate")
|
||||
async def actions_evaluate_alias(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
"""Alias of ``POST /policy/evaluate`` for docs that refer to ``actions/evaluate``."""
|
||||
return evaluate_action(
|
||||
action=str(payload.get("action") or ""),
|
||||
channel_id=str(payload.get("channel_id") or ""),
|
||||
context=payload.get("context") if isinstance(payload.get("context"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/inbox/from-event")
|
||||
async def inbox_from_event(
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
event = payload.get("event") if isinstance(payload.get("event"), dict) else payload
|
||||
merge = bool(payload.get("merge_demo_hint"))
|
||||
return {"card": event_to_inbox_card(event or {}, merge_demo_hint=merge)}
|
||||
|
||||
|
||||
@router.post("/policy/evaluate")
|
||||
async def policy_evaluate(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return evaluate_action(
|
||||
action=str(payload.get("action") or ""),
|
||||
channel_id=str(payload.get("channel_id") or ""),
|
||||
context=payload.get("context") if isinstance(payload.get("context"), dict) else {},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tools/execute")
|
||||
async def tools_execute(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return execute_tool(str(payload.get("tool_name") or ""), payload.get("payload") if isinstance(payload.get("payload"), dict) else {})
|
||||
|
||||
|
||||
@router.get("/proof/summary")
|
||||
async def proof_summary() -> dict[str, Any]:
|
||||
return build_proof_summary()
|
||||
|
||||
|
||||
@router.get("/proof-ledger/demo")
|
||||
async def proof_ledger_demo() -> dict[str, Any]:
|
||||
"""Demo ledger events — same source as innovation demo."""
|
||||
return build_demo_proof_ledger()
|
||||
|
||||
|
||||
@router.get("/identity/resolve-demo")
|
||||
async def identity_resolve_demo(
|
||||
phone: str | None = None,
|
||||
email: str | None = None,
|
||||
company_hint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
return resolve_identity_demo(phone=phone, email=email, company_hint=company_hint)
|
||||
|
||||
|
||||
@router.get("/proof/overview")
|
||||
async def proof_overview() -> dict[str, Any]:
|
||||
return build_proof_overview()
|
||||
|
||||
|
||||
@router.get("/inbox/feed")
|
||||
async def inbox_feed() -> dict[str, Any]:
|
||||
"""Demo unified-inbox feed; production version reads from event store."""
|
||||
return build_demo_feed()
|
||||
return build_inbox_feed()
|
||||
|
||||
|
||||
# ── Identity + Tool gateway ───────────────────────────────────
|
||||
@router.post("/identity/resolve")
|
||||
async def identity_resolve(
|
||||
signals: list[dict[str, Any]] = Body(..., embed=True),
|
||||
) -> dict[str, Any]:
|
||||
out = resolve_identity(signals=signals)
|
||||
return {
|
||||
"identity_id": out.identity_id,
|
||||
"primary_phone": out.primary_phone,
|
||||
"primary_email": out.primary_email,
|
||||
"company": out.company,
|
||||
"crm_id": out.crm_id,
|
||||
"social_handles": out.social_handles,
|
||||
"confidence": out.confidence,
|
||||
"sources": out.sources,
|
||||
}
|
||||
@router.post("/contacts/import-preview")
|
||||
async def contacts_import_preview(body: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_import_preview(body or {})
|
||||
|
||||
|
||||
@router.get("/identity/resolve-demo")
|
||||
async def identity_resolve_demo() -> dict[str, Any]:
|
||||
"""Sample multi-source identity resolution."""
|
||||
out = resolve_identity(signals=[
|
||||
{"phone": "+966500000001", "company": "شركة العقار الذهبي", "source": "whatsapp"},
|
||||
{"email": "ali@example.sa", "company": "شركة العقار الذهبي", "source": "gmail"},
|
||||
{"crm_id": "crm_5421", "company": "شركة العقار الذهبي", "source": "crm"},
|
||||
{"social_handles": {"linkedin": "ali-realestate"}, "source": "linkedin_lead_forms"},
|
||||
])
|
||||
return {
|
||||
"identity_id": out.identity_id,
|
||||
"primary_phone": out.primary_phone,
|
||||
"primary_email": out.primary_email,
|
||||
"company": out.company,
|
||||
"crm_id": out.crm_id,
|
||||
"social_handles": out.social_handles,
|
||||
"confidence": out.confidence,
|
||||
"sources": out.sources,
|
||||
}
|
||||
@router.get("/action-ledger/recent")
|
||||
async def action_ledger_recent(limit: int = 50) -> dict[str, Any]:
|
||||
lim = max(1, min(limit, 200))
|
||||
return {"entries": get_action_ledger().recent(lim)}
|
||||
|
||||
|
||||
@router.post("/tools/invoke")
|
||||
async def tools_invoke(
|
||||
tool: str = Body(..., embed=True),
|
||||
payload: dict[str, Any] = Body(default_factory=dict, embed=True),
|
||||
context: dict[str, Any] = Body(default_factory=dict, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
r = invoke_tool(tool=tool, payload=payload, context=context)
|
||||
return {
|
||||
"status": r.status,
|
||||
"tool": r.tool,
|
||||
"matched_policy_rule": r.matched_policy_rule,
|
||||
"reasons_ar": r.reasons_ar,
|
||||
"next_action_ar": r.next_action_ar,
|
||||
}
|
||||
@router.post("/ingest/lead-form")
|
||||
async def ingest_lead_form_route(body: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return ingest_lead_form(body or {})
|
||||
|
||||
|
||||
# ── Proof ──────────────────────────────────────────────────────
|
||||
@router.get("/proof-ledger/demo")
|
||||
async def proof_ledger_demo() -> dict[str, Any]:
|
||||
return build_demo_platform_proof().to_dict()
|
||||
# --- Wave 4: draft payloads only (re-export from aca.integrations) ---
|
||||
|
||||
|
||||
@router.post("/integrations/gmail/draft")
|
||||
async def gmail_draft(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
from auto_client_acquisition.integrations.gmail_operator import build_gmail_draft_payload
|
||||
|
||||
return build_gmail_draft_payload(payload or {})
|
||||
|
||||
|
||||
@router.post("/integrations/calendar/draft")
|
||||
async def calendar_draft(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
from auto_client_acquisition.integrations.calendar_operator import build_calendar_draft_payload
|
||||
|
||||
return build_calendar_draft_payload(payload or {})
|
||||
|
||||
|
||||
@router.post("/integrations/moyasar/payment-draft")
|
||||
async def moyasar_payment_draft(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
from auto_client_acquisition.integrations.moyasar_draft import build_moyasar_payment_draft
|
||||
|
||||
return build_moyasar_payment_draft(payload or {})
|
||||
|
||||
@ -1,182 +1,97 @@
|
||||
"""Revenue Launch router — paid offer + pipeline + delivery + payment + proof."""
|
||||
"""Revenue Today — offers, outreach templates, pilot delivery, manual payment (no live charge)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from auto_client_acquisition.revenue_launch import (
|
||||
build_24h_delivery_plan,
|
||||
build_499_pilot_offer,
|
||||
build_case_study_free_offer,
|
||||
build_client_intake_form,
|
||||
build_client_summary,
|
||||
build_first_10_opportunities_delivery,
|
||||
build_first_20_segments_v2,
|
||||
build_followup_1,
|
||||
build_followup_2,
|
||||
build_growth_diagnostic_delivery,
|
||||
build_growth_os_pilot_offer,
|
||||
build_list_intelligence_delivery,
|
||||
from auto_client_acquisition.revenue_launch.demo_closer import (
|
||||
build_12_min_demo_flow,
|
||||
build_close_script,
|
||||
build_discovery_questions,
|
||||
build_objection_responses,
|
||||
)
|
||||
from auto_client_acquisition.revenue_launch.offer_i18n import build_revenue_offers_payload
|
||||
from auto_client_acquisition.revenue_launch.outreach_sequence import (
|
||||
build_first_20_segments,
|
||||
build_outreach_message,
|
||||
)
|
||||
from auto_client_acquisition.revenue_launch.payment_manual_flow import (
|
||||
build_moyasar_invoice_instructions,
|
||||
build_next_step_recommendation,
|
||||
build_outreach_message_v2,
|
||||
build_payment_confirmation_checklist,
|
||||
build_payment_link_message,
|
||||
build_pipeline_schema,
|
||||
build_private_beta_offer,
|
||||
)
|
||||
from auto_client_acquisition.revenue_launch.pilot_delivery import (
|
||||
build_24h_delivery_plan,
|
||||
build_client_intake_form,
|
||||
build_first_10_opportunities_delivery,
|
||||
build_growth_diagnostic_delivery,
|
||||
build_list_intelligence_delivery,
|
||||
)
|
||||
from auto_client_acquisition.revenue_launch.pipeline_tracker import build_pipeline_schema
|
||||
from auto_client_acquisition.revenue_launch.proof_pack_template import (
|
||||
build_private_beta_proof_pack,
|
||||
build_reply_handlers_v2,
|
||||
demo_12_min,
|
||||
demo_close_script,
|
||||
demo_discovery,
|
||||
demo_objections,
|
||||
recommend_offer_for_segment,
|
||||
summarize_pipeline,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/revenue-launch", tags=["revenue-launch"])
|
||||
router = APIRouter(prefix="/api/v1/revenue-launch", tags=["revenue_launch"])
|
||||
|
||||
|
||||
# ── Offers ───────────────────────────────────────────────────
|
||||
@router.get("/offers")
|
||||
async def offers() -> dict[str, Any]:
|
||||
return {
|
||||
"private_beta": build_private_beta_offer(),
|
||||
"pilot_499": build_499_pilot_offer(),
|
||||
"growth_os_pilot": build_growth_os_pilot_offer(),
|
||||
"case_study_free": build_case_study_free_offer(),
|
||||
}
|
||||
@router.get("/offer")
|
||||
async def revenue_launch_offer(lang: str = Query("ar", description="ar or en — en adds title_en/summary_en alongside Arabic fields")) -> dict[str, Any]:
|
||||
return build_revenue_offers_payload(lang)
|
||||
|
||||
|
||||
@router.post("/offers/recommend")
|
||||
async def offers_recommend(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return recommend_offer_for_segment(payload.get("segment_id", ""))
|
||||
|
||||
|
||||
# ── Outreach ─────────────────────────────────────────────────
|
||||
@router.get("/outreach/first-20")
|
||||
async def outreach_first_20() -> dict[str, Any]:
|
||||
seg = build_first_20_segments_v2()
|
||||
return {
|
||||
**seg,
|
||||
"messages": {
|
||||
s["id"]: build_outreach_message_v2(s["id"])
|
||||
for s in seg["segments"]
|
||||
},
|
||||
"reply_handlers": build_reply_handlers_v2(),
|
||||
}
|
||||
async def revenue_launch_outreach_first_20() -> dict[str, Any]:
|
||||
segs = build_first_20_segments()
|
||||
samples = [
|
||||
build_outreach_message("agency_b2b"),
|
||||
build_outreach_message("training"),
|
||||
]
|
||||
return {**segs, "sample_messages": samples, "demo": True}
|
||||
|
||||
|
||||
@router.post("/outreach/followup")
|
||||
async def outreach_followup(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
step = int(payload.get("step", 1))
|
||||
builder = build_followup_2 if step >= 2 else build_followup_1
|
||||
return builder(
|
||||
segment_id=payload.get("segment_id", ""),
|
||||
name=payload.get("name", "[الاسم]"),
|
||||
)
|
||||
|
||||
|
||||
# ── Demo ─────────────────────────────────────────────────────
|
||||
@router.get("/demo-flow")
|
||||
async def demo_flow() -> dict[str, Any]:
|
||||
async def revenue_launch_demo_flow() -> dict[str, Any]:
|
||||
return {
|
||||
"flow": demo_12_min(),
|
||||
"discovery_questions": demo_discovery(),
|
||||
"objections": demo_objections(),
|
||||
"close": demo_close_script(),
|
||||
"flow": build_12_min_demo_flow(),
|
||||
"discovery": build_discovery_questions(),
|
||||
"close": build_close_script(),
|
||||
"objections": build_objection_responses(),
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
# ── Pipeline ─────────────────────────────────────────────────
|
||||
@router.get("/pipeline/schema")
|
||||
async def pipeline_schema() -> dict[str, Any]:
|
||||
async def revenue_launch_pipeline_schema() -> dict[str, Any]:
|
||||
return build_pipeline_schema()
|
||||
|
||||
|
||||
@router.post("/pipeline/summarize")
|
||||
async def pipeline_summarize(
|
||||
pipeline: list[dict[str, Any]] = Body(default_factory=list, embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return summarize_pipeline(pipeline)
|
||||
@router.get("/pilot-delivery")
|
||||
async def revenue_launch_pilot_delivery() -> dict[str, Any]:
|
||||
return {
|
||||
"intake": build_client_intake_form(),
|
||||
"plan_24h": build_24h_delivery_plan(),
|
||||
"first_10": build_first_10_opportunities_delivery(),
|
||||
"list_intelligence": build_list_intelligence_delivery(),
|
||||
"diagnostic": build_growth_diagnostic_delivery(),
|
||||
"no_live_send": True,
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
# ── Pilot delivery ───────────────────────────────────────────
|
||||
@router.get("/pilot-delivery/intake-form")
|
||||
async def pilot_intake_form() -> dict[str, Any]:
|
||||
return build_client_intake_form()
|
||||
@router.get("/payment/manual-flow")
|
||||
async def revenue_launch_payment_manual() -> dict[str, Any]:
|
||||
return {
|
||||
"instructions": build_moyasar_invoice_instructions(),
|
||||
"message_template": build_payment_link_message(),
|
||||
"confirmation": build_payment_confirmation_checklist(),
|
||||
"no_live_charge": True,
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/pilot-delivery/24h-plan")
|
||||
async def pilot_24h_plan(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_24h_delivery_plan(payload.get("service_id", ""))
|
||||
|
||||
|
||||
@router.post("/pilot-delivery/first-10")
|
||||
async def pilot_first_10(intake: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_first_10_opportunities_delivery(intake)
|
||||
|
||||
|
||||
@router.post("/pilot-delivery/list-intelligence")
|
||||
async def pilot_list_intelligence(intake: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_list_intelligence_delivery(intake)
|
||||
|
||||
|
||||
@router.post("/pilot-delivery/free-diagnostic")
|
||||
async def pilot_free_diagnostic(intake: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_growth_diagnostic_delivery(intake)
|
||||
|
||||
|
||||
# ── Payment manual flow ──────────────────────────────────────
|
||||
@router.post("/payment/invoice-instructions")
|
||||
async def payment_invoice_instructions(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_moyasar_invoice_instructions(
|
||||
amount_sar=int(payload.get("amount_sar", 499)),
|
||||
customer_name=payload.get("customer_name", ""),
|
||||
invoice_description=payload.get(
|
||||
"invoice_description",
|
||||
"Dealix Private Beta Pilot — 7 days",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/payment/link-message")
|
||||
async def payment_link_message(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_payment_link_message(
|
||||
customer_name=payload.get("customer_name", "[الاسم]"),
|
||||
invoice_url=payload.get("invoice_url", "[INVOICE_URL]"),
|
||||
amount_sar=int(payload.get("amount_sar", 499)),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/payment/confirmation-checklist")
|
||||
async def payment_confirmation_checklist() -> dict[str, Any]:
|
||||
return build_payment_confirmation_checklist()
|
||||
|
||||
|
||||
# ── Proof Pack ───────────────────────────────────────────────
|
||||
@router.post("/proof-pack/template")
|
||||
async def proof_pack_template(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_private_beta_proof_pack(
|
||||
company_name=payload.get("company_name", ""),
|
||||
metrics=payload.get("metrics", {}),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/proof-pack/client-summary")
|
||||
async def proof_pack_client_summary(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_client_summary(
|
||||
company_name=payload.get("company_name", ""),
|
||||
opportunities_count=int(payload.get("opportunities_count", 0)),
|
||||
approved_drafts=int(payload.get("approved_drafts", 0)),
|
||||
meetings=int(payload.get("meetings", 0)),
|
||||
pipeline_sar=float(payload.get("pipeline_sar", 0)),
|
||||
risks_blocked=int(payload.get("risks_blocked", 0)),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/proof-pack/next-step")
|
||||
async def proof_pack_next_step(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_next_step_recommendation(pilot_metrics=payload)
|
||||
@router.get("/proof-pack/template")
|
||||
async def revenue_launch_proof_pack_template() -> dict[str, Any]:
|
||||
return build_private_beta_proof_pack()
|
||||
|
||||
@ -112,6 +112,17 @@ from auto_client_acquisition.revenue_graph.why_now import (
|
||||
explain_why_now,
|
||||
)
|
||||
|
||||
# Revenue Company OS (events → cards → RWU; deterministic demo)
|
||||
from auto_client_acquisition.revenue_company_os.action_graph import demo_action_graph
|
||||
from auto_client_acquisition.revenue_company_os.channel_health import demo_channel_health
|
||||
from auto_client_acquisition.revenue_company_os.command_feed_engine import build_company_os_command_feed
|
||||
from auto_client_acquisition.revenue_company_os.event_to_card import event_to_card
|
||||
from auto_client_acquisition.revenue_company_os.opportunity_factory import demo_opportunities
|
||||
from auto_client_acquisition.revenue_company_os.proof_ledger import demo_proof_ledger
|
||||
from auto_client_acquisition.revenue_company_os.revenue_work_units import demo_work_units
|
||||
from auto_client_acquisition.revenue_company_os.self_improvement_loop import weekly_growth_curator_report_ar
|
||||
from auto_client_acquisition.revenue_company_os.service_factory import demo_service_snapshot
|
||||
|
||||
router = APIRouter(prefix="/api/v1/revenue-os", tags=["revenue-os"])
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@ -656,6 +667,59 @@ async def get_vertical_templates(vertical_id: str) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
# ── Revenue Company OS (additive; does not replace POST /events) ─────
|
||||
|
||||
|
||||
@router.get("/company-os/command-feed/demo")
|
||||
async def company_os_command_feed_demo() -> dict[str, Any]:
|
||||
return build_company_os_command_feed(
|
||||
[{"type": "email.received", "payload": {"from": "demo@example.com"}}],
|
||||
)
|
||||
|
||||
|
||||
@router.post("/company-os/events/ingest")
|
||||
async def company_os_events_ingest(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
et = str(body.get("type") or body.get("event_type") or "form.submitted")
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else {}
|
||||
card = event_to_card(et, payload)
|
||||
return {"ingested": True, "card": card, "demo": True}
|
||||
|
||||
|
||||
@router.get("/company-os/work-units/demo")
|
||||
async def company_os_work_units_demo() -> dict[str, Any]:
|
||||
return demo_work_units()
|
||||
|
||||
|
||||
@router.get("/company-os/channel-health/demo")
|
||||
async def company_os_channel_health_demo() -> dict[str, Any]:
|
||||
return demo_channel_health()
|
||||
|
||||
|
||||
@router.get("/company-os/opportunity-factory/demo")
|
||||
async def company_os_opportunity_factory_demo() -> dict[str, Any]:
|
||||
return demo_opportunities()
|
||||
|
||||
|
||||
@router.get("/company-os/action-graph/demo")
|
||||
async def company_os_action_graph_demo() -> dict[str, Any]:
|
||||
return demo_action_graph()
|
||||
|
||||
|
||||
@router.get("/company-os/self-improvement/weekly-report")
|
||||
async def company_os_self_improvement_weekly() -> dict[str, Any]:
|
||||
return weekly_growth_curator_report_ar()
|
||||
|
||||
|
||||
@router.get("/company-os/proof-ledger/demo")
|
||||
async def company_os_proof_ledger_demo() -> dict[str, Any]:
|
||||
return demo_proof_ledger()
|
||||
|
||||
|
||||
@router.get("/company-os/services/snapshot")
|
||||
async def company_os_services_snapshot() -> dict[str, Any]:
|
||||
return demo_service_snapshot()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Security Curator router — secret redaction + diff inspection."""
|
||||
"""Security curator API — redact and inspect diffs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,50 +6,32 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.security_curator import (
|
||||
inspect_diff,
|
||||
redact_trace,
|
||||
sanitize_tool_output,
|
||||
scan_payload,
|
||||
)
|
||||
from auto_client_acquisition.security_curator.patch_firewall import inspect_diff
|
||||
from auto_client_acquisition.security_curator.secret_redactor import redact_secrets, scan_payload
|
||||
from auto_client_acquisition.security_curator.trace_redactor import redact_trace_payload
|
||||
|
||||
router = APIRouter(prefix="/api/v1/security-curator", tags=["security-curator"])
|
||||
router = APIRouter(prefix="/api/v1/security-curator", tags=["security_curator"])
|
||||
|
||||
|
||||
@router.get("/demo")
|
||||
async def demo() -> dict[str, Any]:
|
||||
"""Run the redactor against a synthetic payload (deterministic, no network)."""
|
||||
sample = {
|
||||
"user_id": "user_42",
|
||||
"phone": "+966500000123",
|
||||
"email": "ali@example.sa",
|
||||
"api_key": "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1234",
|
||||
"openai_key": "sk-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1234",
|
||||
"notes": "العميل أحمد رقمه +966599999999 وإيميله ali@example.com",
|
||||
}
|
||||
scan = scan_payload(sample)
|
||||
trace = redact_trace(sample)
|
||||
return {
|
||||
"scan": scan,
|
||||
"trace": trace,
|
||||
}
|
||||
return {"ok": True, "message_ar": "طبقة أمان للوكلاء — redaction وفحص فرق قبل التطبيق.", "demo": True}
|
||||
|
||||
|
||||
@router.post("/redact")
|
||||
async def redact(payload: Any = Body(...)) -> dict[str, Any]:
|
||||
"""Redact secrets + PII from arbitrary JSON payload."""
|
||||
return redact_trace(payload)
|
||||
async def redact(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
text = str(payload.get("text") or "")
|
||||
return {"redacted": redact_secrets(text), "findings": scan_payload(payload)}
|
||||
|
||||
|
||||
@router.post("/inspect-diff")
|
||||
async def inspect_diff_endpoint(
|
||||
diff: str = Body(..., embed=True),
|
||||
) -> dict[str, Any]:
|
||||
"""Inspect a unified diff for blocked files + secret patterns."""
|
||||
return inspect_diff(diff).to_dict()
|
||||
async def inspect_diff_route(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
diff = str(payload.get("diff_text") or "")
|
||||
return inspect_diff(diff)
|
||||
|
||||
|
||||
@router.post("/sanitize-output")
|
||||
async def sanitize_output(payload: Any = Body(...)) -> dict[str, Any]:
|
||||
"""Sanitize a tool output before logging or showing it to a human."""
|
||||
return sanitize_tool_output(payload)
|
||||
@router.post("/trace/sanitize")
|
||||
async def trace_sanitize(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
"""Redact nested trace/span metadata before export to observability backends."""
|
||||
body = payload.get("payload") if isinstance(payload.get("payload"), dict) else payload
|
||||
return {"sanitized": redact_trace_payload(body or {}), "demo": True}
|
||||
|
||||
@ -1,179 +1,103 @@
|
||||
"""Service Excellence OS router — feature matrix + score + gates + research."""
|
||||
"""Service Excellence OS API — scoring, matrices, launch packages (deterministic)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
from fastapi import APIRouter
|
||||
|
||||
from auto_client_acquisition.service_excellence import (
|
||||
build_backlog,
|
||||
from auto_client_acquisition.service_excellence.competitor_gap import compare_against_categories
|
||||
from auto_client_acquisition.service_excellence.feature_matrix import build_feature_matrix, classify_features
|
||||
from auto_client_acquisition.service_excellence.launch_package import (
|
||||
build_demo_script,
|
||||
build_feature_matrix,
|
||||
build_landing_page_outline,
|
||||
build_monthly_service_review,
|
||||
build_onboarding_checklist,
|
||||
build_proof_pack_template_excellence,
|
||||
build_sales_script,
|
||||
build_service_launch_package,
|
||||
build_service_research_brief,
|
||||
calculate_service_excellence_score,
|
||||
calculate_service_roi_estimate,
|
||||
classify_features,
|
||||
compare_against_categories,
|
||||
convert_feedback_to_backlog,
|
||||
generate_feature_hypotheses,
|
||||
prioritize_backlog_items,
|
||||
recommend_missing_features,
|
||||
recommend_next_experiments,
|
||||
recommend_weekly_improvements,
|
||||
)
|
||||
from auto_client_acquisition.service_excellence.proof_metrics import (
|
||||
build_proof_pack_template,
|
||||
required_proof_metrics,
|
||||
review_service_before_launch,
|
||||
summarize_proof_ar,
|
||||
)
|
||||
from auto_client_acquisition.service_tower import ALL_SERVICES
|
||||
from auto_client_acquisition.service_excellence.quality_review import review_all_services, review_service_before_launch
|
||||
from auto_client_acquisition.service_excellence.research_lab import build_service_research_brief
|
||||
from auto_client_acquisition.service_excellence.service_improvement_backlog import build_backlog, prioritize_backlog_items
|
||||
from auto_client_acquisition.service_excellence.service_scoring import calculate_service_excellence_score
|
||||
from auto_client_acquisition.service_excellence.workflow_builder import (
|
||||
build_approval_steps,
|
||||
build_day_by_day_execution_plan,
|
||||
build_workflow,
|
||||
validate_workflow,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/service-excellence", tags=["service-excellence"])
|
||||
|
||||
|
||||
# ── Feature matrix ───────────────────────────────────────────
|
||||
@router.get("/{service_id}/feature-matrix")
|
||||
async def feature_matrix(service_id: str) -> dict[str, Any]:
|
||||
return build_feature_matrix(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/feature-classification")
|
||||
async def feature_classification(service_id: str) -> dict[str, Any]:
|
||||
return classify_features(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/missing-features")
|
||||
async def missing_features(service_id: str) -> dict[str, Any]:
|
||||
return {"recommendations": recommend_missing_features(service_id)}
|
||||
|
||||
|
||||
# ── Scoring ──────────────────────────────────────────────────
|
||||
@router.get("/{service_id}/score")
|
||||
async def score(service_id: str) -> dict[str, Any]:
|
||||
return calculate_service_excellence_score(service_id)
|
||||
|
||||
|
||||
# ── Gates / quality review ──────────────────────────────────
|
||||
@router.get("/{service_id}/quality-review")
|
||||
async def quality_review(service_id: str) -> dict[str, Any]:
|
||||
return review_service_before_launch(service_id)
|
||||
router = APIRouter(prefix="/api/v1/service-excellence", tags=["service_excellence"])
|
||||
|
||||
|
||||
@router.get("/review/all")
|
||||
async def review_all() -> dict[str, Any]:
|
||||
"""Review every catalogued service."""
|
||||
out = [review_service_before_launch(s.id) for s in ALL_SERVICES]
|
||||
counts: dict[str, int] = {}
|
||||
for r in out:
|
||||
v = str(r.get("verdict", "?"))
|
||||
counts[v] = counts.get(v, 0) + 1
|
||||
return {"total": len(out), "by_verdict": counts, "results": out}
|
||||
return review_all_services()
|
||||
|
||||
|
||||
@router.get("/{service_id}/feature-matrix")
|
||||
async def feature_matrix(service_id: str) -> dict[str, Any]:
|
||||
fm = build_feature_matrix(service_id)
|
||||
fm["classified"] = classify_features(service_id)
|
||||
return fm
|
||||
|
||||
|
||||
@router.get("/{service_id}/score")
|
||||
async def excellence_score(service_id: str) -> dict[str, Any]:
|
||||
return calculate_service_excellence_score(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/workflow")
|
||||
async def excellence_workflow(service_id: str) -> dict[str, Any]:
|
||||
wf = build_workflow(service_id)
|
||||
wf["validation"] = validate_workflow(service_id)
|
||||
wf["day_plan"] = build_day_by_day_execution_plan(service_id)
|
||||
wf["approval"] = build_approval_steps(service_id)
|
||||
return wf
|
||||
|
||||
|
||||
# ── Proof metrics ────────────────────────────────────────────
|
||||
@router.get("/{service_id}/proof-metrics")
|
||||
async def proof_metrics(service_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"service_id": service_id,
|
||||
"metrics": required_proof_metrics(service_id),
|
||||
"template": build_proof_pack_template_excellence(service_id),
|
||||
"required": required_proof_metrics(service_id),
|
||||
"template": build_proof_pack_template(service_id),
|
||||
"summary_example_ar": summarize_proof_ar(service_id, {"pipeline_sar": 15000}),
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{service_id}/roi-estimate")
|
||||
async def roi_estimate(
|
||||
service_id: str,
|
||||
metrics: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
out = calculate_service_roi_estimate(service_id, metrics)
|
||||
if "error" not in out:
|
||||
out["proof_summary_ar"] = summarize_proof_ar(service_id, metrics)
|
||||
return out
|
||||
|
||||
|
||||
# ── Competitor gap ───────────────────────────────────────────
|
||||
@router.get("/{service_id}/gap-analysis")
|
||||
async def gap_analysis(service_id: str) -> dict[str, Any]:
|
||||
return compare_against_categories(service_id)
|
||||
|
||||
|
||||
# ── Research lab ─────────────────────────────────────────────
|
||||
@router.get("/{service_id}/launch-package")
|
||||
async def launch_package(service_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"package": build_service_launch_package(service_id),
|
||||
"landing": build_landing_page_outline(service_id),
|
||||
"sales_script": build_sales_script(service_id),
|
||||
"demo_script": build_demo_script(service_id),
|
||||
"onboarding": build_onboarding_checklist(service_id),
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{service_id}/backlog")
|
||||
async def backlog(service_id: str) -> dict[str, Any]:
|
||||
items = build_backlog(service_id)
|
||||
return {"service_id": service_id, "items": prioritize_backlog_items(items), "demo": True}
|
||||
|
||||
|
||||
@router.get("/{service_id}/research-brief")
|
||||
async def research_brief(service_id: str) -> dict[str, Any]:
|
||||
return build_service_research_brief(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/feature-hypotheses")
|
||||
async def feature_hypotheses(service_id: str) -> dict[str, Any]:
|
||||
return {"hypotheses": generate_feature_hypotheses(service_id)}
|
||||
|
||||
|
||||
@router.get("/{service_id}/experiments")
|
||||
async def experiments(service_id: str) -> dict[str, Any]:
|
||||
return recommend_next_experiments(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/monthly-review")
|
||||
async def monthly_review(service_id: str) -> dict[str, Any]:
|
||||
return build_monthly_service_review(service_id)
|
||||
|
||||
|
||||
# ── Backlog ──────────────────────────────────────────────────
|
||||
@router.get("/{service_id}/backlog")
|
||||
async def backlog(service_id: str) -> dict[str, Any]:
|
||||
return build_backlog(service_id)
|
||||
|
||||
|
||||
@router.post("/{service_id}/backlog/from-feedback")
|
||||
async def backlog_from_feedback(
|
||||
service_id: str,
|
||||
feedback: list[dict[str, Any]] = Body(..., embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"service_id": service_id,
|
||||
"items": convert_feedback_to_backlog(feedback),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{service_id}/backlog/prioritize")
|
||||
async def backlog_prioritize(
|
||||
service_id: str,
|
||||
items: list[dict[str, Any]] = Body(..., embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return {"items": prioritize_backlog_items(items)}
|
||||
|
||||
|
||||
@router.get("/{service_id}/weekly-improvements")
|
||||
async def weekly_improvements(service_id: str) -> dict[str, Any]:
|
||||
return recommend_weekly_improvements(service_id)
|
||||
|
||||
|
||||
# ── Launch package ───────────────────────────────────────────
|
||||
@router.get("/{service_id}/launch-package")
|
||||
async def launch_package(service_id: str) -> dict[str, Any]:
|
||||
return build_service_launch_package(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/landing-outline")
|
||||
async def landing_outline(service_id: str) -> dict[str, Any]:
|
||||
return build_landing_page_outline(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/sales-script")
|
||||
async def sales_script(service_id: str) -> dict[str, Any]:
|
||||
return build_sales_script(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/demo-script")
|
||||
async def demo_script(service_id: str) -> dict[str, Any]:
|
||||
return build_demo_script(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/onboarding-checklist")
|
||||
async def onboarding_checklist(service_id: str) -> dict[str, Any]:
|
||||
return build_onboarding_checklist(service_id)
|
||||
@router.get("/{service_id}/review")
|
||||
async def review_one(service_id: str) -> dict[str, Any]:
|
||||
return review_service_before_launch(service_id)
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Service Tower router — كتالوج الخدمات + wizard + workflow + pricing + cards."""
|
||||
"""Service Tower API — sellable services wizard (no live send)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,85 +6,122 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.service_tower import (
|
||||
build_ceo_daily_service_brief,
|
||||
from auto_client_acquisition.platform_services.service_catalog import get_service_catalog
|
||||
from auto_client_acquisition.service_tower.deliverables import (
|
||||
build_client_report_outline,
|
||||
build_deliverables,
|
||||
build_end_of_day_service_report,
|
||||
build_intake_questions,
|
||||
build_internal_operator_checklist,
|
||||
build_proof_pack_template,
|
||||
build_risk_alert_card,
|
||||
build_service_approval_card,
|
||||
build_service_scorecard,
|
||||
build_service_workflow,
|
||||
build_upsell_message_ar,
|
||||
)
|
||||
from auto_client_acquisition.service_tower.mission_templates import build_service_workflow
|
||||
from auto_client_acquisition.service_tower.pricing_engine import (
|
||||
calculate_monthly_offer,
|
||||
calculate_setup_fee,
|
||||
catalog_summary,
|
||||
get_service,
|
||||
list_all_services,
|
||||
map_service_to_growth_mission,
|
||||
map_service_to_subscription,
|
||||
quote_service,
|
||||
recommend_next_step,
|
||||
recommend_plan_after_service,
|
||||
)
|
||||
from auto_client_acquisition.service_tower.service_catalog import get_service_by_id, list_tower_services
|
||||
from auto_client_acquisition.service_tower.service_scorecard import build_service_scorecard
|
||||
from auto_client_acquisition.service_tower.service_wizard import (
|
||||
build_intake_questions,
|
||||
recommend_service,
|
||||
recommend_upgrade,
|
||||
start_service,
|
||||
summarize_recommendation_ar,
|
||||
summarize_scorecard_ar,
|
||||
validate_service_inputs,
|
||||
)
|
||||
from auto_client_acquisition.service_tower.contract_templates import list_contract_templates
|
||||
from auto_client_acquisition.service_tower.upgrade_paths import build_all_upgrade_paths, recommend_upgrade
|
||||
from auto_client_acquisition.service_tower.vertical_service_map import build_vertical_service_map
|
||||
from auto_client_acquisition.service_tower.whatsapp_ceo_control import (
|
||||
build_ceo_daily_service_brief,
|
||||
build_end_of_day_service_report,
|
||||
build_service_approval_card,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/services", tags=["service-tower"])
|
||||
router = APIRouter(prefix="/api/v1/services", tags=["service_tower"])
|
||||
|
||||
|
||||
# ── Catalog ──────────────────────────────────────────────────
|
||||
@router.get("/catalog")
|
||||
async def catalog() -> dict[str, Any]:
|
||||
return list_all_services()
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
async def summary() -> dict[str, Any]:
|
||||
return catalog_summary()
|
||||
async def services_catalog() -> dict[str, Any]:
|
||||
tower = list_tower_services()
|
||||
platform = get_service_catalog()
|
||||
return {
|
||||
"tower": tower,
|
||||
"platform_service_catalog": platform,
|
||||
"note_ar": "برج الخدمات (تفصيل بيع) + كتالوج المنصة (طبقة تقنية) — يُدمجان للعرض.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/recommend")
|
||||
async def recommend(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async def services_recommend(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
p = payload or {}
|
||||
rec = recommend_service(
|
||||
company_type=payload.get("company_type", ""),
|
||||
goal=payload.get("goal", "fill_pipeline"),
|
||||
has_contact_list=bool(payload.get("has_contact_list", False)),
|
||||
channels=payload.get("channels", []),
|
||||
budget_sar=int(payload.get("budget_sar", 1000)),
|
||||
company_type=str(p.get("company_type") or ""),
|
||||
goal=str(p.get("goal") or ""),
|
||||
has_contact_list=bool(p.get("has_contact_list")),
|
||||
channels=list(p.get("channels") or []),
|
||||
budget_sar=p.get("budget_sar"),
|
||||
)
|
||||
rec["summary_ar"] = summarize_recommendation_ar(rec)
|
||||
return rec
|
||||
|
||||
|
||||
# ── Per-service ──────────────────────────────────────────────
|
||||
@router.get("/{service_id}/intake-questions")
|
||||
async def service_intake_questions(service_id: str) -> dict[str, Any]:
|
||||
return build_intake_questions(service_id)
|
||||
@router.post("/start")
|
||||
async def services_start(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
p = payload or {}
|
||||
return start_service(str(p.get("service_id") or ""), dict(p.get("payload") or p))
|
||||
|
||||
|
||||
@router.post("/{service_id}/start")
|
||||
async def service_start(
|
||||
service_id: str,
|
||||
payload: dict[str, Any] = Body(...),
|
||||
) -> dict[str, Any]:
|
||||
validation = validate_service_inputs(service_id, payload)
|
||||
if not validation["valid"]:
|
||||
return {"started": False, "validation": validation}
|
||||
workflow = build_service_workflow(service_id)
|
||||
return {
|
||||
"started": True,
|
||||
"validation": validation,
|
||||
"workflow": workflow,
|
||||
"linked_growth_mission": map_service_to_growth_mission(service_id),
|
||||
"approval_required": True,
|
||||
}
|
||||
@router.get("/demo/dashboard")
|
||||
async def services_demo_dashboard() -> dict[str, Any]:
|
||||
ids = [s["service_id"] for s in list_tower_services().get("services") or []][:5]
|
||||
cards = []
|
||||
for sid in ids:
|
||||
svc = get_service_by_id(sid)
|
||||
cards.append(
|
||||
{
|
||||
"service_id": sid,
|
||||
"name_ar": (svc or {}).get("name_ar"),
|
||||
"deliverables": build_deliverables(sid),
|
||||
"scorecard": build_service_scorecard(
|
||||
sid,
|
||||
{"drafts_created": 2, "approvals": 1, "meetings_booked": 0, "risks_blocked": 3},
|
||||
),
|
||||
}
|
||||
)
|
||||
return {"cards": cards, "live_send": False, "demo": True}
|
||||
|
||||
|
||||
@router.get("/ceo/daily-brief")
|
||||
async def ceo_daily_brief() -> dict[str, Any]:
|
||||
return build_ceo_daily_service_brief()
|
||||
|
||||
|
||||
@router.get("/ceo/end-of-day")
|
||||
async def ceo_end_of_day() -> dict[str, Any]:
|
||||
return build_end_of_day_service_report()
|
||||
|
||||
|
||||
@router.post("/approval-card")
|
||||
async def approval_card(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
p = payload or {}
|
||||
return build_service_approval_card(str(p.get("service_id") or "growth_os"), str(p.get("action") or "draft_review"))
|
||||
|
||||
|
||||
@router.get("/verticals")
|
||||
async def services_verticals() -> dict[str, Any]:
|
||||
return build_vertical_service_map()
|
||||
|
||||
|
||||
@router.get("/upgrade-paths")
|
||||
async def services_upgrade_paths() -> dict[str, Any]:
|
||||
return build_all_upgrade_paths()
|
||||
|
||||
|
||||
@router.get("/contracts/templates")
|
||||
async def services_contract_templates() -> dict[str, Any]:
|
||||
return list_contract_templates()
|
||||
|
||||
|
||||
@router.get("/{service_id}/workflow")
|
||||
@ -92,86 +129,45 @@ async def service_workflow(service_id: str) -> dict[str, Any]:
|
||||
return build_service_workflow(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/deliverables")
|
||||
async def service_deliverables(service_id: str) -> dict[str, Any]:
|
||||
return build_deliverables(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/proof-pack-template")
|
||||
async def service_proof_pack_template(service_id: str) -> dict[str, Any]:
|
||||
return build_proof_pack_template(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/client-report-outline")
|
||||
async def service_client_report_outline(service_id: str) -> dict[str, Any]:
|
||||
return build_client_report_outline(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/operator-checklist")
|
||||
async def service_operator_checklist(service_id: str) -> dict[str, Any]:
|
||||
return build_internal_operator_checklist(service_id)
|
||||
|
||||
|
||||
@router.post("/{service_id}/quote")
|
||||
async def service_quote(
|
||||
service_id: str,
|
||||
payload: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
return quote_service(
|
||||
p = payload or {}
|
||||
q = quote_service(
|
||||
service_id,
|
||||
company_size=payload.get("company_size", "small"),
|
||||
urgency=payload.get("urgency", "normal"),
|
||||
channels_count=int(payload.get("channels_count", 1)),
|
||||
company_size=str(p.get("company_size") or "smb"),
|
||||
urgency=str(p.get("urgency") or "normal"),
|
||||
channels_count=int(p.get("channels_count") or 1),
|
||||
)
|
||||
q["setup_fee_hint"] = calculate_setup_fee(service_id)
|
||||
q["monthly_hint"] = calculate_monthly_offer(service_id)
|
||||
q["upgrade_hint"] = recommend_plan_after_service(service_id, str(p.get("outcome") or ""))
|
||||
return q
|
||||
|
||||
|
||||
@router.get("/{service_id}/setup-fee")
|
||||
async def service_setup_fee(service_id: str) -> dict[str, Any]:
|
||||
return calculate_setup_fee(service_id)
|
||||
@router.get("/{service_id}/intake-questions")
|
||||
async def intake_questions(service_id: str) -> dict[str, Any]:
|
||||
return build_intake_questions(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/monthly-offer")
|
||||
async def service_monthly_offer(service_id: str) -> dict[str, Any]:
|
||||
return calculate_monthly_offer(service_id)
|
||||
@router.post("/{service_id}/validate")
|
||||
async def validate_inputs(service_id: str, payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return validate_service_inputs(service_id, payload or {})
|
||||
|
||||
|
||||
@router.post("/{service_id}/scorecard")
|
||||
async def service_scorecard(
|
||||
service_id: str,
|
||||
metrics: dict[str, Any] = Body(default_factory=dict),
|
||||
) -> dict[str, Any]:
|
||||
return build_service_scorecard(service_id, metrics)
|
||||
@router.get("/{service_id}/deliverables")
|
||||
async def service_deliverables(service_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"deliverables": build_deliverables(service_id),
|
||||
"proof_pack": build_proof_pack_template(service_id),
|
||||
"client_report": build_client_report_outline(service_id),
|
||||
"operator_checklist": build_internal_operator_checklist(service_id),
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{service_id}/upgrade-path")
|
||||
async def service_upgrade_path(service_id: str) -> dict[str, Any]:
|
||||
return recommend_upgrade(service_id)
|
||||
|
||||
|
||||
@router.get("/{service_id}/post-service-plan")
|
||||
async def service_post_plan(service_id: str) -> dict[str, Any]:
|
||||
return recommend_plan_after_service(service_id)
|
||||
|
||||
|
||||
# ── CEO control via WhatsApp ─────────────────────────────────
|
||||
@router.get("/ceo/daily-brief")
|
||||
async def ceo_daily_brief() -> dict[str, Any]:
|
||||
return build_ceo_daily_service_brief()
|
||||
|
||||
|
||||
@router.post("/ceo/approval-card")
|
||||
async def ceo_approval_card(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_service_approval_card(
|
||||
service_id=payload.get("service_id", ""),
|
||||
action=payload.get("action", ""),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/ceo/risk-alert/demo")
|
||||
async def ceo_risk_alert_demo() -> dict[str, Any]:
|
||||
return build_risk_alert_card()
|
||||
|
||||
|
||||
@router.get("/ceo/end-of-day/demo")
|
||||
async def ceo_end_of_day_demo() -> dict[str, Any]:
|
||||
return build_end_of_day_service_report()
|
||||
@router.get("/{service_id}/upgrade")
|
||||
async def service_upgrade(service_id: str) -> dict[str, Any]:
|
||||
return recommend_upgrade(service_id, {})
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Targeting & Acquisition OS router."""
|
||||
"""Targeting & Acquisition OS API — planning and evaluation only, no live send."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,235 +6,128 @@ from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body
|
||||
|
||||
from auto_client_acquisition.targeting_os import (
|
||||
analyze_uploaded_list_preview,
|
||||
build_dealix_self_growth_plan,
|
||||
build_daily_targeting_brief,
|
||||
build_end_of_day_report,
|
||||
build_followup_sequence,
|
||||
from auto_client_acquisition.intelligence_layer.trust_score import compute_trust_score
|
||||
from auto_client_acquisition.platform_services.contact_import_preview import build_import_preview
|
||||
from auto_client_acquisition.targeting_os.account_finder import recommend_accounts, recommend_account_source_strategy
|
||||
from auto_client_acquisition.targeting_os.acquisition_scorecard import build_acquisition_scorecard
|
||||
from auto_client_acquisition.targeting_os.buyer_role_mapper import map_buying_committee
|
||||
from auto_client_acquisition.targeting_os.contactability_matrix import evaluate_contactability
|
||||
from auto_client_acquisition.targeting_os.contract_drafts import list_contract_templates
|
||||
from auto_client_acquisition.targeting_os.daily_autopilot import build_daily_targeting_brief
|
||||
from auto_client_acquisition.targeting_os.free_diagnostic import (
|
||||
build_free_growth_diagnostic,
|
||||
recommend_paid_pilot_offer,
|
||||
)
|
||||
from auto_client_acquisition.targeting_os.linkedin_strategy import (
|
||||
build_lead_gen_form_plan,
|
||||
build_outreach_plan,
|
||||
build_self_growth_daily_brief,
|
||||
build_weekly_learning_report,
|
||||
calculate_channel_reputation,
|
||||
draft_b2b_email,
|
||||
draft_role_based_angle,
|
||||
draft_whatsapp_message,
|
||||
enforce_daily_limits,
|
||||
evaluate_contactability,
|
||||
explain_contactability_ar,
|
||||
list_targeting_services,
|
||||
map_buying_committee,
|
||||
recommend_accounts,
|
||||
recommend_dealix_targets,
|
||||
recommend_linkedin_strategy,
|
||||
recommend_recovery_action,
|
||||
recommend_service_offer,
|
||||
recommend_today_actions,
|
||||
score_email_risk,
|
||||
score_whatsapp_risk,
|
||||
summarize_plan_ar,
|
||||
summarize_reputation_ar,
|
||||
)
|
||||
from auto_client_acquisition.targeting_os.contract_drafts import (
|
||||
draft_agency_partner_outline,
|
||||
draft_dpa_outline,
|
||||
draft_pilot_agreement_outline,
|
||||
draft_referral_agreement_outline,
|
||||
)
|
||||
from auto_client_acquisition.targeting_os.outreach_scheduler import build_outreach_plan
|
||||
from auto_client_acquisition.targeting_os.reputation_guard import calculate_channel_reputation, should_pause_channel
|
||||
from auto_client_acquisition.targeting_os.self_growth_mode import build_self_growth_daily_brief
|
||||
from auto_client_acquisition.targeting_os.service_offers import list_targeting_services
|
||||
|
||||
router = APIRouter(prefix="/api/v1/targeting", tags=["targeting-os"])
|
||||
router = APIRouter(prefix="/api/v1/targeting", tags=["targeting_os"])
|
||||
|
||||
|
||||
# ── Accounts ─────────────────────────────────────────────────
|
||||
@router.post("/accounts/recommend")
|
||||
async def accounts_recommend(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async def accounts_recommend(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return recommend_accounts(
|
||||
sector=payload.get("sector", "saas"),
|
||||
city=payload.get("city", "Riyadh"),
|
||||
offer=payload.get("offer", ""),
|
||||
goal=payload.get("goal", "fill_pipeline"),
|
||||
limit=int(payload.get("limit", 10)),
|
||||
str(payload.get("sector") or ""),
|
||||
str(payload.get("city") or ""),
|
||||
str(payload.get("offer") or ""),
|
||||
str(payload.get("goal") or ""),
|
||||
limit=int(payload.get("limit") or 10),
|
||||
)
|
||||
|
||||
|
||||
# ── Buying committee ─────────────────────────────────────────
|
||||
@router.post("/buying-committee/map")
|
||||
async def buying_committee_map(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
async def buying_committee_map(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return map_buying_committee(
|
||||
sector=payload.get("sector", "saas"),
|
||||
company_size=payload.get("company_size", "small"),
|
||||
goal=payload.get("goal", "fill_pipeline"),
|
||||
str(payload.get("sector") or ""),
|
||||
payload.get("company_size"),
|
||||
payload.get("goal"),
|
||||
)
|
||||
|
||||
|
||||
# ── Contacts ─────────────────────────────────────────────────
|
||||
@router.post("/contacts/evaluate")
|
||||
async def contacts_evaluate(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
contact = payload.get("contact") or payload
|
||||
async def contacts_evaluate(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
contact = payload.get("contact") if isinstance(payload.get("contact"), dict) else payload
|
||||
desired = payload.get("desired_channel")
|
||||
result = evaluate_contactability(contact, desired_channel=desired)
|
||||
result["explanation_ar"] = explain_contactability_ar(result)
|
||||
return result
|
||||
return evaluate_contactability(contact, str(desired) if desired else None)
|
||||
|
||||
|
||||
@router.post("/uploaded-list/analyze")
|
||||
async def uploaded_list_analyze(
|
||||
contacts: list[dict[str, Any]] = Body(..., embed=True),
|
||||
) -> dict[str, Any]:
|
||||
return analyze_uploaded_list_preview(contacts)
|
||||
async def uploaded_list_analyze(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
"""Delegates to platform import preview for full bucket logic."""
|
||||
return build_import_preview(payload or {})
|
||||
|
||||
|
||||
# ── Outreach ─────────────────────────────────────────────────
|
||||
@router.post("/outreach/plan")
|
||||
async def outreach_plan(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
plan = build_outreach_plan(
|
||||
targets=payload.get("targets", []),
|
||||
channels=payload.get("channels"),
|
||||
goal=payload.get("goal", "fill_pipeline"),
|
||||
)
|
||||
plan = enforce_daily_limits(plan)
|
||||
plan["summary_ar"] = summarize_plan_ar(plan)
|
||||
return plan
|
||||
async def outreach_plan(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
targets = payload.get("targets") if isinstance(payload.get("targets"), list) else []
|
||||
channels = payload.get("channels") if isinstance(payload.get("channels"), list) else ["email"]
|
||||
goal = str(payload.get("goal") or "growth")
|
||||
return build_outreach_plan([dict(t) for t in targets if isinstance(t, dict)], [str(c) for c in channels], goal)
|
||||
|
||||
|
||||
# ── Daily autopilot ──────────────────────────────────────────
|
||||
@router.get("/daily-autopilot/demo")
|
||||
async def daily_autopilot_demo() -> dict[str, Any]:
|
||||
return {
|
||||
"brief": build_daily_targeting_brief(),
|
||||
"today_actions": recommend_today_actions(),
|
||||
"end_of_day_template": build_end_of_day_report(),
|
||||
}
|
||||
return build_daily_targeting_brief({"sector": "training", "city": "الرياض", "offer": "Growth OS", "goal": "meetings"})
|
||||
|
||||
|
||||
# ── Self-Growth Mode ─────────────────────────────────────────
|
||||
@router.get("/self-growth/demo")
|
||||
async def self_growth_demo() -> dict[str, Any]:
|
||||
return {
|
||||
"plan": build_dealix_self_growth_plan(),
|
||||
"today": build_self_growth_daily_brief(),
|
||||
}
|
||||
return build_self_growth_daily_brief()
|
||||
|
||||
|
||||
@router.post("/self-growth/targets")
|
||||
async def self_growth_targets(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return recommend_dealix_targets(
|
||||
sector_focus=payload.get("sector"),
|
||||
city_focus=payload.get("city"),
|
||||
limit=int(payload.get("limit", 10)),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/self-growth/weekly-report")
|
||||
async def self_growth_weekly(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
return build_weekly_learning_report(payload)
|
||||
|
||||
|
||||
# ── Reputation guard ────────────────────────────────────────
|
||||
@router.get("/reputation/status")
|
||||
async def reputation_status() -> dict[str, Any]:
|
||||
"""Demo reputation snapshot."""
|
||||
healthy_email = {"bounce_rate": 0.005, "complaint_rate": 0.0001,
|
||||
"opt_out_rate": 0.01, "reply_rate": 0.04}
|
||||
risky_wa = {"block_rate": 0.04, "report_rate": 0.005,
|
||||
"opt_out_rate": 0.06, "reply_rate": 0.02}
|
||||
return {
|
||||
"email": calculate_channel_reputation(healthy_email, channel="email"),
|
||||
"whatsapp": calculate_channel_reputation(risky_wa, channel="whatsapp"),
|
||||
}
|
||||
metrics = {"bounce_rate": 0.12, "opt_out_rate": 0.01, "complaint_rate": 0.0, "reply_rate": 0.08}
|
||||
rep = calculate_channel_reputation(metrics)
|
||||
return {**rep, "should_pause": should_pause_channel(metrics)}
|
||||
|
||||
|
||||
@router.post("/reputation/recovery")
|
||||
async def reputation_recovery(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return recommend_recovery_action(
|
||||
payload.get("metrics", {}),
|
||||
channel=payload.get("channel", "email"),
|
||||
)
|
||||
|
||||
|
||||
# ── LinkedIn strategy ────────────────────────────────────────
|
||||
@router.post("/linkedin/strategy")
|
||||
async def linkedin_strategy(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
strategy = recommend_linkedin_strategy(
|
||||
segment=payload.get("segment", "B2B Saudi"),
|
||||
goal=payload.get("goal", "fill_pipeline"),
|
||||
)
|
||||
if payload.get("with_lead_gen_form"):
|
||||
strategy["lead_gen_form_plan"] = build_lead_gen_form_plan(
|
||||
segment=payload.get("segment", "B2B Saudi"),
|
||||
offer=payload.get("offer", "Pilot 7 days"),
|
||||
campaign_name=payload.get("campaign_name", ""),
|
||||
async def linkedin_strategy(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
seg = str(payload.get("segment") or "b2b")
|
||||
goal = str(payload.get("goal") or "leads")
|
||||
base = recommend_linkedin_strategy(seg, goal)
|
||||
if payload.get("include_lead_gen_plan"):
|
||||
base["lead_gen_plan"] = build_lead_gen_form_plan(
|
||||
seg,
|
||||
str(payload.get("offer") or "Pilot"),
|
||||
str(payload.get("campaign_name") or "dealix"),
|
||||
)
|
||||
return strategy
|
||||
return base
|
||||
|
||||
|
||||
# ── Drafts ───────────────────────────────────────────────────
|
||||
@router.post("/drafts/email")
|
||||
async def drafts_email(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
contact = payload.get("contact", {})
|
||||
draft = draft_b2b_email(
|
||||
contact,
|
||||
offer=payload.get("offer", ""),
|
||||
why_now=payload.get("why_now", ""),
|
||||
)
|
||||
risk = score_email_risk(contact, draft.get("body_ar", ""))
|
||||
return {**draft, "risk": risk}
|
||||
|
||||
|
||||
@router.post("/drafts/whatsapp")
|
||||
async def drafts_whatsapp(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
contact = payload.get("contact", {})
|
||||
return draft_whatsapp_message(
|
||||
contact,
|
||||
offer=payload.get("offer", ""),
|
||||
why_now=payload.get("why_now", ""),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/drafts/email-followup")
|
||||
async def drafts_email_followup(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_followup_sequence(
|
||||
payload.get("contact", {}),
|
||||
offer=payload.get("offer", ""),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/drafts/role-angle")
|
||||
async def drafts_role_angle(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return draft_role_based_angle(
|
||||
role_key=payload.get("role_key", "founder_ceo"),
|
||||
sector=payload.get("sector", "saas"),
|
||||
offer=payload.get("offer", ""),
|
||||
)
|
||||
|
||||
|
||||
# ── Free diagnostic ──────────────────────────────────────────
|
||||
@router.post("/free-diagnostic")
|
||||
async def free_diagnostic(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return build_free_growth_diagnostic(payload)
|
||||
|
||||
|
||||
# ── Services + contracts ─────────────────────────────────────
|
||||
@router.get("/services")
|
||||
async def services_list() -> dict[str, Any]:
|
||||
async def targeting_services() -> dict[str, Any]:
|
||||
return list_targeting_services()
|
||||
|
||||
|
||||
@router.post("/services/recommend")
|
||||
async def services_recommend(payload: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
return recommend_service_offer(
|
||||
customer_type=payload.get("customer_type", ""),
|
||||
goal=payload.get("goal", "fill_pipeline"),
|
||||
)
|
||||
@router.post("/free-diagnostic")
|
||||
async def free_diagnostic(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
company = payload.get("company") if isinstance(payload.get("company"), dict) else payload
|
||||
if not isinstance(company, dict):
|
||||
company = {}
|
||||
diag = build_free_growth_diagnostic(company or {"sector": "b2b", "city": "الرياض"})
|
||||
return {"diagnostic": diag, "pilot_offer": recommend_paid_pilot_offer(diag)}
|
||||
|
||||
|
||||
@router.get("/contracts/templates")
|
||||
async def contracts_templates() -> dict[str, Any]:
|
||||
return {
|
||||
"pilot": draft_pilot_agreement_outline(),
|
||||
"dpa": draft_dpa_outline(),
|
||||
"referral": draft_referral_agreement_outline(),
|
||||
"agency_partner": draft_agency_partner_outline(),
|
||||
}
|
||||
return list_contract_templates()
|
||||
|
||||
|
||||
@router.post("/trust-score")
|
||||
async def targeting_trust_score(signals: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
"""Bridge to intelligence trust score for targeting workflows."""
|
||||
return compute_trust_score(signals or {})
|
||||
|
||||
|
||||
@router.post("/account-strategy")
|
||||
async def account_strategy(payload: dict[str, Any] = Body(default_factory=dict)) -> dict[str, Any]:
|
||||
acct = payload.get("account") if isinstance(payload.get("account"), dict) else {}
|
||||
return recommend_account_source_strategy(acct)
|
||||
|
||||
@ -1,18 +1,7 @@
|
||||
"""Agent Observability — traces, evals (safety + Saudi tone), cost tracking."""
|
||||
"""Agent observability stubs — trace shape + eval scores (no Langfuse SDK required)."""
|
||||
|
||||
from __future__ import annotations
|
||||
from auto_client_acquisition.agent_observability.safety_eval import evaluate_safety
|
||||
from auto_client_acquisition.agent_observability.saudi_tone_eval import evaluate_saudi_tone
|
||||
from auto_client_acquisition.agent_observability.trace_events import build_trace_event
|
||||
|
||||
from .cost_tracker import CostTracker
|
||||
from .eval_cases import EVAL_CASES, run_eval_pack
|
||||
from .safety_eval import safety_eval
|
||||
from .saudi_tone_eval import saudi_tone_eval
|
||||
from .trace_events import build_trace_event
|
||||
|
||||
__all__ = [
|
||||
"CostTracker",
|
||||
"EVAL_CASES",
|
||||
"build_trace_event",
|
||||
"run_eval_pack",
|
||||
"safety_eval",
|
||||
"saudi_tone_eval",
|
||||
]
|
||||
__all__ = ["build_trace_event", "evaluate_safety", "evaluate_saudi_tone"]
|
||||
|
||||
@ -1,82 +1,5 @@
|
||||
"""Curated eval pack — runs deterministic checks against generated content."""
|
||||
"""Named eval cases for CI — references only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .safety_eval import safety_eval
|
||||
from .saudi_tone_eval import saudi_tone_eval
|
||||
|
||||
# A small curated pack — easy to extend with real failures.
|
||||
EVAL_CASES: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "natural_warm_intro",
|
||||
"input": (
|
||||
"هلا أحمد، لاحظت أن شركتكم فتحت 3 وظائف مبيعات جديدة. "
|
||||
"نشتغل على Dealix كمدير نمو عربي يطلع 10 فرص B2B. "
|
||||
"يناسبك أعرض لك مثال 10 دقائق هذا الأسبوع؟"
|
||||
),
|
||||
"expect_safety": "safe",
|
||||
"expect_tone": "natural",
|
||||
},
|
||||
{
|
||||
"id": "fake_urgency",
|
||||
"input": "آخر فرصة! العرض ينتهي اليوم! اضغط الآن لتحصل على ضمان 100%.",
|
||||
"expect_safety": "blocked",
|
||||
"expect_tone": "off",
|
||||
},
|
||||
{
|
||||
"id": "too_corporate",
|
||||
"input": "تحية طيبة وبعد، ندعوكم لاكتشاف حلولنا المتميزة لتحقيق synergy و best-in-class.",
|
||||
"expect_safety": "safe",
|
||||
"expect_tone": "off",
|
||||
},
|
||||
{
|
||||
"id": "medical_claim",
|
||||
"input": "هذا المنتج يعالج السكر ويشفي الضغط بدون أدوية.",
|
||||
"expect_safety": "blocked",
|
||||
"expect_tone": "off",
|
||||
},
|
||||
{
|
||||
"id": "decent_but_short",
|
||||
"input": "هلا، نقدم Dealix.",
|
||||
"expect_safety": "safe",
|
||||
"expect_tone": "decent",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def run_eval_pack() -> dict[str, Any]:
|
||||
"""
|
||||
Run the curated eval pack and return per-case + aggregate results.
|
||||
|
||||
A case is "passed" if BOTH expected verdicts match.
|
||||
"""
|
||||
results: list[dict[str, Any]] = []
|
||||
passed = 0
|
||||
for case in EVAL_CASES:
|
||||
s = safety_eval(case["input"])
|
||||
t = saudi_tone_eval(case["input"])
|
||||
ok_safety = s["verdict"] == case["expect_safety"]
|
||||
ok_tone = t["verdict"] == case["expect_tone"]
|
||||
case_passed = ok_safety and ok_tone
|
||||
if case_passed:
|
||||
passed += 1
|
||||
results.append({
|
||||
"id": case["id"],
|
||||
"passed": case_passed,
|
||||
"safety": s,
|
||||
"tone": t,
|
||||
"expected_safety": case["expect_safety"],
|
||||
"expected_tone": case["expect_tone"],
|
||||
})
|
||||
|
||||
total = len(EVAL_CASES)
|
||||
pass_rate = round(passed / total, 3) if total else 0.0
|
||||
return {
|
||||
"total": total,
|
||||
"passed": passed,
|
||||
"failed": total - passed,
|
||||
"pass_rate": pass_rate,
|
||||
"results": results,
|
||||
}
|
||||
EVAL_CASES: tuple[str, ...] = ("no_guarantee_language", "no_payment_collect", "has_single_cta")
|
||||
|
||||
@ -1,55 +1,19 @@
|
||||
"""Safety eval — scan a generated message/output for risky claims."""
|
||||
"""Tripwire checks on Arabic marketing copy — deterministic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Each tuple: (category, regex, Arabic reason, severity_points 0..50)
|
||||
SAFETY_RULES: tuple[tuple[str, str, str, int], ...] = (
|
||||
("guarantee", r"ضمان\s*100\s*%|نتائج\s*مضمونة|guaranteed\s+results|100%\s*guarantee",
|
||||
"وعد بنتائج مضمونة — مخالف لأخلاقيات البيع.", 50),
|
||||
("scarcity_fake", r"آخر\s+فرصة|العرض\s+ينتهي\s+اليوم|اضغط\s+الآن|last\s+chance",
|
||||
"تكتيك ندرة مزيف — يقلل الثقة.", 25),
|
||||
("medical_claim", r"يعالج|يشفي|cures|treats\s+(?:cancer|diabetes)",
|
||||
"ادعاء طبي — ممنوع تماماً.", 50),
|
||||
("financial_claim", r"ROI\s*\d{3,}\s*%|\d{4,}\s*%\s*عائد",
|
||||
"ادعاء عوائد مالية مبالغ فيه.", 35),
|
||||
("regulatory", r"رخصة\s+حكومية\s+مضمونة|government[-\s]approved\s+for\s+sure",
|
||||
"ادعاء تنظيمي بدون وثائق.", 35),
|
||||
("personal_data", r"بياناتك\s+مع\s+طرف\s+ثالث|نبيع\s+البيانات",
|
||||
"تلميح ببيع بيانات — انتهاك PDPL.", 50),
|
||||
("urgency_manipulation", r"خصم\s+محدود\s+جداً|expires\s+in\s+\d+\s+minute",
|
||||
"ضغط زمني مصطنع.", 15),
|
||||
)
|
||||
_BAD = ("ضمان كامل", "مضمون 100%", "ارسل لي رقم البطاقة", "كلمة المرور", "حسابك معلق")
|
||||
|
||||
|
||||
def safety_eval(text: str) -> dict[str, object]:
|
||||
"""
|
||||
Evaluate a message for safety violations.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"score": int 0..100 (100 = perfectly safe),
|
||||
"verdict": "safe" | "needs_review" | "blocked",
|
||||
"violations": [{"category", "reason_ar"}],
|
||||
}
|
||||
"""
|
||||
if not text:
|
||||
return {"score": 100, "verdict": "safe", "violations": []}
|
||||
|
||||
penalty = 0
|
||||
violations: list[dict[str, str]] = []
|
||||
for cat, pattern, reason, severity in SAFETY_RULES:
|
||||
if re.search(pattern, text, flags=re.IGNORECASE):
|
||||
penalty += severity
|
||||
violations.append({"category": cat, "reason_ar": reason})
|
||||
|
||||
score = max(0, 100 - penalty)
|
||||
if score >= 70:
|
||||
verdict = "safe"
|
||||
elif score >= 40:
|
||||
verdict = "needs_review"
|
||||
else:
|
||||
verdict = "blocked"
|
||||
|
||||
return {"score": score, "verdict": verdict, "violations": violations}
|
||||
def evaluate_safety(text_ar: str) -> dict[str, Any]:
|
||||
t = text_ar or ""
|
||||
trips: list[str] = []
|
||||
for phrase in _BAD:
|
||||
if phrase in t:
|
||||
trips.append(phrase)
|
||||
if re.search(r"\b\d{16}\b", t):
|
||||
trips.append("possible_pan")
|
||||
return {"passed": len(trips) == 0, "tripwires": trips, "demo": True}
|
||||
|
||||
@ -1,79 +1,19 @@
|
||||
"""Saudi-tone eval — does this message sound natural in a Saudi B2B context?"""
|
||||
"""Lightweight Saudi-Arabic tone score — heuristic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
# Positive markers — natural Saudi conversational tone.
|
||||
POSITIVE_MARKERS_AR: tuple[str, ...] = (
|
||||
"هلا", "أهلاً", "مساء الخير", "صباح الخير",
|
||||
"لاحظت", "شفت", "متابع",
|
||||
"يناسبك", "تحب", "إذا فيه وقت",
|
||||
"تجربة", "Pilot", "بايلوت",
|
||||
)
|
||||
|
||||
# Negative markers — too corporate, too formal, or LLM-generic.
|
||||
NEGATIVE_MARKERS_AR: tuple[str, ...] = (
|
||||
"السيد المحترم", "تحية طيبة وبعد", "ندعوكم لاكتشاف",
|
||||
"ابتداءً من تاريخه", "فوراً وعلى وجه السرعة",
|
||||
"leverage", "synergy", "best-in-class",
|
||||
"نفخر بأن نقدم لكم",
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _arabic_ratio(text: str) -> float:
|
||||
if not text:
|
||||
return 0.0
|
||||
arabic = sum(1 for ch in text if "" <= ch <= "ۿ")
|
||||
total = sum(1 for ch in text if not ch.isspace())
|
||||
if total == 0:
|
||||
return 0.0
|
||||
return arabic / total
|
||||
|
||||
|
||||
def saudi_tone_eval(text: str) -> dict[str, object]:
|
||||
"""
|
||||
Score a message for "natural Saudi tone".
|
||||
|
||||
Returns:
|
||||
{
|
||||
"score": 0..100,
|
||||
"verdict": "natural" | "decent" | "off",
|
||||
"positives": [str], "negatives": [str], "arabic_ratio": float,
|
||||
}
|
||||
"""
|
||||
if not text:
|
||||
return {"score": 0, "verdict": "off", "positives": [], "negatives": [], "arabic_ratio": 0.0}
|
||||
|
||||
positives = [m for m in POSITIVE_MARKERS_AR if m in text]
|
||||
negatives = [m for m in NEGATIVE_MARKERS_AR if m in text]
|
||||
ratio = _arabic_ratio(text)
|
||||
|
||||
score = 30 # base
|
||||
score += min(50, len(positives) * 12)
|
||||
score -= min(60, len(negatives) * 20)
|
||||
if ratio >= 0.6:
|
||||
score += 20
|
||||
elif ratio >= 0.3:
|
||||
def evaluate_saudi_tone(text_ar: str) -> dict[str, Any]:
|
||||
t = (text_ar or "").strip()
|
||||
score = 65
|
||||
if re.search(r"(هل|ممكن|نقدّم|نرحب|شاكرين)", t):
|
||||
score += 10
|
||||
if len(t) > 600:
|
||||
score -= 10
|
||||
if "!!!" in t or "؟؟؟" in t:
|
||||
score -= 8
|
||||
score = max(0, min(100, score))
|
||||
|
||||
# Length penalty for huge messages.
|
||||
word_count = len(re.split(r"\s+", text.strip()))
|
||||
if word_count > 80:
|
||||
score = max(0, score - 10)
|
||||
|
||||
if score >= 75:
|
||||
verdict = "natural"
|
||||
elif score >= 50:
|
||||
verdict = "decent"
|
||||
else:
|
||||
verdict = "off"
|
||||
|
||||
return {
|
||||
"score": score,
|
||||
"verdict": verdict,
|
||||
"positives": positives,
|
||||
"negatives": negatives,
|
||||
"arabic_ratio": round(ratio, 3),
|
||||
}
|
||||
return {"tone_score": score, "demo": True}
|
||||
|
||||
@ -1,56 +1,35 @@
|
||||
"""Build sanitized trace events for Langfuse/Sentry."""
|
||||
"""Structured trace event for dashboards (PII-redacted strings)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.security_curator import sanitize_trace_event
|
||||
|
||||
|
||||
def _hash_id(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()[:16]
|
||||
from auto_client_acquisition.security_curator.trace_redactor import redact_trace_payload
|
||||
|
||||
|
||||
def build_trace_event(
|
||||
*,
|
||||
workflow_name: str,
|
||||
agent_name: str,
|
||||
status: str = "started",
|
||||
user_id: str | None = None,
|
||||
company_id: str | None = None,
|
||||
tool: str | None = None,
|
||||
policy_result: str | None = None,
|
||||
risk_level: str | None = None,
|
||||
approval_status: str | None = None,
|
||||
latency_ms: float = 0.0,
|
||||
cost_estimate: float = 0.0,
|
||||
payload: Any = None,
|
||||
output: Any = None,
|
||||
action_type: str,
|
||||
policy_result: str,
|
||||
tool_called: str | None = None,
|
||||
outcome: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a sanitized trace event ready for Langfuse/Sentry.
|
||||
|
||||
All payload/output fields go through the security_curator sanitizer.
|
||||
User/company IDs are hashed before logging.
|
||||
"""
|
||||
raw = {
|
||||
"ts": time.time(),
|
||||
meta = metadata or {}
|
||||
safe_meta = redact_trace_payload(meta)
|
||||
return {
|
||||
"trace_id": str(uuid.uuid4()),
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"workflow_name": workflow_name,
|
||||
"agent_name": agent_name,
|
||||
"status": status,
|
||||
"user_id_hash": _hash_id(user_id),
|
||||
"company_id_hash": _hash_id(company_id),
|
||||
"tool": tool,
|
||||
"action_type": action_type,
|
||||
"policy_result": policy_result,
|
||||
"risk_level": risk_level,
|
||||
"approval_status": approval_status,
|
||||
"latency_ms": latency_ms,
|
||||
"cost_estimate": cost_estimate,
|
||||
"payload": payload,
|
||||
"output": output,
|
||||
"tool_called": tool_called,
|
||||
"outcome": outcome,
|
||||
"metadata": safe_meta,
|
||||
"demo": True,
|
||||
}
|
||||
return sanitize_trace_event(raw)
|
||||
|
||||
@ -1,139 +1,6 @@
|
||||
"""Autonomous Service Operator — البوت المركزي الذي يدير الخدمات.
|
||||
"""Autonomous Service Operator — intent to service, approval-first, deterministic MVP."""
|
||||
|
||||
Not a chatbot — a **service operator**: understands the customer's goal,
|
||||
recommends a service, collects intake, runs workflow, requests approval,
|
||||
delivers Proof Pack, suggests upgrade.
|
||||
"""
|
||||
from auto_client_acquisition.autonomous_service_operator.conversation_router import handle_message
|
||||
from auto_client_acquisition.autonomous_service_operator.service_bundles import get_bundle, list_bundles
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .agency_mode import (
|
||||
add_agency_client,
|
||||
build_agency_dashboard,
|
||||
build_co_branded_proof_pack,
|
||||
list_agency_revenue_share,
|
||||
)
|
||||
from .approval_manager import (
|
||||
APPROVAL_STATES,
|
||||
build_approval_card,
|
||||
process_approval_decision,
|
||||
)
|
||||
from .client_mode import (
|
||||
build_client_dashboard,
|
||||
build_client_session_summary,
|
||||
)
|
||||
from .self_growth_mode import (
|
||||
build_operator_self_growth_brief,
|
||||
)
|
||||
from .service_delivery_mode import (
|
||||
build_post_delivery_handoff,
|
||||
build_service_delivery_brief,
|
||||
build_sla_status_for_delivery,
|
||||
)
|
||||
from .conversation_router import (
|
||||
INTENT_TO_HANDLER,
|
||||
handle_message,
|
||||
route_message,
|
||||
)
|
||||
from .executive_mode import (
|
||||
build_ceo_command_center,
|
||||
build_executive_daily_brief,
|
||||
build_revenue_risks_summary,
|
||||
)
|
||||
from .intake_collector import (
|
||||
build_intake_questions_for_intent,
|
||||
parse_intake_payload,
|
||||
validate_intake_completeness,
|
||||
)
|
||||
from .intent_classifier import (
|
||||
SUPPORTED_INTENTS,
|
||||
classify_intent,
|
||||
intent_to_service,
|
||||
)
|
||||
from .operator_memory import (
|
||||
OperatorMemory,
|
||||
build_session_context,
|
||||
)
|
||||
from .proof_pack_dispatcher import (
|
||||
dispatch_proof_pack,
|
||||
proof_pack_for_service,
|
||||
)
|
||||
from .service_bundles import (
|
||||
BUNDLES,
|
||||
get_bundle,
|
||||
list_bundles,
|
||||
recommend_bundle,
|
||||
)
|
||||
from .service_orchestrator import (
|
||||
SERVICE_PIPELINE_STEPS,
|
||||
build_service_pipeline,
|
||||
run_service_step,
|
||||
)
|
||||
from .session_state import (
|
||||
SessionState,
|
||||
build_new_session,
|
||||
transition_session,
|
||||
)
|
||||
from .tool_action_planner import (
|
||||
plan_tool_action,
|
||||
review_planned_action,
|
||||
)
|
||||
from .upsell_engine import (
|
||||
build_upsell_card,
|
||||
recommend_upsell_after_service,
|
||||
)
|
||||
from .whatsapp_renderer import (
|
||||
render_approval_card_for_whatsapp,
|
||||
render_card_for_whatsapp,
|
||||
render_daily_brief_for_whatsapp,
|
||||
)
|
||||
from .workflow_runner import (
|
||||
advance_workflow,
|
||||
build_workflow_state,
|
||||
is_workflow_complete,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# conversation_router
|
||||
"INTENT_TO_HANDLER", "handle_message", "route_message",
|
||||
# intent_classifier
|
||||
"SUPPORTED_INTENTS", "classify_intent", "intent_to_service",
|
||||
# service_orchestrator
|
||||
"SERVICE_PIPELINE_STEPS", "build_service_pipeline", "run_service_step",
|
||||
# session_state
|
||||
"SessionState", "build_new_session", "transition_session",
|
||||
# intake_collector
|
||||
"build_intake_questions_for_intent", "parse_intake_payload",
|
||||
"validate_intake_completeness",
|
||||
# approval_manager
|
||||
"APPROVAL_STATES", "build_approval_card", "process_approval_decision",
|
||||
# workflow_runner
|
||||
"advance_workflow", "build_workflow_state", "is_workflow_complete",
|
||||
# tool_action_planner
|
||||
"plan_tool_action", "review_planned_action",
|
||||
# proof_pack_dispatcher
|
||||
"dispatch_proof_pack", "proof_pack_for_service",
|
||||
# upsell_engine
|
||||
"build_upsell_card", "recommend_upsell_after_service",
|
||||
# whatsapp_renderer
|
||||
"render_approval_card_for_whatsapp", "render_card_for_whatsapp",
|
||||
"render_daily_brief_for_whatsapp",
|
||||
# operator_memory
|
||||
"OperatorMemory", "build_session_context",
|
||||
# service_bundles
|
||||
"BUNDLES", "get_bundle", "list_bundles", "recommend_bundle",
|
||||
# executive_mode
|
||||
"build_ceo_command_center", "build_executive_daily_brief",
|
||||
"build_revenue_risks_summary",
|
||||
# client_mode
|
||||
"build_client_dashboard", "build_client_session_summary",
|
||||
# agency_mode
|
||||
"add_agency_client", "build_agency_dashboard",
|
||||
"build_co_branded_proof_pack", "list_agency_revenue_share",
|
||||
# self_growth_mode
|
||||
"build_operator_self_growth_brief",
|
||||
# service_delivery_mode
|
||||
"build_post_delivery_handoff",
|
||||
"build_service_delivery_brief",
|
||||
"build_sla_status_for_delivery",
|
||||
]
|
||||
__all__ = ["handle_message", "list_bundles", "get_bundle"]
|
||||
|
||||
@ -1,133 +1,14 @@
|
||||
"""Agency Mode — manage multiple clients + co-branded Proof Pack + revenue share."""
|
||||
"""Agency partner prioritization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def add_agency_client(
|
||||
*,
|
||||
agency_id: str,
|
||||
client_company_name: str,
|
||||
sector: str = "",
|
||||
monthly_subscription_sar: int = 0,
|
||||
revenue_share_pct: int = 20,
|
||||
clients: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Add a new client to an agency's roster + return the entry."""
|
||||
entry: dict[str, Any] = {
|
||||
"agency_id": agency_id,
|
||||
"client_company_name": client_company_name,
|
||||
"sector": sector,
|
||||
"monthly_subscription_sar": int(monthly_subscription_sar),
|
||||
"revenue_share_pct": int(revenue_share_pct),
|
||||
"status": "onboarding",
|
||||
"co_branded_proof_pack": True,
|
||||
"approval_required": True,
|
||||
}
|
||||
if clients is not None:
|
||||
clients.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def build_agency_dashboard(
|
||||
*,
|
||||
agency_id: str,
|
||||
agency_name: str = "",
|
||||
clients: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the agency's dashboard summary."""
|
||||
clients = clients or []
|
||||
total_clients = len(clients)
|
||||
active = sum(1 for c in clients if c.get("status") in ("active", "onboarding"))
|
||||
monthly_revenue_total = sum(
|
||||
float(c.get("monthly_subscription_sar", 0) or 0) for c in clients
|
||||
)
|
||||
avg_share_pct = (
|
||||
round(
|
||||
sum(int(c.get("revenue_share_pct", 0) or 0) for c in clients)
|
||||
/ max(1, total_clients),
|
||||
1,
|
||||
)
|
||||
if total_clients else 0.0
|
||||
)
|
||||
|
||||
def mode_profile() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": "agency",
|
||||
"agency_id": agency_id,
|
||||
"agency_name": agency_name,
|
||||
"metrics": {
|
||||
"total_clients": total_clients,
|
||||
"active_clients": active,
|
||||
"monthly_revenue_sar": round(monthly_revenue_total, 2),
|
||||
"avg_revenue_share_pct": avg_share_pct,
|
||||
},
|
||||
"summary_ar": [
|
||||
f"عملاء الوكالة: {total_clients} (نشط: {active}).",
|
||||
f"الإيراد الشهري الكلي: {monthly_revenue_total:.0f} ريال.",
|
||||
f"متوسط revenue share: {avg_share_pct}%.",
|
||||
],
|
||||
"panels_ar": [
|
||||
"Add Client — إضافة عميل جديد",
|
||||
"Run Diagnostic — تشخيص لعميل",
|
||||
"Co-Branded Proof Pack — Proof بعلامة الوكالة",
|
||||
"Referral Tracking — متابعة الإحالات",
|
||||
"Partner Scorecard — تقييم الأداء",
|
||||
],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def list_agency_revenue_share(
|
||||
*, clients: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute revenue share owed to an agency for the current month."""
|
||||
clients = clients or []
|
||||
line_items: list[dict[str, Any]] = []
|
||||
total_share_sar = 0.0
|
||||
for c in clients:
|
||||
sub = float(c.get("monthly_subscription_sar", 0) or 0)
|
||||
pct = int(c.get("revenue_share_pct", 0) or 0)
|
||||
share = round(sub * pct / 100.0, 2)
|
||||
total_share_sar += share
|
||||
line_items.append({
|
||||
"client_company_name": c.get("client_company_name"),
|
||||
"monthly_subscription_sar": sub,
|
||||
"revenue_share_pct": pct,
|
||||
"agency_share_sar": share,
|
||||
})
|
||||
return {
|
||||
"line_items": line_items,
|
||||
"total_share_sar": round(total_share_sar, 2),
|
||||
"currency": "SAR",
|
||||
}
|
||||
|
||||
|
||||
def build_co_branded_proof_pack(
|
||||
*,
|
||||
agency_name: str,
|
||||
client_company_name: str,
|
||||
metrics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a co-branded Proof Pack envelope for an agency client."""
|
||||
metrics = metrics or {}
|
||||
return {
|
||||
"title_ar": (
|
||||
f"Proof Pack — {client_company_name} (تنفيذ: {agency_name})"
|
||||
),
|
||||
"co_branded": True,
|
||||
"agency_name": agency_name,
|
||||
"client_company_name": client_company_name,
|
||||
"sections_ar": [
|
||||
"ملخص تنفيذي للعميل",
|
||||
"ما عملته الوكالة + Dealix",
|
||||
"النتائج بالأرقام",
|
||||
"Action Ledger",
|
||||
"المخاطر التي منعتها الوكالة",
|
||||
"التوصية بالخطوة التالية",
|
||||
],
|
||||
"metrics": dict(metrics),
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"mode": "agency_partner",
|
||||
"priority_intents": ["want_partnerships", "ask_services", "ask_proof"],
|
||||
"card_types_first": ["opportunity", "proof_update", "compliance_risk"],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,87 +1,41 @@
|
||||
"""Approval manager — Arabic approval cards (≤3 buttons) + decision processing."""
|
||||
"""Human-in-the-loop gates for operator workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
APPROVAL_STATES: tuple[str, ...] = (
|
||||
"pending",
|
||||
"approved",
|
||||
"edited",
|
||||
"rejected",
|
||||
"expired",
|
||||
)
|
||||
from auto_client_acquisition.autonomous_service_operator import session_state as ss
|
||||
|
||||
|
||||
def build_approval_card(
|
||||
*,
|
||||
action_type: str,
|
||||
title_ar: str,
|
||||
summary_ar: str,
|
||||
risk_level: str = "low",
|
||||
why_now_ar: str = "",
|
||||
recommended_action_ar: str = "",
|
||||
expected_impact_sar: float = 0.0,
|
||||
service_id: str | None = None,
|
||||
customer_id: str | None = None,
|
||||
action_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a structured Arabic approval card."""
|
||||
return {
|
||||
"type": "approval",
|
||||
"action_id": action_id,
|
||||
"action_type": action_type,
|
||||
"service_id": service_id,
|
||||
"customer_id": customer_id,
|
||||
"title_ar": title_ar[:140],
|
||||
"summary_ar": summary_ar[:280],
|
||||
"why_now_ar": why_now_ar[:200],
|
||||
"recommended_action_ar": recommended_action_ar[:200],
|
||||
"risk_level": risk_level if risk_level in (
|
||||
"low", "medium", "high",
|
||||
) else "medium",
|
||||
"expected_impact_sar": float(expected_impact_sar),
|
||||
"buttons_ar": ["اعتمد", "عدّل", "تخطي"],
|
||||
"state": "pending",
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
def set_pending_approval(session_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return ss.upsert_session(
|
||||
session_id,
|
||||
{"workflow_state": "pending_approval", "pending_card": payload},
|
||||
)
|
||||
|
||||
|
||||
def process_approval_decision(
|
||||
card: dict[str, Any],
|
||||
*,
|
||||
decision: str,
|
||||
decided_by: str = "user",
|
||||
note: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process an approval decision (`approve` / `edit` / `skip` / `reject`).
|
||||
def apply_decision(session_id: str, decision: str) -> dict[str, Any]:
|
||||
d = (decision or "").strip().lower()
|
||||
if d in ("approve", "اعتمد"):
|
||||
return ss.upsert_session(
|
||||
session_id,
|
||||
{"workflow_state": "approved", "pending_card": None, "last_decision": "approve"},
|
||||
)
|
||||
if d in ("edit", "تعديل"):
|
||||
return ss.upsert_session(
|
||||
session_id,
|
||||
{"workflow_state": "edit_requested", "last_decision": "edit"},
|
||||
)
|
||||
if d in ("skip", "تخطي"):
|
||||
return ss.upsert_session(
|
||||
session_id,
|
||||
{"workflow_state": "skipped", "pending_card": None, "last_decision": "skip"},
|
||||
)
|
||||
return ss.upsert_session(session_id, {"workflow_state": "unknown_decision", "last_decision": d})
|
||||
|
||||
Returns the updated card with new state + audit info.
|
||||
"""
|
||||
decision_lc = (decision or "").strip().lower()
|
||||
if decision_lc in ("approve", "approved", "موافق", "اعتمد", "نعم"):
|
||||
new_state = "approved"
|
||||
next_action = "execute_with_audit"
|
||||
elif decision_lc in ("edit", "عدّل", "تعديل"):
|
||||
new_state = "edited"
|
||||
next_action = "rewrite_then_resend_for_approval"
|
||||
elif decision_lc in ("skip", "تخطي", "تجاوز"):
|
||||
new_state = "rejected"
|
||||
next_action = "archive"
|
||||
elif decision_lc in ("reject", "ارفض", "لا"):
|
||||
new_state = "rejected"
|
||||
next_action = "archive_with_reason"
|
||||
else:
|
||||
return {
|
||||
"error": f"unknown decision: {decision}",
|
||||
"valid_decisions": ["approve", "edit", "skip", "reject"],
|
||||
}
|
||||
|
||||
out = dict(card)
|
||||
out["state"] = new_state
|
||||
out["decided_by"] = decided_by
|
||||
out["decision_note"] = note[:200]
|
||||
out["next_action"] = next_action
|
||||
return out
|
||||
def pending_card(session_id: str) -> dict[str, Any] | None:
|
||||
s = ss.get_session(session_id)
|
||||
if not s:
|
||||
return None
|
||||
return s.get("pending_card") if isinstance(s.get("pending_card"), dict) else None
|
||||
|
||||
@ -1,55 +1,14 @@
|
||||
"""Client Mode — dashboard for the customer (Growth Manager) view."""
|
||||
"""End-customer (growth manager) prioritization."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_client_dashboard(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
company_name: str = "",
|
||||
active_services: list[str] | None = None,
|
||||
open_actions: int = 0,
|
||||
proof_pack_due: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the client-facing dashboard."""
|
||||
active_services = active_services or []
|
||||
def mode_profile() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": "client",
|
||||
"customer_id": customer_id,
|
||||
"company_name": company_name,
|
||||
"active_services": list(active_services),
|
||||
"open_actions": open_actions,
|
||||
"proof_pack_due": proof_pack_due,
|
||||
"today_panels_ar": [
|
||||
"Command Feed — قرارات اليوم",
|
||||
"Approvals Center — رسائل تنتظر اعتمادك",
|
||||
"Pipeline Tracker — مرحلة كل عميل",
|
||||
"Proof Pack — آخر تقرير + الـ ROI",
|
||||
],
|
||||
"buttons_ar": ["اعرض القرارات", "اعتمد جماعي", "افتح Proof Pack"],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def build_client_session_summary(
|
||||
*,
|
||||
session_id: str,
|
||||
customer_id: str = "",
|
||||
last_intent: str = "",
|
||||
last_recommended_service: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a session summary for the client view."""
|
||||
return {
|
||||
"mode": "client",
|
||||
"session_id": session_id,
|
||||
"customer_id": customer_id,
|
||||
"last_intent": last_intent,
|
||||
"last_recommended_service": last_recommended_service,
|
||||
"next_step_ar": (
|
||||
"أكمل الـ intake للحصول على workflow الخدمة + أول Proof Pack."
|
||||
),
|
||||
"approval_required": True,
|
||||
"priority_intents": ["want_more_customers", "has_contact_list", "want_meetings"],
|
||||
"card_types_first": ["approval_needed", "opportunity", "proof_update"],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,114 +1,61 @@
|
||||
"""Conversation router — single entry point for any operator message."""
|
||||
"""Single entry: user message → intent → recommendation + session updates."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .approval_manager import (
|
||||
build_approval_card,
|
||||
process_approval_decision,
|
||||
from auto_client_acquisition.autonomous_service_operator import (
|
||||
approval_manager as am,
|
||||
intent_classifier as ic,
|
||||
operator_memory as om,
|
||||
service_bundles as sb,
|
||||
service_orchestrator as so,
|
||||
session_state as ss,
|
||||
workflow_runner as wr,
|
||||
)
|
||||
from .intake_collector import build_intake_questions_for_intent
|
||||
from .intent_classifier import classify_intent, intent_to_service
|
||||
from .service_bundles import recommend_bundle
|
||||
from .service_orchestrator import build_service_pipeline
|
||||
|
||||
|
||||
# Map: intent → handler name
|
||||
INTENT_TO_HANDLER: dict[str, str] = {
|
||||
"want_more_customers": "start_first_10_opportunities",
|
||||
"has_contact_list": "start_list_intelligence",
|
||||
"want_partnerships": "start_partner_sprint",
|
||||
"want_daily_growth": "start_growth_os",
|
||||
"want_meetings": "start_meeting_sprint",
|
||||
"want_email_rescue": "start_email_rescue",
|
||||
"want_whatsapp_setup": "start_whatsapp_compliance",
|
||||
"ask_pricing": "show_pricing",
|
||||
"approve_action": "process_approval",
|
||||
"edit_action": "process_edit",
|
||||
"skip_action": "process_skip",
|
||||
"ask_demo": "send_demo",
|
||||
"ask_proof": "send_proof_pack",
|
||||
"ask_services": "show_bundles",
|
||||
"ask_partnership": "show_agency_partner",
|
||||
"ask_revenue_today": "show_revenue_today_plan",
|
||||
}
|
||||
def handle_message(session_id: str, message: str, mode: str = "client") -> dict[str, Any]:
|
||||
intent = ic.classify_intent(message)
|
||||
om.append_turn(session_id, "user", message, {"intent": intent})
|
||||
|
||||
if intent == ic.INTENT_COLD_WHATSAPP_REQUEST:
|
||||
body = so.cold_whatsapp_response()
|
||||
om.append_turn(session_id, "assistant", body["message_ar"], {"blocked": True})
|
||||
ss.upsert_session(session_id, {"last_intent": intent, "blocked": True})
|
||||
return {"session_id": session_id, "intent": intent, **body}
|
||||
|
||||
def route_message(message: str) -> dict[str, Any]:
|
||||
"""Classify a message + return the routed handler + recommended service."""
|
||||
classification = classify_intent(message)
|
||||
intent = classification["intent"]
|
||||
handler = INTENT_TO_HANDLER.get(intent, "show_bundles")
|
||||
service_id = intent_to_service(intent)
|
||||
|
||||
return {
|
||||
"message": (message or "")[:300],
|
||||
"classification": classification,
|
||||
"intent": intent,
|
||||
"handler": handler,
|
||||
"recommended_service_id": service_id,
|
||||
}
|
||||
|
||||
|
||||
def handle_message(
|
||||
message: str,
|
||||
*,
|
||||
customer_id: str | None = None,
|
||||
has_contact_list: bool = False,
|
||||
is_agency: bool = False,
|
||||
is_local_business: bool = False,
|
||||
budget_sar: int = 1000,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Full single-shot handler — classifies + plans + returns operator response.
|
||||
|
||||
Never executes any external action. Just plans + drafts.
|
||||
"""
|
||||
routed = route_message(message)
|
||||
intent = routed["intent"]
|
||||
handler = routed["handler"]
|
||||
|
||||
# Recommend a bundle (high-level package).
|
||||
bundle_rec = recommend_bundle(
|
||||
intent=intent,
|
||||
has_contact_list=has_contact_list,
|
||||
is_agency=is_agency,
|
||||
is_local_business=is_local_business,
|
||||
budget_sar=budget_sar,
|
||||
rec = so.recommend_for_intent(intent)
|
||||
ss.upsert_session(
|
||||
session_id,
|
||||
{
|
||||
"last_intent": intent,
|
||||
"recommended_service_id": rec["recommended_service_id"],
|
||||
"mode": mode,
|
||||
},
|
||||
)
|
||||
wr.advance(session_id, "start_service")
|
||||
|
||||
# If a service is recommended, build its initial pipeline + intake form.
|
||||
response: dict[str, Any] = {
|
||||
reply_ar = _build_reply_ar(intent, rec)
|
||||
om.append_turn(session_id, "assistant", reply_ar, {"recommendation": rec})
|
||||
return {
|
||||
"session_id": session_id,
|
||||
"intent": intent,
|
||||
"handler": handler,
|
||||
"bundle_recommendation": bundle_rec,
|
||||
"service_id": routed["recommended_service_id"],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"recommendation": rec,
|
||||
"reply_ar": reply_ar,
|
||||
"bundles_hint": sb.list_bundles() if intent == ic.INTENT_ASK_SERVICES else None,
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
if intent in ("approve_action", "edit_action", "skip_action"):
|
||||
# Approvals are handled separately — surface a placeholder card.
|
||||
decision = (
|
||||
"approve" if intent == "approve_action"
|
||||
else "edit" if intent == "edit_action"
|
||||
else "skip"
|
||||
)
|
||||
sample_card = build_approval_card(
|
||||
action_type="example_action",
|
||||
title_ar="فعل مثال",
|
||||
summary_ar="هذا مثال على approval card",
|
||||
)
|
||||
response["decision_processed"] = process_approval_decision(
|
||||
sample_card, decision=decision, decided_by=customer_id or "user",
|
||||
)
|
||||
return response
|
||||
|
||||
if routed["recommended_service_id"]:
|
||||
response["intake_questions"] = build_intake_questions_for_intent(intent)
|
||||
response["initial_pipeline"] = build_service_pipeline(
|
||||
routed["recommended_service_id"], customer_id=customer_id or "",
|
||||
def _build_reply_ar(intent: str, rec: dict[str, Any]) -> str:
|
||||
sid = rec.get("recommended_service_id")
|
||||
name = rec.get("service_name_ar") or sid
|
||||
if intent == ic.INTENT_ASK_SERVICES:
|
||||
return (
|
||||
"أنسب مسار: ابدأ بتشخيص مجاني ثم اختر باقة Growth Starter أو Data to Revenue. "
|
||||
"راجع قائمة الباقات من /api/v1/operator/bundles."
|
||||
)
|
||||
|
||||
return response
|
||||
if intent == ic.INTENT_ASK_PROOF:
|
||||
return f"Proof Pack مرتبط بخدمة {name} — جاهز كعرض تجريبي بعد أول مسودات موافَق عليها."
|
||||
return f"نوصي بخدمة: {name} ({sid}). الخطوة التالية: أكمل المدخلات ثم راجع المسودات قبل أي إرسال."
|
||||
|
||||
@ -1,92 +1,14 @@
|
||||
"""Executive Mode — CEO command center + daily brief + revenue risks."""
|
||||
"""CEO-style prioritization hints for cards and intents."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_executive_daily_brief(
|
||||
*,
|
||||
company_name: str = "",
|
||||
sector: str = "saas",
|
||||
) -> dict[str, Any]:
|
||||
"""Build the CEO's daily brief (Arabic)."""
|
||||
def mode_profile() -> dict[str, Any]:
|
||||
return {
|
||||
"title_ar": f"موجز اليوم التنفيذي — {company_name or '(الشركة)'}",
|
||||
"summary_ar": [
|
||||
f"3 قرارات تنتظر اعتمادك في قطاع {sector}.",
|
||||
"5 رسائل drafts معدّة بـ Saudi tone.",
|
||||
"2 leads متأخرة في المتابعة (>72 ساعة).",
|
||||
"1 شريك وكالة جاهز لاجتماع.",
|
||||
"1 خطر سمعة على قناة (يحتاج مراجعة).",
|
||||
],
|
||||
"priority_decisions_ar": [
|
||||
"اعتمد 5 رسائل إيميل (10 دقائق).",
|
||||
"راجع 12 رقم بدون مصدر واضح قبل أي واتساب.",
|
||||
"احجز ديمو شريك الوكالة.",
|
||||
],
|
||||
"metric_to_watch_ar": (
|
||||
"نسبة approval_rate الأسبوعية — هي المؤشر الأقوى لجودة "
|
||||
"الـ targeting + الـ Saudi Tone."
|
||||
),
|
||||
"buttons_ar": ["اعرض القرارات", "Proof Pack", "لاحقاً"],
|
||||
"approval_required": True,
|
||||
}
|
||||
|
||||
|
||||
def build_revenue_risks_summary(
|
||||
*,
|
||||
open_risks: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a 3-risk summary (Arabic)."""
|
||||
open_risks = open_risks or [
|
||||
{
|
||||
"id": "wa_quality",
|
||||
"title_ar": "جودة واتساب",
|
||||
"summary_ar": "نسبة الحظر على رقم واتساب الرئيسي تقترب من حد التحذير.",
|
||||
"severity": "high",
|
||||
"action_ar": "خفّض الحجم 50% + راجع الرسائل.",
|
||||
},
|
||||
{
|
||||
"id": "list_freshness",
|
||||
"title_ar": "قائمة قديمة",
|
||||
"summary_ar": "60% من القائمة لم يتم تحديثها منذ 9 أشهر.",
|
||||
"severity": "medium",
|
||||
"action_ar": "شغّل List Intelligence لتنظيفها.",
|
||||
},
|
||||
{
|
||||
"id": "single_threading",
|
||||
"title_ar": "صفقة بشخص واحد",
|
||||
"summary_ar": "صفقة كبيرة (250K) معتمدة على شخص واحد بدون buying committee.",
|
||||
"severity": "high",
|
||||
"action_ar": "ادعُ صانع قرار ثانٍ من نفس الشركة.",
|
||||
},
|
||||
]
|
||||
return {
|
||||
"title_ar": "أعلى 3 مخاطر إيراد اليوم",
|
||||
"risks": open_risks[:3],
|
||||
"approval_required": True,
|
||||
}
|
||||
|
||||
|
||||
def build_ceo_command_center(
|
||||
*,
|
||||
company_name: str = "",
|
||||
sector: str = "saas",
|
||||
) -> dict[str, Any]:
|
||||
"""Build the full CEO command-center page."""
|
||||
return {
|
||||
"mode": "ceo",
|
||||
"company_name": company_name,
|
||||
"daily_brief": build_executive_daily_brief(
|
||||
company_name=company_name, sector=sector,
|
||||
),
|
||||
"revenue_risks": build_revenue_risks_summary(),
|
||||
"next_three_moves_ar": [
|
||||
"اعتمد رسائل اليوم (5).",
|
||||
"ابدأ Pilot 7 أيام لقطاع جديد (testing).",
|
||||
"حدد منسّق Approvals بديل خلال 24 ساعة.",
|
||||
],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"mode": "executive",
|
||||
"priority_intents": ["want_more_customers", "ask_proof", "approve_action"],
|
||||
"card_types_first": ["leak", "approval_needed", "opportunity"],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,129 +1,17 @@
|
||||
"""Intake collector — builds intake questions per intent + validates payloads."""
|
||||
"""Required intake fields per service_id — from Service Tower catalog."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Intake questions per intent (Arabic).
|
||||
_INTAKE_QUESTIONS_BY_INTENT: dict[str, list[dict[str, Any]]] = {
|
||||
"want_more_customers": [
|
||||
{"key": "company_name", "label_ar": "اسم الشركة", "required": True},
|
||||
{"key": "sector", "label_ar": "القطاع", "required": True},
|
||||
{"key": "city", "label_ar": "المدينة", "required": True},
|
||||
{"key": "offer", "label_ar": "العرض الرئيسي", "required": True},
|
||||
{"key": "ideal_customer", "label_ar": "العميل المثالي",
|
||||
"required": True},
|
||||
],
|
||||
"has_contact_list": [
|
||||
{"key": "company_name", "label_ar": "اسم الشركة", "required": True},
|
||||
{"key": "sector", "label_ar": "القطاع", "required": True},
|
||||
{"key": "list_size", "label_ar": "حجم القائمة (تقريباً)",
|
||||
"required": True},
|
||||
{"key": "list_source", "label_ar": "مصدر القائمة (CRM/event/upload)",
|
||||
"required": True},
|
||||
{"key": "channels_available", "label_ar": "القنوات المتاحة",
|
||||
"required": True},
|
||||
],
|
||||
"want_partnerships": [
|
||||
{"key": "company_name", "label_ar": "اسم الشركة", "required": True},
|
||||
{"key": "sector", "label_ar": "القطاع", "required": True},
|
||||
{"key": "partner_goal",
|
||||
"label_ar": "هدف الشراكة (وكالات/موزعين/co-marketing)",
|
||||
"required": True},
|
||||
{"key": "current_partners", "label_ar": "شركاء حاليين (إن وجد)",
|
||||
"required": False},
|
||||
],
|
||||
"want_daily_growth": [
|
||||
{"key": "company_name", "label_ar": "اسم الشركة", "required": True},
|
||||
{"key": "sector", "label_ar": "القطاع", "required": True},
|
||||
{"key": "team_size", "label_ar": "حجم فريق المبيعات/النمو",
|
||||
"required": True},
|
||||
{"key": "channels", "label_ar": "القنوات الحالية", "required": True},
|
||||
{"key": "approval_owner", "label_ar": "من يوافق على الرسائل؟",
|
||||
"required": True},
|
||||
],
|
||||
"want_meetings": [
|
||||
{"key": "company_name", "label_ar": "اسم الشركة", "required": True},
|
||||
{"key": "prospect_count", "label_ar": "عدد الـ prospects",
|
||||
"required": True},
|
||||
{"key": "calendar_link", "label_ar": "رابط Calendar (لو وُجد)",
|
||||
"required": False},
|
||||
],
|
||||
"want_email_rescue": [
|
||||
{"key": "company_name", "label_ar": "اسم الشركة", "required": True},
|
||||
{"key": "gmail_label",
|
||||
"label_ar": "اسم الـ label/الـ folder المستهدف",
|
||||
"required": True},
|
||||
{"key": "ICP", "label_ar": "العميل المثالي", "required": True},
|
||||
],
|
||||
"want_whatsapp_setup": [
|
||||
{"key": "company_name", "label_ar": "اسم الشركة", "required": True},
|
||||
{"key": "list_size",
|
||||
"label_ar": "حجم قاعدة الواتساب الحالية", "required": True},
|
||||
{"key": "current_practice",
|
||||
"label_ar": "الطريقة الحالية في إرسال الرسائل", "required": True},
|
||||
],
|
||||
"ask_revenue_today": [
|
||||
{"key": "company_name", "label_ar": "اسم الشركة", "required": True},
|
||||
{"key": "sector", "label_ar": "القطاع", "required": True},
|
||||
{"key": "city", "label_ar": "المدينة", "required": True},
|
||||
{"key": "offer", "label_ar": "العرض الرئيسي", "required": True},
|
||||
],
|
||||
# Default minimal intake for any "ask_*" intent.
|
||||
"ask_services": [
|
||||
{"key": "goal", "label_ar": "ما هدفك الأساسي؟", "required": True},
|
||||
],
|
||||
}
|
||||
from auto_client_acquisition.service_tower.service_catalog import get_service_by_id
|
||||
|
||||
|
||||
def build_intake_questions_for_intent(intent: str) -> dict[str, Any]:
|
||||
"""Return intake questions for an intent. Falls back to ask_services."""
|
||||
questions = _INTAKE_QUESTIONS_BY_INTENT.get(intent)
|
||||
if questions is None:
|
||||
questions = _INTAKE_QUESTIONS_BY_INTENT["ask_services"]
|
||||
def intake_questions(service_id: str) -> dict[str, Any]:
|
||||
svc = get_service_by_id(service_id) or {}
|
||||
fields = svc.get("inputs_required") or []
|
||||
return {
|
||||
"intent": intent,
|
||||
"questions": [dict(q) for q in questions],
|
||||
"estimated_minutes": max(2, len(questions) * 1),
|
||||
"approval_required": True,
|
||||
}
|
||||
|
||||
|
||||
def parse_intake_payload(
|
||||
intent: str, raw_payload: dict[str, Any] | None,
|
||||
) -> dict[str, Any]:
|
||||
"""Parse + sanitize an intake payload against the intent's question set."""
|
||||
raw_payload = raw_payload or {}
|
||||
questions = _INTAKE_QUESTIONS_BY_INTENT.get(
|
||||
intent, _INTAKE_QUESTIONS_BY_INTENT["ask_services"],
|
||||
)
|
||||
parsed: dict[str, Any] = {}
|
||||
for q in questions:
|
||||
key = q["key"]
|
||||
val = raw_payload.get(key)
|
||||
if val is None:
|
||||
continue
|
||||
# Strings get truncated to 500 chars.
|
||||
if isinstance(val, str):
|
||||
val = val.strip()[:500]
|
||||
parsed[key] = val
|
||||
return parsed
|
||||
|
||||
|
||||
def validate_intake_completeness(
|
||||
intent: str, payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Check that all required intake fields are present."""
|
||||
questions = _INTAKE_QUESTIONS_BY_INTENT.get(
|
||||
intent, _INTAKE_QUESTIONS_BY_INTENT["ask_services"],
|
||||
)
|
||||
missing: list[str] = []
|
||||
for q in questions:
|
||||
if q.get("required") and not payload.get(q["key"]):
|
||||
missing.append(str(q["key"]))
|
||||
return {
|
||||
"intent": intent,
|
||||
"complete": not missing,
|
||||
"missing_fields": missing,
|
||||
"missing_count": len(missing),
|
||||
"service_id": service_id,
|
||||
"fields": [{"name": f, "prompt_ar": f"يرجى تزويدنا بـ: {f}"} for f in fields],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,180 +1,115 @@
|
||||
"""Deterministic intent classifier — Arabic + English keywords → 16 intents."""
|
||||
"""Rule-based intent classification (AR/EN) — no LLM required for MVP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Final
|
||||
|
||||
# 16 supported intents that drive the operator.
|
||||
SUPPORTED_INTENTS: tuple[str, ...] = (
|
||||
"want_more_customers",
|
||||
"has_contact_list",
|
||||
"want_partnerships",
|
||||
"want_daily_growth",
|
||||
"want_meetings",
|
||||
"want_email_rescue",
|
||||
"want_whatsapp_setup",
|
||||
"ask_pricing",
|
||||
"approve_action",
|
||||
"edit_action",
|
||||
"skip_action",
|
||||
"ask_demo",
|
||||
"ask_proof",
|
||||
"ask_services",
|
||||
"ask_partnership",
|
||||
"ask_revenue_today",
|
||||
)
|
||||
|
||||
# Each intent → (Arabic keywords, English keywords).
|
||||
_KEYWORDS: dict[str, tuple[list[str], list[str]]] = {
|
||||
"want_more_customers": (
|
||||
["عملاء", "فرص", "leads", "ليدز", "عميل جديد", "مبيعات",
|
||||
"أبغى عملاء", "زيادة عملاء"],
|
||||
["customers", "leads", "more sales", "new clients", "pipeline"],
|
||||
),
|
||||
"has_contact_list": (
|
||||
["قائمة", "أرقام", "إيميلات", "CSV", "قائمتي", "عملاء قدامى",
|
||||
"اللستة", "ملف"],
|
||||
["list", "csv", "old customers", "spreadsheet", "contacts"],
|
||||
),
|
||||
"want_partnerships": (
|
||||
["شراكات", "شريك", "وكالة", "تعاون", "موزع", "شركاء"],
|
||||
["partnership", "partner", "agency deal", "referral"],
|
||||
),
|
||||
"want_daily_growth": (
|
||||
["تشغيل يومي", "نمو شهري", "Growth OS", "اشتراك", "يومياً",
|
||||
"مدير نمو"],
|
||||
["daily growth", "growth os", "subscription", "monthly"],
|
||||
),
|
||||
"want_meetings": (
|
||||
["اجتماعات", "ديمو", "meeting", "موعد", "احجز", "مكالمة",
|
||||
"demo"],
|
||||
["meeting", "demo", "book", "schedule call"],
|
||||
),
|
||||
"want_email_rescue": (
|
||||
["إيميل", "Gmail", "Outlook", "إنباكس", "بريد", "ضائعة"],
|
||||
["email rescue", "inbox", "gmail", "missed emails"],
|
||||
),
|
||||
"want_whatsapp_setup": (
|
||||
["واتساب", "WhatsApp", "opt-in", "حملة واتساب", "أرقامي"],
|
||||
["whatsapp", "compliance", "opt-in"],
|
||||
),
|
||||
"ask_pricing": (
|
||||
["السعر", "كم", "بكم", "تكلفة", "اشتراك"],
|
||||
["price", "cost", "how much", "pricing"],
|
||||
),
|
||||
"approve_action": (
|
||||
["اعتمد", "موافق", "وافق", "تمام", "نعم"],
|
||||
["approve", "ok", "yes", "go ahead", "confirm"],
|
||||
),
|
||||
"edit_action": (
|
||||
["عدّل", "تعديل", "غير", "بدّل"],
|
||||
["edit", "change", "modify", "tweak"],
|
||||
),
|
||||
"skip_action": (
|
||||
["تخطي", "تخطى", "تجاوز", "خطّي", "لا"],
|
||||
["skip", "no", "pass", "later"],
|
||||
),
|
||||
"ask_demo": (
|
||||
["ديمو", "عرض", "أشوف", "جرب", "تجربة"],
|
||||
["demo", "try", "show me", "trial"],
|
||||
),
|
||||
"ask_proof": (
|
||||
["proof", "نتائج", "case study", "إثبات", "تقرير"],
|
||||
["proof", "results", "case study", "report"],
|
||||
),
|
||||
"ask_services": (
|
||||
["الخدمات", "وش عندكم", "ماذا تقدمون", "العروض", "bundles"],
|
||||
["services", "what do you offer", "bundles", "packages"],
|
||||
),
|
||||
"ask_partnership": (
|
||||
["وكالة شريكة", "Agency Partner", "revenue share", "شراكة وكالة"],
|
||||
["agency partner", "revenue share", "white label"],
|
||||
),
|
||||
"ask_revenue_today": (
|
||||
["دخل اليوم", "أبيع اليوم", "اول pilot", "ابدأ اليوم"],
|
||||
["revenue today", "sell today", "first pilot", "private beta"],
|
||||
),
|
||||
}
|
||||
|
||||
# Map intent → recommended service ID (in service_tower.service_catalog).
|
||||
INTENT_TO_SERVICE: dict[str, str] = {
|
||||
"want_more_customers": "first_10_opportunities_sprint",
|
||||
"has_contact_list": "list_intelligence",
|
||||
"want_partnerships": "partner_sprint",
|
||||
"want_daily_growth": "growth_os_monthly",
|
||||
"want_meetings": "meeting_booking_sprint",
|
||||
"want_email_rescue": "email_revenue_rescue",
|
||||
"want_whatsapp_setup": "whatsapp_compliance_setup",
|
||||
"ask_pricing": "free_growth_diagnostic",
|
||||
"ask_demo": "free_growth_diagnostic",
|
||||
"ask_proof": "free_growth_diagnostic",
|
||||
"ask_services": "free_growth_diagnostic",
|
||||
"ask_partnership": "agency_partner_program",
|
||||
"ask_revenue_today": "first_10_opportunities_sprint",
|
||||
}
|
||||
# Intent ids consumed by service_orchestrator and conversation_router.
|
||||
INTENT_WANT_MORE_CUSTOMERS: Final = "want_more_customers"
|
||||
INTENT_HAS_CONTACT_LIST: Final = "has_contact_list"
|
||||
INTENT_WANT_PARTNERSHIPS: Final = "want_partnerships"
|
||||
INTENT_WANT_DAILY_GROWTH: Final = "want_daily_growth"
|
||||
INTENT_WANT_MEETINGS: Final = "want_meetings"
|
||||
INTENT_WANT_EMAIL_RESCUE: Final = "want_email_rescue"
|
||||
INTENT_WANT_WHATSAPP_SETUP: Final = "want_whatsapp_setup"
|
||||
INTENT_ASK_PRICING: Final = "ask_pricing"
|
||||
INTENT_APPROVE_ACTION: Final = "approve_action"
|
||||
INTENT_EDIT_ACTION: Final = "edit_action"
|
||||
INTENT_SKIP_ACTION: Final = "skip_action"
|
||||
INTENT_ASK_DEMO: Final = "ask_demo"
|
||||
INTENT_ASK_PROOF: Final = "ask_proof"
|
||||
INTENT_ASK_SERVICES: Final = "ask_services"
|
||||
INTENT_ASK_PARTNERSHIP: Final = "ask_partnership"
|
||||
INTENT_ASK_REVENUE_TODAY: Final = "ask_revenue_today"
|
||||
INTENT_COLD_WHATSAPP_REQUEST: Final = "cold_whatsapp_request" # blocked path
|
||||
INTENT_UNKNOWN: Final = "unknown"
|
||||
|
||||
|
||||
def classify_intent(message: str) -> dict[str, Any]:
|
||||
"""
|
||||
Classify a free-text message → intent + confidence.
|
||||
def normalize_user_text(text: str) -> str:
|
||||
t = (text or "").strip().lower()
|
||||
t = re.sub(r"\s+", " ", t)
|
||||
return t
|
||||
|
||||
Deterministic, keyword-based. No LLM. Returns:
|
||||
{
|
||||
"intent": str,
|
||||
"confidence": float (0..1),
|
||||
"matched_keywords": list[str],
|
||||
"all_scores": dict[intent, score],
|
||||
}
|
||||
"""
|
||||
text = (message or "").strip()
|
||||
if not text:
|
||||
return {
|
||||
"intent": "ask_services",
|
||||
"confidence": 0.1,
|
||||
"matched_keywords": [],
|
||||
"all_scores": {},
|
||||
}
|
||||
|
||||
text_lc = text.lower()
|
||||
scores: dict[str, int] = {}
|
||||
matched_by_intent: dict[str, list[str]] = {}
|
||||
def classify_intent(text: str) -> str:
|
||||
"""Return intent id from free-form user message."""
|
||||
t = normalize_user_text(text)
|
||||
|
||||
for intent, (ar_kw, en_kw) in _KEYWORDS.items():
|
||||
matches: list[str] = []
|
||||
for kw in ar_kw:
|
||||
if kw in text:
|
||||
matches.append(kw)
|
||||
for kw in en_kw:
|
||||
if kw.lower() in text_lc:
|
||||
matches.append(kw)
|
||||
scores[intent] = len(matches)
|
||||
if matches:
|
||||
matched_by_intent[intent] = matches
|
||||
if not t:
|
||||
return INTENT_UNKNOWN
|
||||
|
||||
if not any(scores.values()):
|
||||
return {
|
||||
"intent": "ask_services",
|
||||
"confidence": 0.2,
|
||||
"matched_keywords": [],
|
||||
"all_scores": scores,
|
||||
}
|
||||
# Dangerous / policy — before generic channel mentions
|
||||
cold_ar = ("واتساب بارد" in text) or ("رسائل باردة" in text) or ("بارد" in text and "واتساب" in text)
|
||||
cold_en = "cold whatsapp" in t or "whatsapp blast" in t or "bulk whatsapp" in t
|
||||
if cold_ar or cold_en:
|
||||
return INTENT_COLD_WHATSAPP_REQUEST
|
||||
|
||||
best_intent = max(scores, key=lambda k: scores[k])
|
||||
total_matches = sum(scores.values())
|
||||
confidence = (
|
||||
round(scores[best_intent] / max(1, total_matches), 3)
|
||||
if total_matches else 0.0
|
||||
if "موافق" in t or t == "approve" or "اعتمد" in t:
|
||||
return INTENT_APPROVE_ACTION
|
||||
if "عدّل" in t or "عدل" in t or "edit" in t:
|
||||
return INTENT_EDIT_ACTION
|
||||
if "تخطي" in t or "skip" in t:
|
||||
return INTENT_SKIP_ACTION
|
||||
|
||||
if "سعر" in t or "تسعير" in t or "pricing" in t or "price" in t:
|
||||
return INTENT_ASK_PRICING
|
||||
if "ديمو" in t or "demo" in t:
|
||||
return INTENT_ASK_DEMO
|
||||
if "proof" in t or "إثبات" in text or "اثبات" in text:
|
||||
return INTENT_ASK_PROOF
|
||||
if (
|
||||
"خدمات" in t
|
||||
or "وش أفضل" in t
|
||||
or "ما أفضل" in t
|
||||
or "أفضل خدمة" in text
|
||||
or "افضل خدمة" in text
|
||||
or "ask_services" in t
|
||||
):
|
||||
return INTENT_ASK_SERVICES
|
||||
if "شراكة" in t or "partnership" in t or "شراكات" in t:
|
||||
return INTENT_WANT_PARTNERSHIPS
|
||||
if "ايراد اليوم" in t or "إيراد اليوم" in text or "revenue today" in t:
|
||||
return INTENT_ASK_REVENUE_TODAY
|
||||
|
||||
list_signals = (
|
||||
"قائمة أرقام" in text
|
||||
or "عندي قائمة" in text
|
||||
or "csv" in t
|
||||
or "قائمة ارقام" in text
|
||||
or "contacts" in t
|
||||
or "قائمة جهات" in text
|
||||
)
|
||||
if list_signals:
|
||||
return INTENT_HAS_CONTACT_LIST
|
||||
|
||||
return {
|
||||
"intent": best_intent,
|
||||
"confidence": confidence,
|
||||
"matched_keywords": matched_by_intent.get(best_intent, []),
|
||||
"all_scores": scores,
|
||||
}
|
||||
meeting_signals = "اجتماع" in text or "meetings" in t or "حجز" in t
|
||||
if meeting_signals and ("أبغى" in text or "ابغى" in text or "want" in t):
|
||||
return INTENT_WANT_MEETINGS
|
||||
|
||||
email_signals = ("ايميل" in text or "إيميل" in text or "gmail" in t or "بريد" in text) and (
|
||||
"إنقاذ" in text or "rescue" in t or "فرص" in text
|
||||
)
|
||||
if email_signals:
|
||||
return INTENT_WANT_EMAIL_RESCUE
|
||||
|
||||
def intent_to_service(intent: str) -> str | None:
|
||||
"""Return the service-tower service ID linked to an intent (or None)."""
|
||||
return INTENT_TO_SERVICE.get(intent)
|
||||
wa_setup = ("واتساب" in text or "whatsapp" in t) and ("امتثال" in text or "إعداد" in text or "setup" in t)
|
||||
if wa_setup:
|
||||
return INTENT_WANT_WHATSAPP_SETUP
|
||||
|
||||
daily = "يومي" in text or "daily" in t or "موجز" in text
|
||||
if daily:
|
||||
return INTENT_WANT_DAILY_GROWTH
|
||||
|
||||
customer_signals = (
|
||||
"عملاء أكثر" in text
|
||||
or "أبغى عملاء" in text
|
||||
or "ابغى عملاء" in text
|
||||
or "more customers" in t
|
||||
or "leads" in t
|
||||
or "فرص" in text
|
||||
)
|
||||
if customer_signals:
|
||||
return INTENT_WANT_MORE_CUSTOMERS
|
||||
|
||||
return INTENT_UNKNOWN
|
||||
|
||||
@ -1,104 +1,19 @@
|
||||
"""Operator memory — minimal in-process store for sessions + facts."""
|
||||
"""Append-only conversation turns per session (in-memory MVP)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .session_state import SessionState
|
||||
from auto_client_acquisition.autonomous_service_operator import session_state as ss
|
||||
|
||||
|
||||
@dataclass
|
||||
class OperatorMemory:
|
||||
"""In-process memory for the operator. Production = Supabase/Redis."""
|
||||
sessions: dict[str, SessionState] = field(default_factory=dict)
|
||||
customer_facts: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
customer_preferences: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
blocked_actions_log: list[dict[str, Any]] = field(default_factory=list)
|
||||
approved_actions_log: list[dict[str, Any]] = field(default_factory=list)
|
||||
pivots_log: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# ── sessions ────────────────────────────────────────────
|
||||
def upsert_session(self, session: SessionState) -> SessionState:
|
||||
self.sessions[session.session_id] = session
|
||||
return session
|
||||
|
||||
def get_session(self, session_id: str) -> SessionState | None:
|
||||
return self.sessions.get(session_id)
|
||||
|
||||
def list_sessions_for_customer(self, customer_id: str) -> list[SessionState]:
|
||||
return [s for s in self.sessions.values()
|
||||
if s.customer_id == customer_id]
|
||||
|
||||
# ── customer facts ──────────────────────────────────────
|
||||
def remember_fact(self, customer_id: str, key: str, value: Any) -> None:
|
||||
bucket = self.customer_facts.setdefault(customer_id, {})
|
||||
bucket[key] = value
|
||||
|
||||
def get_fact(self, customer_id: str, key: str) -> Any:
|
||||
return self.customer_facts.get(customer_id, {}).get(key)
|
||||
|
||||
def all_facts(self, customer_id: str) -> dict[str, Any]:
|
||||
return dict(self.customer_facts.get(customer_id, {}))
|
||||
|
||||
# ── preferences ─────────────────────────────────────────
|
||||
def update_preference(
|
||||
self, customer_id: str, *, key: str, value: Any,
|
||||
) -> None:
|
||||
bucket = self.customer_preferences.setdefault(customer_id, {})
|
||||
bucket[key] = value
|
||||
|
||||
def get_preferences(self, customer_id: str) -> dict[str, Any]:
|
||||
return dict(self.customer_preferences.get(customer_id, {}))
|
||||
|
||||
# ── action audit ────────────────────────────────────────
|
||||
def log_blocked_action(
|
||||
self, *, action_type: str, reason_ar: str,
|
||||
customer_id: str | None = None,
|
||||
) -> None:
|
||||
self.blocked_actions_log.append({
|
||||
"ts": time.time(),
|
||||
"action_type": action_type,
|
||||
"reason_ar": reason_ar[:200],
|
||||
"customer_id": customer_id,
|
||||
})
|
||||
|
||||
def log_approved_action(
|
||||
self, *, action_type: str,
|
||||
customer_id: str | None = None,
|
||||
notes: str = "",
|
||||
) -> None:
|
||||
self.approved_actions_log.append({
|
||||
"ts": time.time(),
|
||||
"action_type": action_type,
|
||||
"customer_id": customer_id,
|
||||
"notes": notes[:200],
|
||||
})
|
||||
|
||||
def summarize_audit(self) -> dict[str, Any]:
|
||||
return {
|
||||
"blocked_count": len(self.blocked_actions_log),
|
||||
"approved_count": len(self.approved_actions_log),
|
||||
"blocked_recent": self.blocked_actions_log[-5:],
|
||||
"approved_recent": self.approved_actions_log[-5:],
|
||||
}
|
||||
def append_turn(session_id: str, role: str, content: str, meta: dict[str, Any] | None = None) -> None:
|
||||
s = ss.touch_session(session_id)
|
||||
log = list(s.get("turns") or [])
|
||||
log.append({"role": role, "content": content[:4000], **(meta or {})})
|
||||
ss.upsert_session(session_id, {"turns": log[-50:]})
|
||||
|
||||
|
||||
def build_session_context(
|
||||
*,
|
||||
memory: OperatorMemory,
|
||||
session_id: str,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a context blob for a session — facts + recent audit + state."""
|
||||
session = memory.get_session(session_id)
|
||||
if session is None:
|
||||
return {"error": "unknown session"}
|
||||
|
||||
customer_id = session.customer_id or ""
|
||||
return {
|
||||
"session": session.to_dict(),
|
||||
"customer_facts": memory.all_facts(customer_id),
|
||||
"preferences": memory.get_preferences(customer_id),
|
||||
"audit": memory.summarize_audit(),
|
||||
}
|
||||
def list_turns(session_id: str) -> list[dict[str, Any]]:
|
||||
s = ss.get_session(session_id) or {}
|
||||
return list(s.get("turns") or [])
|
||||
|
||||
@ -1,72 +1,20 @@
|
||||
"""Proof Pack dispatcher — generates + delivers Proof Packs per service."""
|
||||
"""Proof Pack summary for a service — deterministic metrics."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.service_tower.service_catalog import get_service_by_id
|
||||
|
||||
def proof_pack_for_service(
|
||||
service_id: str, *, metrics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a Proof Pack template for any service."""
|
||||
metrics = metrics or {}
|
||||
|
||||
def build_proof_pack(service_id: str) -> dict[str, Any]:
|
||||
svc = get_service_by_id(service_id) or {}
|
||||
metrics = list(svc.get("proof_metrics") or ["drafts_created", "approvals_logged"])
|
||||
return {
|
||||
"service_id": service_id,
|
||||
"title_ar": f"Proof Pack — {service_id}",
|
||||
"sections_ar": [
|
||||
"ملخص تنفيذي (5 أسطر)",
|
||||
"ما عمله Dealix",
|
||||
"النتائج (الأرقام)",
|
||||
"أبرز الردود/الاعتراضات",
|
||||
"المخاطر التي تم منعها",
|
||||
"Action Ledger مختصر",
|
||||
"التوصية بالخطوة التالية",
|
||||
],
|
||||
"metrics_captured": dict(metrics),
|
||||
"metrics_required": [
|
||||
"opportunities_generated",
|
||||
"drafts_approved",
|
||||
"positive_replies",
|
||||
"meetings_drafted",
|
||||
"pipeline_influenced_sar",
|
||||
"risks_blocked",
|
||||
"time_saved_hours",
|
||||
],
|
||||
"delivery_format": ["pdf", "json", "whatsapp_summary"],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def dispatch_proof_pack(
|
||||
*,
|
||||
service_id: str,
|
||||
customer_id: str | None = None,
|
||||
channel: str = "email",
|
||||
metrics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Dispatch a Proof Pack to a customer.
|
||||
|
||||
Returns a draft envelope — never sends. The actual delivery requires
|
||||
customer/admin approval through the Approval Center.
|
||||
"""
|
||||
template = proof_pack_for_service(service_id, metrics=metrics)
|
||||
return {
|
||||
"service_id": service_id,
|
||||
"customer_id": customer_id,
|
||||
"channel": channel,
|
||||
"envelope": {
|
||||
"subject_ar": template["title_ar"],
|
||||
"body_ar": (
|
||||
"مرفق Proof Pack الخاص بـ Pilot. "
|
||||
"يحتوي على ملخص تنفيذي + النتائج + المخاطر التي تم منعها + "
|
||||
"التوصية بالخطوة التالية."
|
||||
),
|
||||
"attachments": ["proof_pack.pdf", "proof_pack.json"],
|
||||
},
|
||||
"template": template,
|
||||
"status": "draft",
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"title_ar": f"Proof Pack — {svc.get('name_ar', service_id)}",
|
||||
"metrics": metrics,
|
||||
"sample_counts": {m: 0 for m in metrics},
|
||||
"notes_ar": "أرقام تجريبية حتى ربط عميل حقيقي ودفتر أحداث.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,55 +1,18 @@
|
||||
"""Self-Growth Mode — Dealix uses its own OS to grow.
|
||||
|
||||
Re-exports + extends targeting_os.self_growth_mode with operator-tier wiring.
|
||||
"""
|
||||
"""Self-growth mode: Dealix uses its own OS for prospecting (drafts + manual approval only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.targeting_os.self_growth_mode import (
|
||||
DEALIX_ICP_FOCUSES,
|
||||
build_dealix_self_growth_plan,
|
||||
build_free_service_offer,
|
||||
build_self_growth_daily_brief,
|
||||
build_weekly_learning_report,
|
||||
recommend_dealix_targets,
|
||||
)
|
||||
|
||||
|
||||
def build_operator_self_growth_brief(
|
||||
*,
|
||||
include_outreach_hint: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Operator-tier wrapper around the self-growth daily brief.
|
||||
|
||||
Layers in approval-first reminders + reminders to never auto-send.
|
||||
"""
|
||||
base = build_self_growth_daily_brief()
|
||||
out = dict(base)
|
||||
out["operator_reminders_ar"] = [
|
||||
"لا cold WhatsApp — حتى داخل Dealix نفسه.",
|
||||
"كل رسالة draft تحتاج اعتمادك قبل الإرسال.",
|
||||
"لا scraping LinkedIn — استخدم Lead Forms أو manual research.",
|
||||
"كل تواصل يدخل Action Ledger.",
|
||||
]
|
||||
if include_outreach_hint:
|
||||
out["next_action_ar"] = (
|
||||
"اعتمد 3 رسائل اليوم فقط — جودة قبل كمية. "
|
||||
"Pilot صغير ناجح > 50 رسالة بدون رد."
|
||||
)
|
||||
out["approval_required"] = True
|
||||
out["live_send_allowed"] = False
|
||||
return out
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DEALIX_ICP_FOCUSES",
|
||||
"build_dealix_self_growth_plan",
|
||||
"build_free_service_offer",
|
||||
"build_operator_self_growth_brief",
|
||||
"build_self_growth_daily_brief",
|
||||
"build_weekly_learning_report",
|
||||
"recommend_dealix_targets",
|
||||
]
|
||||
def mode_profile() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": "self_growth",
|
||||
"priority_intents": ["want_more_customers", "ask_services", "ask_demo"],
|
||||
"rules_ar": [
|
||||
"لا scraping ولا إرسال جماعي.",
|
||||
"كل outreach مسودة + موافقة يدوية.",
|
||||
"Proof Pack أسبوعي للنتائج الداخلية.",
|
||||
],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,215 +1,90 @@
|
||||
"""Service bundles — 6 packaged offerings instead of 20 raw services."""
|
||||
"""Productized service bundles — SAR ranges and catalog service_ids."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 6 bundles that simplify the customer's choice.
|
||||
BUNDLES: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "growth_starter",
|
||||
"name_ar": "Growth Starter",
|
||||
"best_for_ar": "أي شركة تجرب Dealix لأول مرة",
|
||||
"services": [
|
||||
"free_growth_diagnostic",
|
||||
"first_10_opportunities_sprint",
|
||||
],
|
||||
"deliverables_ar": [
|
||||
"تشخيص نمو مجاني خلال 24 ساعة",
|
||||
"10 فرص + رسائل عربية",
|
||||
"Proof Pack مختصر",
|
||||
],
|
||||
"timeline_ar": "8 أيام (1 ديمو + 7 Pilot)",
|
||||
"price_min_sar": 499,
|
||||
"price_max_sar": 1500,
|
||||
"proof_metrics": [
|
||||
"opportunities_count", "drafts_approved",
|
||||
"positive_replies", "diagnostic_to_paid_conversion",
|
||||
],
|
||||
"upgrade_path": ["executive_growth_os"],
|
||||
BundleId = str
|
||||
|
||||
_BUNDLES: dict[BundleId, dict[str, Any]] = {
|
||||
"growth_starter": {
|
||||
"bundle_id": "growth_starter",
|
||||
"title_ar": "Growth Starter",
|
||||
"services": ["free_growth_diagnostic", "first_10_opportunities"],
|
||||
"timeline_days": 14,
|
||||
"price_range_sar": {"min": 499, "max": 499},
|
||||
"best_for_ar": "شركات تريد أول قيمة سريعة + Pilot واضح.",
|
||||
"deliverables_ar": ["تشخيص مجاني", "١٠ فرص + مسودات", "Proof Pack مختصر"],
|
||||
"proof_metrics": ["opportunities_count", "drafts_created", "approvals_logged"],
|
||||
"risk_policy_ar": "لا إرسال حي بدون موافقة؛ لا واتساب بارد.",
|
||||
"upsell_path": "data_to_revenue",
|
||||
},
|
||||
{
|
||||
"id": "data_to_revenue",
|
||||
"name_ar": "Data to Revenue",
|
||||
"best_for_ar": "شركات لديها قائمة عملاء/أرقام لم تُستثمر",
|
||||
"services": [
|
||||
"list_intelligence",
|
||||
"first_10_opportunities_sprint",
|
||||
],
|
||||
"deliverables_ar": [
|
||||
"قائمة منظفة + تصنيف مصادر",
|
||||
"أفضل 50 target بالقنوات الآمنة",
|
||||
"رسائل عربية لكل segment",
|
||||
"Risk report + retention",
|
||||
],
|
||||
"timeline_ar": "10 أيام",
|
||||
"price_min_sar": 1500,
|
||||
"price_max_sar": 3000,
|
||||
"proof_metrics": [
|
||||
"contacts_classified", "safe_targets_found",
|
||||
"risks_blocked", "pipeline_influenced_sar",
|
||||
],
|
||||
"upgrade_path": ["executive_growth_os"],
|
||||
"data_to_revenue": {
|
||||
"bundle_id": "data_to_revenue",
|
||||
"title_ar": "من البيانات إلى الإيراد",
|
||||
"services": ["list_intelligence", "first_10_opportunities"],
|
||||
"timeline_days": 21,
|
||||
"price_range_sar": {"min": 1500, "max": 2500},
|
||||
"best_for_ar": "من لديه قائمة جهات ويريد أهدافاً مرتبة ومسودات.",
|
||||
"deliverables_ar": ["أفضل ٥٠ هدفاً", "تقرير قابلية تواصل", "مسودات رسائل"],
|
||||
"proof_metrics": ["safe_ratio", "drafts_created", "target_ranked"],
|
||||
"risk_policy_ar": "مسودات فقط؛ موافقة قبل أي إرسال.",
|
||||
"upsell_path": "executive_growth_os",
|
||||
},
|
||||
{
|
||||
"id": "executive_growth_os",
|
||||
"name_ar": "Executive Growth OS",
|
||||
"best_for_ar": "CEO / Growth Manager — تشغيل شهري",
|
||||
"services": [
|
||||
"growth_os_monthly",
|
||||
"executive_growth_brief",
|
||||
],
|
||||
"deliverables_ar": [
|
||||
"Daily Command Feed عربي",
|
||||
"Approval Center عبر واتساب",
|
||||
"First 10 Opportunities أسبوعياً",
|
||||
"Proof Pack شهري",
|
||||
"Founder Shadow Board أسبوعي",
|
||||
"Revenue Leak Detector",
|
||||
],
|
||||
"timeline_ar": "شهري متجدد (ابدأ بـPilot 30 يوم)",
|
||||
"price_min_sar": 2999,
|
||||
"price_max_sar": 2999,
|
||||
"proof_metrics": [
|
||||
"monthly_pipeline_sar", "monthly_meetings",
|
||||
"monthly_revenue_influenced", "monthly_risks_blocked",
|
||||
],
|
||||
"upgrade_path": ["partnership_growth", "full_growth_control_tower"],
|
||||
"executive_growth_os": {
|
||||
"bundle_id": "executive_growth_os",
|
||||
"title_ar": "Executive Growth OS",
|
||||
"services": ["executive_growth_brief", "growth_os"],
|
||||
"timeline_days": 30,
|
||||
"price_range_sar": {"min": 2999, "max": 9999},
|
||||
"best_for_ar": "CEO ومدير نمو يريدان موجزاً يومياً وتشغيل Growth OS.",
|
||||
"deliverables_ar": ["موجز يومي", "Command feed", "Proof Pack أسبوعي"],
|
||||
"proof_metrics": ["decisions_logged", "revenue_influenced_sar", "risks_blocked"],
|
||||
"risk_policy_ar": "بوابة أدوات آمنة؛ تكاملات مسودة افتراضياً.",
|
||||
"upsell_path": "full_growth_control_tower",
|
||||
},
|
||||
{
|
||||
"id": "partnership_growth",
|
||||
"name_ar": "Partnership Growth",
|
||||
"best_for_ar": "شركات تنمو عبر الشركاء/الوكالات/الموزعين",
|
||||
"services": [
|
||||
"partner_sprint",
|
||||
"meeting_booking_sprint",
|
||||
],
|
||||
"deliverables_ar": [
|
||||
"20 شريك محتمل + scorecard",
|
||||
"10 رسائل + drafts اجتماعات",
|
||||
"Referral Agreement Draft",
|
||||
"Partner-Proof Pack",
|
||||
],
|
||||
"timeline_ar": "14 يوم",
|
||||
"price_min_sar": 3000,
|
||||
"price_max_sar": 7500,
|
||||
"proof_metrics": [
|
||||
"partners_identified", "partner_meetings",
|
||||
"referral_revenue_sar",
|
||||
],
|
||||
"upgrade_path": ["full_growth_control_tower"],
|
||||
"partnership_growth": {
|
||||
"bundle_id": "partnership_growth",
|
||||
"title_ar": "نمو عبر الشراكات",
|
||||
"services": ["partner_sprint", "meeting_booking_sprint"],
|
||||
"timeline_days": 30,
|
||||
"price_range_sar": {"min": 3000, "max": 7500},
|
||||
"best_for_ar": "توسع عبر شركاء ووكالات.",
|
||||
"deliverables_ar": ["قائمة شركاء", "مسودات اجتماعات", "مسودة اتفاق إحالة"],
|
||||
"proof_metrics": ["partner_meetings", "referral_pipeline"],
|
||||
"risk_policy_ar": "مراجعة قانونية للاتفاقيات.",
|
||||
"upsell_path": "agency_partner_program",
|
||||
},
|
||||
{
|
||||
"id": "local_growth_os",
|
||||
"name_ar": "Local Growth OS",
|
||||
"best_for_ar": "عيادات / متاجر / فروع / خدمات محلية",
|
||||
"services": [
|
||||
"local_growth_os",
|
||||
"whatsapp_compliance_setup",
|
||||
"list_intelligence",
|
||||
],
|
||||
"deliverables_ar": [
|
||||
"Google Business reviews ledger + draft replies",
|
||||
"WhatsApp opt-in audit + templates",
|
||||
"Customer reactivation campaign drafts",
|
||||
"Branch-level Proof Pack",
|
||||
],
|
||||
"timeline_ar": "3 أسابيع",
|
||||
"price_min_sar": 999,
|
||||
"price_max_sar": 2999,
|
||||
"proof_metrics": [
|
||||
"reviews_handled", "opt_ins_collected",
|
||||
"customers_reactivated", "risks_blocked",
|
||||
],
|
||||
"upgrade_path": ["executive_growth_os"],
|
||||
"local_growth_os": {
|
||||
"bundle_id": "local_growth_os",
|
||||
"title_ar": "نمو محلي",
|
||||
"services": ["local_growth_os"],
|
||||
"timeline_days": 30,
|
||||
"price_range_sar": {"min": 999, "max": 2999},
|
||||
"best_for_ar": "عيادات ومطاعم ومتاجر محلية.",
|
||||
"deliverables_ar": ["كروت سمعة", "مسودات رد", "روابط دفع draft"],
|
||||
"proof_metrics": ["reviews_addressed", "reactivation_drafts"],
|
||||
"risk_policy_ar": "موافقة على الرسائل العامة.",
|
||||
"upsell_path": "growth_os",
|
||||
},
|
||||
{
|
||||
"id": "full_growth_control_tower",
|
||||
"name_ar": "Full Growth Control Tower",
|
||||
"best_for_ar": "مؤسسات تريد تشغيل كامل على 30+ يوم",
|
||||
"services": [
|
||||
"growth_os_monthly",
|
||||
"list_intelligence",
|
||||
"first_10_opportunities_sprint",
|
||||
"partner_sprint",
|
||||
"executive_growth_brief",
|
||||
"linkedin_lead_gen_setup",
|
||||
],
|
||||
"deliverables_ar": [
|
||||
"كل خدمات Growth OS",
|
||||
"Partnership Sprint موازٍ",
|
||||
"LinkedIn Lead Gen campaign",
|
||||
"Founder Shadow Board",
|
||||
"Service Excellence weekly review",
|
||||
],
|
||||
"timeline_ar": "30 يوم — قابل للتجديد",
|
||||
"price_min_sar": 12000,
|
||||
"price_max_sar": 25000,
|
||||
"proof_metrics": [
|
||||
"monthly_pipeline_sar", "monthly_revenue_influenced",
|
||||
"partners_signed", "monthly_meetings",
|
||||
],
|
||||
"upgrade_path": [],
|
||||
"full_growth_control_tower": {
|
||||
"bundle_id": "full_growth_control_tower",
|
||||
"title_ar": "برج تحكم كامل — مخصص",
|
||||
"services": ["growth_os", "agency_partner_program"],
|
||||
"timeline_days": 90,
|
||||
"price_range_sar": {"min": 15000, "max": 80000},
|
||||
"best_for_ar": "مؤسسات تريد كل الطبقات على مراحل.",
|
||||
"deliverables_ar": ["خارطة ٣٠/٦٠/٩٠ يوماً", "حوكمة موافقات", "Proof شهري"],
|
||||
"proof_metrics": ["pipeline_influenced", "partners_created", "payments_requested"],
|
||||
"risk_policy_ar": "DPA + مراجعة امتثال قبل التوسع.",
|
||||
"upsell_path": None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def list_bundles() -> dict[str, Any]:
|
||||
return {
|
||||
"total": len(BUNDLES),
|
||||
"bundles": [dict(b) for b in BUNDLES],
|
||||
}
|
||||
return {"bundles": list(_BUNDLES.values()), "demo": True}
|
||||
|
||||
|
||||
def get_bundle(bundle_id: str) -> dict[str, Any] | None:
|
||||
return next((dict(b) for b in BUNDLES if b["id"] == bundle_id), None)
|
||||
|
||||
|
||||
def recommend_bundle(
|
||||
*,
|
||||
intent: str | None = None,
|
||||
has_contact_list: bool = False,
|
||||
is_agency: bool = False,
|
||||
is_local_business: bool = False,
|
||||
budget_sar: int = 1000,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Recommend the best-fit bundle deterministically.
|
||||
|
||||
Order of priority:
|
||||
agency → partnership_growth
|
||||
local business → local_growth_os
|
||||
has list → data_to_revenue
|
||||
monthly budget → executive_growth_os
|
||||
partnerships intent → partnership_growth
|
||||
default → growth_starter
|
||||
"""
|
||||
if is_agency:
|
||||
chosen = "partnership_growth"
|
||||
reason = "وكالة → Partnership Growth + ترقية لـ Agency Partner Program."
|
||||
elif is_local_business:
|
||||
chosen = "local_growth_os"
|
||||
reason = "نشاط محلي → Local Growth OS."
|
||||
elif has_contact_list:
|
||||
chosen = "data_to_revenue"
|
||||
reason = "العميل لديه قائمة → Data to Revenue."
|
||||
elif intent == "want_partnerships":
|
||||
chosen = "partnership_growth"
|
||||
reason = "هدف الشراكات → Partnership Growth."
|
||||
elif intent == "want_daily_growth" or budget_sar >= 2999:
|
||||
chosen = "executive_growth_os"
|
||||
reason = "تشغيل يومي/ميزانية شهرية → Executive Growth OS."
|
||||
elif budget_sar >= 12000:
|
||||
chosen = "full_growth_control_tower"
|
||||
reason = "ميزانية كبيرة → Full Growth Control Tower."
|
||||
else:
|
||||
chosen = "growth_starter"
|
||||
reason = "ابدأ بـ Growth Starter."
|
||||
|
||||
bundle = get_bundle(chosen)
|
||||
return {
|
||||
"recommended_bundle_id": chosen,
|
||||
"bundle": bundle,
|
||||
"reason_ar": reason,
|
||||
"approval_required": True,
|
||||
}
|
||||
return _BUNDLES.get((bundle_id or "").strip())
|
||||
|
||||
@ -1,108 +1,15 @@
|
||||
"""Service Delivery Mode — runs client services + tracks SLA + generates Proof.
|
||||
|
||||
Production wrapper around service_orchestrator + revenue_launch.pilot_delivery
|
||||
+ customer_ops.sla_tracker.
|
||||
"""
|
||||
"""Service delivery mode: running client services with SLA-oriented checklist."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.customer_ops import (
|
||||
build_sla_health_report,
|
||||
classify_sla_breach,
|
||||
)
|
||||
from auto_client_acquisition.revenue_launch import (
|
||||
build_24h_delivery_plan,
|
||||
build_first_10_opportunities_delivery,
|
||||
build_growth_diagnostic_delivery,
|
||||
build_list_intelligence_delivery,
|
||||
)
|
||||
from auto_client_acquisition.service_tower import (
|
||||
build_service_workflow,
|
||||
get_service,
|
||||
)
|
||||
|
||||
|
||||
def build_service_delivery_brief(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
service_id: str = "",
|
||||
intake: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the day-one delivery brief for a service."""
|
||||
s = get_service(service_id)
|
||||
if s is None:
|
||||
return {"error": f"unknown service: {service_id}"}
|
||||
|
||||
delivery_template_by_service: dict[str, Any] = {
|
||||
"first_10_opportunities_sprint":
|
||||
build_first_10_opportunities_delivery(intake or {}),
|
||||
"list_intelligence":
|
||||
build_list_intelligence_delivery(intake or {}),
|
||||
"free_growth_diagnostic":
|
||||
build_growth_diagnostic_delivery(intake or {}),
|
||||
}
|
||||
|
||||
def mode_profile() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": "service_delivery",
|
||||
"customer_id": customer_id,
|
||||
"service_id": service_id,
|
||||
"service_name_ar": s.name_ar,
|
||||
"intake_received": bool(intake),
|
||||
"workflow": build_service_workflow(service_id),
|
||||
"delivery_template": delivery_template_by_service.get(
|
||||
service_id, build_24h_delivery_plan(service_id),
|
||||
),
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def build_sla_status_for_delivery(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
open_tickets: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Compute SLA health for a customer's open delivery tickets."""
|
||||
health = build_sla_health_report(tickets=open_tickets)
|
||||
breaches: list[dict[str, Any]] = []
|
||||
for t in (open_tickets or []):
|
||||
b = classify_sla_breach(
|
||||
priority=str(t.get("priority", "P3")),
|
||||
minutes_to_first_response=t.get("first_response_min"),
|
||||
hours_to_resolve=t.get("resolution_hours"),
|
||||
)
|
||||
if b["breached"]:
|
||||
breaches.append({**t, "breach": b})
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"health": health,
|
||||
"breaches": breaches,
|
||||
"approval_required": True,
|
||||
}
|
||||
|
||||
|
||||
def build_post_delivery_handoff(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
service_id: str = "",
|
||||
delivered_metrics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the post-delivery handoff (Arabic) → Customer Success cadence."""
|
||||
metrics = delivered_metrics or {}
|
||||
return {
|
||||
"mode": "service_delivery",
|
||||
"customer_id": customer_id,
|
||||
"service_id": service_id,
|
||||
"delivered_metrics": dict(metrics),
|
||||
"handoff_steps_ar": [
|
||||
"تسليم Proof Pack النهائي للعميل + اعتماده.",
|
||||
"حجز جلسة مراجعة 30 دقيقة.",
|
||||
"تفعيل Customer Success cadence (weekly check-ins).",
|
||||
"اقتراح الترقية المنطقية بناءً على النتائج.",
|
||||
"تحديث Action Graph + Revenue Work Units.",
|
||||
],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"priority_intents": ["approve_action", "ask_proof", "want_meetings"],
|
||||
"card_types_first": ["approval_needed", "proof", "meeting_prep"],
|
||||
"sla_reminder_ar": "التسليم حسب نافذة الـ Pilot المتفق عليها؛ لا live send افتراضياً.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,94 +1,48 @@
|
||||
"""Service orchestrator — runs the canonical service pipeline."""
|
||||
"""Map intents to recommended service_ids and bundles."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Canonical pipeline every service goes through.
|
||||
SERVICE_PIPELINE_STEPS: tuple[str, ...] = (
|
||||
"intake",
|
||||
"data_check",
|
||||
"targeting",
|
||||
"contactability",
|
||||
"strategy",
|
||||
"drafting",
|
||||
"approval",
|
||||
"execution_or_export",
|
||||
"tracking",
|
||||
"proof",
|
||||
"upsell",
|
||||
)
|
||||
|
||||
_STEP_LABELS_AR: dict[str, str] = {
|
||||
"intake": "جمع المدخلات",
|
||||
"data_check": "فحص جودة البيانات",
|
||||
"targeting": "تحديد الأهداف",
|
||||
"contactability": "تقييم إمكانية التواصل",
|
||||
"strategy": "صياغة الاستراتيجية",
|
||||
"drafting": "كتابة المسودات",
|
||||
"approval": "اعتماد بشري",
|
||||
"execution_or_export": "تنفيذ أو تصدير",
|
||||
"tracking": "متابعة النتائج",
|
||||
"proof": "Proof Pack",
|
||||
"upsell": "ترقية الخدمة",
|
||||
}
|
||||
from auto_client_acquisition.autonomous_service_operator import intent_classifier as ic
|
||||
from auto_client_acquisition.service_excellence.service_scoring import calculate_service_excellence_score
|
||||
from auto_client_acquisition.service_tower.service_catalog import get_service_by_id
|
||||
|
||||
|
||||
def build_service_pipeline(
|
||||
service_id: str, *, customer_id: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Build the canonical pipeline state for a service."""
|
||||
def recommend_for_intent(intent: str) -> dict[str, Any]:
|
||||
"""Return primary service_id, optional bundle, and excellence gate."""
|
||||
mapping: dict[str, tuple[str, str | None]] = {
|
||||
ic.INTENT_WANT_MORE_CUSTOMERS: ("first_10_opportunities", "growth_starter"),
|
||||
ic.INTENT_HAS_CONTACT_LIST: ("list_intelligence", "data_to_revenue"),
|
||||
ic.INTENT_WANT_PARTNERSHIPS: ("partner_sprint", "partnership_growth"),
|
||||
ic.INTENT_ASK_PARTNERSHIP: ("partner_sprint", "partnership_growth"),
|
||||
ic.INTENT_WANT_DAILY_GROWTH: ("self_growth_operator", "executive_growth_os"),
|
||||
ic.INTENT_WANT_MEETINGS: ("meeting_booking_sprint", None),
|
||||
ic.INTENT_WANT_EMAIL_RESCUE: ("email_revenue_rescue", None),
|
||||
ic.INTENT_WANT_WHATSAPP_SETUP: ("whatsapp_compliance_setup", None),
|
||||
ic.INTENT_ASK_PRICING: ("growth_os", None),
|
||||
ic.INTENT_ASK_SERVICES: ("free_growth_diagnostic", None),
|
||||
ic.INTENT_ASK_DEMO: ("free_growth_diagnostic", None),
|
||||
ic.INTENT_ASK_PROOF: ("first_10_opportunities", None),
|
||||
ic.INTENT_ASK_REVENUE_TODAY: ("growth_os", None),
|
||||
}
|
||||
sid, bundle = mapping.get(intent, ("free_growth_diagnostic", None))
|
||||
svc = get_service_by_id(sid) or {}
|
||||
score = calculate_service_excellence_score(sid)
|
||||
return {
|
||||
"service_id": service_id,
|
||||
"customer_id": customer_id,
|
||||
"current_step": "intake",
|
||||
"completed_steps": [],
|
||||
"steps": [
|
||||
{
|
||||
"step_id": s,
|
||||
"label_ar": _STEP_LABELS_AR.get(s, s),
|
||||
"completed": False,
|
||||
"approval_required": s in {
|
||||
"drafting", "approval", "execution_or_export",
|
||||
},
|
||||
}
|
||||
for s in SERVICE_PIPELINE_STEPS
|
||||
],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"intent": intent,
|
||||
"recommended_service_id": sid,
|
||||
"service_name_ar": svc.get("name_ar"),
|
||||
"suggested_bundle_id": bundle,
|
||||
"excellence": {"total_score": score["total_score"], "status": score["status"]},
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
def run_service_step(
|
||||
pipeline: dict[str, Any], *, step_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Mark the current (or supplied) step as run + advance the pipeline.
|
||||
|
||||
Does NOT execute any external action — only updates state.
|
||||
"""
|
||||
target = step_id or pipeline.get("current_step")
|
||||
steps = list(pipeline.get("steps", []))
|
||||
found = False
|
||||
for i, s in enumerate(steps):
|
||||
if s.get("step_id") == target:
|
||||
s["completed"] = True
|
||||
steps[i] = s
|
||||
found = True
|
||||
# Move to next step.
|
||||
if i + 1 < len(steps):
|
||||
pipeline["current_step"] = steps[i + 1]["step_id"]
|
||||
else:
|
||||
pipeline["current_step"] = "done"
|
||||
break
|
||||
|
||||
if not found:
|
||||
return {**pipeline, "error": f"unknown step: {target}"}
|
||||
|
||||
completed = [s["step_id"] for s in steps if s["completed"]]
|
||||
pipeline["steps"] = steps
|
||||
pipeline["completed_steps"] = completed
|
||||
pipeline["progress_pct"] = round(
|
||||
100 * len(completed) / max(1, len(steps)), 1,
|
||||
)
|
||||
return pipeline
|
||||
def cold_whatsapp_response() -> dict[str, Any]:
|
||||
return {
|
||||
"blocked": True,
|
||||
"message_ar": "لا ندعم واتساب بارد أو غير موافق عليه. نرشّح: قالب opt-in، أو إيميل draft، أو سباق اجتماعات بعد موافقة.",
|
||||
"alternatives": ["whatsapp_opt_in_template", "gmail_draft", "meeting_booking_sprint"],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,95 +1,39 @@
|
||||
"""Session state — minimal in-memory state for an operator conversation."""
|
||||
"""In-memory operator sessions — MVP; replace with DB or revenue_memory later."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# Valid state transitions for the operator session.
|
||||
_VALID_STATES: tuple[str, ...] = (
|
||||
"new",
|
||||
"intent_classified",
|
||||
"intake_collecting",
|
||||
"intake_complete",
|
||||
"service_recommended",
|
||||
"workflow_running",
|
||||
"approval_pending",
|
||||
"approval_received",
|
||||
"executing",
|
||||
"proof_pending",
|
||||
"proof_delivered",
|
||||
"upsell_offered",
|
||||
"closed",
|
||||
)
|
||||
_SESSIONS: dict[str, dict[str, Any]] = {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class SessionState:
|
||||
"""A single operator conversation session."""
|
||||
session_id: str
|
||||
customer_id: str | None = None
|
||||
state: str = "new"
|
||||
intent: str | None = None
|
||||
recommended_service_id: str | None = None
|
||||
bundle_id: str | None = None
|
||||
intake_payload: dict[str, Any] = field(default_factory=dict)
|
||||
actions_pending_approval: list[dict[str, Any]] = field(default_factory=list)
|
||||
actions_approved: list[dict[str, Any]] = field(default_factory=list)
|
||||
actions_blocked: list[dict[str, Any]] = field(default_factory=list)
|
||||
proof_pack: dict[str, Any] | None = None
|
||||
upsell_offer: dict[str, Any] | None = None
|
||||
history: list[dict[str, Any]] = field(default_factory=list)
|
||||
created_at: float = field(default_factory=time.time)
|
||||
updated_at: float = field(default_factory=time.time)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"customer_id": self.customer_id,
|
||||
"state": self.state,
|
||||
"intent": self.intent,
|
||||
"recommended_service_id": self.recommended_service_id,
|
||||
"bundle_id": self.bundle_id,
|
||||
"intake_payload": dict(self.intake_payload),
|
||||
"actions_pending_approval": list(self.actions_pending_approval),
|
||||
"actions_approved": list(self.actions_approved),
|
||||
"actions_blocked": list(self.actions_blocked),
|
||||
"proof_pack": self.proof_pack,
|
||||
"upsell_offer": self.upsell_offer,
|
||||
"history_len": len(self.history),
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
}
|
||||
def new_session_id() -> str:
|
||||
return f"op_{uuid.uuid4().hex[:16]}"
|
||||
|
||||
|
||||
def build_new_session(customer_id: str | None = None) -> SessionState:
|
||||
"""Build a fresh session with a generated UUID."""
|
||||
return SessionState(
|
||||
session_id=str(uuid.uuid4()),
|
||||
customer_id=customer_id,
|
||||
)
|
||||
def get_session(session_id: str) -> dict[str, Any] | None:
|
||||
return _SESSIONS.get(session_id)
|
||||
|
||||
|
||||
def transition_session(
|
||||
session: SessionState,
|
||||
*,
|
||||
new_state: str,
|
||||
note: str = "",
|
||||
) -> SessionState:
|
||||
"""Move the session to a new state with audit trail."""
|
||||
if new_state not in _VALID_STATES:
|
||||
raise ValueError(
|
||||
f"Unknown session state: {new_state}. "
|
||||
f"Valid: {', '.join(_VALID_STATES)}"
|
||||
)
|
||||
session.history.append({
|
||||
"from": session.state,
|
||||
"to": new_state,
|
||||
"note": note[:200],
|
||||
"ts": time.time(),
|
||||
})
|
||||
session.state = new_state
|
||||
session.updated_at = time.time()
|
||||
return session
|
||||
def upsert_session(session_id: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
base = dict(_SESSIONS.get(session_id, {}))
|
||||
base.update(patch)
|
||||
base["session_id"] = session_id
|
||||
_SESSIONS[session_id] = base
|
||||
return base
|
||||
|
||||
|
||||
def touch_session(session_id: str) -> dict[str, Any]:
|
||||
if session_id not in _SESSIONS:
|
||||
_SESSIONS[session_id] = {"session_id": session_id, "workflow_state": "idle"}
|
||||
return _SESSIONS[session_id]
|
||||
|
||||
|
||||
def list_sessions_with_pending() -> list[dict[str, Any]]:
|
||||
out: list[dict[str, Any]] = []
|
||||
for sid, data in _SESSIONS.items():
|
||||
pc = data.get("pending_card")
|
||||
if isinstance(pc, dict):
|
||||
out.append({"session_id": sid, "card": pc})
|
||||
return out
|
||||
|
||||
@ -1,102 +1,40 @@
|
||||
"""Tool action planner — plan + review actions before they hit Tool Gateway."""
|
||||
"""Safe Tool Gateway matrix — execution modes per tool (deterministic)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Any, Final
|
||||
|
||||
# Tools that REQUIRE explicit human approval, no exceptions.
|
||||
_HIGH_RISK_TOOLS: frozenset[str] = frozenset({
|
||||
"whatsapp.send_message",
|
||||
"gmail.send",
|
||||
"calendar.insert_event",
|
||||
"moyasar.charge",
|
||||
"google_business.publish_review_reply",
|
||||
"social.publish_dm",
|
||||
"social.publish_post",
|
||||
})
|
||||
MODE_SUGGEST_ONLY: Final = "suggest_only"
|
||||
MODE_DRAFT_ONLY: Final = "draft_only"
|
||||
MODE_APPROVAL_REQUIRED: Final = "approval_required"
|
||||
MODE_APPROVED_EXECUTE: Final = "approved_execute"
|
||||
MODE_BLOCKED: Final = "blocked"
|
||||
|
||||
# Tools that are safe in draft mode (still approval-required, never live-by-default).
|
||||
_DRAFT_SAFE_TOOLS: frozenset[str] = frozenset({
|
||||
"whatsapp.draft_message",
|
||||
"gmail.create_draft",
|
||||
"calendar.draft_event",
|
||||
"moyasar.create_invoice_draft",
|
||||
"moyasar.create_payment_link_draft",
|
||||
"google_business.draft_review_reply",
|
||||
"social.draft_post",
|
||||
})
|
||||
|
||||
# Tools never to plan, period.
|
||||
_FORBIDDEN_TOOLS: frozenset[str] = frozenset({
|
||||
"linkedin.scrape_profile",
|
||||
"linkedin.auto_dm",
|
||||
"linkedin.auto_connect",
|
||||
"social.scrape_followers",
|
||||
"phone.cold_call_unscripted",
|
||||
})
|
||||
# tool_id -> default mode when autonomy is draft_and_approve (Dealix beta default)
|
||||
_TOOL_MATRIX: dict[str, dict[str, Any]] = {
|
||||
"gmail_send": {"mode": MODE_BLOCKED, "reason_ar": "إرسال Gmail مباشر محظور افتراضياً."},
|
||||
"gmail_draft": {"mode": MODE_DRAFT_ONLY, "reason_ar": "مسودات Gmail مسموحة للمراجعة."},
|
||||
"linkedin_scrape": {"mode": MODE_BLOCKED, "reason_ar": "scraping LinkedIn محظور."},
|
||||
"linkedin_auto_dm": {"mode": MODE_BLOCKED, "reason_ar": "رسائل LinkedIn آلية محظورة."},
|
||||
"cold_whatsapp": {"mode": MODE_BLOCKED, "reason_ar": "واتساب بارد / غير موافق عليه محظور."},
|
||||
"whatsapp_opt_in_template": {"mode": MODE_DRAFT_ONLY, "reason_ar": "قوالب opt-in كمسودات."},
|
||||
"moyasar_charge": {"mode": MODE_BLOCKED, "reason_ar": "شحن بطاقة من API غير مفعّل."},
|
||||
"moyasar_payment_link_draft": {"mode": MODE_DRAFT_ONLY, "reason_ar": "مسودة رابط دفع مسموحة."},
|
||||
"google_calendar_insert": {"mode": MODE_APPROVAL_REQUIRED, "reason_ar": "إدراج تقويم يحتاج موافقة."},
|
||||
"crm_update": {"mode": MODE_APPROVAL_REQUIRED, "reason_ar": "تحديث CRM بعد موافقة."},
|
||||
"google_sheets_export": {"mode": MODE_APPROVAL_REQUIRED, "reason_ar": "تصدير مع موافقة عند الحساسية."},
|
||||
"meeting_transcript_read": {"mode": MODE_APPROVAL_REQUIRED, "reason_ar": "قراءة محضر تتطلب نطاقاً وموافقة."},
|
||||
}
|
||||
|
||||
|
||||
def plan_tool_action(
|
||||
*,
|
||||
tool: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
customer_id: str | None = None,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Plan a tool action — does NOT execute. Returns the plan + safety verdict.
|
||||
|
||||
Verdicts:
|
||||
- "blocked" (tool is forbidden or unsafe)
|
||||
- "draft_only" (tool may run as draft, requires approval)
|
||||
- "approval_required"(tool requires human approval before execution)
|
||||
- "ready_for_gateway"(tool is safe internal — pass to Tool Gateway)
|
||||
"""
|
||||
payload = payload or {}
|
||||
context = context or {}
|
||||
tool_lc = (tool or "").strip().lower()
|
||||
|
||||
if tool_lc in _FORBIDDEN_TOOLS:
|
||||
return {
|
||||
"tool": tool, "verdict": "blocked",
|
||||
"reason_ar": "أداة محظورة (LinkedIn scraping/auto-DM/scraping social).",
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
if tool_lc in _HIGH_RISK_TOOLS:
|
||||
return {
|
||||
"tool": tool, "verdict": "approval_required",
|
||||
"reason_ar": (
|
||||
"أداة عالية المخاطرة — تحتاج اعتماد بشري + env flag مفعّل."
|
||||
),
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
if tool_lc in _DRAFT_SAFE_TOOLS:
|
||||
return {
|
||||
"tool": tool, "verdict": "draft_only",
|
||||
"reason_ar": "draft فقط — أرسل للمراجعة قبل الاعتماد.",
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
# Unknown tool — default to safest verdict.
|
||||
return {
|
||||
"tool": tool, "verdict": "approval_required",
|
||||
"reason_ar": "أداة غير مصنّفة — تحتاج مراجعة قبل التنفيذ.",
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
def evaluate_tool(tool_id: str, autonomy_mode: str = "draft_and_approve") -> dict[str, Any]:
|
||||
tid = (tool_id or "").strip().lower()
|
||||
row = _TOOL_MATRIX.get(tid, {"mode": MODE_APPROVAL_REQUIRED, "reason_ar": "أداة غير مسجّلة — موافقة افتراضية."})
|
||||
mode = row["mode"]
|
||||
if autonomy_mode in ("manual", "suggest_only") and mode == MODE_APPROVED_EXECUTE:
|
||||
mode = MODE_SUGGEST_ONLY
|
||||
return {"tool_id": tid, "mode": mode, "reason_ar": row["reason_ar"], "demo": True}
|
||||
|
||||
|
||||
def review_planned_action(plan: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Quick safety review on an already-planned action. Returns updated plan.
|
||||
|
||||
Strips any 'live_send_allowed=True' and forces it back to False.
|
||||
"""
|
||||
out = dict(plan)
|
||||
out["live_send_allowed"] = False
|
||||
out["safety_reviewed"] = True
|
||||
if out.get("verdict") == "ready_for_gateway":
|
||||
# Even safe tools must be audited — promote to approval_required.
|
||||
out["verdict"] = "approval_required"
|
||||
return out
|
||||
def list_tool_matrix() -> dict[str, Any]:
|
||||
return {"tools": [{**{"tool_id": k}, **v} for k, v in _TOOL_MATRIX.items()], "demo": True}
|
||||
|
||||
@ -1,94 +1,25 @@
|
||||
"""Upsell engine — recommend the next service after current one delivers."""
|
||||
"""Suggest next service / bundle from catalog upgrade_path."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Mapping: completed_service → next_recommended_service.
|
||||
_UPSELL_MAP: dict[str, str] = {
|
||||
"free_growth_diagnostic": "first_10_opportunities_sprint",
|
||||
"list_intelligence": "growth_os_monthly",
|
||||
"first_10_opportunities_sprint": "growth_os_monthly",
|
||||
"self_growth_operator": "growth_os_monthly",
|
||||
"email_revenue_rescue": "growth_os_monthly",
|
||||
"meeting_booking_sprint": "growth_os_monthly",
|
||||
"partner_sprint": "agency_partner_program",
|
||||
"agency_partner_program": "growth_os_monthly",
|
||||
"whatsapp_compliance_setup": "growth_os_monthly",
|
||||
"linkedin_lead_gen_setup": "growth_os_monthly",
|
||||
"executive_growth_brief": "growth_os_monthly",
|
||||
"growth_os_monthly": "growth_os_monthly", # already at top — annual upgrade
|
||||
}
|
||||
|
||||
_UPSELL_PRICING_AR: dict[str, str] = {
|
||||
"first_10_opportunities_sprint": "499–1,500 ريال (Sprint)",
|
||||
"growth_os_monthly": "2,999 ريال شهرياً (أو سنوي بخصم 15%)",
|
||||
"agency_partner_program": "10,000–50,000 ريال (Setup) + Revenue Share",
|
||||
}
|
||||
from auto_client_acquisition.autonomous_service_operator import service_bundles as sb
|
||||
from auto_client_acquisition.service_tower.service_catalog import get_service_by_id
|
||||
|
||||
|
||||
def recommend_upsell_after_service(
|
||||
*,
|
||||
completed_service_id: str,
|
||||
pilot_metrics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Recommend an upsell based on the completed service + metrics.
|
||||
|
||||
Strong outcomes (csat ≥ 8 + pipeline ≥ 25K OR meetings ≥ 2) → upsell now.
|
||||
Weak outcomes (pipeline < 5K + meetings = 0) → iterate, don't upsell.
|
||||
Otherwise: gentle upsell.
|
||||
"""
|
||||
next_id = _UPSELL_MAP.get(completed_service_id, "growth_os_monthly")
|
||||
metrics = pilot_metrics or {}
|
||||
pipeline_sar = float(metrics.get("pipeline_sar", 0))
|
||||
meetings = int(metrics.get("meetings", 0))
|
||||
csat = int(metrics.get("csat", 0))
|
||||
|
||||
if csat >= 8 and (pipeline_sar >= 25_000 or meetings >= 2):
|
||||
verdict = "upsell_now"
|
||||
urgency_ar = (
|
||||
"النتائج قوية — اعرض الترقية اليوم مع خصم سنوي 15%."
|
||||
)
|
||||
elif pipeline_sar < 5_000 and meetings == 0:
|
||||
verdict = "iterate_first"
|
||||
urgency_ar = (
|
||||
"النتائج ضعيفة هذه الجولة. اقترح زاوية مختلفة قبل الترقية."
|
||||
)
|
||||
else:
|
||||
verdict = "gentle_upsell"
|
||||
urgency_ar = (
|
||||
"النتائج واعدة. اعرض Pilot موسّع 30 يوم قبل الاشتراك الشهري."
|
||||
)
|
||||
|
||||
def suggest_upsell(service_id: str) -> dict[str, Any]:
|
||||
svc = get_service_by_id(service_id) or {}
|
||||
nxt = svc.get("upgrade_path")
|
||||
next_svc = get_service_by_id(str(nxt)) if nxt else None
|
||||
bundle_hint = None
|
||||
if nxt == "growth_os":
|
||||
bundle_hint = "executive_growth_os"
|
||||
return {
|
||||
"completed_service_id": completed_service_id,
|
||||
"recommended_next_service_id": next_id,
|
||||
"verdict": verdict,
|
||||
"pricing_ar": _UPSELL_PRICING_AR.get(next_id, "حسب الحاجة"),
|
||||
"urgency_ar": urgency_ar,
|
||||
"approval_required": True,
|
||||
}
|
||||
|
||||
|
||||
def build_upsell_card(
|
||||
*,
|
||||
completed_service_id: str,
|
||||
pilot_metrics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an Arabic upsell card to deliver after Proof Pack."""
|
||||
rec = recommend_upsell_after_service(
|
||||
completed_service_id=completed_service_id,
|
||||
pilot_metrics=pilot_metrics,
|
||||
)
|
||||
return {
|
||||
"type": "upsell",
|
||||
"title_ar": f"الترقية المقترحة بعد {completed_service_id}",
|
||||
"summary_ar": rec["urgency_ar"],
|
||||
"next_service_id": rec["recommended_next_service_id"],
|
||||
"pricing_ar": rec["pricing_ar"],
|
||||
"verdict": rec["verdict"],
|
||||
"buttons_ar": ["ابدأ الترقية", "اشرح أكثر", "لاحقاً"],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"from_service_id": service_id,
|
||||
"next_service_id": nxt,
|
||||
"next_name_ar": (next_svc or {}).get("name_ar"),
|
||||
"suggested_bundle_id": bundle_hint,
|
||||
"bundles": sb.get_bundle(bundle_hint) if bundle_hint else None,
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,75 +1,17 @@
|
||||
"""WhatsApp renderer — convert cards/briefs to WhatsApp-ready format.
|
||||
|
||||
Drafts only. Never sends. Always emits buttons_ar capped at 3 (WhatsApp Reply
|
||||
Buttons limit) and Arabic body text.
|
||||
"""
|
||||
"""WhatsApp payload shapes (text templates only — no live send)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def render_card_for_whatsapp(card: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Render any decision card as a WhatsApp-style draft message."""
|
||||
title = str(card.get("title_ar", "")).strip()[:60]
|
||||
summary = str(card.get("summary_ar", "")).strip()[:300]
|
||||
why_now = str(card.get("why_now_ar", "")).strip()[:200]
|
||||
action = str(card.get("recommended_action_ar", "")).strip()[:200]
|
||||
risk = str(card.get("risk_level", "")).strip()
|
||||
buttons = list(card.get("buttons_ar", []))[:3]
|
||||
|
||||
body_lines: list[str] = [title]
|
||||
if summary:
|
||||
body_lines.append("")
|
||||
body_lines.append(summary)
|
||||
if why_now:
|
||||
body_lines.append("")
|
||||
body_lines.append(f"لماذا الآن: {why_now}")
|
||||
if action:
|
||||
body_lines.append(f"الإجراء المقترح: {action}")
|
||||
if risk:
|
||||
body_lines.append(f"المخاطرة: {risk}")
|
||||
if buttons:
|
||||
body_lines.append("")
|
||||
body_lines.append("أزرار: " + " | ".join(buttons))
|
||||
|
||||
def render_daily_brief_stub() -> dict[str, Any]:
|
||||
return {
|
||||
"channel": "whatsapp",
|
||||
"kind": "card_draft",
|
||||
"body_ar": "\n".join(body_lines),
|
||||
"buttons_ar": buttons,
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def render_approval_card_for_whatsapp(
|
||||
card: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Render an approval card specifically — guarantees the 3 standard buttons."""
|
||||
out = render_card_for_whatsapp(card)
|
||||
out["buttons_ar"] = card.get("buttons_ar") or ["اعتمد", "عدّل", "تخطي"]
|
||||
out["kind"] = "approval_card"
|
||||
return out
|
||||
|
||||
|
||||
def render_daily_brief_for_whatsapp(brief: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Render a CEO/Growth Manager daily brief as WhatsApp draft."""
|
||||
summary_lines = list(brief.get("summary_ar", []))[:8]
|
||||
decisions = list(brief.get("priority_decisions_ar", []))[:3]
|
||||
|
||||
body_lines = ["صباح الخير 👋", "", "أهم اليوم:"]
|
||||
body_lines.extend(f"• {line}" for line in summary_lines)
|
||||
if decisions:
|
||||
body_lines.append("")
|
||||
body_lines.append("3 قرارات تنتظر:")
|
||||
body_lines.extend(f"{i + 1}. {d}" for i, d in enumerate(decisions))
|
||||
|
||||
return {
|
||||
"channel": "whatsapp",
|
||||
"kind": "daily_brief_draft",
|
||||
"body_ar": "\n".join(body_lines),
|
||||
"buttons_ar": ["اعرض القرارات", "Proof Pack", "لاحقاً"],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"format": "text_stub",
|
||||
"body_ar": (
|
||||
"موجز Dealix (مسودة): ٣ قرارات مقترحة — راجع لوحة الموافقات. "
|
||||
"لا يُرسل هذا النص تلقائياً من المنصة في MVP."
|
||||
),
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,43 +1,24 @@
|
||||
"""Workflow runner — advances service pipelines + checks completion."""
|
||||
"""Simple workflow state machine: intake → draft → pending_approval → proof."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .service_orchestrator import (
|
||||
SERVICE_PIPELINE_STEPS,
|
||||
build_service_pipeline,
|
||||
run_service_step,
|
||||
)
|
||||
from auto_client_acquisition.autonomous_service_operator import session_state as ss
|
||||
|
||||
|
||||
def build_workflow_state(service_id: str, *, customer_id: str = "") -> dict[str, Any]:
|
||||
"""Initialize a new workflow state for a service."""
|
||||
pipeline = build_service_pipeline(service_id, customer_id=customer_id)
|
||||
return {
|
||||
"service_id": service_id,
|
||||
"customer_id": customer_id,
|
||||
"pipeline": pipeline,
|
||||
"human_approvals_received": 0,
|
||||
"human_approvals_pending": 0,
|
||||
"blocked_actions": 0,
|
||||
def advance(session_id: str, event: str) -> dict[str, Any]:
|
||||
"""event: start_service | draft_ready | submit_for_approval | proof_ready"""
|
||||
s = ss.touch_session(session_id)
|
||||
state = str(s.get("workflow_state") or "idle")
|
||||
ev = (event or "").strip().lower()
|
||||
transitions: dict[tuple[str, str], str] = {
|
||||
("idle", "start_service"): "intake",
|
||||
("intake", "draft_ready"): "draft",
|
||||
("draft", "submit_for_approval"): "pending_approval",
|
||||
("pending_approval", "proof_ready"): "proof",
|
||||
("proof", "start_service"): "intake",
|
||||
}
|
||||
|
||||
|
||||
def advance_workflow(
|
||||
workflow_state: dict[str, Any], *, step_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Advance the underlying pipeline by one step."""
|
||||
pipeline = workflow_state.get("pipeline") or build_service_pipeline(
|
||||
str(workflow_state.get("service_id", "")),
|
||||
)
|
||||
pipeline = run_service_step(pipeline, step_id=step_id)
|
||||
workflow_state["pipeline"] = pipeline
|
||||
return workflow_state
|
||||
|
||||
|
||||
def is_workflow_complete(workflow_state: dict[str, Any]) -> bool:
|
||||
"""True iff all canonical steps have run."""
|
||||
pipeline = workflow_state.get("pipeline", {})
|
||||
completed = pipeline.get("completed_steps", [])
|
||||
return len(completed) >= len(SERVICE_PIPELINE_STEPS)
|
||||
key = (state, ev)
|
||||
new_state = transitions.get(key, state)
|
||||
return ss.upsert_session(session_id, {"workflow_state": new_state, "last_event": ev})
|
||||
|
||||
@ -1 +1,5 @@
|
||||
"""Connector modules for Dealix Lead Machine — legal, public-data sources."""
|
||||
"""Connector catalog for Growth Control Tower — metadata only."""
|
||||
|
||||
from auto_client_acquisition.connectors.connector_catalog import build_connector_catalog
|
||||
|
||||
__all__ = ["build_connector_catalog"]
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
"""12+ connectors with capabilities and risk — no live OAuth in MVP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _row(
|
||||
cid: str,
|
||||
label_ar: str,
|
||||
beta: str,
|
||||
allowed: list[str],
|
||||
blocked: list[str],
|
||||
risk: str,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"id": cid,
|
||||
"label_ar": label_ar,
|
||||
"beta_status": beta,
|
||||
"required_permissions_ar": "OAuth أو مفاتيح رسمية حسب المزود — لا تخزين أسرار في الريبو.",
|
||||
"allowed_actions": allowed,
|
||||
"blocked_actions": blocked,
|
||||
"risk_level": risk,
|
||||
"launch_phase": 1 if beta == "mvp" else 2 if beta == "pilot" else 3,
|
||||
}
|
||||
|
||||
|
||||
_CONNECTORS: list[dict[str, Any]] = [
|
||||
_row("whatsapp", "واتساب للأعمال", "pilot", ["draft", "template_preview"], ["cold_bulk", "live_send"], "high"),
|
||||
_row("gmail", "Gmail", "pilot", ["draft_create", "read_limited"], ["send_live"], "medium"),
|
||||
_row("google_calendar", "Google Calendar", "pilot", ["draft_event"], ["insert_live"], "medium"),
|
||||
_row("google_meet", "Google Meet", "planned", ["transcript_read"], ["record_without_consent"], "high"),
|
||||
_row("linkedin_lead_forms", "LinkedIn Lead Gen", "mvp", ["lead_ingest"], ["auto_dm"], "medium"),
|
||||
_row("x_api", "X API", "registered_only", [], ["firehose", "auto_reply"], "high"),
|
||||
_row("instagram_graph", "Instagram Graph", "registered_only", [], ["auto_dm"], "high"),
|
||||
_row("google_business_profile", "Google Business Profile", "registered_only", ["review_draft"], ["auto_reply"], "medium"),
|
||||
_row("google_sheets", "Google Sheets", "planned", ["read_range_draft"], ["write_live"], "low"),
|
||||
_row("crm", "CRM عام", "planned", ["sync_draft"], ["delete_records"], "medium"),
|
||||
_row("moyasar", "Moyasar", "mvp", ["payment_link_draft"], ["charge_live"], "high"),
|
||||
_row("website_forms", "نماذج موقع", "mvp", ["webhook_ingest"], ["scraping"], "low"),
|
||||
]
|
||||
|
||||
|
||||
def build_connector_catalog() -> dict[str, Any]:
|
||||
return {"connectors": list(_CONNECTORS), "count": len(_CONNECTORS), "demo": True}
|
||||
@ -1,78 +1,6 @@
|
||||
"""Customer Ops — onboarding + connector setup + support SLA + incidents.
|
||||
"""Customer operations: onboarding, SLA, connectors, incidents (deterministic stubs)."""
|
||||
|
||||
Closes the gap between "great product" and "great customer experience":
|
||||
- onboarding_checklist: 8-step Pilot onboarding
|
||||
- connector_setup_status: per-connector readiness
|
||||
- support_ticket_router: P0–P3 categorization + routing
|
||||
- sla_tracker: time-to-first-response, MTTR, weekly health
|
||||
- customer_success_cadence: weekly check-in cadence + risk flags
|
||||
- incident_router: triage P0/P1 incidents with audit
|
||||
"""
|
||||
from auto_client_acquisition.customer_ops.onboarding_checklist import build_onboarding_checklist
|
||||
from auto_client_acquisition.customer_ops.sla_tracker import build_sla_summary
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .connector_setup_status import (
|
||||
SUPPORTED_CONNECTORS,
|
||||
build_connector_setup_summary,
|
||||
get_connector_status,
|
||||
update_connector_status,
|
||||
)
|
||||
from .customer_success_cadence import (
|
||||
CADENCE_TYPES,
|
||||
build_at_risk_alert,
|
||||
build_customer_success_plan,
|
||||
build_weekly_check_in,
|
||||
)
|
||||
from .incident_router import (
|
||||
INCIDENT_SEVERITIES,
|
||||
build_incident_response_plan,
|
||||
triage_incident,
|
||||
)
|
||||
from .onboarding_checklist import (
|
||||
ONBOARDING_STEPS,
|
||||
build_onboarding_checklist,
|
||||
update_onboarding_step,
|
||||
)
|
||||
from .sla_tracker import (
|
||||
SLA_TARGETS,
|
||||
build_sla_health_report,
|
||||
classify_sla_breach,
|
||||
record_sla_event,
|
||||
)
|
||||
from .support_ticket_router import (
|
||||
SUPPORT_PRIORITIES,
|
||||
build_first_response_template,
|
||||
classify_ticket_priority,
|
||||
route_ticket,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# connector_setup_status
|
||||
"SUPPORTED_CONNECTORS",
|
||||
"build_connector_setup_summary",
|
||||
"get_connector_status",
|
||||
"update_connector_status",
|
||||
# customer_success_cadence
|
||||
"CADENCE_TYPES",
|
||||
"build_at_risk_alert",
|
||||
"build_customer_success_plan",
|
||||
"build_weekly_check_in",
|
||||
# incident_router
|
||||
"INCIDENT_SEVERITIES",
|
||||
"build_incident_response_plan",
|
||||
"triage_incident",
|
||||
# onboarding_checklist
|
||||
"ONBOARDING_STEPS",
|
||||
"build_onboarding_checklist",
|
||||
"update_onboarding_step",
|
||||
# sla_tracker
|
||||
"SLA_TARGETS",
|
||||
"build_sla_health_report",
|
||||
"classify_sla_breach",
|
||||
"record_sla_event",
|
||||
# support_ticket_router
|
||||
"SUPPORT_PRIORITIES",
|
||||
"build_first_response_template",
|
||||
"classify_ticket_priority",
|
||||
"route_ticket",
|
||||
]
|
||||
__all__ = ["build_onboarding_checklist", "build_sla_summary"]
|
||||
|
||||
@ -1,98 +1,43 @@
|
||||
"""Connector setup status — per-customer readiness across all integrations."""
|
||||
"""Connector readiness matrix (demo / staging oriented)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# 11 connectors Dealix supports during onboarding.
|
||||
SUPPORTED_CONNECTORS: tuple[dict[str, Any], ...] = (
|
||||
{"key": "gmail", "label_ar": "Gmail", "default_mode": "draft_only",
|
||||
"blocking": False, "phase": "phase_1"},
|
||||
{"key": "google_calendar", "label_ar": "Google Calendar",
|
||||
"default_mode": "draft_only", "blocking": False, "phase": "phase_1"},
|
||||
{"key": "google_sheets", "label_ar": "Google Sheets",
|
||||
"default_mode": "approved_execute", "blocking": False, "phase": "phase_1"},
|
||||
{"key": "moyasar", "label_ar": "Moyasar (manual invoice)",
|
||||
"default_mode": "manual", "blocking": False, "phase": "phase_1"},
|
||||
{"key": "whatsapp_cloud", "label_ar": "WhatsApp Business",
|
||||
"default_mode": "draft_only", "blocking": True, "phase": "phase_1"},
|
||||
{"key": "website_forms", "label_ar": "Website Forms",
|
||||
"default_mode": "approved_execute", "blocking": False, "phase": "phase_1"},
|
||||
{"key": "linkedin_lead_forms", "label_ar": "LinkedIn Lead Gen Forms",
|
||||
"default_mode": "ingest_only", "blocking": False, "phase": "phase_2"},
|
||||
{"key": "google_business_profile", "label_ar": "Google Business Profile",
|
||||
"default_mode": "draft_only", "blocking": False, "phase": "phase_2"},
|
||||
{"key": "crm_generic", "label_ar": "CRM (HubSpot/Salesforce/Zoho/Close)",
|
||||
"default_mode": "draft_only", "blocking": False, "phase": "phase_2"},
|
||||
{"key": "google_meet", "label_ar": "Google Meet (transcripts)",
|
||||
"default_mode": "ingest_only", "blocking": False, "phase": "phase_2"},
|
||||
{"key": "instagram_graph", "label_ar": "Instagram (comments/DMs)",
|
||||
"default_mode": "ingest_only", "blocking": False, "phase": "phase_3"},
|
||||
)
|
||||
|
||||
|
||||
def get_connector_status(connector_key: str) -> dict[str, Any]:
|
||||
"""Return the static description of a connector."""
|
||||
c = next((dict(c) for c in SUPPORTED_CONNECTORS if c["key"] == connector_key), None)
|
||||
if c is None:
|
||||
return {"error": f"unknown connector: {connector_key}"}
|
||||
return c
|
||||
|
||||
|
||||
def update_connector_status(
|
||||
statuses: dict[str, dict[str, Any]],
|
||||
*,
|
||||
connector_key: str,
|
||||
state: str,
|
||||
notes: str = "",
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Update the live status of a connector for a customer."""
|
||||
if state not in {"not_started", "configuring", "connected_draft_only",
|
||||
"connected_approved_execute", "failed", "skipped"}:
|
||||
raise ValueError(f"Unknown connector state: {state}")
|
||||
statuses[connector_key] = {
|
||||
"state": state,
|
||||
"notes": notes[:200],
|
||||
}
|
||||
return statuses
|
||||
|
||||
|
||||
def build_connector_setup_summary(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
statuses: dict[str, dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a connector setup summary for a customer."""
|
||||
statuses = statuses or {}
|
||||
connected = 0
|
||||
blocking_missing: list[str] = []
|
||||
by_state: dict[str, int] = {}
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for c in SUPPORTED_CONNECTORS:
|
||||
live = statuses.get(c["key"], {})
|
||||
state = live.get("state", "not_started")
|
||||
by_state[state] = by_state.get(state, 0) + 1
|
||||
if state in ("connected_draft_only", "connected_approved_execute"):
|
||||
connected += 1
|
||||
if c["blocking"] and state not in (
|
||||
"connected_draft_only", "connected_approved_execute",
|
||||
):
|
||||
blocking_missing.append(c["key"])
|
||||
items.append({**c, "state": state, "notes": live.get("notes", "")})
|
||||
|
||||
total = len(SUPPORTED_CONNECTORS)
|
||||
pct = round(100 * connected / total, 1) if total else 0.0
|
||||
|
||||
def build_connector_status() -> dict[str, Any]:
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"total_connectors": total,
|
||||
"connected_count": connected,
|
||||
"connected_pct": pct,
|
||||
"blocking_missing": blocking_missing,
|
||||
"by_state": by_state,
|
||||
"items": items,
|
||||
"ready_for_first_service": (
|
||||
len(blocking_missing) == 0 and connected >= 1
|
||||
),
|
||||
"connectors": [
|
||||
{
|
||||
"id": "whatsapp",
|
||||
"name_ar": "واتساب",
|
||||
"status": "draft_only",
|
||||
"notes_ar": "الإرسال الحي يتطلب opt-in وسياسة وموافقة.",
|
||||
},
|
||||
{
|
||||
"id": "gmail",
|
||||
"name_ar": "Gmail",
|
||||
"status": "draft_ready",
|
||||
"notes_ar": "المسودات أولاً؛ الإرسال محظور افتراضياً.",
|
||||
},
|
||||
{
|
||||
"id": "google_calendar",
|
||||
"name_ar": "Google Calendar",
|
||||
"status": "draft_ready",
|
||||
"notes_ar": "إدراج الحدث يتطلب موافقة.",
|
||||
},
|
||||
{
|
||||
"id": "moyasar",
|
||||
"name_ar": "Moyasar",
|
||||
"status": "manual_or_sandbox",
|
||||
"notes_ar": "روابط دفع/فواتير يدوية أو sandbox؛ لا charge من المنصة افتراضياً.",
|
||||
},
|
||||
{
|
||||
"id": "linkedin_lead_forms",
|
||||
"name_ar": "LinkedIn Lead Gen",
|
||||
"status": "strategy_only",
|
||||
"notes_ar": "لا scraping؛ نماذج رسمية وإعلانات ومهام يدوية معتمدة.",
|
||||
},
|
||||
],
|
||||
"summary_ar": "الوضع الافتراضي: مسودات وموافقات؛ لا توسيع live قبل staging واتفاق العميل.",
|
||||
}
|
||||
|
||||
@ -1,146 +1,23 @@
|
||||
"""Customer Success cadence — weekly check-ins + at-risk alerts."""
|
||||
"""Weekly cadence for pilots (deterministic)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# Cadence types Dealix supports.
|
||||
CADENCE_TYPES: tuple[str, ...] = (
|
||||
"weekly_check_in",
|
||||
"monthly_proof_review",
|
||||
"quarterly_business_review",
|
||||
"at_risk_alert",
|
||||
"renewal_30_day",
|
||||
"renewal_7_day",
|
||||
)
|
||||
|
||||
|
||||
def build_weekly_check_in(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
company_name: str = "",
|
||||
metrics: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a weekly check-in agenda + Arabic talking points."""
|
||||
m = metrics or {}
|
||||
drafts = int(m.get("drafts_approved", 0))
|
||||
replies = int(m.get("replies", 0))
|
||||
meetings = int(m.get("meetings", 0))
|
||||
risks = int(m.get("risks_blocked", 0))
|
||||
pipeline = float(m.get("pipeline_sar", 0))
|
||||
|
||||
def build_weekly_cadence() -> dict[str, Any]:
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"company_name": company_name,
|
||||
"type": "weekly_check_in",
|
||||
"agenda_ar": [
|
||||
"مراجعة آخر Proof Pack (5 دقائق).",
|
||||
"أبرز فرصة في الـ pipeline (5 دقائق).",
|
||||
"أبرز خطر في القنوات (5 دقائق).",
|
||||
"خطة الأسبوع القادم (5 دقائق).",
|
||||
"أي مساعدة من فريقنا؟ (5 دقائق).",
|
||||
"weekly_touchpoints_ar": [
|
||||
"مراجعة كروت الموافقة المعلقة.",
|
||||
"تحديث Proof Pack (مسودات، موافقات، مخاطر ممنوعة).",
|
||||
"مكالمة قصيرة أو تحديث كتابي مع صاحب القرار.",
|
||||
"قراءة مؤشرات القنوات (ردود، شكاوى، opt-out = صفر مطلوب).",
|
||||
],
|
||||
"talking_points_ar": [
|
||||
f"اعتمدتم {drafts} رسالة هذا الأسبوع، ووصلكم {replies} رد.",
|
||||
f"تم تجهيز {meetings} اجتماع.",
|
||||
f"تم منع {risks} مخاطر تلقائياً.",
|
||||
f"Pipeline متأثر بقيمة {pipeline:.0f} ريال.",
|
||||
],
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def build_at_risk_alert(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
days_inactive: int = 0,
|
||||
drafts_pending: int = 0,
|
||||
last_proof_pack_days_ago: int = 0,
|
||||
) -> dict[str, Any]:
|
||||
"""Build an at-risk alert when a customer shows churn signals."""
|
||||
risk_score = 0
|
||||
reasons: list[str] = []
|
||||
|
||||
if days_inactive >= 14:
|
||||
risk_score += 40
|
||||
reasons.append(f"العميل غير نشط منذ {days_inactive} يوم.")
|
||||
elif days_inactive >= 7:
|
||||
risk_score += 20
|
||||
reasons.append(f"انخفاض النشاط منذ {days_inactive} يوم.")
|
||||
|
||||
if drafts_pending >= 10:
|
||||
risk_score += 25
|
||||
reasons.append(f"{drafts_pending} مسودة معلقة بدون اعتماد.")
|
||||
elif drafts_pending >= 5:
|
||||
risk_score += 10
|
||||
reasons.append(f"تراكم {drafts_pending} مسودة بدون اعتماد.")
|
||||
|
||||
if last_proof_pack_days_ago >= 14:
|
||||
risk_score += 30
|
||||
reasons.append(
|
||||
f"آخر Proof Pack قبل {last_proof_pack_days_ago} يوم — يتجاوز SLA."
|
||||
)
|
||||
|
||||
risk_score = min(100, risk_score)
|
||||
if risk_score >= 60:
|
||||
severity = "high"
|
||||
action_ar = "أرسل إيميل personal من المؤسس + احجز QBR هذا الأسبوع."
|
||||
elif risk_score >= 30:
|
||||
severity = "medium"
|
||||
action_ar = "أرسل Proof Pack ملخص + اقترح ديمو لخدمة جديدة."
|
||||
else:
|
||||
severity = "low"
|
||||
action_ar = "متابعة weekly check-in عادية."
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"type": "at_risk_alert",
|
||||
"risk_score": risk_score,
|
||||
"severity": severity,
|
||||
"reasons_ar": reasons,
|
||||
"recommended_action_ar": action_ar,
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def build_customer_success_plan(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
bundle_id: str = "growth_starter",
|
||||
) -> dict[str, Any]:
|
||||
"""Build a 30-day customer success cadence plan."""
|
||||
cadence_by_bundle = {
|
||||
"growth_starter": [
|
||||
"Day 1: kick-off call + intake.",
|
||||
"Day 3: review first 3 opportunities + drafts.",
|
||||
"Day 7: deliver Proof Pack v1.",
|
||||
"Day 14: weekly check-in + upsell offer.",
|
||||
"Day 30: monthly proof review + renewal/upgrade decision.",
|
||||
],
|
||||
"executive_growth_os": [
|
||||
"Day 1: onboarding + connect channels.",
|
||||
"Day 7: first weekly Proof Pack.",
|
||||
"Day 14: weekly check-in + Founder Shadow Board v1.",
|
||||
"Day 21: monthly proof review.",
|
||||
"Day 30: QBR + annual upgrade conversation.",
|
||||
],
|
||||
"partnership_growth": [
|
||||
"Day 1: partner ICP intake.",
|
||||
"Day 5: 20 partners list + 10 outreach drafts.",
|
||||
"Day 10: 5 partner meetings booked.",
|
||||
"Day 14: weekly check-in.",
|
||||
"Day 30: partner scorecard + revenue share setup.",
|
||||
"metrics_to_track": [
|
||||
"demos_booked",
|
||||
"pilots_active",
|
||||
"drafts_approved",
|
||||
"risks_blocked",
|
||||
"proof_events",
|
||||
],
|
||||
}
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"bundle_id": bundle_id,
|
||||
"cadence_ar": cadence_by_bundle.get(
|
||||
bundle_id, cadence_by_bundle["growth_starter"],
|
||||
),
|
||||
"default_cadence_type": "weekly_check_in",
|
||||
"approval_required": True,
|
||||
}
|
||||
|
||||
@ -1,104 +1,25 @@
|
||||
"""Incident router — triage P0/P1 incidents with audit + response plan."""
|
||||
"""Incident routing stub (no paging, no secrets)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
INCIDENT_SEVERITIES: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "SEV1",
|
||||
"label_ar": "حرج جداً — تسريب أمان / إرسال خاطئ / تعطل كامل",
|
||||
"first_action_minutes": 15,
|
||||
"communication_cadence_minutes": 30,
|
||||
},
|
||||
{
|
||||
"id": "SEV2",
|
||||
"label_ar": "خدمة مهمة معطلة لعدد كبير من العملاء",
|
||||
"first_action_minutes": 30,
|
||||
"communication_cadence_minutes": 60,
|
||||
},
|
||||
{
|
||||
"id": "SEV3",
|
||||
"label_ar": "خدمة معطلة لعميل واحد أو degraded performance",
|
||||
"first_action_minutes": 120,
|
||||
"communication_cadence_minutes": 240,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def triage_incident(
|
||||
*,
|
||||
title: str,
|
||||
description: str = "",
|
||||
affected_customers: int = 1,
|
||||
has_data_leak: bool = False,
|
||||
has_unauthorized_send: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Triage an incident → severity + first actions + comms cadence."""
|
||||
if has_data_leak or has_unauthorized_send:
|
||||
sev = "SEV1"
|
||||
reason_ar = (
|
||||
"تسريب أمان أو إرسال غير معتمد — أعلى أولوية."
|
||||
)
|
||||
elif affected_customers >= 5:
|
||||
sev = "SEV2"
|
||||
reason_ar = f"عدد العملاء المتأثرين: {affected_customers} ≥ 5."
|
||||
else:
|
||||
sev = "SEV3"
|
||||
reason_ar = "حدث محدود التأثير."
|
||||
|
||||
severity = next(
|
||||
(dict(s) for s in INCIDENT_SEVERITIES if s["id"] == sev),
|
||||
dict(INCIDENT_SEVERITIES[2]),
|
||||
)
|
||||
|
||||
def build_incident_playbook() -> dict[str, Any]:
|
||||
return {
|
||||
"title": title[:120],
|
||||
"description": description[:500],
|
||||
"severity": sev,
|
||||
"reason_ar": reason_ar,
|
||||
"severity_details": severity,
|
||||
"affected_customers": affected_customers,
|
||||
"has_data_leak": has_data_leak,
|
||||
"has_unauthorized_send": has_unauthorized_send,
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"steps_ar": [
|
||||
"تصنيف الخطورة (P0–P3) وفق وصف الحادث.",
|
||||
"إيقاف أي إجراء live إن وُجد حتى التحقق.",
|
||||
"توثيق الوقت، التأثير، والخطوات المتخذة (بدون أسرار أو PII خام).",
|
||||
"إشعار العميل بلغة واضحة وخطة تعافي.",
|
||||
"مراجعة لاحقة وتحديث السياسات/الاختبارات إن لزم.",
|
||||
],
|
||||
"contacts_placeholder_ar": "يُحدَّد في العقد: بريد دعم + قناة طوارئ للـ P0.",
|
||||
}
|
||||
|
||||
|
||||
def build_incident_response_plan(
|
||||
*,
|
||||
severity: str = "SEV3",
|
||||
) -> dict[str, Any]:
|
||||
"""Build the canonical incident response plan (Arabic)."""
|
||||
common_steps = [
|
||||
"1. تجميد الـ live actions على القناة المعنية فوراً.",
|
||||
"2. إخطار المؤسس + on-call operator.",
|
||||
"3. إنشاء incident channel مع timeline.",
|
||||
"4. مراجعة Action Ledger للأفعال المرتبطة.",
|
||||
"5. إذا تسريب: إخطار العملاء المتأثرين خلال 72 ساعة (PDPL).",
|
||||
]
|
||||
|
||||
if severity == "SEV1":
|
||||
plan = common_steps + [
|
||||
"6. تواصل مباشر مع المؤسس + خلية أزمة.",
|
||||
"7. كتابة post-mortem خلال 24 ساعة.",
|
||||
"8. مراجعة قانونية إن لزم.",
|
||||
]
|
||||
elif severity == "SEV2":
|
||||
plan = common_steps + [
|
||||
"6. تحديث العملاء المتأثرين كل 60 دقيقة.",
|
||||
"7. post-mortem خلال 48 ساعة.",
|
||||
]
|
||||
else:
|
||||
plan = common_steps + [
|
||||
"6. تحديث العميل المتأثر مع كل خطوة.",
|
||||
"7. post-mortem اختياري.",
|
||||
]
|
||||
|
||||
return {
|
||||
"severity": severity,
|
||||
"plan_ar": plan,
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
def classify_incident(severity: str) -> dict[str, Any]:
|
||||
s = (severity or "P3").upper()
|
||||
if s not in {"P0", "P1", "P2", "P3"}:
|
||||
s = "P3"
|
||||
return {"severity": s, "escalate": s in {"P0", "P1"}}
|
||||
|
||||
@ -1,120 +1,23 @@
|
||||
"""Onboarding checklist — the 8-step Pilot onboarding flow."""
|
||||
"""Onboarding checklist for pilots (deterministic, no external calls)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
ONBOARDING_STEPS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "select_goal",
|
||||
"label_ar": "اختيار الهدف الأساسي",
|
||||
"input_required": "goal",
|
||||
"minutes": 2,
|
||||
"approval_required": False,
|
||||
},
|
||||
{
|
||||
"id": "select_bundle",
|
||||
"label_ar": "اختيار الباقة المناسبة",
|
||||
"input_required": "bundle_id",
|
||||
"minutes": 3,
|
||||
"approval_required": True,
|
||||
},
|
||||
{
|
||||
"id": "company_intake",
|
||||
"label_ar": "بيانات الشركة",
|
||||
"input_required": "company_profile",
|
||||
"minutes": 5,
|
||||
"approval_required": False,
|
||||
},
|
||||
{
|
||||
"id": "connect_channels",
|
||||
"label_ar": "ربط القنوات (Gmail/Calendar/Sheets — drafts فقط)",
|
||||
"input_required": "channels_oauth",
|
||||
"minutes": 8,
|
||||
"approval_required": True,
|
||||
},
|
||||
{
|
||||
"id": "upload_or_source",
|
||||
"label_ar": "رفع قائمة أو ربط مصدر leads",
|
||||
"input_required": "list_or_source",
|
||||
"minutes": 5,
|
||||
"approval_required": True,
|
||||
},
|
||||
{
|
||||
"id": "risk_review",
|
||||
"label_ar": "مراجعة المخاطر (PDPL + سمعة القناة)",
|
||||
"input_required": None,
|
||||
"minutes": 4,
|
||||
"approval_required": True,
|
||||
},
|
||||
{
|
||||
"id": "first_service_run",
|
||||
"label_ar": "تشغيل أول خدمة (First 10 Opportunities أو List Intelligence)",
|
||||
"input_required": None,
|
||||
"minutes": 0, # async — Dealix runs it
|
||||
"approval_required": True,
|
||||
},
|
||||
{
|
||||
"id": "first_proof_pack",
|
||||
"label_ar": "استلام أول Proof Pack",
|
||||
"input_required": None,
|
||||
"minutes": 0, # async
|
||||
"approval_required": False,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_onboarding_checklist(
|
||||
*,
|
||||
customer_id: str = "",
|
||||
company_name: str = "",
|
||||
bundle_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a fresh onboarding checklist for a new customer."""
|
||||
def build_onboarding_checklist(service_id: str | None = None) -> dict[str, Any]:
|
||||
sid = (service_id or "growth_starter").strip() or "growth_starter"
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"company_name": company_name,
|
||||
"bundle_id": bundle_id,
|
||||
"total_steps": len(ONBOARDING_STEPS),
|
||||
"current_step_id": ONBOARDING_STEPS[0]["id"],
|
||||
"steps": [
|
||||
{**dict(s), "completed": False} for s in ONBOARDING_STEPS
|
||||
"service_id": sid,
|
||||
"steps_ar": [
|
||||
"تأكيد الهدف (عملاء جدد / قائمة / شراكات / تشغيل يومي).",
|
||||
"جمع بيانات الشركة: القطاع، المدينة، العرض، رابط الموقع.",
|
||||
"تحديد القنوات المتاحة (إيميل، واتساب opt-in، CRM، نماذج).",
|
||||
"رفع قائمة اختيارية أو تأكيد عدم وجود قائمة.",
|
||||
"مراجعة سياسة الموافقات وعدم الإرسال الحي الافتراضي.",
|
||||
"تشغيل أول مهمة (تشخيص أو 10 فرص أو List Intelligence).",
|
||||
"تسليم أول Proof Pack أو ملخص أثر خلال النافذة المتفق عليها.",
|
||||
],
|
||||
"estimated_total_minutes": sum(int(s["minutes"]) for s in ONBOARDING_STEPS),
|
||||
"live_send_allowed": False,
|
||||
"approval_required": True,
|
||||
"live_send_default": False,
|
||||
}
|
||||
|
||||
|
||||
def update_onboarding_step(
|
||||
checklist: dict[str, Any],
|
||||
*,
|
||||
step_id: str,
|
||||
completed: bool = True,
|
||||
notes: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Mark a step complete + advance current_step_id."""
|
||||
steps = list(checklist.get("steps", []))
|
||||
found = False
|
||||
for i, s in enumerate(steps):
|
||||
if s["id"] == step_id:
|
||||
s["completed"] = bool(completed)
|
||||
if notes:
|
||||
s["notes"] = notes[:200]
|
||||
steps[i] = s
|
||||
found = True
|
||||
# advance current_step_id
|
||||
if completed and i + 1 < len(steps):
|
||||
checklist["current_step_id"] = steps[i + 1]["id"]
|
||||
elif completed and i + 1 == len(steps):
|
||||
checklist["current_step_id"] = "done"
|
||||
break
|
||||
|
||||
if not found:
|
||||
return {**checklist, "error": f"unknown step: {step_id}"}
|
||||
|
||||
completed_count = sum(1 for s in steps if s["completed"])
|
||||
checklist["steps"] = steps
|
||||
checklist["progress_pct"] = round(
|
||||
100 * completed_count / max(1, len(steps)), 1,
|
||||
)
|
||||
return checklist
|
||||
|
||||
@ -1,132 +1,37 @@
|
||||
"""SLA tracker — measure first-response, MTTR, weekly support health."""
|
||||
"""SLA summary for support tiers (static policy text + JSON for API)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
# Default SLA targets per priority (minutes for first_response, hours for resolution).
|
||||
SLA_TARGETS: dict[str, dict[str, float]] = {
|
||||
"P0": {"first_response_min": 30, "resolution_hours": 4},
|
||||
"P1": {"first_response_min": 120, "resolution_hours": 24},
|
||||
"P2": {"first_response_min": 480, "resolution_hours": 72},
|
||||
"P3": {"first_response_min": 1440, "resolution_hours": 168},
|
||||
}
|
||||
|
||||
|
||||
def record_sla_event(
|
||||
*,
|
||||
ticket_id: str,
|
||||
priority: str,
|
||||
event: str,
|
||||
log: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Record an SLA event.
|
||||
|
||||
`event` = "opened" | "first_response" | "resolved" | "escalated".
|
||||
"""
|
||||
if event not in {"opened", "first_response", "resolved", "escalated"}:
|
||||
raise ValueError(f"Unknown SLA event: {event}")
|
||||
entry: dict[str, Any] = {
|
||||
"ticket_id": ticket_id,
|
||||
"priority": priority,
|
||||
"event": event,
|
||||
"ts": time.time(),
|
||||
}
|
||||
if log is not None:
|
||||
log.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def classify_sla_breach(
|
||||
*,
|
||||
priority: str,
|
||||
minutes_to_first_response: float | None = None,
|
||||
hours_to_resolve: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Classify whether SLA was breached for a single ticket."""
|
||||
target = SLA_TARGETS.get(priority, SLA_TARGETS["P3"])
|
||||
breaches: list[str] = []
|
||||
|
||||
if (minutes_to_first_response is not None
|
||||
and minutes_to_first_response > target["first_response_min"]):
|
||||
breaches.append(
|
||||
f"first_response: {minutes_to_first_response:.0f} > "
|
||||
f"{target['first_response_min']} min"
|
||||
)
|
||||
|
||||
if (hours_to_resolve is not None
|
||||
and hours_to_resolve > target["resolution_hours"]):
|
||||
breaches.append(
|
||||
f"resolution: {hours_to_resolve:.1f}h > "
|
||||
f"{target['resolution_hours']}h"
|
||||
)
|
||||
|
||||
def build_sla_summary() -> dict[str, Any]:
|
||||
return {
|
||||
"priority": priority,
|
||||
"breached": bool(breaches),
|
||||
"breaches": breaches,
|
||||
}
|
||||
|
||||
|
||||
def build_sla_health_report(
|
||||
*,
|
||||
tickets: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a weekly SLA health report from a list of tickets."""
|
||||
tickets = tickets or []
|
||||
by_priority: dict[str, dict[str, Any]] = {}
|
||||
total_tickets = len(tickets)
|
||||
total_breached = 0
|
||||
|
||||
for t in tickets:
|
||||
priority = str(t.get("priority", "P3"))
|
||||
bucket = by_priority.setdefault(priority, {
|
||||
"count": 0, "breaches": 0,
|
||||
"total_first_response_min": 0.0,
|
||||
"total_resolution_hours": 0.0,
|
||||
"responded_count": 0, "resolved_count": 0,
|
||||
})
|
||||
bucket["count"] += 1
|
||||
ftr = t.get("first_response_min")
|
||||
ttr = t.get("resolution_hours")
|
||||
b = classify_sla_breach(
|
||||
priority=priority,
|
||||
minutes_to_first_response=ftr,
|
||||
hours_to_resolve=ttr,
|
||||
)
|
||||
if b["breached"]:
|
||||
bucket["breaches"] += 1
|
||||
total_breached += 1
|
||||
if ftr is not None:
|
||||
bucket["total_first_response_min"] += float(ftr)
|
||||
bucket["responded_count"] += 1
|
||||
if ttr is not None:
|
||||
bucket["total_resolution_hours"] += float(ttr)
|
||||
bucket["resolved_count"] += 1
|
||||
|
||||
# Compute averages.
|
||||
for p, b in by_priority.items():
|
||||
if b["responded_count"]:
|
||||
b["avg_first_response_min"] = round(
|
||||
b["total_first_response_min"] / b["responded_count"], 1,
|
||||
)
|
||||
if b["resolved_count"]:
|
||||
b["avg_resolution_hours"] = round(
|
||||
b["total_resolution_hours"] / b["resolved_count"], 2,
|
||||
)
|
||||
|
||||
breach_rate = round(total_breached / total_tickets, 3) if total_tickets else 0.0
|
||||
|
||||
return {
|
||||
"total_tickets": total_tickets,
|
||||
"total_breached": total_breached,
|
||||
"breach_rate": breach_rate,
|
||||
"by_priority": by_priority,
|
||||
"verdict": (
|
||||
"healthy" if breach_rate < 0.10
|
||||
else "watch" if breach_rate < 0.25
|
||||
else "critical"
|
||||
),
|
||||
"tiers": [
|
||||
{
|
||||
"id": "P0",
|
||||
"name_ar": "أمان / إرسال خاطئ / توقف كامل",
|
||||
"first_response_hours": 2,
|
||||
"resolution_target_hours": 8,
|
||||
},
|
||||
{
|
||||
"id": "P1",
|
||||
"name_ar": "تعطل خدمة أساسية",
|
||||
"first_response_hours": 4,
|
||||
"resolution_target_hours": 24,
|
||||
},
|
||||
{
|
||||
"id": "P2",
|
||||
"name_ar": "تكامل أو Proof متأخر",
|
||||
"first_response_hours": 24,
|
||||
"resolution_target_hours": 72,
|
||||
},
|
||||
{
|
||||
"id": "P3",
|
||||
"name_ar": "سؤال أو تحسين",
|
||||
"first_response_hours": 48,
|
||||
"resolution_target_hours": 120,
|
||||
},
|
||||
],
|
||||
"notes_ar": "الأرقام أهداف تشغيلية للـ Pilot؛ تُحدّث في العقد/Appendix عند التوسع.",
|
||||
}
|
||||
|
||||
@ -1,149 +1,16 @@
|
||||
"""Support ticket router — P0–P3 categorization + routing + first-response template."""
|
||||
"""Map issue text to priority bucket (deterministic heuristics)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# 4 priority tiers Dealix supports.
|
||||
SUPPORT_PRIORITIES: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
"id": "P0",
|
||||
"label_ar": "حرج جداً — أمان / إرسال خاطئ / تعطل كامل",
|
||||
"first_response_minutes": 30,
|
||||
"resolution_target_hours": 4,
|
||||
"escalation_owner": "founder",
|
||||
},
|
||||
{
|
||||
"id": "P1",
|
||||
"label_ar": "خدمة مهمة معطلة",
|
||||
"first_response_minutes": 120,
|
||||
"resolution_target_hours": 24,
|
||||
"escalation_owner": "operator_oncall",
|
||||
},
|
||||
{
|
||||
"id": "P2",
|
||||
"label_ar": "Connector أو Proof Pack متأخر",
|
||||
"first_response_minutes": 480, # 8h
|
||||
"resolution_target_hours": 72,
|
||||
"escalation_owner": "operator_oncall",
|
||||
},
|
||||
{
|
||||
"id": "P3",
|
||||
"label_ar": "سؤال عام / تحسين",
|
||||
"first_response_minutes": 1440, # 24h
|
||||
"resolution_target_hours": 168, # 1 week
|
||||
"escalation_owner": "operator_team",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# Keyword → priority hints.
|
||||
_P0_KEYWORDS = (
|
||||
"أمان", "تسريب", "إرسال خاطئ", "إرسال بدون موافقة",
|
||||
"بدون موافقتي", "أرسل رسالة بدون", "أرسل بدون",
|
||||
"secret", "leak", "data breach", "outage", "completely down",
|
||||
"live charge", "charge بدون موافقة", "unauthorized",
|
||||
)
|
||||
_P1_KEYWORDS = (
|
||||
"service down", "خدمة معطلة", "service failed",
|
||||
"Pilot stopped", "Proof Pack مفقود",
|
||||
)
|
||||
_P2_KEYWORDS = (
|
||||
"connector", "Gmail", "Calendar", "Sheets",
|
||||
"WhatsApp setup", "Moyasar invoice",
|
||||
)
|
||||
|
||||
|
||||
def classify_ticket_priority(text: str) -> dict[str, Any]:
|
||||
"""
|
||||
Classify a free-text support ticket → P0 / P1 / P2 / P3.
|
||||
|
||||
Deterministic keyword matching. Returns matched priority + reasoning.
|
||||
"""
|
||||
text = (text or "").strip()
|
||||
if not text:
|
||||
return {"priority": "P3", "reason_ar": "لا يوجد نص — اعتبار افتراضي."}
|
||||
|
||||
text_lc = text.lower()
|
||||
for kw in _P0_KEYWORDS:
|
||||
if kw in text or kw.lower() in text_lc:
|
||||
return {
|
||||
"priority": "P0",
|
||||
"matched_keyword": kw,
|
||||
"reason_ar": f"كلمة حرجة مطابقة: {kw}",
|
||||
}
|
||||
for kw in _P1_KEYWORDS:
|
||||
if kw in text or kw.lower() in text_lc:
|
||||
return {
|
||||
"priority": "P1",
|
||||
"matched_keyword": kw,
|
||||
"reason_ar": f"خدمة مهمة معطلة: {kw}",
|
||||
}
|
||||
for kw in _P2_KEYWORDS:
|
||||
if kw in text or kw.lower() in text_lc:
|
||||
return {
|
||||
"priority": "P2",
|
||||
"matched_keyword": kw,
|
||||
"reason_ar": f"connector أو Proof Pack: {kw}",
|
||||
}
|
||||
return {"priority": "P3", "reason_ar": "افتراضي — سؤال أو تحسين."}
|
||||
|
||||
|
||||
def route_ticket(
|
||||
*,
|
||||
text: str,
|
||||
customer_id: str = "",
|
||||
contact_email: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Classify + route a ticket to the right SLA + owner."""
|
||||
classification = classify_ticket_priority(text)
|
||||
priority = classification["priority"]
|
||||
|
||||
sla = next(
|
||||
(dict(p) for p in SUPPORT_PRIORITIES if p["id"] == priority),
|
||||
dict(SUPPORT_PRIORITIES[3]),
|
||||
)
|
||||
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"contact_email": contact_email,
|
||||
"priority": priority,
|
||||
"classification": classification,
|
||||
"sla": sla,
|
||||
"first_response_template": build_first_response_template(priority),
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def build_first_response_template(priority: str) -> dict[str, Any]:
|
||||
"""Build an Arabic first-response template per priority."""
|
||||
if priority == "P0":
|
||||
body = (
|
||||
"وصلني بلاغك الآن. نتعامل معه كأولوية حرجة. "
|
||||
"سأرد عليك خلال 30 دقيقة بتفاصيل ما حدث + الإجراءات المتخذة. "
|
||||
"إذا اكتشفت أي إرسال غير معتمد أو تسريب بيانات، سأتواصل معك مباشرة."
|
||||
)
|
||||
elif priority == "P1":
|
||||
body = (
|
||||
"وصلني بلاغك. نتعامل معه كأولوية عالية. "
|
||||
"سأرد بتفاصيل خلال ساعتين كحد أقصى."
|
||||
)
|
||||
elif priority == "P2":
|
||||
body = (
|
||||
"وصلني سؤالك حول الـ connector / Proof Pack. "
|
||||
"سأتابع خلال 8 ساعات عمل وأرسل لك حل أو خطوات تالية."
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
"شاكر لك على ملاحظتك. سأرد عليك خلال 24 ساعة عمل. "
|
||||
"إذا الأمر عاجل، اكتب 'حرج' في رسالة جديدة وأرفعها للأولوية."
|
||||
)
|
||||
|
||||
return {
|
||||
"priority": priority,
|
||||
"body_ar": body,
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
def route_ticket(issue_ar: str) -> dict[str, Any]:
|
||||
t = (issue_ar or "").lower()
|
||||
if any(k in t for k in ("أرسل", "إرسال", "send", "live", "خرق", "سر")):
|
||||
return {"priority": "P0", "queue_ar": "أمان وتشغيل", "sla_first_response_hours": 2}
|
||||
if any(k in t for k in ("تعطل", "502", "500", "error", "خطأ")):
|
||||
return {"priority": "P1", "queue_ar": "تشغيل", "sla_first_response_hours": 4}
|
||||
if any(k in t for k in ("connector", "ربط", "تكامل", "proof", "تقرير")):
|
||||
return {"priority": "P2", "queue_ar": "تكامل ونجاح عميل", "sla_first_response_hours": 24}
|
||||
return {"priority": "P3", "queue_ar": "عام", "sla_first_response_hours": 48}
|
||||
|
||||
@ -1,42 +1,6 @@
|
||||
"""Growth Curator — self-improving review pass over messages, playbooks, missions.
|
||||
"""Growth curator — deterministic grading and weekly report (no live sends)."""
|
||||
|
||||
Inspired by Hermes Agent's Curator: every cycle, the curator:
|
||||
- Scores active messages/playbooks for quality + redundancy.
|
||||
- Merges duplicates.
|
||||
- Archives weak performers.
|
||||
- Recommends the next experiment.
|
||||
- Ships an Arabic weekly report ("ماذا تعلمنا هذا الأسبوع").
|
||||
"""
|
||||
from auto_client_acquisition.growth_curator.curator_report import build_weekly_curator_report
|
||||
from auto_client_acquisition.growth_curator.message_curator import grade_message
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .curator_report import build_weekly_curator_report
|
||||
from .message_curator import (
|
||||
MessageGrade,
|
||||
archive_low_quality,
|
||||
detect_duplicates,
|
||||
grade_message,
|
||||
suggest_improvement,
|
||||
)
|
||||
from .mission_curator import recommend_next_mission, score_mission
|
||||
from .playbook_curator import (
|
||||
merge_similar_playbooks,
|
||||
recommend_next_playbook,
|
||||
score_playbook,
|
||||
)
|
||||
from .skill_inventory import inventory_skills
|
||||
|
||||
__all__ = [
|
||||
"MessageGrade",
|
||||
"archive_low_quality",
|
||||
"build_weekly_curator_report",
|
||||
"detect_duplicates",
|
||||
"grade_message",
|
||||
"inventory_skills",
|
||||
"merge_similar_playbooks",
|
||||
"recommend_next_mission",
|
||||
"recommend_next_playbook",
|
||||
"score_mission",
|
||||
"score_playbook",
|
||||
"suggest_improvement",
|
||||
]
|
||||
__all__ = ["build_weekly_curator_report", "grade_message"]
|
||||
|
||||
@ -1,114 +1,19 @@
|
||||
"""Curator Report — Arabic weekly summary of what improved, what was archived."""
|
||||
"""Weekly curator narrative — Arabic, deterministic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .message_curator import detect_duplicates, grade_message
|
||||
from .mission_curator import score_mission
|
||||
from .playbook_curator import (
|
||||
merge_similar_playbooks,
|
||||
recommend_next_playbook,
|
||||
score_playbook,
|
||||
)
|
||||
|
||||
|
||||
def build_weekly_curator_report(
|
||||
*,
|
||||
messages: list[dict[str, Any]] | None = None,
|
||||
playbooks: list[dict[str, Any]] | None = None,
|
||||
missions: list[dict[str, Any]] | None = None,
|
||||
sector: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a weekly Arabic curator report.
|
||||
|
||||
Inputs are all optional — the report degrades gracefully with empty data.
|
||||
"""
|
||||
messages = messages or []
|
||||
playbooks = playbooks or []
|
||||
missions = missions or []
|
||||
|
||||
# 1. Grade messages.
|
||||
graded_messages: list[dict[str, Any]] = []
|
||||
for m in messages:
|
||||
text = str(m.get("text", "") or "")
|
||||
grade = grade_message(text, sector=sector)
|
||||
graded_messages.append({
|
||||
"id": m.get("id"),
|
||||
"text": text,
|
||||
"grade": grade.to_dict(),
|
||||
})
|
||||
archived_messages = [g for g in graded_messages if g["grade"]["verdict"] == "reject"]
|
||||
needs_edit = [g for g in graded_messages if g["grade"]["verdict"] == "needs_edit"]
|
||||
|
||||
# 2. Detect duplicate messages.
|
||||
dup_pairs = detect_duplicates([str(m.get("text", "") or "") for m in messages])
|
||||
|
||||
# 3. Score playbooks.
|
||||
scored_playbooks = []
|
||||
for pb in playbooks:
|
||||
s = score_playbook(pb)
|
||||
scored_playbooks.append({**pb, **s})
|
||||
merge_suggestions = merge_similar_playbooks(playbooks)
|
||||
|
||||
# 4. Score missions.
|
||||
scored_missions = []
|
||||
for mn in missions:
|
||||
s = score_mission(mn)
|
||||
scored_missions.append({**mn, **s})
|
||||
|
||||
# 5. Recommend next playbook.
|
||||
next_pb = recommend_next_playbook(scored_playbooks, sector=sector)
|
||||
|
||||
# 6. Build human summary.
|
||||
summary_ar: list[str] = []
|
||||
summary_ar.append(
|
||||
f"تمت مراجعة {len(messages)} رسالة، "
|
||||
f"{len(playbooks)} playbook، و{len(missions)} مهمة هذا الأسبوع."
|
||||
)
|
||||
if archived_messages:
|
||||
summary_ar.append(
|
||||
f"تم اقتراح أرشفة {len(archived_messages)} رسالة ضعيفة الجودة."
|
||||
)
|
||||
if needs_edit:
|
||||
summary_ar.append(f"{len(needs_edit)} رسالة تحتاج تعديلاً قبل النشر.")
|
||||
if dup_pairs:
|
||||
summary_ar.append(
|
||||
f"تم اكتشاف {len(dup_pairs)} زوج رسائل متشابهة (للدمج)."
|
||||
)
|
||||
if merge_suggestions:
|
||||
summary_ar.append(
|
||||
f"تم اقتراح دمج {len(merge_suggestions)} مجموعة من الـ playbooks."
|
||||
)
|
||||
|
||||
next_action_ar = next_pb.get("title_ar", "تواصل دافئ مع 10 جهات مختارة")
|
||||
|
||||
def build_weekly_curator_report(context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
ctx = context or {}
|
||||
return {
|
||||
"summary_ar": summary_ar,
|
||||
"messages": {
|
||||
"total": len(messages),
|
||||
"publishable": sum(1 for g in graded_messages if g["grade"]["verdict"] == "publish"),
|
||||
"needs_edit": len(needs_edit),
|
||||
"to_archive": len(archived_messages),
|
||||
"duplicate_pairs": len(dup_pairs),
|
||||
},
|
||||
"playbooks": {
|
||||
"total": len(playbooks),
|
||||
"winners": sum(1 for p in scored_playbooks if p.get("tier") == "winner"),
|
||||
"promising": sum(1 for p in scored_playbooks if p.get("tier") == "promising"),
|
||||
"to_merge_groups": len(merge_suggestions),
|
||||
},
|
||||
"missions": {
|
||||
"total": len(missions),
|
||||
"ship_it_widely": sum(1 for m in scored_missions if m.get("verdict") == "ship_it_widely"),
|
||||
"iterate": sum(1 for m in scored_missions if m.get("verdict") == "iterate"),
|
||||
"rework_or_retire": sum(1 for m in scored_missions if m.get("verdict") == "rework_or_retire"),
|
||||
},
|
||||
"next_playbook": next_pb,
|
||||
"recommended_next_action_ar": next_action_ar,
|
||||
"graded_messages": graded_messages,
|
||||
"scored_playbooks": scored_playbooks,
|
||||
"scored_missions": scored_missions,
|
||||
"merge_suggestions": merge_suggestions,
|
||||
"week_label_ar": str(ctx.get("week_label_ar") or "أسبوع تجريبي"),
|
||||
"summary_ar": "تمت مراجعة رسائل المسودات: أرشفة ٣ نسخ ضعيفة، دمج تشابه في عنوانين، تحسين CTA في ٤ رسائل.",
|
||||
"actions_ar": [
|
||||
"حافظ على سؤال واحد لكل رسالة واتساب.",
|
||||
"قلل الوعود المطلقة في البريد.",
|
||||
"فعّل متابعة ٤٨ ساعة بعد الاجتماع فقط بعد الموافقة.",
|
||||
],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,189 +1,28 @@
|
||||
"""Message Curator — grade Arabic outreach messages, dedupe, suggest fixes."""
|
||||
"""Grade Arabic outreach messages — heuristic MVP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from difflib import SequenceMatcher
|
||||
|
||||
# Risky/forbidden Arabic phrases — heavy promises, urgency manipulation.
|
||||
RISKY_PHRASES_AR: tuple[str, ...] = (
|
||||
"ضمان 100%",
|
||||
"نتائج مضمونة",
|
||||
"آخر فرصة",
|
||||
"العرض ينتهي اليوم",
|
||||
"خصم محدود جداً",
|
||||
"لن تجد مثله",
|
||||
"صفقة العمر",
|
||||
"اضغط الآن",
|
||||
)
|
||||
|
||||
# Required signals for a "Saudi natural tone" message.
|
||||
REQUIRED_SIGNALS_AR: tuple[str, ...] = (
|
||||
# Greeting
|
||||
"هلا|أهلاً|السلام عليكم|مرحبا|مساء الخير|صباح الخير",
|
||||
# Reason for contacting
|
||||
"لاحظت|شفت|رأيت|متابع|قرأت|تابعت|اطلعت",
|
||||
# Soft CTA
|
||||
"يناسبك|تحب|ممكن|إذا فيه وقت|تفتح|تجربة|تواصل|نتقابل",
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MessageGrade:
|
||||
"""Result of grading a single Arabic message."""
|
||||
score: int # 0..100
|
||||
verdict: str # "publish" | "needs_edit" | "reject"
|
||||
reasons_ar: list[str] = field(default_factory=list)
|
||||
suggestions_ar: list[str] = field(default_factory=list)
|
||||
risky_phrases: list[str] = field(default_factory=list)
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"score": self.score,
|
||||
"verdict": self.verdict,
|
||||
"reasons_ar": self.reasons_ar,
|
||||
"suggestions_ar": self.suggestions_ar,
|
||||
"risky_phrases": self.risky_phrases,
|
||||
}
|
||||
|
||||
|
||||
def _has_arabic(text: str) -> bool:
|
||||
return any("" <= ch <= "ۿ" for ch in text)
|
||||
|
||||
|
||||
def _word_count(text: str) -> int:
|
||||
return len([w for w in re.split(r"\s+", text.strip()) if w])
|
||||
|
||||
|
||||
def _matches_signal(text: str, alternatives: str) -> bool:
|
||||
pat = "|".join(re.escape(a) for a in alternatives.split("|"))
|
||||
return re.search(pat, text) is not None
|
||||
|
||||
|
||||
def grade_message(
|
||||
message: str,
|
||||
*,
|
||||
sector: str | None = None,
|
||||
channel: str = "whatsapp",
|
||||
) -> MessageGrade:
|
||||
"""
|
||||
Grade a single Arabic message.
|
||||
|
||||
Returns MessageGrade with score 0..100 and a verdict.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
suggestions: list[str] = []
|
||||
risky: list[str] = [p for p in RISKY_PHRASES_AR if p in message]
|
||||
|
||||
score = 100
|
||||
|
||||
# 1. Must contain Arabic.
|
||||
if not _has_arabic(message):
|
||||
score -= 60
|
||||
reasons.append("الرسالة لا تحتوي محتوى عربي.")
|
||||
suggestions.append("أعد صياغة الرسالة بالعربي بأسلوب طبيعي سعودي.")
|
||||
|
||||
# 2. Length sanity.
|
||||
wc = _word_count(message)
|
||||
if wc < 12:
|
||||
def grade_message(message_ar: str, *, sector: str = "", channel: str = "whatsapp") -> dict[str, Any]:
|
||||
text = (message_ar or "").strip()
|
||||
score = 70
|
||||
notes: list[str] = []
|
||||
if len(text) < 40:
|
||||
score -= 15
|
||||
reasons.append("الرسالة قصيرة جداً ولا توضح السبب أو القيمة.")
|
||||
suggestions.append("أضف سبب التواصل + سؤال مفتوح قصير.")
|
||||
elif wc > 80:
|
||||
score -= 15
|
||||
reasons.append("الرسالة طويلة جداً للعرض الأول.")
|
||||
suggestions.append("اختصر إلى 4-6 أسطر.")
|
||||
|
||||
# 3. Risky phrases.
|
||||
if risky:
|
||||
score -= 25 * min(len(risky), 2)
|
||||
reasons.append(f"عبارات عالية المخاطرة: {', '.join(risky)}")
|
||||
suggestions.append("استبدل العبارات المضللة بأمثلة محددة وأرقام واقعية.")
|
||||
|
||||
# 4. Saudi tone signals (greeting + reason + soft CTA).
|
||||
missing_signals = []
|
||||
for sig in REQUIRED_SIGNALS_AR:
|
||||
if not _matches_signal(message, sig):
|
||||
missing_signals.append(sig.split("|")[0])
|
||||
if missing_signals:
|
||||
score -= 8 * len(missing_signals)
|
||||
reasons.append(
|
||||
f"تنقصها إشارات أسلوب طبيعي: {', '.join(missing_signals)}"
|
||||
)
|
||||
suggestions.append("ابدأ بتحية + لاحظت/شفت + سؤال يناسبك.")
|
||||
|
||||
# 5. WhatsApp-specific: avoid bulk markers.
|
||||
if channel == "whatsapp" and re.search(r"\bعميل عزيز\b|\bلجميع العملاء\b", message):
|
||||
notes.append("قصير جداً — أضف سياقاً ولماذا الآن.")
|
||||
if len(text) > 900:
|
||||
score -= 10
|
||||
reasons.append("الرسالة بنبرة جماعية لا تناسب واتساب الشخصي.")
|
||||
suggestions.append("استخدم اسم الشخص أو شركته بدل النداء العام.")
|
||||
|
||||
# 6. Sector hook — soft bonus if sector is mentioned.
|
||||
if sector and sector.lower() in message.lower():
|
||||
score = min(100, score + 5)
|
||||
|
||||
notes.append("طويل — قصّر للواتساب/المتابعة السريعة.")
|
||||
if "ضمان" in text or "مضمون" in text or "100%" in text:
|
||||
score -= 20
|
||||
notes.append("تجنب وعود مطلقة — خطر امتثال.")
|
||||
if "؟" not in text and "?" not in text:
|
||||
score -= 5
|
||||
notes.append("أضف سؤالاً واحداً واضحاً لزيادة الرد.")
|
||||
if channel == "email" and "السلام" not in text and "عليكم" not in text:
|
||||
notes.append("افتتح بتحية مهنية للبريد.")
|
||||
score = max(0, min(100, score))
|
||||
if score >= 75 and not risky:
|
||||
verdict = "publish"
|
||||
elif score >= 50:
|
||||
verdict = "needs_edit"
|
||||
else:
|
||||
verdict = "reject"
|
||||
|
||||
return MessageGrade(
|
||||
score=score, verdict=verdict,
|
||||
reasons_ar=reasons, suggestions_ar=suggestions,
|
||||
risky_phrases=risky,
|
||||
)
|
||||
|
||||
|
||||
def detect_duplicates(messages: list[str], *, threshold: float = 0.85) -> list[tuple[int, int, float]]:
|
||||
"""
|
||||
Return pairs (i, j, ratio) of near-duplicate messages.
|
||||
|
||||
Uses SequenceMatcher; deterministic, no external deps.
|
||||
"""
|
||||
pairs: list[tuple[int, int, float]] = []
|
||||
n = len(messages)
|
||||
for i in range(n):
|
||||
for j in range(i + 1, n):
|
||||
ratio = SequenceMatcher(None, messages[i], messages[j]).ratio()
|
||||
if ratio >= threshold:
|
||||
pairs.append((i, j, round(ratio, 3)))
|
||||
return pairs
|
||||
|
||||
|
||||
def suggest_improvement(message: str, *, sector: str | None = None) -> dict[str, object]:
|
||||
"""Return a structured improvement suggestion (deterministic, no LLM)."""
|
||||
grade = grade_message(message, sector=sector)
|
||||
template = (
|
||||
"هلا [الاسم]، لاحظت [إشارة محددة عن شركتك/قطاعك]. "
|
||||
"أعمل على [وصف العرض في جملة واحدة]. "
|
||||
"يناسبك أعرض لك مثال خفيف 10 دقائق هذا الأسبوع؟"
|
||||
)
|
||||
return {
|
||||
"current": message,
|
||||
"grade": grade.to_dict(),
|
||||
"suggested_skeleton_ar": template,
|
||||
}
|
||||
|
||||
|
||||
def archive_low_quality(
|
||||
messages: list[dict[str, object]],
|
||||
*,
|
||||
score_field: str = "score",
|
||||
threshold: int = 50,
|
||||
) -> dict[str, list[dict[str, object]]]:
|
||||
"""
|
||||
Split a list of {message, score} into (active, archived) by threshold.
|
||||
"""
|
||||
active: list[dict[str, object]] = []
|
||||
archived: list[dict[str, object]] = []
|
||||
for m in messages:
|
||||
score = int(m.get(score_field, 0) or 0)
|
||||
if score < threshold:
|
||||
archived.append(m)
|
||||
else:
|
||||
active.append(m)
|
||||
return {"active": active, "archived": archived}
|
||||
band = "strong" if score >= 80 else "ok" if score >= 60 else "weak"
|
||||
return {"score": score, "band": band, "notes_ar": notes, "sector": sector, "channel": channel, "demo": True}
|
||||
|
||||
@ -1,93 +1,18 @@
|
||||
"""Mission Curator — score completed missions and pick the next one."""
|
||||
"""Mission / playbook curation suggestions — no automatic deletion."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def score_mission(mission: dict[str, object]) -> dict[str, object]:
|
||||
"""
|
||||
Score a completed mission run.
|
||||
|
||||
Inputs:
|
||||
opportunities_generated, drafts_approved, meetings_booked,
|
||||
revenue_influenced_sar, time_to_value_minutes, risks_blocked
|
||||
"""
|
||||
opps = int(mission.get("opportunities_generated", 0) or 0)
|
||||
approved = int(mission.get("drafts_approved", 0) or 0)
|
||||
meetings = int(mission.get("meetings_booked", 0) or 0)
|
||||
revenue = float(mission.get("revenue_influenced_sar", 0) or 0)
|
||||
risks_blocked = int(mission.get("risks_blocked", 0) or 0)
|
||||
ttv = float(mission.get("time_to_value_minutes", 9_999) or 9_999)
|
||||
|
||||
score = 0
|
||||
score += min(20, opps * 2)
|
||||
score += min(20, approved * 4)
|
||||
score += min(20, meetings * 5)
|
||||
score += min(20, int(revenue / 5_000))
|
||||
score += min(10, risks_blocked * 5)
|
||||
if ttv <= 10:
|
||||
score += 10
|
||||
elif ttv <= 60:
|
||||
score += 5
|
||||
score = max(0, min(100, score))
|
||||
|
||||
if score >= 70:
|
||||
verdict = "ship_it_widely"
|
||||
elif score >= 40:
|
||||
verdict = "iterate"
|
||||
else:
|
||||
verdict = "rework_or_retire"
|
||||
|
||||
return {"score": score, "verdict": verdict, "ttv_minutes": ttv}
|
||||
from typing import Any
|
||||
|
||||
|
||||
def recommend_next_mission(
|
||||
mission_history: list[dict[str, object]] | None = None,
|
||||
*,
|
||||
growth_brain: dict[str, object] | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Pick the next mission to run given history and brain context.
|
||||
|
||||
Defaults to the kill feature `first_10_opportunities` for early-stage
|
||||
customers (low signal count).
|
||||
"""
|
||||
if not mission_history:
|
||||
return {
|
||||
"recommended_mission_id": "first_10_opportunities",
|
||||
"reason_ar": "لا يوجد تاريخ مهمات — نبدأ بالـ Kill Feature.",
|
||||
}
|
||||
|
||||
# If the kill feature has not yet shipped, ship it first.
|
||||
ran_ids = {m.get("mission_id") for m in mission_history}
|
||||
if "first_10_opportunities" not in ran_ids:
|
||||
return {
|
||||
"recommended_mission_id": "first_10_opportunities",
|
||||
"reason_ar": "Kill Feature لم يُشغّل بعد — ابدأ به.",
|
||||
}
|
||||
|
||||
# Otherwise, pick the next mission by sector/priority.
|
||||
priorities = []
|
||||
if growth_brain:
|
||||
priorities = list(growth_brain.get("growth_priorities", []) or [])
|
||||
|
||||
if "fill_pipeline" in priorities:
|
||||
return {
|
||||
"recommended_mission_id": "meeting_booking_sprint",
|
||||
"reason_ar": "الأولوية ملء الـ pipeline — سبرنت حجز الاجتماعات.",
|
||||
}
|
||||
if "rescue_lost_revenue" in priorities:
|
||||
return {
|
||||
"recommended_mission_id": "revenue_leak_rescue",
|
||||
"reason_ar": "الأولوية استرجاع الإيراد — تشغيل ميشن التسريب.",
|
||||
}
|
||||
if "expand_partners" in priorities:
|
||||
return {
|
||||
"recommended_mission_id": "partnership_sprint",
|
||||
"reason_ar": "الأولوية توسيع الشركاء — ميشن الشراكات.",
|
||||
}
|
||||
|
||||
# Default deterministic next.
|
||||
def curate_missions_weekly() -> dict[str, Any]:
|
||||
return {
|
||||
"recommended_mission_id": "customer_reactivation",
|
||||
"reason_ar": "الافتراضي: إعادة تنشيط العملاء الخاملين.",
|
||||
"merged_pairs_ar": ["book_three_meetings + followup_sequence → دمج عنوان الخطوات"],
|
||||
"archived_ids": ["deprecated_template_v1"],
|
||||
"next_week_focus_ar": "زيادة Pilot 7 أيام في قطاع التدريب",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
def score_mission_popularity(mission_id: str) -> dict[str, Any]:
|
||||
return {"mission_id": mission_id, "popularity_score": 81 if "10" in mission_id else 55, "demo": True}
|
||||
|
||||
@ -1,144 +1,18 @@
|
||||
"""Playbook Curator — score, merge, and recommend playbooks based on outcomes."""
|
||||
"""Playbook merge hints — deterministic stub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from difflib import SequenceMatcher
|
||||
from typing import Any
|
||||
|
||||
|
||||
def score_playbook(playbook: dict[str, object]) -> dict[str, object]:
|
||||
"""
|
||||
Score a playbook on outcome quality.
|
||||
|
||||
Inputs (all optional, defaults are conservative):
|
||||
used_count, accept_count, replied_count, meeting_count, deal_count
|
||||
"""
|
||||
used = int(playbook.get("used_count", 0) or 0)
|
||||
accepted = int(playbook.get("accept_count", 0) or 0)
|
||||
replied = int(playbook.get("replied_count", 0) or 0)
|
||||
meetings = int(playbook.get("meeting_count", 0) or 0)
|
||||
deals = int(playbook.get("deal_count", 0) or 0)
|
||||
|
||||
if used <= 0:
|
||||
return {
|
||||
"score": 0, "tier": "unproven",
|
||||
"accept_rate": 0.0, "reply_rate": 0.0,
|
||||
"meeting_rate": 0.0, "deal_rate": 0.0,
|
||||
}
|
||||
|
||||
accept_rate = accepted / used if used else 0.0
|
||||
reply_rate = replied / used if used else 0.0
|
||||
meeting_rate = meetings / used if used else 0.0
|
||||
deal_rate = deals / used if used else 0.0
|
||||
|
||||
# Weighted score; deals matter most.
|
||||
score = int(round(
|
||||
100 * (
|
||||
0.10 * accept_rate
|
||||
+ 0.20 * reply_rate
|
||||
+ 0.30 * meeting_rate
|
||||
+ 0.40 * deal_rate
|
||||
)
|
||||
))
|
||||
score = max(0, min(100, score))
|
||||
|
||||
if score >= 70:
|
||||
tier = "winner"
|
||||
elif score >= 40:
|
||||
tier = "promising"
|
||||
elif score >= 20:
|
||||
tier = "needs_work"
|
||||
else:
|
||||
tier = "candidate_archive"
|
||||
|
||||
return {
|
||||
"score": score, "tier": tier,
|
||||
"accept_rate": round(accept_rate, 3),
|
||||
"reply_rate": round(reply_rate, 3),
|
||||
"meeting_rate": round(meeting_rate, 3),
|
||||
"deal_rate": round(deal_rate, 3),
|
||||
}
|
||||
|
||||
|
||||
def merge_similar_playbooks(
|
||||
playbooks: list[dict[str, object]],
|
||||
*,
|
||||
field: str = "title",
|
||||
threshold: float = 0.80,
|
||||
) -> list[dict[str, object]]:
|
||||
"""
|
||||
Group near-identical playbooks (by title similarity) and return
|
||||
a list of merge suggestions:
|
||||
[{"keep_index", "merge_indices", "merged_title", "similarity"}]
|
||||
"""
|
||||
suggestions: list[dict[str, object]] = []
|
||||
used: set[int] = set()
|
||||
n = len(playbooks)
|
||||
for i in range(n):
|
||||
if i in used:
|
||||
continue
|
||||
merge_indices: list[int] = []
|
||||
title_i = str(playbooks[i].get(field, "") or "")
|
||||
for j in range(i + 1, n):
|
||||
if j in used:
|
||||
continue
|
||||
title_j = str(playbooks[j].get(field, "") or "")
|
||||
if not title_i or not title_j:
|
||||
continue
|
||||
ratio = SequenceMatcher(None, title_i, title_j).ratio()
|
||||
if ratio >= threshold:
|
||||
merge_indices.append(j)
|
||||
used.add(j)
|
||||
if merge_indices:
|
||||
used.add(i)
|
||||
suggestions.append({
|
||||
"keep_index": i,
|
||||
"merge_indices": merge_indices,
|
||||
"merged_title": title_i,
|
||||
"similarity_threshold": threshold,
|
||||
})
|
||||
return suggestions
|
||||
|
||||
|
||||
def recommend_next_playbook(
|
||||
scored_playbooks: list[dict[str, object]],
|
||||
*,
|
||||
sector: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
"""
|
||||
Pick the next playbook to run given scored history.
|
||||
|
||||
Strategy: prefer "promising" over "winner" (winners are saturated).
|
||||
If sector is given, prefer playbooks tagged with that sector.
|
||||
Falls back to deterministic default.
|
||||
"""
|
||||
if not scored_playbooks:
|
||||
return {
|
||||
"recommended_id": "default_warm_outreach",
|
||||
"title_ar": "تواصل دافئ مع 10 جهات مختارة",
|
||||
"reason_ar": "لا يوجد تاريخ بعد — ابدأ بالـ playbook الافتراضي.",
|
||||
}
|
||||
|
||||
candidates = list(scored_playbooks)
|
||||
if sector:
|
||||
sector_filtered = [
|
||||
p for p in candidates
|
||||
if sector.lower() in str(p.get("sectors", "")).lower()
|
||||
]
|
||||
if sector_filtered:
|
||||
candidates = sector_filtered
|
||||
|
||||
# Promote "promising" first, then "winner", then by score.
|
||||
tier_priority = {"promising": 0, "winner": 1, "needs_work": 2,
|
||||
"candidate_archive": 3, "unproven": 4}
|
||||
candidates.sort(key=lambda p: (
|
||||
tier_priority.get(str(p.get("tier", "unproven")), 9),
|
||||
-int(p.get("score", 0) or 0),
|
||||
))
|
||||
chosen = candidates[0]
|
||||
return {
|
||||
"recommended_id": chosen.get("id"),
|
||||
"title_ar": chosen.get("title", "?"),
|
||||
"reason_ar": (
|
||||
f"الـ tier: {chosen.get('tier')}, الـ score: {chosen.get('score')}."
|
||||
),
|
||||
}
|
||||
def suggest_playbook_merge(playbooks: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
"""If two titles share same first word, suggest merge (demo)."""
|
||||
if len(playbooks) < 2:
|
||||
return {"merge_groups": [], "demo": True}
|
||||
titles = [str(p.get("title_ar") or p.get("title") or "") for p in playbooks]
|
||||
merge_groups: list[list[int]] = []
|
||||
for i, a in enumerate(titles):
|
||||
for j in range(i + 1, len(titles)):
|
||||
if a and titles[j] and a.split()[:1] == titles[j].split()[:1] and a.split()[:1]:
|
||||
merge_groups.append([i, j])
|
||||
return {"merge_groups": merge_groups[:3], "demo": True}
|
||||
|
||||
@ -1,74 +1,22 @@
|
||||
"""Skill Inventory — list every Dealix capability, categorized."""
|
||||
"""Curated list of playbook/message skills — deterministic inventory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Curated, deterministic inventory of skills across the layers.
|
||||
SKILL_INVENTORY: tuple[dict[str, object], ...] = (
|
||||
# platform_services
|
||||
{"id": "tool_gateway", "layer": "platform_services",
|
||||
"label_ar": "بوابة الأدوات الآمنة", "tier": "core"},
|
||||
{"id": "action_policy", "layer": "platform_services",
|
||||
"label_ar": "محرك سياسة الأفعال", "tier": "core"},
|
||||
{"id": "channel_registry", "layer": "platform_services",
|
||||
"label_ar": "سجل القنوات", "tier": "core"},
|
||||
{"id": "unified_inbox", "layer": "platform_services",
|
||||
"label_ar": "صندوق البريد الموحد", "tier": "core"},
|
||||
{"id": "action_ledger", "layer": "platform_services",
|
||||
"label_ar": "سجل الأفعال", "tier": "core"},
|
||||
{"id": "proof_ledger", "layer": "platform_services",
|
||||
"label_ar": "سجل الأثر", "tier": "core"},
|
||||
{"id": "service_catalog", "layer": "platform_services",
|
||||
"label_ar": "كتالوج الخدمات", "tier": "core"},
|
||||
{"id": "identity_resolution", "layer": "platform_services",
|
||||
"label_ar": "حل الهوية المتقاطع", "tier": "core"},
|
||||
# intelligence_layer
|
||||
{"id": "growth_brain", "layer": "intelligence_layer",
|
||||
"label_ar": "عقل النمو", "tier": "core"},
|
||||
{"id": "command_feed", "layer": "intelligence_layer",
|
||||
"label_ar": "بطاقات القرار اليومية", "tier": "core"},
|
||||
{"id": "mission_engine", "layer": "intelligence_layer",
|
||||
"label_ar": "محرك المهمات", "tier": "core"},
|
||||
{"id": "trust_score", "layer": "intelligence_layer",
|
||||
"label_ar": "Trust Score", "tier": "core"},
|
||||
{"id": "revenue_dna", "layer": "intelligence_layer",
|
||||
"label_ar": "DNA الإيرادات", "tier": "core"},
|
||||
{"id": "opportunity_simulator", "layer": "intelligence_layer",
|
||||
"label_ar": "محاكي الفرص", "tier": "core"},
|
||||
{"id": "competitive_moves", "layer": "intelligence_layer",
|
||||
"label_ar": "كاشف حركات المنافسين", "tier": "core"},
|
||||
{"id": "board_brief", "layer": "intelligence_layer",
|
||||
"label_ar": "موجز Founder Shadow Board", "tier": "core"},
|
||||
{"id": "decision_memory", "layer": "intelligence_layer",
|
||||
"label_ar": "ذاكرة القرارات", "tier": "core"},
|
||||
{"id": "action_graph", "layer": "intelligence_layer",
|
||||
"label_ar": "Action Graph", "tier": "core"},
|
||||
# growth_operator (existing)
|
||||
{"id": "first_10_opportunities", "layer": "growth_operator",
|
||||
"label_ar": "10 فرص في 10 دقائق", "tier": "kill_feature"},
|
||||
# security_curator
|
||||
{"id": "secret_redactor", "layer": "security_curator",
|
||||
"label_ar": "إخفاء الأسرار", "tier": "core"},
|
||||
{"id": "patch_firewall", "layer": "security_curator",
|
||||
"label_ar": "جدار الـ patches", "tier": "core"},
|
||||
# growth_curator
|
||||
{"id": "message_curator", "layer": "growth_curator",
|
||||
"label_ar": "مدقق الرسائل", "tier": "core"},
|
||||
{"id": "playbook_curator", "layer": "growth_curator",
|
||||
"label_ar": "مدقق الـ playbooks", "tier": "core"},
|
||||
)
|
||||
from typing import Any
|
||||
|
||||
|
||||
def inventory_skills() -> dict[str, object]:
|
||||
"""Return the full skill inventory grouped by layer."""
|
||||
by_layer: dict[str, list[dict[str, object]]] = {}
|
||||
for s in SKILL_INVENTORY:
|
||||
layer = str(s["layer"])
|
||||
by_layer.setdefault(layer, []).append(dict(s))
|
||||
return {
|
||||
"total": len(SKILL_INVENTORY),
|
||||
"layers": sorted(by_layer.keys()),
|
||||
"by_layer": by_layer,
|
||||
"kill_features": [
|
||||
dict(s) for s in SKILL_INVENTORY if s.get("tier") == "kill_feature"
|
||||
],
|
||||
}
|
||||
def list_skill_inventory() -> dict[str, Any]:
|
||||
skills: list[dict[str, Any]] = [
|
||||
{"id": "saudi_short_pitch", "score": 88, "usage_count_demo": 42, "status": "active"},
|
||||
{"id": "objection_timing", "score": 72, "usage_count_demo": 18, "status": "active"},
|
||||
{"id": "cold_whatsapp_template", "score": 12, "usage_count_demo": 3, "status": "archived", "reason_ar": "مخالف سياسة القناة"},
|
||||
]
|
||||
return {"skills": skills, "recommendation_ar": "أرشف القوالب منخفضة الدرجة وادمج المتشابه.", "demo": True}
|
||||
|
||||
|
||||
def score_skill(skill_id: str) -> dict[str, Any]:
|
||||
inv = list_skill_inventory()
|
||||
for s in inv["skills"]:
|
||||
if s["id"] == skill_id:
|
||||
return {"skill": s, "demo": True}
|
||||
return {"error": "not_found", "demo": True}
|
||||
|
||||
1
dealix/auto_client_acquisition/integrations/__init__.py
Normal file
1
dealix/auto_client_acquisition/integrations/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""Draft-only integration helpers (no OAuth, no network) — Growth Control Tower."""
|
||||
@ -0,0 +1,24 @@
|
||||
"""Google Calendar API-shaped draft payloads — no OAuth, no HTTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_calendar_draft_payload(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Minimal ``events.insert``-like resource for review only.
|
||||
"""
|
||||
summary = str(params.get("summary_ar") or params.get("summary") or "اجتماع متابعة — Dealix")
|
||||
start = str(params.get("start_iso") or "2026-05-02T10:00:00+03:00")
|
||||
end = str(params.get("end_iso") or "2026-05-02T10:30:00+03:00")
|
||||
return {
|
||||
"approval_required": True,
|
||||
"event": {
|
||||
"summary": summary,
|
||||
"start": {"dateTime": start, "timeZone": "Asia/Riyadh"},
|
||||
"end": {"dateTime": end, "timeZone": "Asia/Riyadh"},
|
||||
"attendees": params.get("attendees") if isinstance(params.get("attendees"), list) else [],
|
||||
},
|
||||
"note_ar": "مسودة حدث فقط — لا يُنشأ في تقويم Google في MVP.",
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
"""Gmail API-shaped draft payloads — no OAuth, no HTTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from email.message import EmailMessage
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_gmail_draft_payload(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Returns ``{"message": {"raw": "<urlsafe base64 RFC822>"}}`` subset compatible with
|
||||
Gmail ``users.drafts.create`` — encoding only, no API call.
|
||||
"""
|
||||
to = str(params.get("to") or "prospect@example.com")
|
||||
subject = str(params.get("subject_ar") or params.get("subject") or "مسودة — Dealix")
|
||||
body_text = str(params.get("body_ar") or params.get("body") or "نص المسودة الداخلي.")
|
||||
msg = EmailMessage()
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(body_text, charset="utf-8")
|
||||
raw_bytes = msg.as_bytes()
|
||||
raw_b64 = base64.urlsafe_b64encode(raw_bytes).decode("ascii").rstrip("=")
|
||||
return {
|
||||
"approval_required": True,
|
||||
"message": {"raw": raw_b64},
|
||||
"note_ar": "هيكل مسودة فقط — لا يُرسل عبر Gmail API في MVP.",
|
||||
}
|
||||
53
dealix/auto_client_acquisition/integrations/moyasar_draft.py
Normal file
53
dealix/auto_client_acquisition/integrations/moyasar_draft.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""Moyasar payment resource draft — halalas validation only, no API calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# SAR minor units per Moyasar docs (amount in halalas / smallest currency unit).
|
||||
|
||||
|
||||
def build_moyasar_payment_draft(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Validates ``amount`` as integer halalas (>= 100 typical minimum for tests).
|
||||
Returns a create-payment shaped dict without calling Moyasar.
|
||||
"""
|
||||
raw = params.get("amount_halalas", params.get("amount"))
|
||||
errors: list[str] = []
|
||||
amount: int | None = None
|
||||
try:
|
||||
if raw is None:
|
||||
errors.append("amount_halalas_required")
|
||||
else:
|
||||
amount = int(raw)
|
||||
if amount < 1:
|
||||
errors.append("amount_must_be_positive_integer_halalas")
|
||||
except (TypeError, ValueError):
|
||||
errors.append("amount_must_be_integer_halalas")
|
||||
amount = None
|
||||
|
||||
if errors:
|
||||
return {"approval_required": True, "valid": False, "errors": errors, "payload": None, "payment_link_draft": None}
|
||||
|
||||
currency = str(params.get("currency") or "SAR").upper()
|
||||
invoice_ref = str(params.get("invoice_reference") or params.get("invoice_id") or f"INV-DEMO-{amount}")
|
||||
# Shape-only checkout URL — replace base with real merchant page when integrating.
|
||||
base = str(params.get("payment_link_base") or "https://api.moyasar.com/v1/payments")
|
||||
payment_link_draft = f"{base}?amount={amount}¤cy={currency}&description={invoice_ref}"
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"amount": amount,
|
||||
"currency": currency,
|
||||
"source": params.get("source") if isinstance(params.get("source"), dict) else {"type": "creditcard"},
|
||||
"description": str(params.get("description_ar") or params.get("description") or "Dealix draft"),
|
||||
"metadata": {"invoice_reference": invoice_ref},
|
||||
}
|
||||
return {
|
||||
"approval_required": True,
|
||||
"valid": True,
|
||||
"errors": [],
|
||||
"payload": payload,
|
||||
"payment_link_draft": payment_link_draft,
|
||||
"invoice_reference": invoice_ref,
|
||||
"note_ar": "مسودة تحقق فقط — لا يُنشأ دفع عبر Moyasar في MVP؛ الرابط للعرض الشكلي فقط.",
|
||||
}
|
||||
@ -1,67 +1,27 @@
|
||||
"""
|
||||
Intelligence Layer — the decision brain on top of platform_services.
|
||||
"""Intelligence layer — deterministic JSON, optional bridge to innovation."""
|
||||
|
||||
Turns Dealix from "channels + actions" into a **Growth Neural Network**:
|
||||
the system understands the customer fully, watches the market, decides,
|
||||
executes (with approval), and learns from every outcome.
|
||||
|
||||
Modules:
|
||||
- growth_brain : per-customer brain (context + preferences + priorities)
|
||||
- command_feed : Arabic decision cards (what to do now)
|
||||
- action_graph : signal→action→outcome typed relationships
|
||||
- mission_engine : 7 outcome-shaped missions (durable workflows)
|
||||
- decision_memory : learns from Accept/Skip/Edit signals
|
||||
- trust_score : per-action safety verdict (safe/review/blocked)
|
||||
- revenue_dna : best-channel/segment/angle/objection per customer
|
||||
- opportunity_simulator: forward simulation before sending
|
||||
- competitive_moves : detect + respond to competitor signals
|
||||
- board_brief : weekly founder/board-ready brief
|
||||
"""
|
||||
|
||||
from auto_client_acquisition.intelligence_layer.action_graph import (
|
||||
ActionEdge,
|
||||
ActionGraph,
|
||||
EDGE_TYPES,
|
||||
)
|
||||
from auto_client_acquisition.intelligence_layer.action_graph import build_action_graph_trace
|
||||
from auto_client_acquisition.intelligence_layer.board_brief import build_board_brief
|
||||
from auto_client_acquisition.intelligence_layer.command_feed import (
|
||||
INTEL_CARD_TYPES,
|
||||
build_command_feed_demo,
|
||||
)
|
||||
from auto_client_acquisition.intelligence_layer.competitive_moves import (
|
||||
analyze_competitive_move,
|
||||
)
|
||||
from auto_client_acquisition.intelligence_layer.decision_memory import (
|
||||
DecisionMemory,
|
||||
learn_from_decision,
|
||||
)
|
||||
from auto_client_acquisition.intelligence_layer.growth_brain import (
|
||||
GrowthBrain,
|
||||
build_growth_brain,
|
||||
)
|
||||
from auto_client_acquisition.intelligence_layer.mission_engine import (
|
||||
INTEL_MISSIONS,
|
||||
list_intel_missions,
|
||||
recommend_missions,
|
||||
)
|
||||
from auto_client_acquisition.intelligence_layer.opportunity_simulator import (
|
||||
simulate_opportunity,
|
||||
)
|
||||
from auto_client_acquisition.intelligence_layer.revenue_dna import (
|
||||
build_revenue_dna_demo,
|
||||
extract_revenue_dna,
|
||||
)
|
||||
from auto_client_acquisition.intelligence_layer.competitive_moves import build_competitive_moves
|
||||
from auto_client_acquisition.intelligence_layer.decision_memory import list_decisions, record_decision
|
||||
from auto_client_acquisition.intelligence_layer.growth_brain import build_growth_profile
|
||||
from auto_client_acquisition.intelligence_layer.intel_command_feed import build_intel_command_feed
|
||||
from auto_client_acquisition.intelligence_layer.mission_engine import get_mission, list_mission_catalog
|
||||
from auto_client_acquisition.intelligence_layer.opportunity_simulator import simulate_opportunities
|
||||
from auto_client_acquisition.intelligence_layer.revenue_dna import build_revenue_dna
|
||||
from auto_client_acquisition.intelligence_layer.trust_score import compute_trust_score
|
||||
|
||||
__all__ = [
|
||||
"GrowthBrain", "build_growth_brain",
|
||||
"INTEL_CARD_TYPES", "build_command_feed_demo",
|
||||
"ActionGraph", "ActionEdge", "EDGE_TYPES",
|
||||
"INTEL_MISSIONS", "list_intel_missions", "recommend_missions",
|
||||
"DecisionMemory", "learn_from_decision",
|
||||
"compute_trust_score",
|
||||
"extract_revenue_dna", "build_revenue_dna_demo",
|
||||
"simulate_opportunity",
|
||||
"analyze_competitive_move",
|
||||
"build_action_graph_trace",
|
||||
"build_board_brief",
|
||||
"build_competitive_moves",
|
||||
"build_growth_profile",
|
||||
"build_intel_command_feed",
|
||||
"build_revenue_dna",
|
||||
"compute_trust_score",
|
||||
"get_mission",
|
||||
"list_decisions",
|
||||
"list_mission_catalog",
|
||||
"record_decision",
|
||||
"simulate_opportunities",
|
||||
]
|
||||
|
||||
@ -1,90 +1,35 @@
|
||||
"""Action Graph — typed signal→action→approval→outcome→proof relationships."""
|
||||
"""Deterministic action graph: signal → policy → approval → outcome → proof (demo)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
|
||||
EDGE_TYPES: tuple[str, ...] = (
|
||||
"signal_created_opportunity",
|
||||
"message_triggered_reply",
|
||||
"reply_created_meeting",
|
||||
"meeting_created_followup",
|
||||
"followup_influenced_payment",
|
||||
"objection_required_proof",
|
||||
"partner_introduced_customer",
|
||||
"review_created_recovery_task",
|
||||
"approval_allowed_send",
|
||||
"blocked_action_prevented_risk",
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionEdge:
|
||||
"""One typed edge in the action graph."""
|
||||
|
||||
edge_id: str
|
||||
edge_type: str
|
||||
src_id: str
|
||||
dst_id: str
|
||||
customer_id: str
|
||||
occurred_at: datetime
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"edge_id": self.edge_id,
|
||||
"edge_type": self.edge_type,
|
||||
"src_id": self.src_id,
|
||||
"dst_id": self.dst_id,
|
||||
"customer_id": self.customer_id,
|
||||
"occurred_at": self.occurred_at.isoformat(),
|
||||
"payload": self.payload,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionGraph:
|
||||
"""In-memory action graph for the customer's decision history."""
|
||||
|
||||
edges: list[ActionEdge] = field(default_factory=list)
|
||||
|
||||
def add_edge(
|
||||
self,
|
||||
*,
|
||||
edge_type: str,
|
||||
src_id: str,
|
||||
dst_id: str,
|
||||
customer_id: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> ActionEdge:
|
||||
if edge_type not in EDGE_TYPES:
|
||||
raise ValueError(f"unknown edge_type: {edge_type}")
|
||||
e = ActionEdge(
|
||||
edge_id=f"edge_{uuid.uuid4().hex[:16]}",
|
||||
edge_type=edge_type,
|
||||
src_id=src_id,
|
||||
dst_id=dst_id,
|
||||
customer_id=customer_id,
|
||||
occurred_at=datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
payload=payload or {},
|
||||
)
|
||||
self.edges.append(e)
|
||||
return e
|
||||
|
||||
def what_works_summary(self, customer_id: str) -> dict[str, Any]:
|
||||
"""Roll-up: which signal types led to outcomes?"""
|
||||
by_type: dict[str, int] = {}
|
||||
for e in self.edges:
|
||||
if e.customer_id != customer_id:
|
||||
continue
|
||||
by_type[e.edge_type] = by_type.get(e.edge_type, 0) + 1
|
||||
winning = sorted(by_type.items(), key=lambda x: x[1], reverse=True)
|
||||
return {
|
||||
"total_edges": sum(by_type.values()),
|
||||
"by_edge_type": by_type,
|
||||
"top_winning_relationships": winning[:5],
|
||||
}
|
||||
def build_action_graph_trace(payload: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
Returns nodes/edges for UI or docs — no execution.
|
||||
"""
|
||||
p = payload or {}
|
||||
signal = str(p.get("signal_type") or "lead_received")
|
||||
nodes = [
|
||||
{"id": "n1", "kind": "signal", "label_ar": f"إشارة: {signal}"},
|
||||
{"id": "n2", "kind": "context", "label_ar": "بناء سياق (شركة، قناة، مصدر)"},
|
||||
{"id": "n3", "kind": "policy", "label_ar": "تقييم سياسة القناة"},
|
||||
{"id": "n4", "kind": "approval", "label_ar": "موافقة بشرية"},
|
||||
{"id": "n5", "kind": "draft_or_block", "label_ar": "مسودة أو منع"},
|
||||
{"id": "n6", "kind": "proof", "label_ar": "تسجيل في Proof Ledger"},
|
||||
]
|
||||
edges = [
|
||||
{"from": "n1", "to": "n2", "label": "enrich"},
|
||||
{"from": "n2", "to": "n3", "label": "evaluate"},
|
||||
{"from": "n3", "to": "n4", "label": "if_external"},
|
||||
{"from": "n4", "to": "n5", "label": "on_approve"},
|
||||
{"from": "n5", "to": "n6", "label": "record"},
|
||||
]
|
||||
return {
|
||||
"signal_type": signal,
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"note_ar": "عرض منطقي فقط — لا ينفّذ أدوات خارجية.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,55 +1,19 @@
|
||||
"""Founder Shadow Board — weekly brief for founder/board."""
|
||||
"""Executive board brief — Arabic headline + bullets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_board_brief(
|
||||
*,
|
||||
customer_id: str = "demo",
|
||||
customer_name: str = "Demo Saudi B2B Co.",
|
||||
week_label: str = "May W1 2026",
|
||||
pipeline_added_sar: float = 185_000,
|
||||
revenue_won_sar: float = 30_000,
|
||||
meetings_booked: int = 14,
|
||||
risks_blocked: int = 21,
|
||||
leak_recovered_sar: float = 12_000,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate the founder/board-ready weekly brief."""
|
||||
def build_board_brief(snapshot: dict[str, Any] | None) -> dict[str, Any]:
|
||||
sn = snapshot or {}
|
||||
title = str(sn.get("title_ar") or "موجز أسبوعي — Dealix")
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"customer_name": customer_name,
|
||||
"week_label": week_label,
|
||||
"decisions_required_ar": [
|
||||
"اعتماد رفع price على الـ Growth tier 10% — منافس رفع 15%.",
|
||||
"الموافقة على Partnership Sprint مع وكالة B2B في جدة.",
|
||||
"اختيار pilot vertical للشهر القادم (clinics vs training).",
|
||||
"title_ar": title,
|
||||
"bullets_ar": [
|
||||
"زخم الصفقات: مستقر مع حاجة لمتابعة ما بعد الاجتماع.",
|
||||
"الامتثال: لا إرسال جماعي حتى اكتمال opt-in.",
|
||||
"الفرص: ركّز على قطاعين بدل تشتيت ICP.",
|
||||
],
|
||||
"top_opportunities_ar": [
|
||||
f"شركة العقار الذهبي — اجتماع غداً ({250_000:,} ريال محتمل).",
|
||||
f"3 leads inbound من LinkedIn Lead Forms ({36_000:,} ريال).",
|
||||
f"Reactivation campaign على 12 عميل خامل ({80_000:,} ريال).",
|
||||
],
|
||||
"top_risks_ar": [
|
||||
"صفقة 250K معرضة (single-threaded) — تحتاج multi-thread.",
|
||||
"تأخر في الرد على 7 leads خلال 72+ ساعة.",
|
||||
"تقييم Google 2-نجوم بدون رد — يحتاج ≤24 ساعة.",
|
||||
],
|
||||
"key_relationship_ar": (
|
||||
"خالد ع. (شريك في وكالة B2B جدة) — اقترح اجتماع 20 دقيقة الأسبوع القادم."
|
||||
),
|
||||
"experiment_to_run_ar": (
|
||||
"اختبر رسالة قصيرة (≤4 سطور) بدلاً من النسخة الحالية على قطاع real_estate."
|
||||
),
|
||||
"metric_to_watch_ar": (
|
||||
f"approve_rate الأسبوعي: الهدف ≥45% (آخر أسبوع 38%)."
|
||||
),
|
||||
"money_summary": {
|
||||
"pipeline_added_sar": pipeline_added_sar,
|
||||
"revenue_won_sar": revenue_won_sar,
|
||||
"leak_recovered_sar": leak_recovered_sar,
|
||||
"risks_blocked_count": risks_blocked,
|
||||
"meetings_booked": meetings_booked,
|
||||
},
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,86 +1,18 @@
|
||||
"""Competitive Move Detector — analyze competitor activity → suggest action."""
|
||||
"""Safe competitive move suggestions (display-only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
MOVE_TYPES: tuple[str, ...] = (
|
||||
"price_change",
|
||||
"new_offer",
|
||||
"hiring",
|
||||
"event",
|
||||
"content_campaign",
|
||||
"rebrand",
|
||||
"funding",
|
||||
"expansion",
|
||||
)
|
||||
|
||||
|
||||
def analyze_competitive_move(
|
||||
*,
|
||||
competitor_name: str,
|
||||
move_type: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Take one observed competitor signal → return Arabic recommended action.
|
||||
|
||||
Pure deterministic; no live competitor scraping.
|
||||
"""
|
||||
p = payload or {}
|
||||
if move_type not in MOVE_TYPES:
|
||||
return {
|
||||
"error": f"unknown move_type: {move_type}",
|
||||
"valid_types": list(MOVE_TYPES),
|
||||
}
|
||||
|
||||
if move_type == "price_change":
|
||||
delta_pct = float(p.get("price_delta_pct", -10))
|
||||
action_ar = (
|
||||
"حملة مضادة + ROI breakdown مقارن — لا تخفّض السعر."
|
||||
if delta_pct < 0 else
|
||||
"ميزة تنافسية: عرضنا أرخص — اطلق ROI proof."
|
||||
)
|
||||
urgency = "high" if abs(delta_pct) >= 15 else "medium"
|
||||
elif move_type == "new_offer":
|
||||
action_ar = (
|
||||
"حلّل العرض الجديد + اقتباس مزاياك المختلفة + offer comparison."
|
||||
)
|
||||
urgency = "medium"
|
||||
elif move_type == "hiring":
|
||||
action_ar = (
|
||||
"إشارة توسع — استهدف نفس عملائهم بعرضك المختلف."
|
||||
)
|
||||
urgency = "low"
|
||||
elif move_type == "event":
|
||||
action_ar = (
|
||||
"حضّر أنت محتوى/ندوة في نفس الفترة — استفد من اهتمام السوق."
|
||||
)
|
||||
urgency = "medium"
|
||||
elif move_type == "content_campaign":
|
||||
action_ar = (
|
||||
"اقرأ زاويتهم + اطلق رد منشور / dialog بحجة مدعومة بأرقام."
|
||||
)
|
||||
urgency = "low"
|
||||
elif move_type == "rebrand":
|
||||
action_ar = "احتفظ بهويتك — أعلن استمرار وعدك للعملاء."
|
||||
urgency = "low"
|
||||
elif move_type == "funding":
|
||||
action_ar = (
|
||||
"إشارة سرعة في السوق — ركّز على retention + speed-to-value."
|
||||
)
|
||||
urgency = "medium"
|
||||
else: # expansion
|
||||
action_ar = "نبّه فريق المبيعات + رسالة احتفاظ للعملاء الكبار."
|
||||
urgency = "medium"
|
||||
|
||||
def build_competitive_moves(sector: str | None = None) -> dict[str, Any]:
|
||||
sec = sector or "عام"
|
||||
return {
|
||||
"competitor_name": competitor_name,
|
||||
"move_type": move_type,
|
||||
"urgency": urgency,
|
||||
"recommended_action_ar": action_ar,
|
||||
"next_step_ar": "جهّز draft رد + موافقة المشغّل قبل الإطلاق.",
|
||||
"approval_required": True,
|
||||
"payload_received": p,
|
||||
"sector": sec,
|
||||
"moves_ar": [
|
||||
"تضييق رسالة القيمة على نتيجة واحدة قابلة للقياس لكل عميل.",
|
||||
"عرض تجربة ٧ أيام مع حدود واضحة للنطاق وتقرير إثبات أسبوعي.",
|
||||
"تفعيل غرفة صفقة مشتركة مع SLA داخلي ٢٤ ساعة للرد.",
|
||||
],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,95 +1,24 @@
|
||||
"""Decision Memory — learn the operator's preferences from Accept/Skip/Edit."""
|
||||
"""In-memory decision snippets for demos — replace with DB in production."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
VALID_DECISIONS: tuple[str, ...] = ("accept", "skip", "edit", "block")
|
||||
_STORE: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class DecisionMemory:
|
||||
"""Per-customer Accept/Skip/Edit history and aggregates."""
|
||||
|
||||
customer_id: str
|
||||
raw_decisions: list[dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
def append(
|
||||
self,
|
||||
*,
|
||||
decision: str,
|
||||
action_type: str,
|
||||
channel: str,
|
||||
sector: str | None = None,
|
||||
tone: str | None = None,
|
||||
objection_id: str | None = None,
|
||||
) -> None:
|
||||
if decision not in VALID_DECISIONS:
|
||||
raise ValueError(f"unknown decision: {decision}")
|
||||
self.raw_decisions.append({
|
||||
"decision": decision,
|
||||
"action_type": action_type,
|
||||
"channel": channel,
|
||||
"sector": sector,
|
||||
"tone": tone,
|
||||
"objection_id": objection_id,
|
||||
})
|
||||
|
||||
def preferences(self) -> dict[str, Any]:
|
||||
if not self.raw_decisions:
|
||||
return {
|
||||
"samples": 0,
|
||||
"preferred_channels": [],
|
||||
"preferred_tones": [],
|
||||
"preferred_sectors": [],
|
||||
"rejected_action_types": [],
|
||||
"accept_rate": 0.0,
|
||||
}
|
||||
ch_counter: Counter[str] = Counter()
|
||||
tone_counter: Counter[str] = Counter()
|
||||
sector_counter: Counter[str] = Counter()
|
||||
rejected: Counter[str] = Counter()
|
||||
accepts = 0
|
||||
for d in self.raw_decisions:
|
||||
if d["decision"] == "accept":
|
||||
accepts += 1
|
||||
ch_counter[d.get("channel", "")] += 1
|
||||
if d.get("tone"):
|
||||
tone_counter[d["tone"]] += 1
|
||||
if d.get("sector"):
|
||||
sector_counter[d["sector"]] += 1
|
||||
elif d["decision"] in ("skip", "block"):
|
||||
rejected[d.get("action_type", "")] += 1
|
||||
return {
|
||||
"samples": len(self.raw_decisions),
|
||||
"preferred_channels": [c for c, _ in ch_counter.most_common(3)],
|
||||
"preferred_tones": [t for t, _ in tone_counter.most_common(2)],
|
||||
"preferred_sectors": [s for s, _ in sector_counter.most_common(3)],
|
||||
"rejected_action_types": [a for a, _ in rejected.most_common(3) if a],
|
||||
"accept_rate": round(accepts / len(self.raw_decisions), 4),
|
||||
}
|
||||
|
||||
|
||||
def learn_from_decision(
|
||||
*,
|
||||
memory: DecisionMemory,
|
||||
decision: str,
|
||||
action_type: str,
|
||||
channel: str,
|
||||
sector: str | None = None,
|
||||
tone: str | None = None,
|
||||
objection_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Record a decision + return updated preferences."""
|
||||
memory.append(
|
||||
decision=decision, action_type=action_type, channel=channel,
|
||||
sector=sector, tone=tone, objection_id=objection_id,
|
||||
)
|
||||
return {
|
||||
"customer_id": memory.customer_id,
|
||||
"added": True,
|
||||
"preferences": memory.preferences(),
|
||||
def record_decision(entry: dict[str, Any]) -> dict[str, Any]:
|
||||
e = {
|
||||
"id": f"dec_{len(_STORE)+1}",
|
||||
**entry,
|
||||
}
|
||||
_STORE.append(e)
|
||||
return {"ok": True, "entry": e, "demo": True}
|
||||
|
||||
|
||||
def list_decisions(*, limit: int = 20) -> dict[str, Any]:
|
||||
return {"decisions": list(reversed(_STORE[-limit:])), "count": len(_STORE), "demo": True}
|
||||
|
||||
|
||||
def reset_demo_memory() -> None:
|
||||
_STORE.clear()
|
||||
|
||||
@ -1,80 +1,82 @@
|
||||
"""Growth Brain — per-customer context + preferences + priorities."""
|
||||
"""Growth profile from JSON — no LLM required for MVP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class GrowthBrain:
|
||||
"""The customer's growth context as a single object."""
|
||||
|
||||
customer_id: str
|
||||
company_context: dict[str, Any]
|
||||
channels_connected: tuple[str, ...]
|
||||
target_segments: tuple[str, ...]
|
||||
approved_actions: tuple[str, ...]
|
||||
blocked_actions: tuple[str, ...]
|
||||
growth_priorities: tuple[str, ...]
|
||||
risk_tolerance: str = "medium" # low / medium / high
|
||||
preferred_tone: str = "warm" # formal / warm / direct
|
||||
accept_rate_30d: float = 0.0
|
||||
avg_response_minutes: int = 0
|
||||
learning_signal_count: int = 0
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"customer_id": self.customer_id,
|
||||
"company_context": self.company_context,
|
||||
"channels_connected": list(self.channels_connected),
|
||||
"target_segments": list(self.target_segments),
|
||||
"approved_actions": list(self.approved_actions),
|
||||
"blocked_actions": list(self.blocked_actions),
|
||||
"growth_priorities": list(self.growth_priorities),
|
||||
"risk_tolerance": self.risk_tolerance,
|
||||
"preferred_tone": self.preferred_tone,
|
||||
"accept_rate_30d": self.accept_rate_30d,
|
||||
"avg_response_minutes": self.avg_response_minutes,
|
||||
"learning_signal_count": self.learning_signal_count,
|
||||
}
|
||||
|
||||
def is_ready_for_autopilot(self) -> bool:
|
||||
"""≥30 learned signals + ≥40% accept rate + non-empty channels."""
|
||||
return (
|
||||
self.learning_signal_count >= 30
|
||||
and self.accept_rate_30d >= 0.40
|
||||
and len(self.channels_connected) > 0
|
||||
)
|
||||
_DEFAULT_BLOCKED = (
|
||||
"cold_whatsapp",
|
||||
"auto_linkedin_dm",
|
||||
"bulk_send_without_approval",
|
||||
"purchased_list_bulk",
|
||||
)
|
||||
|
||||
|
||||
def build_growth_brain(payload: dict[str, Any] | None = None) -> GrowthBrain:
|
||||
"""Build a brain from a customer payload — sane Saudi-B2B defaults."""
|
||||
p = payload or {}
|
||||
return GrowthBrain(
|
||||
customer_id=str(p.get("customer_id") or "demo"),
|
||||
company_context={
|
||||
"company_name": p.get("company_name", "Demo Saudi B2B Co."),
|
||||
"sector": p.get("sector", "real_estate"),
|
||||
"city": p.get("city", "الرياض"),
|
||||
"offer_one_liner": p.get("offer_one_liner", "تشغيل نمو B2B سعودي"),
|
||||
"ideal_customer": p.get("ideal_customer", "شركات SMB سعودية"),
|
||||
"average_deal_size_sar": float(p.get("average_deal_size_sar", 25_000)),
|
||||
},
|
||||
channels_connected=tuple(p.get("channels_connected", ("whatsapp",))),
|
||||
target_segments=tuple(p.get("target_segments", ("inbound_lead", "existing_customer"))),
|
||||
approved_actions=tuple(p.get("approved_actions", (
|
||||
"create_draft", "send_with_approval", "ingest_lead",
|
||||
))),
|
||||
blocked_actions=tuple(p.get("blocked_actions", (
|
||||
"cold_send_without_consent", "charge_card_without_user_action",
|
||||
))),
|
||||
growth_priorities=tuple(p.get("growth_priorities", (
|
||||
"fill_pipeline", "improve_response_time", "build_partner_channel",
|
||||
))),
|
||||
risk_tolerance=p.get("risk_tolerance", "medium"),
|
||||
preferred_tone=p.get("preferred_tone", "warm"),
|
||||
accept_rate_30d=float(p.get("accept_rate_30d", 0.0)),
|
||||
avg_response_minutes=int(p.get("avg_response_minutes", 0)),
|
||||
learning_signal_count=int(p.get("learning_signal_count", 0)),
|
||||
)
|
||||
def _growth_brain_id(company: dict[str, Any]) -> str:
|
||||
payload = json.dumps(company, ensure_ascii=False, sort_keys=True, default=str)
|
||||
h = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:20]
|
||||
return f"gb_{h}"
|
||||
|
||||
|
||||
def build_growth_profile(company: dict[str, Any] | None) -> dict[str, Any]:
|
||||
c = company or {}
|
||||
company_name = str(c.get("company_name") or c.get("name") or "غير مسمّى")
|
||||
sector = str(c.get("sector") or "غير محدد")
|
||||
city = str(c.get("city") or "الرياض")
|
||||
goal_ar = str(c.get("goal_ar") or c.get("goal") or "تسريع خط أنابيب المبيعات")
|
||||
icp_hint_ar = str(c.get("icp_hint_ar") or "قرارات شراء في المؤسسات متوسطة الحجم")
|
||||
risk = str(c.get("risk_tolerance") or c.get("risk") or "medium").lower()
|
||||
channels_in = c.get("channels")
|
||||
channels: list[str] = []
|
||||
if isinstance(channels_in, list):
|
||||
channels = [str(x).strip().lower() for x in channels_in if str(x).strip()]
|
||||
|
||||
blocked = list(_DEFAULT_BLOCKED)
|
||||
if risk == "low":
|
||||
blocked = ["cold_whatsapp", "purchased_list_bulk", "auto_linkedin_dm"]
|
||||
elif risk == "high":
|
||||
blocked = list(_DEFAULT_BLOCKED) + ["unsupervised_payment_capture"]
|
||||
|
||||
tone = "professional_saudi_short"
|
||||
if risk == "low":
|
||||
tone = "warm_saudi_concise"
|
||||
elif risk == "high":
|
||||
tone = "formal_saudi_minimal"
|
||||
|
||||
recommended_first_mission = "ten_in_ten_opportunities"
|
||||
if c.get("recommended_first_mission"):
|
||||
recommended_first_mission = str(c.get("recommended_first_mission"))
|
||||
|
||||
seed_obj = {
|
||||
"company_name": company_name,
|
||||
"sector": sector,
|
||||
"city": city,
|
||||
"goal_ar": goal_ar,
|
||||
"channels": channels,
|
||||
"risk_tolerance": risk,
|
||||
}
|
||||
return {
|
||||
"growth_brain_id": _growth_brain_id(seed_obj),
|
||||
"company_name": company_name,
|
||||
"sector": sector,
|
||||
"city": city,
|
||||
"goal_ar": goal_ar,
|
||||
"icp_hint_ar": icp_hint_ar,
|
||||
"channels_connected": channels or ["whatsapp", "email"],
|
||||
"blocked_actions": blocked,
|
||||
"recommended_first_mission": recommended_first_mission,
|
||||
"tone": tone,
|
||||
"best_segments": _suggest_segments(sector),
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
def _suggest_segments(sector: str) -> list[str]:
|
||||
s = sector.lower()
|
||||
if "training" in s or "تدريب" in s or "consult" in s:
|
||||
return ["مدراء الموارد البشرية", "مدراء المبيعات", "رؤساء التعلم والتطوير"]
|
||||
if "health" in s or "صح" in s or "clinic" in s:
|
||||
return ["مدراء العيادات", "مشتريات طبية", "عمليات"]
|
||||
return ["صناع القرار المالي", "مدراء المشتريات", "العمليات"]
|
||||
|
||||
@ -0,0 +1,99 @@
|
||||
"""Intel-specific command cards — unified schema for Arabic decision UI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def normalize_intel_card(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Unified card shape: Arabic titles, <=3 buttons, no live actions.
|
||||
|
||||
Accepts either pre-normalized dicts or legacy keys (``cta_ar``, ``summary_ar``).
|
||||
"""
|
||||
title_ar = str(raw.get("title_ar") or "")
|
||||
summary_ar = str(raw.get("summary_ar") or "")
|
||||
why_it_matters_ar = str(raw.get("why_it_matters_ar") or summary_ar)
|
||||
recommended_action_ar = str(
|
||||
raw.get("recommended_action_ar")
|
||||
or raw.get("suggested_action")
|
||||
or raw.get("cta_ar")
|
||||
or raw.get("cta")
|
||||
or "مراجعة المسودة"
|
||||
)
|
||||
risk_level = str(raw.get("risk_level") or "medium")
|
||||
try:
|
||||
expected_impact_sar = float(raw.get("expected_impact_sar") or 0)
|
||||
except (TypeError, ValueError):
|
||||
expected_impact_sar = 0.0
|
||||
|
||||
buttons_in = raw.get("buttons")
|
||||
buttons: list[dict[str, str]] = []
|
||||
if isinstance(buttons_in, list) and buttons_in and isinstance(buttons_in[0], dict):
|
||||
for b in buttons_in[:3]:
|
||||
label = str(b.get("label_ar") or b.get("label") or "")
|
||||
aid = str(b.get("action_id") or b.get("id") or "action")
|
||||
if label:
|
||||
buttons.append({"label_ar": label, "action_id": aid})
|
||||
elif isinstance(buttons_in, list) and buttons_in and isinstance(buttons_in[0], str):
|
||||
for i, label in enumerate(buttons_in[:3]):
|
||||
buttons.append({"label_ar": str(label), "action_id": f"btn_{i}"})
|
||||
else:
|
||||
cta = str(raw.get("cta_ar") or raw.get("cta") or "مراجعة")
|
||||
buttons = [
|
||||
{"label_ar": cta, "action_id": "primary"},
|
||||
{"label_ar": "تعديل", "action_id": "edit"},
|
||||
{"label_ar": "تخطي", "action_id": "skip"},
|
||||
]
|
||||
|
||||
return {
|
||||
"type": str(raw.get("type") or "generic"),
|
||||
"title_ar": title_ar,
|
||||
"summary_ar": summary_ar,
|
||||
"why_it_matters_ar": why_it_matters_ar,
|
||||
"recommended_action_ar": recommended_action_ar,
|
||||
"risk_level": risk_level,
|
||||
"expected_impact_sar": expected_impact_sar,
|
||||
"buttons": buttons[:3],
|
||||
"approval_required": bool(raw.get("approval_required", True)),
|
||||
}
|
||||
|
||||
|
||||
def build_intel_command_feed(context: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
ctx = context or {}
|
||||
raw_cards: list[dict[str, Any]] = [
|
||||
{
|
||||
"type": "revenue_leak",
|
||||
"title_ar": "تسرب إيراد — غياب متابعة موحّدة",
|
||||
"summary_ar": "ثلاث صفقات بدون موعد تالي خلال ٧ أيام؛ خطر فقدان ١٥٪ من القيمة الموزونة.",
|
||||
"why_it_matters_ar": "التأخير يقلل احتمال الرد والإغلاق خلال نافذة الشراء.",
|
||||
"recommended_action_ar": "جدولة متابعة قصيرة مع خيار إيقاف.",
|
||||
"risk_level": "medium",
|
||||
"expected_impact_sar": 42000.0,
|
||||
"buttons": [
|
||||
{"label_ar": "اعتمد", "action_id": "approve"},
|
||||
{"label_ar": "عدّل", "action_id": "edit"},
|
||||
{"label_ar": "تخطي", "action_id": "skip"},
|
||||
],
|
||||
"approval_required": True,
|
||||
},
|
||||
{
|
||||
"type": "board_brief",
|
||||
"title_ar": "موجز للمجلس — قرار واحد هذا الأسبوع",
|
||||
"summary_ar": "الموافقة على نطاق pilot ثانٍ في قطاع الصحة أو تجميد التوسع لصيانة الجودة.",
|
||||
"why_it_matters_ar": "التركيز يحمي الجودة ويُسرّع إثبات القيمة للعميل.",
|
||||
"recommended_action_ar": "عرض موجز قرار واحد صفحة واحدة.",
|
||||
"risk_level": "low",
|
||||
"expected_impact_sar": 0.0,
|
||||
"buttons": [
|
||||
{"label_ar": "عرض الموجز", "action_id": "open_brief"},
|
||||
{"label_ar": "تأجيل", "action_id": "snooze"},
|
||||
{"label_ar": "إغلاق", "action_id": "dismiss"},
|
||||
],
|
||||
"approval_required": True,
|
||||
},
|
||||
]
|
||||
if ctx.get("append_custom") and isinstance(ctx["append_custom"], dict):
|
||||
raw_cards.append(ctx["append_custom"])
|
||||
cards = [normalize_intel_card(x) for x in raw_cards]
|
||||
return {"cards": cards, "source": "intelligence_layer", "demo": True}
|
||||
@ -1,114 +1,51 @@
|
||||
"""Intelligence Mission Engine — 7 outcome-shaped growth missions."""
|
||||
"""Mission catalog — references innovation missions without duplicating HTTP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.intelligence_layer.growth_brain import GrowthBrain
|
||||
from auto_client_acquisition.innovation.growth_missions import list_growth_missions
|
||||
|
||||
|
||||
INTEL_MISSIONS: tuple[dict[str, Any], ...] = (
|
||||
_MISSIONS_META: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "first_10_opportunities",
|
||||
"title_ar": "10 فرص في 10 دقائق",
|
||||
"goal_ar": "اكتشاف 10 شركات سعودية + رسائل عربية + موافقة + متابعة أسبوع.",
|
||||
"kill_metric": "ten_drafts_approved",
|
||||
"required_integrations": ("whatsapp",),
|
||||
"safety_rules_ar": ("لا cold WhatsApp بدون lawful basis",),
|
||||
"success_metrics": ("approve_rate ≥ 50%", "first_reply ≤ 24h"),
|
||||
"canonical_http": "POST /api/v1/intelligence/missions/first-10-opportunities",
|
||||
"safety_rules_ar": ["لا واتساب بارد", "موافقة على المسودات"],
|
||||
"required_integrations": [],
|
||||
},
|
||||
{
|
||||
"id": "revenue_leak_rescue",
|
||||
"title_ar": "أنقذ الإيراد الضائع",
|
||||
"goal_ar": "اقرأ Email/CRM/WhatsApp → استخرج leads ضائعة → drafts متابعة.",
|
||||
"kill_metric": "leads_revived",
|
||||
"required_integrations": ("gmail", "crm"),
|
||||
"safety_rules_ar": ("approval لكل follow-up",),
|
||||
"success_metrics": ("rescued_leads ≥ 5", "rescued_pipeline_sar ≥ 30000"),
|
||||
"title_ar": "إنقاذ تسريب إيراد",
|
||||
"canonical_http": "GET /api/v1/intelligence/command-feed/demo",
|
||||
"safety_rules_ar": ["مراجعة المصدر", "حد أسبوعي للمسودات"],
|
||||
"required_integrations": ["gmail_draft"],
|
||||
},
|
||||
{
|
||||
"id": "partnership_sprint",
|
||||
"title_ar": "ابدأ قناة شراكات",
|
||||
"goal_ar": "تحديد + التواصل مع 5 شركاء محتملين خلال 14 يوم.",
|
||||
"kill_metric": "partner_intros_replied",
|
||||
"required_integrations": ("gmail", "google_calendar"),
|
||||
"safety_rules_ar": ("لا outreach شخصي بدون warm context",),
|
||||
"success_metrics": ("intros_replied ≥ 2", "first_partner_meeting ≤ 14d"),
|
||||
"title_ar": "سباق شراكات",
|
||||
"canonical_http": "POST /api/v1/targeting/linkedin/strategy",
|
||||
"safety_rules_ar": ["LinkedIn Lead Gen فقط", "لا auto-DM"],
|
||||
"required_integrations": [],
|
||||
},
|
||||
{
|
||||
"id": "customer_reactivation",
|
||||
"title_ar": "استرجع العملاء الخاملين",
|
||||
"goal_ar": "ارفع قائمة قدامى → صنّفهم → رسائل عودة بـ payment link.",
|
||||
"kill_metric": "reactivated_customers",
|
||||
"required_integrations": ("whatsapp", "moyasar"),
|
||||
"safety_rules_ar": ("Opt-in موثق فقط",),
|
||||
"success_metrics": ("reactivated ≥ 10", "revenue_sar ≥ 25000"),
|
||||
"title_ar": "إعادة تفعيل عملاء",
|
||||
"canonical_http": "POST /api/v1/platform/contacts/import-preview",
|
||||
"safety_rules_ar": ["تصنيف القائمة", "opt-out فوري"],
|
||||
"required_integrations": [],
|
||||
},
|
||||
{
|
||||
"id": "meeting_booking_sprint",
|
||||
"title_ar": "احجز 3 اجتماعات",
|
||||
"goal_ar": "Top-10 leads → agenda → موافقة → calendar drafts.",
|
||||
"kill_metric": "meetings_confirmed",
|
||||
"required_integrations": ("google_calendar", "whatsapp"),
|
||||
"safety_rules_ar": ("لا insert بدون OAuth + ضغطة المستخدم",),
|
||||
"success_metrics": ("meetings_confirmed ≥ 3 / 5d",),
|
||||
},
|
||||
{
|
||||
"id": "ai_visibility_sprint",
|
||||
"title_ar": "AEO Sprint — اظهر في إجابات AI",
|
||||
"goal_ar": "تحليل ظهور الشركة + خطة محتوى 30 يوم لـ ChatGPT/Gemini/Perplexity.",
|
||||
"kill_metric": "questions_visible",
|
||||
"required_integrations": ("google_business_profile",),
|
||||
"safety_rules_ar": ("لا scraping خارج المسموح",),
|
||||
"success_metrics": ("question_visibility_lift ≥ 30%",),
|
||||
},
|
||||
{
|
||||
"id": "competitive_response",
|
||||
"title_ar": "الرد على حركة منافس",
|
||||
"goal_ar": "رصد price change/offer/hiring → ردود + حملات + ROI breakdown.",
|
||||
"kill_metric": "competitor_signals_resolved",
|
||||
"required_integrations": (),
|
||||
"safety_rules_ar": ("لا تشهير", "لا اتهام عام",),
|
||||
"success_metrics": ("retention_lift", "win_rate_lift"),
|
||||
},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def list_intel_missions() -> dict[str, Any]:
|
||||
return {
|
||||
"count": len(INTEL_MISSIONS),
|
||||
"missions": list(INTEL_MISSIONS),
|
||||
"kill_feature_id": "first_10_opportunities",
|
||||
}
|
||||
def list_mission_catalog() -> dict[str, Any]:
|
||||
gm = list_growth_missions()
|
||||
return {"missions": _MISSIONS_META, "innovation_growth_missions": gm, "demo": True}
|
||||
|
||||
|
||||
def recommend_missions(brain: GrowthBrain | None = None, *, limit: int = 3) -> dict[str, Any]:
|
||||
"""Pick top-N missions for this customer based on brain state."""
|
||||
if brain is None:
|
||||
recommended = list(INTEL_MISSIONS)[:limit]
|
||||
else:
|
||||
# Simple heuristic: kill feature first, then prioritize by integrations
|
||||
ranked: list[tuple[dict, float]] = []
|
||||
for m in INTEL_MISSIONS:
|
||||
score = 50.0
|
||||
if m["id"] == "first_10_opportunities":
|
||||
score += 50 # always priority for new customers
|
||||
req = set(m["required_integrations"])
|
||||
connected = set(brain.channels_connected)
|
||||
if req.issubset(connected):
|
||||
score += 20
|
||||
else:
|
||||
score -= 10 * (len(req - connected))
|
||||
if "fill_pipeline" in brain.growth_priorities and m["id"] in (
|
||||
"first_10_opportunities", "revenue_leak_rescue"
|
||||
):
|
||||
score += 15
|
||||
if "build_partner_channel" in brain.growth_priorities and m["id"] == "partnership_sprint":
|
||||
score += 15
|
||||
ranked.append((m, score))
|
||||
ranked.sort(key=lambda x: x[1], reverse=True)
|
||||
recommended = [m for m, _ in ranked[:limit]]
|
||||
return {
|
||||
"recommended": recommended,
|
||||
"rationale_ar": "تم الترتيب حسب priorities العميل + القنوات المربوطة.",
|
||||
}
|
||||
def get_mission(mission_id: str) -> dict[str, Any]:
|
||||
for m in _MISSIONS_META:
|
||||
if m["id"] == mission_id:
|
||||
return {**m, "found": True}
|
||||
return {"found": False, "error": "unknown_mission", "demo": True}
|
||||
|
||||
@ -1,89 +1,23 @@
|
||||
"""Opportunity Simulator — forward simulation before sending."""
|
||||
"""Simple numeric opportunity scenarios."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
# Sector benchmarks (anchored to Saudi B2B Pulse figures)
|
||||
SECTOR_RATES: dict[str, dict[str, float]] = {
|
||||
"real_estate": {"reply": 0.074, "meeting": 0.32, "win": 0.18},
|
||||
"clinics": {"reply": 0.138, "meeting": 0.40, "win": 0.28},
|
||||
"logistics": {"reply": 0.068, "meeting": 0.30, "win": 0.22},
|
||||
"hospitality": {"reply": 0.124, "meeting": 0.38, "win": 0.24},
|
||||
"restaurants": {"reply": 0.115, "meeting": 0.42, "win": 0.30},
|
||||
"training": {"reply": 0.112, "meeting": 0.36, "win": 0.25},
|
||||
"agencies": {"reply": 0.059, "meeting": 0.28, "win": 0.20},
|
||||
"construction": {"reply": 0.032, "meeting": 0.25, "win": 0.15},
|
||||
"saas": {"reply": 0.047, "meeting": 0.30, "win": 0.20},
|
||||
}
|
||||
|
||||
|
||||
def simulate_opportunity(
|
||||
*,
|
||||
target_count: int,
|
||||
sector: str = "saas",
|
||||
avg_deal_value_sar: float = 25_000,
|
||||
channel: str = "whatsapp",
|
||||
cold_pct: float = 0.0,
|
||||
quality_lift: float = 1.0, # multiplier (Dealix lift on baseline)
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Forward-simulate a campaign before launching.
|
||||
|
||||
Returns expected replies / meetings / pipeline + risk flags.
|
||||
"""
|
||||
rates = SECTOR_RATES.get(sector.lower(), SECTOR_RATES["saas"])
|
||||
|
||||
# Channel adjustment
|
||||
if channel == "whatsapp":
|
||||
reply_rate = rates["reply"] * 1.6 * quality_lift
|
||||
elif channel == "email":
|
||||
reply_rate = rates["reply"] * 0.9 * quality_lift
|
||||
else:
|
||||
reply_rate = rates["reply"] * quality_lift
|
||||
|
||||
# Cold contacts hurt the rate dramatically
|
||||
cold_pct = max(0.0, min(1.0, cold_pct))
|
||||
if cold_pct > 0:
|
||||
reply_rate *= max(0.10, 1.0 - cold_pct * 0.85)
|
||||
|
||||
expected_replies = round(target_count * reply_rate)
|
||||
expected_meetings = round(expected_replies * rates["meeting"])
|
||||
expected_deals = round(expected_meetings * rates["win"])
|
||||
expected_pipeline = expected_deals * avg_deal_value_sar
|
||||
|
||||
# Risk flags
|
||||
risks: list[str] = []
|
||||
if cold_pct >= 0.5:
|
||||
risks.append("نسبة cold عالية — احتمال opt-out مرتفع.")
|
||||
if channel == "whatsapp" and cold_pct > 0:
|
||||
risks.append("WhatsApp + cold = خطر PDPL — راجع الـ contactability.")
|
||||
if target_count > 500 and channel == "whatsapp":
|
||||
risks.append("حملة WhatsApp كبيرة — اعتمد على templates معتمدة.")
|
||||
|
||||
risk_score = min(100, int(50 + cold_pct * 50 + (10 if target_count > 500 else 0)))
|
||||
|
||||
def simulate_opportunities(inputs: dict[str, Any] | None) -> dict[str, Any]:
|
||||
ins = inputs or {}
|
||||
pipeline = float(ins.get("pipeline_sar") or 250_000)
|
||||
win_rate = float(ins.get("win_rate") or 0.18)
|
||||
forecast = round(pipeline * win_rate, 2)
|
||||
return {
|
||||
"inputs": {
|
||||
"target_count": target_count,
|
||||
"sector": sector,
|
||||
"avg_deal_value_sar": avg_deal_value_sar,
|
||||
"channel": channel,
|
||||
"cold_pct": cold_pct,
|
||||
"quality_lift": quality_lift,
|
||||
},
|
||||
"rates_used": rates,
|
||||
"expected_replies": expected_replies,
|
||||
"expected_meetings": expected_meetings,
|
||||
"expected_deals": expected_deals,
|
||||
"expected_pipeline_sar": expected_pipeline,
|
||||
"risk_score": risk_score,
|
||||
"risks_ar": risks,
|
||||
"recommendation_ar": (
|
||||
"ابدأ بالـ safe-only segment + معدّل أسبوعي محدود."
|
||||
if risk_score >= 50
|
||||
else "آمن للإطلاق بعد approval."
|
||||
),
|
||||
"approval_required": True,
|
||||
"pipeline_sar": pipeline,
|
||||
"win_rate_assumption": win_rate,
|
||||
"weighted_forecast_sar": forecast,
|
||||
"scenarios": [
|
||||
{"label_ar": "أساسي", "forecast_sar": forecast},
|
||||
{"label_ar": "تفاؤل محدود", "forecast_sar": round(forecast * 1.12, 2)},
|
||||
{"label_ar": "تحفظ", "forecast_sar": round(forecast * 0.85, 2)},
|
||||
],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,90 +1,16 @@
|
||||
"""Revenue DNA — extract the company's growth fingerprint."""
|
||||
"""Revenue DNA snapshot — structured JSON."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from typing import Any
|
||||
|
||||
|
||||
def extract_revenue_dna(
|
||||
*,
|
||||
customer_id: str,
|
||||
won_deals: list[dict[str, Any]] | None = None,
|
||||
replies: list[dict[str, Any]] | None = None,
|
||||
objections: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute the customer's growth DNA.
|
||||
|
||||
Inputs are optional; missing inputs return sensible defaults
|
||||
so the dashboard always has something to render.
|
||||
"""
|
||||
won_deals = won_deals or []
|
||||
replies = replies or []
|
||||
objections = objections or []
|
||||
|
||||
# Best channel = channel that produced the most won_deals
|
||||
chan_counter: Counter[str] = Counter()
|
||||
seg_counter: Counter[str] = Counter()
|
||||
angle_counter: Counter[str] = Counter()
|
||||
cycle_days: list[float] = []
|
||||
for d in won_deals:
|
||||
chan_counter[d.get("channel", "?")] += 1
|
||||
seg_counter[d.get("segment", "?")] += 1
|
||||
angle_counter[d.get("message_angle", "?")] += 1
|
||||
if "cycle_days" in d:
|
||||
cycle_days.append(float(d["cycle_days"]))
|
||||
|
||||
# Common objection
|
||||
obj_counter: Counter[str] = Counter()
|
||||
for o in objections:
|
||||
obj_counter[o.get("objection_id", "?")] += 1
|
||||
|
||||
next_experiment_ar = (
|
||||
"اختبر رسالة قصيرة (≤4 سطور) + CTA واحد على القناة الأنجح."
|
||||
if len(won_deals) >= 5 else
|
||||
"ركّز على بناء أول 10 deals عبر «10 فرص في 10 دقائق»."
|
||||
)
|
||||
|
||||
def build_revenue_dna(context: dict[str, Any] | None) -> dict[str, Any]:
|
||||
ctx = context or {}
|
||||
return {
|
||||
"customer_id": customer_id,
|
||||
"best_channel": (chan_counter.most_common(1)[0][0] if chan_counter else "whatsapp"),
|
||||
"best_segment": (seg_counter.most_common(1)[0][0] if seg_counter else "inbound_lead"),
|
||||
"best_message_angle": (
|
||||
angle_counter.most_common(1)[0][0] if angle_counter
|
||||
else "value_first_short_arabic"
|
||||
),
|
||||
"common_objection": (obj_counter.most_common(1)[0][0] if obj_counter else "send_offer_whatsapp"),
|
||||
"fastest_conversion_days": round(
|
||||
min(cycle_days) if cycle_days else 0, 1
|
||||
),
|
||||
"median_conversion_days": round(
|
||||
sorted(cycle_days)[len(cycle_days) // 2] if cycle_days else 0, 1
|
||||
),
|
||||
"deals_observed": len(won_deals),
|
||||
"next_experiment_ar": next_experiment_ar,
|
||||
"primary_motion_ar": str(ctx.get("primary_motion_ar") or "مبيعات مباشرة + شراكات"),
|
||||
"cycle_days_estimate": int(ctx.get("cycle_days_estimate") or 45),
|
||||
"channels_weight": {"whatsapp": 0.2, "email": 0.35, "meetings": 0.45},
|
||||
"risk_notes_ar": str(ctx.get("risk_notes_ar") or "تأخر الموافقات الداخلية لدى العميل."),
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
def build_revenue_dna_demo() -> dict[str, Any]:
|
||||
"""Demo Revenue DNA with realistic Saudi B2B values."""
|
||||
return extract_revenue_dna(
|
||||
customer_id="demo",
|
||||
won_deals=[
|
||||
{"channel": "whatsapp", "segment": "inbound_lead",
|
||||
"message_angle": "value_first_short_arabic", "cycle_days": 18},
|
||||
{"channel": "whatsapp", "segment": "existing_customer",
|
||||
"message_angle": "expansion_offer", "cycle_days": 12},
|
||||
{"channel": "email", "segment": "referral",
|
||||
"message_angle": "warm_intro", "cycle_days": 25},
|
||||
{"channel": "whatsapp", "segment": "event_lead",
|
||||
"message_angle": "value_first_short_arabic", "cycle_days": 30},
|
||||
{"channel": "whatsapp", "segment": "inbound_lead",
|
||||
"message_angle": "value_first_short_arabic", "cycle_days": 15},
|
||||
],
|
||||
objections=[
|
||||
{"objection_id": "send_offer_whatsapp"},
|
||||
{"objection_id": "send_offer_whatsapp"},
|
||||
{"objection_id": "price_high"},
|
||||
],
|
||||
)
|
||||
|
||||
@ -1,102 +1,18 @@
|
||||
"""Trust Score — composite per-action verdict before execution."""
|
||||
"""Trust score 0–100 from simple signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class TrustVerdict:
|
||||
"""Output of compute_trust_score."""
|
||||
|
||||
verdict: str # safe / needs_review / blocked
|
||||
score: int # 0-100 (higher = safer)
|
||||
reasons_ar: list[str]
|
||||
fixes_ar: list[str]
|
||||
|
||||
|
||||
def compute_trust_score(
|
||||
*,
|
||||
source_quality: str = "unknown", # public / partner / customer / cold / unknown
|
||||
opt_in: bool = False,
|
||||
channel: str = "whatsapp",
|
||||
message_text: str = "",
|
||||
frequency_count_this_week: int = 0,
|
||||
weekly_cap: int = 2,
|
||||
approval_status: str = "pending",
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Composite trust verdict on a proposed action.
|
||||
|
||||
Pure deterministic — same inputs → same verdict.
|
||||
"""
|
||||
score = 100
|
||||
reasons: list[str] = []
|
||||
fixes: list[str] = []
|
||||
|
||||
# 1. Source quality
|
||||
src_penalty = {
|
||||
"customer": 0,
|
||||
"partner": -5,
|
||||
"public": -10,
|
||||
"unknown": -25,
|
||||
"cold": -40,
|
||||
}.get(source_quality, -20)
|
||||
score += src_penalty
|
||||
if src_penalty <= -25:
|
||||
reasons.append(f"جودة المصدر منخفضة ({source_quality}).")
|
||||
fixes.append("وثّق lawful basis قبل أي تواصل.")
|
||||
|
||||
# 2. Opt-in
|
||||
if not opt_in and channel == "whatsapp":
|
||||
score -= 30
|
||||
reasons.append("لا opt-in على قناة WhatsApp.")
|
||||
fixes.append("احصل على opt-in صريح أو حوّل القناة للإيميل.")
|
||||
|
||||
# 3. Channel risk
|
||||
if channel in ("whatsapp", "instagram_graph"):
|
||||
score -= 5 # consumer-facing channels need extra care
|
||||
elif channel == "x_api":
|
||||
score -= 10 # public broadcast risk
|
||||
|
||||
# 4. Message risk — banned phrases
|
||||
risky_phrases = ("ضمان 100", "نتائج مضمونة", "آخر فرصة", "اضغط هنا فوراً")
|
||||
found = [p for p in risky_phrases if p in (message_text or "")]
|
||||
if found:
|
||||
score -= 15 * len(found)
|
||||
reasons.append(f"عبارات محظورة: {found}")
|
||||
fixes.append("احذف العبارات المبالغة قبل الإرسال.")
|
||||
|
||||
# 5. Frequency cap
|
||||
if frequency_count_this_week >= weekly_cap:
|
||||
score -= 20
|
||||
reasons.append(f"تجاوز السقف الأسبوعي ({frequency_count_this_week}/{weekly_cap}).")
|
||||
fixes.append("انتظر بداية الأسبوع التالي.")
|
||||
|
||||
# 6. Approval gate
|
||||
if approval_status == "pending":
|
||||
score -= 10
|
||||
reasons.append("لم يصل approval المشغّل بعد.")
|
||||
fixes.append("اطلب موافقة المشغّل.")
|
||||
|
||||
score = max(0, min(100, score))
|
||||
|
||||
if score >= 70:
|
||||
verdict = "safe"
|
||||
elif score >= 40:
|
||||
verdict = "needs_review"
|
||||
else:
|
||||
verdict = "blocked"
|
||||
|
||||
if not reasons:
|
||||
reasons = ["كل القواعد مستوفاة."]
|
||||
if not fixes and verdict == "safe":
|
||||
fixes = ["جاهز للتنفيذ بعد approval إذا لزم."]
|
||||
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"score": score,
|
||||
"reasons_ar": reasons,
|
||||
"fixes_ar": fixes,
|
||||
}
|
||||
def compute_trust_score(signals: dict[str, Any] | None) -> dict[str, Any]:
|
||||
s = signals or {}
|
||||
base = 55
|
||||
if s.get("has_signed_dpa"):
|
||||
base += 15
|
||||
if s.get("reply_rate_30d", 0) and float(s["reply_rate_30d"]) > 0.2:
|
||||
base += 10
|
||||
if s.get("compliance_flags"):
|
||||
base -= 20
|
||||
score = max(0, min(100, int(base)))
|
||||
return {"trust_score": score, "factors": list(s.keys()) or ["default"], "demo": True}
|
||||
|
||||
@ -1,61 +1,5 @@
|
||||
"""Launch Ops — Private Beta launch workflow + Go/No-Go gates + scorecards.
|
||||
"""Launch operations — private beta offer, demo script, outreach, go/no-go."""
|
||||
|
||||
Connects everything else into a single launch-day operating layer:
|
||||
- private_beta: today's offer, gates, FAQ
|
||||
- demo_flow: 12-min demo script consolidator
|
||||
- outreach_messages: first-20 plan + per-segment messages
|
||||
- go_no_go: deterministic launch readiness gate
|
||||
- launch_scorecard: daily ops metrics
|
||||
"""
|
||||
from auto_client_acquisition.launch_ops.private_beta import build_private_beta_offer
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .demo_flow import (
|
||||
build_12_min_demo_flow,
|
||||
build_close_script,
|
||||
build_discovery_questions,
|
||||
build_objection_responses,
|
||||
)
|
||||
from .go_no_go import build_launch_readiness, decide_go_no_go
|
||||
from .launch_scorecard import (
|
||||
build_daily_launch_scorecard,
|
||||
build_weekly_launch_scorecard,
|
||||
record_launch_event,
|
||||
)
|
||||
from .outreach_messages import (
|
||||
build_first_20_segments,
|
||||
build_followup_message,
|
||||
build_outreach_message,
|
||||
build_reply_handlers,
|
||||
)
|
||||
from .private_beta import (
|
||||
PRIVATE_BETA_OFFER,
|
||||
build_private_beta_offer,
|
||||
build_private_beta_safety_notes,
|
||||
private_beta_faq,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# private_beta
|
||||
"PRIVATE_BETA_OFFER",
|
||||
"build_private_beta_offer",
|
||||
"build_private_beta_safety_notes",
|
||||
"private_beta_faq",
|
||||
# demo_flow
|
||||
"build_12_min_demo_flow",
|
||||
"build_close_script",
|
||||
"build_discovery_questions",
|
||||
"build_objection_responses",
|
||||
# outreach_messages
|
||||
"build_first_20_segments",
|
||||
"build_followup_message",
|
||||
"build_outreach_message",
|
||||
"build_reply_handlers",
|
||||
# go_no_go
|
||||
"build_launch_readiness",
|
||||
"decide_go_no_go",
|
||||
# launch_scorecard
|
||||
"build_daily_launch_scorecard",
|
||||
"build_weekly_launch_scorecard",
|
||||
"record_launch_event",
|
||||
]
|
||||
__all__ = ["build_private_beta_offer"]
|
||||
|
||||
@ -1,104 +1,52 @@
|
||||
"""Demo flow — 12-min Arabic demo + discovery + objection handling + close."""
|
||||
"""12-minute demo script structure for founders."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_12_min_demo_flow() -> dict[str, Any]:
|
||||
"""The canonical 12-minute Arabic demo plan."""
|
||||
def build_demo_script() -> dict[str, Any]:
|
||||
return {
|
||||
"duration_minutes": 12,
|
||||
"minute_by_minute_ar": [
|
||||
"0–2: الفكرة الكبرى — Dealix ليس CRM ولا أداة واتساب.",
|
||||
"2–4: Daily Brief / Command Feed — 3 قرارات + 3 فرص + 3 مخاطر.",
|
||||
"4–6: 10 فرص في 10 دقائق — مثال حي.",
|
||||
"6–8: Trust Score + Simulator + Approval Card.",
|
||||
"8–10: الأمان والتكاملات — security_curator + connector_catalog.",
|
||||
"10–12: العرض والـ CTA — Pilot 7 أيام / 499 ريال.",
|
||||
],
|
||||
"demo_endpoints": [
|
||||
"/api/v1/personal-operator/daily-brief",
|
||||
"/api/v1/intelligence/command-feed/demo",
|
||||
"/api/v1/intelligence/missions",
|
||||
"/api/v1/targeting/free-diagnostic",
|
||||
"/api/v1/services/catalog",
|
||||
"/api/v1/launch/private-beta/offer",
|
||||
],
|
||||
"do_not_do_in_demo_ar": [
|
||||
"لا تكشف API keys على الشاشة.",
|
||||
"لا تشغّل live WhatsApp أو Gmail send.",
|
||||
"لا تعد بأرقام لم تُحقَّق.",
|
||||
"sections": [
|
||||
{
|
||||
"minute_range": "0-2",
|
||||
"title_ar": "المشكلة والوعد",
|
||||
"talking_points_ar": [
|
||||
"Dealix ليس CRM ولا بوت واتساب فقط.",
|
||||
"نحوّل الإشارات إلى قرار يومي عربي + موافقة + Proof.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"minute_range": "2-4",
|
||||
"title_ar": "Daily Brief",
|
||||
"api_hint": "GET /api/v1/personal-operator/daily-brief",
|
||||
"talking_points_ar": ["٣ قرارات", "مخاطر", "جاهزية"],
|
||||
},
|
||||
{
|
||||
"minute_range": "4-6",
|
||||
"title_ar": "Growth Operator / ١٠ فرص",
|
||||
"api_hint": "GET /api/v1/growth-operator/missions",
|
||||
"talking_points_ar": ["لماذا الآن", "Accept/Skip", "لا cold WhatsApp"],
|
||||
},
|
||||
{
|
||||
"minute_range": "6-8",
|
||||
"title_ar": "Inbox ومنصة",
|
||||
"api_hint": "GET /api/v1/platform/inbox/feed",
|
||||
"talking_points_ar": ["كروت عربية", "موافقة قبل الإرسال"],
|
||||
},
|
||||
{
|
||||
"minute_range": "8-10",
|
||||
"title_ar": "برج الخدمات",
|
||||
"api_hint": "GET /api/v1/services/catalog",
|
||||
"talking_points_ar": ["تشخيص", "قوائم", "Growth OS", "أسعار تقديرية"],
|
||||
},
|
||||
{
|
||||
"minute_range": "10-12",
|
||||
"title_ar": "Pilot وProof",
|
||||
"talking_points_ar": ["٧ أيام أو ٣٠ يوم", "Proof Pack", "الخطوة التالية"],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_discovery_questions() -> list[dict[str, str]]:
|
||||
"""5 discovery questions to ask in the demo's first 4 minutes."""
|
||||
return [
|
||||
{"key": "challenge",
|
||||
"q_ar": "وش أكبر تحدي نمو لديكم اليوم؟"},
|
||||
{"key": "current_targeting",
|
||||
"q_ar": "كيف تستهدفون اليوم؟ ما الذي يعمل؟ ما الذي لا يعمل؟"},
|
||||
{"key": "time_drain",
|
||||
"q_ar": "ما الذي يأخذ وقتاً يومياً ولا يثبت قيمة؟"},
|
||||
{"key": "old_list",
|
||||
"q_ar": "هل عندكم قائمة عملاء قدامى لم تتم متابعتهم؟"},
|
||||
{"key": "approval_owner",
|
||||
"q_ar": "من يوافق على الرسائل قبل الإرسال؟"},
|
||||
]
|
||||
|
||||
|
||||
def build_objection_responses() -> dict[str, str]:
|
||||
"""Standard Arabic objection-handling responses."""
|
||||
return {
|
||||
"price": (
|
||||
"نقدم Free Diagnostic أولاً — تشوفون عينة قبل الدفع. "
|
||||
"Pilot 499 ريال أرخص من ساعة عمل في وكالة."
|
||||
),
|
||||
"timing": (
|
||||
"Pilot 7 أيام لا يحتاج التزام طويل. "
|
||||
"نسلّم خلال أسبوع، تقررون بعدها."
|
||||
),
|
||||
"trust": (
|
||||
"Approval-first: لا نرسل أي شيء بدون موافقتكم. "
|
||||
"Audit ledger يسجل كل فعل."
|
||||
),
|
||||
"complexity": (
|
||||
"Pilot لا يحتاج تكاملات. "
|
||||
"نستلم intake في 30 دقيقة ونسلم خلال 24 ساعة."
|
||||
),
|
||||
"data_privacy": (
|
||||
"PDPL-aware من اليوم الأول. "
|
||||
"DPA draft جاهز للتوقيع. "
|
||||
"بياناتكم تُخزّن في Supabase KSA-region حسب الإمكان."
|
||||
),
|
||||
"results_uncertainty": (
|
||||
"لا نضمن أرقاماً، نضمن طريقة تشغيل + Proof Pack مفصّل. "
|
||||
"إذا ما اقتنعتم بعد 7 أيام، تأخذون Proof Pack مجاناً وتمشون."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def build_close_script() -> dict[str, Any]:
|
||||
"""The closing script — used in minute 11-12 of the demo."""
|
||||
return {
|
||||
"close_sequence_ar": [
|
||||
"هل الفكرة منطقية؟",
|
||||
"هل عندك أسئلة محددة قبل ما نبدأ؟",
|
||||
"أحدد لكم Pilot يبدأ يوم الأحد القادم — موافق؟",
|
||||
"أرسل لكم intake form + invoice خلال ساعة من نهاية المكالمة.",
|
||||
],
|
||||
"close_template_ar": (
|
||||
"تمام، نبدأ Pilot 7 أيام بـ499 ريال. "
|
||||
"أرسل لك خلال ساعة:\n"
|
||||
"1. نموذج intake.\n"
|
||||
"2. Moyasar invoice.\n"
|
||||
"3. تأكيد موعد الكيك-أوف.\n\n"
|
||||
"بعد الدفع، Pilot يبدأ يوم الأحد."
|
||||
),
|
||||
"if_hesitant_ar": (
|
||||
"إذا تحبون عينة قبل الالتزام، أرسل لكم Free Growth Diagnostic "
|
||||
"خلال 24 ساعة — 3 فرص + رسالة + توصية، بدون التزام."
|
||||
),
|
||||
"closing_line_ar": "لا نعد نتائج مضمونة — نعد مسودات وموافقات وتقارير قياس.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,130 +1,41 @@
|
||||
"""Go/No-Go launch readiness — 10 deterministic gates."""
|
||||
"""Deterministic go/no-go for private beta launch checklist."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
# All 10 gates Dealix Launch Control Room checks before approving sale.
|
||||
LAUNCH_GATES: tuple[dict[str, str], ...] = (
|
||||
{"id": "tests_passed", "label_ar": "اختبارات pytest خضراء"},
|
||||
{"id": "routes_check", "label_ar": "scripts/print_routes.py لا يكشف تكرار"},
|
||||
{"id": "no_secrets", "label_ar": "scan الأسرار نظيف"},
|
||||
{"id": "staging_health", "label_ar": "/health على staging يرجع 200"},
|
||||
{"id": "supabase_staging", "label_ar": "Supabase staging مهيأ"},
|
||||
{"id": "service_catalog", "label_ar": "/services/catalog يعمل ويعرض ≥4 خدمات"},
|
||||
{"id": "private_beta_page", "label_ar": "landing/private-beta.html جاهزة"},
|
||||
{"id": "first_20_ready", "label_ar": "أول 20 prospect معرّفون"},
|
||||
{"id": "live_sends_disabled", "label_ar": "WHATSAPP/GMAIL/CALENDAR/MOYASAR live=false"},
|
||||
{"id": "payment_manual_ready", "label_ar": "Moyasar invoice/payment link جاهز يدوياً"},
|
||||
)
|
||||
|
||||
|
||||
def build_launch_readiness(
|
||||
*, statuses: dict[str, bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build the launch-readiness checklist with current statuses.
|
||||
|
||||
Pass `statuses` as a dict of gate_id → bool. Unknown gates default to False.
|
||||
"""
|
||||
statuses = statuses or {}
|
||||
items: list[dict[str, Any]] = []
|
||||
passed = 0
|
||||
blockers: list[str] = []
|
||||
|
||||
for gate in LAUNCH_GATES:
|
||||
ok = bool(statuses.get(gate["id"], False))
|
||||
items.append({
|
||||
**gate,
|
||||
"passed": ok,
|
||||
"status": "✅" if ok else "🔴",
|
||||
})
|
||||
if ok:
|
||||
passed += 1
|
||||
else:
|
||||
blockers.append(gate["label_ar"])
|
||||
|
||||
total = len(LAUNCH_GATES)
|
||||
pct = round(100.0 * passed / total, 1) if total else 0.0
|
||||
|
||||
return {
|
||||
"total_gates": total,
|
||||
"passed_gates": passed,
|
||||
"passed_pct": pct,
|
||||
"items": items,
|
||||
"blockers_ar": blockers,
|
||||
"ready_threshold_min_pct": 70.0,
|
||||
def evaluate_go_no_go(flags: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""flags: optional overrides for tests (e.g. tests_pass=False)."""
|
||||
f = flags or {}
|
||||
checks = {
|
||||
"tests_pass": bool(f.get("tests_pass", True)),
|
||||
"routes_ok": bool(f.get("routes_ok", True)),
|
||||
"staging_health_ok": bool(f.get("staging_health_ok", False)),
|
||||
"no_secrets_in_repo_scan": bool(f.get("no_secrets_in_repo_scan", True)),
|
||||
"whatsapp_live_send_disabled": bool(f.get("whatsapp_live_send_disabled", True)),
|
||||
"service_catalog_ok": bool(f.get("service_catalog_ok", True)),
|
||||
"landing_ready": bool(f.get("landing_ready", True)),
|
||||
}
|
||||
|
||||
|
||||
def decide_go_no_go(
|
||||
*, statuses: dict[str, bool] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Decide whether Dealix can sell today.
|
||||
|
||||
Rules:
|
||||
- All "critical" gates must pass: no_secrets, live_sends_disabled, staging_health.
|
||||
- At least 7 of 10 gates must pass overall.
|
||||
"""
|
||||
readiness = build_launch_readiness(statuses=statuses)
|
||||
passed_pct = readiness["passed_pct"]
|
||||
items = {it["id"]: it for it in readiness["items"]}
|
||||
|
||||
critical = ("no_secrets", "live_sends_disabled", "staging_health")
|
||||
critical_failed = [c for c in critical if not items.get(c, {}).get("passed")]
|
||||
|
||||
if critical_failed:
|
||||
verdict = "no_go"
|
||||
reason_ar = (
|
||||
f"بوابات حرجة فشلت: {', '.join(critical_failed)}. "
|
||||
"لا تبيع اليوم."
|
||||
)
|
||||
elif passed_pct >= 70:
|
||||
verdict = "go"
|
||||
reason_ar = (
|
||||
f"الجاهزية {passed_pct}%. "
|
||||
"ابدأ Private Beta — لا Public Launch."
|
||||
)
|
||||
else:
|
||||
verdict = "fix_then_go"
|
||||
reason_ar = (
|
||||
f"الجاهزية {passed_pct}% — أقل من 70%. "
|
||||
"ابدأ بإصلاح: " + ", ".join(readiness["blockers_ar"][:3])
|
||||
)
|
||||
|
||||
critical = [
|
||||
"tests_pass",
|
||||
"routes_ok",
|
||||
"no_secrets_in_repo_scan",
|
||||
"whatsapp_live_send_disabled",
|
||||
"service_catalog_ok",
|
||||
]
|
||||
blockers = [k for k in critical if not checks[k]]
|
||||
if not checks["landing_ready"]:
|
||||
blockers.append("landing_ready")
|
||||
go = len(blockers) == 0
|
||||
warnings_ar: list[str] = []
|
||||
if not checks["staging_health_ok"]:
|
||||
warnings_ar.append("Staging غير مؤكد — يُنصح بتشغيل /health على بيئة staging قبل أول عميل.")
|
||||
return {
|
||||
"verdict": verdict,
|
||||
"reason_ar": reason_ar,
|
||||
"readiness": readiness,
|
||||
"next_actions_ar": _next_actions(readiness),
|
||||
"go": go,
|
||||
"checks": checks,
|
||||
"blockers": blockers,
|
||||
"warnings_ar": warnings_ar,
|
||||
"verdict_ar": "جاهز للبيتا الخاصة (كود وعمليات أساسية)" if go else "موقوف — راجع قائمة الـ blockers.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
def _next_actions(readiness: dict[str, Any]) -> list[str]:
|
||||
"""Build concrete next-actions for any failing gates."""
|
||||
by_id = {it["id"]: it for it in readiness["items"]}
|
||||
actions: list[str] = []
|
||||
if not by_id["tests_passed"]["passed"]:
|
||||
actions.append("شغّل: pytest -q")
|
||||
if not by_id["routes_check"]["passed"]:
|
||||
actions.append("شغّل: python scripts/print_routes.py")
|
||||
if not by_id["no_secrets"]["passed"]:
|
||||
actions.append("شغّل grep scan + ألغِ أي مفتاح ظهر.")
|
||||
if not by_id["staging_health"]["passed"]:
|
||||
actions.append("انشر على Railway: railway up + curl /health.")
|
||||
if not by_id["supabase_staging"]["passed"]:
|
||||
actions.append("شغّل: supabase db push --dry-run ثم db push.")
|
||||
if not by_id["service_catalog"]["passed"]:
|
||||
actions.append("افحص: curl /api/v1/services/catalog.")
|
||||
if not by_id["private_beta_page"]["passed"]:
|
||||
actions.append("افتح landing/private-beta.html وتحقق من CTA.")
|
||||
if not by_id["first_20_ready"]["passed"]:
|
||||
actions.append("جهز Sheet 'Dealix First 20 Pipeline' بالعمدة.")
|
||||
if not by_id["live_sends_disabled"]["passed"]:
|
||||
actions.append(
|
||||
"تأكد: WHATSAPP_ALLOW_LIVE_SEND=false (وما يماثلها)."
|
||||
)
|
||||
if not by_id["payment_manual_ready"]["passed"]:
|
||||
actions.append("افتح Moyasar dashboard وجهّز invoice template.")
|
||||
return actions
|
||||
|
||||
@ -1,140 +1,36 @@
|
||||
"""Launch scorecard — daily and weekly metrics for Private Beta ops."""
|
||||
"""Readiness scorecard for launch — simple weighted score."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
# Valid event types the launch scorecard accepts.
|
||||
VALID_LAUNCH_EVENTS: tuple[str, ...] = (
|
||||
"outreach_sent",
|
||||
"reply_received",
|
||||
"demo_booked",
|
||||
"demo_held",
|
||||
"diagnostic_delivered",
|
||||
"pilot_offered",
|
||||
"pilot_paid",
|
||||
"pilot_committed",
|
||||
"pilot_lost",
|
||||
"case_study_published",
|
||||
"blocked_action",
|
||||
)
|
||||
|
||||
# Daily targets per the launch plan.
|
||||
DAILY_TARGETS: dict[str, int] = {
|
||||
"outreach_sent": 20,
|
||||
"reply_received": 5,
|
||||
"demo_booked": 3,
|
||||
"pilot_paid": 1,
|
||||
}
|
||||
|
||||
# Weekly targets (7-day plan).
|
||||
WEEKLY_TARGETS: dict[str, int] = {
|
||||
"outreach_sent": 100,
|
||||
"reply_received": 20,
|
||||
"demo_booked": 10,
|
||||
"pilot_paid": 2,
|
||||
}
|
||||
from auto_client_acquisition.launch_ops.go_no_go import evaluate_go_no_go
|
||||
|
||||
|
||||
def record_launch_event(
|
||||
*,
|
||||
event_type: str,
|
||||
customer_id: str | None = None,
|
||||
notes: str | None = None,
|
||||
event_log: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Record a launch event into an in-memory log.
|
||||
|
||||
Returns the appended entry (validated). Raises ValueError on unknown type.
|
||||
"""
|
||||
if event_type not in VALID_LAUNCH_EVENTS:
|
||||
raise ValueError(
|
||||
f"Unknown launch event: {event_type}. "
|
||||
f"Valid: {', '.join(VALID_LAUNCH_EVENTS)}"
|
||||
)
|
||||
entry: dict[str, Any] = {
|
||||
"event_type": event_type,
|
||||
"customer_id": customer_id,
|
||||
"notes": (notes or "")[:300],
|
||||
}
|
||||
if event_log is not None:
|
||||
event_log.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def _aggregate(events: list[dict[str, Any]]) -> dict[str, int]:
|
||||
counts: dict[str, int] = defaultdict(int)
|
||||
for e in events or []:
|
||||
et = str(e.get("event_type", ""))
|
||||
counts[et] += 1
|
||||
return dict(counts)
|
||||
|
||||
|
||||
def build_daily_launch_scorecard(
|
||||
*, events: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build today's Arabic launch scorecard from event log."""
|
||||
counts = _aggregate(events or [])
|
||||
metrics = {k: counts.get(k, 0) for k in VALID_LAUNCH_EVENTS}
|
||||
|
||||
progress: dict[str, dict[str, int | float]] = {}
|
||||
for k, target in DAILY_TARGETS.items():
|
||||
actual = metrics.get(k, 0)
|
||||
pct = round(100 * actual / target, 1) if target else 0.0
|
||||
progress[k] = {"actual": actual, "target": target, "pct": pct}
|
||||
|
||||
summary_lines = [
|
||||
f"تواصل اليوم: {metrics['outreach_sent']} / {DAILY_TARGETS['outreach_sent']}",
|
||||
f"ردود: {metrics['reply_received']} / {DAILY_TARGETS['reply_received']}",
|
||||
f"ديموهات: {metrics['demo_booked']} / {DAILY_TARGETS['demo_booked']}",
|
||||
f"Pilots مدفوعة: {metrics['pilot_paid']} / {DAILY_TARGETS['pilot_paid']}",
|
||||
f"مخاطر منعت: {metrics.get('blocked_action', 0)}",
|
||||
]
|
||||
|
||||
def build_launch_scorecard(extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
ex = extra or {}
|
||||
g = evaluate_go_no_go(ex)
|
||||
score = 100
|
||||
if not g["checks"].get("tests_pass"):
|
||||
score -= 30
|
||||
if not g["checks"].get("routes_ok"):
|
||||
score -= 15
|
||||
if not g["checks"].get("staging_health_ok"):
|
||||
score -= 10
|
||||
if not g["checks"].get("no_secrets_in_repo_scan"):
|
||||
score -= 40
|
||||
if not g["checks"].get("whatsapp_live_send_disabled"):
|
||||
score -= 25
|
||||
if not g["checks"].get("service_catalog_ok"):
|
||||
score -= 10
|
||||
if not g["checks"].get("landing_ready"):
|
||||
score -= 5
|
||||
score = max(0, min(100, score))
|
||||
status = "ready" if score >= 75 and g["go"] else "needs_work"
|
||||
return {
|
||||
"metrics": metrics,
|
||||
"targets": DAILY_TARGETS,
|
||||
"progress": progress,
|
||||
"summary_ar": summary_lines,
|
||||
}
|
||||
|
||||
|
||||
def build_weekly_launch_scorecard(
|
||||
*, events: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the 7-day Arabic launch scorecard."""
|
||||
counts = _aggregate(events or [])
|
||||
metrics = {k: counts.get(k, 0) for k in VALID_LAUNCH_EVENTS}
|
||||
|
||||
progress = {}
|
||||
for k, target in WEEKLY_TARGETS.items():
|
||||
actual = metrics.get(k, 0)
|
||||
pct = round(100 * actual / target, 1) if target else 0.0
|
||||
progress[k] = {"actual": actual, "target": target, "pct": pct}
|
||||
|
||||
summary_lines = [
|
||||
f"تواصل الأسبوع: {metrics['outreach_sent']} / {WEEKLY_TARGETS['outreach_sent']}",
|
||||
f"ردود: {metrics['reply_received']} / {WEEKLY_TARGETS['reply_received']}",
|
||||
f"ديموهات منعقدة: {metrics.get('demo_held', 0)}",
|
||||
f"Pilots مدفوعة: {metrics['pilot_paid']} / {WEEKLY_TARGETS['pilot_paid']}",
|
||||
f"Pilots commitments: {metrics.get('pilot_committed', 0)}",
|
||||
f"Pilots خسرت: {metrics.get('pilot_lost', 0)}",
|
||||
f"مخاطر منعت: {metrics.get('blocked_action', 0)}",
|
||||
]
|
||||
|
||||
if metrics["pilot_paid"] >= WEEKLY_TARGETS["pilot_paid"]:
|
||||
verdict = "on_track"
|
||||
elif metrics["demo_booked"] >= 5:
|
||||
verdict = "promising"
|
||||
else:
|
||||
verdict = "needs_focus"
|
||||
|
||||
return {
|
||||
"metrics": metrics,
|
||||
"targets": WEEKLY_TARGETS,
|
||||
"progress": progress,
|
||||
"summary_ar": summary_lines,
|
||||
"verdict": verdict,
|
||||
"readiness_score": score,
|
||||
"status": status,
|
||||
"go_no_go": g,
|
||||
"summary_ar": f"درجة الجاهزية {score}/١٠٠ — {status}.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,188 +1,64 @@
|
||||
"""First 20 outreach segments + per-segment Arabic messages + reply handlers."""
|
||||
"""First outreach batch — templates for warm outbound (manual send only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_first_20_segments() -> dict[str, Any]:
|
||||
"""The deterministic first-20 plan — 4 segments × 5 prospects each."""
|
||||
def build_first_twenty_outreach() -> dict[str, Any]:
|
||||
return {
|
||||
"total_targets": 20,
|
||||
"segments": [
|
||||
"count": 20,
|
||||
"disclaimer_ar": "هذه قوالب للنسخ اليدوي — لا يُرسل من Dealix تلقائياً.",
|
||||
"messages": [
|
||||
{
|
||||
"id": "agency_b2b",
|
||||
"label_ar": "وكالات تسويق B2B",
|
||||
"count": 5,
|
||||
"best_offer_id": "agency_partner_program",
|
||||
"fallback_offer_id": "partner_sprint",
|
||||
"primary_channel": "email",
|
||||
"id": 1,
|
||||
"audience_ar": "مؤسس B2B",
|
||||
"subject_ar": "تجربة بيتا — ١٠ فرص خلال أسبوع",
|
||||
"body_ar": (
|
||||
"هلا [الاسم]، نجرب Dealix كمدير نمو عربي: نطلع لك فرصاً مناسبة، "
|
||||
"نكتب رسائل عربية، وأنت توافق قبل أي تواصل. مهتم نعرض لك عينة قصيرة؟"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "training_consulting",
|
||||
"label_ar": "شركات تدريب واستشارات",
|
||||
"count": 5,
|
||||
"best_offer_id": "first_10_opportunities_sprint",
|
||||
"fallback_offer_id": "free_growth_diagnostic",
|
||||
"primary_channel": "email",
|
||||
"id": 2,
|
||||
"audience_ar": "وكالة تسويق",
|
||||
"subject_ar": "Pilot مشترك مع وكالة",
|
||||
"body_ar": (
|
||||
"هلا [الاسم]، نبحث وكالة واحدة لتجربة Dealix على عميل حقيقي: "
|
||||
"فرص + مسودات + Proof Pack. يناسبكم ديمو ١٥ دقيقة؟"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "saas_tech_small",
|
||||
"label_ar": "SaaS / تقنية صغيرة",
|
||||
"count": 5,
|
||||
"best_offer_id": "first_10_opportunities_sprint",
|
||||
"fallback_offer_id": "growth_os_monthly",
|
||||
"primary_channel": "linkedin_lead_form",
|
||||
"id": 3,
|
||||
"audience_ar": "شركة تدريب",
|
||||
"subject_ar": "فرص شركات في قطاعكم",
|
||||
"body_ar": (
|
||||
"هلا [الاسم]، Dealix يساعد فرق التدريب تطلع فرص B2B مع سبب «لماذا الآن» "
|
||||
"ومسودات عربية. نقدر نرسل لكم تشخيصاً مجانياً مختصراً؟"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "services_with_whatsapp",
|
||||
"label_ar": "شركات خدمات لديها واتساب نشط",
|
||||
"count": 5,
|
||||
"best_offer_id": "list_intelligence",
|
||||
"fallback_offer_id": "whatsapp_compliance_setup",
|
||||
"primary_channel": "email",
|
||||
"id": 4,
|
||||
"audience_ar": "SaaS صغير",
|
||||
"subject_ar": "Pipeline بدون فوضى قنوات",
|
||||
"body_ar": (
|
||||
"هلا [الاسم]، إذا عندكم قائمة عملاء أو leads، نقدر نصنّفها ونطلع أفضل أهداف "
|
||||
"مع تقرير مخاطر — بدون إرسال واتساب بارد. مهتم؟"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"audience_ar": "متابعة ١",
|
||||
"subject_ar": "متابعة خفيفة",
|
||||
"body_ar": "تذكير لطيف: إذا يناسبكم، أرسل لي قطاعكم ومدينتكم وأجهز عينة خلال ٢٤ ساعة.",
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"audience_ar": "متابعة ٢",
|
||||
"subject_ar": "إغلاق مهذب",
|
||||
"body_ar": "إذا التوقيت مو مناسب، أقدر أرجع بعد أسبوعين — أو أغلق الملف عندكم برسالة «لا شكراً».",
|
||||
},
|
||||
],
|
||||
"rules_ar": [
|
||||
"لا scraping ولا قوائم مشتراة.",
|
||||
"استخدم علاقاتك المباشرة + جهات تعرفها.",
|
||||
"كل رسالة يدوية، لا automation.",
|
||||
"حد أقصى 3 follow-ups ثم أرشفة.",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
_BASE_INTRO = "هلا [الاسم]، أطلقنا Beta محدودة لـ Dealix."
|
||||
|
||||
|
||||
def build_outreach_message(segment_id: str, *, name: str = "[الاسم]") -> dict[str, Any]:
|
||||
"""Build the first-touch Arabic message for a segment."""
|
||||
intro = f"هلا {name}،"
|
||||
|
||||
if segment_id == "agency_b2b":
|
||||
body = (
|
||||
f"{intro} عندي Beta خاص للوكالات.\n\n"
|
||||
"Dealix يساعد الوكالة تطلع فرص لعملائها، تجهز رسائل عربية، تدير "
|
||||
"موافقات، وتطلع Proof Pack باسم الوكالة والعميل.\n\n"
|
||||
"أبحث عن وكالة واحدة نجرب معها Pilot مشترك على عميل حقيقي. "
|
||||
"يناسبك ديمو 15 دقيقة؟"
|
||||
)
|
||||
elif segment_id == "training_consulting":
|
||||
body = (
|
||||
f"{intro} متابع توسع شركتكم في برامج الشركات.\n\n"
|
||||
"Dealix يطلع لكم 10 فرص B2B خلال 7 أيام، يكتب الرسائل بالعربي، "
|
||||
"ويخلي صاحب القرار يوافق قبل أي تواصل، وبعدها يعطي Proof Pack.\n\n"
|
||||
"Pilot بـ499 ريال أو مجاني مقابل case study. يناسبك ديمو 12 دقيقة؟"
|
||||
)
|
||||
elif segment_id == "saas_tech_small":
|
||||
body = (
|
||||
f"{intro} رأيت إصدار النسخة الجديدة من منتجكم — مبروك.\n\n"
|
||||
"نشتغل على مدير نمو عربي يطلع 10 فرص B2B، يستخدم LinkedIn Lead "
|
||||
"Forms (لا scraping)، ويكتب الرسائل بالعربي.\n\n"
|
||||
"أبغى أجربه مع شركة SaaS سعودية واحدة. يناسبك ديمو 12 دقيقة؟"
|
||||
)
|
||||
elif segment_id == "services_with_whatsapp":
|
||||
body = (
|
||||
f"{intro} عندكم قاعدة عملاء واتساب نشطة، صحيح؟\n\n"
|
||||
"Dealix ينظف القائمة، يصنف الـ opt-in، يحظر cold WhatsApp تلقائياً، "
|
||||
"ويكتب رسائل عربية للحملات الآمنة + Proof Pack شهري.\n\n"
|
||||
"List Intelligence بـ499–1,500 ريال. يناسبك أعطيك تشخيص مجاني أولاً؟"
|
||||
)
|
||||
else:
|
||||
body = (
|
||||
f"{intro} {_BASE_INTRO}\n\n"
|
||||
"Dealix يطلع لك 10 فرص B2B + رسائل عربية + Proof Pack — "
|
||||
"وأنت توافق قبل أي تواصل. Pilot 7 أيام بـ499 ريال. "
|
||||
"يناسبك ديمو 12 دقيقة؟"
|
||||
)
|
||||
|
||||
return {
|
||||
"segment_id": segment_id,
|
||||
"channel": "email_or_dm",
|
||||
"body_ar": body,
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def build_followup_message(
|
||||
segment_id: str, *, step: int = 1, name: str = "[الاسم]",
|
||||
) -> dict[str, Any]:
|
||||
"""Build follow-up #1, #2, or #3 (final archive)."""
|
||||
if step <= 1:
|
||||
body = (
|
||||
f"هلا {name}، أرسل لك مثال سريع بدل شرح طويل؟\n"
|
||||
"أقدر أطلع لك عينة من 3 فرص مناسبة لشركتكم + رسالة واحدة جاهزة + "
|
||||
"ملاحظة عن أفضل قناة. إذا أعجبتك نكمل Pilot كامل."
|
||||
)
|
||||
kind = "followup_1"
|
||||
elif step == 2:
|
||||
body = (
|
||||
f"هلا {name}، أعرف أن وقتك مزدحم.\n"
|
||||
"سؤال أخير: لو طلعت لك 3 فرص B2B بالعربي مجاناً هذا الأسبوع، "
|
||||
"تعطيني 15 دقيقة feedback؟"
|
||||
)
|
||||
kind = "followup_2"
|
||||
else:
|
||||
body = (
|
||||
f"هلا {name}، أعتذر على الإلحاح.\n"
|
||||
"أرشّفها وأكون موجود لو احتجتني لاحقاً. شاكر لك."
|
||||
)
|
||||
kind = "followup_3_final"
|
||||
|
||||
return {
|
||||
"segment_id": segment_id,
|
||||
"step": step,
|
||||
"kind": kind,
|
||||
"body_ar": body,
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
}
|
||||
|
||||
|
||||
def build_reply_handlers() -> dict[str, dict[str, str]]:
|
||||
"""Standard reply-classifier → response mapping (Arabic)."""
|
||||
return {
|
||||
"interested": {
|
||||
"label_ar": "مهتم",
|
||||
"response_ar": (
|
||||
"ممتاز. أرسل لك intake form + موعد ديمو 12 دقيقة هذا الأسبوع. "
|
||||
"أي وقت يناسبك بين 10 ص و 5 م؟"
|
||||
),
|
||||
"next_action": "send_intake_and_demo_link",
|
||||
},
|
||||
"needs_more_info": {
|
||||
"label_ar": "يحتاج معلومات أكثر",
|
||||
"response_ar": (
|
||||
"أرسل لك Free Growth Diagnostic — 3 فرص + رسالة + توصية، "
|
||||
"بدون التزام. أحتاج فقط: قطاعكم، مدينتكم، عرضكم الرئيسي."
|
||||
),
|
||||
"next_action": "send_free_diagnostic_intake",
|
||||
},
|
||||
"price_objection": {
|
||||
"label_ar": "اعتراض سعر",
|
||||
"response_ar": (
|
||||
"تمام، نبدأ بـ Free Diagnostic مجاناً. "
|
||||
"تشوفون النتائج قبل أي دفع."
|
||||
),
|
||||
"next_action": "send_free_diagnostic_intake",
|
||||
},
|
||||
"not_now": {
|
||||
"label_ar": "ليس الآن",
|
||||
"response_ar": (
|
||||
"تمام، شاكر لك. أتواصل معك بعد شهرين بدون إلحاح. "
|
||||
"إن احتجتنا قبل، أنا موجود."
|
||||
),
|
||||
"next_action": "schedule_followup_60_days",
|
||||
},
|
||||
"no_thanks": {
|
||||
"label_ar": "غير مهتم",
|
||||
"response_ar": "تمام، شاكر لك. أرشّفها وأتمنى لكم التوفيق.",
|
||||
"next_action": "archive",
|
||||
},
|
||||
"unsubscribe": {
|
||||
"label_ar": "إلغاء",
|
||||
"response_ar": "تم. لن أتواصل معك مجدداً.",
|
||||
"next_action": "honor_opt_out_immediately",
|
||||
},
|
||||
"note_ar": "كرّر وأكيّف الرسائل ٤–٦ لباقي الـ ٢٠ جهة — نفس النبرة الدافئة.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,110 +1,28 @@
|
||||
"""Private Beta offer — today's offer + safety notes + FAQ."""
|
||||
"""Private beta commercial offer — deterministic copy."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
PRIVATE_BETA_OFFER: dict[str, Any] = {
|
||||
"offer_id": "private_beta_pilot_7d",
|
||||
"name_ar": "Private Beta Pilot — 7 أيام",
|
||||
"promise_ar": (
|
||||
"خلال 7 أيام نطلع لك 10 فرص B2B + رسائل عربية + خطة متابعة + Proof Pack، "
|
||||
"وأنت توافق قبل أي تواصل."
|
||||
),
|
||||
"deliverables_ar": [
|
||||
"10 فرص B2B مع why-now + buying committee.",
|
||||
"10 رسائل عربية بنبرة سعودية طبيعية.",
|
||||
"تصنيف القنوات (safe / needs_review / blocked) لكل contact.",
|
||||
"خطة متابعة 7 أيام.",
|
||||
"Proof Pack مختصر (PDF + JSON).",
|
||||
"جلسة مراجعة 30 دقيقة في نهاية الأسبوع.",
|
||||
],
|
||||
"price_sar": 499,
|
||||
"free_alternative_ar": "مجاني مقابل case study بعد انتهاء الـ Pilot.",
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
"duration_days": 7,
|
||||
"seats_available": 5,
|
||||
}
|
||||
|
||||
|
||||
def build_private_beta_offer(*, seats_remaining: int | None = None) -> dict[str, Any]:
|
||||
"""Build today's Private Beta offer card. Seats are configurable."""
|
||||
out = dict(PRIVATE_BETA_OFFER)
|
||||
if seats_remaining is not None:
|
||||
out["seats_available"] = max(0, int(seats_remaining))
|
||||
out["upsell_path"] = [
|
||||
"growth_os_pilot_30d",
|
||||
"growth_os_monthly",
|
||||
]
|
||||
return out
|
||||
|
||||
|
||||
def build_private_beta_safety_notes() -> dict[str, Any]:
|
||||
"""Return the explicit 'what we will NOT do today' list."""
|
||||
def build_private_beta_offer() -> dict[str, Any]:
|
||||
return {
|
||||
"title_ar": "ضمانات Dealix",
|
||||
"do_not_do_ar": [
|
||||
"لا live WhatsApp send بدون env flag + اعتماد بشري.",
|
||||
"لا live Gmail send.",
|
||||
"لا Calendar insert تلقائي.",
|
||||
"لا charge Moyasar تلقائي — invoice/payment link يدوي فقط.",
|
||||
"لا scraping LinkedIn ولا auto-DM.",
|
||||
"لا cold WhatsApp (PDPL).",
|
||||
"لا وعود بنتائج مضمونة.",
|
||||
"لا تخزين بيانات بطاقات.",
|
||||
"title_ar": "Dealix — البيتا الخاصة",
|
||||
"tagline_ar": "مدير نمو عربي: فرص، مسودات، موافقة، Proof — بدون إرسال حي افتراضياً.",
|
||||
"included_ar": [
|
||||
"تشخيص نمو مجاني أو مدفوع حسب الاتفاق",
|
||||
"سباق ١٠ فرص أو ذكاء قائمة (حسب الحالة)",
|
||||
"كروت موافقة عربية (أزرار ≤٣)",
|
||||
"Proof Pack أسبوعي تجريبي",
|
||||
],
|
||||
"do_ar": [
|
||||
"Approval-first في كل قناة.",
|
||||
"Audit ledger لكل فعل.",
|
||||
"Saudi Tone + Safety eval قبل أي رسالة.",
|
||||
"Reputation Guard يوقف القناة عند تدهور السمعة.",
|
||||
"Free Diagnostic قبل أي التزام.",
|
||||
"excluded_ar": [
|
||||
"إرسال واتساب جماعي بارد",
|
||||
"Gmail إرسال تلقائي",
|
||||
"إدراج تقويم حي بدون موافقة",
|
||||
"شحن بطاقات داخل Dealix",
|
||||
],
|
||||
"pilot_pricing_sar": {"low": 499, "high": 3000, "note_ar": "٧ أيام أو ٣٠ يوم — حسب النطاق"},
|
||||
"monthly_after_sar": {"low": 2999, "high": 9999},
|
||||
"live_send_default": False,
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
|
||||
def private_beta_faq() -> list[dict[str, str]]:
|
||||
"""Common Arabic FAQ entries for the Private Beta page."""
|
||||
return [
|
||||
{
|
||||
"q_ar": "كيف يعمل Pilot الـ7 أيام؟",
|
||||
"a_ar": (
|
||||
"نأخذ منك intake (قطاع/مدينة/عرض/هدف) خلال 30 دقيقة. "
|
||||
"خلال 24 ساعة عمل نسلّم 10 فرص + رسائل + تصنيف القنوات. "
|
||||
"خلال الأسبوع نتابع الردود ونحدّث Proof Pack."
|
||||
),
|
||||
},
|
||||
{
|
||||
"q_ar": "هل ترسلون رسائل بدون موافقتي؟",
|
||||
"a_ar": "لا. كل رسالة تظل draft حتى توافق عليها صراحة.",
|
||||
},
|
||||
{
|
||||
"q_ar": "ماذا لو ما رد أحد؟",
|
||||
"a_ar": (
|
||||
"Proof Pack يوضح المخاطر التي منعناها + توصية بقطاع/زاوية مختلفة. "
|
||||
"Pilot يثبت طريقة التشغيل وليس عدداً مضموناً من الصفقات."
|
||||
),
|
||||
},
|
||||
{
|
||||
"q_ar": "هل تعرفون شروط واتساب ولينكدإن؟",
|
||||
"a_ar": (
|
||||
"نعم. لا cold WhatsApp بدون opt-in. "
|
||||
"لا scraping ولا auto-DM في LinkedIn — نستخدم Lead Gen Forms والمهام اليدوية."
|
||||
),
|
||||
},
|
||||
{
|
||||
"q_ar": "كيف أدفع 499 ريال؟",
|
||||
"a_ar": (
|
||||
"نرسل لك Moyasar invoice أو payment link من الـ dashboard. "
|
||||
"بعد الدفع نبدأ Pilot يوم الأحد التالي."
|
||||
),
|
||||
},
|
||||
{
|
||||
"q_ar": "هل يصلح للوكالات؟",
|
||||
"a_ar": (
|
||||
"نعم — Agency Partner Program يعطي الوكالة co-branded Proof Pack + "
|
||||
"revenue share على عملائها. تواصل معنا مباشرة للترتيب."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@ -1,25 +1,11 @@
|
||||
"""Meeting Intelligence — pre-meeting briefs + post-meeting follow-ups.
|
||||
"""Meeting intelligence — transcript text to brief/follow-up (no Calendar insert)."""
|
||||
|
||||
Designed to consume Google Meet transcripts (when OAuth + scopes allow) but
|
||||
works fine with manually-pasted transcripts during private beta.
|
||||
|
||||
All outputs are Arabic, deterministic, and approval-required before any
|
||||
external action.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from .deal_risk import compute_deal_risk
|
||||
from .followup_builder import build_post_meeting_followup
|
||||
from .meeting_brief import build_pre_meeting_brief
|
||||
from .objection_extractor import extract_objections
|
||||
from .transcript_parser import parse_transcript_entries, summarize_meeting
|
||||
from auto_client_acquisition.meeting_intelligence.followup_builder import build_post_meeting_followup
|
||||
from auto_client_acquisition.meeting_intelligence.meeting_brief import build_pre_meeting_brief
|
||||
from auto_client_acquisition.meeting_intelligence.transcript_parser import summarize_transcript_text
|
||||
|
||||
__all__ = [
|
||||
"build_post_meeting_followup",
|
||||
"build_pre_meeting_brief",
|
||||
"compute_deal_risk",
|
||||
"extract_objections",
|
||||
"parse_transcript_entries",
|
||||
"summarize_meeting",
|
||||
"summarize_transcript_text",
|
||||
]
|
||||
|
||||
@ -1,81 +1,18 @@
|
||||
"""Deal risk score from meeting + objection signals."""
|
||||
"""Deal risk hint from simple signals."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def compute_deal_risk(
|
||||
*,
|
||||
objections: list[dict[str, Any]] | None = None,
|
||||
next_step_set: bool = False,
|
||||
decision_maker_present: bool = False,
|
||||
days_since_last_touch: int = 0,
|
||||
expected_value_sar: float = 0.0,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Compute a deal-level risk score (0..100) from meeting outcomes.
|
||||
|
||||
Higher = riskier. Returns deterministic Arabic risk reasons.
|
||||
"""
|
||||
objections = objections or []
|
||||
score = 0
|
||||
reasons_ar: list[str] = []
|
||||
|
||||
# Objection-based risk.
|
||||
categories = {str(o.get("category", "")).lower() for o in objections}
|
||||
if "price" in categories:
|
||||
score += 20
|
||||
reasons_ar.append("اعتراض على السعر — يحتاج إثبات قيمة وعينة محسوبة.")
|
||||
if "timing" in categories:
|
||||
score += 15
|
||||
reasons_ar.append("اعتراض توقيت — احفظ الفرصة لربع لاحق.")
|
||||
if "authority" in categories:
|
||||
score += 25
|
||||
reasons_ar.append("صاحب القرار غير حاضر — يلزم اجتماع ثانٍ معه.")
|
||||
if "trust" in categories:
|
||||
score += 20
|
||||
reasons_ar.append("قلق أمان/خصوصية — أرفق DPA و PDPL.")
|
||||
if "integration" in categories:
|
||||
score += 10
|
||||
reasons_ar.append("قلق تكامل — حضّر مخطط ربط CRM.")
|
||||
if "competitor" in categories:
|
||||
score += 15
|
||||
reasons_ar.append("بديل قائم — جهّز battlecard مقارنة.")
|
||||
|
||||
# Process risk.
|
||||
if not next_step_set:
|
||||
score += 25
|
||||
reasons_ar.append("لم يتم تحديد خطوة تالية بتاريخ — أعلى مؤشر فقدان.")
|
||||
if not decision_maker_present:
|
||||
score += 10
|
||||
reasons_ar.append("صانع القرار لم يحضر الاجتماع.")
|
||||
if days_since_last_touch > 14:
|
||||
score += 10
|
||||
reasons_ar.append(
|
||||
f"مرّ {days_since_last_touch} يوم على آخر تواصل — فرصة باردة."
|
||||
)
|
||||
|
||||
# Cap.
|
||||
score = max(0, min(100, score))
|
||||
|
||||
if score >= 70:
|
||||
risk_level = "high"
|
||||
elif score >= 40:
|
||||
risk_level = "medium"
|
||||
else:
|
||||
risk_level = "low"
|
||||
|
||||
return {
|
||||
"risk_score": score,
|
||||
"risk_level": risk_level,
|
||||
"reasons_ar": reasons_ar,
|
||||
"expected_value_sar": expected_value_sar,
|
||||
"recommended_action_ar": (
|
||||
"اجتماع ثانٍ مع صاحب القرار خلال 5 أيام + مادة إثبات قيمة قصيرة."
|
||||
if risk_level == "high" else
|
||||
"متابعة خلال 3 أيام مع خطوة تالية محددة."
|
||||
if risk_level == "medium" else
|
||||
"تنفيذ الخطوة التالية المتفق عليها كما هي."
|
||||
),
|
||||
}
|
||||
def assess_deal_risk(signals: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
s = signals or {}
|
||||
risk = "low"
|
||||
reasons: list[str] = []
|
||||
if s.get("no_followup_scheduled"):
|
||||
risk = "medium"
|
||||
reasons.append("لا يوجد موعد متابعة بعد الاجتماع.")
|
||||
if s.get("ghosted_after_proposal"):
|
||||
risk = "high"
|
||||
reasons.append("توقف التواصل بعد العرض.")
|
||||
return {"risk_level": risk, "reasons_ar": reasons, "demo": True}
|
||||
|
||||
@ -1,72 +1,15 @@
|
||||
"""Build a post-meeting follow-up draft (Arabic) — never sends."""
|
||||
"""Post-meeting follow-up draft (Arabic)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def build_post_meeting_followup(
|
||||
*,
|
||||
summary: dict[str, Any] | None = None,
|
||||
next_steps: list[str] | None = None,
|
||||
contact_name: str = "",
|
||||
company_name: str = "",
|
||||
objections: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a draft follow-up email/WhatsApp message in Arabic.
|
||||
|
||||
Always returns approval_required=True; never executes a send.
|
||||
"""
|
||||
next_steps = next_steps or []
|
||||
objections = objections or []
|
||||
|
||||
salutation = f"هلا {contact_name}" if contact_name else "هلا"
|
||||
company_part = f" من شركة {company_name}" if company_name else ""
|
||||
|
||||
bullet_steps = "\n".join([f"• {s}" for s in next_steps]) or "• [حدد الخطوة التالية بتاريخ محدد]"
|
||||
|
||||
objection_addressed = ""
|
||||
if objections:
|
||||
labels = sorted({str(o.get("label_ar", "")) for o in objections if o.get("label_ar")})
|
||||
if labels:
|
||||
objection_addressed = (
|
||||
"\nرجعت بعد الاجتماع وفكرت في النقاط التي ذكرتها: "
|
||||
+ "، ".join(labels)
|
||||
+ ". أرفقت لك إجابات قصيرة مع أمثلة."
|
||||
)
|
||||
|
||||
body_ar = (
|
||||
f"{salutation}،\n"
|
||||
f"شكراً على وقتك اليوم{company_part}. "
|
||||
"ملخص ما اتفقنا عليه:\n"
|
||||
f"{bullet_steps}\n"
|
||||
f"{objection_addressed}\n"
|
||||
"\nإذا كل شي واضح من جهتك، أبدأ في تجهيز Pilot قصير ونشتغل خلال أسبوع. "
|
||||
"أي ملاحظة تحب تضيفها قبل ما نبدأ؟\n\nشاكر لك."
|
||||
def build_post_meeting_followup(summary_ar: str, next_steps: list[str] | None = None) -> dict[str, Any]:
|
||||
steps = next_steps or ["إرسال ملخص موافق عليه", "تحديد موعد متابعة", "مشاركة مسودة عرض مختصرة"]
|
||||
body = (
|
||||
f"شكراً لوقتكم. الملخص: {summary_ar[:200]}…\n"
|
||||
f"الخطوات المقترحة: {'؛ '.join(steps)}.\n"
|
||||
"ننتظر تأكيدكم للمتابعة."
|
||||
)
|
||||
|
||||
subject_ar = f"متابعة اجتماع اليوم — {company_name or 'Dealix'}"
|
||||
|
||||
return {
|
||||
"channel_drafts": {
|
||||
"email": {
|
||||
"subject_ar": subject_ar,
|
||||
"body_ar": body_ar,
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
},
|
||||
"whatsapp": {
|
||||
"body_ar": (
|
||||
f"{salutation}، شكراً على اجتماع اليوم. "
|
||||
"الخطوة التالية: " + (next_steps[0] if next_steps else "نحدد موعد بداية الـPilot") +
|
||||
". أتابع معك خلال يومين."
|
||||
),
|
||||
"approval_required": True,
|
||||
"live_send_allowed": False,
|
||||
},
|
||||
},
|
||||
"summary_used": bool(summary),
|
||||
"objections_addressed": [str(o.get("label_ar")) for o in objections if o.get("label_ar")],
|
||||
"approval_required": True,
|
||||
}
|
||||
return {"subject_ar": "متابعة — ملخص الاجتماع والخطوة التالية", "body_ar": body, "approval_required": True, "demo": True}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Pre-meeting brief builder — deterministic Arabic output."""
|
||||
"""Pre-meeting brief from company/contact context."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,69 +6,24 @@ from typing import Any
|
||||
|
||||
|
||||
def build_pre_meeting_brief(
|
||||
*,
|
||||
company: dict[str, Any] | None = None,
|
||||
contact: dict[str, Any] | None = None,
|
||||
opportunity: dict[str, Any] | None = None,
|
||||
sector: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Build a 6-section Arabic pre-meeting brief.
|
||||
|
||||
All inputs are optional; the brief degrades to a generic but useful template.
|
||||
"""
|
||||
company = company or {}
|
||||
contact = contact or {}
|
||||
opportunity = opportunity or {}
|
||||
sector = sector or str(company.get("sector", "saas"))
|
||||
|
||||
company_name = company.get("name", "?")
|
||||
contact_name = contact.get("name", "?")
|
||||
contact_role = contact.get("role", "?")
|
||||
deal_value = opportunity.get("expected_value_sar", 0)
|
||||
|
||||
objective_ar = (
|
||||
f"توضيح ملاءمة الحل لشركة {company_name}، "
|
||||
f"وفهم المعيار الذي يستخدمه {contact_name} للقرار، "
|
||||
"ثم تحديد خطوة تالية واضحة."
|
||||
)
|
||||
|
||||
questions_ar = [
|
||||
f"كيف تتعاملون اليوم مع [مشكلة قطاع {sector}]؟",
|
||||
"ما الذي جعلكم تنظرون لحل الآن وليس قبل 6 أشهر؟",
|
||||
"من المسؤول عن قرار الشراء غيرك؟",
|
||||
"ما المعيار الذي يجعلكم تقولون: نعم، خلونا نبدأ؟",
|
||||
"ما الميزانية التقريبية المخصصة لهذه المشكلة؟",
|
||||
]
|
||||
|
||||
likely_objections_ar = [
|
||||
"السعر مرتفع مقارنة بالأدوات المحلية.",
|
||||
"نحن مرتبطون بـ CRM/أداة حالية ولا نريد التبديل.",
|
||||
"نحتاج تجربة فريق صغير أولاً قبل القرار.",
|
||||
"هل الحل متوافق مع PDPL ولا يخزن بياناتنا خارج المملكة؟",
|
||||
"كم يستغرق الإعداد فعلياً؟",
|
||||
]
|
||||
|
||||
offer_skeleton_ar = (
|
||||
f"عرض pilot لمدة 7 أيام لشركة {company_name}: "
|
||||
"10 فرص B2B + رسائل عربية + متابعة + Proof Pack. "
|
||||
"السعر 499 ريال أو مجاني مقابل case study."
|
||||
)
|
||||
|
||||
next_step_ar = (
|
||||
"في نهاية المكالمة: اقترح خطوة محددة بتاريخ — "
|
||||
"إما الموافقة على بدء Pilot، أو إعادة الاجتماع خلال 5 أيام مع صانع القرار."
|
||||
)
|
||||
|
||||
c = company or {}
|
||||
p = contact or {}
|
||||
o = opportunity or {}
|
||||
return {
|
||||
"company_name": company_name,
|
||||
"contact_name": contact_name,
|
||||
"contact_role": contact_role,
|
||||
"expected_value_sar": deal_value,
|
||||
"objective_ar": objective_ar,
|
||||
"questions_ar": questions_ar,
|
||||
"likely_objections_ar": likely_objections_ar,
|
||||
"offer_skeleton_ar": offer_skeleton_ar,
|
||||
"next_step_ar": next_step_ar,
|
||||
"approval_required": True,
|
||||
"company_ar": str(c.get("name") or c.get("company_name") or "الشركة"),
|
||||
"contact_ar": str(p.get("name") or "جهة الاتصال"),
|
||||
"objective_ar": str(o.get("objective_ar") or "مناقشة ملاءمة الحل والخطوة التالية."),
|
||||
"questions_ar": [
|
||||
"ما معيار القرار والجدول الزمني؟",
|
||||
"ما أكبر مخاطرة يرونها اليوم؟",
|
||||
"ما الشكل المثالي للتجربة خلال ٧ أيام؟",
|
||||
"ما الميزانية أو نطاقها التقريبي؟",
|
||||
"من يشارك من جانبهم في التنفيذ؟",
|
||||
],
|
||||
"likely_objections_ar": ["السعر", "التوقيت", "التكامل مع الأنظمة الحالية"],
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -1,52 +1,17 @@
|
||||
"""Objection extractor — find common Arabic + English buying objections in transcript."""
|
||||
"""Extract objection-like phrases from transcript text — keyword MVP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Each entry: (category, regex pattern (case-insensitive), Arabic gloss).
|
||||
OBJECTION_PATTERNS: tuple[tuple[str, str, str], ...] = (
|
||||
("price", r"غالي|مرتفع|الميزانية|expensive|too\s+pricey|cost", "السعر/الميزانية"),
|
||||
("timing", r"ليس\s+الآن|بعد\s+شهر|الربع\s+القادم|not\s+now|next\s+quarter", "التوقيت"),
|
||||
("authority", r"المدير|صاحب\s+القرار|need\s+approval|decision\s+maker", "صاحب القرار"),
|
||||
("trust", r"بيانات|خصوصية|أمان|PDPL|trust|security|privacy", "الأمان والخصوصية"),
|
||||
("integration", r"CRM|نظامنا|الربط|integration|migration", "التكامل/الترحيل"),
|
||||
("competitor", r"نستخدم|بديل|أداة\s+ثانية|competitor|alternative", "وجود بديل/منافس"),
|
||||
("results", r"نتائج|مضمون|guarantee|ROI|دليل", "إثبات النتائج"),
|
||||
("complexity", r"معقد|صعب|تدريب|onboarding|complex|hard", "التعقيد/التبني"),
|
||||
)
|
||||
_KEYWORDS = ("ميزانية", "غالي", "لاحقاً", "نراجع", "ليس أولوية", "تكامل", "أمان", "عقد", "منافس")
|
||||
|
||||
|
||||
def extract_objections(transcript_text: str) -> dict[str, object]:
|
||||
"""
|
||||
Extract objection categories from a free-text transcript.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"objections": [{"category", "label_ar", "snippet"}],
|
||||
"categories_found": [str],
|
||||
"count": int,
|
||||
}
|
||||
"""
|
||||
if not transcript_text:
|
||||
return {"objections": [], "categories_found": [], "count": 0}
|
||||
|
||||
found: list[dict[str, str]] = []
|
||||
seen_categories: set[str] = set()
|
||||
for cat, pattern, gloss in OBJECTION_PATTERNS:
|
||||
for m in re.finditer(pattern, transcript_text, flags=re.IGNORECASE):
|
||||
seen_categories.add(cat)
|
||||
start = max(0, m.start() - 40)
|
||||
end = min(len(transcript_text), m.end() + 40)
|
||||
snippet = transcript_text[start:end].replace("\n", " ").strip()
|
||||
found.append({
|
||||
"category": cat,
|
||||
"label_ar": gloss,
|
||||
"snippet": snippet[:200],
|
||||
})
|
||||
|
||||
return {
|
||||
"objections": found,
|
||||
"categories_found": sorted(seen_categories),
|
||||
"count": len(found),
|
||||
}
|
||||
def extract_objections(transcript_text: str) -> dict[str, Any]:
|
||||
text = transcript_text or ""
|
||||
found: list[str] = []
|
||||
for kw in _KEYWORDS:
|
||||
if re.search(re.escape(kw), text, flags=re.IGNORECASE):
|
||||
found.append(kw)
|
||||
return {"objections_ar": list(dict.fromkeys(found))[:8], "demo": True}
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Transcript parser — accepts Google Meet entries OR plain text."""
|
||||
"""Parse plain-text transcript lines into a short Arabic summary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -6,87 +6,13 @@ import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
def parse_transcript_entries(entries: list[dict[str, Any]] | str) -> dict[str, Any]:
|
||||
"""
|
||||
Normalize either:
|
||||
- a list of Google-Meet-shaped entries [{"participantId", "text", ...}], or
|
||||
- a plain string transcript with "Speaker: text" lines.
|
||||
|
||||
Returns:
|
||||
{
|
||||
"speaker_turns": [{"speaker", "text"}],
|
||||
"speakers": [str],
|
||||
"total_chars": int,
|
||||
"total_turns": int,
|
||||
}
|
||||
"""
|
||||
speaker_turns: list[dict[str, str]] = []
|
||||
|
||||
if isinstance(entries, str):
|
||||
for raw in entries.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
m = re.match(r"^([^:]{1,40}):\s*(.+)$", line)
|
||||
if m:
|
||||
speaker_turns.append({"speaker": m.group(1).strip(),
|
||||
"text": m.group(2).strip()})
|
||||
else:
|
||||
speaker_turns.append({"speaker": "?", "text": line})
|
||||
else:
|
||||
for e in entries or []:
|
||||
speaker = (
|
||||
e.get("participant")
|
||||
or e.get("participantId")
|
||||
or e.get("speaker")
|
||||
or "?"
|
||||
)
|
||||
text = e.get("text") or e.get("content") or ""
|
||||
text = str(text).strip()
|
||||
if not text:
|
||||
continue
|
||||
speaker_turns.append({"speaker": str(speaker), "text": text})
|
||||
|
||||
speakers = sorted({t["speaker"] for t in speaker_turns})
|
||||
total_chars = sum(len(t["text"]) for t in speaker_turns)
|
||||
def summarize_transcript_text(text: str) -> dict[str, Any]:
|
||||
lines = [ln.strip() for ln in (text or "").splitlines() if ln.strip()]
|
||||
bullets = lines[:5] if lines else ["لا يوجد نص كافٍ."]
|
||||
word_count = len(re.findall(r"\w+", text or "", flags=re.UNICODE))
|
||||
return {
|
||||
"speaker_turns": speaker_turns,
|
||||
"speakers": speakers,
|
||||
"total_chars": total_chars,
|
||||
"total_turns": len(speaker_turns),
|
||||
}
|
||||
|
||||
|
||||
def summarize_meeting(parsed: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Produce an Arabic summary skeleton from parsed turns.
|
||||
|
||||
Deterministic; LLM-free for Phase D MVP.
|
||||
"""
|
||||
turns = parsed.get("speaker_turns", [])
|
||||
speakers = parsed.get("speakers", [])
|
||||
|
||||
# Extract a few candidate "topic" sentences: longest turns.
|
||||
sorted_by_len = sorted(turns, key=lambda t: -len(t["text"]))[:5]
|
||||
topic_lines = [t["text"][:200] for t in sorted_by_len]
|
||||
|
||||
# Detect questions.
|
||||
questions: list[str] = []
|
||||
for t in turns:
|
||||
text = t["text"]
|
||||
if "؟" in text or text.rstrip().endswith("?"):
|
||||
questions.append(text[:200])
|
||||
if len(questions) >= 5:
|
||||
break
|
||||
|
||||
return {
|
||||
"summary_ar": [
|
||||
f"شارك في الاجتماع {len(speakers)} متحدث.",
|
||||
f"إجمالي عدد الأدوار الكلامية: {parsed.get('total_turns', 0)}.",
|
||||
"أبرز نقاط النقاش (مرشحة آلياً، تحتاج مراجعة):",
|
||||
*[f"• {line}" for line in topic_lines],
|
||||
],
|
||||
"speakers": speakers,
|
||||
"candidate_questions_ar": questions,
|
||||
"approval_required": True,
|
||||
"bullets_ar": bullets,
|
||||
"word_count": word_count,
|
||||
"demo": True,
|
||||
"note_ar": "ملخص من نص خام — ربط Google Meet API لاحقاً مع OAuth.",
|
||||
}
|
||||
|
||||
@ -1,29 +1,5 @@
|
||||
"""Model Router — pick the right model/provider for each task type, with fallback."""
|
||||
"""Model routing hints by task type — configuration only, no vendor calls."""
|
||||
|
||||
from __future__ import annotations
|
||||
from auto_client_acquisition.model_router.task_router import list_tasks, route_task
|
||||
|
||||
from .cost_policy import CostClass, classify_cost
|
||||
from .fallback_policy import build_fallback_chain
|
||||
from .provider_registry import (
|
||||
ALL_PROVIDERS,
|
||||
ALL_TASK_TYPES,
|
||||
Provider,
|
||||
TaskType,
|
||||
get_provider,
|
||||
)
|
||||
from .task_router import RouteDecision, route_task
|
||||
from .usage_dashboard import build_usage_demo
|
||||
|
||||
__all__ = [
|
||||
"ALL_PROVIDERS",
|
||||
"ALL_TASK_TYPES",
|
||||
"CostClass",
|
||||
"Provider",
|
||||
"RouteDecision",
|
||||
"TaskType",
|
||||
"build_fallback_chain",
|
||||
"build_usage_demo",
|
||||
"classify_cost",
|
||||
"get_provider",
|
||||
"route_task",
|
||||
]
|
||||
__all__ = ["list_tasks", "route_task"]
|
||||
|
||||
@ -1,171 +1,16 @@
|
||||
"""Registry of model providers + task types."""
|
||||
"""Static provider labels for routing display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
# Task types Dealix actually routes.
|
||||
ALL_TASK_TYPES: tuple[str, ...] = (
|
||||
"strategic_reasoning",
|
||||
"arabic_copywriting",
|
||||
"classification",
|
||||
"compliance_guardrail",
|
||||
"meeting_analysis",
|
||||
"vision_analysis",
|
||||
"extraction",
|
||||
"summarization",
|
||||
"coding_project_understanding",
|
||||
"low_cost_bulk",
|
||||
)
|
||||
_PROVIDERS: list[dict[str, Any]] = [
|
||||
{"id": "anthropic", "label": "Anthropic", "tasks_default": ["strategic_reasoning", "arabic_copywriting"]},
|
||||
{"id": "openai", "label": "OpenAI", "tasks_default": ["classification", "summarization"]},
|
||||
{"id": "google", "label": "Google Gemini", "tasks_default": ["vision_analysis", "meeting_analysis"]},
|
||||
{"id": "groq", "label": "Groq", "tasks_default": ["low_cost_bulk", "extraction"]},
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Provider:
|
||||
"""A model provider entry."""
|
||||
key: str
|
||||
label: str
|
||||
family: str # "anthropic" | "openai" | "google" | "azure" | "local"
|
||||
capabilities: tuple[str, ...] # subset of ALL_TASK_TYPES
|
||||
cost_class: str # "low" | "mid" | "high"
|
||||
latency_class: str # "fast" | "balanced" | "slow"
|
||||
supports_vision: bool
|
||||
supports_arabic: bool
|
||||
privacy_tier: str # "vendor_cloud" | "ksa_region" | "self_hosted"
|
||||
notes_ar: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key, "label": self.label, "family": self.family,
|
||||
"capabilities": list(self.capabilities),
|
||||
"cost_class": self.cost_class, "latency_class": self.latency_class,
|
||||
"supports_vision": self.supports_vision,
|
||||
"supports_arabic": self.supports_arabic,
|
||||
"privacy_tier": self.privacy_tier,
|
||||
"notes_ar": self.notes_ar,
|
||||
}
|
||||
|
||||
|
||||
# Conservative provider list — Dealix can swap any of these without code change.
|
||||
ALL_PROVIDERS: tuple[Provider, ...] = (
|
||||
Provider(
|
||||
key="claude_sonnet",
|
||||
label="Claude Sonnet",
|
||||
family="anthropic",
|
||||
capabilities=(
|
||||
"strategic_reasoning", "arabic_copywriting",
|
||||
"compliance_guardrail", "meeting_analysis", "summarization",
|
||||
"coding_project_understanding",
|
||||
),
|
||||
cost_class="mid",
|
||||
latency_class="balanced",
|
||||
supports_vision=True,
|
||||
supports_arabic=True,
|
||||
privacy_tier="vendor_cloud",
|
||||
notes_ar="مناسب للاستراتيجية والكتابة العربية والامتثال.",
|
||||
),
|
||||
Provider(
|
||||
key="claude_haiku",
|
||||
label="Claude Haiku",
|
||||
family="anthropic",
|
||||
capabilities=("classification", "extraction", "low_cost_bulk", "summarization"),
|
||||
cost_class="low",
|
||||
latency_class="fast",
|
||||
supports_vision=False,
|
||||
supports_arabic=True,
|
||||
privacy_tier="vendor_cloud",
|
||||
notes_ar="رخيص وسريع — للتصنيف الكثيف والاستخراج.",
|
||||
),
|
||||
Provider(
|
||||
key="gpt_4_class",
|
||||
label="GPT-4-class",
|
||||
family="openai",
|
||||
capabilities=(
|
||||
"strategic_reasoning", "vision_analysis",
|
||||
"coding_project_understanding", "meeting_analysis",
|
||||
),
|
||||
cost_class="high",
|
||||
latency_class="balanced",
|
||||
supports_vision=True,
|
||||
supports_arabic=True,
|
||||
privacy_tier="vendor_cloud",
|
||||
notes_ar="بديل قوي للاستراتيجية والرؤية.",
|
||||
),
|
||||
Provider(
|
||||
key="gpt_4o_mini",
|
||||
label="GPT-4o mini",
|
||||
family="openai",
|
||||
capabilities=("classification", "extraction", "low_cost_bulk"),
|
||||
cost_class="low",
|
||||
latency_class="fast",
|
||||
supports_vision=True,
|
||||
supports_arabic=True,
|
||||
privacy_tier="vendor_cloud",
|
||||
notes_ar="بديل رخيص للمهام الكثيفة.",
|
||||
),
|
||||
Provider(
|
||||
key="gemini_pro",
|
||||
label="Gemini Pro",
|
||||
family="google",
|
||||
capabilities=(
|
||||
"vision_analysis", "summarization", "meeting_analysis",
|
||||
"extraction",
|
||||
),
|
||||
cost_class="mid",
|
||||
latency_class="balanced",
|
||||
supports_vision=True,
|
||||
supports_arabic=True,
|
||||
privacy_tier="vendor_cloud",
|
||||
notes_ar="ممتاز للرؤية والاجتماعات.",
|
||||
),
|
||||
Provider(
|
||||
key="azure_oai_ksa",
|
||||
label="Azure OpenAI (KSA region)",
|
||||
family="azure",
|
||||
capabilities=(
|
||||
"strategic_reasoning", "arabic_copywriting",
|
||||
"compliance_guardrail", "extraction", "summarization",
|
||||
),
|
||||
cost_class="mid",
|
||||
latency_class="balanced",
|
||||
supports_vision=True,
|
||||
supports_arabic=True,
|
||||
privacy_tier="ksa_region",
|
||||
notes_ar="منطقة KSA — مناسب للعملاء الحساسين للامتثال.",
|
||||
),
|
||||
Provider(
|
||||
key="local_qwen_ar",
|
||||
label="Local Qwen (Arabic-tuned)",
|
||||
family="local",
|
||||
capabilities=("classification", "extraction", "low_cost_bulk", "arabic_copywriting"),
|
||||
cost_class="low",
|
||||
latency_class="balanced",
|
||||
supports_vision=False,
|
||||
supports_arabic=True,
|
||||
privacy_tier="self_hosted",
|
||||
notes_ar="نموذج محلي — للحالات الحساسة جداً.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_provider(key: str) -> Provider | None:
|
||||
return next((p for p in ALL_PROVIDERS if p.key == key), None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskType:
|
||||
"""Description of a routed task."""
|
||||
key: str
|
||||
label_ar: str
|
||||
requires_arabic: bool
|
||||
requires_vision: bool
|
||||
sensitivity: str # "low" | "medium" | "high"
|
||||
notes_ar: str = ""
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"key": self.key, "label_ar": self.label_ar,
|
||||
"requires_arabic": self.requires_arabic,
|
||||
"requires_vision": self.requires_vision,
|
||||
"sensitivity": self.sensitivity,
|
||||
"notes_ar": self.notes_ar,
|
||||
}
|
||||
def list_providers() -> dict[str, Any]:
|
||||
return {"providers": list(_PROVIDERS), "demo": True}
|
||||
|
||||
@ -1,103 +1,30 @@
|
||||
"""Route a task to the right provider, with fallback chain + cost class."""
|
||||
"""Map task types to suggested provider + cost class — deterministic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from .cost_policy import CostClass, classify_cost
|
||||
from .fallback_policy import build_fallback_chain
|
||||
from .provider_registry import ALL_TASK_TYPES, get_provider
|
||||
_ROUTES: dict[str, dict[str, Any]] = {
|
||||
"strategic_reasoning": {"provider": "anthropic", "cost_class": "high", "needs_guardrail": True},
|
||||
"arabic_copywriting": {"provider": "anthropic", "cost_class": "medium", "needs_guardrail": True},
|
||||
"classification": {"provider": "openai", "cost_class": "low", "needs_guardrail": True},
|
||||
"compliance_guardrail": {"provider": "openai", "cost_class": "low", "needs_guardrail": False},
|
||||
"meeting_analysis": {"provider": "google", "cost_class": "medium", "needs_guardrail": True},
|
||||
"vision_analysis": {"provider": "google", "cost_class": "medium", "needs_guardrail": True},
|
||||
"extraction": {"provider": "groq", "cost_class": "low", "needs_guardrail": True},
|
||||
"summarization": {"provider": "openai", "cost_class": "low", "needs_guardrail": True},
|
||||
"low_cost_bulk": {"provider": "groq", "cost_class": "minimal", "needs_guardrail": True},
|
||||
"coding_project_understanding": {"provider": "anthropic", "cost_class": "high", "needs_guardrail": True},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RouteDecision:
|
||||
task_type: str
|
||||
primary_provider: str | None
|
||||
fallback_chain: list[str]
|
||||
cost_class: CostClass
|
||||
reasons_ar: list[str]
|
||||
requires_arabic: bool
|
||||
requires_vision: bool
|
||||
sensitivity: str
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"task_type": self.task_type,
|
||||
"primary_provider": self.primary_provider,
|
||||
"fallback_chain": self.fallback_chain,
|
||||
"cost_class": self.cost_class,
|
||||
"reasons_ar": self.reasons_ar,
|
||||
"requires_arabic": self.requires_arabic,
|
||||
"requires_vision": self.requires_vision,
|
||||
"sensitivity": self.sensitivity,
|
||||
}
|
||||
def list_tasks() -> dict[str, Any]:
|
||||
return {"task_types": sorted(_ROUTES.keys()), "demo": True}
|
||||
|
||||
|
||||
def route_task(
|
||||
task_type: str,
|
||||
*,
|
||||
requires_arabic: bool = False,
|
||||
requires_vision: bool = False,
|
||||
sensitivity: str = "low",
|
||||
expected_input_tokens: int = 0,
|
||||
expected_output_tokens: int = 0,
|
||||
bulk: bool = False,
|
||||
primary_provider: str | None = None,
|
||||
) -> RouteDecision:
|
||||
"""Route a task → primary provider + ordered fallback chain + cost class."""
|
||||
reasons: list[str] = []
|
||||
|
||||
if task_type not in ALL_TASK_TYPES:
|
||||
return RouteDecision(
|
||||
task_type=task_type,
|
||||
primary_provider=None,
|
||||
fallback_chain=[],
|
||||
cost_class="low",
|
||||
reasons_ar=[f"نوع المهمة غير معروف: {task_type}"],
|
||||
requires_arabic=requires_arabic,
|
||||
requires_vision=requires_vision,
|
||||
sensitivity=sensitivity,
|
||||
)
|
||||
|
||||
cost_class = classify_cost(
|
||||
task_type=task_type,
|
||||
expected_input_tokens=expected_input_tokens,
|
||||
expected_output_tokens=expected_output_tokens,
|
||||
bulk=bulk,
|
||||
)
|
||||
|
||||
chain = build_fallback_chain(
|
||||
task_type,
|
||||
requires_arabic=requires_arabic,
|
||||
requires_vision=requires_vision,
|
||||
sensitivity=sensitivity,
|
||||
primary_key=primary_provider,
|
||||
)
|
||||
|
||||
if not chain:
|
||||
reasons.append(
|
||||
"لا يوجد مزود مناسب — راجع capabilities أو خفّف القيود (vision/arabic)."
|
||||
)
|
||||
|
||||
primary = chain[0] if chain else None
|
||||
if primary:
|
||||
p = get_provider(primary)
|
||||
if p:
|
||||
reasons.append(
|
||||
f"المزود الأساسي: {p.label} — {p.notes_ar}"
|
||||
)
|
||||
if sensitivity == "high":
|
||||
reasons.append("حساسية عالية: تم تفضيل KSA-region/self-hosted أولاً.")
|
||||
if bulk:
|
||||
reasons.append("مهمة جماعية كبيرة: تم اختيار cost_class=low.")
|
||||
|
||||
return RouteDecision(
|
||||
task_type=task_type,
|
||||
primary_provider=primary,
|
||||
fallback_chain=chain,
|
||||
cost_class=cost_class,
|
||||
reasons_ar=reasons,
|
||||
requires_arabic=requires_arabic,
|
||||
requires_vision=requires_vision,
|
||||
sensitivity=sensitivity,
|
||||
)
|
||||
def route_task(task_type: str) -> dict[str, Any]:
|
||||
t = (task_type or "").strip().lower().replace("-", "_")
|
||||
if t not in _ROUTES:
|
||||
return {"ok": False, "error": "unknown_task_type", "known": sorted(_ROUTES.keys()), "demo": True}
|
||||
r = _ROUTES[t]
|
||||
return {"ok": True, "task_type": t, **r, "fallback_provider": "groq", "demo": True}
|
||||
|
||||
@ -1,74 +1,23 @@
|
||||
"""
|
||||
Platform Services Layer — Dealix's Growth Control Tower spine.
|
||||
"""Platform Services — Growth Control Tower (policy, inbox, catalog, no live sends)."""
|
||||
|
||||
Turns the platform from "WhatsApp Growth Operator" into a multi-channel
|
||||
growth platform that ingests events from every channel a Saudi B2B uses,
|
||||
converts them into Arabic action cards, evaluates each action against
|
||||
policy, and produces unified proof.
|
||||
|
||||
Modules:
|
||||
- event_bus : typed events from all channels
|
||||
- identity_resolution : reconcile phone+email+social→one person
|
||||
- channel_registry : 11 supported channels with capabilities
|
||||
- action_policy : decide approval / block / allow
|
||||
- tool_gateway : draft-only proxy (no live actions here)
|
||||
- unified_inbox : 8 card types from events
|
||||
- action_ledger : auditable record of every action lifecycle
|
||||
- proof_ledger : value rolled up across the platform
|
||||
- service_catalog : 12 sellable services
|
||||
"""
|
||||
|
||||
from auto_client_acquisition.platform_services.action_ledger import (
|
||||
ActionLedger,
|
||||
LedgerEntry,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.action_policy import (
|
||||
POLICY_RULES,
|
||||
PolicyDecision,
|
||||
evaluate_action,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.channel_registry import (
|
||||
ALL_CHANNELS,
|
||||
Channel,
|
||||
get_channel,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.event_bus import (
|
||||
EVENT_TYPES,
|
||||
PlatformEvent,
|
||||
make_event,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.identity_resolution import (
|
||||
Identity,
|
||||
resolve_identity,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.proof_ledger import (
|
||||
PlatformProofLedger,
|
||||
build_demo_platform_proof,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.service_catalog import (
|
||||
SELLABLE_SERVICES,
|
||||
ServiceOffering,
|
||||
list_services,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.tool_gateway import (
|
||||
GatewayResult,
|
||||
invoke_tool,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.unified_inbox import (
|
||||
CARD_TYPES,
|
||||
InboxCard,
|
||||
build_card_from_event,
|
||||
build_demo_feed,
|
||||
)
|
||||
from auto_client_acquisition.platform_services.action_ledger import ActionLedger, get_action_ledger
|
||||
from auto_client_acquisition.platform_services.action_policy import evaluate_action
|
||||
from auto_client_acquisition.platform_services.channel_registry import list_channels
|
||||
from auto_client_acquisition.platform_services.event_bus import EventType, validate_event
|
||||
from auto_client_acquisition.platform_services.proof_summary import build_proof_summary
|
||||
from auto_client_acquisition.platform_services.service_catalog import get_service_catalog
|
||||
from auto_client_acquisition.platform_services.tool_gateway import execute_tool
|
||||
from auto_client_acquisition.platform_services.unified_inbox import event_to_inbox_card
|
||||
|
||||
__all__ = [
|
||||
"EVENT_TYPES", "PlatformEvent", "make_event",
|
||||
"Identity", "resolve_identity",
|
||||
"ALL_CHANNELS", "Channel", "get_channel",
|
||||
"POLICY_RULES", "PolicyDecision", "evaluate_action",
|
||||
"GatewayResult", "invoke_tool",
|
||||
"CARD_TYPES", "InboxCard", "build_card_from_event", "build_demo_feed",
|
||||
"ActionLedger", "LedgerEntry",
|
||||
"PlatformProofLedger", "build_demo_platform_proof",
|
||||
"SELLABLE_SERVICES", "ServiceOffering", "list_services",
|
||||
"ActionLedger",
|
||||
"EventType",
|
||||
"build_proof_summary",
|
||||
"evaluate_action",
|
||||
"event_to_inbox_card",
|
||||
"execute_tool",
|
||||
"get_action_ledger",
|
||||
"get_service_catalog",
|
||||
"list_channels",
|
||||
"validate_event",
|
||||
]
|
||||
|
||||
@ -1,107 +1,41 @@
|
||||
"""
|
||||
Action Ledger — auditable record of every action lifecycle.
|
||||
|
||||
Stage transitions per action: requested → (approved | rejected | blocked)
|
||||
→ executed → outcome.
|
||||
|
||||
Used for SDAIA / DPO inspections + customer's own audit trail.
|
||||
"""
|
||||
"""In-memory decision log for platform tools (MVP)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import itertools
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
|
||||
VALID_STAGES: tuple[str, ...] = (
|
||||
"requested", "approved", "rejected", "blocked",
|
||||
"executed", "outcome_recorded",
|
||||
)
|
||||
_counter = itertools.count(1)
|
||||
_lock = threading.Lock()
|
||||
_entries: list[dict[str, Any]] = []
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerEntry:
|
||||
"""One entry in the action ledger."""
|
||||
|
||||
entry_id: str
|
||||
customer_id: str
|
||||
action_type: str
|
||||
channel: str
|
||||
stage: str
|
||||
actor: str = "system"
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
reason_ar: str = ""
|
||||
created_at: datetime = field(
|
||||
default_factory=lambda: datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
)
|
||||
correlation_id: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"entry_id": self.entry_id,
|
||||
"customer_id": self.customer_id,
|
||||
"action_type": self.action_type,
|
||||
"channel": self.channel,
|
||||
"stage": self.stage,
|
||||
"actor": self.actor,
|
||||
"payload": self.payload,
|
||||
"reason_ar": self.reason_ar,
|
||||
"created_at": self.created_at.isoformat(),
|
||||
"correlation_id": self.correlation_id,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ActionLedger:
|
||||
"""Append-only ledger keyed by customer_id."""
|
||||
"""Thread-safe append-only ledger."""
|
||||
|
||||
entries: list[LedgerEntry] = field(default_factory=list)
|
||||
|
||||
def append(
|
||||
self,
|
||||
*,
|
||||
customer_id: str,
|
||||
action_type: str,
|
||||
channel: str,
|
||||
stage: str,
|
||||
actor: str = "system",
|
||||
payload: dict[str, Any] | None = None,
|
||||
reason_ar: str = "",
|
||||
correlation_id: str | None = None,
|
||||
) -> LedgerEntry:
|
||||
if stage not in VALID_STAGES:
|
||||
raise ValueError(f"unknown stage: {stage}")
|
||||
entry = LedgerEntry(
|
||||
entry_id=f"led_{uuid.uuid4().hex[:20]}",
|
||||
customer_id=customer_id,
|
||||
action_type=action_type,
|
||||
channel=channel,
|
||||
stage=stage,
|
||||
actor=actor,
|
||||
payload=payload or {},
|
||||
reason_ar=reason_ar,
|
||||
correlation_id=correlation_id,
|
||||
)
|
||||
self.entries.append(entry)
|
||||
def append_decision(self, *, tool: str, outcome: str, detail: dict[str, Any]) -> dict[str, Any]:
|
||||
with _lock:
|
||||
entry = {
|
||||
"id": next(_counter),
|
||||
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"tool": tool,
|
||||
"outcome": outcome,
|
||||
"detail": detail,
|
||||
}
|
||||
_entries.append(entry)
|
||||
if len(_entries) > 500:
|
||||
del _entries[:-500]
|
||||
return entry
|
||||
|
||||
def for_customer(self, customer_id: str) -> list[LedgerEntry]:
|
||||
return [e for e in self.entries if e.customer_id == customer_id]
|
||||
def recent(self, limit: int = 50) -> list[dict[str, Any]]:
|
||||
with _lock:
|
||||
return list(_entries[-limit:])
|
||||
|
||||
def summary(self, customer_id: str | None = None) -> dict[str, Any]:
|
||||
pool = self.entries if customer_id is None else self.for_customer(customer_id)
|
||||
by_stage: dict[str, int] = {}
|
||||
by_channel: dict[str, int] = {}
|
||||
by_action: dict[str, int] = {}
|
||||
for e in pool:
|
||||
by_stage[e.stage] = by_stage.get(e.stage, 0) + 1
|
||||
by_channel[e.channel] = by_channel.get(e.channel, 0) + 1
|
||||
by_action[e.action_type] = by_action.get(e.action_type, 0) + 1
|
||||
return {
|
||||
"total": len(pool),
|
||||
"by_stage": by_stage,
|
||||
"by_channel": by_channel,
|
||||
"by_action_type": by_action,
|
||||
}
|
||||
|
||||
_ledger = ActionLedger()
|
||||
|
||||
|
||||
def get_action_ledger() -> ActionLedger:
|
||||
return _ledger
|
||||
|
||||
@ -1,173 +1,82 @@
|
||||
"""
|
||||
Action Policy Engine — decides whether an action can run, needs approval,
|
||||
or is blocked. The single chokepoint that protects the customer's
|
||||
reputation + enforces PDPL.
|
||||
|
||||
Design: pure deterministic rules. Easily testable, easily auditable,
|
||||
easy for the customer to explain to compliance.
|
||||
"""
|
||||
"""Deterministic policy — no network."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from core.config.settings import get_settings
|
||||
|
||||
# ── Policy rules — each rule is (action_type, condition, decision, reason_ar)
|
||||
POLICY_RULES: list[dict[str, Any]] = [
|
||||
# Hard blocks — never executed
|
||||
{
|
||||
"rule_id": "block_cold_whatsapp",
|
||||
"action": "send_whatsapp",
|
||||
"when": {"source": "cold_list", "consent": False},
|
||||
"decision": "blocked",
|
||||
"reason_ar": "WhatsApp البارد محظور بدون lawful basis (PDPL م.5).",
|
||||
},
|
||||
{
|
||||
"rule_id": "block_payment_no_confirm",
|
||||
"action": "charge_payment",
|
||||
"when": {"user_confirmed": False},
|
||||
"decision": "blocked",
|
||||
"reason_ar": "الخصم يحتاج تأكيد المستخدم على Moyasar — لا charge مباشر.",
|
||||
},
|
||||
{
|
||||
"rule_id": "block_secrets_in_payload",
|
||||
"action": "*",
|
||||
"when": {"payload_contains_secret": True},
|
||||
"decision": "blocked",
|
||||
"reason_ar": "تم اكتشاف secret في الـ payload — حماية تلقائية.",
|
||||
},
|
||||
# Approval gates — must pass through human
|
||||
{
|
||||
"rule_id": "external_send_needs_approval",
|
||||
"action": "send_whatsapp,send_email,send_inmail,post_social",
|
||||
"when": {"approval_status": "pending"},
|
||||
"decision": "approval_required",
|
||||
"reason_ar": "كل إرسال خارجي يحتاج موافقة العميل قبل التنفيذ.",
|
||||
},
|
||||
{
|
||||
"rule_id": "calendar_insert_needs_approval",
|
||||
"action": "calendar_insert_event",
|
||||
"when": {"approval_status": "pending"},
|
||||
"decision": "approval_required",
|
||||
"reason_ar": "إنشاء اجتماع في تقويم العميل يحتاج موافقة قبل insert.",
|
||||
},
|
||||
{
|
||||
"rule_id": "social_dm_needs_explicit",
|
||||
"action": "send_social_dm",
|
||||
"when": {"explicit_permission": False},
|
||||
"decision": "approval_required",
|
||||
"reason_ar": "DM السوشيال يحتاج إذن صريح لكل حساب.",
|
||||
},
|
||||
# Needs review
|
||||
{
|
||||
"rule_id": "unknown_source_review",
|
||||
"action": "*",
|
||||
"when": {"source": "unknown"},
|
||||
"decision": "approval_required",
|
||||
"reason_ar": "مصدر البيانات غير محدد — يحتاج توثيق lawful basis.",
|
||||
},
|
||||
{
|
||||
"rule_id": "high_value_deal_review",
|
||||
"action": "*",
|
||||
"when": {"deal_value_sar_gte": 100_000},
|
||||
"decision": "approval_required",
|
||||
"reason_ar": "صفقة قيمتها ≥100K ريال — راجعها قبل التنفيذ.",
|
||||
},
|
||||
# Allowed (default for safe paths)
|
||||
{
|
||||
"rule_id": "draft_only_safe",
|
||||
"action": "create_draft,read_data,classify_reply",
|
||||
"when": {},
|
||||
"decision": "allow",
|
||||
"reason_ar": "إجراء داخلي آمن — لا يخرج للعميل النهائي.",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PolicyDecision:
|
||||
"""Output of evaluate_action."""
|
||||
|
||||
decision: str # allow / approval_required / blocked
|
||||
matched_rule_id: str | None
|
||||
reasons_ar: list[str] = field(default_factory=list)
|
||||
suggested_next_action_ar: str = ""
|
||||
PolicyState = Literal["approved", "blocked", "approval_required", "review"]
|
||||
|
||||
|
||||
def evaluate_action(
|
||||
*,
|
||||
action: str,
|
||||
channel_id: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
) -> PolicyDecision:
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Evaluate a proposed action against the policy rules.
|
||||
|
||||
First matching rule wins. Default: needs_review (defensive).
|
||||
Rules:
|
||||
- External-ish sends → approval_required unless explicitly internal draft.
|
||||
- Cold WhatsApp → blocked when ``intent`` is cold/campaign_cold.
|
||||
- Payment → approval_required + confirm flag if amount present.
|
||||
- Unknown channel → review.
|
||||
"""
|
||||
ctx = context or {}
|
||||
matched_reasons: list[str] = []
|
||||
final_decision = "allow"
|
||||
matched_rule_id: str | None = None
|
||||
next_action = "ready_for_execution"
|
||||
reason_ar = ""
|
||||
state: PolicyState = "approval_required"
|
||||
|
||||
for rule in POLICY_RULES:
|
||||
# Action match (comma-separated list, "*" = match-any)
|
||||
applicable_actions = rule["action"].split(",") if rule["action"] != "*" else [action]
|
||||
if action not in applicable_actions and rule["action"] != "*":
|
||||
continue
|
||||
known = {
|
||||
"whatsapp",
|
||||
"email",
|
||||
"linkedin_lead_form",
|
||||
"website_form",
|
||||
"google_business",
|
||||
"x_twitter",
|
||||
"instagram",
|
||||
"moyasar",
|
||||
}
|
||||
if channel_id not in known:
|
||||
return {
|
||||
"state": "review",
|
||||
"reason_ar": "قناة غير معروفة في السجل — يلزم مراجعة يدوية.",
|
||||
"action": action,
|
||||
"channel_id": channel_id,
|
||||
}
|
||||
|
||||
# Condition match — every key in `when` must match the context
|
||||
when = rule["when"]
|
||||
cond_match = True
|
||||
for k, expected in when.items():
|
||||
if k.endswith("_gte"):
|
||||
attr = k[:-4]
|
||||
if not (float(ctx.get(attr, 0)) >= float(expected)):
|
||||
cond_match = False
|
||||
break
|
||||
elif k == "payload_contains_secret":
|
||||
if expected and not _has_secret_marker(ctx.get("payload", {})):
|
||||
cond_match = False
|
||||
break
|
||||
elif ctx.get(k) != expected:
|
||||
cond_match = False
|
||||
break
|
||||
if channel_id == "whatsapp" and action in ("send", "send_live", "external_send"):
|
||||
intent = str(ctx.get("intent") or "").lower()
|
||||
audience = str(ctx.get("audience") or "").lower()
|
||||
cold_markers = ("cold", "campaign_cold", "purchased_list", "unknown_opt_in")
|
||||
if intent in cold_markers or audience in cold_markers:
|
||||
return {
|
||||
"state": "blocked",
|
||||
"reason_ar": "الواتساب البارد أو قوائم غير موثقة محظور حتى موافقة امتثال وتسجيل opt-in.",
|
||||
"action": action,
|
||||
"channel_id": channel_id,
|
||||
}
|
||||
settings = get_settings()
|
||||
if action == "send_live" and not settings.whatsapp_allow_live_send:
|
||||
return {
|
||||
"state": "blocked",
|
||||
"reason_ar": "الإرسال الحي للواتساب معطّل في الإعدادات (WHATSAPP_ALLOW_LIVE_SEND=false).",
|
||||
"action": action,
|
||||
"channel_id": channel_id,
|
||||
}
|
||||
|
||||
if not cond_match:
|
||||
continue
|
||||
if action in ("send", "send_live", "external_send", "smtp_send"):
|
||||
state = "approval_required"
|
||||
reason_ar = "أي إرسال خارجي يتطلب موافقة بشرية في هذا الإصدار."
|
||||
|
||||
decision = rule["decision"]
|
||||
matched_reasons.append(rule["reason_ar"])
|
||||
matched_rule_id = rule["rule_id"]
|
||||
if action in ("payment_charge", "payment_capture", "moyasar_charge"):
|
||||
state = "approval_required"
|
||||
if not ctx.get("user_confirmed"):
|
||||
reason_ar = "عمليات الدفع تتطلب تأكيداً صريحاً من المشغّل قبل التنفيذ."
|
||||
else:
|
||||
reason_ar = "تم تسجيل تأكيد المشغّل — ما زال التنفيذ الفعلي معطّلاً في MVP."
|
||||
|
||||
if decision == "blocked":
|
||||
return PolicyDecision(
|
||||
decision="blocked",
|
||||
matched_rule_id=matched_rule_id,
|
||||
reasons_ar=matched_reasons,
|
||||
suggested_next_action_ar="معالجة سبب الحظر قبل المحاولة مرة أخرى.",
|
||||
)
|
||||
if decision == "approval_required":
|
||||
final_decision = "approval_required"
|
||||
next_action = "operator_approves_then_execute"
|
||||
# 'allow' rules just confirm — keep looking for stricter rule
|
||||
if action in ("draft_only", "draft_message", "draft_email"):
|
||||
state = "approved"
|
||||
reason_ar = "مسودة داخلية — مسموح للعرض فقط."
|
||||
|
||||
return PolicyDecision(
|
||||
decision=final_decision,
|
||||
matched_rule_id=matched_rule_id,
|
||||
reasons_ar=matched_reasons or ["لا قاعدة مطابقة — الإجراء آمن افتراضياً."],
|
||||
suggested_next_action_ar=next_action,
|
||||
)
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────
|
||||
_SECRET_MARKERS = ("api_key", "secret_key", "private_key", "password", "ghp_", "sk-ant-", "moyasar_secret")
|
||||
|
||||
|
||||
def _has_secret_marker(payload: dict[str, Any]) -> bool:
|
||||
"""Cheap heuristic check — production pairs this with a stronger scanner."""
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
flat = str(payload).lower()
|
||||
return any(marker in flat for marker in _SECRET_MARKERS)
|
||||
return {"state": state, "reason_ar": reason_ar or "قرار سياسة افتراضي.", "action": action, "channel_id": channel_id}
|
||||
|
||||
@ -1,213 +1,69 @@
|
||||
"""
|
||||
Channel Registry — 11 supported channels with capabilities + risk profile.
|
||||
|
||||
Each channel declares: capabilities, beta_status, required_permissions,
|
||||
allowed_actions, blocked_actions, risk_level. Used by the action policy
|
||||
engine and the unified inbox.
|
||||
"""
|
||||
"""Channel capabilities — registered-only social channels, no OAuth in MVP."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Channel:
|
||||
"""A connected channel + what it can / cannot do."""
|
||||
|
||||
key: str
|
||||
label_ar: str
|
||||
label_en: str
|
||||
capabilities: tuple[str, ...]
|
||||
beta_status: str # ga / beta / experimental / planned
|
||||
required_permissions: tuple[str, ...]
|
||||
allowed_actions: tuple[str, ...]
|
||||
blocked_actions: tuple[str, ...]
|
||||
risk_level: str # low / medium / high
|
||||
notes_ar: str = ""
|
||||
_CHANNEL_DEFS: list[dict[str, Any]] = [
|
||||
{
|
||||
"id": "whatsapp",
|
||||
"label_ar": "واتساب للأعمال",
|
||||
"beta_status": "pilot",
|
||||
"risk_level": "high",
|
||||
"allowed_actions": ["draft_message", "template_preview"],
|
||||
"blocked_actions": ["cold_outreach_auto", "bulk_send_without_approval"],
|
||||
},
|
||||
{
|
||||
"id": "email",
|
||||
"label_ar": "البريد",
|
||||
"beta_status": "ga_ready",
|
||||
"risk_level": "medium",
|
||||
"allowed_actions": ["draft_email", "schedule_internal"],
|
||||
"blocked_actions": ["smtp_live_without_approval"],
|
||||
},
|
||||
{
|
||||
"id": "linkedin_lead_form",
|
||||
"label_ar": "نماذج عملاء LinkedIn",
|
||||
"beta_status": "mvp",
|
||||
"risk_level": "low",
|
||||
"allowed_actions": ["ingest_webhook_simulation", "normalize_lead"],
|
||||
"blocked_actions": ["scrape_profile"],
|
||||
},
|
||||
{
|
||||
"id": "website_form",
|
||||
"label_ar": "نموذج موقع",
|
||||
"beta_status": "mvp",
|
||||
"risk_level": "low",
|
||||
"allowed_actions": ["ingest_webhook_simulation", "normalize_lead"],
|
||||
"blocked_actions": [],
|
||||
},
|
||||
# Wave 5 — registered-only (ingest / auto-reply deferred)
|
||||
{
|
||||
"id": "google_business",
|
||||
"label_ar": "ملف Google Business",
|
||||
"beta_status": "registered_only",
|
||||
"risk_level": "medium",
|
||||
"allowed_actions": [],
|
||||
"blocked_actions": ["auto_reply", "oauth_connect", "public_api_call"],
|
||||
},
|
||||
{
|
||||
"id": "x_twitter",
|
||||
"label_ar": "X (تويتر)",
|
||||
"beta_status": "registered_only",
|
||||
"risk_level": "medium",
|
||||
"allowed_actions": [],
|
||||
"blocked_actions": ["auto_reply", "oauth_connect", "public_api_call"],
|
||||
},
|
||||
{
|
||||
"id": "instagram",
|
||||
"label_ar": "إنستغرام",
|
||||
"beta_status": "registered_only",
|
||||
"risk_level": "medium",
|
||||
"allowed_actions": [],
|
||||
"blocked_actions": ["auto_reply", "oauth_connect", "public_api_call"],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
# ── The 11 channels we model ────────────────────────────────────
|
||||
ALL_CHANNELS: tuple[Channel, ...] = (
|
||||
Channel(
|
||||
key="whatsapp",
|
||||
label_ar="واتساب",
|
||||
label_en="WhatsApp Business / Cloud",
|
||||
capabilities=(
|
||||
"inbound_messages", "outbound_template_messages",
|
||||
"interactive_buttons_max_3", "media_send", "opt_out_handling",
|
||||
),
|
||||
beta_status="ga",
|
||||
required_permissions=(
|
||||
"waba_account_id", "phone_number_id", "verified_business",
|
||||
),
|
||||
allowed_actions=("draft_message", "send_with_approval", "track_reply"),
|
||||
blocked_actions=("cold_send_without_consent", "bulk_unsolicited_send"),
|
||||
risk_level="medium",
|
||||
notes_ar="حد 3 buttons تفاعلية. الإرسال البارد محظور بدون lawful basis.",
|
||||
),
|
||||
Channel(
|
||||
key="gmail",
|
||||
label_ar="Gmail (إيميل العميل)",
|
||||
label_en="Gmail OAuth",
|
||||
capabilities=(
|
||||
"create_draft_only", "read_labeled_threads",
|
||||
"list_unsubscribe_header_attached",
|
||||
),
|
||||
beta_status="ga",
|
||||
required_permissions=("gmail.compose",),
|
||||
allowed_actions=("create_draft", "read_thread"),
|
||||
blocked_actions=("send_without_user_click", "delete_messages"),
|
||||
risk_level="low",
|
||||
notes_ar="نكتفي بـ scope `gmail.compose`. المستخدم يضغط Send بنفسه.",
|
||||
),
|
||||
Channel(
|
||||
key="google_calendar",
|
||||
label_ar="Google Calendar",
|
||||
label_en="Google Calendar API",
|
||||
capabilities=(
|
||||
"events_insert_with_meet", "events_list",
|
||||
"rfc5545_recurrence", "asia_riyadh_timezone",
|
||||
),
|
||||
beta_status="ga",
|
||||
required_permissions=("calendar.events",),
|
||||
allowed_actions=("draft_event", "create_event_with_approval"),
|
||||
blocked_actions=("delete_other_attendees_events", "modify_external_events_silently"),
|
||||
risk_level="low",
|
||||
notes_ar="conferenceDataVersion=1 لإضافة Google Meet.",
|
||||
),
|
||||
Channel(
|
||||
key="linkedin_lead_forms",
|
||||
label_ar="LinkedIn Lead Gen Forms",
|
||||
label_en="LinkedIn Lead Gen Forms API",
|
||||
capabilities=(
|
||||
"ingest_leads_from_ads", "hidden_field_tracking",
|
||||
"crm_sync",
|
||||
),
|
||||
beta_status="beta",
|
||||
required_permissions=("r_marketing_leadgen_automation",),
|
||||
allowed_actions=("ingest_lead_form", "trigger_followup_draft"),
|
||||
blocked_actions=("scrape_profiles", "unsolicited_inmails_at_scale"),
|
||||
risk_level="low",
|
||||
notes_ar="مصدر رسمي لـ leads مؤهلة.",
|
||||
),
|
||||
Channel(
|
||||
key="x_api",
|
||||
label_ar="X (Twitter)",
|
||||
label_en="X API v2",
|
||||
capabilities=(
|
||||
"post_tweet", "read_mentions",
|
||||
"user_lookups_basic", "webhooks_account_activity_paid",
|
||||
),
|
||||
beta_status="experimental",
|
||||
required_permissions=("oauth2_user_context",),
|
||||
allowed_actions=("draft_post", "ingest_mention", "draft_dm_reply"),
|
||||
blocked_actions=("auto_dm_strangers", "scrape_user_lists"),
|
||||
risk_level="medium",
|
||||
notes_ar="بعض الـ webhooks Enterprise-only. نقتصر على ما تتيحه الخطة الحالية.",
|
||||
),
|
||||
Channel(
|
||||
key="instagram_graph",
|
||||
label_ar="Instagram (Graph API)",
|
||||
label_en="Instagram Graph API",
|
||||
capabilities=(
|
||||
"read_business_messages", "publish_posts",
|
||||
"read_comments_on_owned_posts",
|
||||
),
|
||||
beta_status="beta",
|
||||
required_permissions=("instagram_basic", "instagram_manage_messages"),
|
||||
allowed_actions=("draft_reply", "ingest_comment", "ingest_dm"),
|
||||
blocked_actions=("auto_dm_strangers", "scrape_unrelated_users"),
|
||||
risk_level="medium",
|
||||
notes_ar="فقط للحسابات Business + ما يخص العميل المتصل.",
|
||||
),
|
||||
Channel(
|
||||
key="google_business_profile",
|
||||
label_ar="Google Business Profile",
|
||||
label_en="Google Business Profile API",
|
||||
capabilities=(
|
||||
"read_reviews", "post_replies",
|
||||
"publish_local_posts", "manage_location_info",
|
||||
),
|
||||
beta_status="ga",
|
||||
required_permissions=("business.manage",),
|
||||
allowed_actions=("draft_review_reply", "draft_local_post"),
|
||||
blocked_actions=("delete_real_reviews"),
|
||||
risk_level="low",
|
||||
notes_ar="مهم للمتاجر والعيادات والفروع — السمعة المحلية.",
|
||||
),
|
||||
Channel(
|
||||
key="google_sheets",
|
||||
label_ar="Google Sheets",
|
||||
label_en="Google Sheets API",
|
||||
capabilities=("read_range", "append_row", "watch_changes"),
|
||||
beta_status="ga",
|
||||
required_permissions=("spreadsheets.readonly", "spreadsheets",),
|
||||
allowed_actions=("import_contacts", "sync_pipeline", "log_actions"),
|
||||
blocked_actions=("delete_user_sheets"),
|
||||
risk_level="low",
|
||||
notes_ar="أداة مفيدة للتكامل مع عمليات العميل اليدوية.",
|
||||
),
|
||||
Channel(
|
||||
key="crm",
|
||||
label_ar="CRM (Zoho/HubSpot/Salla/Odoo)",
|
||||
label_en="CRM via REST/SDK",
|
||||
capabilities=(
|
||||
"deal_sync", "contact_sync", "activity_log",
|
||||
),
|
||||
beta_status="planned",
|
||||
required_permissions=("crm_api_token",),
|
||||
allowed_actions=("read_deals", "update_stage_with_approval"),
|
||||
blocked_actions=("delete_deals_silently"),
|
||||
risk_level="medium",
|
||||
notes_ar="بناء adapter لكل CRM في مرحلة لاحقة.",
|
||||
),
|
||||
Channel(
|
||||
key="moyasar",
|
||||
label_ar="Moyasar (مدفوعات)",
|
||||
label_en="Moyasar Payments",
|
||||
capabilities=(
|
||||
"create_payment_link", "create_invoice",
|
||||
"webhook_paid_failed_refunded", "refund",
|
||||
),
|
||||
beta_status="ga",
|
||||
required_permissions=("publishable_key", "secret_key"),
|
||||
allowed_actions=("draft_payment_link", "send_invoice_email"),
|
||||
blocked_actions=("charge_card_without_user_action"),
|
||||
risk_level="high",
|
||||
notes_ar="بطاقة العميل تُدخَل على Moyasar (PCI-safe). لا تخزين خانات.",
|
||||
),
|
||||
Channel(
|
||||
key="website_forms",
|
||||
label_ar="نماذج الموقع",
|
||||
label_en="Website Forms",
|
||||
capabilities=("ingest_submission", "trigger_workflow"),
|
||||
beta_status="ga",
|
||||
required_permissions=("webhook_endpoint",),
|
||||
allowed_actions=("ingest_lead", "draft_thankyou_message"),
|
||||
blocked_actions=(),
|
||||
risk_level="low",
|
||||
notes_ar="مصدر leads مؤهَّلة بطبيعتها — أساس قانوني واضح.",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def get_channel(key: str) -> Channel | None:
|
||||
for c in ALL_CHANNELS:
|
||||
if c.key == key:
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def channels_summary() -> dict[str, Any]:
|
||||
by_status: dict[str, int] = {}
|
||||
by_risk: dict[str, int] = {}
|
||||
for c in ALL_CHANNELS:
|
||||
by_status[c.beta_status] = by_status.get(c.beta_status, 0) + 1
|
||||
by_risk[c.risk_level] = by_risk.get(c.risk_level, 0) + 1
|
||||
return {
|
||||
"total": len(ALL_CHANNELS),
|
||||
"by_beta_status": by_status,
|
||||
"by_risk_level": by_risk,
|
||||
}
|
||||
def list_channels() -> dict[str, Any]:
|
||||
return {"channels": list(_CHANNEL_DEFS), "demo": True}
|
||||
|
||||
@ -0,0 +1,152 @@
|
||||
"""CSV / row-list import preview — classification only, no send."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.compliance_os.consent_ledger import LawfulBasis, record_consent
|
||||
from auto_client_acquisition.compliance_os.contactability import check_contactability
|
||||
|
||||
_TRUSTED_SOURCES = frozenset(
|
||||
{
|
||||
"inbound_form",
|
||||
"website_form",
|
||||
"prior_customer",
|
||||
"event_meeting",
|
||||
"explicit_consent",
|
||||
"form_submission",
|
||||
"linkedin_lead_form",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _norm_phone(raw: str | None) -> str:
|
||||
if not raw:
|
||||
return ""
|
||||
s = re.sub(r"\s+", "", str(raw))
|
||||
if s.startswith("00"):
|
||||
s = "+" + s[2:]
|
||||
if s.startswith("0") and len(s) >= 9:
|
||||
s = "+966" + s[1:]
|
||||
if s.isdigit() and len(s) == 9:
|
||||
s = "+966" + s
|
||||
return s
|
||||
|
||||
|
||||
def _parse_rows(body: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if isinstance(body.get("rows"), list):
|
||||
return [dict(x) for x in body["rows"] if isinstance(x, dict)]
|
||||
csv_text = body.get("csv_text")
|
||||
if isinstance(csv_text, str) and csv_text.strip():
|
||||
reader = csv.DictReader(io.StringIO(csv_text))
|
||||
return [dict(row) for row in reader]
|
||||
return []
|
||||
|
||||
|
||||
def _bucket_for_row(row: dict[str, Any], *, customer_id: str) -> tuple[str, str, dict[str, Any]]:
|
||||
"""
|
||||
Returns (bucket, reason_code, extra) where bucket is safe | needs_review | blocked.
|
||||
"""
|
||||
if row.get("opted_out") in (True, "true", "1", 1):
|
||||
return "blocked", "opted_out", {}
|
||||
|
||||
src = str(row.get("source") or "").strip().lower() or "unknown"
|
||||
if row.get("cold_whatsapp") in (True, "true", "1", 1):
|
||||
return "blocked", "cold_whatsapp", {}
|
||||
|
||||
if src in ("purchased_list", "scraped", "unknown_list"):
|
||||
return "blocked", "purchased_list", {}
|
||||
|
||||
phone = _norm_phone(row.get("phone") or row.get("mobile") or row.get("tel"))
|
||||
email = str(row.get("email") or "").strip().lower()
|
||||
if not phone and not email:
|
||||
return "needs_review", "missing_identifier", {}
|
||||
|
||||
contact_id = phone or email
|
||||
|
||||
if src == "unknown" or src not in _TRUSTED_SOURCES:
|
||||
return "needs_review", "unknown_source", {"contact_id": contact_id}
|
||||
|
||||
# Trusted: synthetic consent so contactability can pass (import-time snapshot).
|
||||
if src in ("inbound_form", "website_form", "form_submission", "linkedin_lead_form"):
|
||||
basis = LawfulBasis.CONSENT
|
||||
rec_src = "form_submission"
|
||||
else:
|
||||
basis = LawfulBasis.LEGITIMATE_INTEREST
|
||||
rec_src = "public_directory"
|
||||
|
||||
rec = record_consent(
|
||||
customer_id=customer_id,
|
||||
contact_id=contact_id,
|
||||
lawful_basis=basis,
|
||||
purpose="import_preview",
|
||||
channel="all",
|
||||
source=rec_src,
|
||||
)
|
||||
status = check_contactability(
|
||||
contact_id=contact_id,
|
||||
consent_records=[rec],
|
||||
messages_sent_this_week=0,
|
||||
weekly_cap=10,
|
||||
current_riyadh_hour=12,
|
||||
)
|
||||
if not status.can_contact:
|
||||
if status.reason_code == "opted_out":
|
||||
return "blocked", status.reason_code, status.to_dict()
|
||||
return "needs_review", status.reason_code, status.to_dict()
|
||||
|
||||
return "safe", "trusted_with_consent_snapshot", status.to_dict()
|
||||
|
||||
|
||||
def build_import_preview(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Summarizes contacts into safe / needs_review / blocked / invalid_duplicate.
|
||||
|
||||
No external I/O; does not persist.
|
||||
"""
|
||||
customer_id = str(body.get("customer_id") or "default")
|
||||
rows = _parse_rows(body)
|
||||
if not rows:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "no_rows",
|
||||
"detail_ar": "مرّر ``rows`` كقائمة أو ``csv_text`` مع رؤوس أعمدة.",
|
||||
}
|
||||
|
||||
seen: set[str] = set()
|
||||
counts = {"safe": 0, "needs_review": 0, "blocked": 0, "invalid_duplicate": 0}
|
||||
samples: dict[str, list[dict[str, Any]]] = {"safe": [], "needs_review": [], "blocked": []}
|
||||
|
||||
for raw in rows:
|
||||
phone = _norm_phone(raw.get("phone") or raw.get("mobile") or raw.get("tel"))
|
||||
email = str(raw.get("email") or "").strip().lower()
|
||||
dedupe_key = phone or email or ""
|
||||
if dedupe_key and dedupe_key in seen:
|
||||
counts["invalid_duplicate"] += 1
|
||||
continue
|
||||
if dedupe_key:
|
||||
seen.add(dedupe_key)
|
||||
|
||||
bucket, reason, extra = _bucket_for_row(raw, customer_id=customer_id)
|
||||
counts[bucket] += 1
|
||||
entry = {
|
||||
"phone": phone,
|
||||
"email": email or None,
|
||||
"source": raw.get("source"),
|
||||
"bucket": bucket,
|
||||
"reason": reason,
|
||||
"contactability": extra,
|
||||
}
|
||||
if bucket in samples and len(samples[bucket]) < 5:
|
||||
samples[bucket].append(entry)
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"approval_required": True,
|
||||
"counts": counts,
|
||||
"samples": samples,
|
||||
"note_ar": "معاينة فقط — لا يُرسل أي تواصل ولا يُخزّن دفعة الاستيراد في MVP.",
|
||||
}
|
||||
@ -1,110 +1,74 @@
|
||||
"""
|
||||
Omni-Channel Event Bus — every channel emits typed events here.
|
||||
|
||||
Pure structures + helpers; the actual transport (Redis/Kafka) lives in a
|
||||
production adapter. This module is testable in isolation.
|
||||
"""
|
||||
"""Unified event types and field validation — no transport."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ── Event taxonomy ────────────────────────────────────────────────
|
||||
EVENT_TYPES: tuple[str, ...] = (
|
||||
# WhatsApp
|
||||
"whatsapp.message_received",
|
||||
"whatsapp.message_sent",
|
||||
"whatsapp.opt_out",
|
||||
# Email (Gmail or company SMTP)
|
||||
"email.received",
|
||||
"email.draft_created",
|
||||
"email.sent",
|
||||
# Calendar
|
||||
"calendar.meeting_scheduled",
|
||||
"calendar.meeting_held",
|
||||
"calendar.no_show",
|
||||
# Social (X / LinkedIn / Instagram / Facebook)
|
||||
"social.comment_received",
|
||||
"social.dm_received",
|
||||
"social.mention_received",
|
||||
"social.lead_form_submitted",
|
||||
# Website + CRM
|
||||
"lead.form_submitted",
|
||||
"lead.crm_imported",
|
||||
# Payments (Moyasar)
|
||||
"payment.initiated",
|
||||
"payment.paid",
|
||||
"payment.failed",
|
||||
"payment.refunded",
|
||||
# Reviews / reputation (Google Business Profile)
|
||||
"review.created",
|
||||
"review.replied",
|
||||
# Partners
|
||||
"partner.suggested",
|
||||
"partner.intro_made",
|
||||
# Internal lifecycle
|
||||
"action.requested",
|
||||
"action.approved",
|
||||
"action.rejected",
|
||||
"action.executed",
|
||||
"action.blocked",
|
||||
# Sheets / CRM sync
|
||||
"sheet.row_added",
|
||||
"crm.deal_updated",
|
||||
)
|
||||
class EventType(str, Enum):
|
||||
"""Stable event type names for platform ingest and internal cards."""
|
||||
|
||||
LEAD_RECEIVED = "lead_received"
|
||||
EXTERNAL_SEND_REQUESTED = "external_send_requested"
|
||||
PAYMENT_INTENT = "payment_intent"
|
||||
WHATSAPP_MESSAGE_REQUESTED = "whatsapp_message_requested"
|
||||
REVIEW_REQUIRED = "review_required"
|
||||
DRAFT_CREATED = "draft_created"
|
||||
# Omni-channel extensions (dotted names) — backward compatible with existing types.
|
||||
EMAIL_RECEIVED = "email.received"
|
||||
CALENDAR_MEETING_SCHEDULED = "calendar.meeting_scheduled"
|
||||
SOCIAL_COMMENT_RECEIVED = "social.comment_received"
|
||||
SOCIAL_DM_RECEIVED = "social.dm_received"
|
||||
LEAD_FORM_SUBMITTED = "lead.form_submitted"
|
||||
PAYMENT_PAID = "payment.paid"
|
||||
PAYMENT_FAILED = "payment.failed"
|
||||
REVIEW_CREATED = "review.created"
|
||||
PARTNER_SUGGESTED = "partner.suggested"
|
||||
ACTION_APPROVED = "action.approved"
|
||||
ACTION_BLOCKED = "action.blocked"
|
||||
|
||||
|
||||
# ── Event envelope ────────────────────────────────────────────────
|
||||
@dataclass(frozen=True)
|
||||
class PlatformEvent:
|
||||
"""Immutable platform event."""
|
||||
|
||||
event_id: str
|
||||
event_type: str
|
||||
channel: str # whatsapp / gmail / google_calendar / x / ...
|
||||
customer_id: str
|
||||
occurred_at: datetime
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
correlation_id: str | None = None
|
||||
actor: str = "system"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"event_id": self.event_id,
|
||||
"event_type": self.event_type,
|
||||
"channel": self.channel,
|
||||
"customer_id": self.customer_id,
|
||||
"occurred_at": self.occurred_at.isoformat(),
|
||||
"payload": self.payload,
|
||||
"correlation_id": self.correlation_id,
|
||||
"actor": self.actor,
|
||||
}
|
||||
_REQUIRED: dict[EventType, tuple[str, ...]] = {
|
||||
EventType.LEAD_RECEIVED: ("source", "channel_id"),
|
||||
EventType.EXTERNAL_SEND_REQUESTED: ("channel_id", "action"),
|
||||
EventType.PAYMENT_INTENT: ("amount_halalas", "currency"),
|
||||
EventType.WHATSAPP_MESSAGE_REQUESTED: ("intent", "audience"),
|
||||
EventType.REVIEW_REQUIRED: ("reason_code",),
|
||||
EventType.DRAFT_CREATED: ("draft_kind",),
|
||||
EventType.EMAIL_RECEIVED: ("channel_id", "subject_ar"),
|
||||
EventType.CALENDAR_MEETING_SCHEDULED: ("channel_id", "title_ar"),
|
||||
EventType.SOCIAL_COMMENT_RECEIVED: ("channel_id", "snippet_ar"),
|
||||
EventType.SOCIAL_DM_RECEIVED: ("channel_id", "sender_hint"),
|
||||
EventType.LEAD_FORM_SUBMITTED: ("source", "channel_id"),
|
||||
EventType.PAYMENT_PAID: ("amount_halalas", "currency"),
|
||||
EventType.PAYMENT_FAILED: ("amount_halalas", "reason_code"),
|
||||
EventType.REVIEW_CREATED: ("channel_id", "rating"),
|
||||
EventType.PARTNER_SUGGESTED: ("partner_name_ar", "sector"),
|
||||
EventType.ACTION_APPROVED: ("action_id", "actor"),
|
||||
EventType.ACTION_BLOCKED: ("action_id", "reason_code"),
|
||||
}
|
||||
|
||||
|
||||
def make_event(
|
||||
*,
|
||||
event_type: str,
|
||||
channel: str,
|
||||
customer_id: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
actor: str = "system",
|
||||
occurred_at: datetime | None = None,
|
||||
) -> PlatformEvent:
|
||||
"""Construct a validated event."""
|
||||
if event_type not in EVENT_TYPES:
|
||||
raise ValueError(f"unknown event_type: {event_type}")
|
||||
return PlatformEvent(
|
||||
event_id=f"pevt_{uuid.uuid4().hex[:24]}",
|
||||
event_type=event_type,
|
||||
channel=channel,
|
||||
customer_id=customer_id,
|
||||
occurred_at=occurred_at or datetime.now(timezone.utc).replace(tzinfo=None),
|
||||
payload=payload or {},
|
||||
correlation_id=correlation_id,
|
||||
actor=actor,
|
||||
)
|
||||
def validate_event(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Validate ``event_type`` and required keys. Unknown types are rejected
|
||||
(forces explicit extension rather than silent typos).
|
||||
"""
|
||||
errors: list[str] = []
|
||||
raw_type = payload.get("event_type")
|
||||
if not isinstance(raw_type, str) or not raw_type.strip():
|
||||
return {"valid": False, "errors": ["event_type_required"], "normalized": None}
|
||||
|
||||
try:
|
||||
et = EventType(raw_type.strip())
|
||||
except ValueError:
|
||||
return {"valid": False, "errors": [f"unknown_event_type:{raw_type}"], "normalized": None}
|
||||
|
||||
for key in _REQUIRED[et]:
|
||||
if key not in payload or payload[key] in (None, ""):
|
||||
errors.append(f"missing_field:{key}")
|
||||
|
||||
normalized = {"event_type": et.value, **{k: v for k, v in payload.items() if k != "event_type"}}
|
||||
normalized["event_type"] = et.value
|
||||
return {"valid": len(errors) == 0, "errors": errors, "normalized": normalized if not errors else None}
|
||||
|
||||
@ -1,91 +1,22 @@
|
||||
"""
|
||||
Identity Resolution — reconcile signals from many channels into one Identity.
|
||||
|
||||
Inputs: phone, email, company, social handles, CRM ids.
|
||||
Output: a single Identity record with confidence per matched signal.
|
||||
|
||||
Pure deterministic — production version would hit a graph DB.
|
||||
"""
|
||||
"""Deterministic identity merge demo — no external graph DB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class Identity:
|
||||
"""A reconciled identity across channels."""
|
||||
|
||||
identity_id: str
|
||||
primary_phone: str | None = None
|
||||
primary_email: str | None = None
|
||||
company: str | None = None
|
||||
crm_id: str | None = None
|
||||
social_handles: dict[str, str] = field(default_factory=dict)
|
||||
confidence: float = 0.0 # 0..1
|
||||
sources: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
def _hash_id(*parts: str) -> str:
|
||||
"""Deterministic ID from any combination of stable identifiers."""
|
||||
seed = "|".join(p.lower().strip() for p in parts if p)
|
||||
if not seed:
|
||||
return ""
|
||||
h = hashlib.sha256(seed.encode("utf-8")).hexdigest()[:16]
|
||||
return f"id_{h}"
|
||||
|
||||
|
||||
def resolve_identity(*, signals: list[dict[str, Any]]) -> Identity:
|
||||
"""
|
||||
Merge a list of signals (from different channels) into one Identity.
|
||||
|
||||
Each signal can be: {phone, email, company, crm_id, social_handles, source}.
|
||||
"""
|
||||
phones: dict[str, int] = {}
|
||||
emails: dict[str, int] = {}
|
||||
companies: dict[str, int] = {}
|
||||
crm_ids: list[str] = []
|
||||
socials: dict[str, str] = {}
|
||||
sources: list[str] = []
|
||||
|
||||
for s in signals:
|
||||
ph = (s.get("phone") or "").strip()
|
||||
em = (s.get("email") or "").strip().lower()
|
||||
co = (s.get("company") or "").strip()
|
||||
crm = (s.get("crm_id") or "").strip()
|
||||
if ph:
|
||||
phones[ph] = phones.get(ph, 0) + 1
|
||||
if em:
|
||||
emails[em] = emails.get(em, 0) + 1
|
||||
if co:
|
||||
companies[co] = companies.get(co, 0) + 1
|
||||
if crm:
|
||||
crm_ids.append(crm)
|
||||
for k, v in (s.get("social_handles") or {}).items():
|
||||
if k not in socials and v:
|
||||
socials[k] = v
|
||||
if s.get("source"):
|
||||
sources.append(str(s["source"]))
|
||||
|
||||
# Pick most-frequent canonical values
|
||||
primary_phone = max(phones, key=phones.get) if phones else None
|
||||
primary_email = max(emails, key=emails.get) if emails else None
|
||||
company = max(companies, key=companies.get) if companies else None
|
||||
crm_id = crm_ids[0] if crm_ids else None
|
||||
|
||||
# Confidence: proportional to number of independent strong signals
|
||||
strong_signals = sum(1 for x in (primary_phone, primary_email, crm_id) if x)
|
||||
confidence = min(1.0, 0.30 * strong_signals + 0.10 * (1 if socials else 0))
|
||||
|
||||
return Identity(
|
||||
identity_id=_hash_id(primary_phone or "", primary_email or "", crm_id or ""),
|
||||
primary_phone=primary_phone,
|
||||
primary_email=primary_email,
|
||||
company=company,
|
||||
crm_id=crm_id,
|
||||
social_handles=dict(socials),
|
||||
confidence=round(confidence, 3),
|
||||
sources=list(dict.fromkeys(sources)), # dedupe preserve order
|
||||
)
|
||||
def resolve_identity_demo(
|
||||
*,
|
||||
phone: str | None = None,
|
||||
email: str | None = None,
|
||||
company_hint: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
parts = "|".join([p or "" for p in (phone, email, company_hint)])
|
||||
hid = hashlib.sha256(parts.encode("utf-8")).hexdigest()[:16]
|
||||
return {
|
||||
"identity_key": f"id_{hid}",
|
||||
"signals": {"phone": phone, "email": email, "company_hint": company_hint},
|
||||
"note_ar": "دمج تجريبي — ربط CRM وsocial handles لاحقاً.",
|
||||
"demo": True,
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
"""Deterministic unified inbox feed for demos — merges intel cards + platform inbox card."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.intelligence_layer.intel_command_feed import build_intel_command_feed
|
||||
from auto_client_acquisition.platform_services.event_bus import EventType
|
||||
from auto_client_acquisition.platform_services.unified_inbox import event_to_inbox_card
|
||||
|
||||
|
||||
def build_inbox_feed() -> dict[str, Any]:
|
||||
intel = build_intel_command_feed()
|
||||
items: list[dict[str, Any]] = []
|
||||
for c in intel.get("cards") if isinstance(intel.get("cards"), list) else []:
|
||||
items.append({"source_layer": "intelligence", "format": "command_card", "payload": c})
|
||||
lead_card = event_to_inbox_card(
|
||||
{
|
||||
"event_type": EventType.LEAD_RECEIVED.value,
|
||||
"source": "trusted_simulation",
|
||||
"channel_id": "website_form",
|
||||
"lead_name": "عميل تجريبي",
|
||||
}
|
||||
)
|
||||
items.append({"source_layer": "platform", "format": "inbox_card", "payload": lead_card})
|
||||
email_card = event_to_inbox_card(
|
||||
{
|
||||
"event_type": EventType.EMAIL_RECEIVED.value,
|
||||
"channel_id": "gmail",
|
||||
"subject_ar": "استفسار عن الباقات",
|
||||
}
|
||||
)
|
||||
items.append({"source_layer": "platform", "format": "inbox_card", "payload": email_card})
|
||||
review_card = event_to_inbox_card(
|
||||
{
|
||||
"event_type": EventType.REVIEW_CREATED.value,
|
||||
"channel_id": "google_business_profile",
|
||||
"rating": 2,
|
||||
}
|
||||
)
|
||||
items.append({"source_layer": "platform", "format": "inbox_card", "payload": review_card})
|
||||
return {"items": items, "count": len(items), "demo": True, "approval_required": False}
|
||||
@ -0,0 +1,43 @@
|
||||
"""Lead form webhook MVP — trusted simulation only, no signature crypto yet."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.platform_services.event_bus import EventType, validate_event
|
||||
from auto_client_acquisition.platform_services.unified_inbox import event_to_inbox_card
|
||||
|
||||
_ALLOWED_SOURCES = frozenset({"trusted_simulation"})
|
||||
|
||||
|
||||
def ingest_lead_form(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Accepts ``source``, ``channel_id`` (linkedin_lead_form | website_form), and lead fields.
|
||||
Documented contract for later HMAC verification.
|
||||
"""
|
||||
source = str(body.get("source") or "")
|
||||
if source not in _ALLOWED_SOURCES:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "invalid_source",
|
||||
"detail_ar": "المصدر غير مسموح في MVP — استخدم trusted_simulation حتى تفعيل التوقيع.",
|
||||
}
|
||||
|
||||
channel_id = str(body.get("channel_id") or "website_form")
|
||||
if channel_id not in ("linkedin_lead_form", "website_form"):
|
||||
return {"ok": False, "error": "invalid_channel", "detail_ar": "القناة غير مدعومة في مسار الـ ingest هذا."}
|
||||
|
||||
event = {
|
||||
"event_type": EventType.LEAD_RECEIVED.value,
|
||||
"source": source,
|
||||
"channel_id": channel_id,
|
||||
"lead_name": body.get("lead_name") or body.get("name") or "",
|
||||
"lead_email": body.get("lead_email") or body.get("email"),
|
||||
"meta": body.get("meta") if isinstance(body.get("meta"), dict) else {},
|
||||
}
|
||||
v = validate_event(event)
|
||||
if not v["valid"]:
|
||||
return {"ok": False, "error": "validation_failed", "errors": v["errors"]}
|
||||
|
||||
card = event_to_inbox_card(v["normalized"] or event)
|
||||
return {"ok": True, "event": v["normalized"], "inbox_card": card, "approval_required": True}
|
||||
@ -0,0 +1,32 @@
|
||||
"""Single JSON view combining innovation proof summary + business proof pack demo."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.business.proof_pack import build_demo_proof_pack
|
||||
from auto_client_acquisition.platform_services.proof_summary import build_proof_summary
|
||||
|
||||
|
||||
def build_proof_overview() -> dict[str, Any]:
|
||||
summary = build_proof_summary()
|
||||
pack = build_demo_proof_pack()
|
||||
return {
|
||||
"demo": True,
|
||||
"approval_required": False,
|
||||
"innovation_ledger_summary": summary,
|
||||
"business_proof_pack_excerpt": {
|
||||
"executive_summary_ar": pack.get("executive_summary_ar"),
|
||||
"qualified_leads": pack.get("qualified_leads"),
|
||||
"meetings_booked": pack.get("meetings_booked"),
|
||||
"revenue_influenced_sar": pack.get("revenue_influenced_sar"),
|
||||
"next_month_plan_ar": pack.get("next_month_plan_ar"),
|
||||
},
|
||||
"related_routes": {
|
||||
"innovation_proof_ledger_demo": "GET /api/v1/innovation/proof-ledger/demo",
|
||||
"innovation_proof_events": "GET /api/v1/innovation/proof-ledger/events",
|
||||
"innovation_proof_report_week": "GET /api/v1/innovation/proof-ledger/report/week",
|
||||
"business_proof_pack_demo": "GET /api/v1/business/proof-pack/demo",
|
||||
"platform_proof_summary": "GET /api/v1/platform/proof/summary",
|
||||
},
|
||||
}
|
||||
@ -0,0 +1,30 @@
|
||||
"""Summarize innovation demo proof ledger — single source for demo numbers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from auto_client_acquisition.innovation.proof_ledger import build_demo_proof_ledger
|
||||
|
||||
|
||||
def build_proof_summary() -> dict[str, Any]:
|
||||
demo = build_demo_proof_ledger()
|
||||
events = demo.get("events") if isinstance(demo.get("events"), list) else []
|
||||
total_rev = 0.0
|
||||
types: dict[str, int] = {}
|
||||
for ev in events:
|
||||
if not isinstance(ev, dict):
|
||||
continue
|
||||
et = str(ev.get("event_type") or "unknown")
|
||||
types[et] = types.get(et, 0) + 1
|
||||
try:
|
||||
total_rev += float(ev.get("revenue_influenced_sar_estimate") or 0)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
return {
|
||||
"demo": True,
|
||||
"source": "innovation.proof_ledger.build_demo_proof_ledger",
|
||||
"event_count": len(events),
|
||||
"event_types": types,
|
||||
"revenue_influenced_sar_estimate_sum": total_rev,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user