Skip to content

Commit 5410567

Browse files
authored
Merge pull request #45 from tpaulshippy/feature/roadmap-03-server-safety
2 parents 0395826 + 51927d4 commit 5410567

36 files changed

Lines changed: 1847 additions & 65 deletions

back/bots/admin.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
Message,
1313
Profile,
1414
RevenueCatWebhookEvent,
15+
SafetyEvent,
1516
UsageLimitHit,
1617
UserAccount,
1718
)
@@ -89,6 +90,14 @@ def get_readonly_fields(self, request, obj=None):
8990
def get_list_display(self, request):
9091
return ['flashcard_id', 'deck', 'front', 'order', 'created_at', 'updated_at'] + list(super().get_list_display(request))
9192

93+
class SafetyEventAdmin(admin.ModelAdmin):
94+
def get_readonly_fields(self, request, obj=None):
95+
return ['created_at', 'event_id']
96+
97+
def get_list_display(self, request):
98+
return ['created_at', 'stage', 'reason_code', 'user', 'chat', 'snippet_redacted'] + list(super().get_list_display(request))
99+
100+
92101
class UserAccountAdmin(admin.ModelAdmin):
93102
def get_list_display(self, request):
94103
return ['user_id', 'pin', 'subscription_level', 'timezone'] + list(super().get_list_display(request))
@@ -108,5 +117,6 @@ class UserAdmin(BaseUserAdmin):
108117
admin.site.register(RevenueCatWebhookEvent, RevenueCatWebhookEventAdmin)
109118
admin.site.register(Deck, DeckAdmin)
110119
admin.site.register(Flashcard, FlashcardAdmin)
120+
admin.site.register(SafetyEvent, SafetyEventAdmin)
111121
admin.site.unregister(User)
112122
admin.site.register(User, UserAdmin)

back/bots/management/__init__.py

Whitespace-only changes.

