""" Revenue OS Router — single integration point for the v3 Autonomous layers. Endpoints under /api/v1/revenue-os/: Memory: /events /timeline/{account_id} /replay/{customer_id} Agents: /workflows/run /tasks /tasks/{id}/approve /tasks/{id}/reject Market: /market-radar/signals /market-radar/sectors /market-radar/cities /market-radar/opportunities Copilot: /copilot/ask /copilot/intents /copilot/actions/{id} Forecast: /forecast /attribution /impact /churn /expansion Compliance: /contactability /campaign-risk /ropa /dsr /dsr/{id}/process /vendors Verticals: /verticals /verticals/{id} /verticals/{id}/templates """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from typing import Any from fastapi import APIRouter, Body, HTTPException, Query # Compliance OS from auto_client_acquisition.compliance_os.consent_ledger import ( LawfulBasis, record_consent, record_opt_out, ) from auto_client_acquisition.compliance_os.contactability import check_contactability from auto_client_acquisition.compliance_os.data_subject_requests import ( DSR_TYPES, DSRStatus, dsr_dashboard, open_dsr, process_dsr, ) from auto_client_acquisition.compliance_os.risk_engine import score_campaign_risk from auto_client_acquisition.compliance_os.ropa import build_ropa from auto_client_acquisition.compliance_os.vendor_registry import ( DEFAULT_VENDORS, vendors_summary, ) # Copilot from auto_client_acquisition.copilot import ask from auto_client_acquisition.copilot.intent_router import list_intents from auto_client_acquisition.copilot.safe_actions import SAFE_ACTIONS, get_action # Market Intelligence from auto_client_acquisition.market_intelligence.opportunity_feed import ( build_opportunity_feed, ) from auto_client_acquisition.market_intelligence.sector_pulse import build_sector_pulse from auto_client_acquisition.market_intelligence.signal_detectors import ( SIGNAL_TYPES, SignalDetection, detect_ads_signal, detect_funding_signal, detect_hiring_signal, detect_tender_signal, detect_website_change, ) # Orchestrator from auto_client_acquisition.orchestrator.policies import ( AutonomyMode, default_policy, ) from auto_client_acquisition.orchestrator.queue import TaskQueue, TaskStatus from auto_client_acquisition.orchestrator.runtime import DAILY_GROWTH_RUN, Orchestrator from auto_client_acquisition.orchestrator.tools import default_executors # Revenue Memory from auto_client_acquisition.revenue_memory.event_store import ( InMemoryEventStore, get_default_store, ) from auto_client_acquisition.revenue_memory.events import ( EVENT_TYPES, event_to_dict, make_event, ) from auto_client_acquisition.revenue_memory.replay import ( replay_for_account, replay_for_customer, ) from auto_client_acquisition.revenue_memory.retention import retention_summary # Revenue Science from auto_client_acquisition.revenue_science.attribution import ( compute_first_touch, compute_last_touch, compute_linear, compute_time_decay, ) from auto_client_acquisition.revenue_science.causal_impact import simulate_impact from auto_client_acquisition.revenue_science.churn_model import predict_churn from auto_client_acquisition.revenue_science.expansion_model import predict_expansion from auto_client_acquisition.revenue_science.forecast import compute_forecast # Vertical OS from auto_client_acquisition.vertical_os import ( ALL_VERTICALS, get_vertical, list_vertical_summaries, ) # Why-Now (used by opportunity_feed) from auto_client_acquisition.revenue_graph.why_now import ( WhyNowSignal, 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__) def _now() -> datetime: return datetime.now(timezone.utc).replace(tzinfo=None) # ── Module-level singletons (in-memory adapters; production replaces) ─ _QUEUE = TaskQueue() _ORCHESTRATOR_FACTORY = None def _get_orchestrator(customer_id: str) -> Orchestrator: """Build an orchestrator with the default in-memory store + policy.""" store = get_default_store() def policy_resolver(c): return default_policy(c) return Orchestrator( queue=_QUEUE, event_store=store, policy_resolver=policy_resolver, executor_registry=default_executors(), ) # ───────────────────────────────────────────────────────────────── # 1. REVENUE MEMORY ENDPOINTS # ───────────────────────────────────────────────────────────────── @router.get("/events/types") async def list_event_types() -> dict[str, Any]: """50 event types Dealix records.""" return {"count": len(EVENT_TYPES), "event_types": list(EVENT_TYPES)} @router.post("/events") async def append_event( event_type: str = Body(..., embed=True), customer_id: str = Body(..., embed=True), subject_type: str = Body(..., embed=True), subject_id: str = Body(..., embed=True), payload: dict[str, Any] = Body(default_factory=dict, embed=True), actor: str = Body(default="system", embed=True), ) -> dict[str, Any]: """Append a new event to the customer's stream.""" try: e = make_event( event_type=event_type, customer_id=customer_id, subject_type=subject_type, subject_id=subject_id, payload=payload, actor=actor, ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc get_default_store().append(e) return {"event_id": e.event_id, "event_type": e.event_type} @router.get("/timeline/{account_id}") async def get_timeline(account_id: str, customer_id: str = Query(...)) -> dict[str, Any]: """Replay account timeline from the event stream.""" timeline = replay_for_account(customer_id=customer_id, account_id=account_id) return timeline.to_dict() @router.get("/replay/{customer_id}") async def replay_customer_roi( customer_id: str, period_days: int = Query(default=30, ge=1, le=365), ) -> dict[str, Any]: """Compute ROI projection for the customer over the period.""" period_start = _now() - timedelta(days=period_days) proj = replay_for_customer(customer_id=customer_id, period_start=period_start) return { "customer_id": proj.customer_id, "period_days": period_days, "n_leads": proj.n_leads, "n_meetings": proj.n_meetings, "n_proposals": proj.n_proposals, "n_deals_won": proj.n_deals_won, "revenue_won_sar": proj.revenue_won_sar, "pipeline_added_sar": proj.pipeline_added_sar, } @router.get("/retention-summary") async def get_retention_summary(customer_id: str = Query(...)) -> dict[str, Any]: """How many events per retention tier — for Trust Center display.""" events = list(get_default_store().read_for_customer(customer_id)) return retention_summary(events) # ───────────────────────────────────────────────────────────────── # 2. AGENT ORCHESTRATOR ENDPOINTS # ───────────────────────────────────────────────────────────────── @router.post("/workflows/run") async def run_workflow( workflow_id: str = Body(default="daily_growth_run", embed=True), customer_id: str = Body(..., embed=True), autonomy_mode: str = Body(default=AutonomyMode.DRAFT_APPROVE, embed=True), ) -> dict[str, Any]: """Trigger a workflow — Daily Growth Run by default.""" if workflow_id != "daily_growth_run": raise HTTPException(status_code=404, detail=f"unknown workflow: {workflow_id}") store = get_default_store() def resolver(c): p = default_policy(c) p.autonomy_mode = autonomy_mode return p orch = Orchestrator( queue=_QUEUE, event_store=store, policy_resolver=resolver, executor_registry=default_executors(), ) summary = orch.run_workflow(workflow=DAILY_GROWTH_RUN, customer_id=customer_id) return summary @router.get("/tasks") async def list_tasks( customer_id: str = Query(...), status: str | None = Query(default=None), ) -> dict[str, Any]: if status: tasks = [t for t in _QUEUE.for_customer(customer_id) if t.status == status] else: tasks = _QUEUE.for_customer(customer_id) return { "summary": _QUEUE.summary(customer_id), "tasks": [ { "task_id": t.task_id, "agent_id": t.agent_id, "action_type": t.action_type, "status": t.status, "requires_approval": t.requires_approval, "approval_reason": t.approval_reason, "created_at": t.created_at.isoformat(), } for t in tasks ], } @router.post("/tasks/{task_id}/approve") async def approve_task(task_id: str, approved_by: str = Body(..., embed=True)) -> dict[str, Any]: orch = _get_orchestrator("any") try: task = orch.approve_and_execute(task_id=task_id, approved_by=approved_by) except (KeyError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"task_id": task.task_id, "status": task.status} @router.post("/tasks/{task_id}/reject") async def reject_task( task_id: str, rejected_by: str = Body(..., embed=True), reason: str = Body(default="", embed=True), ) -> dict[str, Any]: orch = _get_orchestrator("any") try: task = orch.reject_task(task_id=task_id, rejected_by=rejected_by, reason=reason) except (KeyError, ValueError) as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return {"task_id": task.task_id, "status": task.status} # ───────────────────────────────────────────────────────────────── # 3. MARKET RADAR ENDPOINTS # ───────────────────────────────────────────────────────────────── @router.get("/market-radar/signal-types") async def list_signal_types() -> dict[str, Any]: return {"count": len(SIGNAL_TYPES), "signal_types": list(SIGNAL_TYPES)} @router.post("/market-radar/detect/hiring") async def detect_hiring( company_id: str = Body(..., embed=True), job_postings: list[dict[str, Any]] = Body(default_factory=list, embed=True), ) -> dict[str, Any]: # Convert ISO strings to datetimes parsed = [] for jp in job_postings: posted = jp.get("posted_at") if isinstance(posted, str): try: jp["posted_at"] = datetime.fromisoformat(posted.replace("Z", "+00:00")).replace(tzinfo=None) except Exception: continue parsed.append(jp) sigs = detect_hiring_signal(company_id=company_id, job_postings=parsed) return {"signals": [_signal_to_dict(s) for s in sigs]} @router.post("/market-radar/sectors/{sector}/pulse") async def sector_pulse( sector: str, signals_this_week: list[dict[str, Any]] = Body(default_factory=list, embed=True), signals_prior_week: list[dict[str, Any]] = Body(default_factory=list, embed=True), ) -> dict[str, Any]: this_w = [_signal_from_dict(s) for s in signals_this_week] prior_w = [_signal_from_dict(s) for s in signals_prior_week] pulse = build_sector_pulse( sector=sector, signals_this_week=this_w, signals_prior_week=prior_w ) return pulse.to_dict() @router.post("/market-radar/opportunities") async def opportunities( signals: list[dict[str, Any]] = Body(default_factory=list, embed=True), company_metadata: dict[str, dict[str, Any]] = Body(default_factory=dict, embed=True), sector_trends: dict[str, str] = Body(default_factory=dict, embed=True), top_n: int = Body(default=20, embed=True), ) -> dict[str, Any]: parsed_signals = [_signal_from_dict(s) for s in signals] def explainer(*, company_id, signals, sector, sector_pulse_trend): wn = [ WhyNowSignal( signal_type=s.signal_type, detected_at=s.detected_at, source=s.source, evidence_url=s.evidence_url, payload=s.payload, ) for s in signals ] return explain_why_now( company_id=company_id, signals=wn, sector=sector, sector_pulse_trend=sector_pulse_trend, ) feed = build_opportunity_feed( signals=parsed_signals, company_metadata=company_metadata, why_now_explainer=explainer, sector_trends=sector_trends, top_n=top_n, ) return {"count": len(feed), "opportunities": [o.to_dict() for o in feed]} # ───────────────────────────────────────────────────────────────── # 4. COPILOT ENDPOINTS # ───────────────────────────────────────────────────────────────── @router.post("/copilot/ask") async def copilot_ask( question_ar: str = Body(..., embed=True), customer_id: str = Body(..., embed=True), context: dict[str, Any] = Body(default_factory=dict, embed=True), ) -> dict[str, Any]: return ask(question_ar=question_ar, customer_id=customer_id, context=context) @router.get("/copilot/intents") async def copilot_intents() -> dict[str, Any]: return {"intents": list_intents()} @router.get("/copilot/actions") async def copilot_actions() -> dict[str, Any]: return {"actions": [a.to_dict() for a in SAFE_ACTIONS]} @router.get("/copilot/actions/{action_id}") async def copilot_action_detail(action_id: str) -> dict[str, Any]: a = get_action(action_id) if a is None: raise HTTPException(status_code=404, detail=f"unknown action: {action_id}") return a.to_dict() # ───────────────────────────────────────────────────────────────── # 5. REVENUE SCIENCE ENDPOINTS # ───────────────────────────────────────────────────────────────── @router.post("/forecast") async def forecast_endpoint( customer_id: str = Body(..., embed=True), open_deals: list[dict[str, Any]] = Body(default_factory=list, embed=True), horizon_days: int = Body(default=30, embed=True), ) -> dict[str, Any]: f = compute_forecast(customer_id=customer_id, open_deals=open_deals, horizon_days=horizon_days) return { "customer_id": f.customer_id, "horizon_days": f.horizon_days, "period_label": f.period_label, "best": f.best.__dict__, "likely": f.likely.__dict__, "worst": f.worst.__dict__, "deals_breakdown": f.deals_breakdown, "risks_ar": f.risks_ar, "decisions_required_ar": f.decisions_required_ar, } @router.post("/attribution") async def attribution_endpoint( deals: list[dict[str, Any]] = Body(default_factory=list, embed=True), model: str = Body(default="time_decay", embed=True), ) -> dict[str, Any]: if model == "first_touch": r = compute_first_touch(deals=deals) elif model == "last_touch": r = compute_last_touch(deals=deals) elif model == "linear": r = compute_linear(deals=deals) else: r = compute_time_decay(deals=deals) return {"model": r.model, "by_channel": r.by_channel, "total_revenue_sar": r.total_revenue_sar} @router.post("/impact") async def impact_endpoint( current_baseline_revenue_sar: float = Body(..., embed=True), response_time_reduction_hours: float = Body(default=0, embed=True), extra_followup_touches: int = Body(default=0, embed=True), shift_to_whatsapp_pct: float = Body(default=0, embed=True), drop_n_sectors: int = Body(default=0, embed=True), ) -> dict[str, Any]: out = simulate_impact( current_baseline_revenue_sar=current_baseline_revenue_sar, response_time_reduction_hours=response_time_reduction_hours, extra_followup_touches=extra_followup_touches, shift_to_whatsapp_pct=shift_to_whatsapp_pct, drop_n_sectors=drop_n_sectors, ) return out.__dict__ @router.post("/churn") async def churn_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: p = predict_churn( customer_id=payload.get("customer_id", "unknown"), days_since_last_login=int(payload.get("days_since_last_login", 0)), monthly_engagement_drop_pct=float(payload.get("monthly_engagement_drop_pct", 0)), support_tickets_open=int(payload.get("support_tickets_open", 0)), billing_failures_last_90d=int(payload.get("billing_failures_last_90d", 0)), nps=payload.get("nps"), pipeline_added_drop_pct=float(payload.get("pipeline_added_drop_pct", 0)), months_as_customer=int(payload.get("months_as_customer", 6)), ) return p.__dict__ @router.post("/expansion") async def expansion_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: s = predict_expansion( customer_id=payload.get("customer_id", "unknown"), current_plan=payload.get("current_plan", "Growth"), health_score=float(payload.get("health_score", 0)), monthly_engagement_growth_pct=float(payload.get("monthly_engagement_growth_pct", 0)), sectors_targeted=int(payload.get("sectors_targeted", 1)), pct_of_quota_used=float(payload.get("pct_of_quota_used", 0)), nps=payload.get("nps"), pipeline_added_growth_pct=float(payload.get("pipeline_added_growth_pct", 0)), ) return s.__dict__ # ───────────────────────────────────────────────────────────────── # 6. COMPLIANCE OS ENDPOINTS # ───────────────────────────────────────────────────────────────── @router.post("/compliance/contactability") async def contactability_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: """Check if a contact can be reached now. Records is a list of consent dicts.""" contact_id = payload["contact_id"] records_dicts = payload.get("consent_records", []) # Convert to ConsentRecord (lightweight inline) from auto_client_acquisition.compliance_os.consent_ledger import ConsentRecord records = [] for r in records_dicts: oa = r.get("occurred_at") if isinstance(oa, str): try: oa = datetime.fromisoformat(oa.replace("Z", "+00:00")).replace(tzinfo=None) except Exception: oa = _now() records.append(ConsentRecord( record_id=r.get("record_id", "x"), customer_id=r.get("customer_id", ""), contact_id=contact_id, record_type=r.get("record_type", "consent_granted"), lawful_basis=r.get("lawful_basis"), purpose=r.get("purpose", ""), channel=r.get("channel"), source=r.get("source", "api"), occurred_at=oa, )) s = check_contactability( contact_id=contact_id, consent_records=records, messages_sent_this_week=int(payload.get("messages_sent_this_week", 0)), weekly_cap=int(payload.get("weekly_cap", 2)), current_riyadh_hour=int(payload.get("current_riyadh_hour", 12)), ) return s.to_dict() @router.post("/compliance/campaign-risk") async def campaign_risk_endpoint(payload: dict[str, Any] = Body(...)) -> dict[str, Any]: r = score_campaign_risk( target_count=int(payload.get("target_count", 0)), contacts_with_consent=int(payload.get("contacts_with_consent", 0)), contacts_opted_out=int(payload.get("contacts_opted_out", 0)), contacts_no_lawful_basis=int(payload.get("contacts_no_lawful_basis", 0)), template_body=payload.get("template_body", ""), template_subject=payload.get("template_subject", ""), channel=payload.get("channel", "email"), has_unsubscribe_link=bool(payload.get("has_unsubscribe_link", True)), in_quiet_hours=bool(payload.get("in_quiet_hours", False)), ) return { "risk_score": r.risk_score, "risk_band": r.risk_band, "issues": r.issues, "blockers": r.blockers, "contacts_safe": r.contacts_safe, "contacts_blocked": r.contacts_blocked, "contacts_needing_review": r.contacts_needing_review, "recommended_fixes_ar": r.recommended_fixes_ar, } @router.get("/compliance/ropa") async def get_ropa( customer_id: str = Query(...), customer_name: str = Query(default="Customer"), dpo_email: str | None = Query(default=None), ) -> dict[str, Any]: r = build_ropa(customer_id=customer_id, customer_name=customer_name, dpo_email=dpo_email) return r.to_json() @router.post("/compliance/dsr") async def open_dsr_endpoint( customer_id: str = Body(..., embed=True), data_subject_id: str = Body(..., embed=True), request_type: str = Body(..., embed=True), ) -> dict[str, Any]: if request_type not in DSR_TYPES: raise HTTPException(status_code=400, detail=f"unknown DSR type: {request_type}") r = open_dsr(customer_id=customer_id, data_subject_id=data_subject_id, request_type=request_type) return { "request_id": r.request_id, "request_type": r.request_type, "status": r.status, "received_at": r.received_at.isoformat(), "sla_due_at": r.sla_due_at.isoformat(), } @router.get("/compliance/vendors") async def list_vendors() -> dict[str, Any]: return { "summary": vendors_summary(), "vendors": [ { "vendor_id": v.vendor_id, "name": v.name, "purpose_ar": v.purpose_ar, "data_accessed": v.data_accessed, "region": v.region, "has_dpa_signed": v.has_dpa_signed, "iso27001": v.iso27001, "soc2": v.soc2, "risk_tier": v.risk_tier, "status": v.status, } for v in DEFAULT_VENDORS ], } # ───────────────────────────────────────────────────────────────── # 7. VERTICAL OS ENDPOINTS # ───────────────────────────────────────────────────────────────── @router.get("/verticals") async def list_verticals() -> dict[str, Any]: return {"summaries": list_vertical_summaries()} @router.get("/verticals/{vertical_id}") async def get_vertical_detail(vertical_id: str) -> dict[str, Any]: v = get_vertical(vertical_id) if v is None: raise HTTPException(status_code=404, detail=f"unknown vertical: {vertical_id}") return { "vertical_id": v.vertical_id, "sector_ar": v.sector_ar, "sector_en": v.sector_en, "icp_company_size": list(v.icp_company_size), "icp_cities": list(v.icp_cities), "icp_keywords": list(v.icp_keywords), "pain_points_ar": list(v.pain_points_ar), "top_objection_ids": list(v.top_objection_ids), "priority_signals": list(v.priority_signals), "dashboard_kpis": [ {"metric_id": k.metric_id, "name_ar": k.name_ar, "description_ar": k.description_ar, "unit": k.unit, "higher_is_better": k.higher_is_better, "target_p50": k.target_p50, "target_p90": k.target_p90} for k in v.dashboard_kpis ], "n_message_templates": len(v.message_templates), "avg_deal_value_sar": v.avg_deal_value_sar, "avg_cycle_days": v.avg_cycle_days, "benchmark_reply_rate": v.benchmark_reply_rate, "benchmark_meeting_rate": v.benchmark_meeting_rate, "benchmark_win_rate": v.benchmark_win_rate, "compliance_notes_ar": list(v.compliance_notes_ar), "recommended_channel_mix": v.recommended_channel_mix, } @router.get("/verticals/{vertical_id}/templates") async def get_vertical_templates(vertical_id: str) -> dict[str, Any]: v = get_vertical(vertical_id) if v is None: raise HTTPException(status_code=404, detail=f"unknown vertical: {vertical_id}") return { "vertical_id": vertical_id, "templates": [ { "template_id": t.template_id, "channel": t.channel, "purpose": t.purpose, "subject_ar": t.subject_ar, "body_ar": t.body_ar, "variables": list(t.variables), "expected_reply_rate": t.expected_reply_rate, } for t in v.message_templates ], "proposal_template_ar": v.proposal_template_ar, "qbr_section_template_ar": v.qbr_section_template_ar, } # ── 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 # ───────────────────────────────────────────────────────────────── def _signal_to_dict(s: SignalDetection) -> dict[str, Any]: return { "company_id": s.company_id, "signal_type": s.signal_type, "detected_at": s.detected_at.isoformat(), "source": s.source, "confidence": s.confidence, "evidence_url": s.evidence_url, "payload": s.payload, } def _signal_from_dict(d: dict[str, Any]) -> SignalDetection: detected = d.get("detected_at") if isinstance(detected, str): try: detected = datetime.fromisoformat(detected.replace("Z", "+00:00")).replace(tzinfo=None) except Exception: detected = _now() return SignalDetection( company_id=d["company_id"], signal_type=d["signal_type"], detected_at=detected or _now(), source=d.get("source", "api"), confidence=float(d.get("confidence", 0.5)), evidence_url=d.get("evidence_url"), payload=d.get("payload", {}), )