Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions core/orchestrator.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
ToolObservation,
)
from core.providers import LLMClient
from core.pendo_track import pendo_track
from memory.store import MemoryStore

WORKFLOW_STEPS = [
Expand Down Expand Up @@ -76,6 +77,18 @@ def run(self, user_question: str, event_callback=None) -> ResearchRunResult:
{"memory_hits": [asdict(item) for item in related_memories]},
)

if related_memories:
top_score = max(
(getattr(m, "similarity", 0.0) for m in related_memories),
default=0.0,
)
pendo_track("memory_hit_found", {
"run_id": run_id,
"memory_hit_count": len(related_memories),
"idea_length": len(question),
"top_similarity_score": round(float(top_score), 3),
})

plan_message = self.planner.create_plan(question, related_memories, run_id)
plan = self._hydrate_plan(plan_message.payload)
trace.record(
Expand Down Expand Up @@ -109,7 +122,9 @@ def run(self, user_question: str, event_callback=None) -> ResearchRunResult:
step.query,
{"agent": agent_name, "step_id": step.step_id, "objective": step.objective},
)
step_start = int(__import__("time").time() * 1000)
tool_message = self._execute_step(step, run_id)
step_duration = int(__import__("time").time() * 1000) - step_start
observation = self._hydrate_observation(tool_message.payload)
observations.append(observation)
trace.record(
Expand All @@ -123,6 +138,16 @@ def run(self, user_question: str, event_callback=None) -> ResearchRunResult:
},
)

pendo_track("pipeline_stage_completed", {
"run_id": run_id,
"stage_name": stage,
"agent_name": agent_name,
"duration_ms": step_duration,
"iteration": iteration,
"status": observation.status,
"source_count": len(observation.sources),
})

evaluation_message = self.evaluator.evaluate(question, plan, observations, run_id)
last_evaluation = self._hydrate_evaluation(evaluation_message.payload)
trace.record(
Expand All @@ -149,6 +174,15 @@ def run(self, user_question: str, event_callback=None) -> ResearchRunResult:
)
break

pendo_track("evaluation_reflection_triggered", {
"run_id": run_id,
"iteration": iteration,
"confidence_score": last_evaluation.confidence,
"gaps_count": len(last_evaluation.gaps),
"suggested_queries_count": len(last_evaluation.suggested_queries),
"ready_to_finalize": last_evaluation.ready_to_finalize,
})

plan_message = self.planner.create_plan(
question,
related_memories,
Expand Down Expand Up @@ -237,6 +271,15 @@ def run(self, user_question: str, event_callback=None) -> ResearchRunResult:
)
trace.save()
self.memory.save_run(result)

