defossil 0.0.1__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.
- defossil-0.0.1/LICENSE +21 -0
- defossil-0.0.1/PKG-INFO +66 -0
- defossil-0.0.1/README.md +45 -0
- defossil-0.0.1/pyproject.toml +87 -0
- defossil-0.0.1/pyproject.toml.orig +126 -0
- defossil-0.0.1/src/defossil/__init__.py +1 -0
- defossil-0.0.1/src/defossil/__main__.py +38 -0
- defossil-0.0.1/src/defossil/core/__init__.py +1 -0
- defossil-0.0.1/src/defossil/core/core.py +68 -0
- defossil-0.0.1/src/defossil/core/db.py +55 -0
- defossil-0.0.1/src/defossil/core/errors.py +17 -0
- defossil-0.0.1/src/defossil/core/features/__init__.py +1 -0
- defossil-0.0.1/src/defossil/core/features/ai/__init__.py +1 -0
- defossil-0.0.1/src/defossil/core/features/ai/backends.py +85 -0
- defossil-0.0.1/src/defossil/core/features/ai/models.py +63 -0
- defossil-0.0.1/src/defossil/core/features/ai/prompts/explain.md +9 -0
- defossil-0.0.1/src/defossil/core/features/ai/prompts/report.md +63 -0
- defossil-0.0.1/src/defossil/core/features/ai/prompts/review.md +18 -0
- defossil-0.0.1/src/defossil/core/features/ai/service.py +178 -0
- defossil-0.0.1/src/defossil/core/features/correction/__init__.py +1 -0
- defossil-0.0.1/src/defossil/core/features/correction/models.py +182 -0
- defossil-0.0.1/src/defossil/core/features/correction/service.py +217 -0
- defossil-0.0.1/src/defossil/core/features/message/__init__.py +1 -0
- defossil-0.0.1/src/defossil/core/features/message/models.py +58 -0
- defossil-0.0.1/src/defossil/core/features/message/service.py +139 -0
- defossil-0.0.1/src/defossil/core/features/message/sources/__init__.py +1 -0
- defossil-0.0.1/src/defossil/core/features/message/sources/claude_code.py +43 -0
- defossil-0.0.1/src/defossil/core/features/message/sources/codex.py +64 -0
- defossil-0.0.1/src/defossil/core/features/report/__init__.py +1 -0
- defossil-0.0.1/src/defossil/core/features/report/models.py +43 -0
- defossil-0.0.1/src/defossil/core/features/report/service.py +123 -0
- defossil-0.0.1/src/defossil/core/features/setting/__init__.py +1 -0
- defossil-0.0.1/src/defossil/core/features/setting/models.py +32 -0
- defossil-0.0.1/src/defossil/core/features/setting/service.py +22 -0
- defossil-0.0.1/src/defossil/core/migrations.py +64 -0
- defossil-0.0.1/src/defossil/core/pipeline.py +141 -0
- defossil-0.0.1/src/defossil/core/service.py +26 -0
- defossil-0.0.1/src/defossil/web/__init__.py +1 -0
- defossil-0.0.1/src/defossil/web/app.py +53 -0
- defossil-0.0.1/src/defossil/web/routers/__init__.py +1 -0
- defossil-0.0.1/src/defossil/web/routers/ai.py +46 -0
- defossil-0.0.1/src/defossil/web/routers/correction.py +92 -0
- defossil-0.0.1/src/defossil/web/routers/message.py +55 -0
- defossil-0.0.1/src/defossil/web/routers/report.py +56 -0
- defossil-0.0.1/src/defossil/web/routers/setting.py +37 -0
- defossil-0.0.1/src/defossil/web/routers/system.py +94 -0
- defossil-0.0.1/src/defossil/web/static/favicon.svg +5 -0
- defossil-0.0.1/src/defossil/web/static/logo.svg +8 -0
- defossil-0.0.1/src/defossil/web/static/marked.min.js +69 -0
- defossil-0.0.1/src/defossil/web/static/purify.min.js +3 -0
- defossil-0.0.1/src/defossil/web/static/styles.css +266 -0
- defossil-0.0.1/src/defossil/web/templates/ai_call.html +31 -0
- defossil-0.0.1/src/defossil/web/templates/ai_calls.html +42 -0
- defossil-0.0.1/src/defossil/web/templates/base.html +46 -0
- defossil-0.0.1/src/defossil/web/templates/corrections.html +199 -0
- defossil-0.0.1/src/defossil/web/templates/developer.html +20 -0
- defossil-0.0.1/src/defossil/web/templates/message.html +24 -0
- defossil-0.0.1/src/defossil/web/templates/messages.html +43 -0
- defossil-0.0.1/src/defossil/web/templates/pipeline.html +44 -0
- defossil-0.0.1/src/defossil/web/templates/prompts.html +10 -0
- defossil-0.0.1/src/defossil/web/templates/report.html +25 -0
- defossil-0.0.1/src/defossil/web/templates/reports.html +27 -0
- defossil-0.0.1/src/defossil/web/templates/settings.html +69 -0
- defossil-0.0.1/src/defossil/web/templates/system_base.html +10 -0
- defossil-0.0.1/src/defossil/web/templating.py +66 -0
defossil-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pybass
|
|
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.
|
defossil-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: defossil
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Improve your English by reviewing your own chats with AI coding agents.
|
|
5
|
+
Author: pybass
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Classifier: Environment :: Web Environment
|
|
9
|
+
Classifier: Intended Audience :: Developers
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
11
|
+
Classifier: Topic :: Education
|
|
12
|
+
Requires-Dist: fastapi>=0.141.1
|
|
13
|
+
Requires-Dist: jinja2>=3.1.6
|
|
14
|
+
Requires-Dist: markupsafe>=3.0.3
|
|
15
|
+
Requires-Dist: pydantic>=2.13.4
|
|
16
|
+
Requires-Dist: uvicorn>=0.52.3
|
|
17
|
+
Requires-Python: >=3.14
|
|
18
|
+
Project-URL: Repository, https://github.com/pybass/defossil
|
|
19
|
+
Project-URL: Issues, https://github.com/pybass/defossil/issues
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# defossil
|
|
23
|
+
|
|
24
|
+
**Status: prototype.** No tests yet; anything can change without backward compatibility.
|
|
25
|
+
|
|
26
|
+
Improve your English by reviewing your own chats with AI coding agents. The name comes from "fossilized errors" — recurring mistakes that stick in a learner's language. You already write a lot of English when talking to agents like Claude Code and Codex. defossil collects those messages, reviews them with an LLM, and gives you two things:
|
|
27
|
+
|
|
28
|
+
- **Corrections** — one per mistake: your fragment, the fix, and a short note on how to say it better.
|
|
29
|
+
- **Reports** — the most valuable part. A short lesson over many corrections: the mistakes you repeat, the native-language patterns in your phrasing, shorter ways to say what you keep saying long — real examples from your own text. Fast to read and a realistic picture of your English.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
One background thread — the pipeline — runs the whole chain every 5 minutes: collect → classify → review → report. Nothing else creates corrections or reports, so both tables are append-only and no step races another. The dashboard only shows what the pipeline did.
|
|
34
|
+
|
|
35
|
+
1. **Collect** — archive every message you typed, verbatim, into SQLite, deduplicated by the source's own key. Only real typed text: tool output, command expansions, and programmatic runs are skipped. Sources: Claude Code and Codex CLI; one module per source.
|
|
36
|
+
2. **Classify** — stamp each new message `pending` / `non-english` / `too-short` / `no-prose` / `too-long`, once. Only `pending` goes to review; the text itself is never rewritten.
|
|
37
|
+
3. **Review** — send `pending` messages to the LLM in batches, store what it corrects — real mistakes and style (wordiness, calques, register) — as corrections, and stamp the messages `reviewed`. A message is reviewed once, ever. On the dashboard a correction can be acknowledged, and the explain button asks the LLM for a deeper explanation.
|
|
38
|
+
4. **Report** — a markdown lesson over each `corrections_per_report` corrections: repeated mistakes, native-language patterns, shorter phrasings, one focus habit until the next report. Reports are stored and never regenerated.
|
|
39
|
+
|
|
40
|
+
## Architecture
|
|
41
|
+
|
|
42
|
+
`web` → `Core` → feature service → `Db`. `Core` is a container and the lifecycle: it opens the database, builds one service per feature, starts them in order and stops them in reverse. A feature is one job, named after the record it owns: `message` (the archive and its sources), `correction`, `report`, `setting`, and `ai` — every prompt the app sends, plus the `ai_calls` log of what each call cost. Every table has exactly one owner, and only the owner writes SQL against it. Features reach each other through `self.core.services.<other>`.
|
|
43
|
+
|
|
44
|
+
The schema evolves through append-only migrations (`core/migrations.py`, tracked by `PRAGMA user_version`), so it can change without dropping data. Nothing is redone: a message is classified and reviewed once, corrections and reports only accumulate. The archive is the one thing the sources cannot give back (Claude Code deletes transcripts after ~30 days), and nothing drops it.
|
|
45
|
+
|
|
46
|
+
## Usage
|
|
47
|
+
|
|
48
|
+
Python, FastAPI, SQLite. LLM calls go through the `claude` CLI by default (`claude -p` — works with a Claude subscription, no API key); a setting switches to `codex exec`. Local only: data never leaves the machine except text sent for review.
|
|
49
|
+
|
|
50
|
+
Run `defossil`, open http://127.0.0.1:3677.
|
|
51
|
+
|
|
52
|
+
## Settings
|
|
53
|
+
|
|
54
|
+
The data root cannot live in the database it locates, so it is the one setting outside it: `~/.local/share/defossil` by default, overridden only by `--data-dir`. Everything else — native language, AI backend, model and effort per prompt category, source roots, batch sizes, page size — lives in the `settings` table, is edited on the dashboard's settings page, and is read at use time, so a change applies without a restart.
|
|
55
|
+
|
|
56
|
+
## Non-goals
|
|
57
|
+
|
|
58
|
+
Decided against — do not re-propose or implement:
|
|
59
|
+
|
|
60
|
+
- **Exercises** — drills, quizzes, flashcards, spaced repetition built from the stored mistakes. The app shows mistakes and writes reports, nothing more.
|
|
61
|
+
- **Dismissing false positives** — a "not a mistake" flag. Premature: the archive shows no false positives yet.
|
|
62
|
+
- **Fossils page** — a page grouping corrections by category and fragment. Fragments group only when they repeat verbatim, so it adds little over the corrections page and the report.
|
|
63
|
+
|
|
64
|
+
## License
|
|
65
|
+
|
|
66
|
+
[MIT](LICENSE)
|
defossil-0.0.1/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# defossil
|
|
2
|
+
|
|
3
|
+
**Status: prototype.** No tests yet; anything can change without backward compatibility.
|
|
4
|
+
|
|
5
|
+
Improve your English by reviewing your own chats with AI coding agents. The name comes from "fossilized errors" — recurring mistakes that stick in a learner's language. You already write a lot of English when talking to agents like Claude Code and Codex. defossil collects those messages, reviews them with an LLM, and gives you two things:
|
|
6
|
+
|
|
7
|
+
- **Corrections** — one per mistake: your fragment, the fix, and a short note on how to say it better.
|
|
8
|
+
- **Reports** — the most valuable part. A short lesson over many corrections: the mistakes you repeat, the native-language patterns in your phrasing, shorter ways to say what you keep saying long — real examples from your own text. Fast to read and a realistic picture of your English.
|
|
9
|
+
|
|
10
|
+
## How it works
|
|
11
|
+
|
|
12
|
+
One background thread — the pipeline — runs the whole chain every 5 minutes: collect → classify → review → report. Nothing else creates corrections or reports, so both tables are append-only and no step races another. The dashboard only shows what the pipeline did.
|
|
13
|
+
|
|
14
|
+
1. **Collect** — archive every message you typed, verbatim, into SQLite, deduplicated by the source's own key. Only real typed text: tool output, command expansions, and programmatic runs are skipped. Sources: Claude Code and Codex CLI; one module per source.
|
|
15
|
+
2. **Classify** — stamp each new message `pending` / `non-english` / `too-short` / `no-prose` / `too-long`, once. Only `pending` goes to review; the text itself is never rewritten.
|
|
16
|
+
3. **Review** — send `pending` messages to the LLM in batches, store what it corrects — real mistakes and style (wordiness, calques, register) — as corrections, and stamp the messages `reviewed`. A message is reviewed once, ever. On the dashboard a correction can be acknowledged, and the explain button asks the LLM for a deeper explanation.
|
|
17
|
+
4. **Report** — a markdown lesson over each `corrections_per_report` corrections: repeated mistakes, native-language patterns, shorter phrasings, one focus habit until the next report. Reports are stored and never regenerated.
|
|
18
|
+
|
|
19
|
+
## Architecture
|
|
20
|
+
|
|
21
|
+
`web` → `Core` → feature service → `Db`. `Core` is a container and the lifecycle: it opens the database, builds one service per feature, starts them in order and stops them in reverse. A feature is one job, named after the record it owns: `message` (the archive and its sources), `correction`, `report`, `setting`, and `ai` — every prompt the app sends, plus the `ai_calls` log of what each call cost. Every table has exactly one owner, and only the owner writes SQL against it. Features reach each other through `self.core.services.<other>`.
|
|
22
|
+
|
|
23
|
+
The schema evolves through append-only migrations (`core/migrations.py`, tracked by `PRAGMA user_version`), so it can change without dropping data. Nothing is redone: a message is classified and reviewed once, corrections and reports only accumulate. The archive is the one thing the sources cannot give back (Claude Code deletes transcripts after ~30 days), and nothing drops it.
|
|
24
|
+
|
|
25
|
+
## Usage
|
|
26
|
+
|
|
27
|
+
Python, FastAPI, SQLite. LLM calls go through the `claude` CLI by default (`claude -p` — works with a Claude subscription, no API key); a setting switches to `codex exec`. Local only: data never leaves the machine except text sent for review.
|
|
28
|
+
|
|
29
|
+
Run `defossil`, open http://127.0.0.1:3677.
|
|
30
|
+
|
|
31
|
+
## Settings
|
|
32
|
+
|
|
33
|
+
The data root cannot live in the database it locates, so it is the one setting outside it: `~/.local/share/defossil` by default, overridden only by `--data-dir`. Everything else — native language, AI backend, model and effort per prompt category, source roots, batch sizes, page size — lives in the `settings` table, is edited on the dashboard's settings page, and is read at use time, so a change applies without a restart.
|
|
34
|
+
|
|
35
|
+
## Non-goals
|
|
36
|
+
|
|
37
|
+
Decided against — do not re-propose or implement:
|
|
38
|
+
|
|
39
|
+
- **Exercises** — drills, quizzes, flashcards, spaced repetition built from the stored mistakes. The app shows mistakes and writes reports, nothing more.
|
|
40
|
+
- **Dismissing false positives** — a "not a mistake" flag. Premature: the archive shows no false positives yet.
|
|
41
|
+
- **Fossils page** — a page grouping corrections by category and fragment. Fragments group only when they repeat verbatim, so it adds little over the corrections page and the report.
|
|
42
|
+
|
|
43
|
+
## License
|
|
44
|
+
|
|
45
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "defossil"
|
|
3
|
+
version = "0.0.1"
|
|
4
|
+
description = "Improve your English by reviewing your own chats with AI coding agents."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.14"
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Environment :: Web Environment",
|
|
11
|
+
"Intended Audience :: Developers",
|
|
12
|
+
"Programming Language :: Python :: 3.14",
|
|
13
|
+
"Topic :: Education",
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"fastapi>=0.141.1",
|
|
17
|
+
"jinja2>=3.1.6",
|
|
18
|
+
"markupsafe>=3.0.3",
|
|
19
|
+
"pydantic>=2.13.4",
|
|
20
|
+
"uvicorn>=0.52.3",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[[project.authors]]
|
|
24
|
+
name = "pybass"
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Repository = "https://github.com/pybass/defossil"
|
|
28
|
+
Issues = "https://github.com/pybass/defossil/issues"
|
|
29
|
+
|
|
30
|
+
[project.scripts]
|
|
31
|
+
defossil = "defossil.__main__:main"
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
35
|
+
build-backend = "uv_build"
|
|
36
|
+
|
|
37
|
+
[dependency-groups]
|
|
38
|
+
dev = [
|
|
39
|
+
"ruff~=0.16.3",
|
|
40
|
+
"mypy~=2.3.1",
|
|
41
|
+
"pip-audit~=2.10.1",
|
|
42
|
+
"deptry~=0.25.1",
|
|
43
|
+
"pre-commit~=4.6.2",
|
|
44
|
+
"watchfiles~=1.2.0",
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
[tool.uv]
|
|
48
|
+
required-version = ">=0.12"
|
|
49
|
+
|
|
50
|
+
[tool.ruff]
|
|
51
|
+
line-length = 130
|
|
52
|
+
target-version = "py314"
|
|
53
|
+
|
|
54
|
+
[tool.ruff.lint]
|
|
55
|
+
select = ["ALL"]
|
|
56
|
+
ignore = [
|
|
57
|
+
"COM812",
|
|
58
|
+
"D203",
|
|
59
|
+
"D213",
|
|
60
|
+
"PLC0414",
|
|
61
|
+
"RET503",
|
|
62
|
+
"G004",
|
|
63
|
+
"FIX",
|
|
64
|
+
"FBT",
|
|
65
|
+
"EM",
|
|
66
|
+
"CPY",
|
|
67
|
+
"TC",
|
|
68
|
+
"TRY003",
|
|
69
|
+
"C901",
|
|
70
|
+
"PLR0911",
|
|
71
|
+
"PLR0912",
|
|
72
|
+
"PLR0913",
|
|
73
|
+
"PLR2004",
|
|
74
|
+
"ASYNC109",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.format]
|
|
78
|
+
quote-style = "double"
|
|
79
|
+
indent-style = "space"
|
|
80
|
+
docstring-code-format = true
|
|
81
|
+
|
|
82
|
+
[tool.mypy]
|
|
83
|
+
python_version = "3.14"
|
|
84
|
+
strict = true
|
|
85
|
+
|
|
86
|
+
[tool.deptry.per_rule_ignores]
|
|
87
|
+
DEP002 = ["jinja2"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# ============================================================================
|
|
2
|
+
# Project metadata
|
|
3
|
+
# ============================================================================
|
|
4
|
+
[project]
|
|
5
|
+
name = "defossil"
|
|
6
|
+
version = "0.0.1"
|
|
7
|
+
description = "Improve your English by reviewing your own chats with AI coding agents."
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
authors = [{ name = "pybass" }]
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
requires-python = ">=3.14"
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Environment :: Web Environment",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3.14",
|
|
17
|
+
"Topic :: Education",
|
|
18
|
+
]
|
|
19
|
+
dependencies = [
|
|
20
|
+
"fastapi>=0.141.1",
|
|
21
|
+
"jinja2>=3.1.6",
|
|
22
|
+
"markupsafe>=3.0.3",
|
|
23
|
+
"pydantic>=2.13.4",
|
|
24
|
+
"uvicorn>=0.52.3",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Repository = "https://github.com/pybass/defossil"
|
|
29
|
+
Issues = "https://github.com/pybass/defossil/issues"
|
|
30
|
+
|
|
31
|
+
[project.scripts]
|
|
32
|
+
defossil = "defossil.__main__:main"
|
|
33
|
+
|
|
34
|
+
# ============================================================================
|
|
35
|
+
# Build system
|
|
36
|
+
# ============================================================================
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.12,<0.13"] # pinned: backends live outside uv.lock — an open range breaks reproducible tag builds
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# ============================================================================
|
|
43
|
+
# Development dependencies — grouped by role (mirrors the tool sections below)
|
|
44
|
+
# ============================================================================
|
|
45
|
+
[dependency-groups]
|
|
46
|
+
dev = [
|
|
47
|
+
# Linting & formatting
|
|
48
|
+
"ruff~=0.16.3",
|
|
49
|
+
# Type checking
|
|
50
|
+
"mypy~=2.3.1",
|
|
51
|
+
# Security & vulnerability audit
|
|
52
|
+
"pip-audit~=2.10.1",
|
|
53
|
+
# Dependency hygiene — imports vs declared deps
|
|
54
|
+
"deptry~=0.25.1",
|
|
55
|
+
# Git pre-commit hooks
|
|
56
|
+
"pre-commit~=4.6.2",
|
|
57
|
+
# Dev-server auto-restart (justfile `dev`)
|
|
58
|
+
"watchfiles~=1.2.0",
|
|
59
|
+
]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ============================================================================
|
|
63
|
+
# uv
|
|
64
|
+
# ============================================================================
|
|
65
|
+
[tool.uv]
|
|
66
|
+
required-version = ">=0.12" # refuse a uv too old for the lock/sync behaviors the justfile relies on
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ============================================================================
|
|
70
|
+
# Ruff — linting & formatting
|
|
71
|
+
# ============================================================================
|
|
72
|
+
[tool.ruff]
|
|
73
|
+
line-length = 130
|
|
74
|
+
target-version = "py314"
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint]
|
|
77
|
+
select = ["ALL"]
|
|
78
|
+
ignore = [
|
|
79
|
+
# --- Ecosystem / tool conflicts ---
|
|
80
|
+
"COM812", # redundant and incompatible with ruff format
|
|
81
|
+
"D203", # one-blank-line-before-class — conflicts with D211
|
|
82
|
+
"D213", # multi-line-summary-second-line — conflicts with D212
|
|
83
|
+
|
|
84
|
+
# --- Stylistic preferences ---
|
|
85
|
+
"PLC0414", # useless-import-alias — `import x as x` is our explicit re-export marker
|
|
86
|
+
"RET503", # implicit-return — explicit None returns are noisy
|
|
87
|
+
"G004", # f-strings in logging — accepted
|
|
88
|
+
|
|
89
|
+
# --- Categories that fight our coding style ---
|
|
90
|
+
"FIX", # flake8-fixme — TODOs are fine; tracked elsewhere
|
|
91
|
+
"FBT", # boolean-trap — too restrictive
|
|
92
|
+
"EM", # error-message strings — inline messages accepted
|
|
93
|
+
"CPY", # flake8-copyright — no copyright headers
|
|
94
|
+
"TC", # flake8-type-checking — TYPE_CHECKING blocks save import time we don't care about; cost: import noise + broken runtime annotations (pydantic)
|
|
95
|
+
"TRY003", # raise-vanilla-args — too noisy
|
|
96
|
+
"C901", # complex-structure — judgment call
|
|
97
|
+
|
|
98
|
+
# --- Soft pylint thresholds ---
|
|
99
|
+
"PLR0911", # too-many-return-statements
|
|
100
|
+
"PLR0912", # too-many-branches
|
|
101
|
+
"PLR0913", # too-many-arguments
|
|
102
|
+
"PLR2004", # magic-value-comparison
|
|
103
|
+
|
|
104
|
+
# --- Async edge case ---
|
|
105
|
+
"ASYNC109", # async-function-with-timeout — debatable, off
|
|
106
|
+
]
|
|
107
|
+
|
|
108
|
+
[tool.ruff.format]
|
|
109
|
+
quote-style = "double"
|
|
110
|
+
indent-style = "space"
|
|
111
|
+
docstring-code-format = true
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ============================================================================
|
|
115
|
+
# Type checking — mypy (strict)
|
|
116
|
+
# ============================================================================
|
|
117
|
+
[tool.mypy]
|
|
118
|
+
python_version = "3.14"
|
|
119
|
+
strict = true
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
# ============================================================================
|
|
123
|
+
# deptry — imports vs declared deps
|
|
124
|
+
# ============================================================================
|
|
125
|
+
[tool.deptry.per_rule_ignores]
|
|
126
|
+
DEP002 = ["jinja2"] # used only through fastapi.templating (an optional fastapi extra), never imported directly
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""defossil — improve your English by reviewing your own chats with AI coding agents."""
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Entry point: defossil runs the web dashboard."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
from importlib.metadata import version
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import uvicorn
|
|
9
|
+
|
|
10
|
+
from defossil.core.core import Core
|
|
11
|
+
from defossil.web.app import create_app
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
"""Parse arguments and run the web dashboard."""
|
|
16
|
+
parser = argparse.ArgumentParser(prog="defossil", description="Improve your English by reviewing your chats with AI agents.")
|
|
17
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {version('defossil')}")
|
|
18
|
+
parser.add_argument("--port", type=int, default=3677, help="dashboard port on 127.0.0.1")
|
|
19
|
+
parser.add_argument(
|
|
20
|
+
"--data-dir", type=Path, default=Core.DEFAULT_DATA_DIR, help="data root (default: ~/.local/share/defossil)"
|
|
21
|
+
)
|
|
22
|
+
args = parser.parse_args()
|
|
23
|
+
# uvicorn configures its own loggers and leaves the root one bare, which would drop every line the workers log.
|
|
24
|
+
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
|
|
25
|
+
logging.getLogger("defossil").info(f"dashboard: http://127.0.0.1:{args.port}")
|
|
26
|
+
# log_config=None routes uvicorn's records to our root handler; warnings and errors still show, its chatter does not.
|
|
27
|
+
uvicorn.run(
|
|
28
|
+
create_app(Core(args.data_dir)),
|
|
29
|
+
host="127.0.0.1",
|
|
30
|
+
port=args.port,
|
|
31
|
+
log_config=None,
|
|
32
|
+
log_level="warning",
|
|
33
|
+
access_log=False,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
if __name__ == "__main__":
|
|
38
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The application core: configuration, storage, and one service per feature."""
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Composition root — the single object the CLI and the web app work through."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from defossil.core.db import Db
|
|
6
|
+
from defossil.core.features.ai.service import AiService
|
|
7
|
+
from defossil.core.features.correction.service import CorrectionService
|
|
8
|
+
from defossil.core.features.message.service import MessageService
|
|
9
|
+
from defossil.core.features.report.service import ReportService
|
|
10
|
+
from defossil.core.features.setting.service import SettingService
|
|
11
|
+
from defossil.core.pipeline import Pipeline
|
|
12
|
+
from defossil.core.service import Service
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Services:
|
|
16
|
+
"""Every feature service in one namespace, so `core.*` stays the storage and `core.services.*` the work.
|
|
17
|
+
|
|
18
|
+
Plain fields, listed by hand: a service is added here and nowhere else. The pipeline sits last so it starts
|
|
19
|
+
after every service it drives and stops before them.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, core: Core) -> None:
|
|
23
|
+
"""Build one service per feature over *core*. None of them may touch another while this runs."""
|
|
24
|
+
self.setting = SettingService(core)
|
|
25
|
+
self.ai = AiService(core)
|
|
26
|
+
self.message = MessageService(core)
|
|
27
|
+
self.correction = CorrectionService(core)
|
|
28
|
+
self.report = ReportService(core)
|
|
29
|
+
self.pipeline = Pipeline(core)
|
|
30
|
+
# Read off the fields above, so a service is still added in one place.
|
|
31
|
+
self._services: list[Service] = [value for value in vars(self).values() if isinstance(value, Service)]
|
|
32
|
+
|
|
33
|
+
def start_all(self) -> None:
|
|
34
|
+
"""Start the services in the order they were built."""
|
|
35
|
+
for service in self._services:
|
|
36
|
+
service.on_start()
|
|
37
|
+
|
|
38
|
+
def stop_all(self) -> None:
|
|
39
|
+
"""Stop them in the reverse order, so nothing is torn down under a service still using it."""
|
|
40
|
+
for service in reversed(self._services):
|
|
41
|
+
service.on_stop()
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Core:
|
|
45
|
+
"""A container, not a layer with behaviour: it owns the storage and the feature services over it.
|
|
46
|
+
|
|
47
|
+
A client builds one Core and calls `core.services.correction.get_corrections(...)`; nothing here forwards that call.
|
|
48
|
+
Core is also the lifecycle: `start` is what lets the pipeline run in the background, and only the server calls it.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
# Where the data lives without --data-dir. A fixed path, never read from the environment:
|
|
52
|
+
# a variable exported for some other tool must not move the archive.
|
|
53
|
+
DEFAULT_DATA_DIR = Path.home() / ".local" / "share" / "defossil"
|
|
54
|
+
|
|
55
|
+
def __init__(self, data_dir: Path) -> None:
|
|
56
|
+
"""Open the database under *data_dir* and build the services over it. Nothing runs yet."""
|
|
57
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
58
|
+
self.db = Db(data_dir / "defossil.db")
|
|
59
|
+
self.services = Services(self)
|
|
60
|
+
|
|
61
|
+
def start(self) -> None:
|
|
62
|
+
"""Start every service; nothing runs in the background before this."""
|
|
63
|
+
self.services.start_all()
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
"""Stop the services, then close the database — in that order, or the pipeline outlives its connection."""
|
|
67
|
+
self.services.stop_all()
|
|
68
|
+
self.db.close()
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""SQLite storage: the connection and the migration runner.
|
|
2
|
+
|
|
3
|
+
It runs no query of its own — a table's SQL belongs to its feature, and the schema lives in migrations.py.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import sqlite3
|
|
7
|
+
import threading
|
|
8
|
+
from collections.abc import Generator
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from defossil.core.migrations import MIGRATIONS
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Db:
|
|
16
|
+
"""The one connection every feature service runs its own SQL on."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, path: Path) -> None:
|
|
19
|
+
"""Open the database and apply pending migrations; the directory must exist (Core makes it)."""
|
|
20
|
+
# autocommit=True gives every lone statement its own transaction, so a write is never left hanging in an
|
|
21
|
+
# implicit one. check_same_thread=False because fastapi serves sync handlers from a threadpool, so a request
|
|
22
|
+
# is not handled by the thread that opened this connection.
|
|
23
|
+
self.conn = sqlite3.connect(path, autocommit=True, check_same_thread=False)
|
|
24
|
+
self.conn.row_factory = sqlite3.Row
|
|
25
|
+
self._migrate()
|
|
26
|
+
self._write_lock = threading.Lock()
|
|
27
|
+
|
|
28
|
+
def close(self) -> None:
|
|
29
|
+
"""Close the connection."""
|
|
30
|
+
self.conn.close()
|
|
31
|
+
|
|
32
|
+
def _migrate(self) -> None:
|
|
33
|
+
"""Apply pending migrations, tracked via PRAGMA user_version."""
|
|
34
|
+
version = int(self.conn.execute("PRAGMA user_version").fetchone()[0])
|
|
35
|
+
for number, script in enumerate(MIGRATIONS[version:], start=version + 1):
|
|
36
|
+
# The script and the version bump commit together: a crash mid-migration rolls back cleanly, so a
|
|
37
|
+
# migration is either fully applied and recorded, or not at all.
|
|
38
|
+
self.conn.executescript(f"BEGIN;\n{script}\nPRAGMA user_version = {number};\nCOMMIT;")
|
|
39
|
+
|
|
40
|
+
@contextmanager
|
|
41
|
+
def transaction(self) -> Generator[sqlite3.Connection]:
|
|
42
|
+
"""Run one or more statements as a single transaction; every write goes through here, reads need not.
|
|
43
|
+
|
|
44
|
+
One at a time, under the lock: the connection is shared by the request threadpool, the pipeline worker and
|
|
45
|
+
the explain pool, so a lone autocommit write could join another thread's open transaction and be rolled
|
|
46
|
+
back with it, and overlapping transactions would nest and commit each other's rows.
|
|
47
|
+
"""
|
|
48
|
+
with self._write_lock:
|
|
49
|
+
self.conn.execute("BEGIN")
|
|
50
|
+
try:
|
|
51
|
+
yield self.conn
|
|
52
|
+
except Exception:
|
|
53
|
+
self.conn.execute("ROLLBACK")
|
|
54
|
+
raise
|
|
55
|
+
self.conn.execute("COMMIT")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Errors Core raises on purpose. Each client maps them to its own vocabulary — HTTP status, exit code."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class DefossilError(Exception):
|
|
5
|
+
"""Base of everything Core raises deliberately, so a client can catch the whole family."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotFoundError(DefossilError):
|
|
9
|
+
"""A record was asked for by id and does not exist."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class InvalidOperationError(DefossilError):
|
|
13
|
+
"""The operation a caller asked for would break a rule of the model."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AiError(DefossilError):
|
|
17
|
+
"""A backend call failed or its answer could not be read; the same ask can be retried later."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One package per feature: the record it owns, its table, and every operation over both."""
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""AI: the backend contract and the CLIs behind it, every prompt the app sends, and the log of what each query cost."""
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""The backend contract, both CLI implementations, and the one place a name from the settings becomes a backend.
|
|
2
|
+
|
|
3
|
+
A failed call raises to the caller, which decides whether that is recorded or fatal. A missing CLI binary is not
|
|
4
|
+
that: it is the machine being wrong, and it stops the run.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Protocol
|
|
13
|
+
|
|
14
|
+
from defossil.core.features.ai.models import AiResponse
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AiBackend(Protocol):
|
|
18
|
+
"""One prompt in, the answer with its metadata out; what is asked and how it is read is the caller's."""
|
|
19
|
+
|
|
20
|
+
def send_prompt(self, prompt: str, model: str, effort: str) -> AiResponse:
|
|
21
|
+
"""Answer *prompt* on *model* at *effort*; a failed call raises to the caller."""
|
|
22
|
+
...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ClaudeBackend:
|
|
26
|
+
"""Answers a prompt by running the `claude` CLI in print mode."""
|
|
27
|
+
|
|
28
|
+
def send_prompt(self, prompt: str, model: str, effort: str) -> AiResponse:
|
|
29
|
+
"""Run `claude -p` over *prompt* with the given model and effort."""
|
|
30
|
+
claude_bin = shutil.which("claude")
|
|
31
|
+
if claude_bin is None:
|
|
32
|
+
raise RuntimeError("claude CLI not found in PATH")
|
|
33
|
+
# Hooks are for interactive sessions: without this they fire on every call (sounds, prompt-injecting hooks).
|
|
34
|
+
argv = [claude_bin, "-p", "--output-format", "json", "--settings", '{"disableAllHooks": true}']
|
|
35
|
+
argv += ["--model", model, "--effort", effort]
|
|
36
|
+
result = subprocess.run( # noqa: S603 -- fixed argv, resolved binary, no shell; nothing user-controlled is executed
|
|
37
|
+
argv, input=prompt, capture_output=True, text=True, check=True
|
|
38
|
+
)
|
|
39
|
+
answer = json.loads(result.stdout)
|
|
40
|
+
usage = answer.get("usage")
|
|
41
|
+
# modelUsage also lists the CLI's internal haiku helper calls; the answer comes from the model that wrote the most.
|
|
42
|
+
model_usage = answer.get("modelUsage") or {}
|
|
43
|
+
return AiResponse(
|
|
44
|
+
reply=str(answer["result"]).strip(),
|
|
45
|
+
model=max(model_usage, key=lambda m: model_usage[m].get("outputTokens") or 0, default=None),
|
|
46
|
+
# Cache writes and reads are counted in: with `claude -p` almost the whole prompt lands there.
|
|
47
|
+
input_tokens=sum(
|
|
48
|
+
usage.get(k) or 0 for k in ("input_tokens", "cache_creation_input_tokens", "cache_read_input_tokens")
|
|
49
|
+
)
|
|
50
|
+
if usage
|
|
51
|
+
else None,
|
|
52
|
+
output_tokens=usage.get("output_tokens") if usage else None,
|
|
53
|
+
cost_usd=answer.get("total_cost_usd"),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class CodexBackend:
|
|
58
|
+
"""Answers a prompt by running the `codex` CLI non-interactively; the CLI reports no usable usage metadata."""
|
|
59
|
+
|
|
60
|
+
def send_prompt(self, prompt: str, model: str, effort: str) -> AiResponse:
|
|
61
|
+
"""Run `codex exec` over *prompt*; tokens and cost stay None — only the machine-readable answer file comes back."""
|
|
62
|
+
codex_bin = shutil.which("codex")
|
|
63
|
+
if codex_bin is None:
|
|
64
|
+
raise RuntimeError("codex CLI not found in PATH")
|
|
65
|
+
# --ephemeral keeps the run out of ~/.codex/sessions, so the collector never re-ingests defossil's own prompts.
|
|
66
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
67
|
+
answer_file = Path(tmp) / "answer.txt"
|
|
68
|
+
flags = ["--skip-git-repo-check", "--ephemeral", "--sandbox", "read-only", "-o", str(answer_file)]
|
|
69
|
+
flags += ["--model", model, "-c", f"model_reasoning_effort={effort}"]
|
|
70
|
+
argv = [codex_bin, "exec", *flags, "-"]
|
|
71
|
+
subprocess.run( # noqa: S603 -- fixed argv, resolved binary, no shell; the prompt only travels over stdin
|
|
72
|
+
argv, input=prompt, capture_output=True, text=True, check=True
|
|
73
|
+
)
|
|
74
|
+
return AiResponse(reply=answer_file.read_text().strip())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def build_backend(name: str) -> AiBackend:
|
|
78
|
+
"""Return the backend *name* names; an unknown name stops the run."""
|
|
79
|
+
match name:
|
|
80
|
+
case "claude":
|
|
81
|
+
return ClaudeBackend()
|
|
82
|
+
case "codex":
|
|
83
|
+
return CodexBackend()
|
|
84
|
+
case _:
|
|
85
|
+
raise ValueError(f"Unknown AI backend {name!r}: use claude or codex")
|