feat: Oracle CRM Page, Synthetic Client Data and Live Snapshot when hitting emotion hotpoint

This commit is contained in:
Sagnik
2026-04-19 00:43:01 +05:30
parent f616a33ab0
commit 4b21c2cad6
197 changed files with 105054 additions and 89 deletions

2
app/dist/index.html vendored
View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Velocity WebOS</title>
<script type="module" crossorigin src="./assets/index-DbaoiOvw.js"></script>
<script type="module" crossorigin src="./assets/index-CV1YNwsn.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-BDvIhi37.css">
</head>
<body>

View File

@@ -21,6 +21,7 @@ import {
getVelocityToken,
isAdminRole,
normalizeVelocityRole,
type VelocityUserProfile,
} from '@/lib/velocityPlatformClient';
import {
@@ -258,7 +259,7 @@ function App() {
if (cancelled) return;
login({
id: me.user_id,
name: me.user_id,
name: resolveVelocityDisplayName(me),
role: normalizeVelocityRole(me.role),
});
setAuthBootstrapped(true);
@@ -287,7 +288,7 @@ function App() {
if (cancelled) return;
login({
id: me.user_id,
name: me.user_id,
name: resolveVelocityDisplayName(me),
role: normalizeVelocityRole(me.role),
});
})
@@ -362,3 +363,17 @@ function formatRoleLabel(role: string | undefined) {
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(' ');
}
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
const fullName = profile.full_name?.trim();
if (fullName) {
return fullName;
}
const email = profile.email?.trim();
if (email) {
return email;
}
return profile.user_id;
}

View File

