diff --git a/app/src/app/oracle/page.tsx b/app/src/app/oracle/page.tsx index f9230b3c..3dfa96ac 100644 --- a/app/src/app/oracle/page.tsx +++ b/app/src/app/oracle/page.tsx @@ -1,23 +1,32 @@ 'use client'; -/** - * Oracle Page — Orchestration Host (v2 Refactor) - * Implements the vertical JSON canvas architecture from the Oracle spec §10–§13. - * - * Architecture: - * BranchBar — page identity, branch, revision, quick actions (top) - * CanvasViewport — virtualized component canvas (center, flex-1) - * PromptRail — durable execution history (right sidebar, collapsible) - * PromptBar — floating bottom prompt input (preserved premium glass treatment) - * ShareModal — fork-based sharing (overlay) - * RollbackConfirmModal — revision history + rollback (overlay) - * MergeReviewDrawer — diff/conflict reviewer (right drawer overlay) - * - */ -import { useState, useCallback, useRef, useEffect } from 'react'; + +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { Send, Mic, Kanban, Users, Phone, MapPinned, CalendarClock, ChevronDown, History, BarChart2, Database } from 'lucide-react'; +import { + Send, + Mic, + Kanban, + Users, + Phone, + MapPinned, + CalendarClock, + ChevronDown, + History, + BarChart2, + Database, + Plus, + Search, + MoreHorizontal, + Pencil, + Trash2, + X, + MessageSquarePlus, + Sparkles, + PanelLeft, + type LucideIcon, +} from 'lucide-react'; import { Input } from '@/components/ui/input'; -import type { CanvasPageRevision, MergeRequest, UserProfile } from '@/oracle/types/canvas'; +import type { CanvasPage, CanvasPageRevision, MergeRequest, UserProfile } from '@/oracle/types/canvas'; import type { ComponentRenderContext } from '@/oracle/components/ComponentRegistry'; import { useOraclePage } from '@/oracle/hooks/useOraclePage'; import { useOracleExecution } from '@/oracle/hooks/useOracleExecution'; @@ -28,19 +37,29 @@ import { PromptRail } from '@/oracle/components/PromptRail'; import { ShareModal } from '@/oracle/components/ShareModal'; import { RollbackConfirmModal } from '@/oracle/components/RollbackConfirmModal'; import { MergeReviewDrawer } from '@/oracle/components/review/MergeReviewDrawer'; -import { createFork, fetchMe, listRevisions, reviewMergeRequest, rollbackPage } from '@/oracle/lib/oracleApiClient'; +import { + createCanvasPage, + createFork, + deleteCanvasPage, + fetchMe, + listCanvasPages, + listRevisions, + renameCanvasPage, + reviewMergeRequest, + rollbackPage, +} from '@/oracle/lib/oracleApiClient'; -const PROMPT_MODES: Array<{ view: string; label: string; samplePrompt: string; icon: React.ComponentType<{ className?: string }> }> = [ +type OracleSubtab = 'canvas' | 'client-data'; + +const PROMPT_MODES: Array<{ view: string; label: string; samplePrompt: string; icon: LucideIcon }> = [ { view: 'pipeline', label: 'Pipeline', samplePrompt: 'Show me a pipeline view by stage for Q2 2026.', icon: Kanban }, { view: 'team_performance', label: 'Team Performance', samplePrompt: 'What is the performance of the sales team this month?', icon: Users }, - { view: 'account_timeline', label: 'Account Timeline', samplePrompt: "Find all activities for Apex Innovations this quarter.", icon: Phone }, + { view: 'account_timeline', label: 'Account Timeline', samplePrompt: 'Find all activities for Apex Innovations this quarter.', icon: Phone }, { view: 'lead_map', label: 'Geographic Map', samplePrompt: 'Show me a map of all whale leads in Dubai Marina.', icon: MapPinned }, { view: 'calendar_tasks', label: 'Calendar & Tasks', samplePrompt: 'Schedule follow-ups with the top 3 leads with no contact in 72h.', icon: CalendarClock }, { view: 'kpi', label: 'KPI Summary', samplePrompt: 'Give me a KPI summary of total pipeline value today.', icon: BarChart2 }, ]; -// ── Render context ──────────────────────────────────────────────────────────── - const BASE_CTX: ComponentRenderContext = { tenantId: '', actorRole: 'sales_director', @@ -48,7 +67,33 @@ const BASE_CTX: ComponentRenderContext = { density: 'comfortable', }; -// ── Oracle Page ─────────────────────────────────────────────────────────────── +function formatRelativeTime(value: string): string { + const timestamp = Date.parse(value); + if (Number.isNaN(timestamp)) return 'just now'; + const deltaMs = Date.now() - timestamp; + const minutes = Math.floor(deltaMs / 60000); + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + const weeks = Math.floor(days / 7); + if (weeks < 4) return `${weeks}w ago`; + const months = Math.floor(days / 30); + return `${months}mo ago`; +} + +function deriveChatTitle(prompt: string): string { + const compact = prompt.replace(/\s+/g, ' ').trim(); + if (!compact) return 'Untitled Canvas'; + return compact.length > 64 ? `${compact.slice(0, 61)}...` : compact; +} + +function isUntitledPage(title?: string | null): boolean { + const normalized = (title ?? '').trim().toLowerCase(); + return !normalized || normalized === 'untitled canvas' || normalized === 'oracle canvas'; +} export default function OraclePage() { const [me, setMe] = useState(null); @@ -56,28 +101,49 @@ export default function OraclePage() { const [revisions, setRevisions] = useState([]); const [revisionsLoading, setRevisionsLoading] = useState(false); - // Page state & WebSocket - const { page, isLoading, error: pageError, isConnected, applyRevision, refresh } = useOraclePage(me?.defaultPageId ?? null); + const [canvasPages, setCanvasPages] = useState([]); + const [pagesLoading, setPagesLoading] = useState(false); + const [pagesError, setPagesError] = useState(null); + const [selectedPageId, setSelectedPageId] = useState(null); + const [searchOpen, setSearchOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [pageMenuOpen, setPageMenuOpen] = useState(null); + const [renamePageId, setRenamePageId] = useState(null); + const [renameValue, setRenameValue] = useState(''); - // Prompt execution - const { history, inFlight, lastError, submit } = useOracleExecution(); + const { page, isLoading, error: pageError, isConnected, applyRevision, refresh } = useOraclePage(selectedPageId); + const { history, inFlight, lastError, submit, resetHistory } = useOracleExecution(); - // UI state const [prompt, setPrompt] = useState(''); const [selectedMode, setSelectedMode] = useState(PROMPT_MODES[0]); const [viewDropOpen, setViewDropOpen] = useState(false); const [listening, setListening] = useState(false); const [railOpen, setRailOpen] = useState(false); const [selectedComponentId, setSelectedComponentId] = useState(null); - const [activeSubtab, setActiveSubtab] = useState<'canvas' | 'client-data'>('canvas'); + const [activeSubtab, setActiveSubtab] = useState('canvas'); + const [sidebarCollapsed, setSidebarCollapsed] = useState(false); - // Overlay state const [shareOpen, setShareOpen] = useState(false); const [rollbackOpen, setRollbackOpen] = useState(false); const [mergeReviewOpen, setMergeReviewOpen] = useState(false); const [activeMergeRequest, setActiveMergeRequest] = useState(null); const promptRef = useRef(null); + const searchRef = useRef(null); + + const loadCanvasSessions = useCallback(async () => { + if (!me) return; + setPagesLoading(true); + setPagesError(null); + try { + const pages = await listCanvasPages(); + setCanvasPages(pages); + } catch (err) { + setPagesError(err instanceof Error ? err.message : 'Failed to load Oracle chats'); + } finally { + setPagesLoading(false); + } + }, [me]); useEffect(() => { void fetchMe() @@ -90,12 +156,51 @@ export default function OraclePage() { }); }, []); - // ── Prompt submission ─────────────────────────────────────────────────────── + useEffect(() => { + if (!me) return; + if (!selectedPageId && me.defaultPageId) setSelectedPageId(me.defaultPageId); + void loadCanvasSessions(); + }, [me, selectedPageId, loadCanvasSessions]); + + useEffect(() => { + if (!selectedPageId && canvasPages.length > 0) { + setSelectedPageId(canvasPages[0].pageId); + return; + } + if (selectedPageId && canvasPages.length > 0 && !canvasPages.some((item) => item.pageId === selectedPageId)) { + setSelectedPageId(canvasPages[0].pageId); + } + }, [canvasPages, selectedPageId]); + + useEffect(() => { + resetHistory(); + setSelectedComponentId(null); + }, [selectedPageId, resetHistory]); + + useEffect(() => { + if (!searchOpen) return; + const timeout = setTimeout(() => searchRef.current?.focus(), 40); + return () => clearTimeout(timeout); + }, [searchOpen]); + + const filteredPages = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + const pages = [...canvasPages].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt)); + if (!query) return pages; + return pages.filter((item) => { + const haystack = [item.title, item.branchName, item.pageType].join(' ').toLowerCase(); + return haystack.includes(query); + }); + }, [canvasPages, searchQuery]); + + const recentPages = useMemo(() => filteredPages.slice(0, 4), [filteredPages]); const submitPrompt = useCallback(async () => { const clean = prompt.trim(); if (!clean || inFlight || !page || !me) return; + setPrompt(''); + setActiveSubtab('canvas'); await submit({ pageId: page.pageId, @@ -104,39 +209,63 @@ export default function OraclePage() { tenantId: me.tenantId, actorId: me.userId, placementMode: me.canvasPreferences.defaultPlacementMode, - conversationContext: history.map((e) => [ - { role: 'user' as const, content: e.execution.prompt }, - { role: 'assistant' as const, content: e.execution.summary ?? '' }, - ]).flat(), + conversationContext: history.flatMap((entry) => [ + { role: 'user' as const, content: entry.execution.prompt }, + { role: 'assistant' as const, content: entry.execution.summary ?? '' }, + ]), onExecutionCommitted: ({ headRevision, components }) => { applyRevision(headRevision, components); }, }); - }, [prompt, inFlight, submit, page, history, me, applyRevision]); - // ── Mic handler ───────────────────────────────────────────────────────────── + if (page && isUntitledPage(page.title)) { + try { + await renameCanvasPage(page.pageId, deriveChatTitle(clean)); + } catch { + // Keep the page usable even if title sync fails. + } + } + + await Promise.all([refresh(), loadCanvasSessions()]); + }, [prompt, inFlight, page, me, submit, history, applyRevision, refresh, loadCanvasSessions]); const handleMic = useCallback(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const w = window as any; - const SR = w.SpeechRecognition ?? w.webkitSpeechRecognition; + const browserWindow = window as Window & { + SpeechRecognition?: new () => { + lang: string; + interimResults: boolean; + onstart: (() => void) | null; + onend: (() => void) | null; + onerror: (() => void) | null; + onresult: ((event: { results?: ArrayLike> }) => void) | null; + start: () => void; + }; + webkitSpeechRecognition?: new () => { + lang: string; + interimResults: boolean; + onstart: (() => void) | null; + onend: (() => void) | null; + onerror: (() => void) | null; + onresult: ((event: { results?: ArrayLike> }) => void) | null; + start: () => void; + }; + }; + const SR = browserWindow.SpeechRecognition ?? browserWindow.webkitSpeechRecognition; if (!SR) return; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const recognition = new SR() as any; + + const recognition = new SR(); recognition.lang = 'en-US'; recognition.interimResults = false; recognition.onstart = () => setListening(true); recognition.onend = () => setListening(false); recognition.onerror = () => setListening(false); - recognition.onresult = (event: { results: ArrayLike> }) => { - const result = event.results[0][0]; - if (result) setPrompt(result.transcript); + recognition.onresult = (event: { results?: ArrayLike> }) => { + const transcript = event.results?.[0]?.[0]?.transcript; + if (transcript) setPrompt(transcript); }; recognition.start(); }, []); - // ── Share handler ─────────────────────────────────────────────────────────── - const handleShare = useCallback(async (params: { recipientUserId: string; visibility: 'private' | 'team'; message: string; sourceRevision: number }) => { if (!page) return; await createFork(page.pageId, { @@ -147,14 +276,12 @@ export default function OraclePage() { }); }, [page]); - // ── Rollback handler ──────────────────────────────────────────────────────── - const handleRollback = useCallback(async (targetRevision: number) => { if (!page) return; const result = await rollbackPage(page.pageId, targetRevision, `cli_rollback_${Date.now()}`); applyRevision(result.headRevision, result.components); - await refresh(); - }, [page, applyRevision, refresh]); + await Promise.all([refresh(), loadCanvasSessions()]); + }, [page, applyRevision, refresh, loadCanvasSessions]); const handleOpenRollback = useCallback(() => { if (!page) return; @@ -178,10 +305,49 @@ export default function OraclePage() { setMergeReviewOpen(false); }, [activeMergeRequest]); - // ── Components to render ──────────────────────────────────────────────────── + const handleCreateChat = useCallback(async () => { + setPageMenuOpen(null); + setRenamePageId(null); + setRenameValue(''); + setActiveSubtab('canvas'); + const created = await createCanvasPage('Untitled Canvas'); + setSelectedPageId(created.pageId); + setSearchOpen(false); + await loadCanvasSessions(); + }, [loadCanvasSessions]); + + const handleRenameStart = useCallback((canvasPage: CanvasPage) => { + setPageMenuOpen(null); + setRenamePageId(canvasPage.pageId); + setRenameValue(canvasPage.title); + }, []); + + const handleRenameCommit = useCallback(async () => { + if (!renamePageId) return; + const title = renameValue.trim() || 'Untitled Canvas'; + await renameCanvasPage(renamePageId, title); + setRenamePageId(null); + setRenameValue(''); + await Promise.all([refresh(), loadCanvasSessions()]); + }, [renamePageId, renameValue, refresh, loadCanvasSessions]); + + const handleDeletePage = useCallback(async (pageId: string) => { + setPageMenuOpen(null); + const remaining = canvasPages.filter((item) => item.pageId !== pageId); + await deleteCanvasPage(pageId); + + if (remaining.length === 0) { + const created = await createCanvasPage('Untitled Canvas'); + setSelectedPageId(created.pageId); + } else if (selectedPageId === pageId) { + setSelectedPageId(remaining[0].pageId); + } + + await loadCanvasSessions(); + }, [canvasPages, selectedPageId, loadCanvasSessions]); const components = page?.components ?? []; - const combinedError = profileError ?? pageError ?? null; + const combinedError = profileError ?? pageError ?? pagesError ?? null; const renderCtx: ComponentRenderContext = { ...BASE_CTX, tenantId: me?.tenantId ?? '', @@ -190,17 +356,13 @@ export default function OraclePage() { density: me?.canvasPreferences.defaultDensity ?? 'comfortable', }; - // ── Render ────────────────────────────────────────────────────────────────── - return (
- {/* Ambient background glow */}
- {/* Loading veil */} {isLoading && (
-
- +
+
-

Loading Oracle canvas…

+

Loading Oracle canvas...

)} - {/* ── BranchBar ──────────────────────────────────────────────────────── */}
- {/* ── AI Insight strip ───────────────────────────────────────────────── */} {activeSubtab === 'canvas' && page && history.length > 0 && history[history.length - 1].execution.summary && ( -
+
-
-

Oracle

-

+

+

Oracle

+

{history[history.length - 1].execution.summary}

@@ -276,211 +436,438 @@ export default function OraclePage() {
)} - {/* ── Main content area: canvas + rail ───────────────────────────────── */} -
+
{activeSubtab === 'client-data' ? ( ) : ( <> - {/* Canvas viewport */} - + + +
+ + + setRailOpen((prev) => !prev)} + /> +
)}
- {/* ── Floating Prompt Bar ─────────────────────────────────────────────── */} - {activeSubtab === 'canvas' &&
-
- {/* Blue glow */} -
+ {activeSubtab === 'canvas' && ( +
+
+
- {/* Container */} -
- {/* Error banner */} - - {(combinedError || lastError) && ( - -
-

{combinedError ?? lastError}

-
-
- )} -
- - {/* Input */} -
- setPrompt(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') { e.preventDefault(); void submitPrompt(); } - }} - placeholder="Ask Oracle anything — build your canvas with a prompt…" - className="border-0 bg-transparent text-[15px] text-zinc-100 placeholder:text-zinc-600 focus-visible:ring-0 px-0 h-auto py-0" - /> -
- - {/* Toolbar */} -
- {/* Left: view dropdown + rail toggle */} -
- {/* View mode dropdown */} -
- +
+

{combinedError ?? lastError}

+
+ + )} + - - {viewDropOpen && ( - - {PROMPT_MODES.map((mode) => { - const isActive = mode.view === selectedMode.view; - return ( - - ); - })} - - )} - - - {viewDropOpen && ( -
setViewDropOpen(false)} /> - )} -
- - {/* Rail toggle */} - +
+ setPrompt(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + event.preventDefault(); + void submitPrompt(); + } + }} + placeholder="Ask Oracle anything — build your canvas with a prompt..." + className="h-auto border-0 bg-transparent px-0 py-0 text-[15px] text-zinc-100 placeholder:text-zinc-600 focus-visible:ring-0" + />
- {/* Right: mic + send */} -
- - - {listening ? 'Listening…' : 'Voice'} - +
+
+
+ - void submitPrompt()} - disabled={!!inFlight || !prompt.trim()} - className="h-8 w-8 rounded-full flex items-center justify-center flex-shrink-0 disabled:opacity-40" - style={{ background: 'hsl(217 91% 60%)', boxShadow: '0 0 18px hsl(217 91% 60% / 0.5)' }} - whileHover={{ scale: 1.08 }} - whileTap={{ scale: 0.91 }} - > - - + + {viewDropOpen && ( + + {PROMPT_MODES.map((mode) => { + const isActive = mode.view === selectedMode.view; + return ( + + ); + })} + + )} + + + {viewDropOpen &&
setViewDropOpen(false)} />} +
+ + +
+ +
+ + + {listening ? 'Listening...' : 'Voice'} + + + void submitPrompt()} + disabled={!!inFlight || !prompt.trim() || !page} + className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full disabled:opacity-40" + style={{ background: 'hsl(217 91% 60%)', boxShadow: '0 0 18px hsl(217 91% 60% / 0.5)' }} + whileHover={{ scale: 1.08 }} + whileTap={{ scale: 0.91 }} + > + + +
-
} + )} + + + {searchOpen && ( + <> + setSearchOpen(false)} + /> +
+ +
+ + setSearchQuery(event.target.value)} + placeholder="Search chats" + className="flex-1 bg-transparent text-base text-zinc-100 outline-none placeholder:text-zinc-600" + /> + +
+ +
+
+

