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 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
@@ -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,3 @@
1
+ from .cli import main
2
+
3
+ __all__ = ["main"]
@@ -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)