Document TF-SYS-001 Product TidyFlow Issue 2.3.1 Writing standard ASD-STE100 (Simplified Technical English)

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:

  1. 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".
  2. 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.
  3. It gives each file a folder and a confidence value. Rules, keyword scores and an AI model (optional) make this decision.
  4. It shows you the result. Files with high confidence are already in a folder. Files with low confidence wait for you.
  5. When you approve, it copies the files into an Organized folder. It checks each copy with SHA-256.
  6. You can undo. Undo removes only the copies that are still identical to what TidyFlow made.
Backend
Python 3.10+, FastAPI, Pydantic, SQLite (WAL + FTS5)
Frontend
React 19, TypeScript, Vite 7, Tailwind CSS 4, Motion
Desktop shell
pywebview window + uvicorn server on 127.0.0.1, packaged with PyInstaller
OCR
Apple Vision (Swift helper) on macOS, PaddleOCR on Windows
AI
Any OpenAI-compatible API: DeepSeek, OpenAI, Gemini, Groq, OpenRouter, custom
Without AI
Works. It uses rules, keywords and file types.
NOTEThe three main principles are: read content, not only names; do nothing without approval; every change can be undone safely. Most design decisions in the code follow from these three.

2Terms

Words that this manual uses. Each word has one meaning.

TermMeaning
Input folderThe messy folder that you select.
Output folderThe folder where organized copies go. The default is <input>/Organized_Output (the UI calls it "Organized").
Category / folderOne target folder in the plan, for example Finance/Invoices. A "/" makes a subfolder.
PlanThe set of categories for one input folder. Each category has a name, description, keywords, extensions and an optional symbol.
FileRecordThe Pydantic model that holds all data about one file: path, size, hash, text, duplicates, scores and classification.
file_idA 12-character ID. It is a SHA-256 of "content hash + relative path". Two identical files in different places get different IDs.
ClassificationThe result for one file: category, confidence (0.0–1.0), reason, suggested filename, action and source.
Actioncopy_to_organized (sure) or manual_review (not sure).
SourceWho decided: user_rule, extension_rule, heuristic_high_confidence, llm or none.
ThresholdThe minimum confidence for "sure". The default is 0.85.
ManifestA list of every copy or move that TidyFlow made: source, destination, SHA-256, time. Undo uses it.
pHashPerceptual hash. A 64-bit fingerprint of how an image looks. Similar images have similar hashes.
SSEServer-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
Fig 3-1. Runtime architecture. Dashed lines go to the internet and occur only with an API key.

Main backend modules (src/)

FileJob
api.pyFastAPI app. All HTTP endpoints, SSE, run state, apply and undo.
main_loop.pyrun_pipeline(): runs the 9 pipeline steps in order.
scanner.pyWalks the folder, applies ignore rules, makes FileRecords.
metadata.pyImage size, EXIF, PDF page count, base64 thumbnails.
hashing.pyExact duplicates (SHA-256) and near duplicates (pHash + Union-Find).
extractor.pyText from PDF (PyMuPDF), plain text/code, and Office XML.
ocr_engine.pyOCR with a cache keyed by SHA-256.
rules.pyUser rules, extension rules, rapidfuzz keyword scoring, fast path.
llm_provider.pyBatched AI classification, prompts, retries, key storage.
ai_assistant.pyMakes the folder plan, edits it in plain English, runs review commands.
applier.pyVerified copy or move, manifest.
database.pySQLite with WAL, a single-writer queue, and FTS5 search.
utils.pyHashing, file IDs, secret detection, safe names, app data paths.
folder_style.pymacOS 26 folder symbol, emoji and colour.
config.py, models.pyTyped config (YAML) and data models (Pydantic).
cli.pyTyper 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
Fig 4-1. Screen sequence (FlowStep = home | plan | sorting | preview | done).
  1. 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.
  2. 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.
  3. Sorting. The pipeline runs. The screen shows live messages from the server. You can cancel.
  4. 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".
  5. 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
Fig 5-1. Pipeline. A cancel check runs after each step.
StepInputOutput on each FileRecord
1 ScanInput folderpath, size, dates, sha256, file_id, file_category, too_large, skipped
2 MetadataImages, PDFswidth, height, EXIF, page count, thumbnail_b64
3 DuplicatesHashes, imagesduplicate_group_id, perceptual_hash, near_duplicate_group_id
4 ExtractPDF, Office, text, codeextracted_text_raw, extracted_text_normalized, extraction_source
5 OCRImages, PDFs with no textSame text fields, ocr_confidence
6 RulesName, extension, textkeyword_scores, maybe classification
7 LLMFiles with no classificationclassification (source llm)
8 SaveAll recordsdata/file_records.jsonl; CSV/HTML reports only if enabled
9 ApplySure filesCopies + manifest (CLI --auto-apply only)
NOTEThe pipeline is synchronous code. The API runs it in a worker thread with 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 .tidyignore file 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

  1. Read size and dates with stat().
  2. Set too_large = True if the size is more than max_file_size_mb (200 MB). A large file is still organized by name and type, but its content is never read.
  3. Compute SHA-256 in 64 KB chunks.
  4. Make file_id = first 12 hex characters of sha256(content_hash + ":" + rel_path).
  5. Set file_category from the extension: image, document, code, data, media, archive, binary, other.
