Spaces:
Runtime error
Runtime error
| import base64 | |
| import binascii | |
| import datetime | |
| from io import BytesIO | |
| import os | |
| import re | |
| import shutil | |
| import sys | |
| from flask import Flask, jsonify, request, send_from_directory | |
| from flask_cors import CORS | |
| from PIL import Image | |
| import pytesseract | |
| from model_service import DarkPatternModelService | |
| if hasattr(sys.stdout, "reconfigure"): | |
| try: | |
| sys.stdout.reconfigure(encoding="utf-8") | |
| sys.stderr.reconfigure(encoding="utf-8") | |
| except Exception: | |
| pass | |
| if shutil.which("tesseract") is None: | |
| windows_tesseract = r"C:\Program Files\Tesseract-OCR\tesseract.exe" | |
| if os.name == "nt" and os.path.exists(windows_tesseract): | |
| pytesseract.pytesseract.tesseract_cmd = windows_tesseract | |
| SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| DIST_DIR = os.path.abspath(os.path.join(SCRIPT_DIR, "../dist")) | |
| DATASET_PATH = os.path.join(SCRIPT_DIR, "dataset.csv") | |
| SAMPLES_DIR = os.path.join(SCRIPT_DIR, "samples") | |
| MAX_IMAGE_BYTES = 10 * 1024 * 1024 | |
| MAX_IMAGE_PIXELS = 25_000_000 | |
| MAX_BATCH_ITEMS = 300 | |
| MAX_TEXT_LENGTH = 2_000 | |
| OCR_MIN_CONFIDENCE = float(os.environ.get("OCR_MIN_CONFIDENCE", "35")) | |
| SCREENSHOT_MIN_MODEL_SCORE = float( | |
| os.environ.get("SCREENSHOT_MIN_MODEL_SCORE", "45") | |
| ) | |
| Image.MAX_IMAGE_PIXELS = MAX_IMAGE_PIXELS | |
| if os.path.exists(DIST_DIR): | |
| app = Flask(__name__, static_folder=DIST_DIR, static_url_path="/") | |
| else: | |
| app = Flask(__name__) | |
| app.config["MAX_CONTENT_LENGTH"] = 12 * 1024 * 1024 | |
| CORS( | |
| app, | |
| resources={ | |
| r"/api/*": { | |
| "origins": [ | |
| re.compile(r"^chrome-extension://[a-p]{32}$"), | |
| "http://127.0.0.1:5173", | |
| "http://localhost:5173", | |
| "http://127.0.0.1:8000", | |
| "http://localhost:8000", | |
| ] | |
| } | |
| }, | |
| ) | |
| print("Loading grouped and calibrated NLP model...") | |
| model_service = DarkPatternModelService(DATASET_PATH) | |
| print( | |
| "NLP model ready: " | |
| f"accuracy={model_service.metrics['accuracy']}, " | |
| f"macro-F1={model_service.metrics['macroF1']}, " | |
| f"group-overlap={model_service.metrics['groupOverlap']}" | |
| ) | |
| def get_severity(prediction): | |
| severity_map = { | |
| "Urgency": "high", | |
| "Scarcity": "medium", | |
| "Social Proof": "low", | |
| "Misdirection": "high", | |
| "Obstruction": "critical", | |
| "Sneaking": "critical", | |
| "Forced Action": "critical", | |
| } | |
| return severity_map.get(prediction, "medium") | |
| def get_compliance_metadata(prediction, text): | |
| metadata = { | |
| "Urgency": ( | |
| "Artificial urgency may pressure users into immediate decisions and " | |
| "can contribute to a deceptive-practices finding.", | |
| f"Remove or substantiate urgency language such as '{text}'.", | |
| ), | |
| "Scarcity": ( | |
| "Unverified scarcity claims can mislead consumers about availability.", | |
| f"Verify '{text}' against live inventory or remove the claim.", | |
| ), | |
| "Social Proof": ( | |
| "Unverified social proof can misrepresent genuine user activity.", | |
| f"Document the source of '{text}' or remove the notification.", | |
| ), | |
| "Misdirection": ( | |
| "Guilt-inducing or biased language can interfere with neutral choice.", | |
| f"Rewrite '{text}' using neutral and symmetric option labels.", | |
| ), | |
| "Obstruction": ( | |
| "Unnecessarily difficult cancellation or opt-out flows may obstruct " | |
| "consumer choice.", | |
| f"Simplify the exit path related to '{text}'.", | |
| ), | |
| "Sneaking": ( | |
| "Hidden charges or preselected additions can obtain payment without " | |
| "clear, active consent.", | |
| f"Require explicit opt-in for any addition related to '{text}'.", | |
| ), | |
| "Forced Action": ( | |
| "Requiring unrelated consent or account actions may undermine freely " | |
| "given consumer choice.", | |
| f"Allow users to continue without the unrelated requirement in '{text}'.", | |
| ), | |
| } | |
| return metadata.get( | |
| prediction, | |
| ( | |
| "Potentially deceptive language requires human review.", | |
| "Review the copy for transparency, neutrality, and informed consent.", | |
| ), | |
| ) | |
| def add_policy_metadata(result): | |
| enriched = dict(result) | |
| if result["isDarkPattern"]: | |
| enriched["severity"] = get_severity(result["prediction"]) | |
| violation, recommendation = get_compliance_metadata( | |
| result["prediction"], result["text"] | |
| ) | |
| enriched["cfpbViolation"] = violation | |
| enriched["recommendation"] = recommendation | |
| else: | |
| enriched["severity"] = "none" | |
| return enriched | |
| def decode_image(image_url): | |
| if image_url.startswith("data:image/"): | |
| match = re.match( | |
| r"^data:image/(?:png|jpeg|jpg|webp|gif);base64,(.*)$", | |
| image_url, | |
| flags=re.IGNORECASE | re.DOTALL, | |
| ) | |
| if not match: | |
| raise ValueError("Unsupported or invalid image data URL") | |
| image_bytes = base64.b64decode(match.group(1), validate=True) | |
| if len(image_bytes) > MAX_IMAGE_BYTES: | |
| raise ValueError("Image exceeds the 10 MB limit") | |
| return Image.open(BytesIO(image_bytes)).convert("RGB") | |
| if "/api/samples/" in image_url: | |
| filename = os.path.basename(image_url.split("?")[0]) | |
| sample_path = os.path.join(SAMPLES_DIR, filename) | |
| if not os.path.isfile(sample_path): | |
| raise ValueError("Sample image not found") | |
| return Image.open(sample_path).convert("RGB") | |
| raise ValueError( | |
| "Remote image URLs are disabled. Upload an image or use a bundled sample." | |
| ) | |
| def extract_ocr_lines(image): | |
| ocr_data = pytesseract.image_to_data( | |
| image, output_type=pytesseract.Output.DICT | |
| ) | |
| lines = {} | |
| for index, raw_text in enumerate(ocr_data["text"]): | |
| text = raw_text.strip() | |
| if not text: | |
| continue | |
| try: | |
| confidence = float(ocr_data["conf"][index]) | |
| except (TypeError, ValueError): | |
| confidence = -1 | |
| if confidence < OCR_MIN_CONFIDENCE: | |
| continue | |
| key = ( | |
| ocr_data["block_num"][index], | |
| ocr_data["par_num"][index], | |
| ocr_data["line_num"][index], | |
| ) | |
| left = ocr_data["left"][index] | |
| top = ocr_data["top"][index] | |
| width = ocr_data["width"][index] | |
| height = ocr_data["height"][index] | |
| line = lines.setdefault( | |
| key, | |
| { | |
| "words": [], | |
| "confidences": [], | |
| "left": left, | |
| "top": top, | |
| "right": left + width, | |
| "bottom": top + height, | |
| }, | |
| ) | |
| line["words"].append(text) | |
| line["confidences"].append(confidence) | |
| line["left"] = min(line["left"], left) | |
| line["top"] = min(line["top"], top) | |
| line["right"] = max(line["right"], left + width) | |
| line["bottom"] = max(line["bottom"], top + height) | |
| extracted_lines = [] | |
| for line in lines.values(): | |
| text = " ".join(line["words"]).strip() | |
| if len(text) < 3: | |
| continue | |
| line["text"] = text | |
| line["ocrConfidence"] = round( | |
| sum(line["confidences"]) / len(line["confidences"]), 1 | |
| ) | |
| extracted_lines.append(line) | |
| return extracted_lines | |
| def health(): | |
| return jsonify( | |
| { | |
| "status": "ok", | |
| "modelReady": model_service is not None, | |
| "calibrated": model_service.metrics["calibrated"], | |
| } | |
| ) | |
| def get_metrics(): | |
| return jsonify(model_service.metrics) | |
| def analyze_text(): | |
| if request.method == "OPTIONS": | |
| return jsonify({}), 200 | |
| data = request.get_json(silent=True) or {} | |
| text = " ".join(str(data.get("text") or "").split()) | |
| if len(text) < 3: | |
| return jsonify({"error": "Text must contain at least 3 characters"}), 400 | |
| if len(text) > MAX_TEXT_LENGTH: | |
| return jsonify({"error": "Text exceeds the 2,000 character limit"}), 400 | |
| return jsonify(add_policy_metadata(model_service.classify(text))) | |
| def analyze_texts(): | |
| if request.method == "OPTIONS": | |
| return jsonify({}), 200 | |
| data = request.get_json(silent=True) or {} | |
| items = data.get("items") | |
| if not isinstance(items, list): | |
| return jsonify({"error": "items must be an array"}), 400 | |
| if len(items) > MAX_BATCH_ITEMS: | |
| return jsonify( | |
| {"error": f"Batch exceeds the {MAX_BATCH_ITEMS} item limit"} | |
| ), 400 | |
| normalized = [] | |
| for index, item in enumerate(items): | |
| if isinstance(item, str): | |
| item_id = str(index) | |
| text = item | |
| elif isinstance(item, dict): | |
| item_id = str(item.get("id", index)) | |
| text = item.get("text", "") | |
| else: | |
| continue | |
| cleaned_text = " ".join(str(text).split()) | |
| if 3 <= len(cleaned_text) <= MAX_TEXT_LENGTH: | |
| normalized.append({"id": item_id, "text": cleaned_text}) | |
| predictions = model_service.classify_many( | |
| [item["text"] for item in normalized] | |
| ) | |
| results = [] | |
| for item, prediction in zip(normalized, predictions): | |
| enriched = add_policy_metadata(prediction) | |
| enriched["id"] = item["id"] | |
| results.append(enriched) | |
| return jsonify( | |
| { | |
| "status": "success", | |
| "received": len(items), | |
| "analyzed": len(results), | |
| "results": results, | |
| } | |
| ) | |
| def analyze_image(): | |
| if request.method == "OPTIONS": | |
| return jsonify({}), 200 | |
| data = request.get_json(silent=True) or {} | |
| image_url = str(data.get("imageUrl") or "") | |
| if not image_url: | |
| return jsonify({"error": "imageUrl is required"}), 400 | |
| try: | |
| image = decode_image(image_url) | |
| width, height = image.size | |
| ocr_lines = extract_ocr_lines(image) | |
| classifications = model_service.classify_many( | |
| [line["text"] for line in ocr_lines] | |
| ) | |
| dark_patterns = [] | |
| for line, classification in zip(ocr_lines, classifications): | |
| if not classification["isDarkPattern"]: | |
| continue | |
| if classification["confidence"] < SCREENSHOT_MIN_MODEL_SCORE: | |
| continue | |
| violation, recommendation = get_compliance_metadata( | |
| classification["prediction"], line["text"] | |
| ) | |
| dark_patterns.append( | |
| { | |
| "id": str(len(dark_patterns) + 1), | |
| "type": classification["prediction"], | |
| "severity": get_severity(classification["prediction"]), | |
| "description": ( | |
| "Language classified as a potential " | |
| f"{classification['prediction']} dark pattern." | |
| ), | |
| "confidence": classification["confidence"], | |
| "confidenceBand": classification["confidenceBand"], | |
| "calibrated": True, | |
| "ocrConfidence": line["ocrConfidence"], | |
| "location": { | |
| "x": round((line["left"] / width) * 100, 2), | |
| "y": round((line["top"] / height) * 100, 2), | |
| "width": round( | |
| ((line["right"] - line["left"]) / width) * 100, 2 | |
| ), | |
| "height": round( | |
| ((line["bottom"] - line["top"]) / height) * 100, 2 | |
| ), | |
| }, | |
| "cfpbViolation": violation, | |
| "recommendation": recommendation, | |
| "evidence": line["text"], | |
| "explanation": classification["explanation"], | |
| } | |
| ) | |
| deductions = { | |
| "critical": 25, | |
| "high": 15, | |
| "medium": 10, | |
| "low": 5, | |
| } | |
| score_deduction = sum( | |
| deductions.get(pattern["severity"], 10) | |
| for pattern in dark_patterns | |
| ) | |
| overall_score = max(5, 100 - score_deduction) | |
| if not dark_patterns: | |
| overall_score = 98 | |
| if overall_score >= 80: | |
| risk_level = "low" | |
| elif overall_score >= 60: | |
| risk_level = "medium" | |
| elif overall_score >= 45: | |
| risk_level = "high" | |
| else: | |
| risk_level = "critical" | |
| return jsonify( | |
| { | |
| "imageUrl": image_url, | |
| "extractedText": "\n".join( | |
| line["text"] for line in ocr_lines | |
| ) | |
| or "No reliable text detected in screenshot.", | |
| "ocrMinimumConfidence": OCR_MIN_CONFIDENCE, | |
| "overallScore": overall_score, | |
| "riskLevel": risk_level, | |
| "darkPatterns": dark_patterns, | |
| "complianceReport": { | |
| "cfpbAlignment": overall_score, | |
| "issues": list( | |
| dict.fromkeys( | |
| pattern["cfpbViolation"] | |
| for pattern in dark_patterns | |
| ) | |
| ), | |
| "recommendations": list( | |
| dict.fromkeys( | |
| pattern["recommendation"] | |
| for pattern in dark_patterns | |
| ) | |
| ), | |
| }, | |
| "timestamp": datetime.datetime.now( | |
| datetime.timezone.utc | |
| ).isoformat(), | |
| } | |
| ) | |
| except (ValueError, binascii.Error) as error: | |
| return jsonify({"error": str(error)}), 400 | |
| except Exception: | |
| app.logger.exception("Screenshot analysis failed") | |
| return jsonify({"error": "Screenshot analysis failed"}), 500 | |
| def get_sample(filename): | |
| return send_from_directory(SAMPLES_DIR, filename) | |
| def get_dataset(): | |
| query = request.args.get("q", "").strip() | |
| category = request.args.get("category", "").strip() | |
| try: | |
| limit = min(max(int(request.args.get("limit", 50)), 1), 100) | |
| offset = max(int(request.args.get("offset", 0)), 0) | |
| except ValueError: | |
| return jsonify({"error": "limit and offset must be integers"}), 400 | |
| filtered = model_service.dataset | |
| if query: | |
| filtered = filtered[ | |
| filtered["text"].str.contains( | |
| query, case=False, na=False, regex=False | |
| ) | |
| ] | |
| if category: | |
| filtered = filtered[ | |
| filtered["Pattern Category"].str.casefold() | |
| == category.casefold() | |
| ] | |
| return jsonify( | |
| { | |
| "status": "success", | |
| "total": int(len(filtered)), | |
| "limit": limit, | |
| "offset": offset, | |
| "records": filtered.iloc[offset : offset + limit].to_dict( | |
| orient="records" | |
| ), | |
| "categoryCounts": model_service.metrics["classDistribution"], | |
| } | |
| ) | |
| if os.path.exists(DIST_DIR): | |
| def serve(path): | |
| target = os.path.join(app.static_folder, path) | |
| if path and os.path.isfile(target): | |
| return app.send_static_file(path) | |
| return app.send_static_file("index.html") | |
| if __name__ == "__main__": | |
| app.run( | |
| host="0.0.0.0", | |
| port=int(os.environ.get("PORT", 8000)), | |
| debug=False, | |
| ) | |