feat: Ipad app production readiness, Colony orchestration, Social posting (#44)
All checks were successful
Production Readiness / backend-contracts (push) Successful in 1m47s
Production Readiness / webos-typecheck (push) Successful in 1m50s
Production Readiness / ipad-parse (push) Successful in 1m34s

#38 Ipad app production readiness, Colony orchestration, Social posting

Co-authored-by: Sayan Datta <sayan@Sayans-MacBook-Air.local>
Reviewed-on: #44
This commit was merged in pull request #44.
This commit is contained in:
2026-05-03 18:30:38 +05:30
parent 59d398abc3
commit eeb684b46c
86 changed files with 20349 additions and 1655 deletions

View File

@@ -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")