TidyFlow System Manual
TidyFlow is a desktop app that organizes a messy folder. It reads each file, proposes folders, shows you the result, and changes nothing until you approve. This manual explains how each part works.
1Summary
What the product does, in one page.
You select a folder, for example Downloads. TidyFlow does these steps:
- It looks at the folder and proposes a set of folders (a "plan"). You can change the plan with plain English, for example "remove Media" or "add a folder for Bills".
- It reads every file. It reads the text in PDFs, Office files and code. It uses OCR to read text in images and scanned PDFs.
- It gives each file a folder and a confidence value. Rules, keyword scores and an AI model (optional) make this decision.
- It shows you the result. Files with high confidence are already in a folder. Files with low confidence wait for you.
- When you approve, it copies the files into an
Organizedfolder. It checks each copy with SHA-256. - You can undo. Undo removes only the copies that are still identical to what TidyFlow made.
2Terms
Words that this manual uses. Each word has one meaning.
| Term | Meaning |
|---|---|
| Input folder | The messy folder that you select. |
| Output folder | The folder where organized copies go. The default is <input>/Organized_Output (the UI calls it "Organized"). |
| Category / folder | One target folder in the plan, for example Finance/Invoices. A "/" makes a subfolder. |
| Plan | The set of categories for one input folder. Each category has a name, description, keywords, extensions and an optional symbol. |
| FileRecord | The Pydantic model that holds all data about one file: path, size, hash, text, duplicates, scores and classification. |
| file_id | A 12-character ID. It is a SHA-256 of "content hash + relative path". Two identical files in different places get different IDs. |
| Classification | The result for one file: category, confidence (0.0–1.0), reason, suggested filename, action and source. |
| Action | copy_to_organized (sure) or manual_review (not sure). |
| Source | Who decided: user_rule, extension_rule, heuristic_high_confidence, llm or none. |
| Threshold | The minimum confidence for "sure". The default is 0.85. |
| Manifest | A list of every copy or move that TidyFlow made: source, destination, SHA-256, time. Undo uses it. |
| pHash | Perceptual hash. A 64-bit fingerprint of how an image looks. Similar images have similar hashes. |
| SSE | Server-Sent Events. A one-way HTTP stream from the server to the UI for live progress. |
3Architecture
The parts of the system and how they connect.
TidyFlow is a local web app inside a native window. One Python process runs the HTTP server and the file pipeline. The React UI talks to that server over HTTP on 127.0.0.1. Only the AI step goes to the internet, and only when you add an API key.
flowchart LR
subgraph Desktop["Desktop app (one process)"]
W["pywebview window
(WebKit / Edge WebView2)"]
subgraph Server["uvicorn + FastAPI (src/api.py)"]
API["REST endpoints"]
SSE["/events SSE stream"]
PIPE["Pipeline
src/main_loop.py"]
AIA["AI assistant
src/ai_assistant.py"]
APP["Applier + Undo
src/applier.py"]
end
end
UI["React UI
frontend/dist"] -- "fetch JSON" --> API
SSE -- "progress events" --> UI
W -- "loads" --> UI
PIPE --> FS[("Your files")]
APP --> FS
PIPE --> OCR["OCR
Apple Vision / PaddleOCR"]
PIPE --> DB[("SQLite
tidyflow.db")]
PIPE -. "names + 600-char excerpts
(secrets removed)" .-> LLM["LLM provider
(optional)"]
AIA -. "file samples" .-> LLM
Main backend modules (src/)
| File | Job |
|---|---|
api.py | FastAPI app. All HTTP endpoints, SSE, run state, apply and undo. |
main_loop.py | run_pipeline(): runs the 9 pipeline steps in order. |
scanner.py | Walks the folder, applies ignore rules, makes FileRecords. |
metadata.py | Image size, EXIF, PDF page count, base64 thumbnails. |
hashing.py | Exact duplicates (SHA-256) and near duplicates (pHash + Union-Find). |
extractor.py | Text from PDF (PyMuPDF), plain text/code, and Office XML. |
ocr_engine.py | OCR with a cache keyed by SHA-256. |
rules.py | User rules, extension rules, rapidfuzz keyword scoring, fast path. |
llm_provider.py | Batched AI classification, prompts, retries, key storage. |
ai_assistant.py | Makes the folder plan, edits it in plain English, runs review commands. |
applier.py | Verified copy or move, manifest. |
database.py | SQLite with WAL, a single-writer queue, and FTS5 search. |
utils.py | Hashing, file IDs, secret detection, safe names, app data paths. |
folder_style.py | macOS 26 folder symbol, emoji and colour. |
config.py, models.py | Typed config (YAML) and data models (Pydantic). |
cli.py | Typer command line: run, scan, apply, and more. |
4User flow
The five screens of the guided flow and the API calls behind them.
flowchart LR H["Home
pick folder"] --> P["Plan
agree on folders"] P --> S["Sorting
read + classify"] S --> V["Preview
review unsure files"] V --> D["Done
open / undo"] D -. "Undo" .-> H P -- "POST /ai/chat-structure" --> P S -- "POST /pipeline/run
+ SSE progress" --> S V -- "POST /ai/review-chat" --> V V -- "POST /pipeline/apply-direct" --> D D -- "POST /pipeline/undo-last" --> D
- Home. You select a folder (quick locations, OS folder dialog, or an in-app browser). If an earlier sort of this folder exists, you can resume it.
- Plan. The app asks the AI (or the offline planner) to propose folders from a sample of your files. You set detail level: Fewer, Balanced, Detailed. You can rename, add, remove, or type a change in plain English.
- Sorting. The pipeline runs. The screen shows live messages from the server. You can cancel.
- Preview. Files are in two groups: "sure" (already placed) and "to review". You can move, rename, exclude files, or type a command such as "put the invoices in Bills".
- Done. TidyFlow copies the files. You can open the output folder, or undo everything with one click.
The app also has Search, Settings, and two "advanced" views (a full plan editor and a full review table).
5Pipeline overview
run_pipeline() in src/main_loop.py. Nine steps, in order. Each step adds data to the same list of FileRecords.
flowchart TD A["1 Scan
scanner.py"] --> B["2 Metadata + thumbnails
metadata.py"] B --> C["3 Duplicates
SHA-256 + pHash"] C --> D["4 Text extraction
PDF / Office / text"] D --> E["5 OCR
images + scanned PDFs"] E --> F["6 Rules + keyword scores
+ fast path"] F --> G{"AI enabled
and key set?"} G -- yes --> H["7 Batched LLM
for unclassified files"] G -- no --> I H --> I["8 Save records (JSONL)
+ optional reports"] I --> J{"auto_apply?"} J -- yes --> K["9 Copy / move
(CLI only)"] J -- no --> L["Index in SQLite
return to UI"] K --> L
| Step | Input | Output on each FileRecord |
|---|---|---|
| 1 Scan | Input folder | path, size, dates, sha256, file_id, file_category, too_large, skipped |
| 2 Metadata | Images, PDFs | width, height, EXIF, page count, thumbnail_b64 |
| 3 Duplicates | Hashes, images | duplicate_group_id, perceptual_hash, near_duplicate_group_id |
| 4 Extract | PDF, Office, text, code | extracted_text_raw, extracted_text_normalized, extraction_source |
| 5 OCR | Images, PDFs with no text | Same text fields, ocr_confidence |
| 6 Rules | Name, extension, text | keyword_scores, maybe classification |
| 7 LLM | Files with no classification | classification (source llm) |
| 8 Save | All records | data/file_records.jsonl; CSV/HTML reports only if enabled |
| 9 Apply | Sure files | Copies + manifest (CLI --auto-apply only) |
asyncio.to_thread(), so the server stays responsive. Cancel uses a threading.Event that the pipeline checks between steps and between LLM batches.6Scan and inventory
src/scanner.py — which files enter the pipeline.
iter_candidate_files() walks the folder with os.walk. It is the single source of truth for "what can be organized". The scan and the folder-plan sampler both use it.
The scanner skips
- Names in the default ignore list:
.git,node_modules,venv,__pycache__,.DS_Store,Organized,Organized_Output,Staging, and more. - Patterns in a
.tidyignorefile in the input folder (like.gitignore). - The output and staging folders, so a second run does not sort its own output.
- Hidden files (start with
.) and Office lock files (start with~$). - The inside of bundles:
.app,.photoslibrary,.xcodeproj,.pages, and others. A bundle is one document. Its inner files must not be sorted one by one. - Secret files (
is_secret_file): known names such as.env, key extensions such as.pem, and CSV/TXT files whose first line looks like a saved-passwords export. These become "skipped" records.
For each accepted file
- Read size and dates with
stat(). - Set
too_large = Trueif the size is more thanmax_file_size_mb(200 MB). A large file is still organized by name and type, but its content is never read. - Compute SHA-256 in 64 KB chunks.
- Make
file_id= first 12 hex characters ofsha256(content_hash + ":" + rel_path). - Set
file_categoryfrom the extension: image, document, code, data, media, archive, binary, other.
7Duplicates
src/hashing.py — exact copies and images that look the same.
Exact duplicates
Files with the same SHA-256 go into one group. The group ID is dup_<first 10 chars of hash>. This is a dictionary group-by, O(n).
Near duplicates (images)
- For each image (not too large), compute a pHash. The code has its own pHash: it resizes to 32×32 grayscale, applies a 2D DCT with a NumPy matrix, keeps the 8×8 low-frequency block, and sets each bit to "value > median". This avoids SciPy, which keeps the packaged app small.
- Compare each pair of hashes. The Hamming distance is the number of different bits.
- If the distance is ≤ 8 (
hamming_distance_threshold), join the two images with Union-Find. - Each Union-Find set with 2 or more images becomes a group
near_<id>.
flowchart LR A["photo.jpg"] -- "distance 3" --- B["photo_small.jpg"] B -- "distance 6" --- C["photo_edit.jpg"] A -. "distance 11
(no direct link)" .- C subgraph G["One group: near_xxxx"] A B C end
Union-Find uses path compression (parent[x] = parent[parent[x]]). The pair loop is O(n²) in the number of images.
8Text extraction and OCR
src/extractor.py and src/ocr_engine.py — how TidyFlow reads content.
Direct extraction (step 4)
| Type | Method | Limit |
|---|---|---|
PyMuPDF (fitz) reads text from the first 5 pages. If there are fewer than 20 characters, the PDF is a scan; the text stays empty so OCR runs. | 4,096 chars | |
| Text, code, CSV, MD | Read the file as UTF-8 (errors ignored). | 4,096 bytes |
| DOCX / XLSX / PPTX | Open as ZIP. Parse word/document.xml, xl/sharedStrings.xml, or the first 5 ppt/slides/slide*.xml. Collect all <…:t> text nodes. No Office library is needed. | 4,096 bytes |
All extracted text goes through redact_secrets(). Then normalize_ocr_text() makes it lowercase, removes single punctuation marks, and collapses spaces.
OCR (step 5)
OCR runs on images, and on documents that have no text after step 4.
flowchart TD
S["Candidate file"] --> SM{"Smaller than
120 px?"}
SM -- yes --> X["Skip (icon)"]
SM -- no --> C{"SHA-256 in
ocr_cache.json?"}
C -- yes --> R["Use cached text"]
C -- no --> M{"macOS and
bin/macos_ocr exists?"}
M -- yes --> V["Apple Vision
VNRecognizeTextRequest (.accurate)"]
M -- no --> P{"PaddleOCR
available?"}
P -- yes --> PO["PaddleOCR PP-OCRv5 det + v4 rec
after preprocessing"]
P -- no --> N["No text"]
V --> W["Save to cache"]
PO --> W
- Apple Vision.
src/native/macos_ocr.swiftis a small Swift program. The build compiles it tobin/macos_ocr. In development, the app compiles it on first use ifswiftcexists. Python calls it withsubprocess. - PaddleOCR. The engine loads lazily (only when needed). Preprocessing: fix EXIF rotation, convert to RGB, shrink to max 768 px, increase contrast by 1.2.
- Cache. The key is the file's SHA-256. If you rename or move a file, OCR does not run again. Only changed content runs OCR again.
9Rules and keyword scores
src/rules.py — fast decisions that need no AI.
Rule order in RuleEngine.evaluate_record()
- Large file. Size > 50 MB and the plan has
Large_Files(or no plan) →Large_Files, confidence 1.0. - User rules from
rules.yaml(app data folder first). A rule matches byextensions, a regexpatternon the name, or acontainsword. Result: confidence 1.0, sourceuser_rule. A rule is ignored if its category is not in the current plan. - Extension rule. Only for code, archive and media files. Example:
.mp3→Media/Audio. Confidence 0.90, sourceextension_rule. Only if that category is in the plan.
Keyword scoring
For each file with text, and for each category, the score is the best score of its keywords (0–100):
- Multi-word keyword: 100 if the exact phrase is in the text. Otherwise
rapidfuzz.fuzz.partial_ratio. - Single word: 100 if it matches on a word boundary (
\bword\b). Otherwise fuzzy score only if the word has 6 or more letters. Short words get 0, because fuzzy matching on short words gives false positives.
Fast path (skip the AI)
A file goes on the fast path when the top score ≥ 88 and the second score < 50. A clear winner means the AI is not necessary. Confidence = score / 100. The action is "sure" only if that is ≥ the threshold. This saves API cost and time.
10AI classification
classify_files_batched() in src/llm_provider.py.
Only files with no classification after step 6 go to the AI. They go in batches (default batch_size: 15). One HTTP request classifies a full batch. This uses fewer tokens than one request per file, because the long system prompt is sent once per batch.
What the AI receives for each file (LlmFilePayload)
file_id, name, extension, file category, size, up to 600 characters of text, keyword scores above 40, and duplicate group IDs. No file bytes. No images.
The prompt
- The list of allowed categories, with descriptions and up to 8 keywords each.
- Your custom instructions (optional).
- A confidence scale: 0.95–1.0 name and content agree; 0.75–0.94 mostly name; 0.40–0.74 a guess; below 0.40 prefer Unknown.
- Rename rule: suggest a name only if it says more than the current name.
- Strict JSON output:
{"results":[{file_id, category, confidence, file_type, suggested_filename, reason, action}]}.
There are two prompt modes. Standard mode always picks the closest category with a lower confidence. Strict mode (custom narrow categories) puts every file that does not clearly fit into Unknown.
Request settings
temperature: 0.1, max_tokens: 8000, response_format: json_object, endpoint {base_url}/chat/completions, Bearer key.
sequenceDiagram
participant P as Pipeline
participant L as LLM API
P->>L: system prompt + batch (JSON)
alt 200 OK and valid JSON
L-->>P: {"results":[...]}
P->>P: validate with Pydantic (LlmBatchResponse)
else 429 rate limit
P->>P: wait Retry-After or 2^n x 2.5 s (max 30 s)
P->>L: retry
else 401 / 402
P->>P: stop batch, keep friendly error
else bad JSON
P->>L: same request + broken answer + "return ONLY valid JSON"
end
P->>P: files with no answer -> Unknown, manual_review
After the answer
- Category resolve. Exact match, then case-insensitive, then last path part (
"Invoices"→"Finance/Invoices"). No match →Unknown. The AI cannot invent a folder. - Confidence normalize. If the model answers 0–100, divide by 100. Clamp to 0–1.
- Action decide. The code ignores the model's action. It sets "sure" only if category ≠ Unknown and confidence ≥ threshold. So the result always agrees with your threshold.
- Rename check.
rename_adds_meaning()keeps a new name only if it adds a real word that is not filler (such as "image", "scan", "final").
Providers
| Provider | Base URL | Default model |
|---|---|---|
| deepseek | api.deepseek.com/v1 | deepseek-chat |
| openai | api.openai.com/v1 | gpt-4o-mini |
| gemini | generativelanguage.googleapis.com/v1beta/openai | gemini-2.0-flash |
| groq | api.groq.com/openai/v1 | llama-3.3-70b-versatile |
| openrouter | openrouter.ai/api/v1 | deepseek/deepseek-chat |
| custom | Your URL | From config |
All providers use the same OpenAI-compatible format, so one HTTP client (httpx) serves all of them. At startup, verify_llm_key() tests the key in the background. The last AI error is kept so the UI can show a clear message.
11Folder plan (AI assistant)
chat_generate_structure() in src/ai_assistant.py, called by POST /ai/chat-structure.
Step A — describe the folder
inspect_directory() makes two things:
- Summary of the whole folder: total files, counts by kind, top 12 extensions, top 10 subfolders.
- Sample of up to 80 files. Each line has the path, type, size, and a short text preview for text files and PDFs. Secret files show only name and size.
The sample uses _spread_sample(). It groups files by (top subfolder, extension). Groups take turns and get one slot each, until the sample is full. Then it picks files evenly spaced in each group. This makes sure rare file types appear, not only the first 80 files found.
Step B — ask the AI
The system prompt has a detail level:
| UI name | Level | Structure | Sure threshold |
|---|---|---|---|
| Fewer | low | Flat only, no "/" | 0.70 |
| Balanced | medium | One level of subfolders where useful | 0.85 |
| Detailed | high / complex | 1–2 levels, project or date aware | 0.90 / 0.92 |
Rules in the prompt: the returned categories replace the full set; remove, rename, merge and split exactly as asked; every folder must hold several files; for fewer than about 30 files, prefer flat; do not stretch a category to hold an unrelated file; pick an SF Symbol for each folder. The last 8 chat turns are sent, so you can make changes in steps.
Without a key
_fallback_heuristic_structure() makes a plan from file names and types, and parses simple commands such as "add a folder for X". If the AI call fails, the same fallback runs, and the UI marks the plan as "basic".
The plan for each folder is saved in the app data folder (GET/POST /categories?folder=…), so it comes back when you open the same folder again.
12Review step
PreviewScreen.tsx and apply_review_command().
Sure or unsure
The UI puts a file in "to review" when there is no user override and one of these is true:
- the category is
Unknown; - the confidence is below the threshold;
- the category is not a folder in the current plan.
All other files are "sure" and are shown inside their folders. When you change a file by hand, it becomes sure.
Plain-English review commands
Example: "move the course screenshots to Academic". POST /ai/review-chat does this:
- Number the folders. Send the files as a compact tab-separated table: id, name, folder number, organize flag, size, 200-character text excerpt.
- Split large folders into chunks of 150 files. Send up to 4 chunks in parallel with a
ThreadPoolExecutor. - The AI returns
moves,renames,exclude,include, and a short message. _validate_review_result()keeps only real IDs, cleans folder and file names (removes\ / : * ? " < > |, keeps the extension), drops changes that do nothing, and joins "Bills" and "bills" into one folder.- If the counts changed during validation, the code writes a new message from the real changes, so the message never claims something that did not happen.
13Apply
POST /pipeline/apply-direct → apply_decisions() in src/applier.py.
The UI sends one decision for each file: approved, override folder, new filename. The server uses the FileRecords from the last run (in memory, or loaded from disk after restart).
flowchart TD
A["Approved decision"] --> B{"Folder is Unknown
and no override?"}
B -- yes --> SK["Skip"]
B -- no --> C{"Unsafe extension?
.exe .sh .ps1 .pem .env ..."}
C -- yes --> SK
C -- no --> D["choose_destination()"]
D --> E{"Same name AND
same SHA-256 there?"}
E -- yes --> SK2["Skip: already organized"]
E -- no --> F["shutil.copy2()
keeps dates"]
F --> G{"SHA-256 of copy
= original?"}
G -- no --> RB["Delete copy (rollback)"]
G -- yes --> H{"move_mode?"}
H -- yes --> RM["Delete original"]
H -- no --> M["Add manifest entry"]
RM --> M
Name clashes (choose_destination)
Organizing makes folders flat, so two files can have the same name. TidyFlow never overwrites. It tries, in order:
photo.jpgphoto (Trip).jpg— the name of the folder where the file came fromphoto (2).jpg…photo (999).jpg— like Finderphoto_1a2b3c4d.jpg— first 8 characters of the hash, as a last fallback
After apply
- Write the manifest to
<app data>/manifests/copy_manifest.csv. - Save undo data to
last_apply.json. It records if the output folder was new. - On macOS 26+, set each new folder's symbol, emoji and colour (only folders that did not exist before).
- Clear the run results, so a restart does not offer the same results again.
- If nothing was copied and the output folder is new, delete the empty folder.
14Undo
POST /pipeline/undo-last in src/api.py.
sequenceDiagram
participant U as UI
participant A as API
participant F as File system
U->>A: POST /pipeline/undo-last
A->>A: read last_apply.json
loop each manifest entry
A->>F: SHA-256 of destination
alt missing or changed
A->>A: skip (do not touch user's file)
else copy
A->>F: delete copy
else move
A->>F: move back (only if original place is empty)
end
end
A->>F: remove emptied folders, deepest first, never above output root
A->>F: remove output root if TidyFlow made it and it is empty
A-->>U: {restored, skipped}
The applier also skips files that are "already organized". This makes sure each manifest entry is a file that TidyFlow made, so undo cannot delete an older copy from a previous run.
15Safety and privacy
All the protections in one table.
| Risk | Protection | Where |
|---|---|---|
| Data loss | Copy by default; move only on request | applier.py |
| Bad copy | SHA-256 check after each copy; delete on mismatch | applier.py |
| Overwrite | Never overwrite; readable unique names | utils.choose_destination |
| Undo deletes user work | Hash check before each delete; walk up folders only inside output root | api.undo_last_apply |
| Secrets sent to AI | Regex redaction (API keys, GitHub tokens, private keys, password=, URLs with login). Password-table files send no text at all | utils.redact_secrets, _make_payload |
| Secret files sorted | .env, .pem, keystores, password exports are skipped | utils.is_secret_file |
| Executables moved | .exe .bat .sh .ps1 .dll .so .pem .p12 .env … are never copied | utils.is_safe_to_copy |
| AI invents folders | Category must resolve to a plan folder, else Unknown | _resolve_category |
| AI changes wrong files | Only known IDs; validated; all-or-nothing | _validate_review_result |
| Key leak | settings.json in user data folder with mode 0600 | save_settings |
| Path escape | Allow-list of input and output roots for file tools | mcp_server.is_path_allowed |
| Bundle damage | Never walk into .app, .photoslibrary and similar | scanner.is_bundle_dir |
Data that leaves your computer: only with an API key, and only file names, extension, size, up to 600 characters of redacted text, and keyword hints. OCR and extraction run on your computer.
16Data storage
Where TidyFlow keeps its files.
The app data folder is ~/Library/Application Support/TidyFlow (macOS), %APPDATA%\TidyFlow (Windows), or ~/.local/share/tidyflow (Linux). The packaged app writes caches and logs here, because its own folder is read-only. From source, the work folder is ./data.
| File | Contents |
|---|---|
settings.json | Provider, API key, custom URL (mode 0600) |
tidyflow.db | SQLite index of files, for search |
cache/ocr_cache.json | OCR text, keyed by SHA-256 |
file_records.jsonl | All FileRecords of the last run, one JSON per line |
| latest run folder | Saved run, so you can resume after restart |
last_apply.json | Undo data |
manifests/copy_manifest.csv | Audit log of copies |
logs/llm_requests.jsonl, llm_responses.jsonl | Batch IDs and first 2,000 chars of each AI answer, for debugging |
rules.yaml | User rules |
SQLite design (src/database.py)
- WAL mode +
synchronous=NORMAL: reads do not block writes. - Table
files: path (unique), category, new path, confidence, text, thumbnail, status, suggested name, reason. Columns are added automatically if an old database lacks them. - FTS5 table
files_ftson path, text and category. Three triggers (insert, delete, update) keep it in sync. - Single writer. All async writes go into an
asyncio.Queue. One task takes up to 100 writes and runs them as one batch in a thread. This avoids "database is locked" errors. - Search. The query becomes tokens
"tok"*joined with AND (prefix search), ranked, max 30, with a highlighted snippet. If FTS finds nothing, aLIKEsearch runs. Rows whose file no longer exists are deleted. - Crash recovery. At start, rows with status
processinggo back topending.
17Live progress
How the Sorting screen gets updates while the pipeline runs.
sequenceDiagram
participant UI as React UI
participant API as FastAPI (event loop)
participant T as Worker thread (pipeline)
UI->>API: GET /events (EventSource, stays open)
UI->>API: POST /pipeline/run
API->>T: asyncio.to_thread(run_pipeline)
T->>API: progress_callback("extract", "Reading...")
via run_coroutine_threadsafe
API-->>UI: event: pipeline_progress
Note over API,UI: every 15 s without news: event "ping"
T-->>API: records, summary
API-->>UI: event: pipeline_complete
API-->>UI: HTTP response with all files (JSON)
The pipeline runs in a thread, but the queues belong to the event loop. So the callback uses asyncio.run_coroutine_threadsafe(…, loop) to put events safely on the loop. SSE is enough here because data goes only from server to UI. WebSocket is not necessary.
18API reference
Endpoints in src/api.py (FastAPI, version 2.3.1).
| Method + path | Purpose |
|---|---|
| GET /status | Server health, version, AI key state, last AI error, folder-look support |
| GET, POST /categories | Read or save the plan for a folder |
| GET, POST /settings | Read or save provider and key |
| GET /fs/quick-locations | Desktop, Downloads, Documents, … |
| GET /fs/list-directory | In-app folder browser |
| POST /fs/create-directory | Make a folder |
| POST /fs/browse-native | Open the OS folder dialog |
| POST /fs/open-path | Show a file or folder in Finder / Explorer |
| POST /pipeline/run | Run the pipeline; returns summary + files |
| POST /pipeline/cancel | Set the cancel event |
| POST /pipeline/clear | Forget the current sort and plan |
| GET /pipeline/latest | Last run (for resume) |
| POST /pipeline/apply-direct | Copy or move approved files |
| GET, POST /pipeline/undo-last | Check or run undo |
| GET /search?q= | Full-text search |
| POST /ai/chat-structure | Make or change the plan |
| POST /ai/review-chat | Plain-English review command |
| POST /ai/cluster-unrecognized | Group Unknown files into new folders |
| GET /events, /stream | SSE streams |
| GET /costs, /files, POST /rules | Token cost log, indexed files, save rules |
| GET /{path} | Serves the React build (frontend/dist), with no-cache on index.html |
Startup (lifespan): start the DB writer, start the processor workers, load the last run from disk, test the AI key in the background.
19Frontend
frontend/src — React 19 + TypeScript.
App.tsxholds all state and all API calls. It mirrors the current folder intolocalStorage.flow/has the five screens (HomeScreen,PlanScreen,SortingScreen,PreviewScreen,DoneScreen) plusTopBar,ReviewAssistant,FolderLookPicker. The screens only show data and call callbacks.flow/contracts.tsdefines their props. This is the "container / presentational" pattern.components/has the advanced views:ReviewView,CategoriesView,AiStructureAssistant,SearchView,SettingsView, modals.wizard/is an animated SVG mascot. Each body part rotates around its own joint (rig.ts,poses.ts,motions.ts).utils/: folder icons and colours, preset categories, category history.config.tspicks the API base. In the packaged app, the page comes from the backend on a free port, so it useswindow.location.origin. In dev (Vite on port 1420) it useshttp://127.0.0.1:8000orVITE_API_BASE.
Styling uses Tailwind CSS 4 with tokens, Inter and JetBrains Mono fonts, light and dark mode, Motion for animation, and Lucide icons. frontend/src-tauri holds a Tauri project, but the release build uses pywebview.
20Desktop shell and build
desktop.py, build/, .github/workflows/build.yml.
Start sequence (desktop.py)
- Fix
stdout/stderr(they areNonein a Windows GUI build). - Find a free port from 8000 (try 50 ports).
- Start uvicorn in a daemon thread with its own event loop. Bind to
127.0.0.1only. - Poll the server until it answers (max 10 s).
- Open a pywebview window (1280×840). On Windows use Edge WebView2. If pywebview fails, open the system browser.
- When the window closes, stop the server and exit.
Build (build/build.py)
- Build the frontend (
npm run build). - On macOS, compile the Apple Vision helper with
swiftc. - Run PyInstaller with
build/tidyflow.spec. - Package:
TidyFlow.dmgon macOS,TidyFlow-windows.zipon Windows.
PyInstaller cannot cross-compile. A v* git tag starts GitHub Actions on macos-latest and windows-latest. It attaches both builds to the release. get_resource_path() finds bundled files in dev, in sys._MEIPASS, and in Contents/Resources of a macOS app.
21CLI and configuration
src/cli.py (Typer) and config.yaml.
python3 -m src.cli run -i ~/Downloads -o ~/Downloads/Organized # dry run + HTML report
python3 -m src.cli run -i ~/Downloads -o ~/Organized --auto-apply --confirm
python3 -m src.cli inventory -i ~/Downloads -o ./report # offline, no AI
python3 -m src.cli config-set-llm deepseek <key>
Commands: run, inventory, scan, report, apply (from a reviewed review_decisions.csv), serve, config-set-llm, status.
Important config keys
| Key | Default | Effect |
|---|---|---|
max_file_size_mb | 200 | Above this, content is not read |
ocr.enabled | true | Turn OCR on or off |
ocr.max_image_dimension | 768 | Resize before PaddleOCR |
ocr.skip_images_smaller_than | 120 | Skip icons |
llm.batch_size | 15 | Files per AI request |
llm.max_retries | 3 | Tries per batch |
llm.custom_instructions | text | Added to the AI prompt |
classification.auto_copy_threshold | 0.85 | "Sure" limit (UI detail level overrides it) |
classification.heuristic_high_threshold | 88 | Fast path: top score |
classification.heuristic_low_second | 50 | Fast path: second score must be below |
duplicates.hamming_distance_threshold | 8 | Near-duplicate limit (of 64 bits) |
categories | map | name, description, keywords, extensions, active |
22Tests
tests/ — pytest, 26 test files.
There is one test file for almost every module: scanner, hashing, extractor, rules, keyword scorer, LLM provider, AI assistant, applier, undo API, file system API, search, database, config, models, utils, CLI, folder style, MCP server, graph, main loop. There are also end-to-end (test_e2e.py) and scale (test_scale.py) tests. The AI is mocked, so tests need no key and no network.
python3 -m pytest tests/
23Other modules
Code that is in the repository but is not on the main path.
graph.py— a LangGraph state machine for one file (scan → route → extract → classify → review or move → checkpoint). Only its tests use it. The main pipeline is a batch function, which is simpler and cheaper.mcp_server.py— file tools (list, copy, move, delete) with a path allow-list, using FastMCP if it is installed. The API callsset_allowed_directories()with the input and output folders on each run.file_mover.py— uses the MCP tools to move files inside the allow-list.Processorinmain_loop.py— an async worker pool (queue + semaphore, 5 workers). It applies basic rules to single files and writes to the DB. It starts with the server.reporter.py— CSV inventory, OCR JSONL, classification CSV, run summary, and an HTML review report (Jinja template). Used by the CLI and whenexport_reportsis on.scripts/compare_decision_models.py— compares the decisions of different AI models.
24Limits and next steps
Honest weak points. Interviewers often ask about these.
| Limit | Why it matters | Possible fix |
|---|---|---|
| Near-duplicate pairs are O(n²) | Slow with tens of thousands of images | BK-tree or multi-index hashing on hash parts |
| Last run is kept in one global dict | One user, one run at a time | Run IDs and a run table in SQLite |
| CORS allows all origins | A web page could call the local server | Allow only the app origin, add a per-launch token |
| AI sees only 600 characters | Long documents can be misread | Smart excerpts (title, first page, headings) or embeddings |
| Pipeline steps are sequential | OCR and hashing do not use all CPU cores | Process pool for OCR and hashing |
| Folders are flattened | Original subfolder structure is lost | Option to keep relative paths |
| Builds are not notarized | Users see OS warnings | Apple notarization, Windows code signing |
| Two backend paths (graph.py, Processor) | Extra code to maintain | Remove or merge with the main pipeline |
25Interview questions and answers
Click a question to see a short, clear answer.
Product and design
Explain TidyFlow in 30 seconds.
TidyFlow is a desktop file organizer. You point it at a messy folder. It reads the content of each file, including OCR on images, and proposes folders that fit your real files. It places the files it is sure about and asks you about the rest. It copies files only after you approve, checks each copy with SHA-256, and has a safe one-click undo. It works offline with rules, and uses any OpenAI-compatible AI if you add a key.
Why copy instead of move by default?
A copy can never lose data. If the classification is wrong, the original is still in place. Undo for a copy is simple: delete the copy. Move is available, but it needs a clear choice from the user.
Why a local web server plus a webview, and not Electron or native UI?
The heavy work (OCR, PDF parsing, hashing, AI calls) is in Python. A FastAPI server lets the React UI call that code directly. pywebview uses the system web engine (WebKit on macOS, WebView2 on Windows), so the app does not ship a full Chromium like Electron. The same server also works in a normal browser for development, and the same pipeline works from the CLI.
Why does the plan come from the files and not from a fixed template?
Every folder is different. A student's Downloads needs "Lectures" and "Assignments"; a freelancer's needs "Invoices" and "Clients". The app samples up to 80 files across all types and subfolders and sends a summary of the full folder. The AI then makes folders that each hold several files. A fixed template gives many empty folders and one large "Other".
Pipeline and algorithms
Walk me through the pipeline.
Nine steps on one list of FileRecords: scan, metadata, duplicates, text extraction, OCR, rules and keyword scores, batched AI, save, and optional apply. Each step adds fields to the records. Cheap steps run first, so the expensive AI step only sees files that the rules could not decide.
How do you detect near-duplicate images?
I compute a perceptual hash: resize to 32×32 grayscale, apply a DCT, keep the 8×8 low frequencies, and set each bit by comparing to the median. That gives 64 bits. Two images with a Hamming distance of 8 or less are similar. I join similar pairs with Union-Find with path compression, so groups are transitive. I wrote the DCT with NumPy to remove the SciPy dependency from the packaged app.
Why Union-Find for grouping?
Similarity is a graph problem: images are nodes, similar pairs are edges, and groups are connected components. Union-Find finds components with almost constant time per operation. It also handles chains: if A is like B and B is like C, all three go into one group.
How does the keyword fast path work, and why?
Each category has keywords. I score the text against them with exact word matches and rapidfuzz partial ratio. If the best category scores 88 or more and the second scores below 50, the winner is clear, so I classify without AI. This saves money and time and gives the same answer each run. Fuzzy matching is only for words of 6 letters or more, to avoid false matches.
How do you handle scanned PDFs?
PyMuPDF reads the text of the first 5 pages. If it finds fewer than 20 characters, the PDF is a scan. The text stays empty, so the OCR step picks the file up. OCR uses Apple Vision on macOS and PaddleOCR on Windows.
Why is the OCR cache keyed by SHA-256?
OCR is the slowest step. The hash identifies the content, not the location. A renamed or moved file hits the cache. A changed file gets a new hash, so the cache can never return stale text.
AI and LLM
Why send files to the LLM in batches?
The system prompt with all category definitions is long. In a batch of 15 files, it is sent once instead of 15 times. That cuts tokens, cost and the number of rate-limited requests. The model also sees related files together, which helps consistency.
How do you make the LLM output reliable?
- JSON mode (
response_format: json_object) and low temperature (0.1). - Strip Markdown fences; accept a bare list or keys like "files" and "items".
- Validate with Pydantic models. Invalid action →
manual_review; confidence 0–100 → 0–1. - On failure, send a repair message with the broken answer and the exact schema.
- Map the category to a real folder, else Unknown.
- Decide the action in code from the threshold, not from the model.
- Files with no answer become Unknown and go to review.
How do you stop hallucinated folders or file IDs?
The model can only choose from the given categories. _resolve_category turns any other answer into Unknown. In review commands, folders are sent as numbers and only IDs from the request are accepted. New folders are allowed only when the user asks for one. A validator drops all other changes and rewrites the message so it describes only real changes.
How do you handle rate limits and errors?
On 429, wait for Retry-After, or use exponential backoff (2ⁿ × 2.5 s, max 30 s). On 401 and 402, stop at once and show a clear message ("invalid key", "no credit"). DNS and connection errors get a specific message. The last error is kept so the UI can show it on the Home and Plan screens.
What about privacy when the AI is on?
Only the name, extension, size, up to 600 characters of text and keyword hints go out. Regex patterns remove API keys, GitHub tokens, private keys, passwords and URLs with logins. Files that look like password exports send no text. Secret files are never scanned. Without a key, nothing leaves the computer.
How is confidence calibrated?
The prompt gives the model a clear scale (0.95+ only when name and content agree). The UI detail level sets the threshold: 0.70 for Fewer, 0.85 for Balanced, 0.90–0.92 for Detailed. Below the threshold, a person decides. So a model that is too confident does not move files without review.
Does it work without AI?
Yes. User rules, extension rules and keyword scores classify files. The plan comes from a heuristic planner that reads names and types. Review commands use a name matcher. The UI says when results come from "basic matching".
Safety and correctness
How do you make sure no file is lost or overwritten?
Copy is the default. After each copy, I hash the copy and compare it with the original; on mismatch I delete the copy. The destination name is always new: folder label, then (2), (3)…, then a hash suffix. Same name plus same hash means "already organized", and the file is skipped.
How does undo stay safe?
Undo reads the manifest. For each entry it hashes the destination first. If the file is missing or changed, it skips it. For a move, it moves back only if the original place is empty. Then it removes empty folders from the deepest up, but never above the output root. It removes the output root only if TidyFlow created it.
Why does the file ID include the path?
Duplicates and empty files share a hash. If the ID were only the hash, two files would share one ID, and the UI, the AI batch and the applier would confuse them. Hash plus relative path is unique and is stable across runs.
Backend engineering
How does the UI get live progress?
Server-Sent Events on /events. Each client gets an asyncio.Queue. The pipeline runs in a thread and calls a callback, which uses run_coroutine_threadsafe to put events on the server's event loop. A ping every 15 s keeps the connection open. SSE fits because the data goes one way.
How do you keep FastAPI responsive during a long run?
The pipeline is blocking code, so I run it with asyncio.to_thread. The event loop stays free for SSE, cancel and other requests. Cancel sets a threading.Event, which the pipeline checks between steps and between AI batches.
Why SQLite with WAL and one writer?
SQLite needs no server, which fits a desktop app. WAL lets reads run during a write. SQLite allows one writer at a time, so I put all writes in one async queue. One task runs up to 100 writes per batch. This prevents "database is locked" errors and makes writes faster.
How does search work?
An FTS5 virtual table indexes path, text (including OCR text) and category. Triggers keep it in sync with the main table. The query becomes prefix tokens joined with AND, ranked by FTS rank, with a highlighted snippet. If FTS finds nothing, a LIKE search runs. Results point to where the file is now, and rows for deleted files are removed.
How does a restart keep your work?
The last run is saved to disk and loaded in the lifespan startup hook. The plan for each folder is saved too. After you apply, the run is cleared on purpose, so you do not apply the same results again. Undo data stays in last_apply.json.
Frontend and packaging
How is the React app structured?
App.tsx owns all state and API calls. The flow screens are presentational and get typed props from contracts.ts. This keeps screens simple and easy to change, and keeps the API logic in one place.
How does the frontend find the backend port?
The desktop app picks a free port from 8000. The backend also serves the page, so in production the UI uses window.location.origin. That way it never talks to another TidyFlow on a fixed port. In dev, Vite runs on 1420 and the UI uses port 8000.
How do you build for macOS and Windows?
One script, build/build.py: build the frontend, compile the Swift OCR helper on macOS, run PyInstaller, then make a DMG or ZIP. PyInstaller cannot cross-compile, so GitHub Actions builds on a macOS and a Windows runner when I push a v* tag, and attaches both files to the release.
What was a hard bug you fixed?
Example from the history (release 2.3.1): the packaged app tried to write caches, logs and records in its working folder, which is read-only inside an app bundle. The fix was work_data_dir(): in a frozen build it uses the user's app data folder; from source it uses ./data. A related design point: the file ID includes the path, because duplicate and empty files share a hash and would otherwise be confused.
Scale and improvement
What happens with 100,000 files?
Scan and hashing are linear. Text is capped at 4 KB per file, and files above 200 MB are not read. OCR is cached. AI batches of 15 mean about 6,700 requests, so cost and rate limits matter; the fast path reduces that. The weak point is near-duplicate matching, which is O(n²) in images. I would add a BK-tree, run OCR in a process pool, and stream results to the UI page by page.
What would you improve next?
- Lock the local API: allow only the app origin and add a launch token.
- Store runs in SQLite with IDs instead of a global dict.
- Learn from user corrections: turn repeated manual moves into rules.
- Better excerpts or embeddings for long documents.
- Parallel OCR and hashing.
- Code signing and notarization.
How do you test code that calls an LLM?
The tests mock the HTTP calls and the settings loader. They check the parsing, the repair path, category mapping, threshold logic and validation with fixed fake answers. So tests are fast, free and give the same result each time. A separate script compares real models on the same files.