44
55import boto3
66from django .conf import settings
7- from django .db import models
7+ from django .db import models , transaction
88from langchain_aws import ChatBedrock
99from langchain_core .messages import AIMessage , HumanMessage , SystemMessage
1010
1111from 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
1320from .ai_model import AiModel
1421from .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 :
0 commit comments