diff --git a/.gitignore b/.gitignore index c944433..b254c1d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,8 @@ /.cache/ /cache/* /cache-dev/* - - +./run-stats.json +manifest.json /.venv/ /__pycache__/ diff --git a/profiles/contact-loop/filters/noop_filter2.py b/profiles/contact-loop/filters/noop_filter2.py new file mode 100755 index 0000000..c1e0ec4 --- /dev/null +++ b/profiles/contact-loop/filters/noop_filter2.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""No-op FILTER-2 worker for exercising redmine_contacts.py loop-run. + +Reads the loop bundle on stdin and returns false so the runner applies the +profile default tag. This proves the conveyor belt without classifying or +mutating Redmine directly. +""" + +from __future__ import annotations + +import json +import sys + + +def main() -> int: + json.load(sys.stdin) + json.dump( + { + "result": False, + "summary": "noop FILTER-2 did not classify this contact", + "mutations": [], + }, + sys.stdout, + ) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/profiles/contact-loop/filters/spam_promo_scorer.py b/profiles/contact-loop/filters/spam_promo_scorer.py new file mode 100755 index 0000000..837ae62 --- /dev/null +++ b/profiles/contact-loop/filters/spam_promo_scorer.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +""" +FILTER-2 worker: spam / promo scorer for the contact triage loop. + +Reads the loop bundle on stdin and returns tag recommendations +based on simple evidence scoring for promotional and spam senders. + +Contract: +- Input: JSON {profile, contact, issues, runtime} +- Output: JSON {result: bool, summary: str, mutations?: list[str]} + +Scoring signals (from contact_triage_loop_raw_notes): +- Sender hints (no-reply, noreply, deals, promotions) → +1 +- Message mentions discount/coupon/deal language → +1 +Threshold: score >= 2 → tag as spam + promo (or just spam for v0) +Otherwise: result=false (runner will add default_tag) + +This worker never mutates Redmine directly; it only recommends. +The runner owns tag application and ai-checked fallback. +""" + +from __future__ import annotations + +import json +import re +import sys +from typing import Any + +SPAM_HINTS = re.compile( + r"\b(no-?reply|noreply|deals?|promotions?|marketing|newsletter|offers?)\b", + re.IGNORECASE, +) +DEAL_LANGUAGE = re.compile( + r"\b(discount|coupon|deal|promo|sale|offer|free|save|% off|limited time)\b", + re.IGNORECASE, +) + + +def score_contact(bundle: dict[str, Any]) -> tuple[bool, str, list[str]]: + contact = bundle.get("contact", {}) + issues = bundle.get("issues", []) + profile = bundle.get("profile", {}) + runtime = bundle.get("runtime", {}) + dry_run = runtime.get("dry_run", True) + + contact_id = contact.get("id") + sender_hints = 0 + deal_hits = 0 + + # Quick contact-level signals (if any metadata is present) + # For now we rely on helpdesk messages; extend later if contact fields improve. + + for issue in issues: + summary = issue.get("issue_summary", {}) + subject = (summary.get("subject") or "").lower() + # Full messages are in the bundle if fetch_full_issues was true; here we use what was provided. + # The runner already sanitizes; we still run our patterns. + if SPAM_HINTS.search(subject): + sender_hints += 1 + if DEAL_LANGUAGE.search(subject): + deal_hits += 1 + + # Also scan a small amount of message text if present in the payload + # (the runner passes compact issue data; we keep it simple) + for msg in issue.get("messages", [])[:3]: # light scan + text = (msg.get("body") or "")[:500].lower() + if SPAM_HINTS.search(text): + sender_hints += 1 + if DEAL_LANGUAGE.search(text): + deal_hits += 1 + + score = sender_hints + deal_hits + tags_to_add: list[str] = [] + + if score >= 2: + tags_to_add = ["spam", "promo"] + summary = ( + f"Score {score} (sender hints={sender_hints}, deal language={deal_hits}). " + f"Recommending spam + promo tags for contact {contact_id}." + ) + result = True + else: + summary = ( + f"Score {score} (sender hints={sender_hints}, deal language={deal_hits}). " + f"Insufficient evidence for spam/promo on contact {contact_id}." + ) + result = False + + # mutations are informational only in v0; runner applies the tags + mutations = [f"contact:{contact_id} add {', '.join(tags_to_add)}"] if tags_to_add else [] + + # In strict dry-run mode we still return the recommendation so the operator can review. + return result, summary, mutations + + +def main() -> int: + try: + bundle = json.load(sys.stdin) + except Exception as exc: + json.dump( + { + "result": False, + "summary": f"FILTER-2 failed to parse input: {exc}", + "mutations": [], + }, + sys.stdout, + ) + sys.stdout.write("\n") + return 1 + + result, summary, mutations = score_contact(bundle) + + json.dump( + { + "result": result, + "summary": summary, + "mutations": mutations, + }, + sys.stdout, + indent=2, + ) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/profiles/contact-loop/noop-ai-checked.json b/profiles/contact-loop/noop-ai-checked.json new file mode 100644 index 0000000..341de00 --- /dev/null +++ b/profiles/contact-loop/noop-ai-checked.json @@ -0,0 +1,21 @@ +{ + "name": "noop-ai-checked", + "default_tag": "ai-checked", + "remove_tags": [], + "filter_1": { + "type": "builtin", + "name": "fixed-ticket-limit", + "limit": 5 + }, + "ticket_fetch": { + "fetch_full_issues": false, + "message_limit": 25, + "sanitize_text": true + }, + "filter_2": { + "type": "command", + "argv": ["python3", "filters/noop_filter2.py"], + "timeout_seconds": 30, + "allow_mutation": false + } +} diff --git a/profiles/contact-loop/spam-scorer.json b/profiles/contact-loop/spam-scorer.json new file mode 100644 index 0000000..2669aa0 --- /dev/null +++ b/profiles/contact-loop/spam-scorer.json @@ -0,0 +1,21 @@ +{ + "name": "spam-scorer", + "default_tag": "ai-checked", + "remove_tags": [], + "filter_1": { + "type": "builtin", + "name": "fixed-ticket-limit", + "limit": 5 + }, + "ticket_fetch": { + "fetch_full_issues": false, + "message_limit": 25, + "sanitize_text": true + }, + "filter_2": { + "type": "command", + "argv": ["python3", "filters/spam_promo_scorer.py"], + "timeout_seconds": 30, + "allow_mutation": false + } +} \ No newline at end of file diff --git a/profiles/known-spam/customer-service.json b/profiles/known-spam/customer-service.json new file mode 100644 index 0000000..757f682 --- /dev/null +++ b/profiles/known-spam/customer-service.json @@ -0,0 +1,132 @@ +{ + "version": 1, + "project": "customer-service", + "description": "Known spam / clutter profiles seeded from verified production broom passes and legacy Rebot KnownSpam rules. Customer and maybe-customer contact safety gates must be applied by the tool before any close.", + "universal_safety_gates": { + "skip_contact_tags": [ + "Customer", + "maybe-customer" + ], + "require_open_ticket": true, + "require_project": "customer-service", + "write_impersonate_user": "rebot" + }, + "profiles": [ + { + "name": "globalindustrial-promos", + "category": "spam-promotional", + "confidence": "high", + "source": "verified-production-and-rebot", + "sender_regexes": [ + "^reply@globalindustrial\\.com$" + ], + "subject_regexes": [ + "(?i)\\b(clearance|sale|save|% off|bestsellers|fans|blowers|cooling|storage|power tools|kee safety|roadway safety|advanced ac)\\b" + ] + }, + { + "name": "vistaprint-promos", + "category": "spam-promotional", + "confidence": "high", + "source": "verified-production", + "sender_regexes": [ + "^noreply@mail\\.vistaprint\\.com$" + ], + "subject_regexes": [ + "(?i)\\b(sale|save|% off|email exclusive|ends|qualifying orders|favorite sale|customer faves|buy more)\\b" + ] + }, + { + "name": "qnap-newsletters", + "category": "spam-promotional", + "confidence": "high", + "source": "verified-production", + "sender_regexes": [ + "^enews-noreply@qnap\\.com$" + ], + "subject_regexes": [ + "(?i)\\b(qnap|photodirector|nas|security advisory|product of the year|jc-star|file backup|heic)\\b|^—$|^61|New$" + ] + }, + { + "name": "business-journals-promos", + "category": "spam-promotional", + "confidence": "high", + "source": "verified-production", + "sender_regexes": [ + "^replycincinnati@engaged\\.bizjournals\\.com$", + "^replycincinnati@partner\\.bizjournals\\.com$", + "^acollett@bizjournals\\.com$" + ], + "subject_regexes": [ + "(?i)\\b(events|nominations|business summit|brand next to key business lists|chambercomp|workers.? comp)\\b" + ] + }, + { + "name": "legacy-rebot-promotional-senders", + "category": "spam-promotional", + "confidence": "medium", + "source": "legacy-rebot-knownspam", + "sender_regexes": [ + "^.*@.*roku\\.com$", + "^no\\.reply@masterclass\\.com$", + "^donotreply@walmart\\.com$", + "^.*@uber\\.com$", + "^no-reply@inhome\\.walmart\\.com$", + "^.*wingsandrings\\.com$", + "^.*@tryfinally\\.co$", + "^.*crackerbarrel\\.com$", + "^.*shop\\.tiktok\\.com$", + "^noreply_samsung@sea\\.samsung\\.com$", + "^noreply@email\\.crunchbase\\.com$", + "^.*webstaurantstore\\.com$", + "^support@helium10\\.com$", + "^noreply@popmenu\\.com$", + "^info@go\\.cbts\\.com$", + "^noreply@rush-delivery\\.com$" + ], + "subject_regexes": [] + }, + { + "name": "legacy-rebot-operational-clutter", + "category": "automated-operational", + "confidence": "medium", + "source": "legacy-rebot-knownspam", + "sender_regexes": [ + "^.*@indeedemail\\.com$", + "^no-reply@indeed\\.com$", + "mailnull@amazon\\.com", + "^info@emailbiz\\.dukeenergy\\.com$" + ], + "subject_regexes": [ + "(?i)Your Amazon\\.com order.*has shipped", + "(?i)^Your Duke energy statement is ready", + "(?i)^Your Amazon\\.com order.*", + "(?i)^UPS Billing: Payment Received.*", + "(?i)^UPS Billing:.*new invoice.*", + "(?i)^.applied on indeed.*", + "(?i)^See results for the assessments you sent to.*", + "(?i)^Reminder: Leave feedback on your interview with.*", + "(?i)^Pekin AutoPay Payment.*", + "(?i)^Notification of Active Listings Report availability" + ], + "default_action": "report-only-until-approved" + }, + { + "name": "legacy-rebot-receipts-routing-reference", + "category": "routing-reference", + "confidence": "medium", + "source": "legacy-rebot-receipts-classifier", + "sender_regexes": [ + "culliganbillingoh@culliganwaterco\\.com", + "accounting@amshippingsupplies\\.com", + "bookkeeperasl@gmail\\.com", + "bounces-noreply@mandrillapp\\.com" + ], + "subject_regexes": [ + "(?i)You have a new bill from altafiber" + ], + "default_action": "do-not-close-as-spam-route-later" + } + ] +}