add blur detection + Ollama ID validation + corporate check for all carriers
- Client-side: Laplacian variance blur detection in photo quality check (very blurry / somewhat blurry / acceptable / good) - Server-side: async Ollama vision model validates uploaded image is a real government ID (minicpm-v:8b), flags non-ID uploads - Corporate check: sole proprietors now get yellow 'form an LLC' upsell, formal entities get annual report/RA reminder
This commit is contained in:
parent
e40f359693
commit
e2313bcc5e
3 changed files with 150 additions and 2 deletions
|
|
@ -431,7 +431,7 @@ router.get("/api/v1/dot/lookup", async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Check 15: Corporate / LLC Compliance ──
|
// ── Check 15: Corporate / Entity Compliance ──
|
||||||
{
|
{
|
||||||
const entityName = (name || "").toUpperCase();
|
const entityName = (name || "").toUpperCase();
|
||||||
const isLLC = /\bLLC\b|\bL\.L\.C\b/.test(entityName);
|
const isLLC = /\bLLC\b|\bL\.L\.C\b/.test(entityName);
|
||||||
|
|
@ -439,10 +439,10 @@ router.get("/api/v1/dot/lookup", async (req, res) => {
|
||||||
const isLTD = /\bLTD\b|\bLTD\.\b|\bLIMITED\b/.test(entityName);
|
const isLTD = /\bLTD\b|\bLTD\.\b|\bLIMITED\b/.test(entityName);
|
||||||
const isLP = /\bLP\b|\bL\.P\.\b|\bLLP\b/.test(entityName);
|
const isLP = /\bLP\b|\bL\.P\.\b|\bLLP\b/.test(entityName);
|
||||||
const isFormalEntity = isLLC || isCorp || isLTD || isLP;
|
const isFormalEntity = isLLC || isCorp || isLTD || isLP;
|
||||||
|
const state = carrier?.phyState || census?.phy_state || "";
|
||||||
|
|
||||||
if (isFormalEntity) {
|
if (isFormalEntity) {
|
||||||
const entityType = isLLC ? "LLC" : isCorp ? "Corporation" : isLTD ? "Ltd" : "LP/LLP";
|
const entityType = isLLC ? "LLC" : isCorp ? "Corporation" : isLTD ? "Ltd" : "LP/LLP";
|
||||||
const state = carrier?.phyState || census?.phy_state || "";
|
|
||||||
checks.push({
|
checks.push({
|
||||||
id: "corporate_compliance",
|
id: "corporate_compliance",
|
||||||
label: "Corporate / Entity Compliance",
|
label: "Corporate / Entity Compliance",
|
||||||
|
|
@ -452,6 +452,17 @@ router.get("/api/v1/dot/lookup", async (req, res) => {
|
||||||
+ `administrative dissolution and loss of liability protection. `
|
+ `administrative dissolution and loss of liability protection. `
|
||||||
+ `Performance West can handle your annual filings and registered agent service.`,
|
+ `Performance West can handle your annual filings and registered agent service.`,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
// Sole proprietor / no formal entity — suggest forming one
|
||||||
|
checks.push({
|
||||||
|
id: "corporate_compliance",
|
||||||
|
label: "Business Entity & Liability Protection",
|
||||||
|
status: "yellow",
|
||||||
|
detail: `Operating as a sole proprietor in ${state || "your state"} means your personal assets `
|
||||||
|
+ `are not protected from business liabilities, lawsuits, or accidents. `
|
||||||
|
+ `Forming an LLC or Corporation separates your personal and business assets. `
|
||||||
|
+ `Performance West can form your LLC in as little as 24 hours and serve as your registered agent.`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,9 @@ router.post("/api/v1/id-upload/:token", async (req: Request, res: Response) => {
|
||||||
thumbnailUrl = (await presign(minioPath, "GET", 3600)) || "";
|
thumbnailUrl = (await presign(minioPath, "GET", 3600)) || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick off async Ollama ID validation (non-blocking)
|
||||||
|
validateIdWithOllama(base64Data, tokenData.order_number as string, token).catch(() => {});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
uploaded: true,
|
uploaded: true,
|
||||||
|
|
@ -227,4 +230,93 @@ router.get("/api/v1/id-upload/:token/status", async (req: Request, res: Response
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
// ── Ollama Vision ID Validation ────────────────────────────────────
|
||||||
|
|
||||||
|
const OLLAMA_URL = process.env.OLLAMA_URL || "http://ollama:11434";
|
||||||
|
const OLLAMA_MODEL = process.env.OLLAMA_VISION_MODEL || "minicpm-v:8b";
|
||||||
|
|
||||||
|
async function validateIdWithOllama(
|
||||||
|
base64Image: string,
|
||||||
|
orderNumber: string,
|
||||||
|
token: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const prompt = `Analyze this image. Is this a government-issued photo ID (driver's license, passport, state ID, or military ID)?
|
||||||
|
|
||||||
|
Reply in this exact JSON format:
|
||||||
|
{"is_id": true/false, "id_type": "driver_license/passport/state_id/military_id/unknown", "readable": true/false, "issues": ["list of issues if any"], "confidence": 0.0-1.0}
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- "is_id" = true only if this clearly shows a government photo ID
|
||||||
|
- "readable" = true only if text on the ID appears legible
|
||||||
|
- List issues like "blurry", "too dark", "partial/cropped", "glare", "wrong document"
|
||||||
|
- Be strict: a photo of a credit card, insurance card, or random document is NOT an ID`;
|
||||||
|
|
||||||
|
const resp = await fetch(`${OLLAMA_URL}/api/generate`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: OLLAMA_MODEL,
|
||||||
|
prompt,
|
||||||
|
images: [base64Image.substring(0, 500000)], // Cap at ~375KB decoded
|
||||||
|
stream: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
console.warn("[id-validate] Ollama returned", resp.status);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await resp.json()) as { response?: string };
|
||||||
|
const raw = data.response || "";
|
||||||
|
console.log("[id-validate] Ollama response:", raw.substring(0, 300));
|
||||||
|
|
||||||
|
// Parse JSON from response
|
||||||
|
const jsonMatch = raw.match(/\{[\s\S]*?\}/);
|
||||||
|
if (!jsonMatch) return;
|
||||||
|
|
||||||
|
let validation: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
validation = JSON.parse(jsonMatch[0]);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store validation result in the token record
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE id_upload_tokens SET minio_paths = jsonb_set(
|
||||||
|
COALESCE(minio_paths, '{}'::jsonb),
|
||||||
|
'{validation}',
|
||||||
|
$1::jsonb
|
||||||
|
) WHERE token = $2`,
|
||||||
|
[JSON.stringify(validation), token],
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
// If it's clearly not an ID, flag it in the order
|
||||||
|
if (validation.is_id === false) {
|
||||||
|
await pool.query(
|
||||||
|
`UPDATE compliance_orders SET intake_data = jsonb_set(
|
||||||
|
COALESCE(intake_data, '{}'::jsonb),
|
||||||
|
'{photo_id_validation}',
|
||||||
|
$1::jsonb
|
||||||
|
) WHERE order_number = $2`,
|
||||||
|
[JSON.stringify({
|
||||||
|
valid: false,
|
||||||
|
reason: "Uploaded image does not appear to be a government-issued photo ID",
|
||||||
|
issues: validation.issues || [],
|
||||||
|
checked_at: new Date().toISOString(),
|
||||||
|
}), orderNumber],
|
||||||
|
).catch(() => {});
|
||||||
|
|
||||||
|
console.warn(`[id-validate] Order ${orderNumber}: uploaded image is NOT a valid ID`);
|
||||||
|
} else {
|
||||||
|
console.log(`[id-validate] Order ${orderNumber}: valid ${validation.id_type}, readable=${validation.readable}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[id-validate] Ollama validation failed:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|
|
||||||
|
|
@ -624,6 +624,51 @@
|
||||||
checks.push({ok: false, text: "Aspect ratio: " + ratio.toFixed(1) + ":1 — doesn't look like a standard ID"});
|
checks.push({ok: false, text: "Aspect ratio: " + ratio.toFixed(1) + ":1 — doesn't look like a standard ID"});
|
||||||
warnings.push("This doesn't appear to be a photo of an ID card. Please upload a clear photo of the front of your government-issued ID.");
|
warnings.push("This doesn't appear to be a photo of an ID card. Please upload a clear photo of the front of your government-issued ID.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Blur detection — Laplacian variance on grayscale canvas
|
||||||
|
try {
|
||||||
|
var canvas = document.createElement("canvas");
|
||||||
|
var sz = 300; // downsample for speed
|
||||||
|
var scale = Math.min(sz / w, sz / h, 1);
|
||||||
|
canvas.width = Math.round(w * scale);
|
||||||
|
canvas.height = Math.round(h * scale);
|
||||||
|
var ctx = canvas.getContext("2d");
|
||||||
|
if (ctx) {
|
||||||
|
ctx.drawImage(imgEl, 0, 0, canvas.width, canvas.height);
|
||||||
|
var imgData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||||
|
var gray = new Float32Array(canvas.width * canvas.height);
|
||||||
|
for (var p = 0; p < gray.length; p++) {
|
||||||
|
gray[p] = imgData.data[p * 4] * 0.299 + imgData.data[p * 4 + 1] * 0.587 + imgData.data[p * 4 + 2] * 0.114;
|
||||||
|
}
|
||||||
|
// 3x3 Laplacian kernel: [0,-1,0,-1,4,-1,0,-1,0]
|
||||||
|
var cw = canvas.width;
|
||||||
|
var laplacianSum = 0;
|
||||||
|
var laplacianCount = 0;
|
||||||
|
for (var y = 1; y < canvas.height - 1; y++) {
|
||||||
|
for (var x = 1; x < cw - 1; x++) {
|
||||||
|
var idx = y * cw + x;
|
||||||
|
var lap = 4 * gray[idx] - gray[idx - 1] - gray[idx + 1] - gray[idx - cw] - gray[idx + cw];
|
||||||
|
laplacianSum += lap * lap;
|
||||||
|
laplacianCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var blurScore = laplacianSum / laplacianCount;
|
||||||
|
// Typical thresholds: <100 = very blurry, 100-300 = moderate, >300 = sharp
|
||||||
|
if (blurScore < 50) {
|
||||||
|
checks.push({ok: false, text: "Sharpness: very blurry — text will be unreadable"});
|
||||||
|
warnings.push("Your photo is too blurry. Hold the camera steady and make sure the ID is in focus before taking the picture.");
|
||||||
|
} else if (blurScore < 150) {
|
||||||
|
checks.push({ok: false, text: "Sharpness: somewhat blurry — try retaking"});
|
||||||
|
warnings.push("Your photo may be slightly blurry. For best results, hold the camera steady and ensure good lighting.");
|
||||||
|
} else if (blurScore < 400) {
|
||||||
|
checks.push({ok: true, text: "Sharpness: acceptable"});
|
||||||
|
} else {
|
||||||
|
checks.push({ok: true, text: "Sharpness: good, text should be readable"});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (blurErr) {
|
||||||
|
// Blur check failed silently — not critical
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render checks
|
// Render checks
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue