hunch-jev 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.
- hunch_jev-0.1.0/.env.example +5 -0
- hunch_jev-0.1.0/.github/workflows/publish.yml +32 -0
- hunch_jev-0.1.0/.gitignore +17 -0
- hunch_jev-0.1.0/LICENSE +21 -0
- hunch_jev-0.1.0/PKG-INFO +140 -0
- hunch_jev-0.1.0/README.md +124 -0
- hunch_jev-0.1.0/examples/organize_downloads.py +218 -0
- hunch_jev-0.1.0/pyproject.toml +34 -0
- hunch_jev-0.1.0/src/hunch/__init__.py +41 -0
- hunch_jev-0.1.0/src/hunch/answer.py +82 -0
- hunch_jev-0.1.0/src/hunch/cache.py +126 -0
- hunch_jev-0.1.0/src/hunch/client.py +73 -0
- hunch_jev-0.1.0/src/hunch/exceptions.py +9 -0
- hunch_jev-0.1.0/src/hunch/llm.py +138 -0
- hunch_jev-0.1.0/src/hunch/over.py +73 -0
- hunch_jev-0.1.0/src/hunch/py.typed +0 -0
- hunch_jev-0.1.0/src/hunch/role.py +148 -0
- hunch_jev-0.1.0/src/hunch/session.py +471 -0
- hunch_jev-0.1.0/src/hunch/shapes.py +44 -0
- hunch_jev-0.1.0/src/hunch/subject.py +53 -0
- hunch_jev-0.1.0/src/hunch/usage.py +55 -0
- hunch_jev-0.1.0/tests/__init__.py +0 -0
- hunch_jev-0.1.0/tests/fakes.py +61 -0
- hunch_jev-0.1.0/tests/test_ask.py +103 -0
- hunch_jev-0.1.0/tests/test_cache.py +23 -0
- hunch_jev-0.1.0/tests/test_draft.py +95 -0
- hunch_jev-0.1.0/tests/test_over.py +75 -0
- hunch_jev-0.1.0/tests/test_rate.py +46 -0
- hunch_jev-0.1.0/tests/test_shapes.py +16 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
name: Publish
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- run: pip install -e ".[dev]"
|
|
17
|
+
- run: pytest
|
|
18
|
+
|
|
19
|
+
pypi:
|
|
20
|
+
needs: test
|
|
21
|
+
runs-on: ubuntu-latest
|
|
22
|
+
environment: pypi
|
|
23
|
+
permissions:
|
|
24
|
+
id-token: write
|
|
25
|
+
steps:
|
|
26
|
+
- uses: actions/checkout@v4
|
|
27
|
+
- uses: actions/setup-python@v5
|
|
28
|
+
with:
|
|
29
|
+
python-version: "3.12"
|
|
30
|
+
- run: pip install build
|
|
31
|
+
- run: python -m build
|
|
32
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
hunch_jev-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Steven Shoemaker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
hunch_jev-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: hunch-jev
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Ask Jev over columns of data: closed-set questions, cached and joined back.
|
|
5
|
+
Project-URL: Homepage, https://github.com/steven-shoemaker/hunch
|
|
6
|
+
Project-URL: Repository, https://github.com/steven-shoemaker/hunch
|
|
7
|
+
Author: Steven Shoemaker
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Requires-Dist: pandas>=2.0
|
|
12
|
+
Requires-Dist: typesafe-sdk>=0.5.7
|
|
13
|
+
Provides-Extra: dev
|
|
14
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# hunch
|
|
18
|
+
|
|
19
|
+
Jev as a column primitive.
|
|
20
|
+
|
|
21
|
+
[Jev](https://docs.typesafe.ai) is TypeSafe’s System One model: you send state and typed questions, and you get labels, scores, and yes/no probabilities back. **hunch** is a small Python layer on that API. Code owns the table and the side effects. Jev only judges. An optional LLM role may *propose* strings; it never closes.
|
|
22
|
+
|
|
23
|
+
```python
|
|
24
|
+
import hunch
|
|
25
|
+
from hunch import ask, over, rate
|
|
26
|
+
|
|
27
|
+
jev = hunch.connect() # TYPESAFE_API_KEY
|
|
28
|
+
|
|
29
|
+
@over(prospects, "JOB_TITLE")
|
|
30
|
+
def classify(title):
|
|
31
|
+
function = ask(
|
|
32
|
+
title,
|
|
33
|
+
"what business function does this title state?",
|
|
34
|
+
among=functions,
|
|
35
|
+
by=function_rules,
|
|
36
|
+
)
|
|
37
|
+
level = ask(
|
|
38
|
+
title,
|
|
39
|
+
"what level of rank does this title state?",
|
|
40
|
+
among=levels,
|
|
41
|
+
by=seniority_rules,
|
|
42
|
+
)
|
|
43
|
+
seniority = level.on(
|
|
44
|
+
sure=level.top,
|
|
45
|
+
torn=lambda: ask(
|
|
46
|
+
title,
|
|
47
|
+
"which of these two fits better?",
|
|
48
|
+
among=level.top2,
|
|
49
|
+
by=seniority_rules,
|
|
50
|
+
).top,
|
|
51
|
+
lost=lambda: (
|
|
52
|
+
"Individual Contributor"
|
|
53
|
+
if title.feels("a rank word governing a product, program, or account rather than people")
|
|
54
|
+
else "review"
|
|
55
|
+
),
|
|
56
|
+
)
|
|
57
|
+
keep = rate(
|
|
58
|
+
title,
|
|
59
|
+
"How important is this account?",
|
|
60
|
+
["disposable", "nice to have", "should keep", "must keep"],
|
|
61
|
+
)
|
|
62
|
+
return {
|
|
63
|
+
"function": function.top,
|
|
64
|
+
"function_shape": function.shape,
|
|
65
|
+
"seniority": seniority,
|
|
66
|
+
"seniority_shape": level.shape,
|
|
67
|
+
"keep": keep.level,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
results = classify.run(jev)
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Independent `ask` / `rate` / `feels` calls on the same value go in **one** Jev request. `@over` classifies each distinct value once, caches it, and left-joins onto the frame.
|
|
74
|
+
|
|
75
|
+
## Install
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
pip install hunch-jev
|
|
79
|
+
# or from a clone:
|
|
80
|
+
pip install -e ".[dev]"
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
Requires Python 3.10+. Set `TYPESAFE_API_KEY`. Do not put keys in source.
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
pytest
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Verbs
|
|
90
|
+
|
|
91
|
+
| Call | Jev primitive | You get |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `ask(state, question, among=..., by=...)` | Choice | `.top`, `.top2`, `.p`, `.confidence`, `.shape` |
|
|
94
|
+
| `rate(state, question, levels)` | Score | `.score`, `.level`, `.shape` |
|
|
95
|
+
| `value.feels("...")` | Noul | truthy when P(yes) ≥ 0.5 |
|
|
96
|
+
|
|
97
|
+
`.shape` is **your** policy on the distribution, not a Jev field:
|
|
98
|
+
|
|
99
|
+
| Shape | Meaning |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `sure` | One option dominates |
|
|
102
|
+
| `torn` | Two options are close |
|
|
103
|
+
| `lost` | Flat or weak evidence |
|
|
104
|
+
|
|
105
|
+
Cutoffs live on `ShapePolicy`. Confidence is how peaked the distribution is, not whether the label is true.
|
|
106
|
+
|
|
107
|
+
`connect(cache="~/.cache/hunch")` persists answers on disk. `jev.usage` reports calls, cache hits, tokens, and the model name.
|
|
108
|
+
|
|
109
|
+
## LLM roles
|
|
110
|
+
|
|
111
|
+
A role may only propose text or a list of labels. `ask` still decides.
|
|
112
|
+
|
|
113
|
+
```python
|
|
114
|
+
from hunch import ask, draft, openrouter, role
|
|
115
|
+
|
|
116
|
+
jev = hunch.connect(llm=openrouter()) # OPENROUTER_API_KEY
|
|
117
|
+
taxonomist = role(
|
|
118
|
+
"Propose 8–16 kebab-case folder names. Include junk. No review pile.",
|
|
119
|
+
emit=list[str],
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
with jev.session():
|
|
123
|
+
taxonomy = draft(listing, taxonomist).labels
|
|
124
|
+
|
|
125
|
+
folder = ask(name, "which folder?", among=taxonomy)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`hunch.openai`, `hunch.cerebras`, and `hunch.openrouter` are OpenAI-compatible adapters. `via=` on a role overrides `llm=` on `connect()`. A role stops after `max_loops` (default 5) in one session.
|
|
129
|
+
|
|
130
|
+
## Example
|
|
131
|
+
|
|
132
|
+
[`examples/organize_downloads.py`](examples/organize_downloads.py) files a Downloads folder: one LLM taxonomy, then Jev assigns each loose file, then the script moves. Destination folders are skipped on later runs. `--dry-run` prints the plan.
|
|
133
|
+
|
|
134
|
+
## What this is not
|
|
135
|
+
|
|
136
|
+
Jev does not invent labels. `among=` is the whole set of allowed answers. Roles invent candidates; they have no tools and do not move files. Open-ended writing and multi-step agents are out of scope.
|
|
137
|
+
|
|
138
|
+
## License
|
|
139
|
+
|
|
140
|
+
MIT. Jev and TypeSafe are [typesafe.ai](https://typesafe.ai); this library is not affiliated.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# hunch
|
|
2
|
+
|
|
3
|
+
Jev as a column primitive.
|
|
4
|
+
|
|
5
|
+
[Jev](https://docs.typesafe.ai) is TypeSafe’s System One model: you send state and typed questions, and you get labels, scores, and yes/no probabilities back. **hunch** is a small Python layer on that API. Code owns the table and the side effects. Jev only judges. An optional LLM role may *propose* strings; it never closes.
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
import hunch
|
|
9
|
+
from hunch import ask, over, rate
|
|
10
|
+
|
|
11
|
+
jev = hunch.connect() # TYPESAFE_API_KEY
|
|
12
|
+
|
|
13
|
+
@over(prospects, "JOB_TITLE")
|
|
14
|
+
def classify(title):
|
|
15
|
+
function = ask(
|
|
16
|
+
title,
|
|
17
|
+
"what business function does this title state?",
|
|
18
|
+
among=functions,
|
|
19
|
+
by=function_rules,
|
|
20
|
+
)
|
|
21
|
+
level = ask(
|
|
22
|
+
title,
|
|
23
|
+
"what level of rank does this title state?",
|
|
24
|
+
among=levels,
|
|
25
|
+
by=seniority_rules,
|
|
26
|
+
)
|
|
27
|
+
seniority = level.on(
|
|
28
|
+
sure=level.top,
|
|
29
|
+
torn=lambda: ask(
|
|
30
|
+
title,
|
|
31
|
+
"which of these two fits better?",
|
|
32
|
+
among=level.top2,
|
|
33
|
+
by=seniority_rules,
|
|
34
|
+
).top,
|
|
35
|
+
lost=lambda: (
|
|
36
|
+
"Individual Contributor"
|
|
37
|
+
if title.feels("a rank word governing a product, program, or account rather than people")
|
|
38
|
+
else "review"
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
keep = rate(
|
|
42
|
+
title,
|
|
43
|
+
"How important is this account?",
|
|
44
|
+
["disposable", "nice to have", "should keep", "must keep"],
|
|
45
|
+
)
|
|
46
|
+
return {
|
|
47
|
+
"function": function.top,
|
|
48
|
+
"function_shape": function.shape,
|
|
49
|
+
"seniority": seniority,
|
|
50
|
+
"seniority_shape": level.shape,
|
|
51
|
+
"keep": keep.level,
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
results = classify.run(jev)
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Independent `ask` / `rate` / `feels` calls on the same value go in **one** Jev request. `@over` classifies each distinct value once, caches it, and left-joins onto the frame.
|
|
58
|
+
|
|
59
|
+
## Install
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
pip install hunch-jev
|
|
63
|
+
# or from a clone:
|
|
64
|
+
pip install -e ".[dev]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Requires Python 3.10+. Set `TYPESAFE_API_KEY`. Do not put keys in source.
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pytest
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Verbs
|
|
74
|
+
|
|
75
|
+
| Call | Jev primitive | You get |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `ask(state, question, among=..., by=...)` | Choice | `.top`, `.top2`, `.p`, `.confidence`, `.shape` |
|
|
78
|
+
| `rate(state, question, levels)` | Score | `.score`, `.level`, `.shape` |
|
|
79
|
+
| `value.feels("...")` | Noul | truthy when P(yes) ≥ 0.5 |
|
|
80
|
+
|
|
81
|
+
`.shape` is **your** policy on the distribution, not a Jev field:
|
|
82
|
+
|
|
83
|
+
| Shape | Meaning |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `sure` | One option dominates |
|
|
86
|
+
| `torn` | Two options are close |
|
|
87
|
+
| `lost` | Flat or weak evidence |
|
|
88
|
+
|
|
89
|
+
Cutoffs live on `ShapePolicy`. Confidence is how peaked the distribution is, not whether the label is true.
|
|
90
|
+
|
|
91
|
+
`connect(cache="~/.cache/hunch")` persists answers on disk. `jev.usage` reports calls, cache hits, tokens, and the model name.
|
|
92
|
+
|
|
93
|
+
## LLM roles
|
|
94
|
+
|
|
95
|
+
A role may only propose text or a list of labels. `ask` still decides.
|
|
96
|
+
|
|
97
|
+
```python
|
|
98
|
+
from hunch import ask, draft, openrouter, role
|
|
99
|
+
|
|
100
|
+
jev = hunch.connect(llm=openrouter()) # OPENROUTER_API_KEY
|
|
101
|
+
taxonomist = role(
|
|
102
|
+
"Propose 8–16 kebab-case folder names. Include junk. No review pile.",
|
|
103
|
+
emit=list[str],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
with jev.session():
|
|
107
|
+
taxonomy = draft(listing, taxonomist).labels
|
|
108
|
+
|
|
109
|
+
folder = ask(name, "which folder?", among=taxonomy)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
`hunch.openai`, `hunch.cerebras`, and `hunch.openrouter` are OpenAI-compatible adapters. `via=` on a role overrides `llm=` on `connect()`. A role stops after `max_loops` (default 5) in one session.
|
|
113
|
+
|
|
114
|
+
## Example
|
|
115
|
+
|
|
116
|
+
[`examples/organize_downloads.py`](examples/organize_downloads.py) files a Downloads folder: one LLM taxonomy, then Jev assigns each loose file, then the script moves. Destination folders are skipped on later runs. `--dry-run` prints the plan.
|
|
117
|
+
|
|
118
|
+
## What this is not
|
|
119
|
+
|
|
120
|
+
Jev does not invent labels. `among=` is the whole set of allowed answers. Roles invent candidates; they have no tools and do not move files. Open-ended writing and multi-step agents are out of scope.
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT. Jev and TypeSafe are [typesafe.ai](https://typesafe.ai); this library is not affiliated.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Sort the Downloads folder: an LLM proposes folders, Jev assigns, this script moves."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pandas as pd
|
|
13
|
+
|
|
14
|
+
from hunch import ask, connect, draft, openrouter, over, role
|
|
15
|
+
|
|
16
|
+
SKIP_NAMES = {".DS_Store", ".localized", "$RECYCLE.BIN"}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def load_env(path: Path) -> None:
|
|
20
|
+
if not path.is_file():
|
|
21
|
+
return
|
|
22
|
+
for line in path.read_text().splitlines():
|
|
23
|
+
stripped = line.strip()
|
|
24
|
+
if not stripped or stripped.startswith("#") or "=" not in stripped:
|
|
25
|
+
continue
|
|
26
|
+
key, value = stripped.split("=", 1)
|
|
27
|
+
os.environ.setdefault(key.strip(), value.strip())
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def list_downloads(root: Path) -> pd.DataFrame:
|
|
31
|
+
rows: list[dict[str, object]] = []
|
|
32
|
+
for path in root.iterdir():
|
|
33
|
+
if path.name.startswith(".") or path.name in SKIP_NAMES:
|
|
34
|
+
continue
|
|
35
|
+
try:
|
|
36
|
+
info = path.stat()
|
|
37
|
+
except OSError:
|
|
38
|
+
continue
|
|
39
|
+
rows.append(
|
|
40
|
+
{
|
|
41
|
+
"name": path.name,
|
|
42
|
+
"kind": "dir" if path.is_dir() else "file",
|
|
43
|
+
"ext": "" if path.is_dir() else path.suffix.lower(),
|
|
44
|
+
"bytes": info.st_size,
|
|
45
|
+
"modified": datetime.fromtimestamp(info.st_mtime, tz=timezone.utc).isoformat(),
|
|
46
|
+
"path": str(path),
|
|
47
|
+
}
|
|
48
|
+
)
|
|
49
|
+
return pd.DataFrame(rows).sort_values("modified", ascending=False).reset_index(drop=True)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def unique_dest(folder: Path, name: str) -> Path:
|
|
53
|
+
dest = folder / name
|
|
54
|
+
if not dest.exists():
|
|
55
|
+
return dest
|
|
56
|
+
stem = Path(name).stem
|
|
57
|
+
suffix = Path(name).suffix
|
|
58
|
+
index = 1
|
|
59
|
+
while True:
|
|
60
|
+
candidate = folder / f"{stem} ({index}){suffix}"
|
|
61
|
+
if not candidate.exists():
|
|
62
|
+
return candidate
|
|
63
|
+
index += 1
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def move_item(source: Path, dest: Path) -> Path:
|
|
67
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
68
|
+
shutil.move(str(source), str(dest))
|
|
69
|
+
return dest
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def main() -> None:
|
|
73
|
+
parser = argparse.ArgumentParser(description="Sort Downloads into Jev-chosen folders.")
|
|
74
|
+
parser.add_argument("--root", type=Path, default=Path.home() / "Downloads")
|
|
75
|
+
parser.add_argument("--limit", type=int, default=0, help="Newest items only. 0 = all.")
|
|
76
|
+
parser.add_argument("--workers", type=int, default=8)
|
|
77
|
+
parser.add_argument("--dry-run", action="store_true")
|
|
78
|
+
parser.add_argument("--refresh-taxonomy", action="store_true")
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--cache",
|
|
81
|
+
type=Path,
|
|
82
|
+
default=Path.home() / ".cache" / "hunch" / "downloads",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument("--env", type=Path, default=Path(__file__).resolve().parents[1] / ".env")
|
|
85
|
+
args = parser.parse_args()
|
|
86
|
+
|
|
87
|
+
load_env(args.env)
|
|
88
|
+
items = list_downloads(args.root)
|
|
89
|
+
if items.empty:
|
|
90
|
+
raise SystemExit(f"No visible items in {args.root}")
|
|
91
|
+
|
|
92
|
+
listing = [
|
|
93
|
+
f"{row.kind}\t{row.ext}\t{row.name}"
|
|
94
|
+
for row in items.head(200).itertuples(index=False)
|
|
95
|
+
]
|
|
96
|
+
jev = connect(llm=openrouter(model="z-ai/glm-5.3-flash"), cache=args.cache)
|
|
97
|
+
taxonomist = role(
|
|
98
|
+
"Propose 8–16 kebab-case folder names that cover this Downloads pile. "
|
|
99
|
+
"Group by meaning (work docs, personal paperwork, books, design assets, "
|
|
100
|
+
"installers, archives, media, project folders), not by file extension alone. "
|
|
101
|
+
"Include junk for disposable leftovers. Do not include review, other, or misc — "
|
|
102
|
+
"every item must have a real home. No duplicates.",
|
|
103
|
+
emit=list[str],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
print(f"Listed {len(items)} top-level items in {args.root}", flush=True)
|
|
107
|
+
saved = None if args.refresh_taxonomy else _read_taxonomy(args.cache)
|
|
108
|
+
if saved:
|
|
109
|
+
taxonomy = saved
|
|
110
|
+
print("Using saved taxonomy:", ", ".join(taxonomy), flush=True)
|
|
111
|
+
else:
|
|
112
|
+
print("Asking OpenRouter for a taxonomy…", flush=True)
|
|
113
|
+
with jev.session():
|
|
114
|
+
taxonomy = draft(listing, taxonomist).labels
|
|
115
|
+
taxonomy = [label for label in taxonomy if label not in {"review", "other", "misc", "unsorted"}]
|
|
116
|
+
if "junk" not in taxonomy:
|
|
117
|
+
taxonomy.append("junk")
|
|
118
|
+
_write_taxonomy(args.cache, taxonomy)
|
|
119
|
+
print("Taxonomy:", ", ".join(taxonomy), flush=True)
|
|
120
|
+
|
|
121
|
+
dest_names = set(taxonomy)
|
|
122
|
+
movable = items[~items["name"].isin(dest_names) | (items["kind"] != "dir")].copy()
|
|
123
|
+
sample = movable if args.limit <= 0 else movable.head(args.limit)
|
|
124
|
+
if sample.empty:
|
|
125
|
+
print("Nothing new to file. Destination folders were left alone.")
|
|
126
|
+
print(f"Jev usage: {jev.usage.calls} calls, {jev.usage.hits} cache hits.")
|
|
127
|
+
return
|
|
128
|
+
lookup = {row["name"]: row for row in sample.to_dict(orient="records")}
|
|
129
|
+
print(f"Classifying {len(sample)} new items with Jev ({args.workers} workers)…", flush=True)
|
|
130
|
+
|
|
131
|
+
@over(sample, "name")
|
|
132
|
+
def classify(name):
|
|
133
|
+
row = lookup[str(name)]
|
|
134
|
+
state = {
|
|
135
|
+
"name": row["name"],
|
|
136
|
+
"kind": row["kind"],
|
|
137
|
+
"ext": row["ext"],
|
|
138
|
+
"bytes": row["bytes"],
|
|
139
|
+
"modified": row["modified"],
|
|
140
|
+
}
|
|
141
|
+
folder = ask(
|
|
142
|
+
state,
|
|
143
|
+
"Which folder should this Downloads item go in?",
|
|
144
|
+
among=taxonomy,
|
|
145
|
+
by=(
|
|
146
|
+
"Pick the best fitting folder even if the name is thin. "
|
|
147
|
+
"Camera-roll stills (IMG_, DSC_, screenshots) go in a photos or screenshots folder. "
|
|
148
|
+
"App disk images and installers go in installers. "
|
|
149
|
+
"Named project folders and their zips go in side-projects or dev-projects. "
|
|
150
|
+
"Use junk only for obvious leftovers or disposable exports. "
|
|
151
|
+
"Never pick a review, other, or unsorted pile."
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
return {
|
|
155
|
+
"folder": folder.top,
|
|
156
|
+
"shape": folder.shape,
|
|
157
|
+
"confidence": round(folder.confidence, 3),
|
|
158
|
+
"p": round(folder.p, 3),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
results = classify.run(
|
|
162
|
+
jev,
|
|
163
|
+
max_workers=args.workers,
|
|
164
|
+
on_item=lambda done, total, _value: print(f" classified {done}/{total}", flush=True)
|
|
165
|
+
if done == total or done % 25 == 0
|
|
166
|
+
else None,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
moved = 0
|
|
170
|
+
skipped = 0
|
|
171
|
+
for row in results.itertuples(index=False):
|
|
172
|
+
folder = getattr(row, "folder", None)
|
|
173
|
+
if not isinstance(folder, str) or not folder:
|
|
174
|
+
skipped += 1
|
|
175
|
+
continue
|
|
176
|
+
source = Path(row.path)
|
|
177
|
+
if not source.exists():
|
|
178
|
+
skipped += 1
|
|
179
|
+
continue
|
|
180
|
+
dest_dir = args.root / folder
|
|
181
|
+
if source.resolve() == dest_dir.resolve():
|
|
182
|
+
skipped += 1
|
|
183
|
+
continue
|
|
184
|
+
dest = unique_dest(dest_dir, source.name)
|
|
185
|
+
print(f"{source.name} -> {folder}/{dest.name}", flush=True)
|
|
186
|
+
if not args.dry_run:
|
|
187
|
+
move_item(source, dest)
|
|
188
|
+
moved += 1
|
|
189
|
+
|
|
190
|
+
print()
|
|
191
|
+
print(results.groupby("folder").size().sort_values(ascending=False).to_string())
|
|
192
|
+
print()
|
|
193
|
+
action = "Would move" if args.dry_run else "Moved"
|
|
194
|
+
used = jev.usage
|
|
195
|
+
print(f"{action} {moved} items. Skipped {skipped}.")
|
|
196
|
+
print(
|
|
197
|
+
f"Jev {used.model or 'jev'}: {used.calls} calls, {used.hits} cache hits, "
|
|
198
|
+
f"{used.input_tokens} in / {used.output_tokens} out tokens."
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _read_taxonomy(cache: Path) -> list[str] | None:
|
|
203
|
+
path = cache / "taxonomy.json"
|
|
204
|
+
if not path.is_file():
|
|
205
|
+
return None
|
|
206
|
+
labels = json.loads(path.read_text())
|
|
207
|
+
if not isinstance(labels, list) or not all(isinstance(item, str) for item in labels):
|
|
208
|
+
return None
|
|
209
|
+
return labels
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _write_taxonomy(cache: Path, labels: list[str]) -> None:
|
|
213
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
(cache / "taxonomy.json").write_text(json.dumps(labels, indent=2) + "\n")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hunch-jev"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Ask Jev over columns of data: closed-set questions, cached and joined back."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
license = { text = "MIT" }
|
|
8
|
+
authors = [{ name = "Steven Shoemaker" }]
|
|
9
|
+
dependencies = [
|
|
10
|
+
"pandas>=2.0",
|
|
11
|
+
"typesafe-sdk>=0.5.7",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Homepage = "https://github.com/steven-shoemaker/hunch"
|
|
16
|
+
Repository = "https://github.com/steven-shoemaker/hunch"
|
|
17
|
+
|
|
18
|
+
[project.optional-dependencies]
|
|
19
|
+
dev = ["pytest>=8.0"]
|
|
20
|
+
|
|
21
|
+
[build-system]
|
|
22
|
+
requires = ["hatchling"]
|
|
23
|
+
build-backend = "hatchling.build"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/hunch"]
|
|
27
|
+
|
|
28
|
+
[tool.pytest.ini_options]
|
|
29
|
+
testpaths = ["tests"]
|
|
30
|
+
pythonpath = ["src", "."]
|
|
31
|
+
|
|
32
|
+
[tool.ruff]
|
|
33
|
+
target-version = "py310"
|
|
34
|
+
line-length = 100
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""hunch — Jev as a column primitive, with optional LLM roles that only propose."""
|
|
2
|
+
|
|
3
|
+
from hunch.answer import Answer, Feeling, Rating
|
|
4
|
+
from hunch.client import Hunch, connect
|
|
5
|
+
from hunch.exceptions import HunchError, NoSessionError
|
|
6
|
+
from hunch.llm import LanguageModel, OpenAICompat, cerebras, openai, openrouter
|
|
7
|
+
from hunch.over import OverJob, over
|
|
8
|
+
from hunch.role import Draft, Role, draft, role
|
|
9
|
+
from hunch.session import ask, rate
|
|
10
|
+
from hunch.shapes import Shape, ShapePolicy
|
|
11
|
+
from hunch.subject import Subject
|
|
12
|
+
from hunch.usage import Usage
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"Answer",
|
|
16
|
+
"Draft",
|
|
17
|
+
"Feeling",
|
|
18
|
+
"Hunch",
|
|
19
|
+
"HunchError",
|
|
20
|
+
"LanguageModel",
|
|
21
|
+
"NoSessionError",
|
|
22
|
+
"OpenAICompat",
|
|
23
|
+
"OverJob",
|
|
24
|
+
"Rating",
|
|
25
|
+
"Role",
|
|
26
|
+
"Shape",
|
|
27
|
+
"ShapePolicy",
|
|
28
|
+
"Subject",
|
|
29
|
+
"Usage",
|
|
30
|
+
"ask",
|
|
31
|
+
"cerebras",
|
|
32
|
+
"connect",
|
|
33
|
+
"draft",
|
|
34
|
+
"openai",
|
|
35
|
+
"openrouter",
|
|
36
|
+
"over",
|
|
37
|
+
"rate",
|
|
38
|
+
"role",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|