diff --git a/api/src/routes/dot-lookup.ts b/api/src/routes/dot-lookup.ts index 4567974..374c79a 100644 --- a/api/src/routes/dot-lookup.ts +++ b/api/src/routes/dot-lookup.ts @@ -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 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 isLP = /\bLP\b|\bL\.P\.\b|\bLLP\b/.test(entityName); const isFormalEntity = isLLC || isCorp || isLTD || isLP; + const state = carrier?.phyState || census?.phy_state || ""; if (isFormalEntity) { const entityType = isLLC ? "LLC" : isCorp ? "Corporation" : isLTD ? "Ltd" : "LP/LLP"; - const state = carrier?.phyState || census?.phy_state || ""; checks.push({ id: "corporate_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. ` + `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.`, + }); } } diff --git a/api/src/routes/id-upload.ts b/api/src/routes/id-upload.ts index 86d0418..6e9ff4d 100644 --- a/api/src/routes/id-upload.ts +++ b/api/src/routes/id-upload.ts @@ -185,6 +185,9 @@ router.post("/api/v1/id-upload/:token", async (req: Request, res: Response) => { 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({ success: 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 { + 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; + 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; diff --git a/site/src/components/intake/steps/DOTIntakeStep.astro b/site/src/components/intake/steps/DOTIntakeStep.astro index db42a6c..d411bd3 100644 --- a/site/src/components/intake/steps/DOTIntakeStep.astro +++ b/site/src/components/intake/steps/DOTIntakeStep.astro @@ -624,6 +624,51 @@ 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."); } + + // 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