New contain conditions now work incl. timer

This commit is contained in:
Ruben van de Ven 2019-02-11 21:28:48 +01:00
parent 74730c1d25
commit 7eb48bd016
7 changed files with 306 additions and 79 deletions

View file

@ -3,6 +3,9 @@ import logging
logger = logging.getLogger("communication") logger = logging.getLogger("communication")
# hyper verbose log level. Have it here, becase it needs to be _somewhere_
LOG_BS = 5
def getTopic(hugvey_id): def getTopic(hugvey_id):
return "hv{}".format(hugvey_id) return "hv{}".format(hugvey_id)

View file

@ -3,10 +3,28 @@ import time
import logging import logging
import re import re
import asyncio import asyncio
from .communication import LOG_BS
logger = logging.getLogger("narrative") logger = logging.getLogger("narrative")
class Utterance(object):
"""Part of a reply"""
def __init__(self, startTime):
self.startTime = startTime
self.endTime = None
self.text = ""
def setText(self, text):
self.text = text
def setFinished(self, endTime):
self.endTime = endTime
def isFinished(self):
return self.endTime is not None
class Message(object): class Message(object):
def __init__(self, id, text): def __init__(self, id, text):
@ -14,39 +32,97 @@ class Message(object):
self.text = text self.text = text
self.isStart = False self.isStart = False
self.reply = None self.reply = None
self.replyTime = None # self.replyTime = None
self.audioFile= None self.audioFile= None
self.interruptCount = 0
self.afterrunTime = 0. # the time after this message to allow for interrupts
self.finishTime = None # message can be finished without finished utterance (with instant replycontains)
@classmethod @classmethod
def initFromJson(message, data, story): def initFromJson(message, data, story):
msg = message(data['@id'], data['text']) msg = message(data['@id'], data['text'])
msg.isStart = data['start'] if 'start' in data else False msg.isStart = data['start'] if 'start' in data else False
msg.afterrunTime = data['afterrun'] if 'afterrun' in data else 0.
if 'audio' in data: if 'audio' in data:
msg.audioFile = data['audio']['file'] msg.audioFile = data['audio']['file']
return msg return msg
def setReply(self, text, replyTime): def setReply(self, reply):
self.reply = text self.reply = reply
self.replyTime = replyTime
def hasReply(self): def hasReply(self):
return self.reply is not None return self.reply is not None
def getReply(self): def getReply(self):
if self.reply is None: if not self.hasReply():
raise Exception( raise Exception(
"Getting reply while there is none! {0}".format(self.id)) "Getting reply while there is none! {0}".format(self.id))
return self.reply return self.reply
def isFinished(self):
return self.finishTime is not None
def setFinished(self, currentTime):
self.finishTime = currentTime
def getFinishedTime(self):
return self.finishTime
def getLogSummary(self): def getLogSummary(self):
return { return {
'id': self.id, 'id': self.id,
'time': self.replyTime, 'time': None if self.reply is None else [u.startTime for u in self.reply.utterances],
'replyText': self.reply 'replyText': None if self.reply is None else [u.text for u in self.reply.utterances]
} }
class Reply(object):
def __init__(self, message: Message):
self.forMessage = None
self.utterances = []
self.setForMessage(message)
def setForMessage(self, message: Message):
self.forMessage = message
message.setReply(self)
def getLastUtterance(self) -> Utterance:
if not self.hasUtterances():
return None
return self.utterances[-1]
def getFirstUtterance(self) -> Utterance:
if not self.hasUtterances():
return None
return self.utterances[0]
def hasUtterances(self) -> bool:
return len(self.utterances) > 0
def addUtterance(self, utterance: Utterance):
self.utterances.append(utterance)
def getText(self) -> str:
return ". ".join([u.text for u in self.utterances])
def getActiveUtterance(self, currentTime) -> Utterance:
"""
If no utterance is active, create a new one. Otherwise return non-finished utterance for update
"""
if len(self.utterances) < 1 or self.getLastUtterance().isFinished():
u = Utterance(currentTime)
self.addUtterance(u)
else:
u = self.getLastUtterance()
return u
def isSpeaking(self):
u = self.getLastUtterance()
if u is not None and not u.isFinished():
return True
return False
class Condition(object): class Condition(object):
""" """
A condition, basic conditions are built in, custom condition can be given by A condition, basic conditions are built in, custom condition can be given by
@ -86,28 +162,57 @@ class Condition(object):
return now - story.lastMsgFinishTime >= float(self.vars['seconds']) return now - story.lastMsgFinishTime >= float(self.vars['seconds'])
def _hasMetReplyContains(self, story): def _hasMetReplyContains(self, story) -> bool:
if not story.currentMessage.hasReply(): """
Check the reply for specific characteristics:
- regex: regular expression. If empy, just way for isFinished()
- delays: an array of [{'minReplyDuration', 'waitTime'},...]
- minReplyDuration: the nr of seconds the reply should take. Preferably have one with 0
- waitTime: the time to wait after isFinished() before continuing
"""
r = story.currentReply # make sure we keep working with the same object
if not r or not r.hasUtterances():
return False return False
if 'regex' in self.vars: if 'regex' in self.vars and len(self.vars['regex']):
if 'regexCompiled' not in self.vars: if 'regexCompiled' not in self.vars:
# Compile once, as we probably run it more than once # Compile once, as we probably run it more than once
self.vars['regexCompiled'] = re.compile(self.vars['regex']) self.vars['regexCompiled'] = re.compile(self.vars['regex'])
result = re.match(self.vars['regexCompiled'], story.currentMessage.getReply())
t = story.currentReply.getText().lower()
logger.log(LOG_BS, 'attempt regex: {} on {}'.format(self.vars['regex'], t))
result = self.vars['regexCompiled'].search(t)
if result is None: if result is None:
#if there is something to match, but not found, it's never ok
return False return False
logger.debug('Got match on {}'.format(self.vars['regex']))
results = result.groupdict() results = result.groupdict()
for captureGroup in results: for captureGroup in results:
story.variables[captureGroup] = results[captureGroup] story.variables[captureGroup] = results[captureGroup]
logger.critical("Regex not implemented yet")
# return True # TODO: implement 'instant match' -> don't wait for isFinished()
if r.isSpeaking():
logger.log(LOG_BS, "is speaking")
return False return False
if 'contains' in self.vars: # print(self.vars)
if self.vars['contains'] == '*': # either there's a match, or nothing to match at all
return True if 'delays' in self.vars:
return self.vars['contains'] in story.currentMessage.getReply() replyDuration = story.timer.getElapsed() - r.getFirstUtterance().endTime
timeSinceReply = story.timer.getElapsed() - r.getLastUtterance().endTime
delays = sorted(self.vars['delays'], key=lambda k: float(k['minReplyDuration']), reverse=True)
for delay in delays:
if replyDuration > float(delay['minReplyDuration']):
logger.log(LOG_BS, f"check delay duration is now {replyDuration}, already waiting for {timeSinceReply}, have to wait {delay['waitTime']}")
if timeSinceReply > float(delay['waitTime']):
return True
break # don't check other delays
# wait for delay to match
return False
# There is a match and no delay say, person finished speaking. Go ahead sir!
return True
def getLogSummary(self): def getLogSummary(self):
return { return {
@ -245,6 +350,7 @@ class Story(object):
self.commands = [] # queue of commands to send self.commands = [] # queue of commands to send
self.log = [] # all nodes/elements that are triggered self.log = [] # all nodes/elements that are triggered
self.currentMessage = None self.currentMessage = None
self.currentReply = None
self.timer = Stopwatch() self.timer = Stopwatch()
self.isRunning = False self.isRunning = False
@ -312,6 +418,7 @@ class Story(object):
self.events = [] # queue of received events self.events = [] # queue of received events
self.commands = [] # queue of commands to send self.commands = [] # queue of commands to send
self.log = [] # all nodes/elements that are triggered self.log = [] # all nodes/elements that are triggered
self.currentReply = None
def add(self, obj): def add(self, obj):
if obj.id in self.elements: if obj.id in self.elements:
@ -369,26 +476,26 @@ class Story(object):
if e['event'] == 'speech': if e['event'] == 'speech':
# message is still playing: # message is still playing:
if self.currentMessage and not self.lastMsgFinishTime: if self.currentMessage and not self.lastMsgFinishTime and self.previousReply and self.previousReply.forMessage.interruptCount < 4:
#interrupt: timeDiff = self.timer.getElapsed() - self.previousReply.forMessage.getFinishedTime()
# FINISH THIS!!! if self.previousReply.forMessage.afterrunTime > timeDiff:
# self.hugvey.sendCommand({ #interrupt only in given interval:
# 'action': 'stop', logger.warn("Interrupt message, replay {}".format(self.previousReply.forMessage.id))
# 'id': self.currentMessage.id, self.currentReply = self.previousReply
# }) self.previousReply.forMessage.interruptCount += 1
# .... self.currentMessage = self.setCurrentMessage(self.previousReply.forMessage)
pass
# log if somebody starts speaking # log if somebody starts speaking
# TODO: use pausing timer
# TODO: implement interrupt # TODO: implement interrupt
if self.lastSpeechStartTime is None or self.lastSpeechStartTime < self.lastMsgTime: if self.currentReply is None:
self.lastSpeechStartTime = e['time'] self.currentReply= Reply(self.currentMessage)
utterance = self.currentReply.getActiveUtterance(self.timer.getElapsed())
utterance.setText(e['transcript'])
if e['is_final']: if e['is_final']:
# final result utterance.setFinished(self.timer.getElapsed())
self.lastSpeechEndTime = e['time']
self.currentMessage.setReply(e['transcript'], self.timer.getElapsed())
def _processDirections(self, directions): def _processDirections(self, directions):
for direction in directions: for direction in directions:
@ -399,6 +506,7 @@ class Story(object):
direction.setMetCondition(condition) direction.setMetCondition(condition)
self.addToLog(condition) self.addToLog(condition)
self.addToLog(direction) self.addToLog(direction)
self.currentMessage.setFinished(self.timer.getElapsed())
self.setCurrentMessage(direction.msgTo) self.setCurrentMessage(direction.msgTo)
return direction return direction
@ -439,9 +547,19 @@ class Story(object):
logger.info("Stop renderer") logger.info("Stop renderer")
def setCurrentMessage(self, message): def setCurrentMessage(self, message):
if self.currentMessage and not self.lastMsgFinishTime:
logger.info("Interrupt playback {}".format(self.currentMessage.id))
# message is playing
self.hugvey.sendCommand({
'action': 'stop',
'id': self.currentMessage.id,
})
self.currentMessage = message self.currentMessage = message
self.lastMsgTime = time.time() self.lastMsgTime = time.time()
self.lastMsgFinishTime = None # to be filled in by the event self.lastMsgFinishTime = None # to be filled in by the event
self.previousReply = self.currentReply # we can use this for interrptions
self.currentReply = self.currentMessage.reply
logger.info("Current message: ({0}) \"{1}\"".format( logger.info("Current message: ({0}) \"{1}\"".format(
message.id, message.text)) message.id, message.text))

View file

@ -19,18 +19,20 @@ if __name__ == '__main__':
argParser.add_argument( argParser.add_argument(
'--verbose', '--verbose',
'-v', '-v',
action="store_true", action='count', default=0
) )
args = argParser.parse_args() args = argParser.parse_args()
# print(coloredlogs.DEFAULT_LOG_FORMAT) # print(coloredlogs.DEFAULT_LOG_FORMAT)
# exit() # exit()
loglevel = logging.NOTSET if args.verbose > 1 else logging.DEBUG if args.verbose > 0 else logging.INFO
coloredlogs.install( coloredlogs.install(
level=logging.DEBUG if args.verbose else logging.INFO, level=loglevel,
# default: "%(asctime)s %(hostname)s %(name)s[%(process)d] %(levelname)s %(message)s" # default: "%(asctime)s %(hostname)s %(name)s[%(process)d] %(levelname)s %(message)s"
fmt="%(asctime)s %(hostname)s %(name)s[%(process)d,%(threadName)s] %(levelname)s %(message)s" fmt="%(asctime)s %(hostname)s %(name)s[%(process)d,%(threadName)s] %(levelname)s %(message)s"
) )
command = CentralCommand(debug_mode=args.verbose) command = CentralCommand(debug_mode=args.verbose > 0)
command.loadConfig(args.config) command.loadConfig(args.config)
command.start() command.start()

2
local

@ -1 +1 @@
Subproject commit e799809a59522d0f68f99f668a9ddf0f5f629912 Subproject commit 16738c586f1938814b12ad50be2af06ffa2bf37e

View file

@ -16,6 +16,10 @@ body {
.btn:hover, input[type="submit"]:hover { .btn:hover, input[type="submit"]:hover {
background: #666; } background: #666; }
input[type="number"] {
width: 80px;
text-align: right; }
@keyframes dash-animation { @keyframes dash-animation {
to { to {
stroke-dashoffset: -1000; } } stroke-dashoffset: -1000; } }
@ -147,8 +151,8 @@ img.icon {
display: block; display: block;
margin: 0 -10px; margin: 0 -10px;
padding: 5px 10px; } padding: 5px 10px; }
#story label input, #story label select, #story label .label-value { #story label input, #story label select, #story label .label-value, #story label .label-unit {
float: right; } float: right; }
#story label:nth-child(odd) { #story label:nth-child(odd) {
background-color: rgba(255, 255, 255, 0.3); } background-color: rgba(255, 255, 255, 0.3); }
#story #msg { #story #msg {

