Skip to content

Commit fa89128

Browse files
masonwyatt23claude
andcommitted
feat: Multi-Provider Image Clustering & Deduplication Report Engine
- Add packages/core/src/deduplication-report.ts with generateDeduplicationReport() and exportClusteringMetrics() — semantic + pHash clustering, per-cluster FP/FN risk assessment, composite confidence, recommended threshold, provider diversity - Export new module from packages/core/src/index.ts - Add MCP tool analyze_deduplication_quality to packages/mcp/src/tools.ts - Add CLI command dedupe-report to packages/cli/src/commands.ts - Add 40-test suite tests/deduplication-report.test.ts (all green) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c4aac0c commit fa89128

5 files changed

Lines changed: 1797 additions & 2 deletions

File tree

packages/cli/src/commands.ts

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { readFileSync } from "node:fs";
1111
import { mkdir, writeFile } from "node:fs/promises";
1212
import { basename, dirname, resolve } from "node:path";
1313
import type { FederationRepairPlan, ImageCandidate, PhashDiagnosticsResult, ProviderId, SearchOptions, SearchResultBundle, ExportFormat, ProviderSelectionMode, BatchReverseImageOutput, BatchConflictAuditResult, BatchConflictResolutionResult } from "webfetch-core";
14-
import { analyzePhashQuality, auditLicenseConflictBatch, batchReverseImageSearch, getCacheAnalyticsSnapshot, getFederationRepairPlan, providerRegistry, exportImageMetadata, loadPluginFromPath, listPluginProviders, reconcileLicenseConflictsBatch } from "webfetch-core";
14+
import { analyzePhashQuality, auditLicenseConflictBatch, batchReverseImageSearch, getCacheAnalyticsSnapshot, getFederationRepairPlan, providerRegistry, exportImageMetadata, loadPluginFromPath, listPluginProviders, reconcileLicenseConflictsBatch, generateDeduplicationReport, exportClusteringMetrics } from "webfetch-core";
1515
import { type ParsedArgs, getBool, getInt, getString, parseArgs } from "./args.ts";
1616
import {
1717
BUILTIN_DEFAULTS,
@@ -1886,6 +1886,168 @@ export async function cmdResolveLicenseConflicts(
18861886
return result.legalReviewNeeded ? 1 : 0;
18871887
}
18881888

1889+
// ---------- `webfetch dedupe-report` -----------------------------------------
1890+
1891+
/**
1892+
* `webfetch dedupe-report <query> [flags]`
1893+
*
1894+
* Runs a federated search for <query>, then generates a full deduplication
1895+
* quality report: per-cluster false-positive / false-negative risk, composite
1896+
* confidence, provider diversity, and a recommended pHash threshold.
1897+
*
1898+
* Flags:
1899+
* --phash-threshold N Hamming distance threshold for clustering (default 8)
1900+
* --semantic-weight N Weight 0..1 for metadata similarity vs pHash (default 0.3)
1901+
* --confidence-floor N Min composite confidence before a cluster needs review (default 0.5)
1902+
* --export csv|json Export clustering metrics in the given format (default: none)
1903+
* --export-path PATH Write the export to a file instead of stdout
1904+
* --json Emit raw JSON DeduplicationReport
1905+
* --verbose Print provider reports to stderr
1906+
*/
1907+
export async function cmdDedupeReport(
1908+
args: ParsedArgs,
1909+
io: CommandIO = DEFAULT_IO,
1910+
): Promise<number> {
1911+
const env = io.env ?? process.env;
1912+
const query = args.positional.join(" ").trim();
1913+
if (!query) {
1914+
io.stderr(c.red("usage: webfetch dedupe-report <query> [--phash-threshold N] [--export csv|json]"));
1915+
return 2;
1916+
}
1917+
1918+
const cfg = await resolveCliConfig(args, env);
1919+
const { opts, verbose, json } = buildSearchOptions(args, env, cfg);
1920+
1921+
const phashThresholdRaw = getInt(args.flags, "phash-threshold");
1922+
const phashThreshold = phashThresholdRaw !== undefined
1923+
? Math.max(1, Math.min(32, phashThresholdRaw))
1924+
: 8;
1925+
1926+
const semanticWeightRaw = getString(args.flags, "semantic-weight");
1927+
const semanticWeight = semanticWeightRaw !== undefined
1928+
? Math.max(0, Math.min(1, parseFloat(semanticWeightRaw)))
1929+
: 0.3;
1930+
1931+
const confidenceFloorRaw = getString(args.flags, "confidence-floor");
1932+
const confidenceFloor = confidenceFloorRaw !== undefined
1933+
? Math.max(0, Math.min(1, parseFloat(confidenceFloorRaw)))
1934+
: 0.5;
1935+
1936+
const exportFmt = getString(args.flags, "export") as "csv" | "json" | undefined;
1937+
const exportPath = getString(args.flags, "export-path");
1938+
1939+
const bundle: SearchResultBundle = wantsCloud(args, env)
1940+
? await cloudRequest<SearchResultBundle>(cfg, "/search", { body: searchBody(query, opts) })
1941+
: await core().searchImages(query, opts);
1942+
1943+
if (verbose) {
1944+
for (const w of bundle.warnings) io.stderr(c.yellow(`warning: ${w}`));
1945+
for (const r of bundle.providerReports) {
1946+
const detail = r.ok
1947+
? c.dim(`${r.count} results in ${r.timeMs}ms`)
1948+
: c.dim(r.skipped ?? r.error ?? "failed");
1949+
io.stderr(c.dim(` ${r.provider}: `) + detail);
1950+
}
1951+
io.stderr("");
1952+
}
1953+
1954+
const report = generateDeduplicationReport(bundle.candidates, {
1955+
phashThreshold,
1956+
semanticWeight,
1957+
confidenceFloor,
1958+
});
1959+
1960+
if (json) {
1961+
io.stdout(JSON.stringify(report, null, 2));
1962+
return 0;
1963+
}
1964+
1965+
// Human-readable output.
1966+
io.stdout(c.bold(`Deduplication Quality Report`));
1967+
io.stdout(
1968+
c.dim(
1969+
`Query: "${query}" | Candidates: ${report.totalCandidates} | ` +
1970+
`Clusters: ${report.totalClusters} | dedupeRate: ${(report.dedupeRate * 100).toFixed(1)}%`,
1971+
),
1972+
);
1973+
io.stdout(c.dim(`Threshold: ${report.options.phashThreshold} | Recommended: ${report.recommendedThreshold} | Generated: ${report.generatedAt}`));
1974+
io.stdout("");
1975+
1976+
// Overall risk summary.
1977+
const fpColor = report.falsePositiveRisk === "high" ? c.red : report.falsePositiveRisk === "medium" ? c.yellow : c.green;
1978+
const fnColor = report.falseNegativeRisk === "high" ? c.red : report.falseNegativeRisk === "medium" ? c.yellow : c.green;
1979+
io.stdout(
1980+
`Overall risk — FP: ${fpColor(report.falsePositiveRisk)} FN: ${fnColor(report.falseNegativeRisk)} ` +
1981+
`Provider diversity: ${report.providerDiversity.toFixed(2)}`,
1982+
);
1983+
io.stdout("");
1984+
1985+
// Multi-candidate cluster table.
1986+
if (report.multiCandidateClusters.length > 0) {
1987+
io.stdout(c.bold(`Multi-candidate clusters (${report.multiCandidateClusters.length}):`));
1988+
const cols = [
1989+
{ header: "id", width: 4 },
1990+
{ header: "size", width: 5 },
1991+
{ header: "conf", width: 6 },
1992+
{ header: "FP", width: 7 },
1993+
{ header: "FN", width: 7 },
1994+
{ header: "action", width: 8 },
1995+
{ header: "providers", width: 10 },
1996+
{ header: "centroid", width: 55 },
1997+
];
1998+
const rows = report.multiCandidateClusters.map((cl) => {
1999+
const fpC = cl.falsePositiveRisk === "high" ? c.red : cl.falsePositiveRisk === "medium" ? c.yellow : c.green;
2000+
const fnC = cl.falseNegativeRisk === "high" ? c.red : cl.falseNegativeRisk === "medium" ? c.yellow : c.green;
2001+
const actC = cl.recommendation === "accept" ? c.green : cl.recommendation === "review" ? c.yellow : c.red;
2002+
return [
2003+
cl.clusterId,
2004+
String(cl.size),
2005+
cl.compositeConfidence.toFixed(2),
2006+
fpC(cl.falsePositiveRisk),
2007+
fnC(cl.falseNegativeRisk),
2008+
actC(cl.recommendation),
2009+
String(cl.providerDiversity),
2010+
cl.centroid.url.slice(0, 54),
2011+
];
2012+
});
2013+
io.stdout(renderTable(cols, rows));
2014+
io.stdout("");
2015+
} else {
2016+
io.stdout(c.dim("No multi-candidate clusters found — all candidates are unique."));
2017+
io.stdout("");
2018+
}
2019+
2020+
// Threshold recommendation.
2021+
if (report.recommendedThreshold !== report.options.phashThreshold) {
2022+
const direction = report.recommendedThreshold < report.options.phashThreshold ? "stricter" : "more permissive";
2023+
io.stdout(
2024+
c.yellow(
2025+
`Threshold recommendation: change from ${report.options.phashThreshold}${report.recommendedThreshold} ` +
2026+
`(${direction}) based on pairwise distance distribution.`,
2027+
),
2028+
);
2029+
io.stdout(c.dim(` Re-run with: webfetch dedupe-report "${query}" --phash-threshold ${report.recommendedThreshold}`));
2030+
io.stdout("");
2031+
}
2032+
2033+
// Export metrics if requested.
2034+
if (exportFmt) {
2035+
const exported = exportClusteringMetrics(report, exportFmt);
2036+
if (exportPath) {
2037+
const { writeFile, mkdir } = await import("node:fs/promises");
2038+
const { dirname } = await import("node:path");
2039+
await mkdir(dirname(resolve(exportPath)), { recursive: true });
2040+
await writeFile(resolve(exportPath), exported.content, "utf8");
2041+
io.stdout(c.green(`Exported ${exported.rowCount} row(s) (${exported.format}) → ${resolve(exportPath)}`));
2042+
} else {
2043+
io.stdout(c.bold(`Metrics export (${exported.format}, ${exported.rowCount} rows):`));
2044+
io.stdout(exported.content);
2045+
}
2046+
}
2047+
2048+
return 0;
2049+
}
2050+
18892051
export function cmdHelp(_args: ParsedArgs, io: CommandIO = DEFAULT_IO): number {
18902052
io.stdout(USAGE);
18912053
return 0;
@@ -1929,6 +2091,7 @@ ${c.bold("COMMANDS")}
19292091
plugin <list|add|test> Manage runtime provider plugins (third-party image sources)
19302092
audit-license-conflicts <query> Audit all license conflicts from a federation run [--json] [--severity major]
19312093
resolve-license-conflicts <query> Recommend license upgrades per provider [--json] [--suggest-upgrades]
2094+
dedupe-report <query> Cluster dedup quality report: FP/FN risk, confidence, threshold recommendation
19322095
help Show this message
19332096
version Print version
19342097
@@ -1986,6 +2149,7 @@ export const COMMANDS: Record<string, Dispatcher> = {
19862149
plugin: cmdPlugin,
19872150
"audit-license-conflicts": cmdAuditLicenseConflicts,
19882151
"resolve-license-conflicts": cmdResolveLicenseConflicts,
2152+
"dedupe-report": cmdDedupeReport,
19892153
help: cmdHelp,
19902154
"--help": cmdHelp,
19912155
"-h": cmdHelp,

0 commit comments

Comments
 (0)