pendo_track("analysis_run_persisted", {
"run_id": run_id,
"trace_path": trace_path,
"observation_count": len(observations),
"score": int(final_decision.get("score", 0)),
"verdict": str(final_decision.get("final_verdict", "")),
"confidence": int(final_decision.get("confidence", 0)),
})
result.trace = list(trace.events)
result.trace_path = trace_path
return result
Expand Down
52 changes: 52 additions & 0 deletions core/pendo_track.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Pendo server-side Track Event utility using the Pendo Track API."""

from __future__ import annotations

import json
import logging
import threading
import time
import urllib.request
from typing import Any

logger = logging.getLogger(__name__)

PENDO_DATA_HOST = "https://data.pendo.io"
PENDO_INTEGRATION_KEY = "976d716a-f2e2-46c6-b542-8dc1566311d8"


def pendo_track(
event: str,
properties: dict[str, Any] | None = None,
visitor_id: str = "system",
account_id: str = "system",
) -> None:
"""Send a Track Event to the Pendo Track API (fire-and-forget in a thread)."""
payload = {
"type": "track",
"event": event,
"visitorId": visitor_id,
"accountId": account_id,
"timestamp": int(time.time() * 1000),
"properties": properties or {},
}
# Fire-and-forget so tracking never blocks the pipeline
threading.Thread(target=_send, args=(payload,), daemon=True).start()


def _send(payload: dict[str, Any]) -> None:
try:
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
f"{PENDO_DATA_HOST}/data/track",
data=data,
headers={
"Content-Type": "application/json",
"x-pendo-integration-key": PENDO_INTEGRATION_KEY,
},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
resp.read()
except Exception:
logger.debug("Pendo track event failed for '%s'", payload.get("event"), exc_info=True)
30 changes: 30 additions & 0 deletions src/app/api/analyze/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,29 @@ import type { ActionLabel, AgentStage, AnalysisResult, TraceEvent, Verdict } fro
export const runtime = "nodejs";
export const maxDuration = 120; // Vercel: allow up to 2 min for AI pipeline

/* ── Pendo server-side tracking ───────────────────────────────────────────── */

const PENDO_TRACK_URL = "https://data.pendo.io/data/track";
const PENDO_INTEGRATION_KEY = "976d716a-f2e2-46c6-b542-8dc1566311d8";

function pendoTrack(event: string, properties: Record<string, unknown> = {}, visitorId = "system", accountId = "system") {
fetch(PENDO_TRACK_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-pendo-integration-key": PENDO_INTEGRATION_KEY,
},
body: JSON.stringify({
type: "track",
event,
visitorId,
accountId,
timestamp: Date.now(),
properties,
}),
}).catch(() => { /* tracking must not break app flow */ });
}

/* ── Rate limiting ─────────────────────────────────────────────────────────── */

const ipRequestMap = new Map<string, { count: number; resetTime: number }>();
Expand Down Expand Up @@ -139,6 +162,13 @@ export async function POST(request: Request) {
const ip = forwarded?.split(",")[0]?.trim() || "unknown";

if (!checkRateLimit(ip)) {
const entry = ipRequestMap.get(ip);
pendoTrack("rate_limit_exceeded", {
ip_hash: ip.replace(/\d/g, "x"),
request_count: entry?.count ?? RATE_LIMIT,
window_remaining_ms: entry ? Math.max(0, entry.resetTime - Date.now()) : 0,
});

return NextResponse.json(
{ error: "Rate limit exceeded — max 10 analyses per hour." },
{ status: 429 }
Expand Down
48 changes: 48 additions & 0 deletions src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ import { generateMockResult } from "@/lib/utils"
import type { AnalysisResult, AgentStage, TraceEvent } from "@/types"
import { STAGE_ORDER, AGENT_DEFINITIONS } from "@/types"

declare global {
interface Window {
pendo?: {
track: (eventName: string, properties?: Record<string, unknown>) => void
}
}
}

// ── Stage display labels ──────────────────────────────────────────────────────

const STAGE_TITLES: Record<AgentStage, string> = {
Expand Down Expand Up @@ -44,11 +52,13 @@ function useAnalysisEngine() {
const [error, setError] = useState<string | null>(null)
const [isDemo, setIsDemo] = useState(false)
const abortRef = useRef<AbortController | null>(null)
const startTimeRef = useRef<number>(0)

const startAnalysis = useCallback(async (idea: string) => {
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
startTimeRef.current = Date.now()

setStatus("analyzing")
setCurrentStage(null)
Expand Down Expand Up @@ -101,6 +111,13 @@ function useAnalysisEngine() {

if ("timeout" in outcome) {
// API took too long — fall back to demo mode
window.pendo?.track("demo_mode_fallback", {
fallback_reason: "timeout",
error_message: "API timed out after 120 seconds",
idea_length: idea.length,
was_timeout: true,
})

const mock = generateMockResult(idea)
setResult({ ...mock, trace: events })
setIsDemo(true)
Expand All @@ -119,11 +136,24 @@ function useAnalysisEngine() {
msg.includes("not configured") ||
msg.includes("fetch")
) {
window.pendo?.track("demo_mode_fallback", {
fallback_reason: "api_unavailable",
error_message: msg.substring(0, 200),
idea_length: idea.length,
was_timeout: false,
})

const mock = generateMockResult(idea)
setResult({ ...mock, trace: events })
setIsDemo(true)
setStatus("done")
} else {
window.pendo?.track("analysis_failed", {
error_message: (msg || "Analysis failed").substring(0, 200),
idea_length: idea.length,
elapsed_time_ms: Date.now() - startTimeRef.current,
})

setError(msg || "Analysis failed. Please try again.")
setStatus("error")
}
Expand All @@ -133,6 +163,24 @@ function useAnalysisEngine() {
// Real result — merge in the animated trace events for better UX
const real = (outcome as { result: AnalysisResult }).result
const mergedTrace = real.trace?.length ? real.trace : events

const fd = real.final_brief.final_decision
window.pendo?.track("analysis_completed", {
score: fd.score,
verdict: fd.final_verdict,
action: fd.action,
confidence: fd.confidence,
risk_level: fd.risk,
market_demand: fd.market_demand,
competition: fd.competition,
processing_time_ms: Date.now() - startTimeRef.current,
is_demo_mode: false,
idea_length: idea.length,
demand_score: fd.demand_score,
competition_score: fd.competition_score,
risk_score: fd.risk_score,
})

setResult({ ...real, trace: mergedTrace })
setIsDemo(false)
setStatus("done")
Expand Down
63 changes: 60 additions & 3 deletions src/components/venturemind/interactive.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client"

import React, { useState, useRef, useCallback } from "react"
import React, { useState, useRef, useCallback, useEffect } from "react"
import { ArrowRight, Sparkles, RefreshCw, Download, RotateCcw, Zap } from "lucide-react"
import { cn, getVerdictConfig, verdictToAction, scoreToVerdict } from "@/lib/utils"
import { Button } from "@/components/ui/button"
Expand All @@ -17,6 +17,14 @@ import {
import type { AnalysisResult, AgentStage, TraceEvent } from "@/types"
import { EXAMPLE_IDEAS } from "@/types"

declare global {
interface Window {
pendo?: {
track: (eventName: string, properties?: Record<string, unknown>) => void
}
}
}

// ── Header ────────────────────────────────────────────────────────────────────

export function Header() {
Expand Down Expand Up @@ -64,13 +72,30 @@ export function IdeaInput({ onSubmit, isLoading }: IdeaInputProps) {
e.preventDefault()
const trimmed = value.trim()
if (trimmed.length < 10 || isLoading) return

const isExample = EXAMPLE_IDEAS.some(ex => ex === trimmed)
window.pendo?.track("idea_submitted", {
idea_length: trimmed.length,
idea_text_preview: trimmed.substring(0, 100),
source: isExample ? "example_preset" : "manual_typing",
char_limit_percentage: Math.round((trimmed.length / charLimit) * 100),
is_example_idea: isExample,
})

onSubmit(trimmed)
}, [value, isLoading, onSubmit])

const handleExample = useCallback((idea: string) => {
const hadExisting = value.trim().length > 0
window.pendo?.track("example_idea_selected", {
example_index: EXAMPLE_IDEAS.indexOf(idea),
example_idea_text: idea.substring(0, 100),
had_existing_input: hadExisting,
})

setValue(idea)
textareaRef.current?.focus()
}, [])
}, [value])

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
Expand Down Expand Up @@ -396,6 +421,17 @@ export function ResultTabs({ result, onReset }: ResultTabsProps) {
<Button variant="gold" size="lg" className="flex-1" aria-label="Download report" onClick={() => {
const fd = result.final_brief.final_decision;
const sw = result.final_brief.swot;
const fileName = `venturemind-report-${Date.now()}.txt`;

window.pendo?.track("report_exported", {
export_format: "txt",
score: fd.score,
verdict: fd.final_verdict,
confidence: fd.confidence,
idea_length: result.idea.length,
file_name: fileName,
})

const blob = new Blob(
[
`VentureMind AI — Analysis Report\n${'='.repeat(40)}\n\n` +
Expand All @@ -420,7 +456,7 @@ export function ResultTabs({ result, onReset }: ResultTabsProps) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `venturemind-report-${Date.now()}.txt`;
a.download = fileName;
a.click();
URL.revokeObjectURL(url);
}}>
Expand Down Expand Up @@ -448,6 +484,27 @@ export function WhatIfSimulator({ baseScore }: WhatIfSimulatorProps) {
const simConfig = getVerdictConfig(simVerdict)
const simAction = verdictToAction(simVerdict)

const baseVerdict = scoreToVerdict(baseScore)

// Debounced tracking: fire once when sliders settle at non-default positions
useEffect(() => {
if (demand === 0 && competition === 0 && risk === 0) return
const timer = setTimeout(() => {
window.pendo?.track("whatif_simulation_performed", {
base_score: baseScore,
simulated_score: simScore,
score_delta: simScore - baseScore,
demand_boost: demand,
competition_pressure: competition,
risk_increase: risk,
base_verdict: baseVerdict,
simulated_verdict: simVerdict,
verdict_changed: simVerdict !== baseVerdict,
})
}, 1000)
return () => clearTimeout(timer)
}, [demand, competition, risk, baseScore, simScore, simVerdict, baseVerdict])

const sliders: {
label: string; value: number;
setter: (v: number) => void; positive: boolean
Expand Down
Loading