diff --git a/sql/core/spark_ideas.sql b/sql/core/spark_ideas.sql index 1a5b23c..5d340f3 100644 --- a/sql/core/spark_ideas.sql +++ b/sql/core/spark_ideas.sql @@ -11,8 +11,12 @@ CREATE TABLE IF NOT EXISTS core.spark_ideas ( complexity TEXT, estimated_impact TEXT, similar_agents JSONB, + user_reaction TEXT, + popularity_score INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ DEFAULT NOW(), - updated_at TIMESTAMPTZ DEFAULT NOW() + updated_at TIMESTAMPTZ DEFAULT NOW(), + CONSTRAINT chk_spark_ideas_user_reaction + CHECK (user_reaction IS NULL OR user_reaction IN ('like', 'dislike')) ); CREATE INDEX IF NOT EXISTS idx_spark_ideas_company_id diff --git a/tavro_api/api/routers/spark.py b/tavro_api/api/routers/spark.py index 8ff8d23..e16ed78 100644 --- a/tavro_api/api/routers/spark.py +++ b/tavro_api/api/routers/spark.py @@ -7,14 +7,14 @@ import os import re from pathlib import Path -from typing import Any, AsyncGenerator +from typing import Any, AsyncGenerator, Literal logger = logging.getLogger(__name__) CURRENT_YEAR = datetime.datetime.now().year import httpx -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy import text @@ -62,6 +62,18 @@ class SparkIdea(BaseModel): complexity: str estimated_impact: str similar_agents: list[SparkSimilarAgent] + user_reaction: Literal["like", "dislike"] | None = None + popularity_score: int = 0 + + +class SparkReactionRequest(BaseModel): + reaction: Literal["like", "dislike"] | None = None + + +class SparkReactionResponse(BaseModel): + idea_id: str + user_reaction: Literal["like", "dislike"] | None = None + popularity_score: int class SparkConvertRequest(BaseModel): @@ -994,7 +1006,8 @@ async def get_spark_ideas( rows = await db.execute(text(f""" SELECT idea_id, title, description, rationale, signal_type, signal_label, - target_dimensions, target_nodes, complexity, estimated_impact, similar_agents + target_dimensions, target_nodes, complexity, estimated_impact, similar_agents, + user_reaction, popularity_score FROM core.spark_ideas WHERE {where} ORDER BY updated_at DESC @@ -1014,10 +1027,55 @@ async def get_spark_ideas( complexity=r["complexity"] or "Medium", estimated_impact=r["estimated_impact"] or "Medium", similar_agents=[SparkSimilarAgent(**a) for a in (r["similar_agents"] or [])], + user_reaction=r["user_reaction"], + popularity_score=r["popularity_score"] or 0, )) return result +@router.patch("/ideas/{idea_id}/reaction", response_model=SparkReactionResponse) +async def update_spark_idea_reaction( + idea_id: str, + payload: SparkReactionRequest, + company_id: str = Query(..., description="Company UUID"), + db: AsyncSession = Depends(get_db), +) -> SparkReactionResponse: + """Persist the current user's Spark idea reaction and derived popularity score.""" + current = await db.execute(text(""" + SELECT user_reaction, popularity_score + FROM core.spark_ideas + WHERE company_id = :company_id AND idea_id = :idea_id + """), {"company_id": company_id, "idea_id": idea_id}) + row = current.mappings().first() + if row is None: + raise HTTPException(status_code=404, detail="Spark idea not found") + + previous_reaction = row["user_reaction"] + previous_value = 1 if previous_reaction == "like" else -1 if previous_reaction == "dislike" else 0 + next_value = 1 if payload.reaction == "like" else -1 if payload.reaction == "dislike" else 0 + popularity_score = int(row["popularity_score"] or 0) - previous_value + next_value + + await db.execute(text(""" + UPDATE core.spark_ideas + SET user_reaction = :reaction, + popularity_score = :popularity_score, + updated_at = NOW() + WHERE company_id = :company_id AND idea_id = :idea_id + """), { + "company_id": company_id, + "idea_id": idea_id, + "reaction": payload.reaction, + "popularity_score": popularity_score, + }) + await db.commit() + + return SparkReactionResponse( + idea_id=idea_id, + user_reaction=payload.reaction, + popularity_score=popularity_score, + ) + + @router.delete("/ideas", status_code=204) async def reset_spark_ideas( company_id: str = Query(..., description="Company UUID"), @@ -1081,7 +1139,12 @@ async def generate_spark_ideas( if not direction_clean: async with AsyncSessionLocal() as clear_db: await clear_db.execute( - text("DELETE FROM core.spark_ideas WHERE company_id = :company_id"), + text(""" + DELETE FROM core.spark_ideas + WHERE company_id = :company_id + AND user_reaction IS NULL + AND COALESCE(popularity_score, 0) = 0 + """), {"company_id": company_id}, ) await clear_db.commit() @@ -1220,7 +1283,12 @@ async def event_stream() -> AsyncGenerator[str, None]: if not direction_clean: async with AsyncSessionLocal() as clear_db: await clear_db.execute( - text("DELETE FROM core.spark_ideas WHERE company_id = :company_id"), + text(""" + DELETE FROM core.spark_ideas + WHERE company_id = :company_id + AND user_reaction IS NULL + AND COALESCE(popularity_score, 0) = 0 + """), {"company_id": company_id}, ) await clear_db.commit() diff --git a/tavro_app/src/components/AgentHeader.tsx b/tavro_app/src/components/AgentHeader.tsx index a36f1c8..ce54399 100644 --- a/tavro_app/src/components/AgentHeader.tsx +++ b/tavro_app/src/components/AgentHeader.tsx @@ -227,6 +227,7 @@ const AgentHeader: React.FC = ({ )} + ); }; diff --git a/tavro_app/src/pages/SparkPage.tsx b/tavro_app/src/pages/SparkPage.tsx index c55a835..289f77e 100644 --- a/tavro_app/src/pages/SparkPage.tsx +++ b/tavro_app/src/pages/SparkPage.tsx @@ -12,12 +12,12 @@ import { Bot, ArrowRight, Lightbulb, - BookmarkPlus, - BookmarkCheck, AlertCircle, SlidersHorizontal, Search, Trash2, + ThumbsUp, + ThumbsDown, Check, CheckSquare, Target, @@ -37,6 +37,7 @@ import { type AgentTool = { name: string; description: string }; type AgentKnowledgeSource = { name: string; description: string }; +type IdeaReaction = 'like' | 'dislike'; type AgentTable = { name: string; description?: string; tool_name?: string; columns?: string[] }; type AgentColumn = { name: string; table_name?: string }; type AgentSkill = { name: string; description: string; tags: string[]; input_modes: string[]; output_modes: string[] }; @@ -238,13 +239,15 @@ function normalizeAgentColumns(value: unknown, tables: AgentTable[]): AgentColum const IdeaCard: React.FC<{ idea: SparkIdea; - saved: boolean; - onSave: () => void; + reaction?: IdeaReaction; + deleting?: boolean; + onDelete: () => void; + onReact: (reaction: IdeaReaction) => void; onClick: () => void; selectMode?: boolean; selected?: boolean; onSelect?: () => void; -}> = ({ idea, saved, onSave, onClick, selectMode = false, selected = false, onSelect }) => { +}> = ({ idea, reaction, deleting = false, onDelete, onReact, onClick, selectMode = false, selected = false, onSelect }) => { const signal = SIGNAL_META[idea.signal_type] ?? SIGNAL_META['gap_coverage']; const complexityClass = COMPLEXITY_META[idea.complexity] ?? COMPLEXITY_META['Medium']; const impactClass = IMPACT_META[idea.estimated_impact] ?? IMPACT_META['Medium']; @@ -272,13 +275,33 @@ const IdeaCard: React.FC<{ {selected && } ) : ( - +
+ + + +
)} @@ -326,7 +349,7 @@ const IdeaCard: React.FC<{ // ── Idea List Row (list-view variant) ──────────────────────────────────────── -const LIST_GRID = 'grid-cols-[32px_1fr_160px_180px_100px_80px_32px]'; +const LIST_GRID = 'grid-cols-[84px_1fr_160px_180px_100px_80px_32px]'; const PAGE_SIZE = 10; const DEFAULT_IDEA_COUNT = 5; const MIN_IDEA_COUNT = 1; @@ -334,13 +357,15 @@ const MAX_IDEA_COUNT = 16; const IdeaListRow: React.FC<{ idea: SparkIdea; - saved: boolean; - onSave: () => void; + reaction?: IdeaReaction; + deleting?: boolean; + onDelete: () => void; + onReact: (reaction: IdeaReaction) => void; onClick: () => void; selectMode?: boolean; selected?: boolean; onSelect?: () => void; -}> = ({ idea, saved, onSave, onClick, selectMode = false, selected = false, onSelect }) => { +}> = ({ idea, reaction, deleting = false, onDelete, onReact, onClick, selectMode = false, selected = false, onSelect }) => { const signal = SIGNAL_META[idea.signal_type] ?? SIGNAL_META['gap_coverage']; const complexityClass = COMPLEXITY_META[idea.complexity] ?? COMPLEXITY_META['Medium']; const impactClass = IMPACT_META[idea.estimated_impact] ?? IMPACT_META['Medium']; @@ -353,18 +378,39 @@ const IdeaListRow: React.FC<{ : 'hover:bg-slate-50 dark:hover:bg-slate-800/30' }`} > - {/* Col 1: bookmark / checkbox */} + {/* Col 1: delete / reaction / checkbox */} {selectMode ? (
{selected && }
) : ( - +
+ + + +
)} {/* Col 2: Title + description */} @@ -688,13 +734,15 @@ const IdeaModal: React.FC<{ const SparkPage: React.FC = () => { const { activeCompany } = useBlueprint(); const [ideas, setIdeas] = useState([]); - const [savedIds, setSavedIds] = useState>(new Set()); + const [reactions, setReactions] = useState>({}); + const [popularity, setPopularity] = useState>({}); const [selectedIdea, setSelectedIdea] = useState(null); const [loading, setLoading] = useState(false); const [generating, setGenerating] = useState(false); const [error, setError] = useState(null); const [contextOpen, setContextOpen] = useState(true); const [activeDimensions, setActiveDimensions] = useState>(new Set()); + const [showMostLiked, setShowMostLiked] = useState(false); const [search, setSearch] = useState(''); const [hasLibrary, setHasLibrary] = useState(false); const [direction, setDirection] = useState(''); @@ -704,9 +752,28 @@ const SparkPage: React.FC = () => { const [page, setPage] = useState(1); const [selectedForDelete, setSelectedForDelete] = useState>(new Set()); const [deleting, setDeleting] = useState(false); + const [deletingIds, setDeletingIds] = useState>(new Set()); const companyId = activeCompany?.id ?? null; + const syncPersistedMetrics = useCallback((nextIdeas: SparkIdea[], replace = false) => { + setReactions(prev => { + const next = replace ? {} : { ...prev }; + for (const idea of nextIdeas) { + if (idea.user_reaction) next[idea.idea_id] = idea.user_reaction; + else delete next[idea.idea_id]; + } + return next; + }); + setPopularity(prev => { + const next = replace ? {} : { ...prev }; + for (const idea of nextIdeas) { + next[idea.idea_id] = idea.popularity_score ?? 0; + } + return next; + }); + }, []); + const toggleDimension = (key: string) => { setActiveDimensions(prev => { const next = new Set(prev); @@ -716,51 +783,112 @@ const SparkPage: React.FC = () => { }); }; - const toggleSave = (ideaId: string) => { - setSavedIds(prev => { - const next = new Set(prev); - if (next.has(ideaId)) next.delete(ideaId); - else next.add(ideaId); + const handleReact = async (ideaId: string, reaction: IdeaReaction) => { + if (!companyId) return; + + const previousReaction = reactions[ideaId] ?? null; + const nextReaction = previousReaction === reaction ? null : reaction; + const previousValue = previousReaction === 'like' ? 1 : previousReaction === 'dislike' ? -1 : 0; + const nextValue = nextReaction === 'like' ? 1 : nextReaction === 'dislike' ? -1 : 0; + const previousPopularity = popularity[ideaId] ?? 0; + const optimisticPopularity = previousPopularity - previousValue + nextValue; + + setError(null); + setReactions(prev => { + const next = { ...prev }; + if (nextReaction) next[ideaId] = nextReaction; + else delete next[ideaId]; return next; }); + setPopularity(prev => ({ ...prev, [ideaId]: optimisticPopularity })); + setIdeas(prev => prev.map(idea => idea.idea_id === ideaId + ? { ...idea, user_reaction: nextReaction, popularity_score: optimisticPopularity } + : idea + )); + + try { + const saved = await sparkApi.updateIdeaReaction(companyId, ideaId, nextReaction); + setReactions(prev => { + const next = { ...prev }; + if (saved.user_reaction) next[ideaId] = saved.user_reaction; + else delete next[ideaId]; + return next; + }); + setPopularity(prev => ({ ...prev, [ideaId]: saved.popularity_score })); + setIdeas(prev => prev.map(idea => idea.idea_id === ideaId + ? { ...idea, user_reaction: saved.user_reaction, popularity_score: saved.popularity_score } + : idea + )); + } catch (err) { + setReactions(prev => { + const next = { ...prev }; + if (previousReaction) next[ideaId] = previousReaction; + else delete next[ideaId]; + return next; + }); + setPopularity(prev => ({ ...prev, [ideaId]: previousPopularity })); + setIdeas(prev => prev.map(idea => idea.idea_id === ideaId + ? { ...idea, user_reaction: previousReaction, popularity_score: previousPopularity } + : idea + )); + setError(err instanceof Error ? err.message : 'Failed to save idea reaction'); + } }; // Load stored ideas from DB on mount / when companyId changes useEffect(() => { if (!companyId) return; + setReactions({}); + setPopularity({}); + setShowMostLiked(false); setLoading(true); setError(null); sparkApi.getIdeas(companyId) .then(data => { setIdeas(data); + syncPersistedMetrics(data, true); setHasLibrary(data.length > 0); }) .catch(err => setError(err instanceof Error ? err.message : 'Failed to load ideas')) .finally(() => setLoading(false)); - }, [companyId]); + }, [companyId, syncPersistedMetrics]); // Search against DB as user types (debounced) useEffect(() => { - if (!companyId) return; + if (!companyId || generating) return; const timer = setTimeout(() => { sparkApi.getIdeas(companyId, search || undefined) - .then(setIdeas) + .then(data => { + setIdeas(data); + syncPersistedMetrics(data); + }) .catch(() => { }); }, 300); return () => clearTimeout(timer); - }, [companyId, search]); + }, [companyId, generating, search, syncPersistedMetrics]); // Generate fresh ideas via SSE — ideas appear progressively as they stream in const inspire = useCallback(async () => { if (!companyId) return; setIdeas([]); + setShowMostLiked(false); setGenerating(true); setError(null); setSearch(''); try { const dims = activeDimensions.size > 0 ? [...activeDimensions] : undefined; for await (const idea of sparkApi.generateIdeasStream(companyId, dims, direction.trim() || undefined, ideaCount)) { - setIdeas(prev => prev.some(i => i.idea_id === idea.idea_id) ? prev : [...prev, idea]); + setIdeas(prev => { + const existing = prev.find(i => i.idea_id === idea.idea_id); + const enrichedIdea = { + ...idea, + user_reaction: reactions[idea.idea_id] ?? existing?.user_reaction ?? idea.user_reaction ?? null, + popularity_score: popularity[idea.idea_id] ?? existing?.popularity_score ?? idea.popularity_score ?? 0, + }; + return existing + ? prev.map(i => i.idea_id === idea.idea_id ? enrichedIdea : i) + : [...prev, enrichedIdea]; + }); setHasLibrary(true); } } catch (err) { @@ -768,7 +896,7 @@ const SparkPage: React.FC = () => { } finally { setGenerating(false); } - }, [companyId, activeDimensions, direction, ideaCount]); + }, [companyId, activeDimensions, direction, ideaCount, popularity, reactions]); const enterSelectMode = () => { setSelectMode(true); @@ -796,9 +924,14 @@ const SparkPage: React.FC = () => { await sparkApi.deleteIdeas(companyId, [...selectedForDelete]); const remaining = ideas.filter(i => !selectedForDelete.has(i.idea_id)); setIdeas(remaining); - setSavedIds(prev => { - const next = new Set(prev); - selectedForDelete.forEach(id => next.delete(id)); + setReactions(prev => { + const next = { ...prev }; + selectedForDelete.forEach(id => delete next[id]); + return next; + }); + setPopularity(prev => { + const next = { ...prev }; + selectedForDelete.forEach(id => delete next[id]); return next; }); setHasLibrary(remaining.length > 0); @@ -810,12 +943,54 @@ const SparkPage: React.FC = () => { } }, [companyId, selectedForDelete, ideas]); - const savedIdeas = ideas.filter(i => savedIds.has(i.idea_id)); + const handleDeleteIdea = useCallback(async (ideaId: string) => { + if (!companyId || deletingIds.has(ideaId)) return; + setDeletingIds(prev => new Set(prev).add(ideaId)); + setError(null); + try { + await sparkApi.deleteIdeas(companyId, [ideaId]); + const remaining = ideas.filter(i => i.idea_id !== ideaId); + setIdeas(remaining); + setReactions(prev => { + const next = { ...prev }; + delete next[ideaId]; + return next; + }); + setPopularity(prev => { + const next = { ...prev }; + delete next[ideaId]; + return next; + }); + setSelectedForDelete(prev => { + const next = new Set(prev); + next.delete(ideaId); + return next; + }); + setSelectedIdea(prev => prev?.idea_id === ideaId ? null : prev); + setHasLibrary(remaining.length > 0); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to delete idea'); + } finally { + setDeletingIds(prev => { + const next = new Set(prev); + next.delete(ideaId); + return next; + }); + } + }, [companyId, deletingIds, ideas]); + + // Client-side filters applied to whatever is currently loaded + const filteredIdeas = useMemo(() => { + const dimensionFiltered = activeDimensions.size > 0 + ? ideas.filter(i => i.target_dimensions.some(d => activeDimensions.has(d))) + : ideas; - // Client-side dimension filter applied to whatever is currently loaded - const filteredIdeas = activeDimensions.size > 0 - ? ideas.filter(i => i.target_dimensions.some(d => activeDimensions.has(d))) - : ideas; + if (!showMostLiked) return dimensionFiltered; + + return dimensionFiltered + .filter(i => (popularity[i.idea_id] ?? 0) > 0) + .sort((a, b) => (popularity[b.idea_id] ?? 0) - (popularity[a.idea_id] ?? 0)); + }, [activeDimensions, ideas, popularity, showMostLiked]); const isSearching = search.trim().length > 0; const totalPages = Math.max(1, Math.ceil(filteredIdeas.length / PAGE_SIZE)); @@ -1023,16 +1198,16 @@ const SparkPage: React.FC = () => {

{SPARK_DIMENSIONS.map(({ key, label }) => { const active = activeDimensions.has(key); return ( @@ -1097,8 +1278,14 @@ const SparkPage: React.FC = () => { ); })} - {activeDimensions.size > 0 && ( - )} @@ -1114,26 +1301,6 @@ const SparkPage: React.FC = () => { )} - {/* ── Saved ideas strip ── */} - {savedIdeas.length > 0 && ( -
-

- Saved ideas ({savedIdeas.length}) -

-
- {savedIdeas.map(idea => ( - - ))} -
-
- )} - {/* ── Loading skeleton — shown while loading/generating with no ideas yet ── */} {(loading || (generating && ideas.length === 0)) && ( viewMode === 'list' ? ( @@ -1191,8 +1358,10 @@ const SparkPage: React.FC = () => { toggleSave(idea.idea_id)} + reaction={reactions[idea.idea_id]} + deleting={deletingIds.has(idea.idea_id)} + onDelete={() => handleDeleteIdea(idea.idea_id)} + onReact={(reaction) => { void handleReact(idea.idea_id, reaction); }} onClick={() => setSelectedIdea(idea)} selectMode={selectMode} selected={selectedForDelete.has(idea.idea_id)} @@ -1206,8 +1375,10 @@ const SparkPage: React.FC = () => { toggleSave(idea.idea_id)} + reaction={reactions[idea.idea_id]} + deleting={deletingIds.has(idea.idea_id)} + onDelete={() => handleDeleteIdea(idea.idea_id)} + onReact={(reaction) => { void handleReact(idea.idea_id, reaction); }} onClick={() => setSelectedIdea(idea)} selectMode={selectMode} selected={selectedForDelete.has(idea.idea_id)} @@ -1260,11 +1431,23 @@ const SparkPage: React.FC = () => { {search ?

No ideas match “{search}”

- :

No ideas match the selected dimension filters

+ : showMostLiked + ?

No liked ideas yet

+ :

No ideas match the selected dimension filters

}
{search && } - {activeDimensions.size > 0 && } + {(activeDimensions.size > 0 || showMostLiked) && ( + + )}
)} diff --git a/tavro_app/src/services/sparkApi.ts b/tavro_app/src/services/sparkApi.ts index 5c3ce8b..e496466 100644 --- a/tavro_app/src/services/sparkApi.ts +++ b/tavro_app/src/services/sparkApi.ts @@ -154,6 +154,26 @@ class SparkApi { appLogger.res('Spark deleteIdeas', { deleted: ideaIds.length }, Date.now() - t0); } + /** Persist a user's reaction and updated popularity score for an idea. */ + async updateIdeaReaction( + companyId: string, + ideaId: string, + reaction: 'like' | 'dislike' | null, + ): Promise<{ idea_id: string; user_reaction: 'like' | 'dislike' | null; popularity_score: number }> { + const params = new URLSearchParams({ company_id: companyId }); + appLogger.req('Spark updateIdeaReaction', { companyId, ideaId, reaction }); + const t0 = Date.now(); + const result = await req<{ idea_id: string; user_reaction: 'like' | 'dislike' | null; popularity_score: number }>( + `/spark/ideas/${encodeURIComponent(ideaId)}/reaction?${params.toString()}`, + { + method: 'PATCH', + body: JSON.stringify({ reaction }), + }, + ); + appLogger.res('Spark updateIdeaReaction', { ideaId, reaction: result.user_reaction }, Date.now() - t0); + return result; + } + /** Delete all stored ideas for a company. */ async resetIdeas(companyId: string): Promise { const params = new URLSearchParams({ company_id: companyId }); diff --git a/tavro_app/src/types/spark.ts b/tavro_app/src/types/spark.ts index 4bd76e1..95ac7e6 100644 --- a/tavro_app/src/types/spark.ts +++ b/tavro_app/src/types/spark.ts @@ -23,6 +23,8 @@ export interface SparkIdea { estimated_impact: 'Low' | 'Medium' | 'High'; similar_agents: SparkSimilarAgent[]; saved?: boolean; + user_reaction?: 'like' | 'dislike' | null; + popularity_score?: number; } export interface SparkConvertRequest {