back/bots/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
"""Seed idempotent e2e data for the server-side safety demo (roadmap 03).
2+
3+
Creates the shared Detox login ('e2e-test-user' / 'testpassword123') plus:
4+
- a profile for the AsyncStorage injection,
5+
- "Safety Demo Bot": custom advanced-editor prompt with restrict flags ON —
6+
proves the server regenerates the policy suffix even when the parent wrote
7+
a custom system prompt (safe path: homework questions answer normally;
8+
unsafe path: adult-topic messages get the fixed refusal),
9+
- "Open Flags Bot": restrict flags OFF — proves the global floor still
10+
applies (crisis terms still blocked).
11+
12+
The command resets a password that is published in this repository, so it
13+
refuses to run unless an E2E environment is explicitly enabled. Accidentally
14+
running it against staging or production would otherwise leave a trivially
15+
accessible user.
16+
17+
Usage: E2E_SEEDING=1 python manage.py seed_e2e_server_safety
18+
"""
19+
20+
import os
21+
22+
from django.contrib.auth.models import User
23+
from django.core.management.base import BaseCommand, CommandError
24+
25+
from bots.models import Bot, Profile
26+
27+
E2E_USERNAME = 'e2e-test-user'
28+
E2E_PASSWORD = 'testpassword123'
29+
30+
SAFETY_DEMO_BOT_NAME = 'Safety Demo Bot'
31+
OPEN_FLAGS_BOT_NAME = 'Open Flags Bot'
32+
33+
34+
class Command(BaseCommand):
35+
help = 'Seed idempotent e2e data for the server-side safety demos.'
36+
37+
def handle(self, *args, **options):
38+
if os.environ.get('E2E_SEEDING') != '1':
39+
raise CommandError(
40+
'Refusing to create a known-password e2e user outside an E2E '
41+
'environment. Re-run with E2E_SEEDING=1 to enable e2e seeding.'
42+
)
43+
user, created = User.objects.get_or_create(username=E2E_USERNAME)
44+
if created or not user.check_password(E2E_PASSWORD):
45+
user.set_password(E2E_PASSWORD)
46+
user.save()
47+
self.stdout.write(f"user: {user.username} ({'created' if created else 'exists'})")
48+
49+
profile, _ = Profile.objects.get_or_create(
50+
user=user,
51+
deleted_at=None,
52+
defaults={'name': 'E2E Kid'},
53+
)
54+
if not profile.name:
55+
# The post_save signal may have created it with a blank first name.
56+
profile.name = 'E2E Kid'
57+
profile.save()
58+
self.stdout.write(f"profile: {profile.name} ({profile.profile_id})")
59+
60+
safety_bot, created = Bot.objects.update_or_create(
61+
user=user,
62+
name=SAFETY_DEMO_BOT_NAME,
63+
defaults={
64+
'system_prompt': (
65+
'You are a patient math tutor for a 14-year-old. Use '
66+
'Socratic questioning. Never just give the final answer.'
67+
),
68+
'simple_editor': False,
69+
'template_name': '',
70+
'response_length': 150,
71+
'restrict_language': True,
72+
'restrict_adult_topics': True,
73+
'enable_web_search': False,
74+
'deleted_at': None,
75+
},
76+
)
77+
self.stdout.write(f"bot: {safety_bot.name} ({'created' if created else 'updated'})")
78+
79+
open_bot, created = Bot.objects.update_or_create(
80+
user=user,
81+
name=OPEN_FLAGS_BOT_NAME,
82+
defaults={
83+
'system_prompt': 'You are a relaxed study buddy. All bot-level restrictions are off.',
84+
'simple_editor': False,
85+
'template_name': '',
86+
'response_length': 200,
87+
'restrict_language': False,
88+
'restrict_adult_topics': False,
89+
'enable_web_search': False,
90+
'deleted_at': None,
91+
},
92+
)
93+
self.stdout.write(f"bot: {open_bot.name} ({'created' if created else 'updated'})")
94+
95+
self.stdout.write(self.style.SUCCESS('e2e server-safety seed complete'))
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Generated by Django 6.1 on 2026-08-25 18:14
2+
3+
import uuid
4+
5+
import django.db.models.deletion
6+
from django.conf import settings
7+
from django.db import migrations, models
8+
9+
10+
class Migration(migrations.Migration):
11+
12+
dependencies = [
13+
('bots', '0038_alter_aimodel_options_alter_device_options'),
14+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
15+
]
16+
17+
operations = [
18+
migrations.CreateModel(
19+
name='SafetyEvent',
20+
fields=[
21+
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
22+
('event_id', models.UUIDField(default=uuid.uuid4, unique=True)),
23+
('stage', models.CharField(max_length=32)),
24+
('reason_code', models.CharField(max_length=64)),
25+
('snippet_redacted', models.CharField(blank=True, max_length=200)),
26+
('created_at', models.DateTimeField(auto_now_add=True)),
27+
('bot', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='bots.bot')),
28+
('chat', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='bots.chat')),
29+
('profile', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='bots.profile')),
30+
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
31+
],
32+
),
33+
]
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
from django.db import migrations, models
2+
3+
4+
class Migration(migrations.Migration):
5+
6+
dependencies = [
7+
('bots', '0039_safetyevent'),
8+
]
9+
10+
operations = [
11+
migrations.AddField(
12+
model_name='message',
13+
name='safety_blocked',
14+
field=models.BooleanField(default=False),
15+
),
16+
]

back/bots/models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from .flashcard import Flashcard
77
from .message import Message
88
from .profile import Profile
9+
from .safety_event import SafetyEvent
910
from .usage_limit_hit import UsageLimitHit
1011
from .user_account import RevenueCatWebhookEvent, UserAccount
1112

@@ -19,6 +20,7 @@
1920
'Message',
2021
'Profile',
2122
'RevenueCatWebhookEvent',
23+
'SafetyEvent',
2224
'UsageLimitHit',
2325
'UserAccount',
2426
]

back/bots/models/chat.py

Lines changed: 96 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,18 @@
44

55
import boto3
66
from django.conf import settings
7-
from django.db import models
7+
from django.db import models, transaction
88
from langchain_aws import ChatBedrock
99
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage
1010

1111
from bots.services.chat_agent import ChatAgentService
12+
from bots.services.safety import (
13+
SafetyPolicy,
14+
build_system_prompt,
15+
evaluate_text,
16+
record_safety_event,
17+
refusal_for_verdict,
18+
)
1219

