@@ -11,7 +11,7 @@ import { readFileSync } from "node:fs";
1111import { mkdir , writeFile } from "node:fs/promises" ;
1212import { basename , dirname , resolve } from "node:path" ;
1313import 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" ;
1515import { type ParsedArgs , getBool , getInt , getString , parseArgs } from "./args.ts" ;
1616import {
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+
18892051export 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