@@ -2,7 +2,12 @@ import { useState } from 'react';
import { motion } from 'framer-motion';
import { Scan, Mail, Lock } from 'lucide-react';
import { useStore } from '@/store/useStore';
import { clearVelocityToken, loginVelocity, normalizeVelocityRole } from '@/lib/velocityPlatformClient';
import {
clearVelocityToken,
loginVelocity,
normalizeVelocityRole,
type VelocityUserProfile,
} from '@/lib/velocityPlatformClient';
export function LoginScreen() {
const { login } = useStore();
@@ -19,7 +24,7 @@ export function LoginScreen() {
const me = await loginVelocity(email.trim(), password);
login({
id: me.user_id,
name: me.user_id,
name: resolveVelocityDisplayName(me),
role: normalizeVelocityRole(me.role),
});
} catch (err) {
@@ -167,3 +172,17 @@ export function LoginScreen() {
</div>
);
}
function resolveVelocityDisplayName(profile: VelocityUserProfile) {
const fullName = profile.full_name?.trim();
if (fullName) {
return fullName;
}
const email = profile.email?.trim();
if (email) {
return email;
}
return profile.user_id;
}

View File

@@ -41,15 +41,21 @@ import { useVelocitySocket } from '@/hooks/useVelocitySocket';
import { encodeLandmarkPacket, hasSignificantActivity } from '@/utils/landmarkPacketEncoder';
import { API_URL } from '@/lib/api';
import { cn } from '@/lib/utils';
import { useStore } from '@/store/useStore';
import type { Lead, QDScoreUpdate } from '@/types';
import type { QDScoreUpdate } from '@/types';
// ── Types ─────────────────────────────────────────────────────────────────────
interface PerceptionPlayerProps {
videoUrl: string;
videoAssetId?: string;
personId?: string;
canonicalLeadId?: string;
leadId?: string;
leadName?: string;
leadBudget?: string;
leadInterest?: string;
priorInteractionCount?: number;
initialQdScore?: number;
sessionMode?: 'assigned' | 'auto';
videoTitle?: string;
onSessionComplete?: (finalQdScore: number) => void;
@@ -214,11 +220,17 @@ function buildScoreMap(categories: Array<{ categoryName: string; score: number }
return map;
}
function deriveInitialSessionScore(lead?: Lead | null, priorInteractionCount = 0): number {
const baseScore = lead?.quantumDynamicsScore ?? 50;
function deriveInitialSessionScore(input?: {
qdScore?: number | null;
budget?: string | null;
interest?: string | null;
tags?: string[];
}, priorInteractionCount = 0): number {
const baseScore = input?.qdScore ?? 50;
let modifier = 0;
const budget = (lead?.budget ?? '').toLowerCase();
const tags = (lead?.tags ?? []).map((tag) => tag.toLowerCase());
const budget = (input?.budget ?? '').toLowerCase();
const interest = (input?.interest ?? '').toLowerCase();
const tags = (input?.tags ?? []).map((tag) => tag.toLowerCase());
if (/(10m|15m|20m|crore|million)/.test(budget)) {
modifier += 15;
@@ -228,6 +240,7 @@ function deriveInitialSessionScore(lead?: Lead | null, priorInteractionCount = 0
if (tags.includes('hni')) modifier += 12;
if (tags.includes('nri')) modifier += 5;
if (/(waterfront|bungalow|penthouse|luxury|premium)/.test(interest)) modifier += 4;
if (baseScore > 75) {
modifier += 8;
@@ -259,19 +272,44 @@ function calculateLocalQdScore(previousScore: number, scoreMap: Record<string, n
const eyeBlinkLeft = scoreMap.eyeBlinkLeft ?? 0;
const eyeBlinkRight = scoreMap.eyeBlinkRight ?? 0;
const mouthFrown = Math.max(scoreMap.mouthFrownLeft ?? 0, scoreMap.mouthFrownRight ?? 0);
const mouthPress = Math.max(scoreMap.lipsPressLeft ?? 0, scoreMap.lipsPressRight ?? 0);
const noseSneer = Math.max(scoreMap.noseSneerLeft ?? 0, scoreMap.noseSneerRight ?? 0);
const mouthStretch = Math.max(scoreMap.mouthStretchLeft ?? 0, scoreMap.mouthStretchRight ?? 0);
const jawForward = scoreMap.jawForward ?? 0;
const browOuterUp = Math.max(scoreMap.browOuterUpLeft ?? 0, scoreMap.browOuterUpRight ?? 0);
const eyeSquint = Math.max(scoreMap.eyeSquintLeft ?? 0, scoreMap.eyeSquintRight ?? 0);
let positiveDelta = 0;
if (smileLeft > 0.5 && smileRight > 0.5) positiveDelta += 15;
else if (smileLeft > 0.5 || smileRight > 0.5) positiveDelta += 10;
if (browInnerUp > 0.4) positiveDelta += 8;
if (eyeWideMax > 0.5) positiveDelta += 7;
if (jawOpen > 0.3 && eyeWideMax > 0.5) positiveDelta += 5;
if (cheekPuff > 0.3) positiveDelta += 3;
const positiveValence =
(smileMax * 0.55) +
(browInnerUp * 0.12) +
(browOuterUp * 0.08) +
(eyeWideMax * 0.08) +
(jawOpen * 0.07) +
(cheekPuff * 0.1);
let negativeDelta = 0;
if (browDownLeft > 0.45 && browDownRight > 0.45 && smileMax < 0.2) negativeDelta -= 10;
if (eyeBlinkLeft > 0.7 && eyeBlinkRight > 0.7 && eyeWideMax < 0.2) negativeDelta -= 15;
if (mouthFrown > 0.4) negativeDelta -= 8;
const negativeValence =
(mouthFrown * 0.28) +
(mouthPress * 0.18) +
(mouthStretch * 0.12) +
(noseSneer * 0.14) +
(Math.max(browDownLeft, browDownRight) * 0.16) +
(jawForward * 0.12);
const engagement =
(eyeWideMax * 0.34) +
(browInnerUp * 0.18) +
(jawOpen * 0.16) +
(smileMax * 0.18) +
(eyeSquint * 0.14);
const disengagement =
((eyeBlinkLeft + eyeBlinkRight) / 2 * 0.36) +
(mouthPress * 0.18) +
(smileMax < 0.14 && eyeWideMax < 0.18 ? 0.2 : 0);
const netSignal = (positiveValence - negativeValence) * 22;
const attentionSignal = (engagement - disengagement) * 10;
let boundedDelta = netSignal + attentionSignal;
const weightedShapes = [
smileLeft,
@@ -286,15 +324,18 @@ function calculateLocalQdScore(previousScore: number, scoreMap: Record<string, n
eyeBlinkLeft,
eyeBlinkRight,
mouthFrown,
mouthPress,
noseSneer,
mouthStretch,
jawForward,
browOuterUp,
eyeSquint,
];
if (weightedShapes.every((value) => value < 0.15)) negativeDelta -= 3;
let delta = positiveDelta + negativeDelta;
if (positiveDelta > 0 && negativeDelta < 0) {
delta = Math.abs(positiveDelta) >= Math.abs(negativeDelta) ? positiveDelta : negativeDelta;
if (weightedShapes.every((value) => value < 0.15)) {
boundedDelta -= 4;
}
const boundedDelta = Math.max(-20, Math.min(20, delta));
boundedDelta = Math.max(-18, Math.min(18, boundedDelta));
return clampScore(previousScore + boundedDelta);
}
@@ -343,16 +384,22 @@ function getFaceBounds(
}
export function PerceptionPlayer({
videoUrl, videoAssetId, leadId,
videoUrl, videoAssetId, personId, canonicalLeadId, leadId,
leadName, leadBudget, leadInterest, priorInteractionCount = 0, initialQdScore,
sessionMode = 'assigned', videoTitle, onSessionComplete,
}: PerceptionPlayerProps) {
const reactId = useId().replace(/:/g, '');
const [sessionId] = useState(
() => globalThis.crypto?.randomUUID?.() ?? `00000000-0000-4000-8000-${reactId.slice(-12).padStart(12, '0')}`,
);
const lead = useStore((state) => state.leads.find((candidate) => candidate.id === leadId) ?? null);
const priorInteractionCount = useStore((state) => (leadId ? state.messages[leadId]?.length ?? 0 : 0));
const initialSessionScore = deriveInitialSessionScore(lead, priorInteractionCount);
const initialSessionScore = deriveInitialSessionScore(
{
qdScore: initialQdScore,
budget: leadBudget,
interest: leadInterest,
},
priorInteractionCount,
);
// Refs
const videoRef = useRef<HTMLVideoElement>(null);
@@ -398,19 +445,22 @@ export function PerceptionPlayer({
onMessage: useCallback((msg: { type: string; data?: Record<string, unknown> }) => {
if (msg.type !== 'QD_UPDATED') return;
const d = msg.data as Partial<QDScoreUpdate>;
const match = (leadId && d.lead_id === leadId) || d.session_id === sessionId;
const match =
(personId && d.person_id === personId) ||
(leadId && d.lead_id === leadId) ||
d.session_id === sessionId;
if (match && typeof d.qd_score === 'number') {
lastBackendQdTsRef.current = Date.now();
qdScoreRef.current = d.qd_score;
setQdScore(d.qd_score);
}
}, [leadId, sessionId]),
}, [leadId, personId, sessionId]),
});
useEffect(() => {
qdScoreRef.current = initialSessionScore;
setQdScore(initialSessionScore);
}, [initialSessionScore, leadId, sessionId]);
}, [initialSessionScore, personId, leadId, sessionId]);
useEffect(() => {
if (!cameraActive || !streamRef.current || !webcamRef.current) return;
@@ -511,7 +561,10 @@ export function PerceptionPlayer({
if (significantActivity) {
sendPacket({
event: 'BIOMETRIC_PACKET',
lead_id: leadId, session_id: sessionId,
person_id: personId,
canonical_lead_id: canonicalLeadId,
lead_id: leadId,
session_id: sessionId,
session_mode: sessionMode, video_asset_id: videoAssetId,
video_ts_ms: packet.video_ts_ms, blend_shapes: packet.blend_shapes,
});
@@ -541,7 +594,7 @@ export function PerceptionPlayer({
});
}
}
}, [detectFrame, isReady, leadId, sendPacket, sessionId, sessionMode, videoAssetId]);
}, [canonicalLeadId, detectFrame, isReady, leadId, personId, sendPacket, sessionId, sessionMode, videoAssetId]);
useEffect(() => {
if (sessionActive && cameraActive && isReady) {
@@ -585,7 +638,15 @@ export function PerceptionPlayer({
void fetch(`${API_URL}/api/sentinel/session/complete`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ session_id: sessionId, session_mode: sessionMode, lead_id: leadId, final_qd_score: qdScore }),
body: JSON.stringify({
session_id: sessionId,
session_mode: sessionMode,
person_id: personId,
canonical_lead_id: canonicalLeadId,
lead_id: leadId,
lead_name: leadName,
final_qd_score: qdScore,
}),
}).catch(() => null);
onSessionComplete?.(qdScore);
};

View File

@@ -17,11 +17,12 @@ import {
Search,
Film,
} from 'lucide-react';
import { useStore } from '@/store/useStore';
import { PerceptionPlayer } from '@/components/modules/sentinel/PerceptionPlayer';
import { API_URL } from '@/lib/api';
import { fetchContacts } from '@/lib/crmApi';
import { cn } from '@/lib/utils';
import type { Lead, MarketingVideo } from '@/types';
import type { MarketingVideo } from '@/types';
import type { CrmContactListItem } from '@/types/crmTypes';
// ── Local static video catalog (served from public/videos/) ──────────────────
// Used as primary source when the remote backend is unreachable.
@@ -86,6 +87,21 @@ function resolveVideoUrl(video_url: string): string {
type SessionMode = 'assigned' | 'auto';
type Step = 'select-video' | 'select-mode' | 'select-lead' | 'session' | 'summary';
interface SentinelClient {
personId: string;
leadId: string | null;
legacyLiId: string | null;
name: string;
phone: string | null;
buyerType: string | null;
leadStatus: string | null;
budget: string | null;
interest: string | null;
tags: string[];
currentQdScore: number;
priorInteractionCount: number;
}
function apiAuthHeaders() {
const token = localStorage.getItem('velocity-api-token');
const headers: Record<string, string> = {};
@@ -101,6 +117,29 @@ function formatDuration(totalSeconds: number) {
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
function mapContactToSentinelClient(contact: CrmContactListItem): SentinelClient {
const inferredTags = [
contact.buyer_type,
contact.lead_status,
contact.urgency,
].filter(Boolean) as string[];
return {
personId: contact.person_id,
leadId: contact.lead_id,
legacyLiId: contact.legacy_li_id,
name: contact.full_name,
phone: contact.primary_phone,
buyerType: contact.buyer_type,
leadStatus: contact.lead_status,
budget: contact.budget_band,
interest: contact.primary_interest,
tags: inferredTags,
currentQdScore: Math.round(Math.max(contact.engagement_score, contact.intent_score, 0.5) * 100),
priorInteractionCount: contact.interaction_count,
};
}
function VideoCard({
video,
isSelected,
@@ -259,12 +298,12 @@ function LeadRow({
isSelected,
onClick,
}: {
lead: Lead;
lead: SentinelClient;
isSelected: boolean;
onClick: () => void;
}) {
const initials = lead.name.split(' ').map((n) => n[0]).join('').slice(0, 2);
const qd = lead.quantumDynamicsScore;
const qd = lead.currentQdScore;
return (
<motion.button
@@ -323,7 +362,7 @@ function SessionSummary({
sessionMode,
onNewSession,
}: {
lead?: Lead;
lead?: SentinelClient;
finalQdScore: number;
sessionMode: SessionMode;
onNewSession: () => void;
@@ -394,15 +433,16 @@ function SessionSummary({
}
export function SentinelLiveSession() {
const { leads } = useStore();
const [step, setStep] = useState<Step>('select-video');
const [videos, setVideos] = useState<MarketingVideo[]>([]);
const [videosLoading, setVideosLoading] = useState(true);
const [videosError, setVideosError] = useState<string>('');
const [selectedVideo, setSelectedVideo] = useState<MarketingVideo | null>(null);
const [sessionMode, setSessionMode] = useState<SessionMode>('assigned');
const [selectedLead, setSelectedLead] = useState<Lead | null>(null);
const [clients, setClients] = useState<SentinelClient[]>([]);
const [clientsLoading, setClientsLoading] = useState(true);
const [clientsError, setClientsError] = useState('');
const [selectedLead, setSelectedLead] = useState<SentinelClient | null>(null);
const [leadSearch, setLeadSearch] = useState('');
const [finalQdScore, setFinalQdScore] = useState(50);
const [sceneFile, setSceneFile] = useState<File | null>(null);
@@ -451,6 +491,33 @@ export function SentinelLiveSession() {
};
}, []);
useEffect(() => {
let cancelled = false;
const loadClients = async () => {
setClientsLoading(true);
setClientsError('');
try {
const data = await fetchContacts({ limit: 200, offset: 0 });
if (!cancelled) {
setClients(data.contacts.map(mapContactToSentinelClient));
}
} catch (error) {
if (!cancelled) {
setClients([]);
setClientsError(error instanceof Error ? error.message : 'Failed to load CRM contacts.');
}
} finally {
if (!cancelled) {
setClientsLoading(false);
}
}
};
void loadClients();
return () => {
cancelled = true;
};
}, []);
const selectedVideoSceneStatus = useMemo(() => {
if (!selectedVideo) {
return 'No video selected';
@@ -467,9 +534,9 @@ export function SentinelLiveSession() {
const filteredLeads = useMemo(() => {
const query = leadSearch.trim().toLowerCase();
if (!query) {
return leads;
return clients;
}
return leads.filter((lead) => {
return clients.filter((lead) => {
const haystack = [
lead.name,
lead.phone,
@@ -482,7 +549,7 @@ export function SentinelLiveSession() {
.toLowerCase();
return haystack.includes(query);
});
}, [leadSearch, leads]);
}, [clients, leadSearch]);
const handleVideoSelect = (video: MarketingVideo) => setSelectedVideo(video);
const handleVideoConfirm = () => {
@@ -782,15 +849,24 @@ export function SentinelLiveSession() {
/>
</div>
<div className="flex flex-col gap-2 max-h-72 overflow-y-auto custom-scrollbar mb-4">
{filteredLeads.map((lead) => (
{clientsLoading ? (
<div className="rounded-xl border border-white/8 bg-white/[0.02] px-4 py-8 text-center text-sm text-zinc-500 flex items-center justify-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" />
Loading CRM contacts
</div>
) : clientsError ? (
<div className="rounded-xl border border-red-500/20 bg-red-500/5 px-4 py-6 text-center text-sm text-red-300">
{clientsError}
</div>
) : filteredLeads.map((lead) => (
<LeadRow
key={lead.id}
key={lead.personId}
lead={lead}
isSelected={selectedLead?.id === lead.id}
isSelected={selectedLead?.personId === lead.personId}
onClick={() => setSelectedLead(lead)}
/>
))}
{filteredLeads.length === 0 && (
{!clientsLoading && !clientsError && filteredLeads.length === 0 && (
<div className="rounded-xl border border-white/8 bg-white/[0.02] px-4 py-8 text-center text-sm text-zinc-500">
No client matches that search.
</div>
@@ -837,7 +913,14 @@ export function SentinelLiveSession() {
<PerceptionPlayer
videoUrl={resolveVideoUrl(selectedVideo.video_url)}
videoAssetId={selectedVideo.id}
leadId={sessionMode === 'assigned' ? selectedLead?.id : undefined}
personId={sessionMode === 'assigned' ? selectedLead?.personId : undefined}
canonicalLeadId={sessionMode === 'assigned' ? selectedLead?.leadId ?? undefined : undefined}
leadId={sessionMode === 'assigned' ? selectedLead?.legacyLiId ?? undefined : undefined}
leadName={sessionMode === 'assigned' ? selectedLead?.name : undefined}
leadBudget={sessionMode === 'assigned' ? selectedLead?.budget ?? undefined : undefined}
leadInterest={sessionMode === 'assigned' ? selectedLead?.interest ?? undefined : undefined}
priorInteractionCount={sessionMode === 'assigned' ? selectedLead?.priorInteractionCount : undefined}
initialQdScore={sessionMode === 'assigned' ? selectedLead?.currentQdScore : undefined}
sessionMode={sessionMode}
videoTitle={selectedVideo.title}
onSessionComplete={handleSessionComplete}

View File

@@ -14,11 +14,12 @@ import type {
ImportReviewDecision,
QdScoreEntry,
} from '@/types/crmTypes';
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? '';
function getAuthHeaders(): Record<string, string> {
const token = localStorage.getItem('velocity_token');
const token = localStorage.getItem(VELOCITY_TOKEN_KEY);
return token ? { Authorization: `Bearer ${token}` } : {};
}

View File

@@ -5,6 +5,8 @@ export const VELOCITY_TOKEN_KEY = 'velocity-api-token';
export interface VelocityUserProfile {
user_id: string;
role: string;
full_name?: string | null;
email?: string | null;
}
export interface VelocityLoginResponse {

View File

@@ -19,8 +19,29 @@ import type {
} from '../types/canvas';
import { VELOCITY_TOKEN_KEY } from '@/lib/velocityPlatformClient';
const BASE_URL = (import.meta.env.VITE_ORACLE_API_URL as string | undefined) ?? '';
const WS_URL = (import.meta.env.VITE_ORACLE_WS_URL as string | undefined) ?? '';
function getBrowserOrigin(): string {
return typeof window !== 'undefined' ? window.location.origin : '';
}
function resolveBaseUrl(): string {
const configured = (import.meta.env.VITE_ORACLE_API_URL as string | undefined)?.trim();
if (configured) {
return configured.replace(/\/$/, '');
}
return getBrowserOrigin();
}
function resolveWsUrl(): string {
const configured = (import.meta.env.VITE_ORACLE_WS_URL as string | undefined)?.trim();
if (configured) {
return configured.replace(/\/$/, '');
}
const origin = getBrowserOrigin();
return origin ? origin.replace(/^http/, 'ws') : '';
}
const BASE_URL = resolveBaseUrl();
const WS_URL = resolveWsUrl();
function apiUrl(path: string): string {
return `${BASE_URL}/api/oracle/v1${path}`;
@@ -30,10 +51,6 @@ async function apiFetch<T>(
path: string,
options?: RequestInit & { idempotencyKey?: string },
): Promise<T> {
if (!BASE_URL) {
throw new Error('Oracle API is not configured. Set VITE_ORACLE_API_URL to a live backend.');
}
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'X-Oracle-Contract-Version': 'v1',

View File

@@ -11,10 +11,13 @@ export interface CrmContactListItem {
primary_phone: string | null;
buyer_type: string | null;
lead_id: string | null;
legacy_li_id: string | null;
lead_status: string | null;
budget_band: string | null;
urgency: string | null;
primary_interest: string | null;
intent_score: number;
engagement_score: number;
urgency_score: number;
interaction_count: number;
last_interaction_at: string | null;

View File

@@ -213,7 +213,9 @@ export interface CatalystSettings {
* plus the exact video playback timestamp for stimulus correlation.
*/
export interface BiometricPacket {
person_id?: string;
lead_id?: string;
canonical_lead_id?: string;
session_id: string;
session_mode?: 'assigned' | 'auto';
video_asset_id?: string;
@@ -225,6 +227,7 @@ export interface BiometricPacket {
/** Emitted by FastAPI after NemoClaw processes a biometric packet batch */
export interface QDScoreUpdate {
person_id?: string;
lead_id?: string;
session_id?: string;
qd_score: number;