1320
from .ai_model import AiModel
1421
from .bot import Bot
@@ -60,37 +67,85 @@ def use_default_model(self, ai=None):
6067

6168
self.ai = AiClientWrapper(model_id=default_model.model_id, client=ai)
6269

63-
def get_response(self, ai=None):
70+
def get_response(self, ai=None, user_message=None):
71+
# Input safety is evaluated BEFORE any model setup or quota check so
72+
# a misconfigured model or an over-limit account cannot swallow the
73+
# fixed crisis refusal or leave the unsafe message unmarked.
74+
policy = SafetyPolicy.for_bot(self.bot)
75+
subject = user_message or self.messages.filter(role='user').order_by('-id').first()
76+
if subject is not None:
77+
verdict = evaluate_text(subject.text, policy, source='INPUT')
78+
if verdict.blocked:
79+
# Short transaction only for the state change; external calls
80+
# (moderation, Bedrock, Tavily) are never made while a row
81+
# lock is held, so the DB connection is not held for tens of
82+
# seconds. The message is marked so later turns exclude it via
83+
# get_input().
84+
with transaction.atomic():
85+
Chat.objects.select_for_update().get(pk=self.pk)
86+
if not subject.safety_blocked:
87+
subject.safety_blocked = True
88+
subject.save(update_fields=['safety_blocked', 'modified_at'])
89+
refusal = refusal_for_verdict(verdict)
90+
self.messages.create(
91+
text=refusal,
92+
role='assistant',
93+
order=self.messages.count(),
94+
)
95+
record_safety_event(
96+
stage='input',
97+
verdict=verdict,
98+
chat=self,
99+
snippet=subject.text,
100+
)
101+
return refusal
102+
103+
if self.user.user_account.over_limit():
104+
return "You have exceeded your daily limit. Please try again tomorrow or upgrade your subscription."
105+
106+
# AI client is instantiated only after input has passed the global
107+
# floor, so a missing default model never blocks the crisis path.
64108
if self.bot and self.bot.ai_model:
65109
self.ai = AiClientWrapper(model_id=self.bot.ai_model.model_id, client=ai)
66110
else:
67111
self.use_default_model(ai)
68-
112+
113+
# Context is built AFTER the blocked check so any safety-blocked
114+
# message (including this turn's) is excluded from model history.
115+
# This work is done outside any DB transaction.
69116
message_list, contains_image = self.get_input()
70117

71118
if contains_image and self.bot and self.bot.ai_model and 'image' not in self.bot.ai_model.supported_input_modalities:
72119
self.use_default_model(ai)
73-
74-
if self.user.user_account.over_limit():
75-
return "You have exceeded your daily limit. Please try again tomorrow or upgrade your subscription."
76-
77-
response_text, usage_metadata = ChatAgentService(self, self.ai.client).respond(message_list)
78-
79-
message_order = self.messages.count()
80-
81-
input_tokens = usage_metadata.get('input_tokens', 0)
82-
output_tokens = usage_metadata.get('output_tokens', 0)
83-
84-
self.messages.create(
85-
text=response_text,
86-
role='assistant',
87-
order=message_order,
88-
input_tokens=input_tokens,
89-
output_tokens=output_tokens
90-
)
91-
self.input_tokens += input_tokens
92-
self.output_tokens += output_tokens
93-
self.save()
120+
121+
response_text, usage_metadata = ChatAgentService(self, self.ai.client, policy=policy).respond(message_list)
122+
123+
# Post-model output filter: replace flagged completions before save.
124+
output_verdict = evaluate_text(response_text, policy, source='OUTPUT')
125+
flagged_output = None
126+
if output_verdict.blocked:
127+
flagged_output = response_text
128+
response_text = refusal_for_verdict(output_verdict)
129+
130+
# Short transaction only for the final persist; the row lock is held
131+
# briefly to claim the message order, not across the model call.
132+
with transaction.atomic():
133+
Chat.objects.select_for_update().get(pk=self.pk)
134+
message_order = self.messages.count()
135+
input_tokens = usage_metadata.get('input_tokens', 0)
136+
output_tokens = usage_metadata.get('output_tokens', 0)
137+
self.messages.create(
138+
text=response_text,
139+
role='assistant',
140+
order=message_order,
141+
input_tokens=input_tokens,
142+
output_tokens=output_tokens,
143+
)
144+
self.input_tokens += input_tokens
145+
self.output_tokens += output_tokens
146+
self.save()
147+
if output_verdict.blocked:
148+
record_safety_event(stage='output', verdict=output_verdict, chat=self, snippet=flagged_output)
94149
return response_text
95150

