From 857d2e11a814658a1d5ba53394d9bd464d6acb5c Mon Sep 17 00:00:00 2001 From: root Date: Wed, 29 Jul 2026 23:51:28 +0200 Subject: [PATCH] feat: automatic recommendations + manual evaluation in redundancy analysis --- artifacts/api-server/src/routes/admin.ts | 122 ++++++++++++++- artifacts/toolrate/src/pages/redundancy.tsx | 162 +++++++++++++------- lib/db/src/schema/tool-relations.ts | 2 +- 3 files changed, 226 insertions(+), 60 deletions(-) diff --git a/artifacts/api-server/src/routes/admin.ts b/artifacts/api-server/src/routes/admin.ts index c9fcf25..d0aa226 100644 --- a/artifacts/api-server/src/routes/admin.ts +++ b/artifacts/api-server/src/routes/admin.ts @@ -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 => { 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 => .select() .from(toolCostsTable); + const manualEvals = await db + .select() + .from(toolRelationsTable) + .where(eq(toolRelationsTable.relationType, "recommended")); + const ratingsByTool = new Map(); for (const r of allRatings) { const arr = ratingsByTool.get(r.toolId) ?? []; @@ -30,6 +92,12 @@ router.get("/admin/redundancy", requireAuth, async (_req, res): Promise => costsByTool.set(c.toolId, arr); } + const manualEvalMap = new Map(); + 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 => 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 => } 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 => 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 => { + 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; diff --git a/artifacts/toolrate/src/pages/redundancy.tsx b/artifacts/toolrate/src/pages/redundancy.tsx index 53f2922..5beb3ef 100644 --- a/artifacts/toolrate/src/pages/redundancy.tsx +++ b/artifacts/toolrate/src/pages/redundancy.tsx @@ -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(null); const [loading, setLoading] = useState(true); + const { toast } = useToast(); useEffect(() => { customFetch("/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("/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 (
-

Redundancy Dashboard

+

Tool Analysis & Recommendations

- 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.

{loading ? (
- {Array.from({ length: 3 }).map((_, i) => )} + {Array.from({ length: 3 }).map((_, i) => )}
) : data && data.length > 0 ? (
{data.map((group) => (
-

{group.category}

+
+

{group.category}

+

{group.tools.length} tools, {group.pairs.length} comparisons

+
{group.totalMonthlyCost > 0 && ( @@ -47,14 +70,22 @@ export default function RedundancyPage() { )}
-
+ +
{group.tools.map((tool: any) => ( - +
-
- {tool.name} +
+
+ {tool.name} + {tool.costs?.length > 0 && tool.totalMonthly > 0 && ( + + {tool.totalMonthly.toFixed(2)}/mo + + )} +
{tool.ratingCount} reviews {tool.avgCombined != null && ( @@ -66,25 +97,12 @@ export default function RedundancyPage() { )} - {tool.totalMonthly > 0 && ( - <> - · - - - {tool.totalMonthly.toFixed(2)}/mo - - - )}
- {tool.costs?.length > 0 && ( -
- {tool.costs.map((c: any, i: number) => ( - - {c.licenseType}{c.billingPeriod ? ` (${c.billingPeriod})` : ""} - - ))} -
- )} +
+ {tool.costs?.map((c: any, i: number) => ( + {c.licenseType}{c.billingPeriod ? ` (${c.billingPeriod})` : ""} + ))} +
{tool.features.length} features
@@ -95,34 +113,72 @@ export default function RedundancyPage() {
{group.pairs.length > 0 && ( -
-

Overlap Analysis

+
+

Comparisons & Recommendations

{group.pairs.map((pair: any, i: number) => ( - - -
- {pair.a.name} - vs - {pair.b.name} -
-
-
- - {pair.overlap}% + + +
+
+
+ + {pair.a.name} + + + {pair.a.avgCombined != null ? pair.a.avgCombined.toFixed(1) : "N/A"} ★ + {pair.a.totalMonthly > 0 ? ` · ${pair.a.totalMonthly.toFixed(2)}/mo` : ""} + +
+
+
vs
+
+ + {pair.overlap}% +
+
+
+ + {pair.b.name} + + + {pair.b.avgCombined != null ? pair.b.avgCombined.toFixed(1) : "N/A"} ★ + {pair.b.totalMonthly > 0 ? ` · ${pair.b.totalMonthly.toFixed(2)}/mo` : ""} + +
+
+ +
+ {pair.recommendation.reason && ( + + + {pair.recommendation.betterName} + + )} +
+ + +
- {pair.scoreDiff !== 0 && ( - 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)}`} - - )} - {pair.costDiff !== 0 && ( - - {pair.costDiff > 0 - ? `${pair.b.name} +${pair.costDiff.toFixed(2)}/mo` - : `${pair.a.name} +${Math.abs(pair.costDiff).toFixed(2)}/mo`} - - )}
+ {pair.recommendation.reason && ( +

+ {pair.recommendation.reason} +

+ )}
))} diff --git a/lib/db/src/schema/tool-relations.ts b/lib/db/src/schema/tool-relations.ts index 7e07b63..9bc37d8 100644 --- a/lib/db/src/schema/tool-relations.ts +++ b/lib/db/src/schema/tool-relations.ts @@ -6,7 +6,7 @@ export const toolRelationsTable = pgTable("tool_relations", { id: serial("id").primaryKey(), toolId: integer("tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }), relatedToolId: integer("related_tool_id").notNull().references(() => toolsTable.id, { onDelete: "cascade" }), - relationType: text("relation_type", { enum: ["similar", "replaces", "superseded_by"] }).notNull().default("similar"), + relationType: text("relation_type", { enum: ["similar", "replaces", "superseded_by", "recommended"] }).notNull().default("similar"), notes: text("notes"), createdBy: integer("created_by").references(() => usersTable.id), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),