247 lines
6.7 KiB
CoffeeScript
247 lines
6.7 KiB
CoffeeScript
|
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)
|