diff --git a/README.md b/README.md index efbdcf3..bd6d51f 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,8 @@ Sorting Hat watches while it is running, shows exactly how each file was renamed Before saving a new ruleset, preview it against up to eight representative files. Preview uses the same extraction, model, compiled routing, collision naming, and validation path as live filing while deliberately skipping every file move and Finder-tag write. +Manual corrections are one-off decisions first: filing the corrected file never changes the ruleset. Afterwards, the on-device model can propose one reusable rule with its evidence, variables, rename policy, destination, and tags. You can edit it, preview it against real files, add it explicitly, or discard it. Duplicate, overlapping, ordering, and catch-all conflicts are surfaced before activation. + ### Send to Sorting Hat from Finder Signed builds include a first-party **Send to Sorting Hat** Quick Action. Select files in Finder, then choose **Quick Actions → Send to Sorting Hat**. The action copies each file into Sorting Hat’s App Group queue. It never changes the original. diff --git a/SortingHat.xcodeproj/project.pbxproj b/SortingHat.xcodeproj/project.pbxproj index f295bda..e5781ec 100644 --- a/SortingHat.xcodeproj/project.pbxproj +++ b/SortingHat.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 07FFEE04A6DF0BCCA1222E6E /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = D4A766D04A9129702226E922 /* Assets.xcassets */; }; + 08068FF335B32E2AEBBA9F4C /* CorrectionProposal.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED0EF45CF551E226954E901B /* CorrectionProposal.swift */; }; 1075DF6EF3C2B7E879D45496 /* ReviewQueueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D732DC01AE240AE483F423D6 /* ReviewQueueView.swift */; }; 134DFD0111902BE8DE6477C8 /* ActionRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 88ADD613E69FA0224084FE99 /* ActionRequestHandler.swift */; }; 156ECDBB56241DCA999D4E1E /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698A482A0EAAA4043EBF9D97 /* DashboardView.swift */; }; @@ -22,10 +23,13 @@ 48E0E24F0F8E56CC0A895839 /* RulePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5877424082F407D420F5A0B0 /* RulePreviewView.swift */; }; 4A9806D2E0B2AA92546EC5A6 /* HatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0EB807461CD2FBEAC90182D /* HatStore.swift */; }; 4D6DAE619D65BF73691A3841 /* APIKeyStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC8CEA00F91AA384944B6DC1 /* APIKeyStore.swift */; }; + 4F87684708872FD91132D0F8 /* CorrectionProposalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFF8527A97DB04266D7C423 /* CorrectionProposalView.swift */; }; 5DDE652A6495AEDF7070DFE9 /* ActivityLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0581E19BBC86EFF7EC95D2EB /* ActivityLedger.swift */; }; 5EE032DA4810D1BD5C9AB3CF /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */; }; 61F1CD9A1B8610DF1CBE4FFE /* SortingHatCore in Frameworks */ = {isa = PBXBuildFile; productRef = F6EB718C6BC41BF6F4740AD2 /* SortingHatCore */; }; + 629085613AC57DF0D3CC73C8 /* CorrectionProposalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EFF8527A97DB04266D7C423 /* CorrectionProposalView.swift */; }; 6F0C6CFB4797F5916E1B1336 /* SortingHatTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149915F7FA55267F8FBC9713 /* SortingHatTheme.swift */; }; + 6F160E9DC65F602FE00674D5 /* CorrectionProposalGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33300E7F125B24F549B5CE1 /* CorrectionProposalGenerator.swift */; }; 76BAE53C12CE3E5355EE24C1 /* SetupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C24EBAF0EB6D03C27BC24A53 /* SetupView.swift */; }; 7BBC5F5FB4BB269F8261358B /* SendToSortingHatAction.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 021448C65120774D985C50A3 /* SendToSortingHatAction.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 7D4248D6CE8CB7ABCEAF1F65 /* SortingHatCore in Frameworks */ = {isa = PBXBuildFile; productRef = 86939E00D996CAE3F6BD283E /* SortingHatCore */; }; @@ -34,6 +38,7 @@ 8C3A3A275FDBBD8377D9A24A /* RulePlanGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B2C203CFB8321153FC8503E /* RulePlanGenerator.swift */; }; 8F2516B9CC84035749E968A9 /* HatStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0EB807461CD2FBEAC90182D /* HatStore.swift */; }; 9264220AF29BFBC9535A239E /* Sparkle.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 6DB89D36D529A5AFAC90D510 /* Sparkle.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 96CD3424C886F3EEE3B7B03A /* CorrectionProposalGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D33300E7F125B24F549B5CE1 /* CorrectionProposalGenerator.swift */; }; 9DE7B839DC241D1700413358 /* RulePlan.swift in Sources */ = {isa = PBXBuildFile; fileRef = E34BBCCB587929B4260D649C /* RulePlan.swift */; }; C2ABECC8A7B77D8EA2907583 /* ModelSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79D9589A55D599EC62EC61FB /* ModelSettingsView.swift */; }; C5B2DB90B8FA1909CD48FA9C /* SortingHatTheme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 149915F7FA55267F8FBC9713 /* SortingHatTheme.swift */; }; @@ -41,6 +46,7 @@ CD5BCBDFE31FA826097C6016 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 698A482A0EAAA4043EBF9D97 /* DashboardView.swift */; }; D74B53C19471B7E723F89DDD /* RulePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5877424082F407D420F5A0B0 /* RulePreviewView.swift */; }; D84B9DD6D206F020352BCD4F /* ActivityLedger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0581E19BBC86EFF7EC95D2EB /* ActivityLedger.swift */; }; + DD361F000AFB6E5916556EF1 /* CorrectionProposal.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED0EF45CF551E226954E901B /* CorrectionProposal.swift */; }; E19A1580814AC46E0FD700F1 /* SortingHatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47521A9F025FA497A89BCEA8 /* SortingHatApp.swift */; }; EBE0940669FF2D55CF7F9BC4 /* Sparkle.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6DB89D36D529A5AFAC90D510 /* Sparkle.xcframework */; }; EF7F78159BA7E53BB24B0C23 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */; }; @@ -111,6 +117,7 @@ 3B2C203CFB8321153FC8503E /* RulePlanGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePlanGenerator.swift; sourceTree = ""; }; 44D8496DA7F3A4922495C13B /* SortingHat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SortingHat; path = .; sourceTree = SOURCE_ROOT; }; 47521A9F025FA497A89BCEA8 /* SortingHatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortingHatApp.swift; sourceTree = ""; }; + 4EFF8527A97DB04266D7C423 /* CorrectionProposalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionProposalView.swift; sourceTree = ""; }; 5877424082F407D420F5A0B0 /* RulePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePreviewView.swift; sourceTree = ""; }; 60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 698A482A0EAAA4043EBF9D97 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; @@ -123,9 +130,11 @@ C0A331DE0605C30EA78F5E41 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; C24EBAF0EB6D03C27BC24A53 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = ""; }; CF783C14FBCE3B3B969A8140 /* RulesEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesEditorView.swift; sourceTree = ""; }; + D33300E7F125B24F549B5CE1 /* CorrectionProposalGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionProposalGenerator.swift; sourceTree = ""; }; D4A766D04A9129702226E922 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D732DC01AE240AE483F423D6 /* ReviewQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewQueueView.swift; sourceTree = ""; }; E34BBCCB587929B4260D649C /* RulePlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePlan.swift; sourceTree = ""; }; + ED0EF45CF551E226954E901B /* CorrectionProposal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionProposal.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -199,6 +208,7 @@ 64F4FDFB90B9426C54FE4A7D /* Services */ = { isa = PBXGroup; children = ( + D33300E7F125B24F549B5CE1 /* CorrectionProposalGenerator.swift */, 3B2C203CFB8321153FC8503E /* RulePlanGenerator.swift */, ); path = Services; @@ -267,6 +277,7 @@ F4A0A0BF472449C9C0CC2E45 /* Models */ = { isa = PBXGroup; children = ( + ED0EF45CF551E226954E901B /* CorrectionProposal.swift */, E34BBCCB587929B4260D649C /* RulePlan.swift */, ); path = Models; @@ -275,6 +286,7 @@ FC45049DD3E289D86DC0E142 /* Views */ = { isa = PBXGroup; children = ( + 4EFF8527A97DB04266D7C423 /* CorrectionProposalView.swift */, 698A482A0EAAA4043EBF9D97 /* DashboardView.swift */, 79D9589A55D599EC62EC61FB /* ModelSettingsView.swift */, D732DC01AE240AE483F423D6 /* ReviewQueueView.swift */, @@ -429,6 +441,9 @@ files = ( 37F835EB8CE4DB9604AB202D /* APIKeyStore.swift in Sources */, 5DDE652A6495AEDF7070DFE9 /* ActivityLedger.swift in Sources */, + DD361F000AFB6E5916556EF1 /* CorrectionProposal.swift in Sources */, + 6F160E9DC65F602FE00674D5 /* CorrectionProposalGenerator.swift in Sources */, + 4F87684708872FD91132D0F8 /* CorrectionProposalView.swift in Sources */, CD5BCBDFE31FA826097C6016 /* DashboardView.swift in Sources */, 4A9806D2E0B2AA92546EC5A6 /* HatStore.swift in Sources */, C2ABECC8A7B77D8EA2907583 /* ModelSettingsView.swift in Sources */, @@ -458,6 +473,9 @@ files = ( 4D6DAE619D65BF73691A3841 /* APIKeyStore.swift in Sources */, D84B9DD6D206F020352BCD4F /* ActivityLedger.swift in Sources */, + 08068FF335B32E2AEBBA9F4C /* CorrectionProposal.swift in Sources */, + 96CD3424C886F3EEE3B7B03A /* CorrectionProposalGenerator.swift in Sources */, + 629085613AC57DF0D3CC73C8 /* CorrectionProposalView.swift in Sources */, 156ECDBB56241DCA999D4E1E /* DashboardView.swift in Sources */, 8F2516B9CC84035749E968A9 /* HatStore.swift in Sources */, 46FB4150E8242BF07A56D09F /* ModelSettingsView.swift in Sources */, diff --git a/Sources/SortingHatApp/Models/CorrectionProposal.swift b/Sources/SortingHatApp/Models/CorrectionProposal.swift new file mode 100644 index 0000000..40f1499 --- /dev/null +++ b/Sources/SortingHatApp/Models/CorrectionProposal.swift @@ -0,0 +1,48 @@ +import Foundation + +struct CorrectionContext: Identifiable, Sendable { + let id: UUID + let originalName: String + let correctedName: String + let destination: String + let fileURL: URL + let reviewReason: String +} + +struct CorrectionRuleProposal: Equatable, Sendable { + var fileKinds: String + var destinationTemplate: String + var renamePolicy: String + var tags: [String] + var reason: String + + var ruleText: String { + var value = "Put \(fileKinds) in \(destinationTemplate)" + let rename = renamePolicy.trimmingCharacters(in: .whitespacesAndNewlines) + if !rename.isEmpty { value += ", rename them \(rename)" } + let cleanTags = tags.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } + if !cleanTags.isEmpty { value += ", and tag them \(cleanTags.joined(separator: " and "))" } + return value + "." + } +} + +enum CorrectionProposalDisposition: String, Codable, Sendable { + case pending + case accepted + case edited + case discarded +} + +struct CorrectionProposalHistory: Codable, Sendable { + let disposition: CorrectionProposalDisposition + let rule: String? + + var summary: String { + switch disposition { + case .pending: "Reusable rule proposal awaiting a decision" + case .accepted: "Reusable rule proposal accepted" + case .edited: "Reusable rule proposal edited and accepted" + case .discarded: "Reusable rule proposal discarded; saved rules unchanged" + } + } +} diff --git a/Sources/SortingHatApp/Services/CorrectionProposalGenerator.swift b/Sources/SortingHatApp/Services/CorrectionProposalGenerator.swift new file mode 100644 index 0000000..08523fb --- /dev/null +++ b/Sources/SortingHatApp/Services/CorrectionProposalGenerator.swift @@ -0,0 +1,106 @@ +import Foundation +import FoundationModels +import SortingHatCore + +struct CorrectionProposalGenerator: Sendable { + func generate(for correction: CorrectionContext) async throws -> CorrectionRuleProposal { + guard #available(macOS 26.0, *) else { + throw RulePlanError.unavailable("Apple Foundation Models are unavailable. The correction is filed, and no rule was changed.") + } + + let excerpt = await Task.detached(priority: .userInitiated) { + DocumentTextExtractor.extract(from: correction.fileURL, characterLimit: 2_000, pageLimit: 2) + }.value + return try await generateNative(for: correction, excerpt: excerpt) + } + + @available(macOS 26.0, *) + private func generateNative(for correction: CorrectionContext, excerpt: String?) async throws -> CorrectionRuleProposal { + let model = SystemLanguageModel(useCase: .general, guardrails: .permissiveContentTransformations) + guard model.isAvailable else { + throw RulePlanError.unavailable("Apple Intelligence is unavailable. The correction is filed, and no rule was changed.") + } + + let prompt = """ + ORIGINAL NAME: \(correction.originalName) + CORRECTED NAME: \(correction.correctedName) + CORRECT DESTINATION: \(correction.destination) + WHY REVIEW WAS NEEDED: \(correction.reviewReason) + LOCAL CONTENT EXCERPT: \(excerpt ?? "No readable text was available.") + """ + + var lastError: Error? + var feedback = "" + for attempt in 0..<3 { + do { + let session = LanguageModelSession(model: model, instructions: Self.instructions) + let response = try await session.respond( + to: prompt + feedback, + schema: Self.schema, + options: GenerationOptions(sampling: .greedy) + ) + let content = response.content + let proposal = CorrectionRuleProposal( + fileKinds: try content.value(String.self, forProperty: "fileKinds"), + destinationTemplate: try content.value(String.self, forProperty: "destinationTemplate"), + renamePolicy: try content.value(String.self, forProperty: "renamePolicy"), + tags: try content.value([String].self, forProperty: "tags"), + reason: try content.value(String.self, forProperty: "reason") + ) + try RoutingDecisionResolver.validateDestinationTemplate(proposal.destinationTemplate) + try Self.validateReusableDestination(proposal.destinationTemplate) + let assessment = RuleProposalPlanner.assess(proposedRule: proposal.ruleText, existingRules: []) + guard assessment.canAdd else { + throw RulePlanError.invalid(assessment.issues.map { $0.message }.joined(separator: " ")) + } + return proposal + } catch { + lastError = error + feedback = """ + + CORRECTION REQUIRED: The previous proposal was invalid: \(error.localizedDescription) + Return a new proposal. A variable must occupy an entire slash-separated component. The only exact variable names are {merchant}, {client}, {project}, {source-app}, {year}, {month}, and {year-month}. Never output {vendor}, {category}, or combine variables such as {year}-{month}. + """ + if attempt < 2 { try? await Task.sleep(for: .milliseconds(400 * (attempt + 1))) } + } + } + + throw RulePlanError.unavailable( + "The correction is filed, but the hat couldn’t produce a safe reusable rule. Your existing rules are unchanged. (\(lastError?.localizedDescription ?? "Invalid proposal"))" + ) + } + + @available(macOS 26.0, *) + private static let schema: GenerationSchema = { + let root = DynamicGenerationSchema( + name: "SortingHatCorrectionRuleProposal", + description: "One reusable filing rule inferred from a person's correction", + properties: [ + .init(name: "fileKinds", description: "A concise plural description of files sharing durable content traits; never a single filename or concrete merchant, client, or project", schema: .init(type: String.self)), + .init(name: "destinationTemplate", description: "A safe relative destination generalized from the correction, using fixed components or only {merchant}, {client}, {project}, {source-app}, {year}, {month}, or {year-month} as whole path components", schema: .init(type: String.self)), + .init(name: "renamePolicy", description: "A concise reusable filename pattern that preserves the original extension and does not contain a concrete corrected filename", schema: .init(type: String.self)), + .init(name: "tags", description: "Zero to four reusable Finder tags, excluding concrete names unless the rule intentionally matches that fixed identity", schema: .init(arrayOf: .init(type: String.self), maximumElements: 4)), + .init(name: "reason", description: "One sentence explaining the evidence and which destination values are variable versus fixed", schema: .init(type: String.self)), + ] + ) + return try! GenerationSchema(root: root, dependencies: []) + }() + + private static let instructions = """ + Propose one narrow, reusable Sorting Hat route from a manual correction. Generalize only what the evidence supports. Do not claim that one example proves a broad category. Use a controlled destination variable only when the corrected destination or content clearly supplies that concept; otherwise retain a meaningful fixed folder component. Never use absolute paths, tilde paths, dot components, unknown placeholders, a generic Sorted folder, or the Inbox. The proposal is advisory and must remain understandable and editable. All processing is local to this Mac. + """ + + private static func validateReusableDestination(_ template: String) throws { + for component in template.split(separator: "/").map(String.init) { + if let year = Int(component), (1900...2200).contains(year) { + throw RulePlanError.invalid("Replace the fixed year \(component) with {year}.") + } + let parts = component.split(separator: "-") + if parts.count == 2, + let year = Int(parts[0]), (1900...2200).contains(year), + let month = Int(parts[1]), (1...12).contains(month) { + throw RulePlanError.invalid("Replace the fixed date \(component) with {year-month}.") + } + } + } +} diff --git a/Sources/SortingHatApp/Stores/HatStore.swift b/Sources/SortingHatApp/Stores/HatStore.swift index 5036c83..2215566 100644 --- a/Sources/SortingHatApp/Stores/HatStore.swift +++ b/Sources/SortingHatApp/Stores/HatStore.swift @@ -163,6 +163,10 @@ final class HatStore { RuleSetInspector.inspect(rules) } + func assessProposal(_ rule: String) throws -> RuleProposalAssessment { + RuleProposalPlanner.assess(proposedRule: rule, existingRules: try loadRules()) + } + func preview(rules: [String], files: [URL], output: URL? = nil) async throws -> [FilingPreview] { let cleaned = Self.cleanedRules(rules) try RuleSetInspector.validate(cleaned) @@ -327,7 +331,7 @@ final class HatStore { status = activity.outcome == .failed ? "Removed the error from Activity" : status } - func resolve(_ activity: Activity, filedName: String, destination: String, teachingRule: String?) throws { + func resolve(_ activity: Activity, filedName: String, destination: String) throws -> CorrectionContext { guard let source = activity.fileURL, FileManager.default.fileExists(atPath: source.path) else { throw RulePlanError.invalid("The review file is no longer in the Inbox.") } @@ -347,13 +351,53 @@ final class HatStore { try FileManager.default.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true) try FileManager.default.moveItem(at: source, to: destinationURL) recent.removeAll { $0.id == activity.id } - record(Activity(sourceName: activity.sourceName, sourceURL: source, filedName: name, destination: folder, - fileURL: destinationURL, detail: "Corrected during review", outcome: .filed)) - if let teachingRule, !teachingRule.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - var rules = try loadRules() - rules.insert(teachingRule.trimmingCharacters(in: .whitespacesAndNewlines), at: max(1, rules.count - 1)) - try saveRules(rules) + let corrected = Activity( + sourceName: activity.sourceName, + sourceURL: source, + filedName: name, + destination: folder, + fileURL: destinationURL, + detail: "Corrected during review", + outcome: .filed, + correctionProposal: CorrectionProposalHistory(disposition: .pending, rule: nil) + ) + record(corrected) + return CorrectionContext( + id: corrected.id, + originalName: activity.sourceName, + correctedName: name, + destination: folder, + fileURL: destinationURL, + reviewReason: activity.detail + ) + } + + func addCorrectionProposal(_ rule: String, generatedRule: String, for correction: CorrectionContext) throws { + let assessment = try assessProposal(rule) + guard assessment.canAdd else { + throw RulePlanError.invalid(assessment.issues.map(\.message).joined(separator: " ")) } + try saveRules(assessment.candidateRules) + let disposition: CorrectionProposalDisposition = assessment.proposedRule == generatedRule + .trimmingCharacters(in: .whitespacesAndNewlines) ? .accepted : .edited + updateCorrectionHistory( + id: correction.id, + history: CorrectionProposalHistory(disposition: disposition, rule: assessment.proposedRule) + ) + status = "Correction rule added" + } + + func discardCorrectionProposal(for correction: CorrectionContext) { + updateCorrectionHistory( + id: correction.id, + history: CorrectionProposalHistory(disposition: .discarded, rule: nil) + ) + } + + private func updateCorrectionHistory(id: UUID, history: CorrectionProposalHistory) { + guard let index = recent.firstIndex(where: { $0.id == id }) else { return } + recent[index] = recent[index].withCorrectionProposal(history) + try? ledger.save(recent) } func loadModelSettings() throws -> (provider: ModelProvider, appleModel: AppleModelSelection, appleUseCase: AppleUseCase, appleGuardrails: AppleGuardrails, url: String, ollamaModel: String, openAIModel: String, openAIKey: String) { @@ -807,6 +851,7 @@ struct Activity: Identifiable, Codable { let detail: String let outcome: Outcome let date: Date + let correctionProposal: CorrectionProposalHistory? init( sourceName: String, @@ -817,9 +862,11 @@ struct Activity: Identifiable, Codable { tags: [String] = [], detail: String, outcome: Outcome, - date: Date = .now + date: Date = .now, + correctionProposal: CorrectionProposalHistory? = nil, + id: UUID = UUID() ) { - self.id = UUID() + self.id = id self.sourceName = sourceName self.sourceURL = sourceURL self.filedName = filedName @@ -833,6 +880,23 @@ struct Activity: Identifiable, Codable { ) self.outcome = outcome self.date = date + self.correctionProposal = correctionProposal + } + + func withCorrectionProposal(_ proposal: CorrectionProposalHistory) -> Activity { + Activity( + sourceName: sourceName, + sourceURL: sourceURL, + filedName: filedName, + destination: destination, + fileURL: fileURL, + tags: tags, + detail: detail, + outcome: outcome, + date: date, + correctionProposal: proposal, + id: id + ) } enum Outcome: String, Codable { diff --git a/Sources/SortingHatApp/Views/CorrectionProposalView.swift b/Sources/SortingHatApp/Views/CorrectionProposalView.swift new file mode 100644 index 0000000..e2dad05 --- /dev/null +++ b/Sources/SortingHatApp/Views/CorrectionProposalView.swift @@ -0,0 +1,228 @@ +import SortingHatCore +import SwiftUI +import UniformTypeIdentifiers + +struct CorrectionProposalSheet: View { + let store: HatStore + let correction: CorrectionContext + @Environment(\.dismiss) private var dismiss + @State private var proposal: CorrectionRuleProposal? + @State private var generatedRule = "" + @State private var editedRule = "" + @State private var isGenerating = true + @State private var errorMessage: String? + @State private var choosingPreviewFiles = false + @State private var previewRequest: RulePreviewRequest? + @State private var finished = false + + private var assessment: RuleProposalAssessment? { + try? store.assessProposal(editedRule) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + content + Divider() + footer + } + .frame(width: 780, height: 510) + .tint(SortingHatTheme.amber) + .task { await generate() } + .fileImporter( + isPresented: $choosingPreviewFiles, + allowedContentTypes: [.item], + allowsMultipleSelection: true + ) { result in + switch result { + case .success(let files): + guard !files.isEmpty, files.count <= 8, let assessment else { + errorMessage = "Choose between 1 and 8 representative files." + return + } + previewRequest = RulePreviewRequest( + rules: assessment.candidateRules, + files: files, + output: store.outputRoot + ) + case .failure(let error): + errorMessage = error.localizedDescription + } + } + .sheet(item: $previewRequest) { request in + RulePreviewSheet(store: store, request: request) + } + .onDisappear { + if !finished { store.discardCorrectionProposal(for: correction) } + } + } + + private var header: some View { + HStack(spacing: 12) { + WizardHatSymbol(size: 34) + VStack(alignment: .leading, spacing: 2) { + Text("Teach the hat—only if you agree").font(.title2.bold()) + Text("Your file is already corrected. No rule changes until you choose Add Rule.") + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(18) + } + + @ViewBuilder + private var content: some View { + if isGenerating { + VStack(spacing: 12) { + ProgressView() + Text("Looking for a reusable pattern in this correction…") + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilityElement(children: .combine) + .accessibilityLabel("Building a correction rule proposal") + } else if proposal == nil { + ContentUnavailableView { + Label("No rule was proposed", systemImage: "text.badge.xmark") + } description: { + Text(errorMessage ?? "The correction is complete and your existing rules remain unchanged.") + } actions: { + Button("Try Again") { Task { await generate() } } + } + } else { + VStack(alignment: .leading, spacing: 16) { + correctionTrail + + if let proposal { + VStack(alignment: .leading, spacing: 6) { + Text("Why this was inferred").font(.headline) + Text(proposal.reason) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + VStack(alignment: .leading, spacing: 7) { + Text("Proposed rule").font(.headline) + TextField("One specific destination rule", text: $editedRule, axis: .vertical) + .lineLimit(2...4) + .textFieldStyle(.roundedBorder) + .accessibilityHint("Edit the proposed rule before previewing or adding it") + Text("The rule will be inserted before your catch-all. All other rules keep their current order and wording.") + .font(.caption) + .foregroundStyle(.secondary) + } + + proposalMessages + Spacer(minLength: 0) + } + .padding(20) + } + } + + private var correctionTrail: some View { + HStack(spacing: 9) { + Label(correction.originalName, systemImage: "doc") + .lineLimit(1).help(correction.originalName) + flowArrow + Label(correction.correctedName, systemImage: "wand.and.sparkles") + .lineLimit(1).help(correction.correctedName) + flowArrow + Label(correction.destination, systemImage: "folder.fill") + .lineLimit(1).help(correction.destination) + Spacer(minLength: 0) + Label("Filed", systemImage: "checkmark.circle.fill").foregroundStyle(.green) + } + .font(.headline) + .accessibilityElement(children: .combine) + .accessibilityLabel("Corrected \(correction.originalName) to \(correction.correctedName) in \(correction.destination)") + } + + @ViewBuilder + private var proposalMessages: some View { + if let issue = assessment?.issues.first { + Label(issue.message, systemImage: "xmark.octagon.fill") + .foregroundStyle(.red) + .accessibilityLabel("Rule cannot be added. \(issue.message)") + } else if let overlap = assessment?.overlaps.first { + Label(overlap.message, systemImage: "arrow.triangle.branch") + .foregroundStyle(SortingHatTheme.amber) + .accessibilityLabel("Possible rule overlap. \(overlap.message)") + } else { + Label("No duplicate, ordering, or catch-all conflict detected.", systemImage: "checkmark.seal") + .foregroundStyle(.secondary) + } + if let errorMessage { + Label(errorMessage, systemImage: "exclamationmark.triangle.fill") + .foregroundStyle(.red) + } + } + + private var footer: some View { + HStack { + Button("Discard Proposal") { discard() } + .keyboardShortcut(.cancelAction) + Spacer() + if proposal != nil { + Menu("Preview") { + Button("Preview Corrected File") { preview(files: [correction.fileURL]) } + Button("Choose Other Files…") { choosingPreviewFiles = true } + } + .disabled(assessment?.canAdd != true) + Button("Add Rule") { addRule() } + .buttonStyle(.borderedProminent) + .keyboardShortcut(.defaultAction) + .disabled(assessment?.canAdd != true) + } + } + .padding(14) + } + + private var flowArrow: some View { + Image(systemName: "arrow.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(SortingHatTheme.amber) + .accessibilityHidden(true) + } + + private func generate() async { + isGenerating = true + errorMessage = nil + do { + let result = try await CorrectionProposalGenerator().generate(for: correction) + proposal = result + generatedRule = result.ruleText + editedRule = result.ruleText + } catch { + proposal = nil + errorMessage = error.localizedDescription + } + isGenerating = false + } + + private func preview(files: [URL]) { + guard let assessment else { return } + previewRequest = RulePreviewRequest( + rules: assessment.candidateRules, + files: files, + output: store.outputRoot + ) + } + + private func addRule() { + do { + try store.addCorrectionProposal(editedRule, generatedRule: generatedRule, for: correction) + finished = true + dismiss() + } catch { + errorMessage = error.localizedDescription + } + } + + private func discard() { + store.discardCorrectionProposal(for: correction) + finished = true + dismiss() + } +} diff --git a/Sources/SortingHatApp/Views/DashboardView.swift b/Sources/SortingHatApp/Views/DashboardView.swift index f62d43b..f35171e 100644 --- a/Sources/SortingHatApp/Views/DashboardView.swift +++ b/Sources/SortingHatApp/Views/DashboardView.swift @@ -469,6 +469,13 @@ private struct ActivityDetailView: View { .lineLimit(3) .help(activity.detail) + if let correction = activity.correctionProposal { + Label(correction.summary, systemImage: correction.disposition == .discarded ? "xmark.circle" : "text.badge.checkmark") + .font(.caption) + .foregroundStyle(.secondary) + .help(correction.rule ?? correction.summary) + } + HStack(spacing: 6) { if !activity.tags.isEmpty { Text("Tags").font(.caption).foregroundStyle(.secondary) diff --git a/Sources/SortingHatApp/Views/ReviewQueueView.swift b/Sources/SortingHatApp/Views/ReviewQueueView.swift index f8e4b8c..6f13f5f 100644 --- a/Sources/SortingHatApp/Views/ReviewQueueView.swift +++ b/Sources/SortingHatApp/Views/ReviewQueueView.swift @@ -5,8 +5,8 @@ struct ReviewQueueView: View { @State private var selection: Activity.ID? @State private var filedName = "" @State private var destination = "" - @State private var teachingRule = "" @State private var errorMessage: String? + @State private var correctionProposal: CorrectionContext? private var items: [Activity] { store.recent.filter { $0.outcome == .needsReview } } private var selected: Activity? { items.first { $0.id == selection } ?? items.first } @@ -32,8 +32,9 @@ struct ReviewQueueView: View { Section("Correct the decision") { TextField("Filed name", text: $filedName, prompt: Text(selected.sourceName)) TextField("Destination", text: $destination, prompt: Text("Documents/2026")) - TextField("Teach the hat (optional)", text: $teachingRule, - prompt: Text("Put bank statements in Finance/Statements/YYYY"), axis: .vertical) + Text("Filing this correction will not change your rules. Afterwards, the hat can propose a reusable rule for you to inspect.") + .font(.callout) + .foregroundStyle(.secondary) } if let errorMessage { Label(errorMessage, systemImage: "exclamationmark.triangle.fill").foregroundStyle(.red) } HStack { @@ -52,17 +53,22 @@ struct ReviewQueueView: View { description: Text("Uncertain files will appear here instead of being moved.")) } } + .sheet(item: $correctionProposal) { correction in + CorrectionProposalSheet(store: store, correction: correction) + } } private func prepare(_ activity: Activity) { filedName = activity.sourceName destination = "" - teachingRule = "" errorMessage = nil } private func resolve(_ activity: Activity) { - do { try store.resolve(activity, filedName: filedName, destination: destination, teachingRule: teachingRule); selection = nil } + do { + correctionProposal = try store.resolve(activity, filedName: filedName, destination: destination) + selection = nil + } catch { errorMessage = error.localizedDescription } } } diff --git a/Sources/SortingHatCore/RoutingDecisionResolver.swift b/Sources/SortingHatCore/RoutingDecisionResolver.swift index ef47df1..d515f02 100644 --- a/Sources/SortingHatCore/RoutingDecisionResolver.swift +++ b/Sources/SortingHatCore/RoutingDecisionResolver.swift @@ -373,7 +373,10 @@ struct CompiledRoutingRule: Equatable, Sendable { let subject = String(body[.. RuleProposalAssessment { + let rule = proposedRule.trimmingCharacters(in: .whitespacesAndNewlines) + let current = existingRules.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + let currentDescriptors = (try? RoutingDecisionResolver.descriptors(for: current)) ?? [] + let catchAllID = currentDescriptors.first(where: \.isCatchAll)?.id + let insertionIndex = catchAllID.flatMap { id in + current.firstIndex { candidate in + (try? RoutingDecisionResolver.descriptors(for: [candidate]).first?.id) == id + } + } ?? current.endIndex + + var candidate = current + candidate.insert(rule, at: insertionIndex) + var inspection = RuleSetInspector.inspect(candidate) + + let proposedDescriptors = (try? RoutingDecisionResolver.descriptors(for: [rule])) ?? [] + if rule.isEmpty { + inspection = adding( + RuleSetIssue(kind: .empty, ruleIndex: insertionIndex, message: "The proposed rule is empty."), + to: inspection + ) + } else if proposedDescriptors.count != 1 || proposedDescriptors.first?.isCatchAll == true { + inspection = adding( + RuleSetIssue( + kind: .invalid, + ruleIndex: insertionIndex, + message: "The proposal must be one specific destination rule beginning with ‘Put’." + ), + to: inspection + ) + } + + let overlaps: [RuleProposalOverlap] = proposedDescriptors.first.map { proposed in + currentDescriptors.enumerated().compactMap { descriptorIndex, existing -> RuleProposalOverlap? in + guard !existing.isCatchAll, + subjectsOverlap(proposed.subject, existing.subject), + normalized(proposed.subject) != normalized(existing.subject) else { return nil } + return RuleProposalOverlap( + existingRuleIndex: ruleIndex(for: existing.id, in: current) ?? descriptorIndex, + message: "This may overlap rule \((ruleIndex(for: existing.id, in: current) ?? descriptorIndex) + 1), which matches \(existing.subject). Preview both routes before adding it." + ) + } + } ?? [] + + return RuleProposalAssessment( + proposedRule: rule, + candidateRules: candidate, + insertionIndex: insertionIndex, + issues: inspection.issues, + overlaps: overlaps + ) + } + + private static func adding(_ issue: RuleSetIssue, to inspection: RuleSetInspection) -> RuleSetInspection { + guard !inspection.issues.contains(issue) else { return inspection } + return RuleSetInspection(descriptors: inspection.descriptors, issues: inspection.issues + [issue]) + } + + private static func ruleIndex(for id: String, in rules: [String]) -> Int? { + rules.firstIndex { rule in + (try? RoutingDecisionResolver.descriptors(for: [rule]).first?.id) == id + } + } + + private static func subjectsOverlap(_ lhs: String, _ rhs: String) -> Bool { + let left = Set(tokens(lhs)) + let right = Set(tokens(rhs)) + guard !left.isEmpty, !right.isEmpty else { return false } + return left.isSubset(of: right) || right.isSubset(of: left) + } + + private static func tokens(_ value: String) -> [String] { + normalized(value).split(separator: " ").map(String.init).filter { token in + !["file", "files", "document", "documents", "the", "a", "an"].contains(token) + } + } + + private static func normalized(_ value: String) -> String { + value.precomposedStringWithCanonicalMapping.lowercased() + .components(separatedBy: CharacterSet.alphanumerics.inverted) + .filter { !$0.isEmpty }.joined(separator: " ") + } +} diff --git a/Tests/SortingHatTests/SortingHatTests.swift b/Tests/SortingHatTests/SortingHatTests.swift index c2c6dd8..177a1b4 100644 --- a/Tests/SortingHatTests/SortingHatTests.swift +++ b/Tests/SortingHatTests/SortingHatTests.swift @@ -2128,4 +2128,72 @@ struct SortingHatTests { #expect(inspection.descriptors.count == 3) try RuleSetInspector.validate(rules) } + + @Test func correctionProposalInsertsOnlyOneRuleBeforeCatchAll() throws { + let rules = [ + "Give every file a descriptive filename.", + "Put screenshots in Screenshots/{project}/{year-month}.", + "Put everything else in Files/YYYY-MM.", + ] + let proposal = "Put receipts in Finance/Receipts/{merchant}/{year}, rename them using YYYY-MM-DD-merchant-description, and tag them receipt." + + let assessment = RuleProposalPlanner.assess(proposedRule: proposal, existingRules: rules) + + #expect(assessment.canAdd) + #expect(assessment.insertionIndex == 2) + #expect(assessment.candidateRules == [rules[0], rules[1], proposal, rules[2]]) + let descriptor = try #require(RoutingDecisionResolver.descriptors(for: [proposal]).first) + #expect(descriptor.destinationTemplate == "Finance/Receipts/{merchant}/{year}") + #expect(descriptor.staticTags == ["receipt"]) + } + + @Test func correctionProposalBlocksDuplicateAndSameSubjectConflict() { + let rules = [ + "Give every file a descriptive filename.", + "Put receipts in Finance/Receipts/{merchant}/{year}.", + "Put everything else in Files/YYYY-MM.", + ] + + let duplicate = RuleProposalPlanner.assess(proposedRule: rules[1], existingRules: rules) + let conflicting = RuleProposalPlanner.assess( + proposedRule: "Put receipts in Purchases/{merchant}/{year}.", + existingRules: rules + ) + + #expect(!duplicate.canAdd) + #expect(duplicate.issues.contains { $0.kind == .duplicate || $0.kind == .conflicting }) + #expect(!conflicting.canAdd) + #expect(conflicting.issues.contains { $0.kind == .conflicting }) + } + + @Test func correctionProposalWarnsAboutBroaderOrNarrowerOverlap() { + let rules = [ + "Put business receipts in Finance/Receipts/{merchant}/{year}.", + "Put everything else in Files/YYYY-MM.", + ] + + let assessment = RuleProposalPlanner.assess( + proposedRule: "Put receipts in Receipts/{merchant}/{year}.", + existingRules: rules + ) + + #expect(assessment.canAdd) + #expect(assessment.overlaps.count == 1) + #expect(assessment.overlaps[0].existingRuleIndex == 0) + } + + @Test func correctionProposalCannotAddAnotherCatchAll() { + let rules = [ + "Put receipts in Receipts/{merchant}/{year}.", + "Put everything else in Files/YYYY-MM.", + ] + + let assessment = RuleProposalPlanner.assess( + proposedRule: "Put anything else in Archive/YYYY-MM.", + existingRules: rules + ) + + #expect(!assessment.canAdd) + #expect(assessment.issues.contains { $0.kind == .invalid || $0.kind == .conflicting }) + } }