From 6582cc1f6f9845474a5d2ffc667db43a0bace715 Mon Sep 17 00:00:00 2001
From: manirajkumar-tavro
Date: Fri, 12 Jun 2026 16:24:20 +0530
Subject: [PATCH 1/2] added like dislike for ideas and delete option to remove
created idea
---
run_connector.py => run_connectors.py | 0
sql/core/spark_ideas.sql | 6 +-
tavro_api/api/routers/spark.py | 78 +++++-
tavro_app/src/components/AgentHeader.tsx | 1 -
tavro_app/src/pages/SparkPage.tsx | 339 +++++++++++++++++------
tavro_app/src/services/sparkApi.ts | 20 ++
tavro_app/src/types/spark.ts | 2 +
7 files changed, 361 insertions(+), 85 deletions(-)
rename run_connector.py => run_connectors.py (100%)
diff --git a/run_connector.py b/run_connectors.py
similarity index 100%
rename from run_connector.py
rename to run_connectors.py
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 2ca3aa6..c4eaf79 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):
@@ -799,7 +811,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
@@ -819,10 +832,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"),
@@ -886,7 +944,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()
@@ -1025,7 +1088,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 90e97ae..f13d6fa 100644
--- a/tavro_app/src/components/AgentHeader.tsx
+++ b/tavro_app/src/components/AgentHeader.tsx
@@ -228,7 +228,6 @@ const AgentHeader: React.FC = ({
-
);
};
diff --git a/tavro_app/src/pages/SparkPage.tsx b/tavro_app/src/pages/SparkPage.tsx
index c1b4783..01a307c 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';
function asRecord(value: unknown): Record | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
@@ -109,13 +110,15 @@ function normalizeKnowledgeSource(value: unknown): AgentKnowledgeSource | undefi
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'];
@@ -143,13 +146,33 @@ const IdeaCard: React.FC<{
{selected && }
) : (
-
+
+
+
+
+
)}
@@ -197,7 +220,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;
@@ -205,13 +228,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'];
@@ -224,18 +249,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 */}
@@ -552,13 +598,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('');
@@ -568,9 +616,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);
@@ -580,51 +647,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) {
@@ -632,7 +760,7 @@ const SparkPage: React.FC = () => {
} finally {
setGenerating(false);
}
- }, [companyId, activeDimensions, direction, ideaCount]);
+ }, [companyId, activeDimensions, direction, ideaCount, popularity, reactions]);
const enterSelectMode = () => {
setSelectMode(true);
@@ -660,9 +788,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);
@@ -674,12 +807,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));
@@ -887,16 +1062,16 @@ const SparkPage: React.FC = () => {