Image rights / copyright detection system: SQLite store, HTTP app, search integrations (Naver, Google Custom Search, Google Cloud Vision web detection), image analysis (fingerprints, face/person detection, evidence enrichment, risk scoring), an admin/review layer, governance and retention policies, batch jobs, and a browser-based operator GUI. This baseline incorporates a full code-review remediation pass (46 fixes; 358 tests passing). Highlights: CRITICAL - Prevent evidence cascade-delete during the schema-constraint migration by disabling FK enforcement around the table rebuild. Security - Sandbox served media (neutralize stored XSS from uploaded/collected SVGs) via CSP + nosniff on the untrusted media routes. - Strip embedded EXIF/GPS from external image derivatives before they are sent to third-party APIs. - Return a clean 404 (not an uncaught StopIteration) for PATCH on an unknown provider. Correctness - LLM-summary failures no longer add +30 to the risk score. - Decode only explicit JS escapes so Korean image URLs are not mangled. - Consume search quota only after a successful request. - Naver/Google adapters map responses inside the failure boundary, so a malformed response degrades to evidence instead of crashing enrichment. - Domain-aware provider attribution; face-box IoU de-duplication; count searches (not result items); per-box crop isolation; clamp evidence confidence and Google CSE num; real submittedEpoch; and more. Robustness - Offline LLM connect fast-fails (short connect timeout) so seed/reload requests are not stalled; full read timeout preserved for generation. - Malformed numeric env vars fall back to defaults instead of crashing startup. Performance - Per-submission evidence reads (no full-table scan per rescore), audit-log LIMIT, lazy active-store lookup, hoisted timestamps. Tests - ~24 regression tests added pinning the above fixes. Runtime data (data/, outputs/, *.sqlite3, *.log), secrets (.env), and node_modules are gitignored.
81 lines
3.3 KiB
JavaScript
81 lines
3.3 KiB
JavaScript
(function attachEvidenceGuidance(global) {
|
|
function searchableEvidenceItems(submission) {
|
|
return (submission?.evidence || []).filter((item) => ["naver", "google", "llm", "failure"].includes(item.source));
|
|
}
|
|
|
|
function directEvidenceItems(submission) {
|
|
return (submission?.evidence || []).filter((item) => ["full", "partial", "page"].includes(item.matchType) || item.contributed);
|
|
}
|
|
|
|
function realSearchableEvidenceItems(submission) {
|
|
return searchableEvidenceItems(submission).filter((item) => item.source !== "failure");
|
|
}
|
|
|
|
function externalProviderStates(submission) {
|
|
// The backend always injects internal:"ok", so it must be excluded — otherwise
|
|
// every submission looks like it has had an external search attempted.
|
|
return Object.entries(submission?.providerState || {})
|
|
.filter(([provider]) => provider !== "internal")
|
|
.map(([, status]) => status);
|
|
}
|
|
|
|
function hasSearchAttempt(submission) {
|
|
const providerStates = externalProviderStates(submission);
|
|
return Boolean(
|
|
submission?.queryHistory?.length ||
|
|
searchableEvidenceItems(submission).length ||
|
|
providerStates.some((status) => ["ok", "covered", "empty", "failed"].includes(status)),
|
|
);
|
|
}
|
|
|
|
function evidenceNeedsFollowup(submission) {
|
|
if (!submission || !hasSearchAttempt(submission)) return false;
|
|
// Use the failure-excluded count so this gate agrees with the count used in
|
|
// evidenceFollowupReasons; counting failure items here suppressed legitimate
|
|
// followups that the reasons function would have reported.
|
|
return directEvidenceItems(submission).length === 0 || realSearchableEvidenceItems(submission).length < 2;
|
|
}
|
|
|
|
function evidenceFollowupReasons(submission) {
|
|
if (!submission || !hasSearchAttempt(submission)) return [];
|
|
|
|
const reasons = [];
|
|
const directCount = directEvidenceItems(submission).length;
|
|
const searchableCount = realSearchableEvidenceItems(submission).length;
|
|
const providerStates = Object.values(submission.providerState || {});
|
|
|
|
if (directCount === 0) reasons.push("직접 매칭 또는 원문 페이지 근거가 없습니다.");
|
|
if (searchableCount < 2) reasons.push("검색 근거가 2건 미만입니다.");
|
|
if (providerStates.includes("empty")) reasons.push("외부 검색 tool이 빈 결과를 반환했습니다.");
|
|
if (providerStates.includes("failed")) reasons.push("외부 검색 tool 실패 이력이 있습니다.");
|
|
|
|
return reasons;
|
|
}
|
|
|
|
function normalizedQuerySeed(submission) {
|
|
return String(submission?.title || submission?.id || "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
}
|
|
|
|
function suggestedEvidenceQueries(submission) {
|
|
const seed = normalizedQuerySeed(submission);
|
|
if (!seed) return [];
|
|
|
|
const existingQueries = new Set((submission.queryHistory || []).map((query) => String(query.query || "").trim().toLowerCase()));
|
|
return [seed, `${seed} 저작권`, `${seed} 공식`, `${seed} 이미지 출처`]
|
|
.filter((query, index, list) => query && list.indexOf(query) === index)
|
|
.filter((query) => !existingQueries.has(query.toLowerCase()))
|
|
.slice(0, 4);
|
|
}
|
|
|
|
global.OperatorEvidenceGuidance = {
|
|
searchableEvidenceItems,
|
|
directEvidenceItems,
|
|
hasSearchAttempt,
|
|
evidenceNeedsFollowup,
|
|
evidenceFollowupReasons,
|
|
normalizedQuerySeed,
|
|
suggestedEvidenceQueries,
|
|
};
|
|
})(window);
|