watfile 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- watfile-0.1.0/PKG-INFO +155 -0
- watfile-0.1.0/README.md +136 -0
- watfile-0.1.0/pyproject.toml +42 -0
- watfile-0.1.0/pyproject.toml.orig +37 -0
- watfile-0.1.0/src/watfile/__init__.py +3 -0
- watfile-0.1.0/src/watfile/classifier/base.py +24 -0
- watfile-0.1.0/src/watfile/classifier/jev.py +33 -0
- watfile-0.1.0/src/watfile/cli.py +149 -0
- watfile-0.1.0/src/watfile/config.py +108 -0
- watfile-0.1.0/src/watfile/extract.py +41 -0
- watfile-0.1.0/src/watfile/sorter.py +72 -0
watfile-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: watfile
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Classify documents with the TypeSafe Jev decision model and sort them into folders
|
|
5
|
+
Keywords: classification,files,llm,typesafe,cli
|
|
6
|
+
Author: Michael Hunger
|
|
7
|
+
Author-email: Michael Hunger <github@jexp.de>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Classifier: Environment :: Console
|
|
10
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
11
|
+
Classifier: Operating System :: OS Independent
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Dist: liteparse>=2.14.6
|
|
15
|
+
Requires-Dist: typesafe-sdk>=0.7.0
|
|
16
|
+
Requires-Python: >=3.12
|
|
17
|
+
Project-URL: Repository, https://github.com/jexp/watfile
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# watfile
|
|
21
|
+
|
|
22
|
+
Classify files with a decision-making AI model and sort them into category folders.
|
|
23
|
+
|
|
24
|
+
watfile sends each document's text (title/abstract-grade extract) to a
|
|
25
|
+
**TypeSafe AI Jev** (System One) [Choice](https://docs.typesafe.ai/primitives/choice)
|
|
26
|
+
question, gets back a typed answer with a selected category, per-category
|
|
27
|
+
probabilities and confidence, then moves the file into the matching folder.
|
|
28
|
+
A `Classifier` abstraction keeps the backend pluggable — local MLX (laya) and
|
|
29
|
+
other backends slot in later.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
34
|
+
|
|
35
|
+
### From PyPI (once published)
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
# one-off run, no install
|
|
39
|
+
uvx watfile --help
|
|
40
|
+
|
|
41
|
+
# persistent CLI on your PATH
|
|
42
|
+
uv tool install watfile
|
|
43
|
+
watfile --help
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### From source
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
git clone <repo> && cd watfile
|
|
50
|
+
uv sync # create venv + install deps (typesafe-sdk, liteparse)
|
|
51
|
+
uv run watfile --help
|
|
52
|
+
|
|
53
|
+
# or install the local checkout as a tool
|
|
54
|
+
uv tool install --from . watfile
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
### Configuration
|
|
58
|
+
|
|
59
|
+
watfile resolves its TypeSafe API key (create one at <https://console.typesafe.ai/>)
|
|
60
|
+
with this precedence — first match wins:
|
|
61
|
+
|
|
62
|
+
1. `TYPESAFE_API_KEY` environment variable
|
|
63
|
+
2. `.env` file in the current directory (gitignored; `TYPESAFE_API_KEY=...`)
|
|
64
|
+
3. `~/.config/watfile/config.toml` (`api_key = "..."`, also `base_url`, `model`;
|
|
65
|
+
`$WATFILE_CONFIG` or `$XDG_CONFIG_HOME` can relocate it)
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
export TYPESAFE_API_KEY=... # option 1
|
|
69
|
+
echo 'TYPESAFE_API_KEY=...' > .env # option 2
|
|
70
|
+
cat > ~/.config/watfile/config.toml <<'EOF' # option 3
|
|
71
|
+
api_key = "..."
|
|
72
|
+
EOF
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Usage
|
|
76
|
+
|
|
77
|
+
Point watfile at files or folders, and either give a comma-separated category
|
|
78
|
+
list (`-c`) or a target folder whose subfolders are the categories (`-d`):
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
# explicit categories, files moved into ./sorted/<category>/
|
|
82
|
+
uv run watfile ~/Downloads/invoice.pdf -c invoice,donation,apartment
|
|
83
|
+
|
|
84
|
+
# folder input, recursive; categories = existing subfolders of -d
|
|
85
|
+
mkdir -p ~/docs/{invoice,donation,apartment}
|
|
86
|
+
uv run watfile ~/Downloads -r -d ~/docs
|
|
87
|
+
|
|
88
|
+
# preview without touching anything
|
|
89
|
+
uv run watfile ~/Downloads -r -d ~/docs -n
|
|
90
|
+
|
|
91
|
+
# actually move the files (default is symlinking into the category folders)
|
|
92
|
+
uv run watfile ~/Downloads -r -d ~/docs -m
|
|
93
|
+
|
|
94
|
+
# copy instead
|
|
95
|
+
uv run watfile ~/Downloads -r -d ~/docs --copy
|
|
96
|
+
|
|
97
|
+
# custom output root with -c
|
|
98
|
+
uv run watfile *.pdf -c computerscience,biology -o ~/sorted
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Output per file:
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
bill.pdf: invoice (conf 0.94) -> symlink to ~/docs/invoice/bill.pdf
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Files that can't be classified (unsupported extension, no extractable text) are
|
|
108
|
+
skipped with a warning; name collisions get a `_1`, `_2`… suffix.
|
|
109
|
+
|
|
110
|
+
### Supported inputs
|
|
111
|
+
|
|
112
|
+
- **Text formats** (read directly): `.txt .md .markdown .rst .log .csv .json`
|
|
113
|
+
- **PDF** (via [liteparse](https://github.com/run-llama/liteparse)): only the
|
|
114
|
+
first 2 pages are parsed, OCR disabled — enough for classification, ~1000x
|
|
115
|
+
faster than a full parse. Scanned/image-only PDFs are skipped.
|
|
116
|
+
|
|
117
|
+
### Options
|
|
118
|
+
|
|
119
|
+
```
|
|
120
|
+
usage: watfile [-h] [-r] (-c CATEGORIES | -d DIRECTORY) [-o OUTPUT]
|
|
121
|
+
[--backend {jev,laya}] [-n] [--copy]
|
|
122
|
+
inputs [inputs ...]
|
|
123
|
+
|
|
124
|
+
positional arguments:
|
|
125
|
+
inputs files and/or folders to process
|
|
126
|
+
|
|
127
|
+
options:
|
|
128
|
+
-h, --help show this help message and exit
|
|
129
|
+
-r, --recursive recurse into folder inputs
|
|
130
|
+
-c CATEGORIES, --categories CATEGORIES
|
|
131
|
+
comma-separated categories, e.g. invoice,donation,apartment
|
|
132
|
+
-d DIRECTORY, --directory
|
|
133
|
+
target folder whose existing subfolders are the categories
|
|
134
|
+
-o OUTPUT, --output output root for sorted files (default: same as -d, or ./sorted with -c)
|
|
135
|
+
--backend {jev,laya} classifier backend (default: jev)
|
|
136
|
+
-n, --dry-run print decisions without placing files
|
|
137
|
+
-m, --move move files into the category folder (default: symlink)
|
|
138
|
+
--copy copy files instead of symlinking
|
|
139
|
+
--symlink create symlinks in category folders (default)
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Development
|
|
143
|
+
|
|
144
|
+
```sh
|
|
145
|
+
uv sync
|
|
146
|
+
uv run pytest # unit tests; live API tests skip without TYPESAFE_API_KEY
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`tests/fixture/` contains 4 real arXiv PDFs with ground-truth categories
|
|
150
|
+
(derived from their arXiv subject tags) used by the integration tests.
|
|
151
|
+
|
|
152
|
+
## Roadmap
|
|
153
|
+
|
|
154
|
+
- `laya` local backend (MLX via OpenAI-compatible HTTP)
|
|
155
|
+
- batching: classify 25/50/100 files in a single API call
|
watfile-0.1.0/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# watfile
|
|
2
|
+
|
|
3
|
+
Classify files with a decision-making AI model and sort them into category folders.
|
|
4
|
+
|
|
5
|
+
watfile sends each document's text (title/abstract-grade extract) to a
|
|
6
|
+
**TypeSafe AI Jev** (System One) [Choice](https://docs.typesafe.ai/primitives/choice)
|
|
7
|
+
question, gets back a typed answer with a selected category, per-category
|
|
8
|
+
probabilities and confidence, then moves the file into the matching folder.
|
|
9
|
+
A `Classifier` abstraction keeps the backend pluggable — local MLX (laya) and
|
|
10
|
+
other backends slot in later.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
Requires Python 3.12+ and [uv](https://docs.astral.sh/uv/).
|
|
15
|
+
|
|
16
|
+
### From PyPI (once published)
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
# one-off run, no install
|
|
20
|
+
uvx watfile --help
|
|
21
|
+
|
|
22
|
+
# persistent CLI on your PATH
|
|
23
|
+
uv tool install watfile
|
|
24
|
+
watfile --help
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
### From source
|
|
28
|
+
|
|
29
|
+
```sh
|
|
30
|
+
git clone <repo> && cd watfile
|
|
31
|
+
uv sync # create venv + install deps (typesafe-sdk, liteparse)
|
|
32
|
+
uv run watfile --help
|
|
33
|
+
|
|
34
|
+
# or install the local checkout as a tool
|
|
35
|
+
uv tool install --from . watfile
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
### Configuration
|
|
39
|
+
|
|
40
|
+
watfile resolves its TypeSafe API key (create one at <https://console.typesafe.ai/>)
|
|
41
|
+
with this precedence — first match wins:
|
|
42
|
+
|
|
43
|
+
1. `TYPESAFE_API_KEY` environment variable
|
|
44
|
+
2. `.env` file in the current directory (gitignored; `TYPESAFE_API_KEY=...`)
|
|
45
|
+
3. `~/.config/watfile/config.toml` (`api_key = "..."`, also `base_url`, `model`;
|
|
46
|
+
`$WATFILE_CONFIG` or `$XDG_CONFIG_HOME` can relocate it)
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
export TYPESAFE_API_KEY=... # option 1
|
|
50
|
+
echo 'TYPESAFE_API_KEY=...' > .env # option 2
|
|
51
|
+
cat > ~/.config/watfile/config.toml <<'EOF' # option 3
|
|
52
|
+
api_key = "..."
|
|
53
|
+
EOF
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Usage
|
|
57
|
+
|
|
58
|
+
Point watfile at files or folders, and either give a comma-separated category
|
|
59
|
+
list (`-c`) or a target folder whose subfolders are the categories (`-d`):
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
# explicit categories, files moved into ./sorted/<category>/
|
|
63
|
+
uv run watfile ~/Downloads/invoice.pdf -c invoice,donation,apartment
|
|
64
|
+
|
|
65
|
+
# folder input, recursive; categories = existing subfolders of -d
|
|
66
|
+
mkdir -p ~/docs/{invoice,donation,apartment}
|
|
67
|
+
uv run watfile ~/Downloads -r -d ~/docs
|
|
68
|
+
|
|
69
|
+
# preview without touching anything
|
|
70
|
+
uv run watfile ~/Downloads -r -d ~/docs -n
|
|
71
|
+
|
|
72
|
+
# actually move the files (default is symlinking into the category folders)
|
|
73
|
+
uv run watfile ~/Downloads -r -d ~/docs -m
|
|
74
|
+
|
|
75
|
+
# copy instead
|
|
76
|
+
uv run watfile ~/Downloads -r -d ~/docs --copy
|
|
77
|
+
|
|
78
|
+
# custom output root with -c
|
|
79
|
+
uv run watfile *.pdf -c computerscience,biology -o ~/sorted
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Output per file:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
bill.pdf: invoice (conf 0.94) -> symlink to ~/docs/invoice/bill.pdf
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Files that can't be classified (unsupported extension, no extractable text) are
|
|
89
|
+
skipped with a warning; name collisions get a `_1`, `_2`… suffix.
|
|
90
|
+
|
|
91
|
+
### Supported inputs
|
|
92
|
+
|
|
93
|
+
- **Text formats** (read directly): `.txt .md .markdown .rst .log .csv .json`
|
|
94
|
+
- **PDF** (via [liteparse](https://github.com/run-llama/liteparse)): only the
|
|
95
|
+
first 2 pages are parsed, OCR disabled — enough for classification, ~1000x
|
|
96
|
+
faster than a full parse. Scanned/image-only PDFs are skipped.
|
|
97
|
+
|
|
98
|
+
### Options
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
usage: watfile [-h] [-r] (-c CATEGORIES | -d DIRECTORY) [-o OUTPUT]
|
|
102
|
+
[--backend {jev,laya}] [-n] [--copy]
|
|
103
|
+
inputs [inputs ...]
|
|
104
|
+
|
|
105
|
+
positional arguments:
|
|
106
|
+
inputs files and/or folders to process
|
|
107
|
+
|
|
108
|
+
options:
|
|
109
|
+
-h, --help show this help message and exit
|
|
110
|
+
-r, --recursive recurse into folder inputs
|
|
111
|
+
-c CATEGORIES, --categories CATEGORIES
|
|
112
|
+
comma-separated categories, e.g. invoice,donation,apartment
|
|
113
|
+
-d DIRECTORY, --directory
|
|
114
|
+
target folder whose existing subfolders are the categories
|
|
115
|
+
-o OUTPUT, --output output root for sorted files (default: same as -d, or ./sorted with -c)
|
|
116
|
+
--backend {jev,laya} classifier backend (default: jev)
|
|
117
|
+
-n, --dry-run print decisions without placing files
|
|
118
|
+
-m, --move move files into the category folder (default: symlink)
|
|
119
|
+
--copy copy files instead of symlinking
|
|
120
|
+
--symlink create symlinks in category folders (default)
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```sh
|
|
126
|
+
uv sync
|
|
127
|
+
uv run pytest # unit tests; live API tests skip without TYPESAFE_API_KEY
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
`tests/fixture/` contains 4 real arXiv PDFs with ground-truth categories
|
|
131
|
+
(derived from their arXiv subject tags) used by the integration tests.
|
|
132
|
+
|
|
133
|
+
## Roadmap
|
|
134
|
+
|
|
135
|
+
- `laya` local backend (MLX via OpenAI-compatible HTTP)
|
|
136
|
+
- batching: classify 25/50/100 files in a single API call
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "watfile"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Classify documents with the TypeSafe Jev decision model and sort them into folders"
|
|
5
|
+
license = "MIT"
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
keywords = [
|
|
8
|
+
"classification",
|
|
9
|
+
"files",
|
|
10
|
+
"llm",
|
|
11
|
+
"typesafe",
|
|
12
|
+
"cli",
|
|
13
|
+
]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Environment :: Console",
|
|
16
|
+
"Intended Audience :: End Users/Desktop",
|
|
17
|
+
"Operating System :: OS Independent",
|
|
18
|
+
"Programming Language :: Python :: 3.12",
|
|
19
|
+
"Topic :: Utilities",
|
|
20
|
+
]
|
|
21
|
+
requires-python = ">=3.12"
|
|
22
|
+
dependencies = [
|
|
23
|
+
"liteparse>=2.14.6",
|
|
24
|
+
"typesafe-sdk>=0.7.0",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[[project.authors]]
|
|
28
|
+
name = "Michael Hunger"
|
|
29
|
+
email = "github@jexp.de"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
watfile = "watfile:main"
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Repository = "https://github.com/jexp/watfile"
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.11.21,<0.12.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[dependency-groups]
|
|
42
|
+
dev = ["pytest>=9.1.1"]
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "watfile"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Classify documents with the TypeSafe Jev decision model and sort them into folders"
|
|
5
|
+
authors = [
|
|
6
|
+
{ name = "Michael Hunger", email = "github@jexp.de" }
|
|
7
|
+
]
|
|
8
|
+
license = "MIT"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
keywords = ["classification", "files", "llm", "typesafe", "cli"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Intended Audience :: End Users/Desktop",
|
|
14
|
+
"Operating System :: OS Independent",
|
|
15
|
+
"Programming Language :: Python :: 3.12",
|
|
16
|
+
"Topic :: Utilities",
|
|
17
|
+
]
|
|
18
|
+
requires-python = ">=3.12"
|
|
19
|
+
dependencies = [
|
|
20
|
+
"liteparse>=2.14.6",
|
|
21
|
+
"typesafe-sdk>=0.7.0",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
watfile = "watfile:main"
|
|
26
|
+
|
|
27
|
+
[build-system]
|
|
28
|
+
requires = ["uv_build>=0.11.21,<0.12.0"]
|
|
29
|
+
build-backend = "uv_build"
|
|
30
|
+
|
|
31
|
+
[project.urls]
|
|
32
|
+
Repository = "https://github.com/jexp/watfile"
|
|
33
|
+
|
|
34
|
+
[dependency-groups]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=9.1.1",
|
|
37
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Classifier abstraction — backend-agnostic file categorisation."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Sequence
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Verdict:
|
|
10
|
+
"""Classification result for one document."""
|
|
11
|
+
|
|
12
|
+
category: str
|
|
13
|
+
confidence: float # backend-reported certainty, 0..1
|
|
14
|
+
probabilities: dict[str, float] # category -> probability
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Classifier(ABC):
|
|
18
|
+
"""Backend contract. Implementations must be stateless per call so batching
|
|
19
|
+
(classify_many) can be added without changing the interface."""
|
|
20
|
+
|
|
21
|
+
@abstractmethod
|
|
22
|
+
def classify(self, text: str, categories: Sequence[str]) -> Verdict:
|
|
23
|
+
"""Classify one document's text into exactly one of *categories*."""
|
|
24
|
+
...
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""TypeSafe AI Jev (System One) classifier backend."""
|
|
2
|
+
|
|
3
|
+
from typing import Sequence
|
|
4
|
+
|
|
5
|
+
from typesafe_sdk import Choice, TypeSafeClient
|
|
6
|
+
|
|
7
|
+
from .base import Classifier, Verdict
|
|
8
|
+
|
|
9
|
+
_QUESTION_KEY = "category"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class JevClassifier(Classifier):
|
|
13
|
+
def __init__(self, model: str = "jev-latest") -> None:
|
|
14
|
+
self._client = TypeSafeClient(model=model)
|
|
15
|
+
|
|
16
|
+
def classify(self, text: str, categories: Sequence[str]) -> Verdict:
|
|
17
|
+
question = Choice(
|
|
18
|
+
instructions=(
|
|
19
|
+
"Which folder/category does this document belong to? "
|
|
20
|
+
"Judge by content, not filename. Pick exactly one."
|
|
21
|
+
),
|
|
22
|
+
criteria={cat: None for cat in categories},
|
|
23
|
+
)
|
|
24
|
+
response = self._client.system_one(
|
|
25
|
+
state={"document": text},
|
|
26
|
+
questions={_QUESTION_KEY: question},
|
|
27
|
+
)
|
|
28
|
+
answer = response.choices[_QUESTION_KEY]
|
|
29
|
+
return Verdict(
|
|
30
|
+
category=answer.choice,
|
|
31
|
+
confidence=answer.confidence,
|
|
32
|
+
probabilities=dict(answer.probabilities),
|
|
33
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""watfile CLI entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Sequence
|
|
9
|
+
|
|
10
|
+
from .classifier.base import Classifier, Verdict
|
|
11
|
+
from .classifier.jev import JevClassifier
|
|
12
|
+
from .config import apply_config, load_config
|
|
13
|
+
from .extract import UnsupportedFileTypeError, extract_text
|
|
14
|
+
from .sorter import PLACEMENT_COPY, PLACEMENT_MOVE, PLACEMENT_SYMLINK, place_file
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _collect_files(inputs: Sequence[str], recursive: bool) -> list[Path]:
|
|
18
|
+
files: list[Path] = []
|
|
19
|
+
for raw in inputs:
|
|
20
|
+
path = Path(raw).expanduser()
|
|
21
|
+
if path.is_dir():
|
|
22
|
+
pattern = "**/*" if recursive else "*"
|
|
23
|
+
files.extend(p for p in sorted(path.glob(pattern)) if p.is_file())
|
|
24
|
+
elif path.is_file():
|
|
25
|
+
files.append(path)
|
|
26
|
+
else:
|
|
27
|
+
print(f"warning: not found, skipped: {path}", file=sys.stderr)
|
|
28
|
+
# de-duplicate, keep order
|
|
29
|
+
seen: set[Path] = set()
|
|
30
|
+
unique: list[Path] = []
|
|
31
|
+
for f in files:
|
|
32
|
+
resolved = f.resolve()
|
|
33
|
+
if resolved not in seen:
|
|
34
|
+
seen.add(resolved)
|
|
35
|
+
unique.append(f)
|
|
36
|
+
return unique
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _categories_from_dir(target: Path) -> list[str]:
|
|
40
|
+
if not target.is_dir():
|
|
41
|
+
raise SystemExit(f"target folder does not exist: {target}")
|
|
42
|
+
subdirs = [p.name for p in sorted(target.iterdir()) if p.is_dir()]
|
|
43
|
+
if not subdirs:
|
|
44
|
+
raise SystemExit(
|
|
45
|
+
f"target folder {target} has no subfolders to use as categories; "
|
|
46
|
+
"create one folder per category or pass -c instead"
|
|
47
|
+
)
|
|
48
|
+
return subdirs
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _parse_categories_arg(raw: str) -> list[str]:
|
|
52
|
+
cats = [c.strip() for c in raw.split(",") if c.strip()]
|
|
53
|
+
if len(cats) < 2:
|
|
54
|
+
raise SystemExit("need at least 2 categories for -c")
|
|
55
|
+
return cats
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _build_classifier(name: str, config) -> Classifier:
|
|
59
|
+
if name == "jev":
|
|
60
|
+
if not config.api_key:
|
|
61
|
+
raise SystemExit(
|
|
62
|
+
"no TYPESAFE_API_KEY found.\n"
|
|
63
|
+
"Set the environment variable, or create a gitignored .env file with\n"
|
|
64
|
+
"TYPESAFE_API_KEY=..., or ~/.config/watfile/config.toml with:\n"
|
|
65
|
+
'api_key = "..."\n'
|
|
66
|
+
"Get a key at https://console.typesafe.ai/"
|
|
67
|
+
)
|
|
68
|
+
apply_config(config)
|
|
69
|
+
model = config.model or "jev-latest"
|
|
70
|
+
return JevClassifier(model=model)
|
|
71
|
+
if name == "laya":
|
|
72
|
+
raise SystemExit("laya backend not yet implemented (step 80 in PLAN.md)")
|
|
73
|
+
raise SystemExit(f"unknown backend: {name}")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _classify_one(classifier: Classifier, path: Path, categories: Sequence[str]) -> Verdict | None:
|
|
77
|
+
try:
|
|
78
|
+
text = extract_text(path)
|
|
79
|
+
except UnsupportedFileTypeError as exc:
|
|
80
|
+
print(f" skipped ({exc})", file=sys.stderr)
|
|
81
|
+
return None
|
|
82
|
+
if not text.strip():
|
|
83
|
+
print(" skipped (no extractable text)", file=sys.stderr)
|
|
84
|
+
return None
|
|
85
|
+
return classifier.classify(text, categories)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
89
|
+
parser = argparse.ArgumentParser(
|
|
90
|
+
prog="watfile",
|
|
91
|
+
description="Classify files with a decision model and sort them into category folders.",
|
|
92
|
+
)
|
|
93
|
+
parser.add_argument("inputs", nargs="+", help="files and/or folders to process")
|
|
94
|
+
parser.add_argument("-r", "--recursive", action="store_true", help="recurse into folder inputs")
|
|
95
|
+
target = parser.add_mutually_exclusive_group(required=True)
|
|
96
|
+
target.add_argument("-c", "--categories", help="comma-separated categories, e.g. invoice,donation,apartment")
|
|
97
|
+
target.add_argument("-d", "--directory", help="target folder whose existing subfolders are the categories")
|
|
98
|
+
parser.add_argument("-o", "--output", help="output root for sorted files (default: same as -d, or ./sorted with -c)")
|
|
99
|
+
parser.add_argument("--backend", default="jev", choices=["jev", "laya"], help="classifier backend (default: jev)")
|
|
100
|
+
parser.add_argument("-n", "--dry-run", action="store_true", help="print decisions without placing files")
|
|
101
|
+
placement = parser.add_mutually_exclusive_group()
|
|
102
|
+
placement.add_argument("-m", "--move", action="store_true", help="move files into the category folder (default: symlink)")
|
|
103
|
+
placement.add_argument("--copy", action="store_true", help="copy files instead of symlinking")
|
|
104
|
+
placement.add_argument("--symlink", action="store_true", help="create symlinks in category folders (default)")
|
|
105
|
+
args = parser.parse_args(argv)
|
|
106
|
+
|
|
107
|
+
if args.categories:
|
|
108
|
+
categories = _parse_categories_arg(args.categories)
|
|
109
|
+
target_root = Path(args.output) if args.output else Path("sorted")
|
|
110
|
+
else:
|
|
111
|
+
target_root = Path(args.directory).expanduser()
|
|
112
|
+
categories = _categories_from_dir(target_root)
|
|
113
|
+
if args.output:
|
|
114
|
+
target_root = Path(args.output).expanduser()
|
|
115
|
+
|
|
116
|
+
files = _collect_files(args.inputs, args.recursive)
|
|
117
|
+
if not files:
|
|
118
|
+
print("no files to process", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
print(f"categories: {', '.join(categories)}")
|
|
122
|
+
print(f"files: {len(files)} backend: {args.backend} target: {target_root}"
|
|
123
|
+
+ (" (dry-run)" if args.dry_run else ""))
|
|
124
|
+
|
|
125
|
+
classifier = _build_classifier(args.backend, load_config())
|
|
126
|
+
|
|
127
|
+
failures = 0
|
|
128
|
+
for path in files:
|
|
129
|
+
print(f"{path.name}: ", end="", flush=True)
|
|
130
|
+
verdict = _classify_one(classifier, path, categories)
|
|
131
|
+
if verdict is None:
|
|
132
|
+
failures += 1
|
|
133
|
+
continue
|
|
134
|
+
placement = (
|
|
135
|
+
PLACEMENT_MOVE if args.move else PLACEMENT_COPY if args.copy else PLACEMENT_SYMLINK
|
|
136
|
+
)
|
|
137
|
+
result = place_file(path, verdict.category, target_root, dry_run=args.dry_run, placement=placement)
|
|
138
|
+
action = ("would " if args.dry_run else "") + {
|
|
139
|
+
PLACEMENT_MOVE: "move",
|
|
140
|
+
PLACEMENT_COPY: "copy",
|
|
141
|
+
PLACEMENT_SYMLINK: "symlink",
|
|
142
|
+
}[placement]
|
|
143
|
+
print(f"{verdict.category} (conf {verdict.confidence:.2f}) -> {action} to {result.destination}")
|
|
144
|
+
|
|
145
|
+
return 1 if failures == len(files) else 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
if __name__ == "__main__":
|
|
149
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Configuration for watfile: API key and model resolution.
|
|
2
|
+
|
|
3
|
+
Resolution order (first wins):
|
|
4
|
+
1. TYPESAFE_API_KEY environment variable
|
|
5
|
+
2. .env file in the current directory (gitignored)
|
|
6
|
+
3. ~/.config/watfile/config.toml (XDG config home; $WATFILE_CONFIG overrides)
|
|
7
|
+
keys: api_key, base_url, model
|
|
8
|
+
|
|
9
|
+
Uses stdlib tomllib only (Python 3.11+), no extra dependency.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import tomllib
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
#: env var the TypeSafe SDK itself reads; used as source and final sink.
|
|
21
|
+
API_KEY_ENV = "TYPESAFE_API_KEY"
|
|
22
|
+
|
|
23
|
+
#: project-local env file, expected gitignored.
|
|
24
|
+
DOTENV_FILE = ".env"
|
|
25
|
+
|
|
26
|
+
#: config file name inside the config dir.
|
|
27
|
+
CONFIG_FILENAME = "config.toml"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def default_config_path() -> Path:
|
|
31
|
+
"""XDG-style config path: $XDG_CONFIG_HOME/watfile/config.toml (default ~/.config/...)."""
|
|
32
|
+
base = os.environ.get("XDG_CONFIG_HOME")
|
|
33
|
+
root = Path(base) if base else Path.home() / ".config"
|
|
34
|
+
return root / "watfile" / CONFIG_FILENAME
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_toml(path: Path) -> dict:
|
|
38
|
+
try:
|
|
39
|
+
with open(path, "rb") as fh:
|
|
40
|
+
return tomllib.load(fh)
|
|
41
|
+
except FileNotFoundError:
|
|
42
|
+
return {}
|
|
43
|
+
except tomllib.TOMLDecodeError as exc:
|
|
44
|
+
print(f"warning: ignoring invalid config {path}: {exc}", file=sys.stderr)
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Config:
|
|
50
|
+
api_key: str | None = None
|
|
51
|
+
base_url: str | None = None
|
|
52
|
+
model: str | None = None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
#: TOML config uses friendly field names (api_key/base_url/model);
|
|
56
|
+
#: .env and environment use the SDK's variable names.
|
|
57
|
+
_TOML_TO_ENV = {
|
|
58
|
+
"api_key": API_KEY_ENV,
|
|
59
|
+
"base_url": "TYPESAFE_BASE_URL",
|
|
60
|
+
"model": "TYPESAFE_DEFAULT_MODEL",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def load_config() -> Config:
|
|
65
|
+
"""Merge TOML config, ./.env, and environment into a Config (env wins)."""
|
|
66
|
+
values: dict[str, str] = {}
|
|
67
|
+
|
|
68
|
+
config_path = Path(os.environ.get("WATFILE_CONFIG") or default_config_path())
|
|
69
|
+
for key, value in _load_toml(config_path).items():
|
|
70
|
+
if isinstance(value, str) and key in _TOML_TO_ENV:
|
|
71
|
+
values[_TOML_TO_ENV[key]] = value
|
|
72
|
+
|
|
73
|
+
dotenv_path = Path(DOTENV_FILE)
|
|
74
|
+
if dotenv_path.is_file():
|
|
75
|
+
for line in dotenv_path.read_text().splitlines():
|
|
76
|
+
line = line.strip()
|
|
77
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
78
|
+
continue
|
|
79
|
+
key, _, value = line.partition("=")
|
|
80
|
+
# .env overrides TOML (project-local beats user-global), but not real env
|
|
81
|
+
if key.strip() not in os.environ:
|
|
82
|
+
values[key.strip()] = value.strip().strip("'\"")
|
|
83
|
+
|
|
84
|
+
# real environment always wins
|
|
85
|
+
for key in (API_KEY_ENV, "TYPESAFE_BASE_URL", "TYPESAFE_DEFAULT_MODEL"):
|
|
86
|
+
if os.environ.get(key):
|
|
87
|
+
values[key] = os.environ[key]
|
|
88
|
+
|
|
89
|
+
return Config(
|
|
90
|
+
api_key=values.get(API_KEY_ENV),
|
|
91
|
+
base_url=values.get("TYPESAFE_BASE_URL"),
|
|
92
|
+
model=values.get("TYPESAFE_DEFAULT_MODEL"),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def apply_config(config: Config) -> None:
|
|
97
|
+
"""Export resolved values into the environment so the typesafe-sdk picks them up."""
|
|
98
|
+
if config.api_key:
|
|
99
|
+
os.environ[API_KEY_ENV] = config.api_key
|
|
100
|
+
if config.base_url:
|
|
101
|
+
os.environ["TYPESAFE_BASE_URL"] = config.base_url
|
|
102
|
+
if config.model:
|
|
103
|
+
os.environ["TYPESAFE_DEFAULT_MODEL"] = config.model
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def api_key_available() -> bool:
|
|
107
|
+
"""True if a key is resolvable from env, ./.env, or the config file."""
|
|
108
|
+
return load_config().api_key is not None
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Text extraction from files for classification."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from liteparse import LiteParse
|
|
6
|
+
|
|
7
|
+
#: Extensions read directly as text.
|
|
8
|
+
TEXT_EXTENSIONS = {".txt", ".md", ".markdown", ".rst", ".log", ".csv", ".json"}
|
|
9
|
+
|
|
10
|
+
#: Extensions parsed via liteparse.
|
|
11
|
+
PARSED_EXTENSIONS = {".pdf"}
|
|
12
|
+
|
|
13
|
+
#: Cap extracted text sent to the classifier (chars). Jev works on decision-relevant
|
|
14
|
+
#: context; whole documents are unnecessary and slow.
|
|
15
|
+
MAX_TEXT_CHARS = 8_000
|
|
16
|
+
|
|
17
|
+
#: Only parse the first pages of PDFs — title/abstract carry the classification signal,
|
|
18
|
+
#: and parsing hundreds of pages wastes seconds per file.
|
|
19
|
+
MAX_PDF_PAGES = 2
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class UnsupportedFileTypeError(ValueError):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def extract_text(path: Path) -> str:
|
|
27
|
+
"""Return text content of *path*, truncated to MAX_TEXT_CHARS.
|
|
28
|
+
|
|
29
|
+
Plain-text formats are read directly; PDFs go through liteparse.
|
|
30
|
+
"""
|
|
31
|
+
ext = path.suffix.lower()
|
|
32
|
+
if ext in TEXT_EXTENSIONS:
|
|
33
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
34
|
+
elif ext in PARSED_EXTENSIONS:
|
|
35
|
+
# OCR off: classification only needs embedded text, and OCR adds 1.5-7s/page.
|
|
36
|
+
# Scanned-image PDFs will come back empty and are skipped by the caller.
|
|
37
|
+
result = LiteParse(ocr_enabled=False, max_pages=MAX_PDF_PAGES, quiet=True).parse(path)
|
|
38
|
+
text = result.text
|
|
39
|
+
else:
|
|
40
|
+
raise UnsupportedFileTypeError(f"unsupported extension: {ext or '<none>'} ({path.name})")
|
|
41
|
+
return text[:MAX_TEXT_CHARS]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Move classified files into category folders."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
_UNCATEGORIZED = "uncategorized"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class MoveResult:
|
|
13
|
+
source: Path
|
|
14
|
+
destination: Path
|
|
15
|
+
moved: bool # False in dry-run or on failure
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
#: How a classified file is placed into its category folder.
|
|
19
|
+
PLACEMENT_SYMLINK = "symlink" # default: leave original in place, link in category folder
|
|
20
|
+
PLACEMENT_MOVE = "move"
|
|
21
|
+
PLACEMENT_COPY = "copy"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def sanitize_category(name: str) -> str:
|
|
25
|
+
"""Make a category safe as a folder name."""
|
|
26
|
+
keep = "-_. ()"
|
|
27
|
+
cleaned = "".join(c if (c.isalnum() or c in keep) else "_" for c in name.strip())
|
|
28
|
+
return cleaned.strip(". ") or _UNCATEGORIZED
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _resolve_collision(destination: Path) -> Path:
|
|
32
|
+
"""Return a non-existing variant of *destination* by appending a counter.
|
|
33
|
+
|
|
34
|
+
Uses lexists so dangling symlinks also count as occupied.
|
|
35
|
+
"""
|
|
36
|
+
if not os.path.lexists(destination):
|
|
37
|
+
return destination
|
|
38
|
+
stem, suffix = destination.stem, destination.suffix
|
|
39
|
+
for i in range(1, 1000):
|
|
40
|
+
candidate = destination.with_name(f"{stem}_{i}{suffix}")
|
|
41
|
+
if not os.path.lexists(candidate):
|
|
42
|
+
return candidate
|
|
43
|
+
raise FileExistsError(f"could not find free name for {destination}")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def place_file(
|
|
47
|
+
source: Path,
|
|
48
|
+
category: str,
|
|
49
|
+
target_root: Path,
|
|
50
|
+
*,
|
|
51
|
+
dry_run: bool = False,
|
|
52
|
+
placement: str = PLACEMENT_SYMLINK,
|
|
53
|
+
) -> MoveResult:
|
|
54
|
+
"""Place *source* into target_root/<category>/ as symlink (default), move, or copy.
|
|
55
|
+
|
|
56
|
+
Symlinks point at the absolute original location; move leaves the original
|
|
57
|
+
gone; copy duplicates the file.
|
|
58
|
+
"""
|
|
59
|
+
category_dir = target_root / sanitize_category(category)
|
|
60
|
+
destination = _resolve_collision(category_dir / source.name)
|
|
61
|
+
if dry_run:
|
|
62
|
+
return MoveResult(source=source, destination=destination, moved=False)
|
|
63
|
+
category_dir.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
if placement == PLACEMENT_SYMLINK:
|
|
65
|
+
os.symlink(source.resolve(), destination)
|
|
66
|
+
elif placement == PLACEMENT_COPY:
|
|
67
|
+
shutil.copy2(source, destination)
|
|
68
|
+
elif placement == PLACEMENT_MOVE:
|
|
69
|
+
shutil.move(str(source), destination)
|
|
70
|
+
else:
|
|
71
|
+
raise ValueError(f"unknown placement: {placement}")
|
|
72
|
+
return MoveResult(source=source, destination=destination, moved=True)
|