View file

@ -282,7 +282,7 @@ class Graph {
let startAttributes = { let startAttributes = {
'name': msg['@id'] + '-start', 'name': msg['@id'] + '-start',
'disabled': true, 'readonly': 'readonly',
'type': 'checkbox', 'type': 'checkbox',
'on': { 'on': {
'change': this.getEditEventListener() 'change': this.getEditEventListener()
@ -354,6 +354,19 @@ class Graph {
} }
} }
} ) } )
),
crel( 'label',
crel( 'span', {
"title": "The time after the reply in which one can still interrupt to continue speaking"
}, 'Afterrun time' ),
crel( 'input', {
'name': msg['@id'] + '-afterrunTime',
'value': msg['afterrunTime'],
'type': 'number',
'on': {
'change': this.getEditEventListener()
}
} )
) )
); );
msgEl.appendChild( msgInfoEl ); msgEl.appendChild( msgInfoEl );
@ -448,6 +461,7 @@ class Graph {
for ( let conditionId of direction['conditions'] ) { for ( let conditionId of direction['conditions'] ) {
let condition = this.getNodeById( conditionId ); let condition = this.getNodeById( conditionId );
console.log(conditionId, condition);
directionEl.appendChild( this.getEditConditionFormEl( condition, direction ) ); directionEl.appendChild( this.getEditConditionFormEl( condition, direction ) );
} }
@ -464,11 +478,13 @@ class Graph {
'on': { 'on': {
'click': ( e ) => { 'click': ( e ) => {
if(confirm("Do you want to remove this condition?")) { if(confirm("Do you want to remove this condition?")) {
console.log('remove condition for direction', condition, direction);
panopticon.graph.rmCondition( condition, direction ); panopticon.graph.rmCondition( condition, direction );
} }
} }
} }
}, 'delete') }, 'delete'),
...this.getConditionInputsForType(condition['type'], condition['@id'], condition['vars'])
) )
let labelLabel = document.createElement( 'label' ); let labelLabel = document.createElement( 'label' );
labelLabel.innerHTML = "Description"; labelLabel.innerHTML = "Description";
@ -482,50 +498,121 @@ class Graph {
labelLabel.appendChild( labelInput ); labelLabel.appendChild( labelInput );
conditionEl.appendChild( labelLabel ); conditionEl.appendChild( labelLabel );
for ( let v in condition['vars'] ) {
let varLabel = document.createElement( 'label' ); // for ( let v in condition['vars'] ) {
varLabel.innerHTML = v; // let varLabel = document.createElement( 'label' );
let varInput = document.createElement( 'input' ); // varLabel.innerHTML = v;
if ( v == 'seconds' ) { // let varInput = document.createElement( 'input' );
varInput.type = 'number'; // if ( v == 'seconds' ) {
} // varInput.type = 'number';
varInput.name = `${condition['@id']}-vars.${v}`; // }
varInput.value = condition['vars'][v]; // varInput.name = `${condition['@id']}-vars.${v}`;
varInput.addEventListener( 'change', this.getEditEventListener() ); // varInput.value = condition['vars'][v];
varLabel.appendChild( varInput ); // varInput.addEventListener( 'change', this.getEditEventListener() );
conditionEl.appendChild( varLabel ); // varLabel.appendChild( varInput );
} // conditionEl.appendChild( varLabel );
// }
return conditionEl; return conditionEl;
} }
getConditionTypes() { getConditionTypes() {
if ( typeof this.conditionTypes === 'undefined' ) { return {
// type: vars: attribtes for crel()
this.conditionTypes = {
'timeout': { 'timeout': {
'seconds': { 'type': 'number', 'value': 10, 'min': 0, 'step': 0.1 } 'seconds': { 'type': 'number', 'value': 10, 'min': 0, 'step': 0.1, 'unit': "s" }
}, },
'replyContains': { 'replyContains': {
'regex': { 'value': '.+' } 'delays.0.minReplyDuration': { 'type': 'number', 'value': 0, 'min': 0, 'step': 0.1, 'label': 'Delay 1 - reply duration', 'unit': "s", 'readonly': 'readonly' },
'delays.0.waitTime': { 'type': 'number', 'value': 3, 'min': 0, 'step': 0.1 , 'label': 'Delay 1 - wait time', 'unit': "s" },
'delays.1.minReplyDuration': { 'type': 'number', 'value': 5, 'min': 0, 'step': 0.1, 'label': 'Delay 2 - reply duration', 'unit': "s" },
'delays.1.waitTime': { 'type': 'number', 'value': 1, 'min': 0, 'step': 0.1, 'label': 'Delay 2 - time', 'unit': "s" },
'delays.2.minReplyDuration': { 'type': 'number', 'value': 10, 'min': 0, 'step': 0.1, 'label': 'Delay 3 - reply duration', 'unit': "s" },
'delays.2.waitTime': { 'type': 'number', 'value': 0, 'min': 0, 'step': 0.1, 'label': 'Delay 3 - time', 'unit': "s" },
'regex': { 'value': '','placeholder': "match any input" },
'instantMatch': { 'value': '', 'title': "When matched, don't wait for reply to finish. Instantly take this direction.", 'type':'checkbox' },
} }
} };
}
return this.conditionTypes;
} }
fillConditionFormForType( conditionForm, type ) { getConditionInputsForType( type, conditionId, values ) {
conditionForm.innerHTML = ""; let inputs = [];
let vars = this.getConditionTypes()[type]; let vars = this.getConditionTypes()[type];
for ( let v in vars ) { for ( let v in vars ) {
let attr = vars[v]; let attr = vars[v];
attr['name'] = v; attr['name'] = typeof conditionId == 'undefined' ? v : `${conditionId}-vars.${v}`;
conditionForm.appendChild( if(typeof values != 'undefined') {
let value = this._getValueForPath(v, values);
attr['value'] = typeof value == 'undefined' ? "": value;
attr['on'] = {
'change': this.getEditEventListener()
} ;
} else {
console.log(attr);
}
inputs.push(
crel( 'label', crel( 'label',
crel( 'span', v ), crel( 'span', {
'title': attr.hasOwnProperty('title') ? attr['title'] : ""
}, attr.hasOwnProperty('label') ? attr['label'] : v ),
crel( 'input', attr ) crel( 'input', attr )
// crel('span', {'class': 'label-unit'}, attr.hasOwnProperty('unit') ? attr['unit'] : "" )
) )
); );
} }
return inputs;
}
fillConditionFormForType( conditionForm, type, values ) {
conditionForm.innerHTML = "";
let inputs = this.getConditionInputsForType(type);
for(let i of inputs) {
conditionForm.appendChild(i);
}
}
_getValueForPath(path, vars) {
path = path.split( '.' ); // use vars.test to set ['vars']['test'] = value
let v = vars;
for ( let i = 0; i < path.length; i++ ) {
if(!isNaN(parseInt(path[i])) && isFinite(path[i])) {
// is int, use array, instead of obj
path[i] = parseInt(path[i]);
}
v = v[path[i]];
if(typeof v == 'undefined') {
break;
}
}
return v;
}
/**
* Save an array path (string) with a value to an object. Used to turn
* strings into nested arrays
* @param string path
* @param {any} value
* @param array|object vars
*/
_formPathToVars(path, value, vars) {
path = path.split( '.' ); // use vars.test to set ['vars']['test'] = value
console.log(path);
let res = vars;
for ( let i = 0; i < path.length; i++ ) {
if ( i == ( path.length - 1 ) ) {
console.log( 'last', path[i] );
res[path[i]] = value;
} else {
if(!isNaN(parseInt(path[i+1])) && isFinite(path[i+1])) {
// is int, use array, instead of obj
path[i+1] = parseInt(path[i+1]);
}
if(typeof res[path[i]] == 'undefined') {
res[path[i]] = typeof path[i+1] == 'number' ? [] : {}
}
res = res[path[i]];
}
}
return vars;
} }
getAddConditionFormEl( direction ) { getAddConditionFormEl( direction ) {
@ -551,8 +638,10 @@ class Graph {
form.delete( 'label' ); form.delete( 'label' );
let vars = {}; let vars = {};
for ( var pair of form.entries() ) { for ( var pair of form.entries() ) {
vars[pair[0]] = pair[1]; vars = g._formPathToVars(pair[0], pair[1], vars);
} }
// TODO: checkboxes
console.log("Createded", vars);
g.addConditionForDirection( type, label, vars, direction ); g.addConditionForDirection( type, label, vars, direction );
} }
} }
@ -602,12 +691,14 @@ class Graph {
// TODO // TODO
if ( typeof direction != 'undefined' ) { if ( typeof direction != 'undefined' ) {
let pos = direction['conditions'].indexOf(id); let pos = direction['conditions'].indexOf(id);
console.log('delete', id, 'on direction');
if(pos > -1) { if(pos > -1) {
direction['conditions'].splice(pos, 1); direction['conditions'].splice(pos, 1);
} }
for(let dir of this.directions) { for(let dir of this.directions) {
if(dir['conditions'].indexOf(id) > 0) { // console.log('check if condition exists for dir', dir)
if(dir['conditions'].indexOf(id) > -1) {
console.log("Condition still in use"); console.log("Condition still in use");
this.updateFromData(); this.updateFromData();
this.build(); this.build();
@ -615,7 +706,8 @@ class Graph {
return; return;
} }
} }
this._rmNode( id ); console.log('No use, remove', condition)
this._rmNode( condition );
} else { } else {
for(let dir of this.directions) { for(let dir of this.directions) {
let pos = dir['conditions'].indexOf(id); let pos = dir['conditions'].indexOf(id);
@ -623,7 +715,9 @@ class Graph {
dir['conditions'].splice(pos, 1); dir['conditions'].splice(pos, 1);
} }
} }
this._rmNode( id );
console.log('remove condition?', id)
this._rmNode( condition );
} }
this.updateMsg(); this.updateMsg();
} }
@ -648,7 +742,8 @@ class Graph {
"@id": this.language_code.substring( 0, 2 ) + "-n" + Date.now().toString( 36 ), "@id": this.language_code.substring( 0, 2 ) + "-n" + Date.now().toString( 36 ),
"@type": "Msg", "@type": "Msg",
"text": "New", "text": "New",
"start": false "start": false,
"afterrunTime": 0.5,
} }
this.data.push( msg ); this.data.push( msg );
this.updateFromData(); this.updateFromData();

View file

@ -23,6 +23,11 @@ body{
} }
} }
input[type="number"] {
width: 80px;
text-align:right;
}
@keyframes dash-animation { @keyframes dash-animation {
to { to {
stroke-dashoffset: -1000; stroke-dashoffset: -1000;
@ -244,9 +249,9 @@ img.icon{
display: block; display: block;
margin: 0 -10px; margin: 0 -10px;
padding: 5px 10px; padding: 5px 10px;
} input,select, .label-value, .label-unit{
label input,label select, label .label-value{ float: right;
float: right; }
} }
label:nth-child(odd){ label:nth-child(odd){
background-color: rgba(255,255,255,0.3); background-color: rgba(255,255,255,0.3);