feat: automatic recommendations + manual evaluation in redundancy analysis
This commit is contained in:
@@ -1,10 +1,67 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db, toolsTable, ratingsTable, toolCostsTable } from "@workspace/db";
|
||||
import { eq, and, sql } from "drizzle-orm";
|
||||
import { db, toolsTable, ratingsTable, toolCostsTable, toolRelationsTable } from "@workspace/db";
|
||||
import { requireAuth } from "../middleware/auth";
|
||||
import { writeAuditLog } from "../lib/audit";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
function buildRecommendation(a: any, b: any): { betterToolId: number; betterName: string; reason: string; certainty: "high" | "medium" | "low"; reasons: string[] } {
|
||||
const reasons: string[] = [];
|
||||
let aScore = 0;
|
||||
let bScore = 0;
|
||||
|
||||
const aRating = a.avgCombined ?? 0;
|
||||
const bRating = b.avgCombined ?? 0;
|
||||
if (aRating > bRating && aRating > 0) {
|
||||
reasons.push(`higher rating (${aRating.toFixed(1)} vs ${bRating.toFixed(1)})`);
|
||||
aScore += 3;
|
||||
} else if (bRating > aRating && bRating > 0) {
|
||||
reasons.push(`higher rating (${bRating.toFixed(1)} vs ${aRating.toFixed(1)})`);
|
||||
bScore += 3;
|
||||
}
|
||||
|
||||
const aCost = a.totalMonthly ?? 0;
|
||||
const bCost = b.totalMonthly ?? 0;
|
||||
if (aCost < bCost && bCost > 0) {
|
||||
reasons.push(`lower cost (${aCost.toFixed(2)}/mo vs ${bCost.toFixed(2)}/mo)`);
|
||||
aScore += 2;
|
||||
} else if (bCost < aCost && aCost > 0) {
|
||||
reasons.push(`lower cost (${bCost.toFixed(2)}/mo vs ${aCost.toFixed(2)}/mo)`);
|
||||
bScore += 2;
|
||||
} else if (aCost === 0 && bCost > 0) {
|
||||
reasons.push("free");
|
||||
aScore += 1;
|
||||
} else if (bCost === 0 && aCost > 0) {
|
||||
reasons.push("free");
|
||||
bScore += 1;
|
||||
}
|
||||
|
||||
const aFeatCount = a.features?.length ?? 0;
|
||||
const bFeatCount = b.features?.length ?? 0;
|
||||
if (aFeatCount > bFeatCount && aFeatCount > 0) {
|
||||
reasons.push(`more features (${aFeatCount} vs ${bFeatCount})`);
|
||||
aScore += 1;
|
||||
} else if (bFeatCount > aFeatCount && bFeatCount > 0) {
|
||||
reasons.push(`more features (${bFeatCount} vs ${aFeatCount})`);
|
||||
bScore += 1;
|
||||
}
|
||||
|
||||
const better = aScore >= bScore ? a : b;
|
||||
let certainty: "high" | "medium" | "low" = "low";
|
||||
const diff = Math.abs(aScore - bScore);
|
||||
if (diff >= 4) certainty = "high";
|
||||
else if (diff >= 2) certainty = "medium";
|
||||
|
||||
return {
|
||||
betterToolId: better.id,
|
||||
betterName: better.name,
|
||||
reason: reasons.length > 0 ? reasons.join(", ") : "insufficient data",
|
||||
certainty,
|
||||
reasons,
|
||||
};
|
||||
}
|
||||
|
||||
router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> => {
|
||||
const tools = await db.select().from(toolsTable).orderBy(toolsTable.category, toolsTable.name);
|
||||
|
||||
@@ -16,6 +73,11 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
||||
.select()
|
||||
.from(toolCostsTable);
|
||||
|
||||
const manualEvals = await db
|
||||
.select()
|
||||
.from(toolRelationsTable)
|
||||
.where(eq(toolRelationsTable.relationType, "recommended"));
|
||||
|
||||
const ratingsByTool = new Map<number, { usefulness: number; usability: number }[]>();
|
||||
for (const r of allRatings) {
|
||||
const arr = ratingsByTool.get(r.toolId) ?? [];
|
||||
@@ -30,6 +92,12 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
||||
costsByTool.set(c.toolId, arr);
|
||||
}
|
||||
|
||||
const manualEvalMap = new Map<string, number>();
|
||||
for (const e of manualEvals) {
|
||||
const key = [Math.min(e.toolId, e.relatedToolId), Math.max(e.toolId, e.relatedToolId)].join(":");
|
||||
manualEvalMap.set(key, e.toolId);
|
||||
}
|
||||
|
||||
function buildStats(t: typeof toolsTable.$inferSelect) {
|
||||
const ratings = ratingsByTool.get(t.id) ?? [];
|
||||
const count = ratings.length;
|
||||
@@ -42,10 +110,11 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
||||
if (c.licenseType === "one_time") return sum;
|
||||
if (c.billingPeriod === "yearly") return sum + amount / 12;
|
||||
if (c.billingPeriod === "quarterly") return sum + amount / 3;
|
||||
return sum + amount; // monthly or subscription default
|
||||
return sum + amount;
|
||||
}, 0);
|
||||
return {
|
||||
id: t.id, name: t.name, category: t.category, features: t.features ?? [], tags: t.tags ?? [],
|
||||
description: t.description,
|
||||
ratingCount: count,
|
||||
avgUsefulness: avgU, avgUsability: avgUs,
|
||||
avgCombined: avgU != null && avgUs != null ? (avgU + avgUs) / 2 : null,
|
||||
@@ -62,7 +131,7 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
||||
}
|
||||
|
||||
const result = Object.entries(grouped).map(([category, items]) => {
|
||||
const pairs: { a: any; b: any; overlap: number; scoreDiff: number; costDiff: number }[] = [];
|
||||
const pairs: any[] = [];
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
for (let j = i + 1; j < items.length; j++) {
|
||||
const aFeat = new Set(items[i].features);
|
||||
@@ -72,15 +141,56 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise<void> =>
|
||||
const overlap = total > 0 ? Math.round((shared / total) * 100) : 0;
|
||||
const scoreDiff = (items[j].avgCombined ?? 0) - (items[i].avgCombined ?? 0);
|
||||
const costDiff = (items[j].totalMonthly ?? 0) - (items[i].totalMonthly ?? 0);
|
||||
pairs.push({ a: items[i], b: items[j], overlap, scoreDiff, costDiff });
|
||||
|
||||
const key = [items[i].id, items[j].id].sort().join(":");
|
||||
const manualBetterId = manualEvalMap.get(key) ?? null;
|
||||
const recommendation = buildRecommendation(items[i], items[j]);
|
||||
|
||||
pairs.push({ a: items[i], b: items[j], overlap, scoreDiff, costDiff, recommendation, manualBetterId });
|
||||
}
|
||||
}
|
||||
pairs.sort((a, b) => b.overlap - a.overlap || Math.abs(b.scoreDiff) - Math.abs(a.scoreDiff));
|
||||
const totalMonthlyCost = items.reduce((s, t) => s + (t.totalMonthly ?? 0), 0);
|
||||
return { category, tools: items, pairs: pairs.slice(0, 5), totalMonthlyCost };
|
||||
return { category, tools: items, pairs: pairs.slice(0, 10), totalMonthlyCost };
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post("/admin/redundancy/evaluate", requireAuth, async (req, res): Promise<void> => {
|
||||
const { toolId, relatedToolId, betterToolId, notes } = req.body;
|
||||
if (!toolId || !relatedToolId || !betterToolId) {
|
||||
res.status(400).json({ error: "toolId, relatedToolId, and betterToolId are required" });
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(toolRelationsTable)
|
||||
.where(and(
|
||||
eq(toolRelationsTable.toolId, Math.min(toolId, relatedToolId)),
|
||||
eq(toolRelationsTable.relatedToolId, Math.max(toolId, relatedToolId)),
|
||||
eq(toolRelationsTable.relationType, "recommended"),
|
||||
))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
await db
|
||||
.update(toolRelationsTable)
|
||||
.set({ notes: notes ?? `Recommended: ${betterToolId}`, createdBy: Number(req.session.user!.sub) })
|
||||
.where(eq(toolRelationsTable.id, existing[0].id));
|
||||
} else {
|
||||
await db.insert(toolRelationsTable).values({
|
||||
toolId: Math.min(toolId, relatedToolId),
|
||||
relatedToolId: Math.max(toolId, relatedToolId),
|
||||
relationType: "recommended",
|
||||
notes: notes ?? `Recommended: ${betterToolId}`,
|
||||
createdBy: Number(req.session.user!.sub),
|
||||
});
|
||||
}
|
||||
|
||||
await writeAuditLog(req, "redundancy_evaluation", 0, "evaluate", { toolId, relatedToolId, betterToolId });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Layout } from "@/components/layout";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { Star, AlertTriangle, DollarSign } from "lucide-react";
|
||||
import { Star, AlertTriangle, DollarSign, ThumbsUp, RotateCcw } from "lucide-react";
|
||||
import { Link } from "wouter";
|
||||
import { customFetch } from "@workspace/api-client-react";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
export default function RedundancyPage() {
|
||||
const [data, setData] = useState<any[] | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
customFetch<any[]>("/api/admin/redundancy")
|
||||
@@ -19,27 +22,47 @@ export default function RedundancyPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
async function handleEvaluate(toolId: number, relatedToolId: number, betterToolId: number) {
|
||||
try {
|
||||
await customFetch("/api/admin/redundancy/evaluate", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ toolId, relatedToolId, betterToolId }),
|
||||
});
|
||||
toast({ title: "Evaluation saved" });
|
||||
setData(await customFetch<any[]>("/api/admin/redundancy"));
|
||||
} catch {
|
||||
toast({ title: "Failed to save evaluation", variant: "destructive" });
|
||||
}
|
||||
}
|
||||
|
||||
function certaintyColor(c: string) {
|
||||
return c === "high" ? "bg-green-100 text-green-800 border-green-300" : c === "medium" ? "bg-amber-100 text-amber-800 border-amber-300" : "bg-gray-100 text-gray-600 border-gray-300";
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="w-6 h-6 text-amber-500" />
|
||||
<h1 className="text-3xl font-bold">Redundancy Dashboard</h1>
|
||||
<h1 className="text-3xl font-bold">Tool Analysis & Recommendations</h1>
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Tools grouped by category with feature overlap analysis.
|
||||
Automatic redundancy detection with cost and rating comparison. Admin can manually confirm which tool is the better choice.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="space-y-6">
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-40 rounded-xl" />)}
|
||||
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-48 rounded-xl" />)}
|
||||
</div>
|
||||
) : data && data.length > 0 ? (
|
||||
<div className="space-y-8">
|
||||
{data.map((group) => (
|
||||
<div key={group.category}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold capitalize">{group.category}</h2>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{group.tools.length} tools, {group.pairs.length} comparisons</p>
|
||||
</div>
|
||||
{group.totalMonthlyCost > 0 && (
|
||||
<span className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
@@ -47,14 +70,22 @@ export default function RedundancyPage() {
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-6">
|
||||
{group.tools.map((tool: any) => (
|
||||
<Link key={tool.id} href={`/tools/${tool.id}`}>
|
||||
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50">
|
||||
<Card className="hover-elevate transition-all cursor-pointer hover:border-primary/50 h-full">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-start gap-2 justify-between">
|
||||
<div className="min-w-0">
|
||||
<span className="font-medium">{tool.name}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{tool.name}</span>
|
||||
{tool.costs?.length > 0 && tool.totalMonthly > 0 && (
|
||||
<Badge variant="outline" className="text-[10px] px-1 py-0 shrink-0">
|
||||
{tool.totalMonthly.toFixed(2)}/mo
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground flex-wrap">
|
||||
<span>{tool.ratingCount} reviews</span>
|
||||
{tool.avgCombined != null && (
|
||||
@@ -66,25 +97,12 @@ export default function RedundancyPage() {
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{tool.totalMonthly > 0 && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-0.5 text-amber-600">
|
||||
<DollarSign className="w-3 h-3" />
|
||||
{tool.totalMonthly.toFixed(2)}/mo
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{tool.costs?.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{tool.costs.map((c: any, i: number) => (
|
||||
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
|
||||
{c.licenseType}{c.billingPeriod ? ` (${c.billingPeriod})` : ""}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{tool.costs?.map((c: any, i: number) => (
|
||||
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">{c.licenseType}{c.billingPeriod ? ` (${c.billingPeriod})` : ""}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="outline" className="shrink-0">{tool.features.length} features</Badge>
|
||||
</div>
|
||||
@@ -95,34 +113,72 @@ export default function RedundancyPage() {
|
||||
</div>
|
||||
|
||||
{group.pairs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Overlap Analysis</h3>
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">Comparisons & Recommendations</h3>
|
||||
{group.pairs.map((pair: any, i: number) => (
|
||||
<Card key={i} className="border-dashed">
|
||||
<CardContent className="p-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<span className="font-medium text-sm truncate">{pair.a.name}</span>
|
||||
<span className="text-muted-foreground text-xs shrink-0">vs</span>
|
||||
<span className="font-medium text-sm truncate">{pair.b.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Progress value={pair.overlap} className="w-16 h-2" />
|
||||
<span className="text-xs text-muted-foreground w-8">{pair.overlap}%</span>
|
||||
<Card key={i} className={pair.recommendation.certainty === "high" ? "border-green-300" : pair.recommendation.certainty === "medium" ? "border-amber-200" : ""}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<div className="text-right min-w-0 flex-1">
|
||||
<span className={`font-medium text-sm block truncate ${pair.recommendation.betterToolId === pair.a.id ? "text-primary" : ""}`}>
|
||||
{pair.a.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"} ★
|
||||
{pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}/mo` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-center shrink-0">
|
||||
<div className="text-xs text-muted-foreground font-medium">vs</div>
|
||||
<div className="flex items-center gap-1 justify-center mt-0.5">
|
||||
<Progress value={pair.overlap} className="w-12 h-1.5" />
|
||||
<span className="text-[10px] text-muted-foreground">{pair.overlap}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className={`font-medium text-sm block truncate ${pair.recommendation.betterToolId === pair.b.id ? "text-primary" : ""}`}>
|
||||
{pair.b.name}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"} ★
|
||||
{pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}/mo` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 justify-end">
|
||||
{pair.recommendation.reason && (
|
||||
<Badge variant="outline" className={`text-[10px] px-1.5 py-0 ${certaintyColor(pair.recommendation.certainty)}`}>
|
||||
<ThumbsUp className="w-2.5 h-2.5 mr-0.5" />
|
||||
{pair.recommendation.betterName}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={pair.manualBetterId === pair.a.id ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2"
|
||||
onClick={() => handleEvaluate(pair.a.id, pair.b.id, pair.a.id)}
|
||||
>
|
||||
{pair.a.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant={pair.manualBetterId === pair.b.id ? "default" : "ghost"}
|
||||
size="sm"
|
||||
className="text-xs h-7 px-2"
|
||||
onClick={() => handleEvaluate(pair.a.id, pair.b.id, pair.b.id)}
|
||||
>
|
||||
{pair.b.name}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{pair.scoreDiff !== 0 && (
|
||||
<Badge variant={pair.scoreDiff > 0 ? "default" : "secondary"} className="text-[10px]">
|
||||
{pair.scoreDiff > 0 ? `${pair.b.name} +${pair.scoreDiff.toFixed(1)}` : `${pair.a.name} +${Math.abs(pair.scoreDiff).toFixed(1)}`}
|
||||
</Badge>
|
||||
)}
|
||||
{pair.costDiff !== 0 && (
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{pair.costDiff > 0
|
||||
? `${pair.b.name} +${pair.costDiff.toFixed(2)}/mo`
|
||||
: `${pair.a.name} +${Math.abs(pair.costDiff).toFixed(2)}/mo`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{pair.recommendation.reason && (
|
||||
<p className="text-[10px] text-muted-foreground mt-2 italic">
|
||||
{pair.recommendation.reason}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user