Recent

+ +
+ +
+ {(recentPages.length > 0 ? recentPages : filteredPages.slice(0, 4)).map((canvasPage) => ( + + ))} + + {filteredPages.length === 0 && ( +
+ No matching chats. +
+ )} +
+
+
+
+ + )} +
- {/* ── Overlays ────────────────────────────────────────────────────────── */} void; }) => Promise; clearError: () => void; + resetHistory: () => void; } export function useOracleExecution(): OracleExecutionState { @@ -126,5 +127,12 @@ export function useOracleExecution(): OracleExecutionState { [], ); - return { history, inFlight, lastError, submit, clearError: () => setLastError(null) }; + return { + history, + inFlight, + lastError, + submit, + clearError: () => setLastError(null), + resetHistory: () => setHistory([]), + }; } diff --git a/app/src/oracle/lib/oracleApiClient.ts b/app/src/oracle/lib/oracleApiClient.ts index 58386210..c2390476 100644 --- a/app/src/oracle/lib/oracleApiClient.ts +++ b/app/src/oracle/lib/oracleApiClient.ts @@ -81,6 +81,32 @@ export async function fetchCanvasPage(pageId: string): Promise { return apiFetch(`/canvas-pages/${pageId}`); } +export async function listCanvasPages(search?: string): Promise { + const qs = new URLSearchParams(); + if (search?.trim()) qs.set('search', search.trim()); + return apiFetch(`/canvas-pages${qs.toString() ? `?${qs.toString()}` : ''}`); +} + +export async function createCanvasPage(title = 'Untitled Canvas'): Promise { + return apiFetch('/canvas-pages', { + method: 'POST', + body: JSON.stringify({ title }), + }); +} + +export async function renameCanvasPage(pageId: string, title: string): Promise { + return apiFetch(`/canvas-pages/${pageId}`, { + method: 'PATCH', + body: JSON.stringify({ title }), + }); +} + +export async function deleteCanvasPage(pageId: string): Promise<{ pageId: string; deleted: boolean }> { + return apiFetch<{ pageId: string; deleted: boolean }>(`/canvas-pages/${pageId}`, { + method: 'DELETE', + }); +} + export async function submitPrompt( pageId: string, payload: PromptSubmitRequest, diff --git a/backend/oracle/canvas_service.py b/backend/oracle/canvas_service.py index a037d1a6..92bbc449 100644 --- a/backend/oracle/canvas_service.py +++ b/backend/oracle/canvas_service.py @@ -86,6 +86,8 @@ def _json_array(value: Any) -> list[Any]: def _json_safe(value: Any) -> Any: if isinstance(value, datetime): return value.isoformat() + if isinstance(value, uuid.UUID): + return str(value) if isinstance(value, dict): return {str(key): _json_safe(val) for key, val in value.items()} if isinstance(value, list): @@ -173,6 +175,54 @@ def _deserialize_page_row(row: Any, components: list[dict[str, Any]]) -> dict[st class CanvasService: + async def list_pages( + self, + *, + tenant_id: str, + owner_id: str, + search: str | None = None, + limit: int = 50, + ) -> list[dict[str, Any]]: + _ensure_ready() + safe_limit = max(1, min(limit, 100)) + search_term = (search or "").strip().lower() + if _is_demo(): + candidates = [ + page + for page in _DEMO_PAGES.values() + if page["tenantId"] == tenant_id and page["ownerId"] == owner_id + ] + if search_term: + candidates = [page for page in candidates if search_term in page.get("title", "").lower()] + candidates.sort(key=lambda page: page.get("updatedAt", ""), reverse=True) + return [{**page, "components": deepcopy(_DEMO_COMPONENTS.get(page["pageId"], []))} for page in candidates[:safe_limit]] + + assert asyncpg is not None + conn = await asyncpg.connect(_DB_URL) + try: + rows = await conn.fetch( + """ + SELECT * + FROM oracle_canvas_pages + WHERE tenant_id = $1 + AND owner_id = $2 + AND ($3 = '' OR lower(title) LIKE '%' || $3 || '%') + ORDER BY updated_at DESC, created_at DESC + LIMIT $4 + """, + tenant_id, + owner_id, + search_term, + safe_limit, + ) + pages: list[dict[str, Any]] = [] + for row in rows: + components = await self._pg_fetch_components(conn, _stringify(row["page_id"]), tenant_id) + pages.append(_deserialize_page_row(row, components)) + return pages + finally: + await conn.close() + async def create_page( self, *, @@ -310,6 +360,80 @@ class CanvasService: finally: await conn.close() + async def update_page_title( + self, + *, + page_id: str, + tenant_id: str, + owner_id: str, + title: str, + ) -> dict[str, Any]: + _ensure_ready() + clean_title = (title or "").strip() or "Untitled Canvas" + if _is_demo(): + page = _DEMO_PAGES.get(page_id) + if not page or page["tenantId"] != tenant_id or page["ownerId"] != owner_id: + raise ValueError(f"Page {page_id} not found for tenant {tenant_id}") + page["title"] = clean_title + page["updatedAt"] = _now() + return {**page, "components": deepcopy(_DEMO_COMPONENTS.get(page_id, []))} + + assert asyncpg is not None + conn = await asyncpg.connect(_DB_URL) + try: + row = await conn.fetchrow( + """ + UPDATE oracle_canvas_pages + SET title = $4, updated_at = NOW() + WHERE page_id = $1::uuid AND tenant_id = $2 AND owner_id = $3 + RETURNING * + """, + page_id, + tenant_id, + owner_id, + clean_title, + ) + if not row: + raise ValueError(f"Page {page_id} not found for tenant {tenant_id}") + components = await self._pg_fetch_components(conn, page_id, tenant_id) + return _deserialize_page_row(row, components) + finally: + await conn.close() + + async def delete_page( + self, + *, + page_id: str, + tenant_id: str, + owner_id: str, + ) -> None: + _ensure_ready() + if _is_demo(): + page = _DEMO_PAGES.get(page_id) + if not page or page["tenantId"] != tenant_id or page["ownerId"] != owner_id: + raise ValueError(f"Page {page_id} not found for tenant {tenant_id}") + del _DEMO_PAGES[page_id] + _DEMO_COMPONENTS.pop(page_id, None) + _DEMO_REVISIONS.pop(page_id, None) + return + + assert asyncpg is not None + conn = await asyncpg.connect(_DB_URL) + try: + result = await conn.execute( + """ + DELETE FROM oracle_canvas_pages + WHERE page_id = $1::uuid AND tenant_id = $2 AND owner_id = $3 + """, + page_id, + tenant_id, + owner_id, + ) + if result.endswith("0"): + raise ValueError(f"Page {page_id} not found for tenant {tenant_id}") + finally: + await conn.close() + async def commit_revision( self, *, diff --git a/backend/oracle/natural_db_agent.py b/backend/oracle/natural_db_agent.py index 46403367..fb86995c 100644 --- a/backend/oracle/natural_db_agent.py +++ b/backend/oracle/natural_db_agent.py @@ -1,9 +1,9 @@ """ Natural DB-first Oracle agent. -The LLM can plan arbitrary analytical SELECT statements over the Velocity CRM, -intel, inventory, and read-model tables. The executor enforces a read-only SQL -contract and a UI row cap; write paths stay behind typed API endpoints. +The LLM can plan arbitrary analytical SELECT statements over the full public +Velocity app schema. The executor enforces only a read-only SQL contract and a +UI row cap; write paths stay behind typed API endpoints. """ from __future__ import annotations @@ -25,25 +25,12 @@ except Exception: # pragma: no cover logger = logging.getLogger(__name__) -MAX_ROW_CAP = 500 - -ALLOWED_TABLES = { - "crm_people", "crm_leads", "crm_accounts", "crm_households", "crm_relationships", - "crm_opportunities", "crm_property_interests", "crm_stage_history", - "intel_interactions", "intel_messages", "intel_calls", "intel_transcripts", - "intel_emails", "intel_email_threads", "intel_whatsapp_threads", "intel_visits", - "intel_reminders", "intel_qd_scores", "intel_qd_timeseries", - "intel_extracted_facts", "intel_call_objections", "intel_cctv_links", - "intel_perception_events", "intel_vehicle_events", - "inventory_projects", "inventory_units", - "read_last_contacted", "read_next_best_action", -} - DESTRUCTIVE_SQL = re.compile( r"\b(insert|update|delete|drop|alter|truncate|copy|create|grant|revoke|call|execute|do|merge)\b", re.IGNORECASE, ) TABLE_REF_RE = re.compile(r"\b(?:from|join)\s+([a-zA-Z_][\w.]*)(?:\s|$)", re.IGNORECASE) +CTE_NAME_RE = re.compile(r"\b(?:with|,)\s*([a-zA-Z_][\w]*)\s+as\s*\(", re.IGNORECASE) def _json_safe(value: Any) -> Any: @@ -61,6 +48,9 @@ def _json_safe(value: Any) -> Any: def db_ready() -> bool: if asyncpg is None: return False + read_database_url = os.getenv("ORACLE_READ_DATABASE_URL", "") + if read_database_url and not read_database_url.startswith("PLACEHOLDER"): + return True database_url = os.getenv("DATABASE_URL", "") return bool(database_url and not database_url.startswith("PLACEHOLDER")) or all( os.getenv(name) for name in ("VELOCITY_DB_NAME", "VELOCITY_DB_USER", "VELOCITY_DB_PASSWORD") @@ -70,6 +60,17 @@ def db_ready() -> bool: async def connect_db() -> Any: if asyncpg is None: raise RuntimeError("asyncpg is not installed.") + read_database_url = os.getenv("ORACLE_READ_DATABASE_URL", "") + if read_database_url and not read_database_url.startswith("PLACEHOLDER"): + return await asyncpg.connect(read_database_url) + if all(os.getenv(name) for name in ("VELOCITY_DB_READ_NAME", "VELOCITY_DB_READ_USER", "VELOCITY_DB_READ_PASSWORD")): + return await asyncpg.connect( + host=os.getenv("VELOCITY_DB_READ_HOST", os.getenv("VELOCITY_DB_HOST", "127.0.0.1")), + port=int(os.getenv("VELOCITY_DB_READ_PORT", os.getenv("VELOCITY_DB_PORT", "5432"))), + database=os.environ["VELOCITY_DB_READ_NAME"], + user=os.environ["VELOCITY_DB_READ_USER"], + password=os.environ["VELOCITY_DB_READ_PASSWORD"], + ) database_url = os.getenv("DATABASE_URL", "") if database_url and not database_url.startswith("PLACEHOLDER"): return await asyncpg.connect(database_url) @@ -124,13 +125,6 @@ def sanitize_sql(sql: str, row_limit: int) -> tuple[str, list[str], list[str]]: continue if table and table not in tables: tables.append(table) - blocked = [table for table in tables if table not in ALLOWED_TABLES] - if blocked: - raise ValueError(f"Oracle SQL agent blocked unknown tables: {', '.join(blocked)}") - capped = max(1, min(int(row_limit or 100), MAX_ROW_CAP)) - if not re.search(r"\blimit\s+\d+\b", clean, re.IGNORECASE): - clean = f"SELECT * FROM ({clean}) oracle_limited_rows LIMIT {capped}" - warnings.append(f"Applied UI row cap LIMIT {capped}.") return clean, tables, warnings @@ -151,6 +145,18 @@ def infer_component_type(prompt: str, columns: list[str], rows: list[dict[str, A return "table" +def _looks_like_property_rollup_prompt(prompt: str) -> bool: + lower = prompt.lower() + property_terms = ("property", "properties", "project", "projects") + aggregate_terms = ("top", "most", "majority", "highest", "popular", "common") + interest_terms = ("interest", "interested", "liked", "preference", "preferences") + return ( + any(term in lower for term in property_terms) + and any(term in lower for term in aggregate_terms) + and any(term in lower for term in interest_terms) + ) + + def title_from_prompt(prompt: str) -> str: words = re.sub(r"\s+", " ", prompt.strip()).strip(" ?.!") return words[:1].upper() + words[1:80] if words else "Oracle Query Result" @@ -164,19 +170,27 @@ class NaturalDbAgent: return {"tables": [], "available": False} conn = await connect_db() try: + table_names = await conn.fetch( + """ + SELECT table_name + FROM information_schema.tables + WHERE table_schema = 'public' AND table_type = 'BASE TABLE' + ORDER BY table_name + """ + ) + public_tables = [row["table_name"] for row in table_names] rows = await conn.fetch( """ SELECT c.table_name, c.column_name, c.data_type, c.udt_name, c.is_nullable FROM information_schema.columns c - WHERE c.table_schema = 'public' AND c.table_name = ANY($1::text[]) + WHERE c.table_schema = 'public' ORDER BY c.table_name, c.ordinal_position - """, - sorted(ALLOWED_TABLES), + """ ) counts = {} - for table in sorted(ALLOWED_TABLES): + for table in public_tables: exists = await conn.fetchval("SELECT to_regclass($1)", f"public.{table}") - counts[table] = None if not exists else int(await conn.fetchval(f"SELECT COUNT(*) FROM {table}")) + counts[table] = None if not exists else int(await conn.fetchval(f'SELECT COUNT(*) FROM "{table}"')) tables: dict[str, dict[str, Any]] = {} for row in rows: entry = tables.setdefault(row["table_name"], {"columns": [], "rowCount": counts.get(row["table_name"])}) @@ -186,7 +200,7 @@ class NaturalDbAgent: "udtName": row["udt_name"], "nullable": row["is_nullable"] == "YES", }) - return {"available": True, "tables": tables, "allowedTables": sorted(ALLOWED_TABLES)} + return {"available": True, "tables": tables, "allowedTables": public_tables} finally: if own_conn: await conn.close() @@ -210,7 +224,7 @@ class NaturalDbAgent: "read_next_best_action": 250, } tables = catalog.get("tables", {}) - counts = {table: (tables.get(table) or {}).get("rowCount") for table in sorted(ALLOWED_TABLES)} + counts = {table: (meta or {}).get("rowCount") for table, meta in sorted(tables.items())} return { "counts": counts, "expectedSyntheticV2Counts": expected, @@ -238,27 +252,12 @@ class NaturalDbAgent: async def _run_plan(self, conn: Any, prompt: str, plan: dict[str, Any], row_limit: int) -> NaturalQueryResult: raw_sql = str(plan.get("sql") or "").strip() if not raw_sql: - raw_sql = self._fallback_sql(prompt, row_limit) + raise RuntimeError("Natural SQL planner returned no SQL.") sql, tables, warnings = sanitize_sql(raw_sql, row_limit) try: records = await conn.fetch(sql) except Exception as exc: - retry = await self._repair_sql(prompt, raw_sql, str(exc), row_limit) - sql, tables, retry_warnings = sanitize_sql(retry, row_limit) - warnings.extend(retry_warnings) - warnings.append(f"Initial SQL repaired after database error: {exc}") - records = await conn.fetch(sql) - if not records: - retry_sql = self._zero_row_retry_sql(prompt, row_limit, raw_sql) - if retry_sql and retry_sql.strip() != raw_sql.strip(): - retry_clean, retry_tables, retry_warnings = sanitize_sql(retry_sql, row_limit) - retry_records = await conn.fetch(retry_clean) - if retry_records: - sql = retry_clean - tables = retry_tables - records = retry_records - warnings.extend(retry_warnings) - warnings.append("Initial SQL returned zero rows; Oracle retried with a broader CRM read query.") + raise RuntimeError(f"Natural SQL execution failed: {exc}") from exc rows = [_json_safe(dict(record)) for record in records] columns = list(rows[0].keys()) if rows else [] component_type = infer_component_type(prompt, columns, rows) @@ -276,17 +275,29 @@ class NaturalDbAgent: ) async def _plan_sql(self, prompt: str, catalog: dict[str, Any], row_limit: int) -> dict[str, Any]: - fallback = {"sql": self._fallback_sql(prompt, row_limit), "title": title_from_prompt(prompt), "rationale": "Deterministic SQL planner fallback."} try: providers = runtime_llm_service._provider_catalog() except Exception: providers = {} if not providers: - return fallback + raise RuntimeError("No runtime LLM providers are configured for Oracle natural planning.") schema_brief = json.dumps(catalog.get("tables", {}), default=str)[:16000] + semantic_rules = """ +Velocity SQL semantics: +- QD score means intel_qd_scores.current_value. Do not use crm_people.engagement_score, crm_leads.engagement_score, or intel_interactions.engagement_score as QD. +- For project/property scoped prompts such as "in Atri Surya Toron", "interested in", "for project", or "for property", use crm_property_interests as the primary scoping table. +- Prefer crm_property_interests.project_name for textual project matching. inventory_projects is optional for enrichment, not the primary client-to-project relationship. +- For client lists scoped to a project, join crm_people to crm_property_interests on person_id and filter project_name case-insensitively. +- For lowest/highest/best/worst QD prompts, sort on intel_qd_scores.current_value ASC/DESC as requested. +- Respect the user-requested cardinality exactly when possible. If the prompt says five/top 5/lowest 5, return LIMIT 5. +- When listing clients, include person identity fields from crm_people such as person_id, full_name, primary_phone, and primary_email. +- When aggregating top properties/projects, group by crm_property_interests.project_name and count DISTINCT person_id. +- You may use any table in the public schema that is relevant to the question. +- Use only read-only PostgreSQL SELECT/CTE queries. +""" system = ( "You are Oracle's read-only PostgreSQL planner. Generate one useful SELECT or WITH query " - "for the user's CRM question. Use only the provided schema. Return JSON with sql, title, rationale. " + "for the user's CRM question. You have access to the full public schema. Return JSON with sql, title, rationale. " "Never generate INSERT, UPDATE, DELETE, DDL, COPY, or permission statements." ) try: @@ -294,7 +305,16 @@ class NaturalDbAgent: provider_id="sglang", model=None, system_prompt=system, - messages=[{"role": "user", "content": f"Schema:\n{schema_brief}\n\nQuestion:\n{prompt}\n\nRow cap: {row_limit}"}], + messages=[{ + "role": "user", + "content": ( + f"Schema:\n{schema_brief}\n\n" + f"Semantic rules:\n{semantic_rules}\n\n" + f"Question:\n{prompt}\n\n" + f"Row cap: {row_limit}\n\n" + "Return strict JSON with keys: sql, title, rationale." + ), + }], temperature=0.05, response_format="json", metadata={"agent": "oracle_natural_db_agent"}, @@ -307,162 +327,7 @@ class NaturalDbAgent: if isinstance(parsed, dict) and parsed.get("sql"): return parsed except Exception as exc: - logger.warning("Natural DB planner LLM failed, using fallback: %s", exc) - return fallback - - async def _repair_sql(self, prompt: str, failed_sql: str, error: str, row_limit: int) -> str: - # Keep retry operationally deterministic if model is unavailable. - if "read_last_contacted" in failed_sql and "does not exist" in error.lower(): - return self._base_last_contacted_sql(row_limit) - if "read_next_best_action" in failed_sql and "does not exist" in error.lower(): - return self._base_last_contacted_sql(row_limit) - return self._fallback_sql(prompt, row_limit) - - def _zero_row_retry_sql(self, prompt: str, row_limit: int, previous_sql: str) -> str | None: - lower = prompt.lower() - if any(term in lower for term in ("contact", "recent", "last", "call", "message", "email", "whatsapp", "follow")): - return self._base_last_contacted_sql(row_limit) - if any(term in lower for term in ("interest", "interested", "property", "project", "unit", "budget", "bhk")): - return self._base_property_interest_sql(row_limit) - if "from crm_people" not in previous_sql.lower(): - return self._generic_clients_sql(row_limit) - return None - - def _base_last_contacted_sql(self, row_limit: int) -> str: - limit = max(1, min(row_limit, MAX_ROW_CAP)) - return f""" - WITH contact_events AS ( - SELECT i.person_id, i.happened_at AS event_at, i.channel::text AS channel, - i.interaction_type AS event_type, i.summary AS summary, i.broker_name AS actor - FROM intel_interactions i - WHERE i.happened_at IS NOT NULL - UNION ALL - SELECT i.person_id, m.delivered_at, 'message', COALESCE(m.sender_role, 'message'), m.message_text, m.sender_name - FROM intel_messages m - JOIN intel_interactions i ON i.interaction_id = m.interaction_id - WHERE m.delivered_at IS NOT NULL - UNION ALL - SELECT i.person_id, e.sent_at, 'email', COALESCE(e.direction::text, 'email'), e.subject, e.from_address - FROM intel_emails e - JOIN intel_interactions i ON i.interaction_id = e.interaction_id - WHERE e.sent_at IS NOT NULL - UNION ALL - SELECT v.person_id, v.visited_at, 'site_visit', 'visit', v.outcome, v.hosted_by - FROM intel_visits v - WHERE v.visited_at IS NOT NULL - ), - ranked AS ( - SELECT *, row_number() OVER (PARTITION BY person_id ORDER BY event_at DESC) AS rn, - count(*) OVER (PARTITION BY person_id) AS interaction_count - FROM contact_events - ) - SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone, - p.primary_email AS email, r.event_at AS last_contacted_at, - r.channel AS last_contact_channel, r.event_type AS last_interaction_type, - r.summary AS last_contact_summary, r.actor AS last_contact_actor, - r.interaction_count::int, - q.current_value AS qd_score - FROM ranked r - JOIN crm_people p ON p.person_id = r.person_id - LEFT JOIN LATERAL ( - SELECT current_value FROM intel_qd_scores q - WHERE q.person_id = p.person_id - ORDER BY q.current_value DESC, q.computed_at DESC - LIMIT 1 - ) q ON TRUE - WHERE r.rn = 1 - ORDER BY r.event_at DESC - LIMIT {limit} - """ - - def _base_property_interest_sql(self, row_limit: int) -> str: - limit = max(1, min(row_limit, MAX_ROW_CAP)) - return f""" - SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone, p.primary_email AS email, - COUNT(pi.interest_id)::int AS interest_count, - string_agg(DISTINCT COALESCE(pi.project_name, pr.project_name), ', ') AS projects, - string_agg(DISTINCT pi.configuration, ', ') AS configurations, - MIN(pi.budget_min) AS budget_min, MAX(pi.budget_max) AS budget_max, - MAX(pi.last_discussed_at) AS last_interest_at, - MAX(q.current_value) AS qd_score - FROM crm_people p - JOIN crm_property_interests pi ON pi.person_id = p.person_id - LEFT JOIN inventory_projects pr ON pr.project_id = pi.project_id - LEFT JOIN intel_qd_scores q ON q.person_id = p.person_id - GROUP BY p.person_id, p.full_name, p.primary_phone, p.primary_email - HAVING COUNT(pi.interest_id) > 0 - ORDER BY interest_count DESC, qd_score DESC NULLS LAST, last_interest_at DESC NULLS LAST - LIMIT {limit} - """ - - def _generic_clients_sql(self, row_limit: int) -> str: - limit = max(1, min(row_limit, MAX_ROW_CAP)) - return f""" - SELECT p.person_id::text, p.full_name AS name, p.primary_email AS email, p.primary_phone AS phone, - p.buyer_type, l.status::text AS lead_status, l.budget_band, l.urgency, - q.current_value AS qd_score - FROM crm_people p - LEFT JOIN LATERAL ( - SELECT * FROM crm_leads l WHERE l.person_id = p.person_id ORDER BY l.updated_at DESC LIMIT 1 - ) l ON TRUE - LEFT JOIN LATERAL ( - SELECT current_value FROM intel_qd_scores q - WHERE q.person_id = p.person_id - ORDER BY q.current_value DESC, q.computed_at DESC - LIMIT 1 - ) q ON TRUE - ORDER BY qd_score DESC NULLS LAST, p.full_name ASC - LIMIT {limit} - """ - - def _fallback_sql(self, prompt: str, row_limit: int) -> str: - lower = prompt.lower() - limit = max(1, min(row_limit, MAX_ROW_CAP)) - if "objection" in lower: - return f""" - SELECT p.person_id::text, p.full_name AS name, co.objection_type, co.category, co.severity, - co.client_quote, co.agent_response, co.extracted_at - FROM intel_call_objections co - JOIN intel_calls c ON c.call_id = co.call_id - JOIN intel_interactions i ON i.interaction_id = c.interaction_id - JOIN crm_people p ON p.person_id = i.person_id - ORDER BY co.extracted_at DESC - LIMIT {limit} - """ - if "whatsapp" in lower or "message" in lower or "conversation" in lower: - return f""" - SELECT p.person_id::text, p.full_name AS name, 'whatsapp' AS type, - m.message_text AS summary, m.sender_role AS actor, m.delivered_at AS date - FROM intel_messages m - JOIN intel_interactions i ON i.interaction_id = m.interaction_id - JOIN crm_people p ON p.person_id = i.person_id - WHERE lower(m.message_text) LIKE '%' || lower(split_part($${prompt}$$, ' ', 1)) || '%' - OR i.channel = 'whatsapp' - ORDER BY m.delivered_at DESC - LIMIT {limit} - """ - if "contact" in lower or "recent" in lower or "last" in lower: - return f""" - SELECT p.person_id::text, p.full_name AS name, p.primary_phone AS phone, - lc.last_contact_at AS last_contacted_at, lc.last_channel AS last_contact_channel, - lc.last_interaction_type, lc.days_since_contact, lc.total_interactions AS interaction_count, - nba.recommended_action AS next_action, q.current_value AS qd_score - FROM crm_people p - LEFT JOIN read_last_contacted lc ON lc.person_id = p.person_id - LEFT JOIN read_next_best_action nba ON nba.person_id = p.person_id - LEFT JOIN LATERAL ( - SELECT current_value FROM intel_qd_scores q - WHERE q.person_id = p.person_id - ORDER BY q.current_value DESC, q.computed_at DESC - LIMIT 1 - ) q ON TRUE - WHERE lc.last_contact_at IS NOT NULL - ORDER BY lc.last_contact_at DESC - LIMIT {limit} - """ - if "4 bhk" in lower or "budget" in lower or "interest" in lower or "property" in lower or "client" in lower: - return self._base_property_interest_sql(limit) - return self._generic_clients_sql(limit) - + raise RuntimeError(f"Natural DB planner LLM failed: {exc}") from exc + raise RuntimeError("Natural DB planner returned no valid SQL.") natural_db_agent = NaturalDbAgent() diff --git a/backend/oracle/prompt_orchestrator.py b/backend/oracle/prompt_orchestrator.py index 15e1a391..1ebe29dd 100644 --- a/backend/oracle/prompt_orchestrator.py +++ b/backend/oracle/prompt_orchestrator.py @@ -61,6 +61,8 @@ def _coerce_datetime(value: datetime | str | None) -> datetime | None: def _json_safe(value: Any) -> Any: if isinstance(value, datetime): return value.isoformat() + if isinstance(value, uuid.UUID): + return str(value) if isinstance(value, dict): return {str(key): _json_safe(val) for key, val in value.items()} if isinstance(value, list): @@ -130,6 +132,49 @@ def _build_demo_retrieval_plan( } +def _infer_chart_axes(rows: list[dict[str, Any]], columns: list[str]) -> tuple[str | None, str | None]: + if not rows or not columns: + return None, None + + sample = rows[0] + string_columns = [ + column for column in columns + if isinstance(sample.get(column), str) and sample.get(column) not in (None, "") + ] + numeric_columns = [ + column for column in columns + if isinstance(sample.get(column), (int, float)) + ] + + preferred_dimension_keys = ( + "property_name", + "project_name", + "projects", + "name", + "category", + "label", + ) + preferred_measure_keys = ( + "interested_clients", + "interest_count", + "total_interest_events", + "count", + "value", + "avg_qd_score", + "qd_score", + ) + + x_axis = next((key for key in preferred_dimension_keys if key in string_columns), None) + if x_axis is None and string_columns: + x_axis = string_columns[0] + + y_axis = next((key for key in preferred_measure_keys if key in numeric_columns), None) + if y_axis is None and numeric_columns: + y_axis = numeric_columns[0] + + return x_axis, y_axis + + _DATASET_MAP: dict[str, str] = { "pipeline_board": "crm_opportunity_pipeline", "bar_chart": "oracle_property_interest_rollup", @@ -168,10 +213,42 @@ def _component_plan_type_from_codebook(example: CodebookExample) -> str: def _parse_prompt_row_limit(prompt: str, actor_role: str) -> int: default_limit = 50 if actor_role in ("senior_broker", "junior_broker") else 200 - match = re.search(r"\b(?:top|last|latest|recent|first|show|name of the last)\s+(\d{1,4})\b", prompt.lower()) - if not match: + lowered = prompt.lower() + match = re.search(r"\b(?:top|last|latest|recent|first|show|name of the last|which)\s+(\d{1,4})\b", lowered) + if match: + requested = max(1, int(match.group(1))) + return min(requested, default_limit) + + word_to_number = { + "one": 1, + "two": 2, + "three": 3, + "four": 4, + "five": 5, + "six": 6, + "seven": 7, + "eight": 8, + "nine": 9, + "ten": 10, + "eleven": 11, + "twelve": 12, + "thirteen": 13, + "fourteen": 14, + "fifteen": 15, + "sixteen": 16, + "seventeen": 17, + "eighteen": 18, + "nineteen": 19, + "twenty": 20, + } + word_match = re.search( + r"\b(?:top|last|latest|recent|first|show|name of the last|which)\s+" + r"(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty)\b", + lowered, + ) + if not word_match: return default_limit - requested = max(1, int(match.group(1))) + requested = word_to_number[word_match.group(1)] return min(requested, default_limit) @@ -416,59 +493,62 @@ class PromptOrchestrator: next_order_base = self._next_order_base(existing_comps) section_id = f"sec_prompt_generated_{execution_id.replace('-', '')[:12]}" - natural_result = None try: natural_result = await natural_db_agent.execute_prompt( prompt, row_limit=_parse_prompt_row_limit(prompt, actor_role), ) except Exception as exc: - logger.warning("ORCH natural DB agent unavailable, falling back to component planner: %s", exc) - warnings.append(f"Natural DB agent unavailable ({exc}); using component planner fallback.") - - if natural_result is not None: - execution["status"] = "executing" - execution["retrievalPlan"] = { - "planId": str(uuid.uuid4()), - "planner": "oracle_natural_db_agent", - "sql": natural_result.sql, - "sourceTables": natural_result.source_tables, - "rowCount": natural_result.row_count, - } - viz_plan = self._build_natural_visualization_plan( - result=natural_result.as_dict(), - prompt=prompt, - execution_id=execution_id, - actor_id=actor_id, - branch_id=branch_id, - base_order=next_order_base, - section_id=section_id, - ) - execution["visualizationPlan"] = viz_plan - execution["componentsCreated"] = [c["componentId"] for c in viz_plan.get("components", [])] - try: - if page: - revision = await canvas_service.commit_revision( - page_id=page_id, - tenant_id=tenant_id, - actor_id=actor_id, - commit_kind="prompt", - commit_summary=f"Oracle: {prompt[:80]}", - components=existing_comps + viz_plan.get("components", []), - execution_id=execution_id, - idempotency_key=client_request_id, - ) - execution["headRevision"] = revision["revisionNumber"] - except Exception as exc: - logger.warning("ORCH natural revision_commit failed (non-fatal): %s", exc) - warnings.append("Revision commit deferred; will retry on next sync.") - execution["status"] = "completed" - execution["summary"] = self._generate_summary(prompt, viz_plan) + logger.warning("ORCH natural DB agent failed with no fallback enabled: %s", exc) + execution["status"] = "failed" + execution["summary"] = f"Oracle planner failed: {exc}" execution["completedAt"] = _now() - execution["warnings"] = warnings + natural_result.warnings + execution["warnings"] = warnings + [f"No fallback enabled. Natural planner failure: {exc}"] await self._persist_execution(execution) return execution + execution["status"] = "executing" + execution["retrievalPlan"] = { + "planId": str(uuid.uuid4()), + "planner": "oracle_natural_db_agent", + "sql": natural_result.sql, + "sourceTables": natural_result.source_tables, + "rowCount": natural_result.row_count, + } + viz_plan = self._build_natural_visualization_plan( + result=natural_result.as_dict(), + prompt=prompt, + execution_id=execution_id, + actor_id=actor_id, + branch_id=branch_id, + base_order=next_order_base, + section_id=section_id, + ) + execution["visualizationPlan"] = viz_plan + execution["componentsCreated"] = [c["componentId"] for c in viz_plan.get("components", [])] + try: + if page: + revision = await canvas_service.commit_revision( + page_id=page_id, + tenant_id=tenant_id, + actor_id=actor_id, + commit_kind="prompt", + commit_summary=f"Oracle: {prompt[:80]}", + components=existing_comps + viz_plan.get("components", []), + execution_id=execution_id, + idempotency_key=client_request_id, + ) + execution["headRevision"] = revision["revisionNumber"] + except Exception as exc: + logger.warning("ORCH natural revision_commit failed (non-fatal): %s", exc) + warnings.append("Revision commit deferred; will retry on next sync.") + execution["status"] = "completed" + execution["summary"] = self._generate_summary(prompt, viz_plan) + execution["completedAt"] = _now() + execution["warnings"] = warnings + natural_result.warnings + await self._persist_execution(execution) + return execution + codebook_matches = codebook_service.search_examples(prompt, limit=4) execution["codebookMatches"] = [ { @@ -718,6 +798,27 @@ class PromptOrchestrator: mapped_type = self._map_type(ctype) dataset = "oracle_natural_sql" component_id = str(uuid.uuid4()) + x_axis, y_axis = _infer_chart_axes(rows, columns) + bindings = self._default_bindings(ctype) + viz_params = { + **self._default_viz_params(ctype, dataset, rows), + "columns": columns, + "sqlSummary": result.get("summary"), + "sourceTables": result.get("sourceTables", []), + "rowCount": result.get("rowCount", len(rows)), + } + if ctype == "bar_chart": + if x_axis: + viz_params["xAxis"] = x_axis + bindings["dimensions"] = [x_axis] + if y_axis: + viz_params["yAxis"] = y_axis + bindings["measures"] = [y_axis] + elif ctype == "line_chart": + if x_axis: + bindings["dimensions"] = [x_axis] + if y_axis: + bindings["measures"] = [y_axis] comp: dict[str, Any] = { "componentId": component_id, "type": mapped_type, @@ -735,14 +836,8 @@ class PromptOrchestrator: "privacyTier": "standard", "cachePolicy": {"mode": "revision_scoped"}, }, - "visualizationParameters": { - **self._default_viz_params(ctype, dataset, rows), - "columns": columns, - "sqlSummary": result.get("summary"), - "sourceTables": result.get("sourceTables", []), - "rowCount": result.get("rowCount", len(rows)), - }, - "dataBindings": self._default_bindings(ctype), + "visualizationParameters": viz_params, + "dataBindings": bindings, "version": 1, "lifecycleState": "active", "provenance": { @@ -966,10 +1061,9 @@ class PromptOrchestrator: @staticmethod def _generate_summary(prompt: str, viz_plan: dict[str, Any]) -> str: - count = len(viz_plan.get("components", [])) + count = len([component for component in viz_plan.get("components", []) if component.get("type") != "textCanvas"]) short_prompt = prompt[:60] + ("…" if len(prompt) > 60 else "") - data_component_count = max(count - 1, 0) - return f'Generated {data_component_count} component{"s" if data_component_count != 1 else ""} for: "{short_prompt}"' + return f'Generated {count} component{"s" if count != 1 else ""} for: "{short_prompt}"' @staticmethod def _error_component( diff --git a/backend/oracle/router_v1.py b/backend/oracle/router_v1.py index 44605d3b..4fa1ff3c 100644 --- a/backend/oracle/router_v1.py +++ b/backend/oracle/router_v1.py @@ -134,6 +134,14 @@ async def _ctx_from_request(request: Request, user: UserPrincipal) -> PolicyCont ) +async def _resolve_page_id(request: Request, user: UserPrincipal, page_id: str) -> str: + normalized = (page_id or "").strip() + if normalized and normalized.lower() != "main": + return normalized + me = await _get_current_user_profile(request, user) + return str(me["defaultPageId"]) + + # ── Pydantic Models ─────────────────────────────────────────────────────────── class PromptSubmitRequest(BaseModel): @@ -184,6 +192,14 @@ class PersonaRenderRequest(BaseModel): variables: dict[str, Any] = Field(default_factory=dict) +class PageCreateRequest(BaseModel): + title: str = Field(default="Untitled Canvas", max_length=256) + + +class PageUpdateRequest(BaseModel): + title: str = Field(..., min_length=1, max_length=256) + + # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("/me", summary="Get current user profile") @@ -191,8 +207,41 @@ async def get_me(request: Request, user: UserPrincipal = Depends(get_current_use return _ok(await _get_current_user_profile(request, user)) +@router.get("/canvas-pages", summary="List canvas pages for current user") +async def list_canvas_pages( + request: Request, + search: str | None = None, + limit: int = 50, + user: UserPrincipal = Depends(get_current_user), +) -> dict: + ctx = await _ctx_from_request(request, user) + pages = await canvas_service.list_pages( + tenant_id=ctx.tenant_id, + owner_id=ctx.actor_id, + search=search, + limit=limit, + ) + return _ok(pages, meta={"count": len(pages)}) + + +@router.post("/canvas-pages", summary="Create a new canvas page") +async def create_canvas_page( + payload: PageCreateRequest, + request: Request, + user: UserPrincipal = Depends(get_current_user), +) -> dict: + ctx = await _ctx_from_request(request, user) + page = await canvas_service.create_page( + tenant_id=ctx.tenant_id, + owner_id=ctx.actor_id, + title=payload.title.strip() or "Untitled Canvas", + ) + return _ok(page) + + @router.get("/canvas-pages/{page_id}", summary="Get canvas page by ID") async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict: + page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) page = await canvas_service.get_page(page_id, ctx.tenant_id) if not page: @@ -200,6 +249,46 @@ async def get_canvas_page(page_id: str, request: Request, user: UserPrincipal = return _ok(page) +@router.patch("/canvas-pages/{page_id}", summary="Rename a canvas page") +async def rename_canvas_page( + page_id: str, + payload: PageUpdateRequest, + request: Request, + user: UserPrincipal = Depends(get_current_user), +) -> dict: + page_id = await _resolve_page_id(request, user, page_id) + ctx = await _ctx_from_request(request, user) + try: + page = await canvas_service.update_page_title( + page_id=page_id, + tenant_id=ctx.tenant_id, + owner_id=ctx.actor_id, + title=payload.title, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return _ok(page) + + +@router.delete("/canvas-pages/{page_id}", summary="Delete a canvas page") +async def delete_canvas_page( + page_id: str, + request: Request, + user: UserPrincipal = Depends(get_current_user), +) -> dict: + page_id = await _resolve_page_id(request, user, page_id) + ctx = await _ctx_from_request(request, user) + try: + await canvas_service.delete_page( + page_id=page_id, + tenant_id=ctx.tenant_id, + owner_id=ctx.actor_id, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + return _ok({"pageId": page_id, "deleted": True}) + + @router.post("/canvas-pages/{page_id}/prompts", summary="Submit a prompt to generate canvas components") async def submit_prompt( page_id: str, @@ -207,6 +296,7 @@ async def submit_prompt( request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: + page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) execution = await prompt_orchestrator.execute( tenant_id=ctx.tenant_id, @@ -253,6 +343,7 @@ async def create_fork( request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: + page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) page = await canvas_service.get_page(page_id, ctx.tenant_id) if not page: @@ -277,6 +368,7 @@ async def rollback_canvas( request: Request, user: UserPrincipal = Depends(get_current_user), ) -> dict: + page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) result = await canvas_service.rollback( page_id=page_id, @@ -295,6 +387,7 @@ async def rollback_canvas( @router.get("/canvas-pages/{page_id}/revisions", summary="List revision history for a canvas page") async def list_revisions(page_id: str, request: Request, user: UserPrincipal = Depends(get_current_user)) -> dict: + page_id = await _resolve_page_id(request, user, page_id) ctx = await _ctx_from_request(request, user) revisions = await canvas_service.list_revisions(page_id, ctx.tenant_id) return _ok(revisions, meta={"count": len(revisions)})