Files
llm-tricks/synopsis_generator.lua
T
2026-07-13 05:17:51 -06:00

296 lines
9.2 KiB
Lua
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env luajit
package.path = (arg[0]:match("@?(.*/)") or arg[0]:match("@?(.*\\)")) .. "lib" .. package.config:sub(1, 1) .. "?.lua;" .. package.path
local utility = require "utility"
local json = utility.require("dkjson")
local default_model = "gemma4:12b-mlx"
local minimum_bytes = 1000
local maximum_bytes = 40000
local synopsis_prompt = [[
Generate a lengthy novel synopsis from the following:
]]
local scoring_prompt = [[
You are evaluating novel synopses for development priority. The goal is NOT to judge writing quality, grammar, or polish. The synopsis is only a rough idea. Assign an integer score from 1–100 for each category. Be extremely harsh with your scoring.
1. Hook
2. Originality
3. Memorability
4. Expansion Potential
5. Conflict Potential
6. Character Potential
7. Worldbuilding Potential
8. Emotional Potential
9. Curiosity
10. Overall Promise
Guidelines:
- Avoid clustering scores near the middle. Use the full 1–100 range.
- A score around 50 represents an average publishable premise.
- Scores above 85 should be rare and reserved for genuinely exceptional ideas.
- Scores below 25 should represent ideas with major conceptual weaknesses.
- Return only valid JSON.
Output format:
{
"hook": 0,
"originality": 0,
"memorability": 0,
"expansion_potential": 0,
"conflict_potential": 0,
"character_potential": 0,
"worldbuilding_potential": 0,
"emotional_potential": 0,
"curiosity": 0,
"overall_promise": 0
}
]]
local read_all = function(file_name)
return utility.open(file_name, "r", function(file)
return file:read("*all")
end)
end
local write_all = function(file_name, text)
return utility.open(file_name, "w", function(file)
file:write(text)
file:write("\n")
end)
end
local files
if utility.path_exists("PRIVATE_DATA/file_list.json") then
files = json.decode(read_all("PRIVATE_DATA/file_list.json"))
else
files = {}
end
-- strip YAML frontmatter (if present)
-- can error, will return nil & error message
local function strip_frontmatter(text)
local tab = text:split("\n")
if tab[1] == "---" then
table.remove(tab, 1)
while true do
local done = tab[1] == "---"
table.remove(tab, 1)
if done then
return table.concat(tab, "\n")
elseif #tab < 1 then
return nil, "Invalid YAML frontmatter."
end
end
end
return text
end
-- strip Markdown formatting of a JSON block
-- just returns the string if it doesn't match
local function strip_markdown_codeblock(text)
local tab = text:split("\n")
if tab[1] == "```json" then
table.remove(tab, 1)
table.remove(tab, #tab)
return table.concat(tab, "\n")
else
return text
end
end
local strip_reasoning = function(text, reasoning_lines)
if not reasoning_lines then reasoning_lines = {} end
local tab = text:split("\n")
table.remove(tab, 1) -- remove "Thinking..."
while true do
local done = tab[1] == "...done thinking."
local line = table.remove(tab, 1)
if done then
table.remove(tab, 1) -- remove newline after end of thinking
return table.concat(tab, "\n")
elseif #tab < 1 then
return text -- no reasoning output
else
reasoning_lines[#reasoning_lines + 1] = line -- export thinking lines
end
end
end
local send_prompt = function(text, model)
if type(text) == "table" then text = table.concat(text, "\n") end
local tmp_file_name = utility.tmp_file_name()
utility.open(tmp_file_name, "w", function(file)
file:write(text)
end)
-- word wrap breaks the raw output badly, so I need to implement my own for terminal output somehow
local output = utility.capture_safe("cat " .. tmp_file_name:enquote() .. " | ollama run " .. (model or default_model) .. " --nowordwrap")
os.execute("ollama stop " .. (model or default_model)) -- NOTE this makes things slower, but more stable
os.execute("rm " .. tmp_file_name)
if not output then error("ollama failed to generate output") end
output = output:sub(1, -2) -- strip extra newline from utility.capture_safe
local thinking = {}
output = strip_reasoning(output, thinking)
return output, thinking
end
local tree
tree = function(path, fn)
utility.list(path or ".", function(path_name)
local blacklist = {
[".git"] = true,
[".gitkeep"] = true,
[".gitignore"] = true,
[".DS_Store"] = true,
}
if blacklist[path_name] then return end
if utility.is_file(path_name) then
fn(path_name)
else
tree(path .. utility.path_separator .. path_name, fn)
end
end)
end
local get_file_size = function(file_name)
local file = io.open(file_name, "rb")
if file then
local size = file:seek("end")
file:close()
return size
end
end
local refresh_file_list = function()
local blacklist = { -- I'm only blacklisting binary formats because funny results happen with really invalid texts
"jpg", "mp4", "pdf", "png", "webp", "jpeg", "gif",
} for _, name in ipairs(blacklist) do blacklist[name] = true end
local new_files_list = {}
tree("PRIVATE_DATA/notebook", function(file_name)
local _, _, extension = utility.split_path_components(file_name)
if not blacklist[extension] then
new_files_list[#new_files_list + 1] = file_name
end
end)
files = new_files_list
write_all("PRIVATE_DATA/file_list.json", json.encode(new_files_list, { indent = true }))
end
local generate_and_score = function(file_name, text)
print("Writing synopsis...")
local synopsis = send_prompt(synopsis_prompt .. text)
print(synopsis)
print("Scoring synopsis...")
local scoring, thinking = send_prompt(scoring_prompt .. synopsis)
print(scoring)
local scoring_decoded = json.decode(scoring) -- likely will not work because it consistently returns Markdown instead of JSON
if not scoring_decoded then
scoring_decoded = json.decode(strip_markdown_codeblock(scoring))
end
local object = {
synopsis = synopsis,
scoring = scoring_decoded or scoring, -- either the correct values or a string of output that isn't JSON
thinking = thinking,
}
write_all("PRIVATE_DATA/synopses/" .. utility.uuid() .. ".json", json.encode(object, { indent = true }))
end
os.execute("mkdir -p PRIVATE_DATA/synopses")
if arg[1] == "refresh_file_list" then
print("Refresing file list...")
refresh_file_list()
end
if arg[1] == "repair_synopsis_exports" then
local path = "PRIVATE_DATA/synopses"
utility.list(path, function(path_name)
path_name = path .. utility.path_separator .. path_name
if path_name:find("%.json") then
local object = json.decode(read_all(path_name))
if type(object.scoring) == "table" then return end -- don't fuck with working pieces
local decoded = json.decode(strip_markdown_codeblock(object.scoring))
if decoded then
object.scoring = decoded
write_all(path_name, json.encode(object, { indent = true, }))
end
end
end)
os.exit(0)
end
if arg[1] == "export_ordered_list_of_prompts" then
local path = "PRIVATE_DATA/synopses"
local items = {}
local item_order = {}
utility.list(path, function(path_name)
local full_path = path .. utility.path_separator .. path_name
if path_name:find("%.json") then
local object = json.decode(read_all(full_path))
items[path_name] = object
if type(object.scoring) == "table" then
local s = object.scoring
local total_score = s.conflict_potential + s.emotional_potential + s.character_potential + s.worldbuilding_potential + s.expansion_potential + s.overall_promise + s.memorability + s.originality + s.curiosity + s.hook
item_order[#item_order + 1] = { path_name = path_name, total_score = total_score, }
end
end
end)
table.sort(item_order, function(A,B) return A.total_score > B.total_score end)
-- for k,v in pairs(item_order) do print(v.path_name,v.total_score) end
local output = {
"---",
"title: Ordered Synopses",
"author: [\"Gemma4:12b-mlx\", \"Tangent\", \"Ollama\"]",
"publisher: Tangent",
"---",
"",
}
for _, v in pairs(item_order) do
local item = items[v.path_name]
local text = item.synopsis
local tab = text:split("\n")
for index, line in ipairs(tab) do
if line:sub(1, 1) == "#" then
tab[index] = "#" .. tab[index]
end
end
-- output[#output + 1] = "# " .. v.path_name .. " (" .. v.total_score .. ")\n\n" .. item.synopsis .. "\n"
output[#output + 1] = "# " .. v.path_name .. " (" .. v.total_score .. ")\n\n" .. table.concat(tab, "\n") .. "\n"
output[#output + 1] = "## Scoring\n\n```json\n" .. json.encode(item.scoring, { indent = true, }) .. "\n```\n"
end
write_all("PRIVATE_DATA/Ordered Synopses.md", table.concat(output, "\n"))
os.exit(0)
end
print(#files .. " files to select from.")
if #files == 0 then
print("Run \"./synopsis_generator.lua refresh_file_list\" first.")
os.exit(1)
end
while true do
print("Selecting a file...")
local file_name = files[math.random(1, #files)]
local file_size = get_file_size(file_name)
if file_size > minimum_bytes and file_size <= maximum_bytes then
local text = read_all(file_name)
text = strip_frontmatter(text)
if #text > minimum_bytes and #text <= maximum_bytes then
print(file_name .. " chosen.")
generate_and_score(file_name, text) -- kind of the main function, innit?
-- os.exit(0)
end
end
end