Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 0 additions & 44 deletions app/jobs/sync_journal_job.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,6 @@ def perform(project_id, clear: false)

entries = parse_journal_entries(journal_content)

if entries.empty?
entries = parse_with_ai(journal_content)
end

return if entries.empty?

current_titles = entries.map { |e| e[:title] }
Expand Down Expand Up @@ -368,44 +364,4 @@ def build_entry(title, body)
time_spent: time_spent
}
end

def parse_with_ai(content)
prompt = <<~PROMPT
Parse this JOURNAL.md file into individual devlog entries. Each entry should have a date/title, the content, and time spent.

Journal content:
#{content.truncate(4000)}

Respond in valid JSON only, no markdown fences:
[{"title": "entry title with date", "content": "the entry content", "time_spent": "time if mentioned or null"}]
PROMPT

response = Net::HTTP.post(
URI("https://ai.hackclub.com/proxy/v1/chat/completions"),
{ model: "qwen/qwen3-32b", messages: [ { role: "user", content: prompt } ] }.to_json,
"Content-Type" => "application/json",
"Authorization" => "Bearer #{ENV['HACKCLUB_AI_API_KEY']}"
)

if response.is_a?(Net::HTTPSuccess)
ai_content = JSON.parse(response.body).dig("choices", 0, "message", "content") || ""
json_match = ai_content.match(/\[[\s\S]*\]/)
if json_match
data = JSON.parse(json_match[0])
return data.filter_map do |entry|
next if entry["title"].blank?
{
title: entry["title"],
content: entry["content"] || "",
time_spent: entry["time_spent"]
}
end
end
end

[]
rescue StandardError => e
Rails.logger.error("AI journal parsing failed: #{e.message}")
[]
end
end
105 changes: 105 additions & 0 deletions lib/tasks/audit_fabricated_devlogs.rake
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
def diagnose_format(content)
body = content.sub(/\A---\s*\n.*?\n---\s*\n/m, "")
levels = body.scan(/^(\#+)\s+.+$/).map { |h| h.first.length }.tally.sort.to_h

return "empty after frontmatter" if body.strip.empty?
return "bold date lines, no headings" if levels.empty? && body.match?(/^\s*\*\*.+\*\*\s*$/)
return "plain text, no headings" if levels.empty?
return "deep headings only (#{levels.keys.join(', ')}) - parser only reads 1, 2" if (levels.keys & [ 1, 2 ]).empty?

"headings " + levels.map { |lvl, n| "#{'#' * lvl}x#{n}" }.join(" ")
end

namespace :devlogs do
desc "Report git-journal devlogs with no matching heading in the current JOURNAL.md"
task audit_fabricated: :environment do
delay = ENV.fetch("AUDIT_DELAY", "0.3").to_f
job = SyncJournalJob.new

fully_fabricated = []
partial = []
unreachable = []

scope = Project.kept
.where(devlog_mode: "git")
.where.not(repo_link: [ nil, "" ])
.where.not(status: :rejected)
.joins(:devlogs)
.distinct

total = scope.count
puts "Auditing #{total} git-mode projects with devlogs\n\n"

scope.find_each(batch_size: 50).with_index do |project, i|
print "\r[#{i + 1}/#{total}] project #{project.id}".ljust(60)

parsed_repo = job.send(:parse_repo_url, project.repo_link)
unless parsed_repo
unreachable << { project: project, reason: "unparseable repo_link" }
next
end

content = job.send(:fetch_journal, parsed_repo, project.journal_branch.presence)
if content.blank?
unreachable << { project: project, reason: "JOURNAL.md not fetchable" }
next
end

heading_titles = job.send(:parse_journal_entries, content).map { |e| e[:title] }
devlogs = project.devlogs.to_a
orphans = devlogs.reject { |d| heading_titles.include?(d.title) }

next if orphans.empty?

record = {
project: project,
owner: project.user,
orphans: orphans,
heading_count: heading_titles.size,
journal_format: diagnose_format(content),
hours: orphans.sum { |d| d.time_hours.to_f }
}

heading_titles.empty? ? fully_fabricated << record : partial << record
ensure
sleep delay if delay.positive?
end

puts "\r".ljust(60)

report = lambda do |label, rows|
next if rows.empty?

puts "\n#{label} (#{rows.size} projects)"
puts "=" * 70
rows.sort_by { |r| -r[:hours] }.each do |r|
puts "project #{r[:project].id} #{r[:project].name}"
puts " owner: #{r[:owner].display_name} <#{r[:owner].email}> tier: #{r[:project].tier} status: #{r[:project].status}"
puts " repo: #{r[:project].repo_link}"
puts " orphan devlogs: #{r[:orphans].size} headings in journal: #{r[:heading_count]} hours: #{r[:hours].round(2)}"
puts " journal format: #{r[:journal_format]}"
r[:orphans].each do |d|
flag = d.lapse_url.present? ? " [has lapse_url]" : ""
puts " ##{d.id} #{d.entry_date} #{d.time_hours.to_f.round(2)}h #{d.title.truncate(60).inspect}#{flag}"
end
puts
end
end

report.call("FULLY FABRICATED - journal has no headings at all", fully_fabricated)
report.call("PARTIAL - some devlog titles match no heading (fabricated or renamed)", partial)

if unreachable.any?
puts "\nCOULD NOT CHECK (#{unreachable.size} projects)"
puts "=" * 70
unreachable.each { |u| puts " project #{u[:project].id}: #{u[:reason]}" }
end

puts "\n" + "=" * 70
puts "Projects audited: #{total}"
puts "Fully fabricated: #{fully_fabricated.size} projects, #{fully_fabricated.sum { |r| r[:orphans].size }} devlogs, #{fully_fabricated.sum { |r| r[:hours] }.round(2)}h"
puts "Partial orphans: #{partial.size} projects, #{partial.sum { |r| r[:orphans].size }} devlogs, #{partial.sum { |r| r[:hours] }.round(2)}h"
puts "Could not check: #{unreachable.size} projects"
puts "\nRead-only. Nothing was modified."
end
end
20 changes: 20 additions & 0 deletions test/jobs/sync_journal_job_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ def run_job(journal, commit_date: nil, clear: false)
job.perform(@project.id, clear: clear)
end

test "a journal with no entry headings creates no devlogs" do
run_job(<<~MD, commit_date: @today)
---
title: "Hackpad"
author: "Amy"
description: "A fairly basic macropad held togther with hopes and dreams"
created_at: "2026-09-05"
---
MD

assert_equal 0, @project.devlogs.count
end

test "an empty journal creates no devlogs" do
run_job("", commit_date: @today)
run_job(" \n\n", commit_date: @today)

assert_equal 0, @project.devlogs.count
end

test "an undated entry is credited to the day JOURNAL.md was last pushed" do
run_job("# Built the enclosure\n\n**Total time spent: 2h**\n", commit_date: @today - 1)

Expand Down
Loading