CAUTIONThe file ID includes the path on purpose. Duplicate files and empty files have the same content hash. If the ID used only the hash, the UI, the AI batches and the applier could mix them up.

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)

  1. 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.
  2. Compare each pair of hashes. The Hamming distance is the number of different bits.
  3. If the distance is ≤ 8 (hamming_distance_threshold), join the two images with Union-Find.
  4. 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
Fig 7-1. Union-Find is transitive. A and C join through B, even when A–C is above the limit.

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)

TypeMethodLimit
PDFPyMuPDF (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, MDRead the file as UTF-8 (errors ignored).4,096 bytes
DOCX / XLSX / PPTXOpen 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
Fig 8-1. OCR decision path.
  • Apple Vision. src/native/macos_ocr.swift is a small Swift program. The build compiles it to bin/macos_ocr. In development, the app compiles it on first use if swiftc exists. Python calls it with subprocess.
  • 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()

  1. Large file. Size > 50 MB and the plan has Large_Files (or no plan) → Large_Files, confidence 1.0.
  2. User rules from rules.yaml (app data folder first). A rule matches by extensions, a regex pattern on the name, or a contains word. Result: confidence 1.0, source user_rule. A rule is ignored if its category is not in the current plan.
  3. Extension rule. Only for code, archive and media files. Example: .mp3 → Media/Audio. Confidence 0.90, source extension_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.

NOTEKeyword scores above 40 are also sent to the AI as hints, so the AI sees what the rules found.

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
Fig 10-1. One batch request with retry and JSON repair (max_retries = 3).

After the answer

  1. Category resolve. Exact match, then case-insensitive, then last path part ("Invoices" → "Finance/Invoices"). No match → Unknown. The AI cannot invent a folder.
  2. Confidence normalize. If the model answers 0–100, divide by 100. Clamp to 0–1.
  3. 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.
  4. 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

ProviderBase URLDefault model
deepseekapi.deepseek.com/v1deepseek-chat
openaiapi.openai.com/v1gpt-4o-mini
geminigenerativelanguage.googleapis.com/v1beta/openaigemini-2.0-flash
groqapi.groq.com/openai/v1llama-3.3-70b-versatile
openrouteropenrouter.ai/api/v1deepseek/deepseek-chat
customYour URLFrom 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 nameLevelStructureSure threshold
FewerlowFlat only, no "/"0.70
BalancedmediumOne level of subfolders where useful0.85
Detailedhigh / complex1–2 levels, project or date aware0.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:

  1. Number the folders. Send the files as a compact tab-separated table: id, name, folder number, organize flag, size, 200-character text excerpt.
  2. Split large folders into chunks of 150 files. Send up to 4 chunks in parallel with a ThreadPoolExecutor.
  3. The AI returns moves, renames, exclude, include, and a short message.
  4. _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.
  5. 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.
CAUTIONIf one chunk fails, the full command fails and nothing changes. A half-applied command would confuse the user. Without a key, a simple name matcher runs, and the message says so.

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
Fig 13-1. Apply one file. Copy is the default; move is optional.

Name clashes (choose_destination)

Organizing makes folders flat, so two files can have the same name. TidyFlow never overwrites. It tries, in order:

  1. photo.jpg
  2. photo (Trip).jpg — the name of the folder where the file came from
  3. photo (2).jpg … photo (999).jpg — like Finder
  4. photo_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}
Fig 14-1. Undo checks every file before it removes it.
WARNINGUndo must never delete a file that the user changed after organizing. That is why undo compares the SHA-256 first. If you edited a copy, undo keeps it and counts it as "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.

RiskProtectionWhere
Data lossCopy by default; move only on requestapplier.py
Bad copySHA-256 check after each copy; delete on mismatchapplier.py
OverwriteNever overwrite; readable unique namesutils.choose_destination
Undo deletes user workHash check before each delete; walk up folders only inside output rootapi.undo_last_apply
Secrets sent to AIRegex redaction (API keys, GitHub tokens, private keys, password=, URLs with login). Password-table files send no text at allutils.redact_secrets, _make_payload
Secret files sorted.env, .pem, keystores, password exports are skippedutils.is_secret_file
Executables moved.exe .bat .sh .ps1 .dll .so .pem .p12 .env … are never copiedutils.is_safe_to_copy
AI invents foldersCategory must resolve to a plan folder, else Unknown_resolve_category
AI changes wrong filesOnly known IDs; validated; all-or-nothing_validate_review_result
Key leaksettings.json in user data folder with mode 0600save_settings
Path escapeAllow-list of input and output roots for file toolsmcp_server.is_path_allowed
Bundle damageNever walk into .app, .photoslibrary and similarscanner.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.

