From e4afeb50c6691e73c60cf756733d1d57d8085855 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Fri, 22 Aug 2025 15:34:56 -0600 Subject: [PATCH 1/5] Asap 208 feedback UI (#265) * Add feedback item model. * Add feedback controller. * Add static markup for feedback widget. * Functional feedback UI. * Add deletion route and make feedback user-based. * Remove checkboxes. * Use auto timestamps. * Move hidden fields out of additional comments. * Wrap interface in form tag. * Use buttons for feedback. * Add a test for the feedback UI. * Clean up sass. * Specify fallback versioning. * Run accessibility scan on feedback form. * Fix linting issues. --- .../stylesheets/application.tailwind.css | 20 +++ app/controllers/documents_controller.rb | 4 +- app/controllers/feedback_items_controller.rb | 27 +++++ .../controllers/feedback_controller.js | 114 ++++++++++++++++++ app/javascript/controllers/index.js | 3 + .../recommendation_list_controller.js | 4 +- .../controllers/summarize_controller.js | 8 +- app/models/document.rb | 7 +- app/models/document_inference.rb | 5 + app/models/feedback_item.rb | 8 ++ app/models/user.rb | 1 + app/views/documents/_feedback.html.erb | 46 +++++++ app/views/documents/_modal_content.html.erb | 16 +-- .../documents/_recommendation_list.html.erb | 7 +- app/views/documents/_summary.html.erb | 19 +++ config/routes.rb | 7 ++ .../20250807171517_create_feedback_items.rb | 15 +++ lib/tasks/documents.rake | 21 ++-- .../scripts/local_accessibility_scan.sh | 2 +- spec/features/document_spec.rb | 88 ++++++++++++++ 20 files changed, 382 insertions(+), 40 deletions(-) create mode 100644 app/controllers/feedback_items_controller.rb create mode 100644 app/javascript/controllers/feedback_controller.js create mode 100644 app/models/feedback_item.rb create mode 100644 app/views/documents/_feedback.html.erb create mode 100644 app/views/documents/_summary.html.erb create mode 100644 db/migrate/20250807171517_create_feedback_items.rb diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index bfe0ddec..0b7103c7 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -136,3 +136,23 @@ @apply m-0 p-2 text-sm h-10; } } + + +.feedback-wrapper { + .sentiment-wrapper { + .sentiment:hover i, + .sentiment.active i { + @apply text-gray-500; + } + } + + .additional-feedback { + transition: max-height 0.5s ease; + max-height: 200px; + + &.collapsed { + @apply max-h-0 overflow-hidden + } + } + +} \ No newline at end of file diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index 47f6a37a..6db1ea2a 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -82,9 +82,7 @@ def update_summary_inference @document.inference_summary! request.base_url @document.reload end - render json: { - display_text: @document.summary - } + render json: {html: render_to_string(partial: "documents/summary", formats: [:html], locals: {document: @document})} end def update_recommendation_inference diff --git a/app/controllers/feedback_items_controller.rb b/app/controllers/feedback_items_controller.rb new file mode 100644 index 00000000..6cef2586 --- /dev/null +++ b/app/controllers/feedback_items_controller.rb @@ -0,0 +1,27 @@ +class FeedbackItemsController < ApplicationController + wrap_parameters false + def update_feedback_items + begin + batch_params["feedback_items"].each do |patched_item| + item = FeedbackItem.where(document_inference_id: patched_item["document_inference_id"], user_id: patched_item["user_id"]).first_or_create + item.assign_attributes(patched_item) + item.save! + end + rescue + return render json: {error: "Error updating feedback items."}, status: :unprocessable_entity + end + render json: {success: true} + end + + def delete_items + batch_params["feedback_items"].each do |patched_item| + FeedbackItem.where(document_inference_id: patched_item["document_inference_id"], user_id: patched_item["user_id"]).destroy_all + end + end + + private + + def batch_params + params.permit(feedback_items: [:id, :document_inference_id, :user_id, :sentiment, :comment]).to_h + end +end diff --git a/app/javascript/controllers/feedback_controller.js b/app/javascript/controllers/feedback_controller.js new file mode 100644 index 00000000..87531776 --- /dev/null +++ b/app/javascript/controllers/feedback_controller.js @@ -0,0 +1,114 @@ +import {Controller} from "@hotwired/stimulus" + +// Connects to data-controller="feedback" +export default class extends Controller { + + static targets = ["sentimentPositive", "sentimentNegative", "additionalFeedback", "status", "comment", "inference"] + + static values = { + userId: Number + } + + store = { + "sentiment": null, + "comment": "", + } + + connect() { + if (this.sentimentPositiveTarget.classList.contains("active")) { + this.store.sentiment = "positive"; + } else if (this.sentimentNegativeTarget.classList.contains("active")) { + this.store.sentiment = "negative"; + } + } + + handleFeedback(e) { + e.preventDefault(); + let feedbackEl = e.target; + if (feedbackEl.tagName === "I") { + feedbackEl = feedbackEl.parentElement + } + this.store.sentiment = feedbackEl.dataset.sentiment; + this.setWidgetDisplay(); + this.patchFeedback(); + } + + handleSubmit(e) { + e.preventDefault(); + this.store.comment = this.commentTarget.value; + this.patchFeedback(); + } + + setWidgetDisplay() { + if (this.store.sentiment === "negative") { + this.additionalFeedbackTarget.classList.remove("collapsed"); + this.sentimentNegativeTarget.classList.add("active"); + this.sentimentPositiveTarget.classList.remove("active"); + } else { + this.additionalFeedbackTarget.classList.add("collapsed"); + this.sentimentNegativeTarget.classList.remove("active"); + this.sentimentPositiveTarget.classList.add("active"); + // Clear any existing comment text. + this.store.comment = ""; + this.commentTarget.value = ""; + } + } + + getInferences() { + let selections = []; + this.inferenceTargets.forEach((inferenceTarget) => { + selections.push(inferenceTarget.dataset.inferenceId); + }); + return selections; + } + + showErrorMessage() { + let wrapper = this.element.querySelector(".feedback-interface") + wrapper.textContent = "There was an error saving your feedback. Please try again later."; + } + + async patchFeedback() { + this.statusTarget.classList.add("hidden"); + try { + const headers = { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content, + "Accept": "application/json" + } + let deleteData = {"feedback_items": []}; + this.getInferences(false).map((inference_id) => { + deleteData["feedback_items"].push({"document_inference_id": inference_id, "user_id": this.userIdValue}) + }) + const deleteRequest = await fetch("/feedback_items/delete_items", { + method: "DELETE", + headers: headers, + body: JSON.stringify(deleteData) + }) + if (!deleteRequest.ok) { + this.showErrorMessage(); + throw new Error("Failed to delete previous feedback items."); + } + let patchData = {"feedback_items": []}; + this.getInferences(true).map((inference_id) => { + patchData["feedback_items"].push(Object.assign({ + "document_inference_id": inference_id, + "user_id": this.userIdValue + }, this.store)) + }) + const patchResponse = await fetch("/feedback_items/update_feedback_items", { + method: "PATCH", + headers: headers, + body: JSON.stringify(patchData) + }) + if (patchResponse.ok) { + this.statusTarget.classList.remove("hidden"); + } else { + this.showErrorMessage(); + throw new Error("Failed to update new feedback items.") + } + } catch (error) { + console.error("Error updating documents:", error) + } + } + +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index d04fedad..38841ada 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -13,6 +13,9 @@ application.register("dropdown", DropdownController) import DropdownEditController from "./dropdown_edit_controller" application.register("dropdown-edit", DropdownEditController) +import FeedbackController from "./feedback_controller" +application.register("feedback", FeedbackController) + import FilterController from "./filter_controller" application.register("filter", FilterController) diff --git a/app/javascript/controllers/recommendation_list_controller.js b/app/javascript/controllers/recommendation_list_controller.js index 7015e3cf..f92cca8d 100644 --- a/app/javascript/controllers/recommendation_list_controller.js +++ b/app/javascript/controllers/recommendation_list_controller.js @@ -25,8 +25,8 @@ export default class extends Controller { }, }) if (response.ok) { - const jsonSummary = await response.json() - this.displayTarget.innerHTML = jsonSummary.html; + const jsonRecommendationList = await response.json() + this.displayTarget.innerHTML = jsonRecommendationList.html; } else { this.displayTarget.textContent = 'An error occurred getting the recommendation list for this document. Please try again later.'; throw new Error("Response was not OK") diff --git a/app/javascript/controllers/summarize_controller.js b/app/javascript/controllers/summarize_controller.js index 418f60c1..625e20f0 100644 --- a/app/javascript/controllers/summarize_controller.js +++ b/app/javascript/controllers/summarize_controller.js @@ -1,7 +1,7 @@ import { Controller } from "@hotwired/stimulus" export default class extends Controller { - static targets = ["display", "button", "preloader"] + static targets = ["summaryValue", "button", "preloader"] static values = { documentId: Number, @@ -20,11 +20,11 @@ export default class extends Controller { }, }) if (response.ok) { - const jsonSummary = await response.json() - this.displayTarget.textContent = jsonSummary.display_text; + const replacementSummary = await response.json() this.preloaderTarget.classList.add('hidden') + this.element.innerHTML = replacementSummary.html } else { - this.displayTarget.textContent = 'An error occurred summarizing this document. Please try again later.'; + this.summaryValueTarget.textContent = 'An error occurred summarizing this document. Please try again later.'; throw new Error("Response was not OK") } } catch (error) { diff --git a/app/models/document.rb b/app/models/document.rb index 09a7b1b1..eca33e21 100644 --- a/app/models/document.rb +++ b/app/models/document.rb @@ -120,9 +120,12 @@ def modification_year end end - def summary + def summary(return_model = false) summary = document_inferences.find_by(inference_type: "summary") - summary.present? ? summary.inference_value : nil + unless return_model + return summary.present? ? summary.inference_value : nil + end + summary end def last_changed_by_human?(field) diff --git a/app/models/document_inference.rb b/app/models/document_inference.rb index 2d3d463f..a707635b 100644 --- a/app/models/document_inference.rb +++ b/app/models/document_inference.rb @@ -22,7 +22,12 @@ class DocumentInference < ApplicationRecord }.freeze belongs_to :document + has_many :feedback_item validates :inference_type, inclusion: {in: INFERENCE_TYPES.keys.map(&:to_s)}, presence: true validates :inference_value, presence: true + + def get_user_feedback_items(user_id) + feedback_item.where(user_id: user_id).first + end end diff --git a/app/models/feedback_item.rb b/app/models/feedback_item.rb new file mode 100644 index 00000000..ea250cf6 --- /dev/null +++ b/app/models/feedback_item.rb @@ -0,0 +1,8 @@ +class FeedbackItem < ApplicationRecord + SENTIMENT_TYPES = %w[positive negative] + + belongs_to :document_inference + belongs_to :user + + validates :sentiment, inclusion: {in: SENTIMENT_TYPES}, presence: true +end diff --git a/app/models/user.rb b/app/models/user.rb index 71865233..c79c5d8f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -3,6 +3,7 @@ class User < ApplicationRecord :recoverable, :rememberable, :validatable, :trackable belongs_to :site, optional: true + has_many :feedback_item delegate :documents, to: :site, allow_nil: true def send_new_account_instructions? diff --git a/app/views/documents/_feedback.html.erb b/app/views/documents/_feedback.html.erb new file mode 100644 index 00000000..f0809510 --- /dev/null +++ b/app/views/documents/_feedback.html.erb @@ -0,0 +1,46 @@ +
+ <% first_document_inference = document_inferences.count > 1 ? document_inferences.find { |item| item.get_user_feedback_items(current_user.id).present? } || document_inferences.first : document_inferences.first %> + <% feedback_item = first_document_inference.present? ? first_document_inference.get_user_feedback_items(current_user.id) : nil %> +
"> +
+ +
+
\ No newline at end of file diff --git a/app/views/documents/_modal_content.html.erb b/app/views/documents/_modal_content.html.erb index 6cc94c5c..8ae6a864 100644 --- a/app/views/documents/_modal_content.html.erb +++ b/app/views/documents/_modal_content.html.erb @@ -22,18 +22,8 @@
-
-

<%= document.summary %>

- <% if document.summary.nil? %> - - - <% end %> +
+ <%= render partial: "documents/summary", locals: { document: document } %>
@@ -185,7 +175,7 @@ <%= version.created_at.strftime("%B %d, %Y %I:%M %p") %>
<% if version.whodunnit.present? %> - <% user=User.find(version.whodunnit) %> + <% user = User.find(version.whodunnit) %> <% if user.present? %> Author: <%= user.email %> <% end %> diff --git a/app/views/documents/_recommendation_list.html.erb b/app/views/documents/_recommendation_list.html.erb index 89ecdc90..d3dd002d 100644 --- a/app/views/documents/_recommendation_list.html.erb +++ b/app/views/documents/_recommendation_list.html.erb @@ -1,9 +1,7 @@ <%= render partial: "documents/exception_check_button", locals: { button_text: "Regenerate AI Exception Check" } %>
-

This suggestion was generated by - a Large - Language Model and while highly reliable, should still be subjected to careful verification.

-
+

This suggestion was generated by a Large Language Model and while highly reliable, should still be subjected to careful verification.

+
AI Exception Check
<% if document.accessibility_recommendation_from_inferences.present? %> @@ -27,4 +25,5 @@ <% end %>
+ <%= render partial: "documents/feedback", locals: { document_inferences: document.exceptions(false) } %>
\ No newline at end of file diff --git a/app/views/documents/_summary.html.erb b/app/views/documents/_summary.html.erb new file mode 100644 index 00000000..b4aacd01 --- /dev/null +++ b/app/views/documents/_summary.html.erb @@ -0,0 +1,19 @@ +
+ <% summary_inference = document.summary(return_model: true) %> +

<%= summary_inference.present? ? summary_inference.inference_value : "" %>

+ <% if summary_inference.nil? %> + + + <% end %> + <% if summary_inference.present? %> +
+ <%= render partial: "documents/feedback", locals: { document_inferences: [summary_inference] } %> +
+ <% end %> +
\ No newline at end of file diff --git a/config/routes.rb b/config/routes.rb index 37605b53..05218724 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -37,6 +37,13 @@ end end + resources :feedback_items do + collection do + patch :update_feedback_items + delete :delete_items + end + end + mount AsapPdf::API => "/api" get "api-docs", to: "api_docs#index" diff --git a/db/migrate/20250807171517_create_feedback_items.rb b/db/migrate/20250807171517_create_feedback_items.rb new file mode 100644 index 00000000..0c7299e6 --- /dev/null +++ b/db/migrate/20250807171517_create_feedback_items.rb @@ -0,0 +1,15 @@ +class CreateFeedbackItems < ActiveRecord::Migration[8.0] + def change + create_table :feedback_items do |t| + t.text :sentiment + t.text :comment + t.references :document_inference, null: true, foreign_key: {on_delete: :cascade} + t.references :user, null: true + t.timestamps + end + end + + def rollback + drop_table :feedback_items + end +end diff --git a/lib/tasks/documents.rake b/lib/tasks/documents.rake index a9b78177..a0c04b3b 100644 --- a/lib/tasks/documents.rake +++ b/lib/tasks/documents.rake @@ -110,21 +110,20 @@ namespace :documents do end desc "Add document inference" - task :add_document_inference, [:document_id, :inference_type, :inference_value, :inference_reason] => :environment do |t, args| + task :add_document_inference, [:document_id, :inference_type, :inference_value, :inference_reason, :include_feedback] => :environment do |t, args| doc = Document.find(args.document_id) if doc.nil? raise ActiveRecord::RecordNotFound end - begin - inference = DocumentInference.new( - inference_type: args.inference_type, - inference_value: args.inference_value, - inference_reason: args.inference_reason, - document: doc - ) - inference.save! - rescue ActiveRecord::RecordNotUnique - p "Inference #{args.inference_type} already exists for document #{args.document_id}. Skipping creation." + inference = DocumentInference.new( + inference_type: args.inference_type, + inference_value: args.inference_value, + inference_reason: args.inference_reason, + document: doc + ) + inference.save! + if args.include_feedback.to_s.strip.downcase == "true" + FeedbackItem.create!(document_inference: inference, sentiment: "negative", comment: "This is a negative comment.", user_id: 1) end end end diff --git a/python_components/accessibility_scan/scripts/local_accessibility_scan.sh b/python_components/accessibility_scan/scripts/local_accessibility_scan.sh index af8725a8..b4f60f87 100755 --- a/python_components/accessibility_scan/scripts/local_accessibility_scan.sh +++ b/python_components/accessibility_scan/scripts/local_accessibility_scan.sh @@ -5,7 +5,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # Add an inference to our test document. -"$SCRIPT_DIR"/../../../bin/rake "documents:add_document_inference[302,exception:is_archival,True,Fee fi fo fum.]" >/dev/null +"$SCRIPT_DIR"/../../../bin/rake "documents:add_document_inference[302,exception:is_archival,True,Fee fi fo fum.,true]" >/dev/null # Name of the image locally. LOCAL_ACCESSIBILITY_SCAN_IMAGE="asap:accessibility_scan" diff --git a/spec/features/document_spec.rb b/spec/features/document_spec.rb index d8b7a8f7..fb8df1a7 100644 --- a/spec/features/document_spec.rb +++ b/spec/features/document_spec.rb @@ -566,4 +566,92 @@ expect(page).to have_content "200-rtd_contract.pdf" end end + + it "collects feedback on LLM features" do + site = Site.create(name: "City of Denver", location: "Colorado", primary_url: "https://denvergov.org") + @current_user.site = site + @current_user.save! + doc = Document.create(url: "http://denvergov.org/docs/ex.ample.pdf", file_name: "ex.ample.pdf", document_category: "Agenda", site_id: site.id) + second_user = User.create(email: "seconduser@example.com", password: "password1231231232wordpass", site: site) + visit "/" + click_link "City of Denver" + within("#document-list") do + click_button "ex.ample.pdf" + end + # Wait for modal to open. + expect(page).to have_selector("#document-list .modal", visible: true, wait: 5) + within("#document-list .modal") do + # Test default state. + expect(page).to have_content "ex.ample.pdf" + expect(page).to have_no_content "Feedback on AI Response:" + click_button "AI Exception Check" + expect(page).to have_no_content "Feedback on AI Response:" + end + DocumentInference.new(document_id: doc.id, inference_type: "summary", inference_value: "A lovely example of accessible PDF practices.").save + DocumentInference.create(inference_type: "exception:is_application", inference_value: "True", inference_reason: "This is not used as an application or means of participation in government services.", document_id: doc.id) + DocumentInference.create(inference_type: "exception:is_archival", inference_value: "True", inference_reason: "This thing was made in 1988 and hasn't been opened since then.", document_id: doc.id) + visit "/" + click_link "City of Denver" + within("#document-list") do + # Test leaving some feedback. + click_button "ex.ample.pdf" + expect(page).to have_selector("#document-list .modal", visible: true, wait: 5) + expect(page).to have_content "Feedback on AI Response:" + expect(page).to have_content "A lovely example of accessible PDF practices." + click_button "AI Exception Check" + expect(page).to have_content "Feedback on AI Response:" + expect(FeedbackItem.count).to eq 0 + click_button "Summary" + page.find("[data-sentiment='positive']").click + expect(page).to have_selector "[data-feedback-target='status']", wait: 5, visible: true + expect(page).to have_selector "[data-sentiment='positive'].active" + expect(page).to have_no_selector "[data-sentiment='negative'].active" + expect(FeedbackItem.count).to eq 1 + click_button "AI Exception Check" + page.find("[data-sentiment='negative']").click + expect(page).to have_selector "[data-feedback-target='status']", wait: 5, visible: true + expect(page).to have_selector "[data-sentiment='negative'].active" + expect(page).to have_no_selector "[data-sentiment='positive'].active" + expect(FeedbackItem.count).to eq 3 + fill_in "Please provide details: (optional)", with: "just bad content" + click_button "Submit" + expect(FeedbackItem.count).to eq 3 + end + # Make sure user see's previous feedback. + visit "/" + click_link "City of Denver" + within("#document-list") do + click_button "ex.ample.pdf" + expect(page).to have_selector("#document-list .modal", visible: true, wait: 5) + expect(page).to have_selector "[data-sentiment='positive'].active" + expect(page).to have_no_selector "[data-sentiment='negative'].active" + click_button "AI Exception Check" + expect(page).to have_selector "[data-sentiment='negative'].active" + expect(page).to have_no_selector "[data-sentiment='positive'].active" + expect(page).to have_field "Please provide details: (optional)", with: "just bad content" + # Make sure changing feedback doesn't duplicate records. + page.find("[data-sentiment='positive']").click + expect(page).to have_selector "[data-feedback-target='status']", wait: 5, visible: true + expect(FeedbackItem.count).to eq 3 + end + # Log out and try another user. + Session.last.destroy + login_user(second_user) + visit "/" + click_link "City of Denver" + within("#document-list") do + click_button "ex.ample.pdf" + expect(page).to have_selector("#document-list .modal", visible: true, wait: 5) + expect(page).to have_content "Feedback on AI Response:" + expect(page).to have_content "A lovely example of accessible PDF practices." + expect(page).to have_no_selector "[data-sentiment='positive'].active" + expect(page).to have_no_selector "[data-sentiment='negative'].active" + click_button "AI Exception Check" + expect(page).to have_no_selector "[data-sentiment='positive'].active" + expect(page).to have_no_selector "[data-sentiment='negative'].active" + page.find("[data-sentiment='positive']").click + expect(page).to have_selector "[data-feedback-target='status']", wait: 5, visible: true + expect(FeedbackItem.count).to eq 5 + end + end end From 7eac47b8da9c527df4c850f058b5eb43737ed521 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Tue, 2 Sep 2025 08:28:32 -0600 Subject: [PATCH 2/5] Asap 216 collect more llm information (#286) * Return token usage and model name from document inference. * Set token usage and model name on API calls. * Update schema to incldue token usage and model name. * Get rid of pypdf. * Return commented out portion. * Remove pypdf. * Add model name to inference UIs. * Add llm author to tests. * Remove scratch script. * Fix up linting issues. * Fix tests. --- app/api/asap_pdf/api.rb | 4 ++ .../stylesheets/application.tailwind.css | 1 - .../documents/_recommendation_list.html.erb | 8 +++- app/views/documents/_summary.html.erb | 29 +++++++----- ...2152_add_llm_details_document_inference.rb | 6 +++ python_components/ci/requirements.txt | 1 - .../document_inference/helpers.py | 44 +++++++++---------- .../document_inference/schemas.py | 8 ++++ .../document_inference/requirements.txt | 1 - .../evaluation/utility/asap_inference.py | 2 +- spec/features/document_spec.rb | 36 ++++++++++++--- 11 files changed, 93 insertions(+), 47 deletions(-) create mode 100644 db/migrate/20250822152152_add_llm_details_document_inference.rb diff --git a/app/api/asap_pdf/api.rb b/app/api/asap_pdf/api.rb index d7c41912..33befe17 100644 --- a/app/api/asap_pdf/api.rb +++ b/app/api/asap_pdf/api.rb @@ -99,6 +99,8 @@ class API < Grape::API::Instance inference = DocumentInference.create(document_id: params[:id], inference_type: "summary") inference.inference_value = params[:result]["summary"] inference.is_active = true + inference.inference_model_name = params[:result]["inference_model"] + inference.token_details = params[:result]["usage"] inference.save! end if params[:inference_type] == "exception" @@ -110,6 +112,8 @@ class API < Grape::API::Instance inference.inference_confidence = params[:result]["#{result_boolean}_confidence"] inference.inference_reason = params[:result]["why_#{type}"] inference.is_active = true + inference.inference_model_name = params[:result]["inference_model"] + inference.token_details = params[:result]["usage"] inference.save! end end diff --git a/app/assets/stylesheets/application.tailwind.css b/app/assets/stylesheets/application.tailwind.css index 0b7103c7..07c625db 100644 --- a/app/assets/stylesheets/application.tailwind.css +++ b/app/assets/stylesheets/application.tailwind.css @@ -154,5 +154,4 @@ @apply max-h-0 overflow-hidden } } - } \ No newline at end of file diff --git a/app/views/documents/_recommendation_list.html.erb b/app/views/documents/_recommendation_list.html.erb index d3dd002d..1fd187d1 100644 --- a/app/views/documents/_recommendation_list.html.erb +++ b/app/views/documents/_recommendation_list.html.erb @@ -1,7 +1,8 @@ <%= render partial: "documents/exception_check_button", locals: { button_text: "Regenerate AI Exception Check" } %>

This suggestion was generated by a Large Language Model and while highly reliable, should still be subjected to careful verification.

-
+
+ <% exceptions = document.exceptions(false) %>
AI Exception Check
<% if document.accessibility_recommendation_from_inferences.present? %> @@ -9,7 +10,7 @@ <%= document.accessibility_recommendation_from_inferences %> <% end %> - <% document.exceptions(false).each do |exception| %> + <% exceptions.each do |exception| %> <% info = DocumentInference::INFERENCE_TYPES[exception.inference_type.to_sym] %>
">
@@ -24,6 +25,9 @@
"><%= exception.inference_reason %>
<% end %>
+ <% if exceptions.present? %> +
Generated by <%= exceptions.first.inference_model_name %>
+ <% end %>
<%= render partial: "documents/feedback", locals: { document_inferences: document.exceptions(false) } %>
\ No newline at end of file diff --git a/app/views/documents/_summary.html.erb b/app/views/documents/_summary.html.erb index b4aacd01..a717ef82 100644 --- a/app/views/documents/_summary.html.erb +++ b/app/views/documents/_summary.html.erb @@ -1,16 +1,21 @@
- <% summary_inference = document.summary(return_model: true) %> -

<%= summary_inference.present? ? summary_inference.inference_value : "" %>

- <% if summary_inference.nil? %> - - - <% end %> +
+ <% summary_inference = document.summary(return_model: true) %> +

<%= summary_inference.present? ? summary_inference.inference_value : "" %>

+ <% if summary_inference.nil? %> + + + <% end %> + <% if summary_inference.present? %> +
Generated by <%= summary_inference.inference_model_name %>
+ <% end %> +
<% if summary_inference.present? %>
<%= render partial: "documents/feedback", locals: { document_inferences: [summary_inference] } %> diff --git a/db/migrate/20250822152152_add_llm_details_document_inference.rb b/db/migrate/20250822152152_add_llm_details_document_inference.rb new file mode 100644 index 00000000..7c350366 --- /dev/null +++ b/db/migrate/20250822152152_add_llm_details_document_inference.rb @@ -0,0 +1,6 @@ +class AddLlmDetailsDocumentInference < ActiveRecord::Migration[8.0] + def change + add_column :document_inferences, :inference_model_name, :string + add_column :document_inferences, :token_details, :json + end +end diff --git a/python_components/ci/requirements.txt b/python_components/ci/requirements.txt index 72b663bd..bd181d0a 100644 --- a/python_components/ci/requirements.txt +++ b/python_components/ci/requirements.txt @@ -14,7 +14,6 @@ llm==0.26 pandas==2.2.3 pip-chill==1.0.3 pymupdf==1.25.5 -pypdf==6.0.0 pysocks==1.7.1 pytest-httpserver==1.1.3 requests-aws4auth==1.3.1 diff --git a/python_components/document_inference/document_inference/helpers.py b/python_components/document_inference/document_inference/helpers.py index ab9b010e..f9058e1b 100644 --- a/python_components/document_inference/document_inference/helpers.py +++ b/python_components/document_inference/document_inference/helpers.py @@ -6,7 +6,6 @@ import boto3 import fitz import llm -import pypdf import requests from document_inference.prompts import RECOMMENDATION, SUMMARY from document_inference.schemas import DocumentRecommendation, DocumentSummarySchema @@ -161,31 +160,32 @@ def document_inference_summary( ) response_json = json.loads(response.text()) logger.info("Inference complete. Validating response.") - DocumentSummarySchema.model_validate(response_json) + structured_output_model = DocumentSummarySchema.model_validate(response_json) + structured_output_model.inference_model = model.model_id + structured_output_model.usage = response.usage() logger.info("Validation complete.") - return response_json + return structured_output_model.model_dump() def document_inference_recommendation( model, document: dict, local_path: str, page_limit: int ) -> dict: logger.info("Beginning recommendation process.") - if not pypdf.PdfReader(local_path).is_encrypted: - # Convert to images. - logger.info("Converting to images!") - attachments = pdf_to_attachments(local_path, "/tmp/data", page_limit) - num_attachments = len(attachments) - logger.info(f"Created {num_attachments} images.") - populated_prompt = RECOMMENDATION.format(**document) - response = model.prompt( - populated_prompt, - attachments=attachments, - schema=DocumentRecommendation.model_json_schema(), - ) - response_json = json.loads(response.text()) - logger.info("Inference complete. Validating response.") - DocumentRecommendation.model_validate(response_json) - logger.info("Validation complete.") - else: - raise RuntimeError("Document was encrypted! Could not proceed.") - return response_json + # Convert to images. + logger.info("Converting to images!") + attachments = pdf_to_attachments(local_path, "/tmp/data", page_limit) + num_attachments = len(attachments) + logger.info(f"Created {num_attachments} images.") + populated_prompt = RECOMMENDATION.format(**document) + response = model.prompt( + populated_prompt, + attachments=attachments, + schema=DocumentRecommendation.model_json_schema(), + ) + response_json = json.loads(response.text()) + logger.info("Inference complete. Validating response.") + structured_output_model = DocumentRecommendation.model_validate(response_json) + structured_output_model.inference_model = model.model_id + structured_output_model.usage = response.usage() + logger.info("Validation complete.") + return structured_output_model.model_dump() diff --git a/python_components/document_inference/document_inference/schemas.py b/python_components/document_inference/document_inference/schemas.py index da01b318..cde0f73c 100644 --- a/python_components/document_inference/document_inference/schemas.py +++ b/python_components/document_inference/document_inference/schemas.py @@ -1,10 +1,16 @@ +from typing import Optional + +from llm.models import Usage from pydantic import BaseModel, Field +from pydantic.json_schema import SkipJsonSchema class DocumentSummarySchema(BaseModel): summary: str = Field( description="A two to three sentence summary of the provided document." ) + inference_model: SkipJsonSchema[str] = "" + usage: SkipJsonSchema[Optional[Usage]] = None class DocumentRecommendation(BaseModel): @@ -20,3 +26,5 @@ class DocumentRecommendation(BaseModel): why_application: str = Field( description="An explanation of why the document meets or does not meet exception 2: Preexisting Conventional Electronic Documents Exception" ) + inference_model: SkipJsonSchema[str] = "" + usage: SkipJsonSchema[Optional[Usage]] = None diff --git a/python_components/document_inference/requirements.txt b/python_components/document_inference/requirements.txt index 60feac84..f3685e36 100644 --- a/python_components/document_inference/requirements.txt +++ b/python_components/document_inference/requirements.txt @@ -13,6 +13,5 @@ llm-gemini==0.19.1 pillow>=11.3.0 pip-chill==1.0.3 pymupdf==1.25.5 -pypdf==6.0.0 requests>=2.32.4 tomli==2.0.1 diff --git a/python_components/evaluation/evaluation/utility/asap_inference.py b/python_components/evaluation/evaluation/utility/asap_inference.py index 302668d1..9cc58a4e 100644 --- a/python_components/evaluation/evaluation/utility/asap_inference.py +++ b/python_components/evaluation/evaluation/utility/asap_inference.py @@ -26,7 +26,7 @@ def get_inference_for_document( local_mode: bool, aws_env: str, page_number: int, -) -> None: +) -> dict: logger.info(f"Performing inference type {inference_type} for {document.url}...") if local_mode: url = ( diff --git a/spec/features/document_spec.rb b/spec/features/document_spec.rb index fb8df1a7..cf5e629b 100644 --- a/spec/features/document_spec.rb +++ b/spec/features/document_spec.rb @@ -281,7 +281,12 @@ end # Prep the doc so we can assess the summary space. # Note the extra quotes necessary for our string escaping. - DocumentInference.new(document_id: doc.id, inference_type: "summary", inference_value: '"A lovely example of accessible PDF practices."').save + DocumentInference.new( + document_id: doc.id, + inference_type: "summary", + inference_value: '"A lovely example of accessible PDF practices."', + inference_model_name: "gemini-friend" + ).save # Check out "History" tab and look for notes. visit "/" click_link("City of Denver") @@ -296,6 +301,7 @@ # Check for the summary we updated above. click_button "Summary" expect(page).to have_content("A lovely example of accessible PDF practices.") + expect(page).to have_content("Generated by gemini-friend") expect(page).to have_no_content "Summarize Document" # Test for the recommendation tab. click_button "AI Exception Check" @@ -303,9 +309,24 @@ expect(page).to have_no_content "This suggestion was generated by a Large Language Model and while highly reliable, should still be subjected to careful verification." end # Add some inferences. - DocumentInference.create(inference_type: "exception:is_application", inference_value: "True", inference_reason: "This is not used as an application or means of participation in government services.", document_id: doc.id, is_active: true) - DocumentInference.create(inference_type: "exception:is_archival", inference_value: "True", inference_reason: "This thing was made in 1988 and hasn't been opened since then.", document_id: doc.id, is_active: true) - DocumentInference.create(inference_type: "exception:is_archival", inference_value: "True", inference_reason: "Old archival value.", document_id: doc.id, is_active: false) + DocumentInference.create(inference_type: "exception:is_application", + inference_value: "True", + inference_reason: "This is not used as an application or means of participation in government services.", + document_id: doc.id, + is_active: true, + inference_model_name: "anthropic-friend") + DocumentInference.create(inference_type: "exception:is_archival", + inference_value: "True", + inference_reason: "This thing was made in 1988 and hasn't been opened since then.", + document_id: doc.id, + is_active: true, + inference_model_name: "anthropic-friend") + DocumentInference.create(inference_type: "exception:is_archival", + inference_value: "True", + inference_reason: "Old archival value.", + document_id: doc.id, + is_active: false, + inference_model_name: "anthropic-friend") visit "/" click_link("City of Denver") within("#document-list") do @@ -320,6 +341,7 @@ expect(page).to have_content("AI Exception Check\nMight be exception") expect(page).to have_content("Preexisting documents: Yes\nThis is not used as an application or means of participation in government services.") expect(page).to have_content("Archived web content: Yes, but clearly mark as archived\nThis thing was made in 1988 and hasn't been opened since then.") + expect(page).to have_content("Generated by anthropic-friend") find(".close").click end end @@ -587,9 +609,9 @@ click_button "AI Exception Check" expect(page).to have_no_content "Feedback on AI Response:" end - DocumentInference.new(document_id: doc.id, inference_type: "summary", inference_value: "A lovely example of accessible PDF practices.").save - DocumentInference.create(inference_type: "exception:is_application", inference_value: "True", inference_reason: "This is not used as an application or means of participation in government services.", document_id: doc.id) - DocumentInference.create(inference_type: "exception:is_archival", inference_value: "True", inference_reason: "This thing was made in 1988 and hasn't been opened since then.", document_id: doc.id) + DocumentInference.create(document_id: doc.id, inference_type: "summary", inference_value: "A lovely example of accessible PDF practices.", is_active: true) + DocumentInference.create(inference_type: "exception:is_application", inference_value: "True", inference_reason: "This is not used as an application or means of participation in government services.", document_id: doc.id, is_active: true) + DocumentInference.create(inference_type: "exception:is_archival", inference_value: "True", inference_reason: "This thing was made in 1988 and hasn't been opened since then.", document_id: doc.id, is_active: true) visit "/" click_link "City of Denver" within("#document-list") do From eba2ba244ee8838821ec926da5c594f15f7573e4 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 3 Sep 2025 06:41:37 -0600 Subject: [PATCH 3/5] Asap 215 document audit export (#284) * Stashing initial work on background job. * Stash progress on exporting. * Mostly function audit export page. * Clean up and make localstack erros more obvious. Move secret names to config. * Remove secret name constants. * Use actual bucket name. * Move S3 permissions to the correct role. * Allow backend to handle errors. * Refactor and simplify API. * Add swagger UI to the app. * Fix API paths and example. * Handle errors more gracefully. * Use staging bucket name (default). * Improve access and responses as revealed by testing. * Refactor tests for new endpoints. * Fix up linting issues. * Remove future fields from factory. * Fix tests by matching new routes and using required fields. * Add a light test for exports. --- app/api/asap_pdf/api.rb | 114 +++++----- app/controllers/configurations_controller.rb | 30 +-- app/controllers/documents_controller.rb | 112 +++++++++- app/controllers/sites_controller.rb | 128 +++--------- app/controllers/swagger_controller.rb | 5 + .../controllers/audit_export_controller.js | 35 ++++ app/javascript/controllers/index.js | 3 + app/javascript/swagger.js | 20 ++ app/models/site.rb | 61 ++++++ app/services/aws_s3_manager.rb | 53 +++++ .../documents/_audit_export_list.html.erb | 30 +++ app/views/documents/audit_exports.html.erb | 58 ++++++ app/views/documents/index.html.erb | 5 +- .../{sites => documents}/insights.html.erb | 9 +- app/views/layouts/swagger.html.erb | 43 ---- app/views/swagger/ui.html.erb | 32 +++ bin/setup-localstack | 19 +- bin/setup_python_components | 16 -- config/credentials/production.yml.enc | 1 + config/database.yml | 1 + config/environments/development.rb | 11 + config/environments/production.rb | 2 + config/environments/staging.rb | 2 + config/environments/test.rb | 11 + config/routes.rb | 10 +- package.json | 1 + spec/factories/document_inferences.rb | 11 + spec/features/document_spec.rb | 41 +++- spec/requests/api/sites_spec.rb | 196 +++++++----------- terraform/modules/ecs/main.tf | 4 +- yarn.lock | 12 ++ 31 files changed, 697 insertions(+), 379 deletions(-) create mode 100644 app/controllers/swagger_controller.rb create mode 100644 app/javascript/controllers/audit_export_controller.js create mode 100644 app/javascript/swagger.js create mode 100644 app/services/aws_s3_manager.rb create mode 100644 app/views/documents/_audit_export_list.html.erb create mode 100644 app/views/documents/audit_exports.html.erb rename app/views/{sites => documents}/insights.html.erb (93%) delete mode 100644 app/views/layouts/swagger.html.erb create mode 100644 app/views/swagger/ui.html.erb delete mode 100755 bin/setup_python_components create mode 100644 config/credentials/production.yml.enc create mode 100644 spec/factories/document_inferences.rb diff --git a/app/api/asap_pdf/api.rb b/app/api/asap_pdf/api.rb index 33befe17..03a8e66b 100644 --- a/app/api/asap_pdf/api.rb +++ b/app/api/asap_pdf/api.rb @@ -6,8 +6,8 @@ class API < Grape::API::Instance format :json http_basic do |email, password| - user = User.find_by(email: email) - user&.valid_password?(password) + @user = User.find_by(email: email) + @user&.valid_password?(password) end rescue_from ActiveRecord::RecordNotFound do |e| @@ -15,35 +15,51 @@ class API < Grape::API::Instance end desc "Return list of sites" do - detail "Returns a list of all sites in the system" + detail "Returns a list of all sites the user has access to." tags ["Sites"] produces ["application/json"] failure [[401, "Unauthorized"], [403, "Forbidden"]] + security [{basic_auth: []}] end get "/sites" do - Site.all + if @user.is_site_admin + Site.all + else + @user.site.present? ? [@user.site] : [] + end end - desc "Return a specific site" do - detail "Returns detailed information about a specific site" - tags ["Sites"] + desc "List documents related to site." do + detail "A paginated list of documents related to a site." + tags ["Documents"] produces ["application/json"] + consumes ["application/json"] failure [ + [400, "Bad Request - Invalid parameters"], [401, "Unauthorized"], [403, "Forbidden"], [404, "Site not found"] ] + named "List documents" end params do requires :id, type: Integer, desc: "Site ID" + optional :page, type: Integer, desc: "Page number for pagination", default: 0 + optional :items_per_page, type: Integer, desc: "Items per page", default: 25 end - get "/sites/:id" do - Site.find(params[:id]) + get "/sites/:id/documents" do + items_per_page = params[:items_per_page].nil? ? 25 : params[:items_per_page].to_i + page = params[:page].nil? ? 0 : params[:page].to_i + unless @user.is_site_admin || params[:id] == @user.site_id + error!("Unauthorized", 401) + end + site = Site.find(params[:id]) + {documents: site.documents.limit(items_per_page).offset(page * items_per_page).order(id: :asc)} end - desc "Discover documents for a site" do - detail "Creates or updates documents for a specific site based on the provided URLs and timestamps" - tags ["Documents"] + desc "List document inferences for a document." do + detail "List LLM document inferences (summary or exception check) for a document." + tags ["Document Inferences"] produces ["application/json"] consumes ["application/json"] failure [ @@ -52,28 +68,16 @@ class API < Grape::API::Instance [403, "Forbidden"], [404, "Site not found"] ] - named "Create Documents" end params do - requires :id, type: Integer, desc: "Site ID" - requires :documents, type: Array do - requires :url, type: String, desc: "Document URL" - requires :modification_date, type: DateTime, desc: "Document's last modified timestamp" - end + requires :id, type: Integer, desc: "Document ID" end - post "/sites/:id/documents" do - site = Site.find(params[:id]) - documents = site.discover_documents!(params[:documents], true) - - status 201 - {documents: documents.map { |doc| - { - id: doc.id, - url: doc.url, - document_status: doc.document_status, - s3_path: doc.s3_path - } - }} + get "/documents/:id/document_inference" do + document = Document.find(params[:id]) + unless @user.is_site_admin || document.site_id == @user.site_id + error!("Unauthorized", 401) + end + {document_inferences: document.document_inferences.order(id: :asc)} end desc "Adds or updates a document inference with type" do @@ -91,12 +95,20 @@ class API < Grape::API::Instance params do requires :id, type: Integer, desc: "Document ID" requires :inference_type, type: String, desc: "Document inference type", values: ["summary", "exception"] - requires :result, type: Hash, desc: "Value of document inference" + optional :result, type: Hash, desc: "Value of document inference" do + optional "summary", type: String, desc: "For inference_type summary, generated summary" + optional "is_", type: String, desc: "For inference_type exception, the exception type" + optional "why_", type: String, desc: "For inference_type exception, the LLM explanation" + end end post "/documents/:id/inference" do status 201 + document = Document.find(params[:id]) + unless @user.is_site_admin || document.site_id == @user.site_id + error!("Unauthorized", 401) + end if params[:inference_type] == "summary" - inference = DocumentInference.create(document_id: params[:id], inference_type: "summary") + inference = document.document_inferences.new(inference_type: "summary") inference.inference_value = params[:result]["summary"] inference.is_active = true inference.inference_model_name = params[:result]["inference_model"] @@ -107,7 +119,7 @@ class API < Grape::API::Instance ["individualized", "archival", "application", "third_party"].each do |type| result_boolean = "is_#{type}" unless params[:result][result_boolean].nil? - inference = DocumentInference.create(document_id: params[:id], inference_type: "exception:#{result_boolean}") + inference = document.document_inferences.new(inference_type: "exception:#{result_boolean}") inference.inference_value = params[:result][result_boolean] ? "True" : "False" inference.inference_confidence = params[:result]["#{result_boolean}_confidence"] inference.inference_reason = params[:result]["why_#{type}"] @@ -121,41 +133,19 @@ class API < Grape::API::Instance end add_swagger_documentation( + doc_version: "1.0.0", mount_path: "/swagger_doc", - openapi_version: "3.0.1", info: { title: "ASAP PDF API", - description: "API for managing ASAP PDF resources and document processing", + description: "API for managing ASAP PDF resources and document processing. Note: Basic authentication is required for all endpoints.", version: "1.0.0" }, tags: [ - {name: "Sites", description: "Site management operations"}, - {name: "Documents", description: "Document processing operations"} + {name: "Sites", description: "Site operations"}, + {name: "Documents", description: "Document operations"}, + {name: "Document Inferences", description: "Document Inference operations"} ], - components: { - schemas: { - Site: { - type: "object", - properties: { - id: {type: "integer", description: "Site ID"}, - name: {type: "string", description: "Site name"}, - location: {type: "string", description: "Site location"}, - primary_url: {type: "string", description: "Primary URL of the site"} - }, - required: ["id", "name", "location", "primary_url"] - }, - Document: { - type: "object", - properties: { - id: {type: "integer", description: "Document ID"}, - url: {type: "string", description: "Document URL"}, - document_status: {type: "string", description: "Current status of the document"}, - s3_path: {type: "string", description: "S3 storage path"} - }, - required: ["id", "url", "document_status", "s3_path"] - } - } - } + models: [] ) end end diff --git a/app/controllers/configurations_controller.rb b/app/controllers/configurations_controller.rb index b7b60453..0e4b11fa 100644 --- a/app/controllers/configurations_controller.rb +++ b/app/controllers/configurations_controller.rb @@ -3,43 +3,35 @@ class ConfigurationsController < AuthenticatedController before_action :ensure_user_site_admin - # This form is only for local development. - # Python components expect to use staging keys for local development. - ASAP_API_USER = "asap-pdf/staging/RAILS_API_USER" - ASAP_API_PASSWORD = "asap-pdf/staging/RAILS_API_PASSWORD" - GOOGLE_API_SECRET_NAME = "asap-pdf/staging/GOOGLE_AI_KEY" - ANTHROPIC_API_SECRET_NAME = "asap-pdf/staging/ANTHROPIC_KEY" - GOOGLE_EVAL_SERVICE_ACCOUNT_CREDS = "asap-pdf/staging/GOOGLE_SERVICE_ACCOUNT" - GOOGLE_EVAL_SHEET_ID = "asap-pdf/staging/GOOGLE_SHEET_ID_EVALUATION" - def initialize super @secret_manager = AwsLocalSecretManager.new + @secret_names = Rails.configuration.local_secret_names end def edit @config = { localstack_not_reachable: false } - response = @secret_manager.get_secret!(GOOGLE_API_SECRET_NAME) + response = @secret_manager.get_secret!(@secret_names[:google_api]) @config["google_ai_api_key"] = response.secret_string if response.present? - response = @secret_manager.get_secret!(ANTHROPIC_API_SECRET_NAME) + response = @secret_manager.get_secret!(@secret_names[:anthropic_api]) @config["anthropic_api_key"] = response.secret_string if response.present? - response = @secret_manager.get_secret!(GOOGLE_EVAL_SERVICE_ACCOUNT_CREDS) + response = @secret_manager.get_secret!(@secret_names[:google_eval_service_account]) @config["google_evaluation_service_account_credentials"] = response.secret_string if response.present? - response = @secret_manager.get_secret!(GOOGLE_EVAL_SHEET_ID) + response = @secret_manager.get_secret!(@secret_names[:google_eval_sheet_id]) @config["google_evaluation_sheet_id"] = response.secret_string if response.present? rescue Seahorse::Client::NetworkingError @config["localstack_not_reachable"] = true end def update - @secret_manager.set_secret!(GOOGLE_API_SECRET_NAME, params[:config][:google_ai_api_key]) - @secret_manager.set_secret!(ANTHROPIC_API_SECRET_NAME, params[:config][:anthropic_api_key]) - @secret_manager.set_secret!(ASAP_API_USER, Rails.application.credentials.config[:api_user]) - @secret_manager.set_secret!(ASAP_API_PASSWORD, Rails.application.credentials.config[:api_password]) - @secret_manager.set_secret!(GOOGLE_EVAL_SERVICE_ACCOUNT_CREDS, params[:config][:google_evaluation_service_account_credentials]) - @secret_manager.set_secret!(GOOGLE_EVAL_SHEET_ID, params[:config][:google_evaluation_sheet_id]) + @secret_manager.set_secret!(@secret_names[:google_api], params.dig(:config, :google_ai_api_key)) + @secret_manager.set_secret!(@secret_names[:anthropic_api], params.dig(:config, :anthropic_api_key)) + @secret_manager.set_secret!(@secret_names[:asap_api_user], Rails.application.credentials.config[:api_user]) + @secret_manager.set_secret!(@secret_names[:asap_api_password], Rails.application.credentials.config[:api_password]) + @secret_manager.set_secret!(@secret_names[:google_eval_service_account], params.dig(:config, :google_evaluation_service_account_credentials)) + @secret_manager.set_secret!(@secret_names[:google_eval_sheet_id], params.dig(:config, :google_evaluation_sheet_id)) redirect_to edit_configuration_path, notice: "Configuration updated successfully. API user set to Rails config values." rescue => e redirect_to edit_configuration_path, alert: "Error updating configuration: #{e.message}" diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index 6db1ea2a..67ba1251 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -4,10 +4,10 @@ class DocumentsController < AuthenticatedController protect_from_forgery with: :exception skip_before_action :verify_authenticity_token, only: [:update_document_category, :update_accessibility_recommendation, :update_notes, :update_summary_inference, :update_recommendation_inference] - before_action :set_site, only: [:index, :modal_content, :batch_update] - before_action :set_document, except: [:index, :batch_update] - before_action :ensure_user_site_access, only: [:index, :modal_content, :batch_update] - before_action :ensure_user_document_access, except: [:index, :modal_content, :batch_update] + before_action :set_site, only: [:index, :insights, :audit_exports, :modal_content, :batch_update] + before_action :set_document, except: [:index, :insights, :audit_exports, :batch_update] + before_action :ensure_user_site_access, only: [:index, :insights, :audit_exports, :modal_content, :batch_update] + before_action :ensure_user_document_access, except: [:index, :insights, :audit_exports, :modal_content, :batch_update] def modal_content render partial: "modal_content", locals: {document: @document} @@ -29,6 +29,110 @@ def index @filters_for_sorts = query_params [:sort, :direction, :page] end + def insights + # Build document list. + @documents = @site.documents + .by_category(params[:category]) + .by_department(params[:department]) + # Create binned date data for visualization. + # First, gather all documents by year + year_groups = @documents.group_by(&:modification_year).map { |label, year_documents| [label, year_documents.size] } + # Extract and remove "Unknown" to handle separately + unknown_group = year_groups.find { |item| item[0] == "Unknown" } + year_groups = year_groups.reject { |item| item[0] == "Unknown" } + year_groups = year_groups.select do |item| + Integer(item[0]) + true + rescue + if unknown_group.nil? + unknown_group = ["Unknown", 0] + end + unknown_group[1] += 1 + false + end + # Convert to integers for sorting and calculations + year_groups = year_groups.map { |year, count| [Integer(year), count] } + # Create bins based on specific year ranges + binned_data = [] + bins = [ + ["< 2000", -Float::INFINITY..1999], + ["2000-2005", 2000..2005], + ["2006-2011", 2006..2011], + ["2012-2017", 2012..2017], + ["2018-2023", 2018..2023], + ["> 2023", 2024..Float::INFINITY] + ] + bins.each do |label, range| + count = year_groups.filter_map { |year, count| count if range.cover?(year) }.sum + binned_data << [label, count] + end + # Add the "Unknown" group if it exists (placing it at the end) + binned_data << unknown_group if unknown_group + @document_years = binned_data + # Create table data. + default_group = Document::DECISION_TYPES.keys.map { |status| [status, 0] }.to_h + @category_groups = {} + @documents.group([:document_category, :accessibility_recommendation]).count.each do |groups, group_count| + @category_groups[groups[0]] = default_group.clone if @category_groups[groups[0]].nil? + if Document::DECISION_TYPES.keys.exclude? groups[1] + parent = Document::DECISION_TYPES.keys.find do |key| + if Document::DECISION_TYPES[key]["children"].present? && Document::DECISION_TYPES[key]["children"].key?(groups[1]) + key + end + end + if parent.present? + groups[1] = parent + end + end + @category_groups[groups[0]][groups[1]] += group_count + end + @category_groups.each do |key, child_hash| + sum = child_hash.values.sum + child_hash["Total"] = sum + end + @category_groups = @category_groups.sort.to_h + # Work on document links. + @document_links = { + complexity: [ + {title: Document::SIMPLE_STATUS, params: query_params.merge({complexity: Document::SIMPLE_STATUS})}, + {title: Document::COMPLEX_STATUS, params: query_params.merge({complexity: Document::COMPLEX_STATUS})} + ], + years: bins.map do |label, range| + document_count = @document_years.find { |item| item[0] == label } + if document_count[1] == 0 + next + end + start_date = (range.begin == -Float::INFINITY) ? nil : "#{range.begin}-01-01" + end_date = (range.end == Float::INFINITY) ? nil : "#{range.end}-12-31" + { + title: label, + params: query_params.merge( + start_date: start_date, + end_date: end_date + ).compact + } + end.compact, + decision: @documents.pluck(:accessibility_recommendation).uniq.map do |decision| + { + title: decision, + params: query_params.merge( + accessibility_recommendation: decision + ) + } + end + } + end + + def audit_exports + @export_links = [] + @error_message = nil + begin + @export_links = @site.get_document_audit_link_hashes! + rescue => e + @error_message = e.message + end + end + def serve_document_url response = HTTParty.get(@document.normalized_url) if response.success? diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index fde11e78..bc7aaa01 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -2,8 +2,8 @@ class SitesController < AuthenticatedController include Access include ParamsHelper - before_action :find_site, only: [:insights, :show, :edit, :update, :destroy] - before_action :ensure_user_site_access, only: [:insights, :show, :edit, :update, :destroy] + before_action :find_site, only: [:show, :edit, :update, :destroy, :create_workflow_audit_report] + before_action :ensure_user_site_access, only: [:show, :edit, :update, :destroy, :workflow_audit_report] def index @sites = if current_user.is_site_admin? @@ -13,100 +13,6 @@ def index end end - def insights - # Build document list. - @documents = @site.documents - .by_category(params[:category]) - .by_department(params[:department]) - # Create binned date data for visualization. - # First, gather all documents by year - year_groups = @documents.group_by(&:modification_year).map { |label, year_documents| [label, year_documents.size] } - # Extract and remove "Unknown" to handle separately - unknown_group = year_groups.find { |item| item[0] == "Unknown" } - year_groups = year_groups.reject { |item| item[0] == "Unknown" } - year_groups = year_groups.select do |item| - Integer(item[0]) - true - rescue - if unknown_group.nil? - unknown_group = ["Unknown", 0] - end - unknown_group[1] += 1 - false - end - # Convert to integers for sorting and calculations - year_groups = year_groups.map { |year, count| [Integer(year), count] } - # Create bins based on specific year ranges - binned_data = [] - bins = [ - ["< 2000", -Float::INFINITY..1999], - ["2000-2005", 2000..2005], - ["2006-2011", 2006..2011], - ["2012-2017", 2012..2017], - ["2018-2023", 2018..2023], - ["> 2023", 2024..Float::INFINITY] - ] - bins.each do |label, range| - count = year_groups.filter_map { |year, count| count if range.cover?(year) }.sum - binned_data << [label, count] - end - # Add the "Unknown" group if it exists (placing it at the end) - binned_data << unknown_group if unknown_group - @document_years = binned_data - # Create table data. - default_group = Document::DECISION_TYPES.keys.map { |status| [status, 0] }.to_h - @category_groups = {} - @documents.group([:document_category, :accessibility_recommendation]).count.each do |groups, group_count| - @category_groups[groups[0]] = default_group.clone if @category_groups[groups[0]].nil? - if Document::DECISION_TYPES.keys.exclude? groups[1] - parent = Document::DECISION_TYPES.keys.find do |key| - if Document::DECISION_TYPES[key]["children"].present? && Document::DECISION_TYPES[key]["children"].key?(groups[1]) - key - end - end - if parent.present? - groups[1] = parent - end - end - @category_groups[groups[0]][groups[1]] += group_count - end - @category_groups.each do |key, child_hash| - sum = child_hash.values.sum - child_hash["Total"] = sum - end - @category_groups = @category_groups.sort.to_h - # Work on document links. - @document_links = { - complexity: [ - {title: Document::SIMPLE_STATUS, params: query_params.merge({complexity: Document::SIMPLE_STATUS})}, - {title: Document::COMPLEX_STATUS, params: query_params.merge({complexity: Document::COMPLEX_STATUS})} - ], - years: bins.map do |label, range| - document_count = @document_years.find { |item| item[0] == label } - if document_count[1] == 0 - next - end - start_date = (range.begin == -Float::INFINITY) ? nil : "#{range.begin}-01-01" - end_date = (range.end == Float::INFINITY) ? nil : "#{range.end}-12-31" - { - title: label, - params: query_params.merge( - start_date: start_date, - end_date: end_date - ).compact - } - end.compact, - decision: @documents.pluck(:accessibility_recommendation).uniq.map do |decision| - { - title: decision, - params: query_params.merge( - accessibility_recommendation: decision - ) - } - end - } - end - def show @documents = @site.documents.order(created_at: :desc) end @@ -141,6 +47,36 @@ def destroy redirect_to sites_path, notice: "Site was successfully deleted.", status: :see_other end + def create_workflow_audit_report + export_links = [] + error_message = nil + begin + @site.export_document_audit!(current_user) + export_links = @site.get_document_audit_link_hashes! + rescue => e + error_message = e.message + end + render json: {html: render_to_string(partial: "documents/audit_export_list", formats: [:html], locals: {export_links: export_links, error: error_message})} + end + + def workflow_audit_report + s3_manager = AwsS3Manager.new + begin + key = params[:key] + key = key.start_with?("/") ? key : "/#{key}" + response = s3_manager.get_object!(params[:bucket_name], key) + send_data response[:body].read, + filename: File.basename(key), + type: response[:content_type] || "application/octet-stream", + disposition: "inline" + rescue Aws::S3::Errors::NoSuchKey + render plain: "File not found", status: 404 + rescue => e + Rails.logger.error "S3 error: #{e.message}" + render plain: "Error retrieving file", status: 500 + end + end + private def site_params diff --git a/app/controllers/swagger_controller.rb b/app/controllers/swagger_controller.rb new file mode 100644 index 00000000..d9f65487 --- /dev/null +++ b/app/controllers/swagger_controller.rb @@ -0,0 +1,5 @@ +class SwaggerController < ApplicationController + layout false + def ui + end +end diff --git a/app/javascript/controllers/audit_export_controller.js b/app/javascript/controllers/audit_export_controller.js new file mode 100644 index 00000000..a6a8719c --- /dev/null +++ b/app/javascript/controllers/audit_export_controller.js @@ -0,0 +1,35 @@ +import {Controller} from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["button", "preloader", "documentList"] + + static values = { + siteId: Number, + } + + async createReport() { + try { + this.buttonTarget.classList.add("hidden"); + this.preloaderTarget.classList.remove("hidden"); + const response = await fetch(`/sites/${this.siteIdValue}/create_workflow_audit_report`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-CSRF-Token": document.querySelector('meta[name="csrf-token"]').content, + "Accept": "application/json" + }, + }) + if (response.ok) { + const jsonSummary = await response.json() + this.documentListTarget.outerHTML = jsonSummary.html; + this.preloaderTarget.classList.add('hidden') + this.buttonTarget.classList.remove("hidden"); + } else { + throw new Error("Response was not OK") + } + } catch (error) { + console.error("Error summarizing document:", error) + this.preloaderTarget.classList.add('hidden') + } + } +} diff --git a/app/javascript/controllers/index.js b/app/javascript/controllers/index.js index 38841ada..19d6daec 100644 --- a/app/javascript/controllers/index.js +++ b/app/javascript/controllers/index.js @@ -4,6 +4,9 @@ import { application } from "./application" +import AuditExportController from "./audit_export_controller" +application.register("audit-export", AuditExportController) + import BulkEditController from "./bulk_edit_controller" application.register("bulk-edit", BulkEditController) diff --git a/app/javascript/swagger.js b/app/javascript/swagger.js new file mode 100644 index 00000000..d3ce8279 --- /dev/null +++ b/app/javascript/swagger.js @@ -0,0 +1,20 @@ +import SwaggerUI from "swagger-ui-dist/swagger-ui-bundle" +import "swagger-ui-dist/swagger-ui.css" + + +const ui = SwaggerUI({ + url: "/api/swagger_doc", + dom_id: "#swagger-ui", + deepLinking: true, + docExpansion: "list", + defaultModelsExpandDepth: 1, + defaultModelExpandDepth: 1, + displayRequestDuration: true, + showExtensions: true, + showCommonExtensions: true, + tryItOutEnabled: false, + persistAuthorization: true, + supportedSubmitMethods: [] +}) + +window.ui = ui; \ No newline at end of file diff --git a/app/models/site.rb b/app/models/site.rb index f4a36f86..7d68d34c 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -54,6 +54,14 @@ class Site < ApplicationRecord validates :primary_url, presence: true, uniqueness: true validate :ensure_safe_url + after_initialize :after_initialize + + def after_initialize + @s3_manager = AwsS3Manager.new + rescue + @s3_manager = nil + end + def has_departments? documents.where.not(department: [nil, ""]).any? end @@ -231,8 +239,61 @@ def process_archive_or_csv(file_path, is_archive) end end + def export_document_audit!(current_user) + assert_s3_manager + bucket_name = Rails.application.config.default_s3_bucket + machine_site_name = name.downcase.gsub(/\W+/, "_") + report_name = "audit_export_#{machine_site_name}_#{Time.now.strftime("%Y-%m-%dT%H-%M-%S")}" + Tempfile.create([report_name, ".csv"]) do |temp_file| + CSV.open(temp_file.path, "wb") do |csv| + csv << Document.column_names + documents.find_each do |record| + csv << record.attributes.values + end + end + metadata = { + "created" => Time.now.iso8601, + "author" => current_user.email + } + @s3_manager.write_file!(bucket_name, "/reports/#{machine_site_name}/#{report_name}.csv", temp_file.path, "text/csv", metadata) + end + end + + def get_document_audit_exports! + assert_s3_manager + bucket_name = Rails.application.config.default_s3_bucket + machine_site_name = name.downcase.gsub(/\W+/, "_") + { + bucket_name: bucket_name, + files: @s3_manager.get_files!(bucket_name, "/reports/#{machine_site_name}") + } + end + + def get_document_audit_link_hashes! + assert_s3_manager + export_links = [] + report_data = get_document_audit_exports! + if report_data[:files].present? + report_data[:files].each do |s3_object| + metadata = @s3_manager.get_metadata!(report_data[:bucket_name], s3_object.key) + export_links << { + url: Rails.application.routes.url_helpers.workflow_audit_report_site_path(self, report_data[:bucket_name], s3_object.key), + created: metadata.has_key?("created") ? metadata["created"] : "", + author: metadata.has_key?("author") ? metadata["author"] : "" + } + end + end + export_links + end + private + def assert_s3_manager + if @s3_manager.nil? + raise StandardError.new("Failed to connect to AWS environment (AwsS3Manager failed to initialize).") + end + end + def attributes_from(data) { document_category: data[:predicted_category] || data[:document_category], diff --git a/app/services/aws_s3_manager.rb b/app/services/aws_s3_manager.rb new file mode 100644 index 00000000..ef01d830 --- /dev/null +++ b/app/services/aws_s3_manager.rb @@ -0,0 +1,53 @@ +class AwsS3Manager + def initialize + @s3_client = if Rails.env.development? + Aws::S3::Client.new( + endpoint: "http://localhost:4566", + account_id: "none", + access_key_id: "none", + secret_access_key: "none", + region: "us-east-1", + force_path_style: true, + stub_responses: false + ) + else + Aws::S3::Client.new + end + end + + def write_file!(bucket_name, key, file_path, content_type = nil, metadata = {}) + File.open(file_path, "rb") do |file| + @s3_client.put_object( + bucket: bucket_name, + key: key, + body: file, + content_type: content_type || "application/octet-stream", + metadata: metadata + ) + end + end + + def get_files!(bucket_name, key) + prefix = key.end_with?("/") ? key : "#{key}/" + params = { + bucket: bucket_name, + prefix: prefix, + max_keys: 100 + } + response = @s3_client.list_objects_v2(params) + response.contents.sort_by(&:last_modified).reverse + end + + def get_metadata!(bucket_name, key) + head_response = @s3_client.head_object( + bucket: bucket_name, + key: key + ) + return {} unless head_response.metadata.present? + head_response.metadata + end + + def get_object!(bucket_name, key) + @s3_client.get_object(bucket: bucket_name, key: key) + end +end diff --git a/app/views/documents/_audit_export_list.html.erb b/app/views/documents/_audit_export_list.html.erb new file mode 100644 index 00000000..63db1b92 --- /dev/null +++ b/app/views/documents/_audit_export_list.html.erb @@ -0,0 +1,30 @@ +<% if error %> +
+ There was an error retrieving your audit exports. Here are the details:
<%= error %> +
+<% else %> + + + + + + + + + + <% export_links.each do |link| %> + + + + + + + <% end %> + +
Link to ExportDate CreatedAuthor
+ + + Download Report + + <%= link[:created] %><%= link[:author] %>
+<% end %> \ No newline at end of file diff --git a/app/views/documents/audit_exports.html.erb b/app/views/documents/audit_exports.html.erb new file mode 100644 index 00000000..4c9b3c7a --- /dev/null +++ b/app/views/documents/audit_exports.html.erb @@ -0,0 +1,58 @@ +<% content_for :head do %> + <%= tag.meta name: "site-id", content: @site.id %> +<% end %> +
+
+
+ +
+
+
+

<%= @site.location %>: <%= @site.name %>

+
+ <%= link_to site_documents_path(@site), role: "tab", class: "tab" do %> + Documents + <% end %> + <%= link_to insights_site_documents_path(@site), role: "tab", class: "tab" do %> + Insights + <% end %> + <%= link_to audit_exports_site_documents_path(@site), role: "tab", class: "tab tab-active" do %> + Audit Exports + <% end %> +
+
+
+
+
+

Audit Exports

+
+

Create a downloadable CSV export of your audit progress.

+ + + <%= render partial: "documents/audit_export_list", locals: { export_links: @export_links, error: @error_message } %> +
+
+

Use the API

+

Use the following RESTful endpoint to get your audit history.

+
+
curl -X 'GET' \
+          '<%= request.base_url %>/api/sites/1/documents?page=0&items_per_page=100' \
+          -H 'accept: application/json' \
+          -H 'authorization: Basic [Your base64 encoded credentials]'
+
+

Read the <%= link_to api_docs_path, class: "text-primary" do %>full API documentation.<% end %>

+
+
+
+
+
+
+
diff --git a/app/views/documents/index.html.erb b/app/views/documents/index.html.erb index 474a4088..58640ccc 100644 --- a/app/views/documents/index.html.erb +++ b/app/views/documents/index.html.erb @@ -16,9 +16,12 @@ <%= link_to site_documents_path(@site), role: "tab", class: "tab tab-active" do %> Documents <% end %> - <%= link_to insights_site_path(@site), role: "tab", class: "tab" do %> + <%= link_to insights_site_documents_path(@site), role: "tab", class: "tab" do %> Insights <% end %> + <%= link_to audit_exports_site_documents_path(@site), role: "tab", class: "tab" do %> + Audit Exports + <% end %>
diff --git a/app/views/sites/insights.html.erb b/app/views/documents/insights.html.erb similarity index 93% rename from app/views/sites/insights.html.erb rename to app/views/documents/insights.html.erb index 2aa98476..80b7bf04 100644 --- a/app/views/sites/insights.html.erb +++ b/app/views/documents/insights.html.erb @@ -11,7 +11,7 @@

Filter View

- <%= form_tag insights_site_path(@site), method: :get, class: "px-4 py-4", data: { action: "submit->filter#submitForm" } do %> + <%= form_tag insights_site_documents_path(@site), method: :get, class: "px-4 py-4", data: { action: "submit->filter#submitForm" } do %>
diff --git a/app/views/layouts/swagger.html.erb b/app/views/layouts/swagger.html.erb deleted file mode 100644 index d0653682..00000000 --- a/app/views/layouts/swagger.html.erb +++ /dev/null @@ -1,43 +0,0 @@ - - - - - ASAP PDF API Documentation - - - - -
- - - - - diff --git a/app/views/swagger/ui.html.erb b/app/views/swagger/ui.html.erb new file mode 100644 index 00000000..defcaf07 --- /dev/null +++ b/app/views/swagger/ui.html.erb @@ -0,0 +1,32 @@ + + + + + ASAP PDF API Documentation + <%= javascript_include_tag "swagger", "data-turbo-track": "reload", defer: true %> + <%= stylesheet_link_tag "swagger", "data-turbo-track": "reload" %> + + + +
+ + diff --git a/bin/setup-localstack b/bin/setup-localstack index 45f7d223..1c602a12 100755 --- a/bin/setup-localstack +++ b/bin/setup-localstack @@ -2,24 +2,27 @@ # Wait for LocalStack to be ready echo "Waiting for LocalStack to be ready..." -while ! curl -s "http://localhost:4566/_localstack/health" | grep -q '"s3": "running"'; do - sleep 1 +while ! curl -s "http://localstack:4566/_localstack/health" | grep -q '"s3".*"available"'; do + echo "S3 not ready yet, waiting..." + sleep 2 done +echo "LocalStack S3 is ready!" # Create the bucket if it doesn't exist +# Must match value in config/environments/development.rb, config.default_s3_bucket. echo "Creating S3 bucket..." -aws --endpoint-url=http://localhost:4566 s3 mb s3://cfa-aistudio-asap-pdf +aws --endpoint-url=http://localstack:4566 s3 mb s3://asap-pdf-staging-documents # Enable versioning on the bucket echo "Enabling bucket versioning..." -aws --endpoint-url=http://localhost:4566 s3api put-bucket-versioning \ - --bucket cfa-aistudio-asap-pdf \ +aws --endpoint-url=http://localstack:4566 s3api put-bucket-versioning \ + --bucket asap-pdf-staging-documents \ --versioning-configuration Status=Enabled # Set bucket policy to allow public access (for development only) echo "Setting bucket policy..." -aws --endpoint-url=http://localhost:4566 s3api put-bucket-policy \ - --bucket cfa-aistudio-asap-pdf \ +aws --endpoint-url=http://localstack:4566 s3api put-bucket-policy \ + --bucket asap-pdf-staging-documents \ --policy '{ "Version": "2012-10-17", "Statement": [ @@ -28,7 +31,7 @@ aws --endpoint-url=http://localhost:4566 s3api put-bucket-policy \ "Effect": "Allow", "Principal": "*", "Action": "s3:*", - "Resource": "arn:aws:s3:::cfa-aistudio-asap-pdf/*" + "Resource": "arn:aws:s3:::asap-pdf-staging-documents/*" } ] }' diff --git a/bin/setup_python_components b/bin/setup_python_components deleted file mode 100755 index 926e48e3..00000000 --- a/bin/setup_python_components +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash - -declare -a arr=("GEMINI_KEY" "ANTHROPIC_KEY") - -for item in "${arr[@]}"; do - read -p "Enter value for $item: " key - docker exec asap_setup aws secretsmanager create-secret \ - --endpoint-url=http://localstack:4566 \ - --name $item \ - --description "LocalStack secret $key" \ - --secret-string $key \ - --region 'us-east-1' - echo "Set secret value for $item" -done - -echo "Python components should be ready to run!" \ No newline at end of file diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc new file mode 100644 index 00000000..2450c765 --- /dev/null +++ b/config/credentials/production.yml.enc @@ -0,0 +1 @@ +t+sp+zLTSKdE0nQaoq74+EZ2GZghyXnVDC9iZKXFjJzdCpzRBouHaHYe2hcTvK88xhrE2mMEvRC72q3fB9ON6Gu+jwPAaR/kyKZLJuMvjaer1Kr57GAEyHmALOqtCHKTRVt9i1SU+YeiXrZ5vrUF6ZXqfISsP5W8OeILAXUv0h3KGq7jenDQAclQTNIrhHG6WgxYdnyrzuFgBWZtYkZUVaBMgX8BAtwN8QX4qXDG31zk3TOt893+3Da/IwiTqMMVphvl1i9qAtQrdwS03sibXTfUVTluv75cU2XDxMvPro7Ws4vgKmwFjiUJKx7IXaZQxBARJD0EOzkhFjiQOfdW2s694Or/uJGtO/nnWjOUrP+8+NWigzOxatjueikuNEAJ//lkTwMX4ni3AZrbC5KrShMWWNrfLAkYpjCthOc9tuPAgRJlrhW/j7x1cGNE3+YOSz4ze/WcpR9SwZfu9SosBJOp1MhJFGww5wXCc66bYGifamnHD+Qr7bx8--4EiPuW5dKUPW0HGF--wVQ1fNnNfCAxI6nvxUne5w== \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index a064c8dc..5584a509 100644 --- a/config/database.yml +++ b/config/database.yml @@ -4,6 +4,7 @@ default: &default prepared_statements: false pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> + development: <<: *default host: localhost diff --git a/config/environments/development.rb b/config/environments/development.rb index 0cb5b88b..66b897f8 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -38,4 +38,15 @@ config.action_controller.raise_on_missing_callback_actions = true config.hosts = nil + + config.default_s3_bucket = "asap-pdf-staging-documents" + + config.local_secret_names = { + asap_api_user: "asap-pdf/staging/RAILS_API_USER", + asap_api_password: "asap-pdf/staging/RAILS_API_PASSWORD", + google_api: "asap-pdf/staging/GOOGLE_AI_KEY", + anthropic_api: "asap-pdf/staging/ANTHROPIC_KEY", + google_eval_service_account: "asap-pdf/staging/GOOGLE_SERVICE_ACCOUNT", + google_eval_sheet_id: "asap-pdf/staging/GOOGLE_SHEET_ID_EVALUATION" + } end diff --git a/config/environments/production.rb b/config/environments/production.rb index 1d825d3e..b559ef3d 100644 --- a/config/environments/production.rb +++ b/config/environments/production.rb @@ -43,4 +43,6 @@ config.i18n.fallbacks = true config.active_record.dump_schema_after_migration = false config.active_record.attributes_for_inspect = [:id] + + config.default_s3_bucket = "asap-pdf-prod-documents" end diff --git a/config/environments/staging.rb b/config/environments/staging.rb index 83af0209..f7afdaa1 100644 --- a/config/environments/staging.rb +++ b/config/environments/staging.rb @@ -43,4 +43,6 @@ config.i18n.fallbacks = true config.active_record.dump_schema_after_migration = false config.active_record.attributes_for_inspect = [:id] + + config.default_s3_bucket = "asap-pdf-staging-documents" end diff --git a/config/environments/test.rb b/config/environments/test.rb index 1446aa10..615b4cd3 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -17,4 +17,15 @@ config.active_support.deprecation = :stderr config.action_controller.raise_on_missing_callback_actions = true + + config.default_s3_bucket = "asap-pdf-staging-documents" + + config.local_secret_names = { + asap_api_user: "asap-pdf/staging/RAILS_API_USER", + asap_api_password: "asap-pdf/staging/RAILS_API_PASSWORD", + google_api: "asap-pdf/staging/GOOGLE_AI_KEY", + anthropic_api: "asap-pdf/staging/ANTHROPIC_KEY", + google_eval_service_account: "asap-pdf/staging/GOOGLE_SERVICE_ACCOUNT", + google_eval_sheet_id: "asap-pdf/staging/GOOGLE_SHEET_ID_EVALUATION" + } end diff --git a/config/routes.rb b/config/routes.rb index 05218724..19c45da8 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,3 +1,5 @@ +require "sidekiq/web" + Rails.application.routes.draw do devise_for :users, controllers: { sessions: "users/sessions", @@ -13,7 +15,8 @@ resources :sites do member do - get :insights + get "workflow_audit_report/:bucket_name/*key", to: "sites#workflow_audit_report", format: false, as: :workflow_audit_report + post :create_workflow_audit_report end resources :documents do member do @@ -22,6 +25,8 @@ end collection do patch :batch_update + get :insights + get :audit_exports end end end @@ -45,7 +50,8 @@ end mount AsapPdf::API => "/api" - get "api-docs", to: "api_docs#index" + + get "/api-docs", to: "swagger#ui" resource :configuration, only: [:edit, :update] diff --git a/package.json b/package.json index 643db994..5e578adb 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "chartkick": "^5.0.1", "daisyui": "^4.12.23", "postcss": "^8.5.1", + "swagger-ui-dist": "^5.27.1", "tailwindcss": "^3.4.1" } } diff --git a/spec/factories/document_inferences.rb b/spec/factories/document_inferences.rb new file mode 100644 index 00000000..d652d90e --- /dev/null +++ b/spec/factories/document_inferences.rb @@ -0,0 +1,11 @@ +FactoryBot.define do + factory :document_inference do + creation_date { Time.current } + inference_type { "exception:is_application" } + inference_value { "True" } + inference_confidence { 0.85 } + inference_reason { "This is an event flyer for a for a croquet party." } + is_active { true } + document + end +end diff --git a/spec/features/document_spec.rb b/spec/features/document_spec.rb index cf5e629b..44de145e 100644 --- a/spec/features/document_spec.rb +++ b/spec/features/document_spec.rb @@ -507,7 +507,7 @@ click_button "Apply Filters" end sleep(1) - assert_match "sites/#{site.id}/insights?category=Agreement&department=Public+Transportation", current_url + assert_match "sites/#{site.id}/documents/insights?category=Agreement&department=Public+Transportation", current_url # Look for general content. within("#insights") do expect(page).to have_content "\nTYPE NEEDS DECISION IN REVIEW DONE TOTAL\nAgreement 1 0 0 1" @@ -527,7 +527,7 @@ teahouse_doc.save market_doc.accessibility_recommendation = Document::ARCHIVE_DECISION market_doc.save - visit "/sites/#{site.id}/insights?category=Agreement&department=Public+Transportation&accessibility_decision=Remediate" + visit "/sites/#{site.id}/documents/insights?category=Agreement&department=Public+Transportation&accessibility_decision=Remediate" within("#insights #chart-modification-year") do find(".dropdown .btn").click click_link "2018-2023" @@ -535,7 +535,7 @@ sleep(1) assert_match "sites/#{site.id}/documents?accessibility_decision=Remediate&category=Agreement&department=Public+Transportation&end_date=2023-12-31&start_date=2018-01-01", current_url - visit "/sites/#{site.id}/insights?category=Notice&department=Parks+and+Recreation&status=Archive" + visit "/sites/#{site.id}/documents/insights?category=Notice&department=Parks+and+Recreation&status=Archive" within("#insights #chart-decision") do find(".dropdown .btn").click click_link "Archive" @@ -609,9 +609,21 @@ click_button "AI Exception Check" expect(page).to have_no_content "Feedback on AI Response:" end - DocumentInference.create(document_id: doc.id, inference_type: "summary", inference_value: "A lovely example of accessible PDF practices.", is_active: true) - DocumentInference.create(inference_type: "exception:is_application", inference_value: "True", inference_reason: "This is not used as an application or means of participation in government services.", document_id: doc.id, is_active: true) - DocumentInference.create(inference_type: "exception:is_archival", inference_value: "True", inference_reason: "This thing was made in 1988 and hasn't been opened since then.", document_id: doc.id, is_active: true) + DocumentInference.new(document_id: doc.id, inference_type: "summary", inference_value: "A lovely example of accessible PDF practices.").save + DocumentInference.create( + inference_type: "exception:is_application", + inference_value: "True", + inference_reason: "This is not used as an application or means of participation in government services.", + document_id: doc.id, + is_active: true + ) + DocumentInference.create( + inference_type: "exception:is_archival", + inference_value: "True", + inference_reason: "This thing was made in 1988 and hasn't been opened since then.", + document_id: doc.id, + is_active: true + ) visit "/" click_link "City of Denver" within("#document-list") do @@ -676,4 +688,21 @@ expect(FeedbackItem.count).to eq 5 end end + + it "exports documents" do + site = Site.create(name: "City of Denver", location: "Colorado", primary_url: "https://denvergov.org") + @current_user.site = site + @current_user.save! + Document.create(url: "http://denvergov.org/docs/ex.ample.pdf", file_name: "ex.ample.pdf", document_category: "Agenda", site_id: site.id) + visit "/" + click_link "City of Denver" + within("#document-list #document-tabs") do + click_link "Audit Exports" + sleep(1) + end + assert_match "sites/#{site.id}/documents/audit_exports", current_url + expect(page).to have_content "Create Audit Export" + expect(page).to have_content "Use the following RESTful endpoint to get your audit history." + expect(page).to have_content "authorization: Basic [Your base64 encoded credentials]" + end end diff --git a/spec/requests/api/sites_spec.rb b/spec/requests/api/sites_spec.rb index de9737c0..96e14bd8 100644 --- a/spec/requests/api/sites_spec.rb +++ b/spec/requests/api/sites_spec.rb @@ -7,13 +7,13 @@ def app AsapPdf::API end - def auth_headers - user = User.last + def auth_headers user encoded_credentials = ActionController::HttpAuthentication::Basic.encode_credentials(user.email, "password") {"HTTP_AUTHORIZATION" => encoded_credentials} end - let!(:user) { create(:user, :site_admin) } + let!(:admin_user) { create(:user, :site_admin) } + let!(:user) { create(:user) } describe "GET /sites" do let!(:sites) { create_list(:site, 3) } @@ -22,14 +22,25 @@ def auth_headers expect(last_response.status).to eq(401) end - it "returns all sites" do - get "/sites", {}, auth_headers + it "returns all accessible sites" do + get "/sites", {}, auth_headers(admin_user) expect(last_response.status).to eq(200) expect(JSON.parse(last_response.body).length).to eq(3) + + get "/sites", {}, auth_headers(user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body).length).to eq(0) + + user.site = sites[0] + user.save! + + get "/sites", {}, auth_headers(user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body).length).to eq(1) end it "returns sites with correct structure" do - get "/sites", {}, auth_headers + get "/sites", {}, auth_headers(admin_user) json_response = JSON.parse(last_response.body) first_site = json_response.first @@ -42,135 +53,75 @@ def auth_headers end end - describe "GET /sites/:id" do + describe "GET /sites/:id/documents" do let!(:site) { create(:site) } - context "when the site exists" do - it "returns the requested site" do - get "/sites/#{site.id}", {}, auth_headers - expect(last_response.status).to eq(200) - - json_response = JSON.parse(last_response.body) - expect(json_response["id"]).to eq(site.id) - expect(json_response["name"]).to eq(site.name) - expect(json_response["location"]).to eq(site.location) - expect(json_response["primary_url"]).to eq(site.primary_url) - end - end + let!(:document) { create_list(:document, 10, site: site) } - context "when the site does not exist" do - it "returns 404 not found" do - get "/sites/0", {}, auth_headers - expect(last_response.status).to eq(404) - end - end - end - - describe "POST /sites/:id/documents" do - let!(:site) { create(:site) } - let(:timestamp) { Time.current } - let(:valid_documents) do - [ - {url: "https://example.com/doc1.pdf", modification_date: timestamp, document_category: "Brochure"}, - {url: "https://example.com/doc2.pdf", modification_date: timestamp, document_category: "Brochure"} - ] + it "blocks access to anonymous users" do + get "/sites/#{site.id}/documents" + expect(last_response.status).to eq(401) end - context "when the site exists" do - it "blocks access to anonymous users" do - post "/sites/#{site.id}/documents", {documents: valid_documents} - expect(last_response.status).to eq(401) - end - - it "creates new documents for new URLs" do - expect { - post "/sites/#{site.id}/documents", {documents: valid_documents}, auth_headers - }.to change(Document, :count).by(2) - - expect(last_response.status).to eq(201) - - json_response = JSON.parse(last_response.body) - expect(json_response["documents"].length).to eq(2) - - first_doc = json_response["documents"].first - expect(first_doc).to include( - "id", - "url", - "document_status", - "s3_path" - ) - expect(first_doc["url"]).to eq(valid_documents.first[:url]) - expect(first_doc["document_status"]).to eq("discovered") - expect(first_doc["s3_path"]).to include(site.s3_endpoint_prefix) - end - - it "updates existing documents when modification_date changes" do - existing_doc = site.documents.create!( - url: valid_documents.first[:url], - modification_date: 1.day.ago, - file_name: "doc1.pdf", - document_status: "discovered", - document_category: "Brochure" - ) + it "returns all accessible documents" do + get "/sites/#{site.id}/documents", {}, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(10) - expect { - post "/sites/#{site.id}/documents", {documents: valid_documents}, auth_headers - }.to change(Document, :count).by(1) # Only creates one new document + get "/sites/#{site.id}/documents", {}, auth_headers(user) + expect(last_response.status).to eq(401) - expect(last_response.status).to eq(201) + user.site = site + user.save! - existing_doc.reload - expect(existing_doc.document_status).to eq("discovered") - expect(existing_doc.modification_date).to be_within(1.second).of(timestamp) - end - - it "doesn't modify existing documents when modification_date hasn't changed" do - existing_doc = site.documents.create!( - url: valid_documents.first[:url], - modification_date: timestamp, - file_name: "doc1.pdf", - document_status: "discovered", - document_category: "Brochure" - ) + get "/sites/#{site.id}/documents", {}, auth_headers(user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(10) + end - expect { - post "/sites/#{site.id}/documents", {documents: valid_documents}, auth_headers - }.to change(Document, :count).by(1) # Only creates one new document + it "paginates" do + get "/sites/#{site.id}/documents", {page: 0, items_per_page: 2}, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(2) + get "/sites/#{site.id}/documents", {page: 4, items_per_page: 2}, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(2) + get "/sites/#{site.id}/documents", {page: 5, items_per_page: 2}, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["documents"].length).to eq(0) + end + end - expect(last_response.status).to eq(201) + describe "GET /documents/:id/document_inference" do + let!(:site) { create(:site) } + let!(:document) { create(:document, site: site) } + let!(:document_inference) { create_list(:document_inference, 4, document: document) } - existing_doc.reload - expect(existing_doc.document_status).to eq("discovered") - end + it "blocks access to anonymous users" do + get "/documents/#{document.id}/document_inference" + expect(last_response.status).to eq(401) end - context "when the site does not exist" do - it "returns 404 not found" do - post "/sites/0/documents", {documents: valid_documents}, auth_headers - expect(last_response.status).to eq(404) - end - end + it "returns all accessible document inferences" do + get "/documents/#{document.id}/document_inference", {}, auth_headers(admin_user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["document_inferences"].length).to eq(4) - context "with invalid parameters" do - it "returns 400 bad request when documents is missing" do - post "/sites/#{site.id}/documents", {}, auth_headers - expect(last_response.status).to eq(400) - end + get "/documents/#{document.id}/document_inference", {}, auth_headers(user) + expect(last_response.status).to eq(401) - it "returns 400 bad request when documents is not an array" do - post "/sites/#{site.id}/documents", {documents: "not_an_array"}, auth_headers - expect(last_response.status).to eq(400) - end + user.site = site + user.save! - it "returns 400 bad request when document is missing required fields" do - post "/sites/#{site.id}/documents", {documents: [{url: "https://example.com/doc.pdf"}]}, auth_headers - expect(last_response.status).to eq(400) - end + get "/documents/#{document.id}/document_inference", {}, auth_headers(user) + expect(last_response.status).to eq(200) + expect(JSON.parse(last_response.body)["document_inferences"].length).to eq(4) end end describe "POST /documents/:id/inference" do let(:timestamp) { Time.current } - let!(:document) { create(:document) } + let!(:site) { create(:site) } + let!(:document) { create(:document, site: site) } let(:inference) { {inference_type: "exception", result: {is_archival: "True", why_archival: "This document is in a special archival section."}} } let(:inference_update) { {inference_type: "exception", result: {is_archival: "True", why_archival: "This document is in a special archival section.", is_application: "True", why_application: "Test 123"}} } @@ -181,13 +132,24 @@ def auth_headers end it "creates new inferences" do expect { - post "/documents/#{document.id}/inference", inference, auth_headers + post "/documents/#{document.id}/inference", inference, auth_headers(admin_user) }.to change(DocumentInference, :count).by(1) expect(document.document_inferences.count).to eq(1) expect { - post "/documents/#{document.id}/inference", inference_update, auth_headers + post "/documents/#{document.id}/inference", inference_update, auth_headers(admin_user) }.to change(DocumentInference, :count).by(2) expect(document.document_inferences.count).to eq(3) + + post "/documents/#{document.id}/inference", inference_update, auth_headers(user) + expect(last_response.status).to eq(401) + expect(document.document_inferences.count).to eq(3) + + user.site = site + user.save! + + post "/documents/#{document.id}/inference", inference_update, auth_headers(user) + expect(last_response.status).to eq(201) + expect(document.document_inferences.count).to eq(5) end end end diff --git a/terraform/modules/ecs/main.tf b/terraform/modules/ecs/main.tf index 9fb36c67..df5f9833 100644 --- a/terraform/modules/ecs/main.tf +++ b/terraform/modules/ecs/main.tf @@ -16,8 +16,8 @@ module "fargate_service" { memory = 2048 container_port = 3000 - execution_policies = [aws_iam_policy.ecs_task_secrets_policy.arn, aws_iam_policy.ecs_s3_access.arn] - task_policies = [aws_iam_policy.ecs_task_lambda_invoke_policy.arn] + execution_policies = [aws_iam_policy.ecs_task_secrets_policy.arn] + task_policies = [aws_iam_policy.ecs_task_lambda_invoke_policy.arn, aws_iam_policy.ecs_s3_access.arn] enable_execute_command = true create_version_parameter = true diff --git a/yarn.lock b/yarn.lock index bc21c61b..3a2266a4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -235,6 +235,11 @@ resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-7.2.201.tgz#bfb3da01b3e2462f5a18f372c52dedd7de76037f" integrity sha512-wsTdWoZ5EfG5k3t7ORdyQF0ZmDEgN4aVPCanHAiNEwCROqibSZMXXmCbH7IDJUVri4FOeAVwwbPINI7HVHPKBw== +"@scarf/scarf@=1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@scarf/scarf/-/scarf-1.4.0.tgz#3bbb984085dbd6d982494538b523be1ce6562972" + integrity sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ== + ansi-regex@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" @@ -945,6 +950,13 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +swagger-ui-dist@^5.27.1: + version "5.27.1" + resolved "https://registry.yarnpkg.com/swagger-ui-dist/-/swagger-ui-dist-5.27.1.tgz#556e77c659752e99621ac61ad5ef6cb0832279e7" + integrity sha512-oGtpYO3lnoaqyGtlJalvryl7TwzgRuxpOVWqEHx8af0YXI+Kt+4jMpLdgMtMcmWmuQ0QTCHLKExwrBFMSxvAUA== + dependencies: + "@scarf/scarf" "=1.4.0" + tailwindcss@^3.4.1: version "3.4.17" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.17.tgz#ae8406c0f96696a631c790768ff319d46d5e5a63" From 3df8c0701f22a386eddade44b2ab2f98302c5c8c Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 3 Sep 2025 07:21:34 -0600 Subject: [PATCH 4/5] Dep: 2025-09-03 (#294) * Bump pg from 1.6.1 to 1.6.2 (#293) Bumps [pg](https://github.com/ged/ruby-pg) from 1.6.1 to 1.6.2. - [Changelog](https://github.com/ged/ruby-pg/blob/master/CHANGELOG.md) - [Commits](https://github.com/ged/ruby-pg/compare/v1.6.1...v1.6.2) --- updated-dependencies: - dependency-name: pg dependency-version: 1.6.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: Leo Kacenjar Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump aws-sdk-ses from 1.88.0 to 1.90.0 (#292) Bumps [aws-sdk-ses](https://github.com/aws/aws-sdk-ruby) from 1.88.0 to 1.90.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-ses/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-ses dependency-version: 1.90.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: Leo Kacenjar Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> * Bump aws-sdk-lambda from 1.158.0 to 1.160.0 (#291) * Bump aws-sdk-lambda from 1.158.0 to 1.160.0 Bumps [aws-sdk-lambda](https://github.com/aws/aws-sdk-ruby) from 1.158.0 to 1.160.0. - [Release notes](https://github.com/aws/aws-sdk-ruby/releases) - [Changelog](https://github.com/aws/aws-sdk-ruby/blob/version-3/gems/aws-sdk-lambda/CHANGELOG.md) - [Commits](https://github.com/aws/aws-sdk-ruby/commits) --- updated-dependencies: - dependency-name: aws-sdk-lambda dependency-version: 1.160.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] * Reroll lock file. --------- Signed-off-by: dependabot[bot] Co-authored-by: Leo Kacenjar Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- Gemfile.lock | 54 ++++++++++++++++++++++++++-------------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index f58c3bc6..b503b534 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -82,8 +82,8 @@ GEM public_suffix (>= 2.0.2, < 7.0) ast (2.4.3) aws-eventstream (1.4.0) - aws-partitions (1.1148.0) - aws-sdk-core (3.229.0) + aws-partitions (1.1154.0) + aws-sdk-core (3.232.0) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) @@ -91,21 +91,21 @@ GEM bigdecimal jmespath (~> 1, >= 1.6.1) logger - aws-sdk-kms (1.110.0) - aws-sdk-core (~> 3, >= 3.228.0) + aws-sdk-kms (1.112.0) + aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) - aws-sdk-lambda (1.158.0) - aws-sdk-core (~> 3, >= 3.228.0) + aws-sdk-lambda (1.160.0) + aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.197.0) - aws-sdk-core (~> 3, >= 3.228.0) + aws-sdk-s3 (1.198.0) + aws-sdk-core (~> 3, >= 3.231.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sdk-secretsmanager (1.119.0) - aws-sdk-core (~> 3, >= 3.228.0) + aws-sdk-secretsmanager (1.120.0) + aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) - aws-sdk-ses (1.88.0) - aws-sdk-core (~> 3, >= 3.228.0) + aws-sdk-ses (1.90.0) + aws-sdk-core (~> 3, >= 3.231.0) aws-sigv4 (~> 1.5) aws-sigv4 (1.12.1) aws-eventstream (~> 1, >= 1.0.2) @@ -116,7 +116,7 @@ GEM erubi (>= 1.0.0) rack (>= 0.9.0) rouge (>= 1.0.0) - bigdecimal (3.2.2) + bigdecimal (3.2.3) bindex (0.8.1) bootsnap (1.18.6) msgpack (~> 1.2) @@ -143,7 +143,7 @@ GEM childprocess (5.1.0) logger (~> 1.5) concurrent-ruby (1.3.5) - connection_pool (2.5.3) + connection_pool (2.5.4) crass (1.0.6) cssbundling-rails (1.4.3) railties (>= 6.0.0) @@ -186,7 +186,7 @@ GEM zeitwerk (~> 2.6) erb (5.0.2) erubi (1.13.1) - factory_bot (6.5.4) + factory_bot (6.5.5) activesupport (>= 6.1.0) factory_bot_rails (6.5.0) factory_bot (~> 6.5) @@ -254,7 +254,7 @@ GEM ruby2_keywords (~> 0.0.1) mustermann-grape (1.1.0) mustermann (>= 1.0.0) - net-imap (0.5.9) + net-imap (0.5.10) date net-protocol net-pop (0.1.2) @@ -292,13 +292,13 @@ GEM parser (3.3.9.0) ast (~> 2.4.1) racc - pg (1.6.1) - pg (1.6.1-aarch64-linux) - pg (1.6.1-aarch64-linux-musl) - pg (1.6.1-arm64-darwin) - pg (1.6.1-x86_64-darwin) - pg (1.6.1-x86_64-linux) - pg (1.6.1-x86_64-linux-musl) + pg (1.6.2) + pg (1.6.2-aarch64-linux) + pg (1.6.2-aarch64-linux-musl) + pg (1.6.2-arm64-darwin) + pg (1.6.2-x86_64-darwin) + pg (1.6.2-x86_64-linux) + pg (1.6.2-x86_64-linux-musl) pp (0.6.2) prettyprint prettyprint (0.2.0) @@ -314,7 +314,7 @@ GEM puma (6.6.1) nio4r (~> 2.0) racc (1.8.1) - rack (3.2.0) + rack (3.2.1) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -374,7 +374,7 @@ GEM responders (3.1.1) actionpack (>= 5.2) railties (>= 5.2) - rexml (3.4.1) + rexml (3.4.2) rouge (4.6.0) rspec (3.13.1) rspec-core (~> 3.13.0) @@ -396,7 +396,7 @@ GEM rspec-expectations (~> 3.13) rspec-mocks (~> 3.13) rspec-support (~> 3.13) - rspec-support (3.13.4) + rspec-support (3.13.5) rubocop (1.75.8) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -486,7 +486,7 @@ GEM railties (>= 7.1.0) tzinfo (2.0.6) concurrent-ruby (~> 1.0) - unicode-display_width (3.1.4) + unicode-display_width (3.1.5) unicode-emoji (~> 4.0, >= 4.0.4) unicode-emoji (4.0.4) uri (1.0.3) From 5a74da35e81b7985527ca24079e896dce944e148 Mon Sep 17 00:00:00 2001 From: Leo Kacenjar Date: Wed, 3 Sep 2025 07:39:13 -0600 Subject: [PATCH 5/5] Hide llm author for inferences without one. (#295) --- app/views/documents/_recommendation_list.html.erb | 2 +- app/views/documents/_summary.html.erb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/documents/_recommendation_list.html.erb b/app/views/documents/_recommendation_list.html.erb index 1fd187d1..87c668ad 100644 --- a/app/views/documents/_recommendation_list.html.erb +++ b/app/views/documents/_recommendation_list.html.erb @@ -25,7 +25,7 @@
"><%= exception.inference_reason %>
<% end %> - <% if exceptions.present? %> + <% if exceptions.present? && exceptions.first.inference_model_name.present? %>
Generated by <%= exceptions.first.inference_model_name %>
<% end %> diff --git a/app/views/documents/_summary.html.erb b/app/views/documents/_summary.html.erb index a717ef82..f2cb0a64 100644 --- a/app/views/documents/_summary.html.erb +++ b/app/views/documents/_summary.html.erb @@ -12,7 +12,7 @@ Summarizing... <% end %> - <% if summary_inference.present? %> + <% if summary_inference.present? && summary_inference.inference_model_name.present? %>
Generated by <%= summary_inference.inference_model_name %>
<% end %>