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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions SortingHat.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand All @@ -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 */; };
Expand All @@ -34,13 +38,15 @@
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 */; };
C9BE8CC1614093E86050F26D /* SortingHatUpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA9D1162A5DD3CA6865A65F3 /* SortingHatUpdateController.swift */; };
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 */; };
Expand Down Expand Up @@ -111,6 +117,7 @@
3B2C203CFB8321153FC8503E /* RulePlanGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePlanGenerator.swift; sourceTree = "<group>"; };
44D8496DA7F3A4922495C13B /* SortingHat */ = {isa = PBXFileReference; lastKnownFileType = folder; name = SortingHat; path = .; sourceTree = SOURCE_ROOT; };
47521A9F025FA497A89BCEA8 /* SortingHatApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SortingHatApp.swift; sourceTree = "<group>"; };
4EFF8527A97DB04266D7C423 /* CorrectionProposalView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionProposalView.swift; sourceTree = "<group>"; };
5877424082F407D420F5A0B0 /* RulePreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePreviewView.swift; sourceTree = "<group>"; };
60D0A89FFA32378E1092BFB0 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; path = PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
698A482A0EAAA4043EBF9D97 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
Expand All @@ -123,9 +130,11 @@
C0A331DE0605C30EA78F5E41 /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
C24EBAF0EB6D03C27BC24A53 /* SetupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupView.swift; sourceTree = "<group>"; };
CF783C14FBCE3B3B969A8140 /* RulesEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulesEditorView.swift; sourceTree = "<group>"; };
D33300E7F125B24F549B5CE1 /* CorrectionProposalGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionProposalGenerator.swift; sourceTree = "<group>"; };
D4A766D04A9129702226E922 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
D732DC01AE240AE483F423D6 /* ReviewQueueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReviewQueueView.swift; sourceTree = "<group>"; };
E34BBCCB587929B4260D649C /* RulePlan.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RulePlan.swift; sourceTree = "<group>"; };
ED0EF45CF551E226954E901B /* CorrectionProposal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorrectionProposal.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */

/* Begin PBXFrameworksBuildPhase section */
Expand Down Expand Up @@ -199,6 +208,7 @@
64F4FDFB90B9426C54FE4A7D /* Services */ = {
isa = PBXGroup;
children = (
D33300E7F125B24F549B5CE1 /* CorrectionProposalGenerator.swift */,
3B2C203CFB8321153FC8503E /* RulePlanGenerator.swift */,
);
path = Services;
Expand Down Expand Up @@ -267,6 +277,7 @@
F4A0A0BF472449C9C0CC2E45 /* Models */ = {
isa = PBXGroup;
children = (
ED0EF45CF551E226954E901B /* CorrectionProposal.swift */,
E34BBCCB587929B4260D649C /* RulePlan.swift */,
);
path = Models;
Expand All @@ -275,6 +286,7 @@
FC45049DD3E289D86DC0E142 /* Views */ = {
isa = PBXGroup;
children = (
4EFF8527A97DB04266D7C423 /* CorrectionProposalView.swift */,
698A482A0EAAA4043EBF9D97 /* DashboardView.swift */,
79D9589A55D599EC62EC61FB /* ModelSettingsView.swift */,
D732DC01AE240AE483F423D6 /* ReviewQueueView.swift */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down
48 changes: 48 additions & 0 deletions Sources/SortingHatApp/Models/CorrectionProposal.swift
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
106 changes: 106 additions & 0 deletions Sources/SortingHatApp/Services/CorrectionProposalGenerator.swift
Original file line number Diff line number Diff line change
@@ -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}.")
}
}
}
}
Loading
Loading