feat(reviewiq): Add AI synthesis support to dashboard components
Frontend: - Add Synthesis type with action plan, insights, annotations - ExecutiveSummary: Accept synthesis prop for AI narrative - SentimentPie: Accept insight prop for contextual explanation - IntensityHeatmap: Accept insight + highlightDomain props - TimelineChart: Accept insight + annotations props - All components gracefully degrade when synthesis is null Backend: - Add Stage 4: Synthesize for generating AI narratives - Gathers context from classified spans - Generates executive narrative, section insights, action plan - Produces timeline annotations and marketing angles - Stores synthesis in pipeline.executions table Components show AI insights with purple gradient styling when available, fall back to existing behavior when synthesis is not yet generated. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
||||
Award,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain } from '../types';
|
||||
import type { Insights, WeaknessItem, OpportunitySpan, OpportunityMatrix, DomainScore, URTDomain, Synthesis } from '../types';
|
||||
import { getSubcodeDefinition } from '@/lib/taxonomy/data';
|
||||
|
||||
interface ExecutiveSummaryProps {
|
||||
@@ -25,6 +25,8 @@ interface ExecutiveSummaryProps {
|
||||
domainScores?: DomainScore[];
|
||||
onDriverClick?: (subcode: string) => void;
|
||||
onDomainClick?: (domain: URTDomain) => void;
|
||||
// AI-generated narrative (optional - enhances when available)
|
||||
synthesis?: Synthesis | null;
|
||||
}
|
||||
|
||||
// User-friendly domain config
|
||||
@@ -199,10 +201,14 @@ export function ExecutiveSummary({
|
||||
domainScores,
|
||||
onDriverClick,
|
||||
onDomainClick,
|
||||
synthesis,
|
||||
}: ExecutiveSummaryProps) {
|
||||
const { strengths, weaknesses, executive_summary, opportunity_matrix, rating_simulator } = insights;
|
||||
const [showFullSummary, setShowFullSummary] = useState(false);
|
||||
|
||||
// Use AI narrative if available, otherwise fall back to generated summary
|
||||
const narrativeText = synthesis?.executive_narrative || executive_summary;
|
||||
|
||||
const topStrength = strengths[0];
|
||||
const topWeakness = weaknesses[0];
|
||||
const ratingDisplay = getRatingDisplay(avgRating);
|
||||
@@ -286,16 +292,23 @@ export function ExecutiveSummary({
|
||||
</div>
|
||||
|
||||
{/* AI Summary */}
|
||||
{executive_summary && (
|
||||
{narrativeText && (
|
||||
<div className="px-6 pb-4">
|
||||
<div className="p-4 bg-white/70 rounded-xl border border-blue-100">
|
||||
<div className={`p-4 rounded-xl border ${
|
||||
synthesis?.executive_narrative
|
||||
? 'bg-gradient-to-r from-purple-50 to-blue-50 border-purple-200'
|
||||
: 'bg-white/70 border-blue-100'
|
||||
}`}>
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">💡</span>
|
||||
<div>
|
||||
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-2'}`}>
|
||||
{executive_summary}
|
||||
<span className="text-lg">{synthesis?.executive_narrative ? '✨' : '💡'}</span>
|
||||
<div className="flex-1">
|
||||
{synthesis?.executive_narrative && (
|
||||
<div className="text-xs font-medium text-purple-600 mb-1">AI-Generated Insight</div>
|
||||
)}
|
||||
<p className={`text-gray-700 leading-relaxed ${!showFullSummary && 'line-clamp-3'}`}>
|
||||
{narrativeText}
|
||||
</p>
|
||||
{executive_summary.length > 150 && (
|
||||
{narrativeText.length > 200 && (
|
||||
<button
|
||||
onClick={() => setShowFullSummary(!showFullSummary)}
|
||||
className="text-blue-600 text-sm font-medium mt-1 hover:underline"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
204
web/components/reviewiq/insights/RatingSimulator.tsx
Normal file
204
web/components/reviewiq/insights/RatingSimulator.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
'use client';
|
||||
|
||||
import { Star, TrendingUp, Award, Zap } from 'lucide-react';
|
||||
import type { RatingSimulator as RatingSimulatorType, WeaknessItem } from '../types';
|
||||
|
||||
interface RatingSimulatorProps {
|
||||
simulator: RatingSimulatorType | null;
|
||||
topWeaknesses?: WeaknessItem[];
|
||||
}
|
||||
|
||||
interface RatingStarProps {
|
||||
rating: number;
|
||||
label: string;
|
||||
color: string;
|
||||
isProjected?: boolean;
|
||||
}
|
||||
|
||||
function RatingDisplay({ rating, label, color, isProjected }: RatingStarProps) {
|
||||
const fullStars = Math.floor(rating);
|
||||
const partialStar = rating - fullStars;
|
||||
const emptyStars = 5 - Math.ceil(rating);
|
||||
|
||||
return (
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-semibold text-gray-500 mb-1">{label}</div>
|
||||
<div className="flex items-center justify-center gap-0.5 mb-1">
|
||||
{/* Full stars */}
|
||||
{Array.from({ length: fullStars }).map((_, i) => (
|
||||
<Star
|
||||
key={`full-${i}`}
|
||||
className="w-5 h-5"
|
||||
style={{ fill: color, stroke: color }}
|
||||
/>
|
||||
))}
|
||||
{/* Partial star */}
|
||||
{partialStar > 0 && (
|
||||
<div className="relative w-5 h-5">
|
||||
<Star className="absolute w-5 h-5 text-gray-300" />
|
||||
<div
|
||||
className="absolute overflow-hidden"
|
||||
style={{ width: `${partialStar * 100}%` }}
|
||||
>
|
||||
<Star
|
||||
className="w-5 h-5"
|
||||
style={{ fill: color, stroke: color }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Empty stars */}
|
||||
{Array.from({ length: emptyStars }).map((_, i) => (
|
||||
<Star key={`empty-${i}`} className="w-5 h-5 text-gray-300" />
|
||||
))}
|
||||
</div>
|
||||
<div className={`text-2xl font-bold ${isProjected ? 'text-green-600' : 'text-gray-900'}`}>
|
||||
{rating.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rating simulator showing potential rating improvements.
|
||||
*/
|
||||
export function RatingSimulator({ simulator, topWeaknesses = [] }: RatingSimulatorProps) {
|
||||
if (!simulator || simulator.potential_gain <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { current_rating, if_fix_top_1, if_fix_top_3, potential_gain } = simulator;
|
||||
|
||||
// Calculate progress towards 5 stars
|
||||
const currentProgress = (current_rating / 5) * 100;
|
||||
const potentialProgress = ((current_rating + potential_gain) / 5) * 100;
|
||||
|
||||
return (
|
||||
<div className="bg-gradient-to-br from-yellow-50 via-white to-green-50 rounded-xl p-6 shadow-md border-2 border-yellow-200">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<div className="p-2 bg-yellow-100 rounded-lg">
|
||||
<Star className="w-5 h-5 text-yellow-600" fill="#ca8a04" />
|
||||
</div>
|
||||
<h3 className="text-lg font-bold text-gray-900">Rating Simulator</h3>
|
||||
<span className="ml-auto flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 text-sm font-bold rounded-full">
|
||||
<TrendingUp className="w-4 h-4" />
|
||||
+{potential_gain.toFixed(2)} potential
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Rating Comparisons */}
|
||||
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||
<RatingDisplay
|
||||
rating={current_rating}
|
||||
label="Current"
|
||||
color="#eab308"
|
||||
/>
|
||||
{if_fix_top_1 && (
|
||||
<RatingDisplay
|
||||
rating={if_fix_top_1}
|
||||
label="Fix #1 Issue"
|
||||
color="#22c55e"
|
||||
isProjected
|
||||
/>
|
||||
)}
|
||||
{if_fix_top_3 && (
|
||||
<RatingDisplay
|
||||
rating={if_fix_top_3}
|
||||
label="Fix Top 3"
|
||||
color="#10b981"
|
||||
isProjected
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-xs text-gray-500 mb-1">
|
||||
<span>Progress to 5 Stars</span>
|
||||
<span>{Math.min(100, potentialProgress).toFixed(0)}% achievable</span>
|
||||
</div>
|
||||
<div className="h-3 bg-gray-200 rounded-full overflow-hidden relative">
|
||||
{/* Current rating progress */}
|
||||
<div
|
||||
className="absolute h-full bg-gradient-to-r from-yellow-400 to-yellow-500 transition-all duration-500"
|
||||
style={{ width: `${currentProgress}%` }}
|
||||
/>
|
||||
{/* Potential gain overlay */}
|
||||
<div
|
||||
className="absolute h-full bg-gradient-to-r from-green-400/50 to-green-500/50 transition-all duration-500"
|
||||
style={{
|
||||
left: `${currentProgress}%`,
|
||||
width: `${Math.min(100 - currentProgress, (potential_gain / 5) * 100)}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs mt-1">
|
||||
<span className="text-gray-400">1</span>
|
||||
<span className="text-gray-400">2</span>
|
||||
<span className="text-gray-400">3</span>
|
||||
<span className="text-gray-400">4</span>
|
||||
<span className="text-gray-400">5</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action Items */}
|
||||
{topWeaknesses.length > 0 && (
|
||||
<div className="border-t border-yellow-200 pt-4">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Zap className="w-4 h-4 text-orange-500" />
|
||||
<span className="text-sm font-semibold text-gray-700">Priority Fixes</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{topWeaknesses.slice(0, 3).map((weakness, index) => (
|
||||
<div
|
||||
key={weakness.subcode}
|
||||
className="flex items-center gap-2 p-2 bg-white rounded-lg border border-gray-200"
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold ${
|
||||
index === 0 ? 'bg-red-100 text-red-700' :
|
||||
index === 1 ? 'bg-orange-100 text-orange-700' :
|
||||
'bg-yellow-100 text-yellow-700'
|
||||
}`}>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 truncate block">
|
||||
{weakness.subcode_name}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
{weakness.negative_percentage.toFixed(0)}% negative
|
||||
{weakness.projected_rating_impact && (
|
||||
<span className="ml-2 text-green-600 font-semibold">
|
||||
+{weakness.projected_rating_impact.toFixed(2)} if fixed
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{weakness.solution_complexity && (
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded ${
|
||||
weakness.solution_complexity === 'simple' ? 'bg-green-100 text-green-700' :
|
||||
weakness.solution_complexity === 'medium' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{weakness.solution_complexity}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-4 p-3 bg-green-50 rounded-lg border border-green-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<Award className="w-5 h-5 text-green-600" />
|
||||
<span className="text-sm font-medium text-green-800">
|
||||
Fixing the top 3 issues could boost your rating by{' '}
|
||||
<span className="font-bold">{((if_fix_top_3 || current_rating) - current_rating).toFixed(2)} stars</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
web/components/reviewiq/insights/StrengthsWeaknesses.tsx
Normal file
185
web/components/reviewiq/insights/StrengthsWeaknesses.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import { TrendingUp, TrendingDown, Lightbulb, Target, Megaphone, User } from 'lucide-react';
|
||||
import type { StrengthItem, WeaknessItem } from '../types';
|
||||
import { DOMAIN_COLORS, COMPLEXITY_LABELS } from '../types';
|
||||
import { getSubcodeDefinition } from '@/lib/taxonomy/data';
|
||||
|
||||
interface StrengthsWeaknessesProps {
|
||||
strengths: StrengthItem[];
|
||||
weaknesses: WeaknessItem[];
|
||||
onStrengthClick?: (subcode: string) => void;
|
||||
onWeaknessClick?: (subcode: string) => void;
|
||||
}
|
||||
|
||||
export function StrengthsWeaknesses({
|
||||
strengths,
|
||||
weaknesses,
|
||||
onStrengthClick,
|
||||
onWeaknessClick,
|
||||
}: StrengthsWeaknessesProps) {
|
||||
const hasData = strengths.length > 0 || weaknesses.length > 0;
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="bg-white rounded-lg border border-gray-200 p-6 text-center text-gray-500">
|
||||
<p>Not enough data to identify strengths and weaknesses.</p>
|
||||
<p className="text-sm mt-1">More reviews are needed for analysis.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{/* Strengths Panel */}
|
||||
<div className="bg-white rounded-lg border border-green-200 overflow-hidden">
|
||||
<div className="bg-green-50 px-4 py-3 border-b border-green-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingUp className="w-5 h-5 text-green-600" />
|
||||
<h3 className="font-semibold text-green-800">Your Strengths</h3>
|
||||
</div>
|
||||
<p className="text-xs text-green-600 mt-0.5">Protect & amplify these</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-green-100">
|
||||
{strengths.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
No strong positive patterns detected yet.
|
||||
</div>
|
||||
) : (
|
||||
strengths.map((strength) => (
|
||||
<button
|
||||
key={strength.subcode}
|
||||
onClick={() => onStrengthClick?.(strength.subcode)}
|
||||
className="w-full p-3 text-left hover:bg-green-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">
|
||||
{strength.rank}. {strength.subcode_name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: `${DOMAIN_COLORS[strength.domain]}20`,
|
||||
color: DOMAIN_COLORS[strength.domain],
|
||||
}}
|
||||
>
|
||||
{strength.domain}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic">
|
||||
{getSubcodeDefinition(strength.subcode) || strength.subcode_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{strength.positive_percentage.toFixed(0)}% positive, {strength.span_count} mentions
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-green-600 font-bold text-lg">
|
||||
{strength.positive_percentage.toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{strength.marketing_angle && (
|
||||
<div className="mt-2 flex items-start gap-1.5 text-sm text-green-700 bg-green-50 rounded p-2">
|
||||
<Megaphone className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{strength.marketing_angle}</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Weaknesses Panel */}
|
||||
<div className="bg-white rounded-lg border border-red-200 overflow-hidden">
|
||||
<div className="bg-red-50 px-4 py-3 border-b border-red-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<TrendingDown className="w-5 h-5 text-red-600" />
|
||||
<h3 className="font-semibold text-red-800">Areas to Improve</h3>
|
||||
</div>
|
||||
<p className="text-xs text-red-600 mt-0.5">Fix these to boost rating</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-red-100">
|
||||
{weaknesses.length === 0 ? (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">
|
||||
No significant issues detected. Great job!
|
||||
</div>
|
||||
) : (
|
||||
weaknesses.map((weakness) => (
|
||||
<button
|
||||
key={weakness.subcode}
|
||||
onClick={() => onWeaknessClick?.(weakness.subcode)}
|
||||
className="w-full p-3 text-left hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-gray-900">
|
||||
{weakness.rank}. {weakness.subcode_name}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
backgroundColor: `${DOMAIN_COLORS[weakness.domain]}20`,
|
||||
color: DOMAIN_COLORS[weakness.domain],
|
||||
}}
|
||||
>
|
||||
{weakness.domain}
|
||||
</span>
|
||||
{weakness.intensity === 'I3' && (
|
||||
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">
|
||||
High Intensity
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 italic">
|
||||
{getSubcodeDefinition(weakness.subcode) || weakness.subcode_name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{weakness.negative_percentage.toFixed(0)}% negative, {weakness.span_count} mentions
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-red-600 font-bold text-lg">
|
||||
{weakness.negative_percentage.toFixed(0)}%
|
||||
</div>
|
||||
{weakness.projected_rating_impact && (
|
||||
<div className="text-xs text-green-600">
|
||||
+{weakness.projected_rating_impact.toFixed(2)} if fixed
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{weakness.solution && (
|
||||
<div className="mt-2 flex items-start gap-1.5 text-sm text-amber-700 bg-amber-50 rounded p-2">
|
||||
<Lightbulb className="w-4 h-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{weakness.solution}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{weakness.owner && (
|
||||
<div className="mt-1.5 flex items-center gap-1 text-xs text-gray-500">
|
||||
<User className="w-3 h-3" />
|
||||
<span>Owner: {weakness.owner}</span>
|
||||
{weakness.solution_complexity && (
|
||||
<>
|
||||
<span className="mx-1">|</span>
|
||||
<Target className="w-3 h-3" />
|
||||
<span>{COMPLEXITY_LABELS[weakness.solution_complexity] || weakness.solution_complexity}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
web/components/reviewiq/insights/index.ts
Normal file
4
web/components/reviewiq/insights/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { ExecutiveSummary } from './ExecutiveSummary';
|
||||
export { StrengthsWeaknesses } from './StrengthsWeaknesses';
|
||||
export { OpportunityMatrix } from './OpportunityMatrix';
|
||||
export { RatingSimulator } from './RatingSimulator';
|
||||
Reference in New Issue
Block a user