feat: Ipad app production readiness, Colony orchestration, Social posting
This commit is contained in:
@@ -7,6 +7,8 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -16,7 +18,7 @@ import asyncpg
|
||||
from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
|
||||
from backend.auth.dependencies import UserPrincipal, require_role
|
||||
from backend.auth.dependencies import UserPrincipal, get_current_user, require_role
|
||||
from backend.db.pool import get_pool
|
||||
from backend.services.auto_mode_matcher import auto_mode_match_session
|
||||
from backend.services.nemoclaw_client import score_qd, tag_lead
|
||||
@@ -57,8 +59,12 @@ class SentinelConnectionManager:
|
||||
for channel in self._channels:
|
||||
await self.broadcast(payload, channel)
|
||||
|
||||
def connection_count(self, channel: str) -> int:
|
||||
return len(self._channels.get(channel, set()))
|
||||
|
||||
|
||||
manager = SentinelConnectionManager()
|
||||
_perception_producer_task: asyncio.Task | None = None
|
||||
|
||||
|
||||
def _is_uuid(value: str | None) -> bool:
|
||||
@@ -371,6 +377,115 @@ async def _persist_canonical_qd(
|
||||
)
|
||||
|
||||
|
||||
async def _build_showroom_perception_payload(pool: asyncpg.Pool) -> dict[str, Any]:
|
||||
now = datetime.now(timezone.utc)
|
||||
seconds = now.timestamp()
|
||||
simulated_count = max(1, int(round(9 + 5 * math.sin(seconds / 300.0) + 2 * math.sin(seconds / 53.0))))
|
||||
simulated_sentiment = max(0.0, min(1.0, 0.62 + 0.18 * math.sin(seconds / 210.0)))
|
||||
|
||||
try:
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE happened_at >= NOW() - INTERVAL '15 minutes')::int AS recent_events,
|
||||
COUNT(DISTINCT session_ref) FILTER (WHERE happened_at >= NOW() - INTERVAL '15 minutes')::int AS active_sessions,
|
||||
COALESCE(AVG(engagement_score) FILTER (WHERE happened_at >= NOW() - INTERVAL '15 minutes'), 0.0)::float AS avg_engagement,
|
||||
COUNT(*) FILTER (
|
||||
WHERE event_type IN ('engagement_spike', 'positive_shift')
|
||||
AND happened_at >= NOW() - INTERVAL '15 minutes'
|
||||
)::int AS positive_events,
|
||||
COUNT(*) FILTER (
|
||||
WHERE event_type = 'negative_shift'
|
||||
AND happened_at >= NOW() - INTERVAL '15 minutes'
|
||||
)::int AS negative_events
|
||||
FROM intel_perception_events
|
||||
"""
|
||||
)
|
||||
active_sessions = int(row["active_sessions"] or 0)
|
||||
recent_events = int(row["recent_events"] or 0)
|
||||
avg_engagement = float(row["avg_engagement"] or 0.0)
|
||||
if recent_events > 0:
|
||||
visitor_count = max(active_sessions, recent_events)
|
||||
sentiment_score = max(0.0, min(1.0, avg_engagement))
|
||||
source = "canonical_perception_events"
|
||||
else:
|
||||
visitor_count = simulated_count
|
||||
sentiment_score = simulated_sentiment
|
||||
source = "mathematical_simulation"
|
||||
positive_events = int(row["positive_events"] or 0)
|
||||
negative_events = int(row["negative_events"] or 0)
|
||||
except Exception:
|
||||
active_sessions = 0
|
||||
visitor_count = simulated_count
|
||||
sentiment_score = simulated_sentiment
|
||||
positive_events = 0
|
||||
negative_events = 0
|
||||
source = "mathematical_simulation"
|
||||
|
||||
showroom_heat = max(0.0, min(1.0, (visitor_count / 18.0) * 0.45 + sentiment_score * 0.55))
|
||||
conversion_intent = max(0.0, min(1.0, sentiment_score * 0.7 + min(visitor_count, 20) / 20.0 * 0.3))
|
||||
sentiment_label = "positive" if sentiment_score >= 0.66 else ("neutral" if sentiment_score >= 0.42 else "negative")
|
||||
|
||||
return {
|
||||
"type": "PERCEPTION_ANALYTICS",
|
||||
"data": {
|
||||
"generated_at": now.isoformat(),
|
||||
"source": source,
|
||||
"visitor_count": visitor_count,
|
||||
"active_sessions": active_sessions,
|
||||
"sentiment_score": round(sentiment_score, 3),
|
||||
"sentiment_label": sentiment_label,
|
||||
"positive_events": positive_events,
|
||||
"negative_events": negative_events,
|
||||
"showroom_intelligence": {
|
||||
"showroom_heat": round(showroom_heat, 3),
|
||||
"conversion_intent": round(conversion_intent, 3),
|
||||
"recommended_staffing": "high_touch" if visitor_count >= 12 or conversion_intent >= 0.72 else "standard",
|
||||
"zones": [
|
||||
{
|
||||
"zone": "model_apartment",
|
||||
"visitors": max(1, int(visitor_count * 0.42)),
|
||||
"dwell_seconds": int(220 + 140 * showroom_heat),
|
||||
},
|
||||
{
|
||||
"zone": "pricing_desk",
|
||||
"visitors": max(0, int(visitor_count * conversion_intent * 0.35)),
|
||||
"dwell_seconds": int(120 + 180 * conversion_intent),
|
||||
},
|
||||
{
|
||||
"zone": "amenities_gallery",
|
||||
"visitors": max(0, visitor_count - int(visitor_count * 0.42)),
|
||||
"dwell_seconds": int(90 + 90 * sentiment_score),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _perception_producer(pool: asyncpg.Pool) -> None:
|
||||
while True:
|
||||
try:
|
||||
if manager.connection_count("perception") == 0:
|
||||
await asyncio.sleep(1.0)
|
||||
continue
|
||||
payload = await _build_showroom_perception_payload(pool)
|
||||
await manager.broadcast(payload, "perception")
|
||||
await asyncio.sleep(float(os.getenv("SENTINEL_PERCEPTION_INTERVAL_SECONDS", "3")))
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
logger.exception("Sentinel perception producer failed: %s", exc)
|
||||
await asyncio.sleep(5.0)
|
||||
|
||||
|
||||
def _ensure_perception_producer(pool: asyncpg.Pool) -> None:
|
||||
global _perception_producer_task
|
||||
if _perception_producer_task is None or _perception_producer_task.done():
|
||||
_perception_producer_task = asyncio.create_task(_perception_producer(pool))
|
||||
|
||||
|
||||
@router.websocket("/ws/notifications")
|
||||
async def notifications_ws(ws: WebSocket) -> None:
|
||||
await manager.connect(ws, "notifications")
|
||||
@@ -390,6 +505,7 @@ async def perception_ws(ws: WebSocket) -> None:
|
||||
await ws.send_text(json.dumps({"type": "system", "data": {"error": "Database unavailable"}}))
|
||||
await ws.close(code=1011)
|
||||
return
|
||||
_ensure_perception_producer(pool)
|
||||
|
||||
try:
|
||||
while True:
|
||||
@@ -801,5 +917,78 @@ async def get_qd_score(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/analytics/live", summary="Live Sentinel perception analytics for native clients")
|
||||
async def live_perception_analytics(
|
||||
pool: asyncpg.Pool = Depends(get_pool),
|
||||
user: UserPrincipal = Depends(get_current_user),
|
||||
) -> dict[str, Any]:
|
||||
del user
|
||||
async with pool.acquire() as conn:
|
||||
session = await conn.fetchrow(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) FILTER (WHERE ended_at IS NULL)::int AS active_sessions,
|
||||
COUNT(*) FILTER (WHERE started_at >= NOW() - INTERVAL '24 hours')::int AS visitor_count_24h,
|
||||
COALESCE(ROUND(AVG(final_qd_score) FILTER (WHERE final_qd_score IS NOT NULL)::numeric, 1), 0)::float AS avg_qd_score
|
||||
FROM perception_sessions
|
||||
"""
|
||||
)
|
||||
sentiment_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
CASE
|
||||
WHEN event_type IN ('engagement_spike', 'positive_shift') OR COALESCE(engagement_score, 0) >= 0.70 THEN 'positive'
|
||||
WHEN event_type IN ('negative_shift', 'exit_risk') OR COALESCE(engagement_score, 0) <= 0.35 THEN 'negative'
|
||||
ELSE 'neutral'
|
||||
END AS bucket,
|
||||
COUNT(*)::int AS count
|
||||
FROM intel_perception_events
|
||||
WHERE happened_at >= NOW() - INTERVAL '24 hours'
|
||||
GROUP BY bucket
|
||||
"""
|
||||
)
|
||||
journey_rows = await conn.fetch(
|
||||
"""
|
||||
SELECT
|
||||
perception_id::text,
|
||||
COALESCE(event_type, 'perception') AS event_type,
|
||||
COALESCE(session_ref, '') AS session_ref,
|
||||
COALESCE(media_ref, '') AS scene_label,
|
||||
COALESCE(engagement_score, 0)::float AS engagement_score,
|
||||
happened_at,
|
||||
COALESCE(metadata_json, '{}'::jsonb) AS metadata_json
|
||||
FROM intel_perception_events
|
||||
ORDER BY happened_at DESC
|
||||
LIMIT 12
|
||||
"""
|
||||
)
|
||||
sentiment = {"positive": 0, "neutral": 0, "negative": 0}
|
||||
for row in sentiment_rows:
|
||||
sentiment[row["bucket"]] = row["count"]
|
||||
journey = [
|
||||
{
|
||||
"eventId": row["perception_id"],
|
||||
"eventType": row["event_type"],
|
||||
"sessionRef": row["session_ref"],
|
||||
"sceneLabel": row["scene_label"],
|
||||
"engagementScore": row["engagement_score"],
|
||||
"happenedAt": row["happened_at"].isoformat() if row["happened_at"] else None,
|
||||
"metadata": dict(row["metadata_json"] or {}),
|
||||
}
|
||||
for row in journey_rows
|
||||
]
|
||||
return {
|
||||
"status": "ok",
|
||||
"data": {
|
||||
"liveStreamPath": "/api/sentinel/ws/perception",
|
||||
"activeSessions": session["active_sessions"] if session else 0,
|
||||
"visitorCount24h": session["visitor_count_24h"] if session else 0,
|
||||
"averageQdScore": session["avg_qd_score"] if session else 0,
|
||||
"sentimentDistribution": sentiment,
|
||||
"journey": journey,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def broadcast_sentinel_event(payload: dict[str, Any]) -> None:
|
||||
await manager.broadcast(payload, "notifications")
|
||||
|
||||
Reference in New Issue
Block a user