improv-salet/index.coffee

247 lines
6.7 KiB
CoffeeScript
Raw Normal View History

2017-11-10 11:11:35 +02:00
defaults =
filters: []
reincorporate: false
persistence: true
audit: false
salienceFormula: (a) ->
return a
class Improv
constructor: (snippets, options = {}) ->
# Constructor for Improv generators.
# We don't want to mutate the options object we've been given we don't know
# where it's been.
spec = {}
$.extend(spec, defaults)
$.extend(spec, options)
@snippets = snippets
@filters = spec.filters
@reincorporate = Boolean(spec.reincorporate)
@persistence = Boolean(spec.persistence)
@audit = Boolean(spec.audit)
@salienceFormula = spec.salienceFormula
@builtins = spec.builtins
@history = []
@tagHistory = []
if @audit
@instantiateAuditData()
###
Actually generate text. Separate from #gen() because we don't want to clear
history or error-handling data while a call to #gen() hasn't finished
returning.
###
__gen: (snippet, model) ->
###
For the sake of better error handling, we try to keep an accurate record
of what snippet is being generated at any given time.
###
if (typeof model.bindings == 'object' && model.bindings[snippet])
# The snippet already exists in the model's bindings.
return model.bindings[snippet]
@__currentSnippet = snippet
chosenPhrase = @selectPhrase(@scoreFilter(@applyFilters(snippet, model)), model)
@history.unshift(chosenPhrase)
if @audit
phraseTotal = @__phraseAudit.get(snippet).get(chosenPhrase)
@__phraseAudit.get(snippet).set(chosenPhrase, phraseTotal + 1)
output = template(chosenPhrase, model, @__gen.bind(this), this)
if @snippets[snippet].bind
model.bindings ?= {}
model.bindings[snippet] = output
return output
###
This is a getter so that the internals of how auditing data is stored
and calculated can change without changing the API.
###
phraseAudit: () ->
if !@audit
throw new Error('Tried retriving audit from generator not in auditing mode.')
return @__phraseAudit
###
Generate text (user-facing API). Since this function can recur, most of
the heavy lifting is done in __gen().
###
gen: (snippet, model = {}) ->
# Make sure the model has a tag property.
model.tags ?= []
if not @snippets[snippet]?
throw new Error("Tried generating snippet \"#{snippet}\", but no such snippet exists in spec")
output = @__gen(snippet, model)
if !@persistence
@clearHistory()
@clearTagHistory()
return output
# Add a group's tags to the model, for reincorporation.
mergeTags: (model, groupTags) ->
mergeTag = (a, b) ->
if a.length < b.length
return b
return a
for a in groupTags
# Find the corresponding tag in the model.
site = model.tags.findIndex (b) =>
a[0] == b[0]
if site == -1
# No such tag simply add the group's tags to the model.
model.tags = model.tags.concat([a])
else
model.tags[site] = mergeTag(model.tags[site], a)
return model
# Once we have a list of suitable groups, finally select a phrase at random.
selectPhrase: (groups, model) ->
log = () =>
if @audit
console.log(groups)
console.log(model)
throw new Error("Ran out of phrases in #{groups} while generating #{@__currentSnippet}")
if groups.length == 0
log()
phrases = @flattenGroups(groups)
if phrases.length == 0
log()
if !this
console.log('not this either!')
chosen = salet.rnd.randomElement(phrases)
if (@reincorporate)
@mergeTags(model, chosen[1])
if Array.isArray(chosen[1])
@tagHistory = chosen[1].concat(@tagHistory)
return chosen[0]
###
Run the filters through an individual group.
###
applyFiltersToGroup: (group, model) ->
output = { score: 0 }
# Make sure the group has tags.
group.tags ?= []
# Since we might return a different group than we got, we use a variable.
currentGroup = group
for cb in @filters
if not output.score?
return
cbOutput = cb.call(this, currentGroup, model)
scoreOffset = undefined
if Array.isArray(cbOutput)
# We got a tuple, meaning the filter wants to modify the group before
# moving on.
scoreOffset = cbOutput[0]
currentGroup = cbOutput[1]
else
scoreOffset = cbOutput
if scoreOffset == null
output.score = null
return
output.score += scoreOffset
output.group = currentGroup
return output
###
Starting with the raw list of groups, return a filtered list with
scores.
###
applyFilters: (snippetName, model) ->
if not @snippets[snippetName]?
throw new Error("Missing snippet object for snippet #{snippetName}.")
groups = @snippets[snippetName].groups
if !Array.isArray(groups)
throw new Error("Missing or bad groups array for snippet #{snippetName} was #{typeof groups}")
if @audit
console.log "Eligible groups for applyFilters, snippet #{@__currentSnippet}:"
console.log groups
validGroups = []
for group in groups
rgr = @applyFiltersToGroup(group, model)
if rgr? and rgr.score?
group.score = rgr.score
validGroups.push(group)
return validGroups
###
Starting with the scored list from applyFilters(), return a list that has
invalid groups scrubbed out and only includes groups with a score past
the threshold.
###
scoreFilter: (groups) ->
if @audit
console.log "Eligible groups for scoreFilter, snippet #{@__currentSnippet}: "
console.log groups
# Filter out groups emptied out by dryness()
if groups.length == 0
return groups
validGroups = []
for g in groups
if g.phrases.length > 0
validGroups.push(g)
maxScore = Number.NEGATIVE_INFINITY
for group in validGroups
if group.score > maxScore
maxScore = group.score
scoreThreshold = @salienceFormula(maxScore)
return validGroups.filter (o) =>
o.score >= scoreThreshold
###
Starting with a list of scored groups, flatten them into a simple list
of phrases.
###
flattenGroups: (groups) ->
if groups.length == 0
return Array()
return ```groups
.map(o => o.phrases.map(i => [i, o.tags]))
.reduce((a, b) => a.concat(b), []);
```
clearHistory: () ->
@history = []
clearTagHistory: () ->
@tagHistory = []
instantiateAuditData: () ->
###
Create and fill audit maps with starter data, ie zeroes.
###
@__phraseAudit = new Map()
self = this
for key in Object.keys(self.snippets)
self.__phraseAudit.set(key, new Map())
for group in self.snippets[key].groups
for phrase in group.phrases
self.phraseAudit().get(key).set(phrase, 0)