FileContents
settings.jsonProvider, API key, custom URL (mode 0600)
tidyflow.dbSQLite index of files, for search
cache/ocr_cache.jsonOCR text, keyed by SHA-256
file_records.jsonlAll FileRecords of the last run, one JSON per line
latest run folderSaved run, so you can resume after restart
last_apply.jsonUndo data
manifests/copy_manifest.csvAudit log of copies
logs/llm_requests.jsonl, llm_responses.jsonlBatch IDs and first 2,000 chars of each AI answer, for debugging
rules.yamlUser 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_fts on 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, a LIKE search runs. Rows whose file no longer exists are deleted.
  • Crash recovery. At start, rows with status processing go back to pending.

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)
Fig 17-1. SSE progress. Each client has its own asyncio.Queue in a shared set.

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 + pathPurpose
GET /statusServer health, version, AI key state, last AI error, folder-look support
GET, POST /categoriesRead or save the plan for a folder
GET, POST /settingsRead or save provider and key
GET /fs/quick-locationsDesktop, Downloads, Documents, …
GET /fs/list-directoryIn-app folder browser
POST /fs/create-directoryMake a folder
POST /fs/browse-nativeOpen the OS folder dialog
POST /fs/open-pathShow a file or folder in Finder / Explorer
POST /pipeline/runRun the pipeline; returns summary + files
POST /pipeline/cancelSet the cancel event
POST /pipeline/clearForget the current sort and plan
GET /pipeline/latestLast run (for resume)
POST /pipeline/apply-directCopy or move approved files
GET, POST /pipeline/undo-lastCheck or run undo
GET /search?q=Full-text search
POST /ai/chat-structureMake or change the plan
POST /ai/review-chatPlain-English review command
POST /ai/cluster-unrecognizedGroup Unknown files into new folders
GET /events, /streamSSE streams
GET /costs, /files, POST /rulesToken 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.tsx holds all state and all API calls. It mirrors the current folder into localStorage.
  • flow/ has the five screens (HomeScreen, PlanScreen, SortingScreen, PreviewScreen, DoneScreen) plus TopBar, ReviewAssistant, FolderLookPicker. The screens only show data and call callbacks. flow/contracts.ts defines 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.ts picks the API base. In the packaged app, the page comes from the backend on a free port, so it uses window.location.origin. In dev (Vite on port 1420) it uses http://127.0.0.1:8000 or VITE_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)

  1. Fix stdout/stderr (they are None in a Windows GUI build).
  2. Find a free port from 8000 (try 50 ports).
  3. Start uvicorn in a daemon thread with its own event loop. Bind to 127.0.0.1 only.
  4. Poll the server until it answers (max 10 s).
  5. Open a pywebview window (1280×840). On Windows use Edge WebView2. If pywebview fails, open the system browser.
  6. When the window closes, stop the server and exit.

Build (build/build.py)

  1. Build the frontend (npm run build).
  2. On macOS, compile the Apple Vision helper with swiftc.
  3. Run PyInstaller with build/tidyflow.spec.
  4. Package: TidyFlow.dmg on macOS, TidyFlow-windows.zip on 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

KeyDefaultEffect
max_file_size_mb200Above this, content is not read
ocr.enabledtrueTurn OCR on or off
ocr.max_image_dimension768Resize before PaddleOCR
ocr.skip_images_smaller_than120Skip icons
llm.batch_size15Files per AI request
llm.max_retries3Tries per batch
llm.custom_instructionstextAdded to the AI prompt
classification.auto_copy_threshold0.85"Sure" limit (UI detail level overrides it)
classification.heuristic_high_threshold88Fast path: top score
classification.heuristic_low_second50Fast path: second score must be below
duplicates.hamming_distance_threshold8Near-duplicate limit (of 64 bits)
categoriesmapname, 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 calls set_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.
  • Processor in main_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 when export_reports is 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.

LimitWhy it mattersPossible fix
Near-duplicate pairs are O(n²)Slow with tens of thousands of imagesBK-tree or multi-index hashing on hash parts
Last run is kept in one global dictOne user, one run at a timeRun IDs and a run table in SQLite
CORS allows all originsA web page could call the local serverAllow only the app origin, add a per-launch token
AI sees only 600 charactersLong documents can be misreadSmart excerpts (title, first page, headings) or embeddings
Pipeline steps are sequentialOCR and hashing do not use all CPU coresProcess pool for OCR and hashing
Folders are flattenedOriginal subfolder structure is lostOption to keep relative paths
Builds are not notarizedUsers see OS warningsApple notarization, Windows code signing
Two backend paths (graph.py, Processor)Extra code to maintainRemove 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.