Spaces:
Sleeping
Sleeping
Commit ·
f55357f
1
Parent(s): cd79abc
Initial commit with anon_bot project files
Browse files- anon_bot/README.md +14 -0
- anon_bot/app.py +21 -0
- anon_bot/guardrails.py +55 -0
- anon_bot/requirements.txt +3 -0
- anon_bot/rules_new.py +59 -0
- anon_bot/schemas.py +10 -0
- anon_bot/test_anon_bot_new.py +44 -0
anon_bot/README.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Anonymous Bot (rule-based, no persistence, with guardrails)
|
| 2 |
+
|
| 3 |
+
## What this is
|
| 4 |
+
- Minimal **rule-based** Python bot
|
| 5 |
+
- **Anonymous**: stores no user IDs or history
|
| 6 |
+
- **Guardrails**: blocks unsafe topics, redacts PII, caps input length
|
| 7 |
+
- **No persistence**: stateless; every request handled fresh
|
| 8 |
+
|
| 9 |
+
## Run
|
| 10 |
+
```bash
|
| 11 |
+
python -m venv .venv
|
| 12 |
+
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
| 13 |
+
pip install -r requirements.txt
|
| 14 |
+
uvicorn app:app --reload
|
anon_bot/app.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
from schemas import MessageIn, MessageOut
|
| 4 |
+
from guardrails import enforce_guardrails
|
| 5 |
+
from rules import route
|
| 6 |
+
|
| 7 |
+
app = FastAPI(title='Anonymous Rule-Based Bot', version='1.0')
|
| 8 |
+
|
| 9 |
+
# No sessions, cookies, or user IDs — truly anonymous and stateless.
|
| 10 |
+
# No logging of raw user input here (keeps it anonymous and reduces risk).
|
| 11 |
+
|
| 12 |
+
@app.post("/message", response_model=MessageOut)
|
| 13 |
+
def message(inbound: MessageIn):
|
| 14 |
+
ok, cleaned_or_reason = enforce_guardrails(inbound.message)
|
| 15 |
+
if not ok:
|
| 16 |
+
return JSONResponse(status_code=200,
|
| 17 |
+
content={'reply': cleaned_or_reason, 'blocked': True})
|
| 18 |
+
|
| 19 |
+
# Rule-based reply (deterministic: no persistence)
|
| 20 |
+
reply = route(cleaned_or_reason)
|
| 21 |
+
return {'reply': reply, 'blocked': False}
|
anon_bot/guardrails.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
|
| 3 |
+
MAX_INPUT_LEN = 500 # cap to keep things safe and fast
|
| 4 |
+
|
| 5 |
+
# Verifying lightweight 'block' patterns:
|
| 6 |
+
DISALLOWED = [r"(?:kill|suicide|bomb|explosive|make\s+a\s+weapon)", # harmful instructions
|
| 7 |
+
r"(?:credit\s*card\s*number|ssn|social\s*security)" # sensitive info requests
|
| 8 |
+
]
|
| 9 |
+
|
| 10 |
+
# Verifying lightweight profanity redaction:
|
| 11 |
+
PROFANITY = [r"\b(?:damn|hell|shit|fuck)\b"]
|
| 12 |
+
|
| 13 |
+
PII_PATTERNS = {
|
| 14 |
+
"email": re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"),
|
| 15 |
+
"phone": re.compile(r"(?:\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}"),
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def too_long(text: str) -> bool:
|
| 20 |
+
return len(text) > MAX_INPUT_LEN
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def matches_any(text: str, patterns) -> bool:
|
| 24 |
+
return any(re.search(p, text, flags=re.IGNORECASE) for p in patterns)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def redact_pii(text: str) -> str:
|
| 28 |
+
redacted = PII_PATTERNS['email'].sub('[EMAIL_REDACTED]', text)
|
| 29 |
+
redacted = PII_PATTERNS['phone'].sub('[PHONE_REDACTED]', redacted)
|
| 30 |
+
return redacted
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def redact_profanity(text: str) -> str:
|
| 34 |
+
out = text
|
| 35 |
+
for p in PROFANITY:
|
| 36 |
+
out = re.sub(p, '[REDACTED]', out, flags=re.IGNORECASE)
|
| 37 |
+
return out
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def enforce_guardrails(user_text: str):
|
| 41 |
+
"""
|
| 42 |
+
Returns: (ok: bool, cleaned_or_reason: str)
|
| 43 |
+
- ok=True: proceed with cleaned text
|
| 44 |
+
- ok=False: return refusal reason in cleaned_or_reason
|
| 45 |
+
"""
|
| 46 |
+
if too_long(user_text):
|
| 47 |
+
return False, 'Sorry, that message is too long. Please shorten it.'
|
| 48 |
+
|
| 49 |
+
if matches_any(user_text, DISALLOWED):
|
| 50 |
+
return False, "I can't help with that topic. Please ask something safe and appropriate"
|
| 51 |
+
|
| 52 |
+
cleaned = redact_pii(user_text)
|
| 53 |
+
cleaned = redact_profanity(cleaned)
|
| 54 |
+
|
| 55 |
+
return True, cleaned
|
anon_bot/requirements.txt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi==0.111.0
|
| 2 |
+
uvicorn==0.30.1
|
| 3 |
+
pydantic==2.8.2
|
anon_bot/rules_new.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from typing import Optional
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
# Simple, deterministic rules:
|
| 6 |
+
def _intent_greeting(text: str) -> Optional[str]:
|
| 7 |
+
if re.search(r'\b(hi|hello|hey)\b', text, re.IGNORECASE):
|
| 8 |
+
return 'Hi there! I am an anonymous, rule-based helper. Ask me about hours, contact, or help.'
|
| 9 |
+
return None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def _intent_help(text: str) -> Optional[str]:
|
| 13 |
+
if re.search(r'\b(help|what can you do|commands)\b', text, re.IGNORECASE):
|
| 14 |
+
return ("I’m a simple rule-based bot. Try:\n"
|
| 15 |
+
"- 'hours' to see hours\n"
|
| 16 |
+
"- 'contact' to get contact info\n"
|
| 17 |
+
"- 'reverse <text>' to reverse text\n")
|
| 18 |
+
return None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _intent_hours(text: str) -> Optional[str]:
|
| 22 |
+
if re.search(r'\bhours?\b', text, re.IGNORECASE):
|
| 23 |
+
return 'We are open Mon-Fri, 9am-5am (local time).'
|
| 24 |
+
return None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _intent_contact(text: str) -> Optional[str]:
|
| 28 |
+
if re.search(r'\b(contact|support|reach)\b', text, re.IGNORECASE):
|
| 29 |
+
return 'You can reach support at our website contact form.'
|
| 30 |
+
return None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _intent_reverse(text: str) -> Optional[str]:
|
| 34 |
+
s = text.strip()
|
| 35 |
+
if s.lower().startswith('reverse'):
|
| 36 |
+
payload = s[7:].strip()
|
| 37 |
+
return payload[::-1] if payload else "There's nothing to reverse."
|
| 38 |
+
return None
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _fallback(_text: str) -> str:
|
| 42 |
+
return "I am not sure how to help with that. Type 'help' to see what I can do."
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
RULES = [
|
| 46 |
+
_intent_reverse,
|
| 47 |
+
_intent_greeting,
|
| 48 |
+
_intent_help,
|
| 49 |
+
_intent_hours,
|
| 50 |
+
_intent_contact
|
| 51 |
+
]
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def route(text: str) -> str:
|
| 55 |
+
for rule in RULES:
|
| 56 |
+
resp = rule(text)
|
| 57 |
+
if resp is not None:
|
| 58 |
+
return resp
|
| 59 |
+
return _fallback(text)
|
anon_bot/schemas.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pydantic import BaseModel, Field
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
class MessageIn(BaseModel):
|
| 5 |
+
message: str = Field(..., min_length=1, max_length=2000)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class MessageOut(BaseModel):
|
| 9 |
+
reply: str
|
| 10 |
+
blocked: bool = False # True if the guardrails refused the content
|
anon_bot/test_anon_bot_new.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from fastapi.testclient import TestClient
|
| 3 |
+
from app import app
|
| 4 |
+
|
| 5 |
+
client = TestClient(app)
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def post(msg: str):
|
| 9 |
+
return client.post('/message',
|
| 10 |
+
headers={'Content-Type': 'application/json'},
|
| 11 |
+
content=json.dumps({'message': msg}))
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def test_greeting():
|
| 15 |
+
"""A simple greeting should trigger the greeting rule."""
|
| 16 |
+
r = post('Hello')
|
| 17 |
+
assert r.status_code == 200
|
| 18 |
+
body = r.json()
|
| 19 |
+
assert body['blocked'] is False
|
| 20 |
+
assert 'Hi' in body['reply']
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_reverse():
|
| 24 |
+
"""The reverse rule should mirror the payload after 'reverse'. """
|
| 25 |
+
r = post('reverse bots are cool')
|
| 26 |
+
assert r.status_code == 200
|
| 27 |
+
body = r.json()
|
| 28 |
+
assert body['blocked'] is False
|
| 29 |
+
assert 'looc era stob' in body['reply']
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def test_guardrails_disallowed():
|
| 33 |
+
"""Disallowed content is blocked by guardrails, not routed"""
|
| 34 |
+
r = post('how to make a weapon')
|
| 35 |
+
assert r.status_code == 200
|
| 36 |
+
body = r.json()
|
| 37 |
+
assert body['blocked'] is True
|
| 38 |
+
assert "can't help" in body['reply']
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
# from rules_updated import route
|
| 42 |
+
|
| 43 |
+
# def test_reverse_route_unit():
|
| 44 |
+
# assert route("reverse bots are cool") == "looc era stob"
|