96151
def setup_human_message_content(self, message):
@@ -111,7 +166,13 @@ def has_image(self, message: HumanMessage):
111166

112167
def get_input(self):
113168
contains_image = False
114-
messages = self.messages.exclude(role='system').order_by('-id')[:10]
169+
# Safety-blocked messages are excluded: denied content never becomes
170+
# later model context even after the turn ends.
171+
messages = (
172+
self.messages.exclude(role='system')
173+
.exclude(safety_blocked=True)
174+
.order_by('-id')[:10]
175+
)
115176
messages = sorted(messages, key=lambda message: message.id)
116177
message_list = []
117178

@@ -131,9 +192,16 @@ def get_input(self):
131192
return message_list, contains_image
132193

133194
def get_system_message(self):
134-
if self.bot and self.bot.system_prompt:
135-
return self.bot.system_prompt
136-
return "You are chatting with a teen. Please keep the conversation appropriate and respectful. Your responses should be 200 words or less."
195+
"""Server-owned layered prompt: preamble + parent customization + policy suffix.
196+
197+
The flags are restated here every turn so a custom (advanced-editor)
198+
system_prompt cannot strip the safety layers, and the client is never
199+
the control plane for policy text.
200+
"""
201+
policy = SafetyPolicy.for_bot(self.bot)
202+
bot_prompt = self.bot.system_prompt if self.bot else None
203+
response_length = self.bot.response_length if self.bot else None
204+
return build_system_prompt(bot_prompt, policy, response_length)
137205

138206
def get_image_data(self, filename):
139207
try:

back/bots/models/message.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@ class Message(models.Model):
1616
created_at = models.DateTimeField(auto_now_add=True)
1717
modified_at = models.DateTimeField(auto_now=True)
1818
image_filename = models.CharField(max_length=255, blank=True, null=True)
19+
# True when the server-side safety filter denied this message. Denied
20+
# content is still visible in the chat history but must never re-enter
21+
# the model context on later turns (see Chat.get_input()).
22+
safety_blocked = models.BooleanField(default=False)
1923

2024
def __str__(self):
2125
user_str = getattr(self.chat.user, 'email', 'unknown')

back/bots/models/safety_event.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import uuid
2+
3+
from django.conf import settings
4+
from django.db import models
5+
6+
from .bot import Bot
7+
from .chat import Chat
8+
from .profile import Profile
9+
10+
11+
class SafetyEvent(models.Model):
12+
"""Audit log of safety blocks. Admin-readable; parent API lands in 04."""
13+
14+
event_id = models.UUIDField(default=uuid.uuid4, unique=True)
15+
user = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.CASCADE)
16+
profile = models.ForeignKey(Profile, null=True, on_delete=models.SET_NULL)
17+
chat = models.ForeignKey(Chat, null=True, on_delete=models.SET_NULL)
18+
bot = models.ForeignKey(Bot, null=True, on_delete=models.SET_NULL)
19+
# input|output|web_query|web_result|tool_flashcard
20+
stage = models.CharField(max_length=32)
21+
# adult_topic|language|global_floor|web_blocked
22+
reason_code = models.CharField(max_length=64)
23+
# never store the full raw text when it matched a sexual/violent term
24+
snippet_redacted = models.CharField(max_length=200, blank=True)
25+
created_at = models.DateTimeField(auto_now_add=True)
26+
27+
def __str__(self):
28+
return f"{self.created_at:%Y-%m-%d %H:%M} {self.stage}/{self.reason_code}"

0 commit comments

Comments
 (0)