jev-grep 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.
- jev_grep-0.1.0/.github/workflows/publish.yml +22 -0
- jev_grep-0.1.0/.github/workflows/test.yml +13 -0
- jev_grep-0.1.0/.gitignore +7 -0
- jev_grep-0.1.0/LICENSE +21 -0
- jev_grep-0.1.0/PKG-INFO +185 -0
- jev_grep-0.1.0/README.md +168 -0
- jev_grep-0.1.0/bench/accuracy.py +193 -0
- jev_grep-0.1.0/bench/corpus.py +47 -0
- jev_grep-0.1.0/bench/phrasing.py +100 -0
- jev_grep-0.1.0/pyproject.toml +35 -0
- jev_grep-0.1.0/src/jgrep/__init__.py +3 -0
- jev_grep-0.1.0/src/jgrep/__main__.py +3 -0
- jev_grep-0.1.0/src/jgrep/cli.py +304 -0
- jev_grep-0.1.0/src/jgrep/core.py +244 -0
- jev_grep-0.1.0/tests/test_cli.py +241 -0
- jev_grep-0.1.0/uv.lock +229 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
# Push a tag like v0.1.0 to release. PyPI trusts this workflow directly (Trusted Publishing),
|
|
3
|
+
# so there is no API token to store, leak or rotate.
|
|
4
|
+
on:
|
|
5
|
+
push:
|
|
6
|
+
tags: ["v*"]
|
|
7
|
+
jobs:
|
|
8
|
+
pypi:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
environment: pypi
|
|
11
|
+
permissions:
|
|
12
|
+
id-token: write
|
|
13
|
+
contents: read
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
- uses: astral-sh/setup-uv@v5
|
|
17
|
+
- name: The tag must match the version in pyproject.toml
|
|
18
|
+
run: test "v$(grep -m1 '^version' pyproject.toml | cut -d'"' -f2)" = "$GITHUB_REF_NAME"
|
|
19
|
+
- run: uv sync
|
|
20
|
+
- run: uv run pytest -q
|
|
21
|
+
- run: uv build
|
|
22
|
+
- run: uv publish
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
name: test
|
|
2
|
+
on: [push, pull_request]
|
|
3
|
+
jobs:
|
|
4
|
+
test:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
strategy:
|
|
7
|
+
matrix:
|
|
8
|
+
python: ["3.10", "3.13"]
|
|
9
|
+
steps:
|
|
10
|
+
- uses: actions/checkout@v4
|
|
11
|
+
- uses: astral-sh/setup-uv@v5
|
|
12
|
+
- run: uv sync --python ${{ matrix.python }}
|
|
13
|
+
- run: uv run pytest -q
|
jev_grep-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Khaled Eltokhy
|
|
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.
|
jev_grep-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jev-grep
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: grep by meaning: filter lines with a plain-English description, judged by TypeSafe's Jev model
|
|
5
|
+
Project-URL: Repository, https://github.com/keltokhy/jgrep
|
|
6
|
+
Author: Khaled Eltokhy
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: cli,grep,jev,openrouter,semantic search,typesafe
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Topic :: Text Processing :: Filters
|
|
13
|
+
Classifier: Topic :: Utilities
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: httpx>=0.27
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# jgrep
|
|
19
|
+
|
|
20
|
+
grep, but the pattern is a description.
|
|
21
|
+
|
|
22
|
+
```console
|
|
23
|
+
$ tail -f app.log | jgrep "a user is getting frustrated"
|
|
24
|
+
user 12: this is the third time checkout has failed, I am done with this app
|
|
25
|
+
user 77: WHY does it log me out every five minutes??
|
|
26
|
+
|
|
27
|
+
$ jgrep -o "announces or releases a new AI model" titles.txt | sort -rn | head -3
|
|
28
|
+
0.980 PrismML Launches Bonsai 2 27B, Its Most Capable Model Yet
|
|
29
|
+
0.970 Alibaba Releases Qwen3.8-Omni-Flash
|
|
30
|
+
0.940 Google announces new experimental "CC" AI agent for families
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Each line becomes one yes/no question to [Jev](https://docs.typesafe.ai), TypeSafe's decision
|
|
34
|
+
model. Jev does not generate text. It returns a probability in about 200 ms for about a
|
|
35
|
+
thousandth of a cent, which is fast and cheap enough to sit in a pipe. jgrep reads lines as
|
|
36
|
+
they arrive, judges them concurrently and prints matches in input order, so it works on
|
|
37
|
+
`tail -f` as well as on files.
|
|
38
|
+
|
|
39
|
+
Measured on 994 Hacker News titles: 4.6 seconds and $0.012 for one description, and the same
|
|
40
|
+
time for three descriptions at once.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv tool install git+https://github.com/keltokhy/jgrep
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
jgrep needs a key for one of two APIs. With keys for both, it uses TypeSafe's.
|
|
49
|
+
|
|
50
|
+
| API | Key | Get one |
|
|
51
|
+
|---|---|---|
|
|
52
|
+
| TypeSafe | `TYPESAFE_API_KEY` | [console.typesafe.ai](https://console.typesafe.ai/settings/keys) |
|
|
53
|
+
| OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) |
|
|
54
|
+
|
|
55
|
+
Set the environment variable, or put the key in `~/.config/jev/typesafe.key` or
|
|
56
|
+
`~/.config/jev/openrouter.key`. Force a choice with `--api` or `JEV_API`.
|
|
57
|
+
|
|
58
|
+
## Use
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
jgrep "a complaint about noise" complaints.txt # lines that fit
|
|
62
|
+
jgrep -v "spam" inbox.txt # lines that do not
|
|
63
|
+
jgrep -c "asks a question" *.txt # counts per file
|
|
64
|
+
jgrep -p 0.9 "mentions a specific dollar amount" f.txt # only confident matches
|
|
65
|
+
jgrep -o -p 0 "the writer is losing sleep" f.txt | sort -rn # rank every line
|
|
66
|
+
jgrep -e "about economics" -e "about New York" f.txt # either; add --all for both
|
|
67
|
+
jgrep --para "describes an identification strategy" paper.txt
|
|
68
|
+
jgrep --whole "uses a bunching estimator" abstracts/*.txt # prints matching file names
|
|
69
|
+
jgrep -q "a stack trace" build.log && notify "build broke"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
| Option | Meaning |
|
|
73
|
+
|---|---|
|
|
74
|
+
| `-p P` | Match when the probability is at least P. Default 0.5. |
|
|
75
|
+
| `-o` | Put the probability in a first, tab-separated column. |
|
|
76
|
+
| `-v`, `-c`, `-n`, `-H`, `-m NUM`, `-q` | As in grep. |
|
|
77
|
+
| `-e DESC` | Another description. All of them go in one call per line. A line matches if any fits, or all with `--all`. |
|
|
78
|
+
| `--para`, `--whole` | Judge paragraphs or whole files in place of lines. |
|
|
79
|
+
| `--json` | One JSON object per match, with the probability. |
|
|
80
|
+
| `--unordered` | Print matches as answers arrive. |
|
|
81
|
+
| `-j N` | Calls in flight. Default 32. |
|
|
82
|
+
| `--budget DOLLARS` | Stop once this much is spent. Default 1.00, or `$JGREP_BUDGET`; 0 for no limit. |
|
|
83
|
+
| `--timeout SECONDS` | Give up on a line after this long, retries included. Default 15. |
|
|
84
|
+
| `--no-cache`, `--api`, `--model`, `--stats` | See `jgrep --help`. |
|
|
85
|
+
|
|
86
|
+
Exit status follows grep: 0 if anything matched, 1 if nothing did, 2 on error.
|
|
87
|
+
|
|
88
|
+
## Cost
|
|
89
|
+
|
|
90
|
+
A call bills roughly 270 tokens of fixed overhead plus the line and the description, so a
|
|
91
|
+
typical line costs about 300 tokens, or $0.0000126 at $0.042 per million. A million lines is
|
|
92
|
+
about $13. Blank lines, repeated lines and anything answered before are free: answers are
|
|
93
|
+
cached in `~/.cache/jev/answers.sqlite`, keyed on the exact model, line and description.
|
|
94
|
+
Extra `-e` descriptions add about 27 tokens each and no time.
|
|
95
|
+
|
|
96
|
+
jgrep stops at `--budget`, one dollar by default, so a stray `jgrep pattern huge.log` cannot
|
|
97
|
+
run up a bill. A dollar is about 80,000 lines. A stopped run loses nothing: rerun with a higher
|
|
98
|
+
budget and everything already judged comes from the cache. For a long-lived `tail -f` monitor,
|
|
99
|
+
set your own default once with `export JGREP_BUDGET=20`, or `0` for no limit. With `--stats`, or whenever stderr is a terminal, it prints what the run cost:
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
jgrep: 994 records, 33 matched; 994 calls, 0 cached; 292,839 tokens; $0.0123; 4.6s
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## How well does it work
|
|
106
|
+
|
|
107
|
+
Three benchmarks on public labeled text, run on 2026-09-18 with Jev 1.13 through OpenRouter.
|
|
108
|
+
Each one runs the installed `jgrep` command itself, uncached, at its default threshold of 0.5.
|
|
109
|
+
Reproduce them with `bench/accuracy.py`.
|
|
110
|
+
|
|
111
|
+
**Against a keyword grep.** The UCI SMS Spam Collection: 5,574 text messages, 747 of them spam.
|
|
112
|
+
|
|
113
|
+
| Filter | Precision | Recall | F1 | Time | Cost |
|
|
114
|
+
|---|---:|---:|---:|---:|---:|
|
|
115
|
+
| `jgrep "an unsolicited spam, scam or marketing text message"` | 0.87 | 0.95 | **0.91** | 27 s | $0.07 |
|
|
116
|
+
| the same with `-p 0.9` | 0.98 | 0.84 | 0.90 | | |
|
|
117
|
+
| `grep -iE "free\|win\|prize\|claim\|urgent\|cash\|txt\|call now\|..."` (17 terms) | 0.64 | 0.81 | 0.72 | 0.03 s | free |
|
|
118
|
+
|
|
119
|
+
The regular expression was written before looking at any results and is in the script.
|
|
120
|
+
|
|
121
|
+
**Against asking a chat model.** The do-it-yourself alternative is a loop that asks an LLM the
|
|
122
|
+
same yes/no question about each line. On 300 of those messages, 32 requests in flight, all
|
|
123
|
+
through OpenRouter:
|
|
124
|
+
|
|
125
|
+
| Judge | F1 | Wall time | Cost | Median latency |
|
|
126
|
+
|---|---:|---:|---:|---:|
|
|
127
|
+
| **jgrep (Jev 1.13)** | 0.90 | **2.7 s** | $0.0039 | about 210 ms |
|
|
128
|
+
| GPT Luna | 0.88 | 9.3 s | $0.0060 | 802 ms |
|
|
129
|
+
| GPT Terra | 0.92 | 10.8 s | $0.0571 | 988 ms |
|
|
130
|
+
| Qwen 3.7 Flash, thinking off | 0.78 | 8.4 s | $0.0007 | 789 ms |
|
|
131
|
+
|
|
132
|
+
jgrep finished three to four times sooner than any of them. Its accuracy sits between the two
|
|
133
|
+
GPT tiers; with 45 spam messages in the sample, those three F1 scores are within noise of each
|
|
134
|
+
other. It is not the cheapest per line: a small open model costs a sixth as much and is
|
|
135
|
+
clearly less accurate. Against the model that matched its accuracy, jgrep cost a fifteenth
|
|
136
|
+
as much.
|
|
137
|
+
|
|
138
|
+
**Several descriptions at once.** AG News test set, 7,600 articles, four descriptions
|
|
139
|
+
(`-e "news about sports" -e "news about business, markets or the economy" ...`) judged in one
|
|
140
|
+
call per article: 37 seconds and $0.13 for all four. Taking the most probable description as the
|
|
141
|
+
label gives 86.6% accuracy with no training. One-vs-rest F1 at 0.5 was 0.97 for sports, 0.82 for
|
|
142
|
+
science and technology, 0.82 for world affairs and 0.72 for business, which over-triggers
|
|
143
|
+
(precision 0.58) because so much technology news is also business news.
|
|
144
|
+
|
|
145
|
+
**Does the wording of a description matter?** `bench/phrasing.py` scores 30 hand-labeled lines
|
|
146
|
+
against five descriptions of different grammatical shapes, including a negation and a question.
|
|
147
|
+
Jev got all 150 right under each of four ways of wording the question; that set is easy on
|
|
148
|
+
purpose. Asking five descriptions in one call changed no decision and moved probabilities by
|
|
149
|
+
0.001 on average. Latency was flat at about 210 ms from 1 to 64 questions per call.
|
|
150
|
+
|
|
151
|
+
On borderline lines the probabilities land in between, which is what `-p` is for:
|
|
152
|
+
|
|
153
|
+
```
|
|
154
|
+
0.65 [a complaint about noise] The music from the church on Sunday mornings is lovely but it does start early.
|
|
155
|
+
0.46 [does not mention a landlord] The owner of the building never answers the phone.
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Things to know:
|
|
159
|
+
|
|
160
|
+
- These are a model's judgments. Check a sample before you rely on a filter.
|
|
161
|
+
- Jev answers the description you wrote, not the one you meant. TypeSafe
|
|
162
|
+
[documents](https://docs.typesafe.ai/model-jaggedness/jev-1.13) weak spots: counting,
|
|
163
|
+
comparing numbers or dates, double negatives, and long inputs full of irrelevant detail.
|
|
164
|
+
- Each line is judged alone. jgrep does not show Jev the lines around it.
|
|
165
|
+
- Jev is close to deterministic, not exactly so. Asking 150 questions three times without the
|
|
166
|
+
cache gave identical probabilities for 128; the rest moved by up to 0.03 and no decision
|
|
167
|
+
flipped. The cache makes reruns exact.
|
|
168
|
+
- The default model ID is an alias for the latest Jev. For results that must reproduce, pin
|
|
169
|
+
one with `--model` (for example `typesafe/jev-1.13` on OpenRouter).
|
|
170
|
+
- Text in the input can try to steer the answer. Do not use jgrep as a security boundary.
|
|
171
|
+
|
|
172
|
+
## Development
|
|
173
|
+
|
|
174
|
+
```bash
|
|
175
|
+
uv sync && uv run pytest # 23 tests against a fake API; no key, no network
|
|
176
|
+
uv run python bench/phrasing.py # live; costs about a cent
|
|
177
|
+
uv run python bench/accuracy.py prepare && uv run python bench/accuracy.py spam # also: news, llm
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`src/jgrep/core.py` is the client: two backends, retries inside a time budget, the cache,
|
|
181
|
+
in-flight deduplication and the cost meter. It is shared verbatim with
|
|
182
|
+
[jlink](https://github.com/keltokhy/jlink), which links records across datasets with the same
|
|
183
|
+
model.
|
|
184
|
+
|
|
185
|
+
MIT license.
|
jev_grep-0.1.0/README.md
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# jgrep
|
|
2
|
+
|
|
3
|
+
grep, but the pattern is a description.
|
|
4
|
+
|
|
5
|
+
```console
|
|
6
|
+
$ tail -f app.log | jgrep "a user is getting frustrated"
|
|
7
|
+
user 12: this is the third time checkout has failed, I am done with this app
|
|
8
|
+
user 77: WHY does it log me out every five minutes??
|
|
9
|
+
|
|
10
|
+
$ jgrep -o "announces or releases a new AI model" titles.txt | sort -rn | head -3
|
|
11
|
+
0.980 PrismML Launches Bonsai 2 27B, Its Most Capable Model Yet
|
|
12
|
+
0.970 Alibaba Releases Qwen3.8-Omni-Flash
|
|
13
|
+
0.940 Google announces new experimental "CC" AI agent for families
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Each line becomes one yes/no question to [Jev](https://docs.typesafe.ai), TypeSafe's decision
|
|
17
|
+
model. Jev does not generate text. It returns a probability in about 200 ms for about a
|
|
18
|
+
thousandth of a cent, which is fast and cheap enough to sit in a pipe. jgrep reads lines as
|
|
19
|
+
they arrive, judges them concurrently and prints matches in input order, so it works on
|
|
20
|
+
`tail -f` as well as on files.
|
|
21
|
+
|
|
22
|
+
Measured on 994 Hacker News titles: 4.6 seconds and $0.012 for one description, and the same
|
|
23
|
+
time for three descriptions at once.
|
|
24
|
+
|
|
25
|
+
## Install
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
uv tool install git+https://github.com/keltokhy/jgrep
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
jgrep needs a key for one of two APIs. With keys for both, it uses TypeSafe's.
|
|
32
|
+
|
|
33
|
+
| API | Key | Get one |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| TypeSafe | `TYPESAFE_API_KEY` | [console.typesafe.ai](https://console.typesafe.ai/settings/keys) |
|
|
36
|
+
| OpenRouter | `OPENROUTER_API_KEY` | [openrouter.ai/keys](https://openrouter.ai/keys) |
|
|
37
|
+
|
|
38
|
+
Set the environment variable, or put the key in `~/.config/jev/typesafe.key` or
|
|
39
|
+
`~/.config/jev/openrouter.key`. Force a choice with `--api` or `JEV_API`.
|
|
40
|
+
|
|
41
|
+
## Use
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
jgrep "a complaint about noise" complaints.txt # lines that fit
|
|
45
|
+
jgrep -v "spam" inbox.txt # lines that do not
|
|
46
|
+
jgrep -c "asks a question" *.txt # counts per file
|
|
47
|
+
jgrep -p 0.9 "mentions a specific dollar amount" f.txt # only confident matches
|
|
48
|
+
jgrep -o -p 0 "the writer is losing sleep" f.txt | sort -rn # rank every line
|
|
49
|
+
jgrep -e "about economics" -e "about New York" f.txt # either; add --all for both
|
|
50
|
+
jgrep --para "describes an identification strategy" paper.txt
|
|
51
|
+
jgrep --whole "uses a bunching estimator" abstracts/*.txt # prints matching file names
|
|
52
|
+
jgrep -q "a stack trace" build.log && notify "build broke"
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
| Option | Meaning |
|
|
56
|
+
|---|---|
|
|
57
|
+
| `-p P` | Match when the probability is at least P. Default 0.5. |
|
|
58
|
+
| `-o` | Put the probability in a first, tab-separated column. |
|
|
59
|
+
| `-v`, `-c`, `-n`, `-H`, `-m NUM`, `-q` | As in grep. |
|
|
60
|
+
| `-e DESC` | Another description. All of them go in one call per line. A line matches if any fits, or all with `--all`. |
|
|
61
|
+
| `--para`, `--whole` | Judge paragraphs or whole files in place of lines. |
|
|
62
|
+
| `--json` | One JSON object per match, with the probability. |
|
|
63
|
+
| `--unordered` | Print matches as answers arrive. |
|
|
64
|
+
| `-j N` | Calls in flight. Default 32. |
|
|
65
|
+
| `--budget DOLLARS` | Stop once this much is spent. Default 1.00, or `$JGREP_BUDGET`; 0 for no limit. |
|
|
66
|
+
| `--timeout SECONDS` | Give up on a line after this long, retries included. Default 15. |
|
|
67
|
+
| `--no-cache`, `--api`, `--model`, `--stats` | See `jgrep --help`. |
|
|
68
|
+
|
|
69
|
+
Exit status follows grep: 0 if anything matched, 1 if nothing did, 2 on error.
|
|
70
|
+
|
|
71
|
+
## Cost
|
|
72
|
+
|
|
73
|
+
A call bills roughly 270 tokens of fixed overhead plus the line and the description, so a
|
|
74
|
+
typical line costs about 300 tokens, or $0.0000126 at $0.042 per million. A million lines is
|
|
75
|
+
about $13. Blank lines, repeated lines and anything answered before are free: answers are
|
|
76
|
+
cached in `~/.cache/jev/answers.sqlite`, keyed on the exact model, line and description.
|
|
77
|
+
Extra `-e` descriptions add about 27 tokens each and no time.
|
|
78
|
+
|
|
79
|
+
jgrep stops at `--budget`, one dollar by default, so a stray `jgrep pattern huge.log` cannot
|
|
80
|
+
run up a bill. A dollar is about 80,000 lines. A stopped run loses nothing: rerun with a higher
|
|
81
|
+
budget and everything already judged comes from the cache. For a long-lived `tail -f` monitor,
|
|
82
|
+
set your own default once with `export JGREP_BUDGET=20`, or `0` for no limit. With `--stats`, or whenever stderr is a terminal, it prints what the run cost:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
jgrep: 994 records, 33 matched; 994 calls, 0 cached; 292,839 tokens; $0.0123; 4.6s
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## How well does it work
|
|
89
|
+
|
|
90
|
+
Three benchmarks on public labeled text, run on 2026-09-18 with Jev 1.13 through OpenRouter.
|
|
91
|
+
Each one runs the installed `jgrep` command itself, uncached, at its default threshold of 0.5.
|
|
92
|
+
Reproduce them with `bench/accuracy.py`.
|
|
93
|
+
|
|
94
|
+
**Against a keyword grep.** The UCI SMS Spam Collection: 5,574 text messages, 747 of them spam.
|
|
95
|
+
|
|
96
|
+
| Filter | Precision | Recall | F1 | Time | Cost |
|
|
97
|
+
|---|---:|---:|---:|---:|---:|
|
|
98
|
+
| `jgrep "an unsolicited spam, scam or marketing text message"` | 0.87 | 0.95 | **0.91** | 27 s | $0.07 |
|
|
99
|
+
| the same with `-p 0.9` | 0.98 | 0.84 | 0.90 | | |
|
|
100
|
+
| `grep -iE "free\|win\|prize\|claim\|urgent\|cash\|txt\|call now\|..."` (17 terms) | 0.64 | 0.81 | 0.72 | 0.03 s | free |
|
|
101
|
+
|
|
102
|
+
The regular expression was written before looking at any results and is in the script.
|
|
103
|
+
|
|
104
|
+
**Against asking a chat model.** The do-it-yourself alternative is a loop that asks an LLM the
|
|
105
|
+
same yes/no question about each line. On 300 of those messages, 32 requests in flight, all
|
|
106
|
+
through OpenRouter:
|
|
107
|
+
|
|
108
|
+
| Judge | F1 | Wall time | Cost | Median latency |
|
|
109
|
+
|---|---:|---:|---:|---:|
|
|
110
|
+
| **jgrep (Jev 1.13)** | 0.90 | **2.7 s** | $0.0039 | about 210 ms |
|
|
111
|
+
| GPT Luna | 0.88 | 9.3 s | $0.0060 | 802 ms |
|
|
112
|
+
| GPT Terra | 0.92 | 10.8 s | $0.0571 | 988 ms |
|
|
113
|
+
| Qwen 3.7 Flash, thinking off | 0.78 | 8.4 s | $0.0007 | 789 ms |
|
|
114
|
+
|
|
115
|
+
jgrep finished three to four times sooner than any of them. Its accuracy sits between the two
|
|
116
|
+
GPT tiers; with 45 spam messages in the sample, those three F1 scores are within noise of each
|
|
117
|
+
other. It is not the cheapest per line: a small open model costs a sixth as much and is
|
|
118
|
+
clearly less accurate. Against the model that matched its accuracy, jgrep cost a fifteenth
|
|
119
|
+
as much.
|
|
120
|
+
|
|
121
|
+
**Several descriptions at once.** AG News test set, 7,600 articles, four descriptions
|
|
122
|
+
(`-e "news about sports" -e "news about business, markets or the economy" ...`) judged in one
|
|
123
|
+
call per article: 37 seconds and $0.13 for all four. Taking the most probable description as the
|
|
124
|
+
label gives 86.6% accuracy with no training. One-vs-rest F1 at 0.5 was 0.97 for sports, 0.82 for
|
|
125
|
+
science and technology, 0.82 for world affairs and 0.72 for business, which over-triggers
|
|
126
|
+
(precision 0.58) because so much technology news is also business news.
|
|
127
|
+
|
|
128
|
+
**Does the wording of a description matter?** `bench/phrasing.py` scores 30 hand-labeled lines
|
|
129
|
+
against five descriptions of different grammatical shapes, including a negation and a question.
|
|
130
|
+
Jev got all 150 right under each of four ways of wording the question; that set is easy on
|
|
131
|
+
purpose. Asking five descriptions in one call changed no decision and moved probabilities by
|
|
132
|
+
0.001 on average. Latency was flat at about 210 ms from 1 to 64 questions per call.
|
|
133
|
+
|
|
134
|
+
On borderline lines the probabilities land in between, which is what `-p` is for:
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
0.65 [a complaint about noise] The music from the church on Sunday mornings is lovely but it does start early.
|
|
138
|
+
0.46 [does not mention a landlord] The owner of the building never answers the phone.
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Things to know:
|
|
142
|
+
|
|
143
|
+
- These are a model's judgments. Check a sample before you rely on a filter.
|
|
144
|
+
- Jev answers the description you wrote, not the one you meant. TypeSafe
|
|
145
|
+
[documents](https://docs.typesafe.ai/model-jaggedness/jev-1.13) weak spots: counting,
|
|
146
|
+
comparing numbers or dates, double negatives, and long inputs full of irrelevant detail.
|
|
147
|
+
- Each line is judged alone. jgrep does not show Jev the lines around it.
|
|
148
|
+
- Jev is close to deterministic, not exactly so. Asking 150 questions three times without the
|
|
149
|
+
cache gave identical probabilities for 128; the rest moved by up to 0.03 and no decision
|
|
150
|
+
flipped. The cache makes reruns exact.
|
|
151
|
+
- The default model ID is an alias for the latest Jev. For results that must reproduce, pin
|
|
152
|
+
one with `--model` (for example `typesafe/jev-1.13` on OpenRouter).
|
|
153
|
+
- Text in the input can try to steer the answer. Do not use jgrep as a security boundary.
|
|
154
|
+
|
|
155
|
+
## Development
|
|
156
|
+
|
|
157
|
+
```bash
|
|
158
|
+
uv sync && uv run pytest # 23 tests against a fake API; no key, no network
|
|
159
|
+
uv run python bench/phrasing.py # live; costs about a cent
|
|
160
|
+
uv run python bench/accuracy.py prepare && uv run python bench/accuracy.py spam # also: news, llm
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
`src/jgrep/core.py` is the client: two backends, retries inside a time budget, the cache,
|
|
164
|
+
in-flight deduplication and the cost meter. It is shared verbatim with
|
|
165
|
+
[jlink](https://github.com/keltokhy/jlink), which links records across datasets with the same
|
|
166
|
+
model.
|
|
167
|
+
|
|
168
|
+
MIT license.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""How accurate, fast and cheap is jgrep on public labeled text, next to the obvious alternatives?
|
|
2
|
+
|
|
3
|
+
uv run python bench/accuracy.py prepare # download the two datasets into bench/out/
|
|
4
|
+
uv run python bench/accuracy.py spam # jgrep vs a keyword grep on SMS spam
|
|
5
|
+
uv run python bench/accuracy.py news # four descriptions in one call on AG News
|
|
6
|
+
uv run python bench/accuracy.py llm [--n 300] # jgrep vs chat LLMs asked the same yes/no question
|
|
7
|
+
|
|
8
|
+
Everything runs the installed `jgrep` command itself, uncached, so the numbers are what a user gets.
|
|
9
|
+
Live; the whole file costs well under a dollar. Results land in bench/out/accuracy-*.json.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import asyncio
|
|
16
|
+
import csv
|
|
17
|
+
import io
|
|
18
|
+
import json
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.request
|
|
24
|
+
import zipfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
import httpx
|
|
28
|
+
|
|
29
|
+
from jgrep.core import BACKENDS
|
|
30
|
+
|
|
31
|
+
OUT = Path(__file__).parent / "out"
|
|
32
|
+
SPAM_URL = "https://archive.ics.uci.edu/static/public/228/sms+spam+collection.zip" # UCI, CC BY 4.0
|
|
33
|
+
NEWS_URL = "https://raw.githubusercontent.com/mhjabreel/CharCnn_Keras/master/data/ag_news_csv/test.csv"
|
|
34
|
+
SPAM_DESCRIPTION = "an unsolicited spam, scam or marketing text message"
|
|
35
|
+
# A keyword filter of the kind people actually write. Fixed before looking at any results.
|
|
36
|
+
SPAM_REGEX = r"free|win|won|prize|claim|urgent|cash|txt|text .* to|call now|reply|offer|guaranteed|£|\$|www\.|http"
|
|
37
|
+
NEWS = {1: "news about world affairs, politics or conflict", 2: "news about sports",
|
|
38
|
+
3: "news about business, markets or the economy", 4: "news about science or technology"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def one_line(text: str) -> str:
|
|
42
|
+
return " ".join(text.split())
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def prepare() -> None:
|
|
46
|
+
OUT.mkdir(exist_ok=True)
|
|
47
|
+
raw = urllib.request.urlopen(SPAM_URL, timeout=60).read()
|
|
48
|
+
rows = zipfile.ZipFile(io.BytesIO(raw)).read("SMSSpamCollection").decode("utf-8").splitlines()
|
|
49
|
+
pairs = [r.split("\t", 1) for r in rows if "\t" in r]
|
|
50
|
+
(OUT / "spam.txt").write_text("".join(one_line(t) + "\n" for _, t in pairs))
|
|
51
|
+
(OUT / "spam.labels").write_text("".join(("1" if y == "spam" else "0") + "\n" for y, _ in pairs))
|
|
52
|
+
news = list(csv.reader(io.StringIO(urllib.request.urlopen(NEWS_URL, timeout=60).read().decode("utf-8"))))
|
|
53
|
+
(OUT / "news.txt").write_text("".join(one_line(f"{title}. {body}").replace("\\", " ") + "\n" for _, title, body in news))
|
|
54
|
+
(OUT / "news.labels").write_text("".join(y + "\n" for y, _, _ in news))
|
|
55
|
+
print(f"spam: {len(pairs):,} messages, {sum(y == 'spam' for y, _ in pairs):,} spam; news: {len(news):,} articles")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def load(name: str) -> tuple[list[str], list[int]]:
|
|
59
|
+
lines = (OUT / f"{name}.txt").read_text().splitlines()
|
|
60
|
+
labels = [int(x) for x in (OUT / f"{name}.labels").read_text().split()]
|
|
61
|
+
assert len(lines) == len(labels), "a text spans lines; rerun prepare"
|
|
62
|
+
return lines, labels
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def jgrep(descriptions: list[str], path: Path, *, jobs: int = 64) -> tuple[list[dict], dict]:
|
|
66
|
+
"""Every line's probabilities from the real command, uncached, plus its own stats line."""
|
|
67
|
+
cmd = ["jgrep", "--json", "-p", "0", "-j", str(jobs), "--no-cache", "--stats", "--budget", "2"]
|
|
68
|
+
for d in descriptions:
|
|
69
|
+
cmd += ["-e", d]
|
|
70
|
+
t0 = time.perf_counter()
|
|
71
|
+
run = subprocess.run(cmd + [str(path)], capture_output=True, text=True)
|
|
72
|
+
seconds = time.perf_counter() - t0
|
|
73
|
+
rows = [json.loads(line) for line in run.stdout.splitlines()]
|
|
74
|
+
stats = re.search(r"([\d,]+) calls.*?([\d,]+) tokens; \$([\d.]+)", run.stderr)
|
|
75
|
+
if run.returncode == 2 or not stats:
|
|
76
|
+
sys.exit(f"jgrep failed: {run.stderr[-400:]}")
|
|
77
|
+
return rows, {"seconds": round(seconds, 1), "calls": int(stats[1].replace(",", "")),
|
|
78
|
+
"tokens": int(stats[2].replace(",", "")), "dollars": float(stats[3])}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def prf(predicted: list[bool], truth: list[bool]) -> dict:
|
|
82
|
+
tp = sum(p and t for p, t in zip(predicted, truth))
|
|
83
|
+
fp = sum(p and not t for p, t in zip(predicted, truth))
|
|
84
|
+
fn = sum(t and not p for p, t in zip(predicted, truth))
|
|
85
|
+
precision, recall = tp / max(tp + fp, 1), tp / max(tp + fn, 1)
|
|
86
|
+
return {"precision": round(precision, 4), "recall": round(recall, 4),
|
|
87
|
+
"f1": round(2 * precision * recall / max(precision + recall, 1e-9), 4),
|
|
88
|
+
"accuracy": round(sum(p == t for p, t in zip(predicted, truth)) / len(truth), 4)}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def spam() -> None:
|
|
92
|
+
lines, labels = load("spam")
|
|
93
|
+
truth = [y == 1 for y in labels]
|
|
94
|
+
rows, stats = jgrep([SPAM_DESCRIPTION], OUT / "spam.txt")
|
|
95
|
+
p = {r["line"]: r["p"] for r in rows}
|
|
96
|
+
ps = [p.get(i + 1, 0.0) for i in range(len(lines))]
|
|
97
|
+
t0 = time.perf_counter()
|
|
98
|
+
keyword = [bool(re.search(SPAM_REGEX, line, re.I)) for line in lines]
|
|
99
|
+
result = {
|
|
100
|
+
"dataset": "UCI SMS Spam Collection", "lines": len(lines), "positives": sum(truth),
|
|
101
|
+
"description": SPAM_DESCRIPTION, "jgrep_at_0.5": prf([x >= 0.5 for x in ps], truth),
|
|
102
|
+
"jgrep_at_0.9": prf([x >= 0.9 for x in ps], truth), "jgrep_run": stats,
|
|
103
|
+
"keyword_grep": prf(keyword, truth) | {"regex": SPAM_REGEX, "seconds": round(time.perf_counter() - t0, 3)},
|
|
104
|
+
}
|
|
105
|
+
(OUT / "accuracy-spam.json").write_text(json.dumps(result, indent=2))
|
|
106
|
+
print(json.dumps(result, indent=2))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def news() -> None:
|
|
110
|
+
lines, labels = load("news")
|
|
111
|
+
rows, stats = jgrep(list(NEWS.values()), OUT / "news.txt")
|
|
112
|
+
by_line = {r["line"]: r.get("ps", [r["p"]]) for r in rows}
|
|
113
|
+
ps = [by_line.get(i + 1, [0.0] * 4) for i in range(len(lines))]
|
|
114
|
+
per_class = {NEWS[c]: prf([x[c - 1] >= 0.5 for x in ps], [y == c for y in labels]) for c in NEWS}
|
|
115
|
+
top1 = [max(range(4), key=lambda j: x[j]) + 1 for x in ps]
|
|
116
|
+
result = {
|
|
117
|
+
"dataset": "AG News test set", "lines": len(lines), "descriptions": list(NEWS.values()),
|
|
118
|
+
"note": "four descriptions packed into one call per line; each scored one-vs-rest at 0.5",
|
|
119
|
+
"per_description": per_class, "macro_f1": round(sum(v["f1"] for v in per_class.values()) / 4, 4),
|
|
120
|
+
"top1_accuracy": round(sum(a == b for a, b in zip(top1, labels)) / len(labels), 4), "jgrep_run": stats,
|
|
121
|
+
}
|
|
122
|
+
(OUT / "accuracy-news.json").write_text(json.dumps(result, indent=2))
|
|
123
|
+
print(json.dumps(result, indent=2))
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
async def chat(model: str, lines: list[str], key: str, jobs: int) -> tuple[list[bool | None], dict]:
|
|
127
|
+
"""The do-it-yourself alternative: ask a chat model the same question, one line per request."""
|
|
128
|
+
sem, latencies, usage = asyncio.Semaphore(jobs), [], {"cost": 0.0, "prompt": 0, "completion": 0}
|
|
129
|
+
prompt = f'Does the text fit this description: "{SPAM_DESCRIPTION}"? Answer with one word, yes or no.'
|
|
130
|
+
|
|
131
|
+
async def one(client: httpx.AsyncClient, text: str) -> bool | None:
|
|
132
|
+
# Hybrid models that think by default (Qwen) spend the whole allowance thinking and never answer,
|
|
133
|
+
# so their thinking is switched off; the GPT models take the lowest effort they offer.
|
|
134
|
+
reasoning = {"enabled": False} if model.startswith("qwen/") else {"effort": "low", "exclude": True}
|
|
135
|
+
body = {"model": model, "temperature": 0, "max_tokens": 200, "usage": {"include": True},
|
|
136
|
+
"reasoning": reasoning,
|
|
137
|
+
"messages": [{"role": "system", "content": prompt}, {"role": "user", "content": text}]}
|
|
138
|
+
async with sem:
|
|
139
|
+
for attempt in range(4):
|
|
140
|
+
t0 = time.perf_counter()
|
|
141
|
+
r = await client.post("https://openrouter.ai/api/v1/chat/completions", json=body)
|
|
142
|
+
if r.status_code == 200 and "choices" in r.json():
|
|
143
|
+
break
|
|
144
|
+
await asyncio.sleep(0.5 * 2 ** attempt)
|
|
145
|
+
else:
|
|
146
|
+
return None
|
|
147
|
+
latencies.append(time.perf_counter() - t0)
|
|
148
|
+
data = r.json()
|
|
149
|
+
u = data.get("usage") or {}
|
|
150
|
+
usage["cost"] += u.get("cost") or 0.0
|
|
151
|
+
usage["prompt"] += u.get("prompt_tokens") or 0
|
|
152
|
+
usage["completion"] += u.get("completion_tokens") or 0
|
|
153
|
+
answer = (data["choices"][0]["message"].get("content") or "").strip().lower()
|
|
154
|
+
return True if answer.startswith("yes") else False if answer.startswith("no") else None
|
|
155
|
+
|
|
156
|
+
t0 = time.perf_counter()
|
|
157
|
+
async with httpx.AsyncClient(headers={"Authorization": f"Bearer {key}"}, timeout=90) as client:
|
|
158
|
+
answers = await asyncio.gather(*(one(client, t) for t in lines))
|
|
159
|
+
latencies.sort()
|
|
160
|
+
return answers, {"seconds": round(time.perf_counter() - t0, 1), "dollars": round(usage["cost"], 5),
|
|
161
|
+
"median_latency_ms": round(latencies[len(latencies) // 2] * 1000) if latencies else None,
|
|
162
|
+
"prompt_tokens": usage["prompt"], "completion_tokens": usage["completion"],
|
|
163
|
+
"unparseable": sum(a is None for a in answers)}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def llm(n: int, models: list[str], jobs: int) -> None:
|
|
167
|
+
lines, labels = load("spam")
|
|
168
|
+
step = len(lines) // n
|
|
169
|
+
idx = list(range(0, step * n, step)) # an even spread through the file, fixed
|
|
170
|
+
sub_lines, truth = [lines[i] for i in idx], [labels[i] == 1 for i in idx]
|
|
171
|
+
sub = OUT / "spam-subset.txt"
|
|
172
|
+
sub.write_text("".join(t + "\n" for t in sub_lines))
|
|
173
|
+
rows, stats = jgrep([SPAM_DESCRIPTION], sub, jobs=jobs)
|
|
174
|
+
p = {r["line"]: r["p"] for r in rows}
|
|
175
|
+
result = {"dataset": f"UCI SMS Spam Collection, every {step}th message", "lines": n, "positives": sum(truth),
|
|
176
|
+
"concurrency": jobs, "jgrep": prf([p.get(i + 1, 0.0) >= 0.5 for i in range(n)], truth) | stats, "chat_models": {}}
|
|
177
|
+
key = BACKENDS["openrouter"].key()
|
|
178
|
+
for model in models:
|
|
179
|
+
answers, run = asyncio.run(chat(model, sub_lines, key, jobs))
|
|
180
|
+
result["chat_models"][model] = prf([a is True for a in answers], truth) | run
|
|
181
|
+
print(model, result["chat_models"][model], flush=True)
|
|
182
|
+
(OUT / "accuracy-llm.json").write_text(json.dumps(result, indent=2))
|
|
183
|
+
print(json.dumps(result, indent=2))
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
if __name__ == "__main__":
|
|
187
|
+
ap = argparse.ArgumentParser()
|
|
188
|
+
ap.add_argument("what", choices=["prepare", "spam", "news", "llm"])
|
|
189
|
+
ap.add_argument("--n", type=int, default=300)
|
|
190
|
+
ap.add_argument("--jobs", type=int, default=32)
|
|
191
|
+
ap.add_argument("--models", nargs="+", default=["~openai/gpt-luna-latest", "qwen/qwen3.7-flash", "~openai/gpt-terra-latest"])
|
|
192
|
+
a = ap.parse_args()
|
|
193
|
+
{"prepare": prepare, "spam": spam, "news": news, "llm": lambda: llm(a.n, a.models, a.jobs)}[a.what]()
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Thirty hand-labeled complaint lines and five descriptions of different grammatical shapes.
|
|
2
|
+
|
|
3
|
+
Labels are in the order of DESCRIPTIONS. The lines were written to be clear-cut, with a few
|
|
4
|
+
deliberate near-misses (brown tap water is not a hot-water complaint; "how much is the fine"
|
|
5
|
+
names no amount).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
DESCRIPTIONS = [
|
|
9
|
+
"a complaint about noise", # noun phrase
|
|
10
|
+
"the writer asks a question", # statement
|
|
11
|
+
"mentions a specific dollar amount", # verb phrase, number-adjacent
|
|
12
|
+
"does not mention a landlord", # negation, a documented weak spot
|
|
13
|
+
"is this about heat or hot water?", # question form
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
LINES = [
|
|
17
|
+
("The bar downstairs blasts music until 4am every single night.", 1, 0, 0, 1, 0),
|
|
18
|
+
("No heat in the apartment for three days and it is 20 degrees outside.", 0, 0, 0, 1, 1),
|
|
19
|
+
("My landlord raised the rent by $400 with no notice.", 0, 0, 1, 0, 0),
|
|
20
|
+
("Can someone tell me when the hot water will be back?", 0, 1, 0, 1, 1),
|
|
21
|
+
("Construction next door starts jackhammering at 6am, is that even legal?", 1, 1, 0, 1, 0),
|
|
22
|
+
("The landlord still has not fixed the boiler and we have no hot water.", 0, 0, 0, 0, 1),
|
|
23
|
+
("I was charged a $75 late fee even though I paid on time.", 0, 0, 1, 1, 0),
|
|
24
|
+
("There is a pothole on Atlantic Avenue near 4th that has been there for months.", 0, 0, 0, 1, 0),
|
|
25
|
+
("Why does my landlord get to ignore the broken radiator all winter?", 0, 1, 0, 0, 1),
|
|
26
|
+
("The upstairs neighbor's dog barks all day while they are at work.", 1, 0, 0, 1, 0),
|
|
27
|
+
("Thank you to the sanitation crew for the quick pickup on our block.", 0, 0, 0, 1, 0),
|
|
28
|
+
("Street light out at the corner of Dekalb and Adelphi.", 0, 0, 0, 1, 0),
|
|
29
|
+
("The super said the repair would cost $1,200 and the landlord refuses to pay.", 0, 0, 1, 0, 0),
|
|
30
|
+
("Car alarms going off for hours on my street, I cannot sleep.", 1, 0, 0, 1, 0),
|
|
31
|
+
("Is there a number I can call about rats in the building?", 0, 1, 0, 1, 0),
|
|
32
|
+
("Our radiators are stone cold and the kids are sleeping in coats.", 0, 0, 0, 1, 1),
|
|
33
|
+
("The parking ticket was $115 for a sign that was covered by scaffolding.", 0, 0, 1, 1, 0),
|
|
34
|
+
("Helicopters fly over the park every ten minutes and the noise is unbearable.", 1, 0, 0, 1, 0),
|
|
35
|
+
("How much is the fine for a blocked fire hydrant?", 0, 1, 0, 1, 0),
|
|
36
|
+
("The landlord's contractor drills through the wall at night and it is deafening.", 1, 0, 0, 0, 0),
|
|
37
|
+
("Bus stop shelter glass is shattered on Flatbush Ave.", 0, 0, 0, 1, 0),
|
|
38
|
+
("Water comes out brown from the kitchen tap.", 0, 0, 0, 1, 0),
|
|
39
|
+
("They want $3,000 for a security deposit, is that allowed?", 0, 1, 1, 1, 0),
|
|
40
|
+
("Ice cream truck jingle plays outside my window for an hour every evening.", 1, 0, 0, 1, 0),
|
|
41
|
+
("The elevator has been out of service since Monday.", 0, 0, 0, 1, 0),
|
|
42
|
+
("When will the landlord turn the heat on? It is November.", 0, 1, 0, 0, 1),
|
|
43
|
+
("Garbage has not been collected on our street in over a week.", 0, 0, 0, 1, 0),
|
|
44
|
+
("A tree branch fell on my car during the storm.", 0, 0, 0, 1, 0),
|
|
45
|
+
("The shower only runs cold, no hot water since the weekend.", 0, 0, 0, 1, 1),
|
|
46
|
+
("Rent is going up to $2,850 next month according to the letter from my landlord.", 0, 0, 1, 0, 0),
|
|
47
|
+
]
|