Add schedule image digestion via Claude Vision API #208
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "204-schedule-digest"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Photograph a printed weekly schedule and let Claude Vision extract property names per day, fuzzy-match them against existing Property records, then create WorkQueueItems after user confirmation. Handles the boss's column-overflow pattern where one day's list spills into the next column.
Changes
Gemfile-- addedgem "anthropic"for Claude Vision API accessapp/services/schedule_digester.rb-- new service orchestrating Vision API call, JSON parsing, and Levenshtein-based fuzzy matching against Property.client_namedb/migrate/20260613000000_add_week_start_to_uploads.rb-- addsweek_start:dateto uploads tableapp/models/upload.rb-- addedvision_compatible?helper and VISION_MEDIA_TYPES constantapp/controllers/uploads_controller.rb-- addeddigestandconfirm_digestactions with error handling for API/parse failuresapp/views/uploads/digest.html.erb-- confirmation UI showing exact matches (green), fuzzy matches (yellow), and unmatched names (gray/skipped)app/views/uploads/show.html.erb-- added week date picker and "Digest Schedule" buttonconfig/routes.rb-- addeddigestandconfirm_digestmember routes under uploadsapp/assets/stylesheets/application.css-- digest component styles following existing design tokens.env.example-- added ANTHROPIC_API_KEY placeholderspec/services/schedule_digester_spec.rb-- 24 unit tests for Levenshtein, matching, date math, item creation, and response parsingspec/requests/uploads_spec.rb-- 18 request specs covering digest actions, error handling, and duplicate skippingTest Plan
bundle exec rspec-- 342 examples, 0 failuresReview Checklist
Related Notes
None
Related
Closes #204
PR #208 Review
DOMAIN REVIEW
Stack: Ruby on Rails 8.1, PostgreSQL, ActiveStorage, ERB views, vanilla CSS, RSpec.
Service object pattern (ScheduleDigester)
The service is well-structured: single public
#callmethod, dependency injection for the API client (client:parameter), clear separation between extraction, parsing, and matching. The class-levelcreate_work_queue_itemsmethod that handles persistence is a clean boundary.One notable code smell in
match_name(lines ~114-126 of the service): the two branches of the ambiguous-match conditional produce identical results:The comment says "if the best match is clearly better (distance gap >= 2), pick it" but the implementation ignores the gap entirely. Both branches do the same thing, and the threshold of 2 mentioned in the comment is not applied. This is dead logic -- the
if/elsecan be collapsed to a single expression. This is not a blocker but it is misleading: a future reader might trust the comment and believe ambiguous ties are handled differently.Levenshtein implementation
The O(n*m) matrix implementation is correct for the use case (short property names). No issues with the algorithm itself. The threshold of 3 is reasonable for landscaping client names.
Fuzzy matching edge case:
normalizestrips all non-alphanumeric characters. A name like "O'Brien" becomes "obrien" (7 chars). If a property is "OBrien" it normalizes to "obrien" -- exact match. If a property is stored as "O'Brien" it also normalizes to "obrien" -- exact match. This is actually desirable behavior. Good.Controller security
require_role :lead, :admin, :super_adminat the class level (line 2 of currentuploads_controller.rb). The newdigestandconfirm_digestactions are NOT excluded from this filter, so they are properly role-gated. Correct.ApplicationController'sbefore_action :authenticate_user!. Correct.Upload.find(params[:id])will raiseActiveRecord::RecordNotFound(which Rails maps to 404) for invalid IDs. Correct.parse_week_starthas a rescue forDate::Error, falling back to current Monday. Defensive. Good.build_day_assignmentswhitelists day names viaScheduleDigester::DAYS.each, preventing arbitrary keys from reaching the service. Theto_i.reject(&:zero?)pattern prevents non-numeric property IDs. Correct.API key handling
ENV.fetch("ANTHROPIC_API_KEY")will raiseKeyErrorat runtime if the env var is missing. This is good -- fail fast rather than sending a nil API key. However, this error is NOT caught by the rescue clauses in thedigestaction (they only catchApiErrorandParseError). A missing API key will produce an unhandledKeyErrorwith a 500 error. This should either be caught or documented as a deploy requirement.View safety (XSS, CSRF)
entry[:name],entry[:property].client_name) is output via ERB<%= %>which auto-escapes HTML. No raw output. Safe.form_withgenerates CSRF tokens by default in Rails. Both forms useform_with. Safe.method: :postfor bothdigestandconfirm_digest. Correct -- these are state-changing operations.Migration
add_column :uploads, :week_start, :date-- simple, reversible, no data loss. Correct.Schema drift note: The
db/schema.rbdiff introduces adaily_notestable that is NOT part of this PR's migration. This is a side effect of schema regeneration picking up a previously merged migration. The PR itself is clean -- this is just schema.rb noise.Zero-bloat constraint
Property.createorProperty.newcalls anywhere in the service or controller. Unmatched names are displayed with "No match -- skipped" and never persisted. Constraint satisfied.Duplicate handling
WorkQueueItemhas a uniqueness validation at the model level (validates :property_id, uniqueness: { scope: :work_date }) and a unique DB index (index_work_queue_items_on_work_date_and_property_id).find_byto detect existing items beforecreate!. This is a check-then-act pattern that has a theoretical race condition (two concurrent confirm_digest requests for the same day/property), but the unique index provides a DB-level safety net -- the secondcreate!would raiseActiveRecord::RecordNotUnique. That exception is NOT caught, so a race would produce a 500. Extremely unlikely in practice for a single-user-at-a-time landscaping app, but worth noting.Test coverage
BLOCKERS
None.
NITS
Dead conditional in
match_name(service line ~114-126): Theif scored[0][:distance] < scored[1][:distance]branch and itselseproduce identical output. The comment about a "distance gap >= 2" is not implemented. Either implement the intended disambiguation logic or collapse to a single expression and update the comment.Hardcoded color
#fffbebin CSS (.digest-item-fuzzy) and#f59e0b(.digest-badge-fuzzy): Every other color in the component uses design tokens (var(--color-success),var(--color-border), etc.). These two should bevar(--color-warning-light)andvar(--color-warning)or similar tokens for consistency. If those tokens don't exist yet, add them.Unhandled
KeyError: IfANTHROPIC_API_KEYis not set,ENV.fetchraisesKeyErrorwhich is not caught by therescueclauses. Consider either: (a) addingrescue KeyErrorto the digest action with a user-friendly message, or (b) adding a model-level or initializer check that fails at boot time.Race condition on duplicate detection:
find_by+create!without arescue ActiveRecord::RecordNotUnique. The unique index saves data integrity, but the unrescued exception means a 500 on concurrent duplicate. Consider wrapping in a rescue or usingfind_or_create_by.Model file (
upload.rb) missing the new column: The diff addsVISION_MEDIA_TYPESandvision_compatible?to the model, but the model in the diff does not haveweek_startin any attribute whitelist or validation. This is fine for Rails (dynamic attributes), but a comment notingweek_startis set by the controller would help future readers.daily_notesin schema.rb: This table appears in the schema diff but has no corresponding migration in this PR. If this is leftover from a prior merge, the schema.rb change is correct but noisy. Not a blocker.SOP COMPLIANCE
.env.exampleonly has placeholder)PROCESS OBSERVATIONS
.env.exampleupdate documents the new env var requirement. The service has thorough inline documentation. The PR body is comprehensive.VERDICT: APPROVED
Solid, well-tested feature with proper role enforcement, XSS safety, and defensive error handling. The nits are all non-blocking improvements -- the dead conditional is the most worth addressing for code clarity, but none block merge.
PR #208 Review
DOMAIN REVIEW
Stack identified: Ruby on Rails 8.1, ActiveRecord, ActiveStorage, Anthropic Ruby SDK, ERB views, vanilla CSS, RSpec.
Ruby / Rails quality:
Service object architecture --
ScheduleDigesteris well-structured: single responsibility, dependency-injectable client for testing, clear public API. Good separation between API call, parsing, matching, and item creation.Levenshtein implementation -- Correct O(m*n) dynamic programming. Threshold of 3 is reasonable for landscaping client names (short strings). No performance concern since the property list is small (loaded with
.to_a).Controller --
digestandconfirm_digestfollow the existing controller patterns (find-by-id, redirect with flash). Error rescue forApiErrorandParseErroris solid. Theparse_week_startfallback to current Monday onDate::Erroris defensive and correct.Duplicate handling -- Double-defense:
find_bycheck +rescue ActiveRecord::RecordNotUnique. This is correct given that theWorkQueueItemmodel validatesuniqueness: { scope: :work_date }onproperty_id.Migration -- Simple
add_column :uploads, :week_start, :date. Non-breaking, nullable. Good.Model additions --
Upload#vision_compatible?andVISION_MEDIA_TYPESare clean. Note thatimage/gifis inVISION_MEDIA_TYPESbut not inALLOWED_CONTENT_TYPES-- this meansvision_compatible?will never return true for GIFs since they can't be uploaded. Not a bug (the check is correct), but GIF in the VISION list is dead code.CSS -- Uses existing design tokens (
--spacing-*,--color-*,--radius). No magic numbers. New--color-warningand--color-warning-lightCSS custom properties added to the root -- these are good additions to the design token system.Security review:
API key handling --
ENV.fetch("ANTHROPIC_API_KEY")with custom error. Key never logged or exposed in views..env.examplehas placeholder only. Good.Input validation --
build_day_assignmentsvalidates input type (ActionController::ParametersorHash), filters to known day names viaScheduleDigester::DAYS.each, converts IDs with.to_i.reject(&:zero?). This prevents injection of arbitrary keys or non-integer IDs.Authorization -- The controller inherits
require_role :lead, :admin, :super_adminfrom line 2 ofUploadsController. The newdigestandconfirm_digestactions are properly covered by this class-level guard. Good.Test coverage:
Unit tests (
schedule_digester_spec.rb, 24 examples) -- Covers: Levenshtein edge cases, exact/fuzzy/no match, date math, WorkQueueItem creation, duplicate skipping, position assignment, response parsing (valid JSON, markdown-fenced JSON, invalid JSON, non-object JSON, partial days), API error paths.Request specs (
uploads_spec.rb, 18 examples) -- Covers: digest success, API failure redirect, parse failure redirect, week_start persistence, default week_start, confirm_digest creation, duplicate skipping, empty assignment redirect, missing assignments param.Mock strategy -- Uses
instance_doublefor the service anddoublefor the Anthropic client chain. The client is injected via constructor, avoiding env-dependent tests. Clean.BLOCKERS
None found. The code is well-structured, properly authorized, has strong test coverage (42 new specs), handles errors defensively, and follows existing patterns.
NITS
Dead media type (non-blocking) --
VISION_MEDIA_TYPESincludesimage/gifbutALLOWED_CONTENT_TYPESdoes not include GIF, sovision_compatible?will never return true for a GIF upload. Consider removingimage/giffromVISION_MEDIA_TYPESto avoid confusion, or adding a comment explaining it's there for future-proofing.Model constant in controller (non-blocking) --
ScheduleDigester::DAYSis referenced directly inUploadsController#build_day_assignments. This is fine for now but creates a coupling. If the service were ever extracted to a gem, the controller would need updating. Minor.Radio button override UX (non-blocking) -- The
digest.html.erbview shows radio buttons for alternative fuzzy match candidates (override_<day>_<idx>), but those radio button values are never processed inconfirm_digest. The form only readsassignments[<day>][]from the checkboxes. If a user selects an alternative candidate via radio, it has no effect. This is a UX gap -- the radio buttons suggest a choice that isn't wired up.schema.rbincludesdaily_notestable (non-blocking) -- The diff todb/schema.rbadds adaily_notestable that isn't part of this PR's migration. This appears to come from another branch's migration being present locally. The PR's own migration (add_week_start_to_uploads) is correct, but the schema.rb diff is noisy.SOP COMPLIANCE
204-schedule-digestreferences #204)Closes #204present in PR body.env.examplehas placeholder only)schema.rbnoise fromdaily_notesis a schema rebuild artifact, not scope creep)PROCESS OBSERVATIONS
week_startcolumn. Removing the feature would just mean dead column, no data loss.VERDICT: APPROVED
b4ac9db150cac79c3b67