defossil 0.0.1__py3-none-any.whl
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/__init__.py +1 -0
- defossil/__main__.py +38 -0
- defossil/core/__init__.py +1 -0
- defossil/core/core.py +68 -0
- defossil/core/db.py +55 -0
- defossil/core/errors.py +17 -0
- defossil/core/features/__init__.py +1 -0
- defossil/core/features/ai/__init__.py +1 -0
- defossil/core/features/ai/backends.py +85 -0
- defossil/core/features/ai/models.py +63 -0
- defossil/core/features/ai/prompts/explain.md +9 -0
- defossil/core/features/ai/prompts/report.md +63 -0
- defossil/core/features/ai/prompts/review.md +18 -0
- defossil/core/features/ai/service.py +178 -0
- defossil/core/features/correction/__init__.py +1 -0
- defossil/core/features/correction/models.py +182 -0
- defossil/core/features/correction/service.py +217 -0
- defossil/core/features/message/__init__.py +1 -0
- defossil/core/features/message/models.py +58 -0
- defossil/core/features/message/service.py +139 -0
- defossil/core/features/message/sources/__init__.py +1 -0
- defossil/core/features/message/sources/claude_code.py +43 -0
- defossil/core/features/message/sources/codex.py +64 -0
- defossil/core/features/report/__init__.py +1 -0
- defossil/core/features/report/models.py +43 -0
- defossil/core/features/report/service.py +123 -0
- defossil/core/features/setting/__init__.py +1 -0
- defossil/core/features/setting/models.py +32 -0
- defossil/core/features/setting/service.py +22 -0
- defossil/core/migrations.py +64 -0
- defossil/core/pipeline.py +141 -0
- defossil/core/service.py +26 -0
- defossil/web/__init__.py +1 -0
- defossil/web/app.py +53 -0
- defossil/web/routers/__init__.py +1 -0
- defossil/web/routers/ai.py +46 -0
- defossil/web/routers/correction.py +92 -0
- defossil/web/routers/message.py +55 -0
- defossil/web/routers/report.py +56 -0
- defossil/web/routers/setting.py +37 -0
- defossil/web/routers/system.py +94 -0
- defossil/web/static/favicon.svg +5 -0
- defossil/web/static/logo.svg +8 -0
- defossil/web/static/marked.min.js +69 -0
- defossil/web/static/purify.min.js +3 -0
- defossil/web/static/styles.css +266 -0
- defossil/web/templates/ai_call.html +31 -0
- defossil/web/templates/ai_calls.html +42 -0
- defossil/web/templates/base.html +46 -0
- defossil/web/templates/corrections.html +199 -0
- defossil/web/templates/developer.html +20 -0
- defossil/web/templates/message.html +24 -0
- defossil/web/templates/messages.html +43 -0
- defossil/web/templates/pipeline.html +44 -0
- defossil/web/templates/prompts.html +10 -0
- defossil/web/templates/report.html +25 -0
- defossil/web/templates/reports.html +27 -0
- defossil/web/templates/settings.html +69 -0
- defossil/web/templates/system_base.html +10 -0
- defossil/web/templating.py +66 -0
- defossil-0.0.1.dist-info/METADATA +66 -0
- defossil-0.0.1.dist-info/RECORD +65 -0
- defossil-0.0.1.dist-info/WHEEL +4 -0
- defossil-0.0.1.dist-info/entry_points.txt +3 -0
- defossil-0.0.1.dist-info/licenses/LICENSE +21 -0
defossil/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""defossil — improve your English by reviewing your own chats with AI coding agents."""
|
defossil/__main__.py
ADDED
|
@@ -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."""
|
defossil/core/core.py
ADDED
|
@@ -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()
|
defossil/core/db.py
ADDED
|
@@ -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")
|
defossil/core/errors.py
ADDED
|
@@ -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")
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""The AI call record: every backend call with what it cost."""
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from enum import StrEnum
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PromptCategory(StrEnum):
|
|
12
|
+
"""Which prompt a call sends; each category has its own model and effort settings."""
|
|
13
|
+
|
|
14
|
+
REVIEW = "review"
|
|
15
|
+
EXPLAIN = "explain"
|
|
16
|
+
REPORT = "report"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AiResponse(BaseModel):
|
|
20
|
+
"""What a backend returns for one prompt: the answer text, plus whatever metadata its CLI reports."""
|
|
21
|
+
|
|
22
|
+
reply: str
|
|
23
|
+
model: str | None = None # as the CLI reported it; None when it does not say
|
|
24
|
+
input_tokens: int | None = None # cache reads and writes included — they are still input paid for
|
|
25
|
+
output_tokens: int | None = None
|
|
26
|
+
cost_usd: float | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AiCall(BaseModel):
|
|
30
|
+
"""One stored backend call; what the ai calls page lists."""
|
|
31
|
+
|
|
32
|
+
id: int
|
|
33
|
+
created_at: datetime
|
|
34
|
+
category: PromptCategory
|
|
35
|
+
backend: str # the ai_backend setting at call time
|
|
36
|
+
model: str | None # as the CLI reported it, or the requested one when it does not say
|
|
37
|
+
effort: str | None # requested reasoning effort
|
|
38
|
+
prompt: str # full text sent, template plus appended data
|
|
39
|
+
reply: str | None # raw answer text; None when the call failed
|
|
40
|
+
input_tokens: int | None # None when the backend reports no usage (codex)
|
|
41
|
+
output_tokens: int | None
|
|
42
|
+
cost_usd: float | None
|
|
43
|
+
duration_ms: int
|
|
44
|
+
error: str | None # why the call failed; the row is still written
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def from_row(cls, row: sqlite3.Row) -> Self:
|
|
48
|
+
"""Rebuild a call from its row in the `ai_calls` table."""
|
|
49
|
+
return cls(
|
|
50
|
+
id=row["id"],
|
|
51
|
+
created_at=datetime.fromisoformat(row["created_at"]),
|
|
52
|
+
category=PromptCategory(row["category"]),
|
|
53
|
+
backend=row["backend"],
|
|
54
|
+
model=row["model"],
|
|
55
|
+
effort=row["effort"],
|
|
56
|
+
prompt=row["prompt"],
|
|
57
|
+
reply=row["reply"],
|
|
58
|
+
input_tokens=row["input_tokens"],
|
|
59
|
+
output_tokens=row["output_tokens"],
|
|
60
|
+
cost_usd=row["cost_usd"],
|
|
61
|
+
duration_ms=row["duration_ms"],
|
|
62
|
+
error=row["error"],
|
|
63
|
+
)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
You are an English teacher. A non-native speaker (native language: {native_language}) typed a message to an AI coding
|
|
2
|
+
agent, a reviewer corrected a fragment of it, and the writer wants that correction clarified.
|
|
3
|
+
|
|
4
|
+
Explain briefly: the rule behind the correction, why the original is wrong or unnatural, and one or two fresh examples
|
|
5
|
+
of the rule. If the mistake mirrors a common {native_language} pattern, point that out. If the writer added a question
|
|
6
|
+
of their own after the JSON, answer it too. Respond in markdown, a few short paragraphs at most — no headings, and do
|
|
7
|
+
not restate the correction itself.
|
|
8
|
+
|
|
9
|
+
The correction and the full message it came from follow as JSON:
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
You are an English teacher writing a recurring personal report for one student — a native {native_language} speaker
|
|
2
|
+
who improves their English by typing all messages to AI coding agents in English.
|
|
3
|
+
|
|
4
|
+
After this prompt comes JSON with the period and four data sets:
|
|
5
|
+
|
|
6
|
+
- `period` — the dates this report's data spans; use it to anchor "this period" and "then vs now" in real time.
|
|
7
|
+
- `corrections` — corrections to the student's messages from this period, one line each:
|
|
8
|
+
"[category] original -> corrected (explanation)". The category and explanation are the reviewer's diagnosis — a
|
|
9
|
+
hint for grouping what repeats, not a limit on it: one habit may hide under several categories.
|
|
10
|
+
- `previous_corrections` — the corrections behind the newest previous report only, same format. This is the "then"
|
|
11
|
+
side when you count then vs now; habits older than that are covered by the previous reports' prose.
|
|
12
|
+
- `messages` — the student's messages from this period, verbatim. They may carry pasted code, logs or command
|
|
13
|
+
output: judge only the sentences the student wrote, never pasted material.
|
|
14
|
+
- `previous_reports` — up to two of your previous reports, newest first.
|
|
15
|
+
|
|
16
|
+
Write these sections, skipping any where you find nothing real:
|
|
17
|
+
|
|
18
|
+
## Cheat sheet
|
|
19
|
+
|
|
20
|
+
At most 5 lines of wrong -> right: the student's current top corrections, skimmable without reading the rest.
|
|
21
|
+
|
|
22
|
+
## Shame on you!
|
|
23
|
+
|
|
24
|
+
Habits taught in the previous reports that still appear in this period's data. Be direct: say plainly that this was
|
|
25
|
+
already taught and is still happening — the student wants to be confronted, not spared. Per habit: the habit, the
|
|
26
|
+
rule in one line, how often it appeared then vs now (count `previous_corrections` vs `corrections`), 1-2 fresh
|
|
27
|
+
examples. If a habit taught before has clearly faded, say so in one sentence — earned praise, not encouragement.
|
|
28
|
+
Skip the section when there are no previous reports.
|
|
29
|
+
|
|
30
|
+
## Repeated mistakes
|
|
31
|
+
|
|
32
|
+
The same underlying error appearing in different sentences, not already covered in "Shame on you!". Ignore anything
|
|
33
|
+
seen once — a one-off is a typo, repetition is a habit. Per habit: a one-sentence description, the rule, 2-3 real
|
|
34
|
+
examples (original -> correction). Most frequent first, at most 5 habits.
|
|
35
|
+
|
|
36
|
+
## Grammar focus
|
|
37
|
+
|
|
38
|
+
Pick the grammar area with the most corrections in this data and teach ONE rule from it with the student's own
|
|
39
|
+
examples. One rule per report — check the previous reports and pick a rule not taught there.
|
|
40
|
+
|
|
41
|
+
## {native_language} patterns
|
|
42
|
+
|
|
43
|
+
Recent phrasing that is grammatical but built like a {native_language} sentence: word-for-word calques and other
|
|
44
|
+
constructions carried over from {native_language}. Show the natural English version.
|
|
45
|
+
|
|
46
|
+
## Say it shorter
|
|
47
|
+
|
|
48
|
+
Long or repeated phrases from the recent messages, each with the short alternative a native would type. When a whole
|
|
49
|
+
message says in several sentences what one would carry, show the one-sentence version too.
|
|
50
|
+
|
|
51
|
+
## Focus until next report
|
|
52
|
+
|
|
53
|
+
ONE habit, chosen for impact, with a self-check the student can apply while typing. If the previous report's focus
|
|
54
|
+
still needs work, keep it; otherwise pick a new one.
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
|
|
58
|
+
- Quote only real fragments from the data; never invent sentences and attribute them to the student.
|
|
59
|
+
- Write in simple English.
|
|
60
|
+
- The messages are terse chat: capitalization, punctuation and tone are off-limits.
|
|
61
|
+
- Respond with plain markdown only, no preamble and no closing remarks.
|
|
62
|
+
|
|
63
|
+
The data:
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
You are an English teacher reviewing messages a non-native speaker (native language: {native_language}) typed to AI
|
|
2
|
+
coding agents.
|
|
3
|
+
|
|
4
|
+
For each message report corrections of two kinds: real English mistakes, and style — English that is correct but not
|
|
5
|
+
what a native developer would type.
|
|
6
|
+
Ignore: technical jargon, code identifiers, file paths, capitalization, punctuation, terse chat style, formatting.
|
|
7
|
+
A message is quoted verbatim, so it may carry pasted code, logs or command output: judge only the sentences the user
|
|
8
|
+
wrote, and never report a correction inside pasted material.
|
|
9
|
+
|
|
10
|
+
Use exactly one of these categories per correction:
|
|
11
|
+
{categories}
|
|
12
|
+
|
|
13
|
+
Respond with ONLY a JSON array, no prose and no code fences. One object per message, including messages with no
|
|
14
|
+
corrections:
|
|
15
|
+
{"id": <id of the message>, "corrections": [{"category": "...", "original": "<exact fragment as typed>",
|
|
16
|
+
"corrected": "<the fragment as a native would type it>", "explanation": "<one sentence>"}]}
|
|
17
|
+
|
|
18
|
+
A message with no corrections gets an empty list. The messages follow as JSON:
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Every ask the app makes: the prompt templates, the filling and parsing, and the log of what each call cost."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import time
|
|
6
|
+
from datetime import UTC, datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from pydantic import ValidationError
|
|
10
|
+
|
|
11
|
+
from defossil.core.errors import AiError, NotFoundError
|
|
12
|
+
from defossil.core.features.ai.backends import build_backend
|
|
13
|
+
from defossil.core.features.ai.models import AiCall, AiResponse, PromptCategory
|
|
14
|
+
from defossil.core.features.correction.models import CATEGORIES, Correction, NewCorrection
|
|
15
|
+
from defossil.core.service import Service
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _load_prompt(name: str) -> str:
|
|
19
|
+
"""Return the text of `prompts/<name>.md` — every prompt the app sends lives in that folder next to this module."""
|
|
20
|
+
return (Path(__file__).parent / "prompts" / f"{name}.md").read_text()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# Nothing marks a stored correction stale when a prompt changes: a message is reviewed once, ever.
|
|
24
|
+
# Filled with replace, not str.format — the templates' JSON examples are full of literal braces.
|
|
25
|
+
REVIEW_PROMPT_TEMPLATE = _load_prompt("review") # {native_language} and {categories} filled per call
|
|
26
|
+
EXPLAIN_PROMPT_TEMPLATE = _load_prompt("explain") # {native_language} filled per call
|
|
27
|
+
REPORT_PROMPT_TEMPLATE = _load_prompt("report") # {native_language} filled per call
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AiService(Service):
|
|
31
|
+
"""Owner of the `ai_calls` table and of every prompt: it builds each ask, runs and logs the call, parses the answer."""
|
|
32
|
+
|
|
33
|
+
def send_review_prompt(self, texts: dict[int, str]) -> list[NewCorrection]:
|
|
34
|
+
"""Review the texts, keyed by message id, and return every correction found tied to its message.
|
|
35
|
+
|
|
36
|
+
Raises AiError on a failed call or an unreadable answer; nothing partial comes back.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
messages_json = json.dumps([{"id": mid, "text": text} for mid, text in texts.items()], ensure_ascii=False)
|
|
40
|
+
reply = self._send(PromptCategory.REVIEW, self.get_review_prompt() + messages_json)
|
|
41
|
+
# Strip the code fences the model sometimes adds, then flatten the per-message JSON array behind them.
|
|
42
|
+
reply = reply.removeprefix("```json").removeprefix("```").removesuffix("```").strip()
|
|
43
|
+
corrections = [NewCorrection(message_id=item["id"], **c) for item in json.loads(reply) for c in item["corrections"]]
|
|
44
|
+
except (subprocess.CalledProcessError, json.JSONDecodeError, ValidationError, KeyError, TypeError) as e:
|
|
45
|
+
raise AiError(f"{type(e).__name__}: {e}") from e
|
|
46
|
+
if any(c.message_id not in texts for c in corrections):
|
|
47
|
+
raise AiError("the answer names a message id outside the batch")
|
|
48
|
+
return corrections
|
|
49
|
+
|
|
50
|
+
def send_explain_prompt(self, correction: Correction, message_text: str, question: str) -> str:
|
|
51
|
+
"""Ask the backend to clarify one correction, the writer's question appended if any; the reply is markdown, as is."""
|
|
52
|
+
data = {
|
|
53
|
+
"category": correction.category,
|
|
54
|
+
"original": correction.original,
|
|
55
|
+
"corrected": correction.corrected,
|
|
56
|
+
"explanation": correction.explanation,
|
|
57
|
+
"message": message_text,
|
|
58
|
+
}
|
|
59
|
+
prompt = self.get_explain_prompt() + json.dumps(data, ensure_ascii=False, indent=2)
|
|
60
|
+
if question.strip():
|
|
61
|
+
prompt += f"\n\nThe writer's own question:\n{question.strip()}"
|
|
62
|
+
return self._send(PromptCategory.EXPLAIN, prompt)
|
|
63
|
+
|
|
64
|
+
def send_report_prompt(
|
|
65
|
+
self,
|
|
66
|
+
corrections: list[Correction],
|
|
67
|
+
previous_corrections: list[Correction],
|
|
68
|
+
messages: list[str],
|
|
69
|
+
previous_reports: list[str],
|
|
70
|
+
) -> str:
|
|
71
|
+
"""Ask the backend for a lesson over the report window's inputs; the reply is the report markdown, as is."""
|
|
72
|
+
data = {
|
|
73
|
+
# The window's corrections are chronological, so their first and last typed_at are the period's bounds.
|
|
74
|
+
"period": f"{corrections[0].typed_at:%Y-%m-%d} to {corrections[-1].typed_at:%Y-%m-%d}" if corrections else "",
|
|
75
|
+
"corrections": [f"[{c.category}] {c.original} -> {c.corrected} ({c.explanation})" for c in corrections],
|
|
76
|
+
"previous_corrections": [
|
|
77
|
+
f"[{c.category}] {c.original} -> {c.corrected} ({c.explanation})" for c in previous_corrections
|
|
78
|
+
],
|
|
79
|
+
"messages": messages,
|
|
80
|
+
"previous_reports": previous_reports,
|
|
81
|
+
}
|
|
82
|
+
return self._send(PromptCategory.REPORT, self.get_report_prompt() + json.dumps(data, ensure_ascii=False))
|
|
83
|
+
|
|
84
|
+
def get_review_prompt(self) -> str:
|
|
85
|
+
"""Return the review prompt as it is sent: the native language and one line per catalog category filled in."""
|
|
86
|
+
lines = [
|
|
87
|
+
f"- {category} ({info.kind}): {info.description}" + (f" — e.g. {info.example}" if info.example else "")
|
|
88
|
+
for category, info in CATEGORIES.items()
|
|
89
|
+
]
|
|
90
|
+
native_language = self.core.services.setting.get_settings().native_language
|
|
91
|
+
return REVIEW_PROMPT_TEMPLATE.replace("{native_language}", native_language).replace("{categories}", "\n".join(lines))
|
|
92
|
+
|
|
93
|
+
def get_explain_prompt(self) -> str:
|
|
94
|
+
"""Return the explain prompt as it is sent; the correction JSON is appended per ask."""
|
|
95
|
+
return EXPLAIN_PROMPT_TEMPLATE.replace("{native_language}", self.core.services.setting.get_settings().native_language)
|
|
96
|
+
|
|
97
|
+
def get_report_prompt(self) -> str:
|
|
98
|
+
"""Return the report prompt as it is sent; the window JSON is appended per ask."""
|
|
99
|
+
return REPORT_PROMPT_TEMPLATE.replace("{native_language}", self.core.services.setting.get_settings().native_language)
|
|
100
|
+
|
|
101
|
+
def _send(self, category: PromptCategory, prompt: str) -> str:
|
|
102
|
+
"""Answer *prompt* with the backend, model and effort the settings name for *category*, logging the call either way.
|
|
103
|
+
|
|
104
|
+
Returns the raw answer text; a failed call is logged with its error and re-raised.
|
|
105
|
+
"""
|
|
106
|
+
settings = self.core.services.setting.get_settings()
|
|
107
|
+
match category:
|
|
108
|
+
case PromptCategory.REVIEW:
|
|
109
|
+
model, effort = settings.review_model, settings.review_effort
|
|
110
|
+
case PromptCategory.EXPLAIN:
|
|
111
|
+
model, effort = settings.explain_model, settings.explain_effort
|
|
112
|
+
case PromptCategory.REPORT:
|
|
113
|
+
model, effort = settings.report_model, settings.report_effort
|
|
114
|
+
backend = build_backend(settings.ai_backend)
|
|
115
|
+
started_at = datetime.now(UTC)
|
|
116
|
+
started = time.monotonic()
|
|
117
|
+
response: AiResponse | None = None
|
|
118
|
+
error: str | None = None
|
|
119
|
+
try:
|
|
120
|
+
response = backend.send_prompt(prompt, model, effort)
|
|
121
|
+
except Exception as e:
|
|
122
|
+
error = f"{type(e).__name__}: {e}"
|
|
123
|
+
raise
|
|
124
|
+
finally:
|
|
125
|
+
metrics = response or AiResponse(reply="")
|
|
126
|
+
with self.db.transaction() as conn:
|
|
127
|
+
conn.execute(
|
|
128
|
+
"""
|
|
129
|
+
INSERT INTO ai_calls (created_at, category, backend, model, effort, prompt, reply,
|
|
130
|
+
input_tokens, output_tokens, cost_usd, duration_ms, error)
|
|
131
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
|
132
|
+
""",
|
|
133
|
+
(
|
|
134
|
+
started_at.isoformat(),
|
|
135
|
+
category,
|
|
136
|
+
settings.ai_backend,
|
|
137
|
+
metrics.model or model, # what actually answered when the CLI says, the requested one otherwise
|
|
138
|
+
effort,
|
|
139
|
+
prompt,
|
|
140
|
+
response.reply if response else None,
|
|
141
|
+
metrics.input_tokens,
|
|
142
|
+
metrics.output_tokens,
|
|
143
|
+
metrics.cost_usd,
|
|
144
|
+
int((time.monotonic() - started) * 1000),
|
|
145
|
+
error,
|
|
146
|
+
),
|
|
147
|
+
)
|
|
148
|
+
return response.reply
|
|
149
|
+
|
|
150
|
+
def get_calls(self, category: PromptCategory | None, limit: int, offset: int) -> list[AiCall]:
|
|
151
|
+
"""Return one page of stored calls, newest first; a None category means no filter."""
|
|
152
|
+
rows = self.db.conn.execute(
|
|
153
|
+
"""
|
|
154
|
+
SELECT * FROM ai_calls WHERE (:category IS NULL OR category = :category)
|
|
155
|
+
ORDER BY id DESC LIMIT :limit OFFSET :offset
|
|
156
|
+
""",
|
|
157
|
+
{"category": category, "limit": limit, "offset": offset},
|
|
158
|
+
)
|
|
159
|
+
return [AiCall.from_row(row) for row in rows]
|
|
160
|
+
|
|
161
|
+
def get_call(self, call_id: int) -> AiCall:
|
|
162
|
+
"""Return one stored call, full prompt and reply included."""
|
|
163
|
+
row = self.db.conn.execute("SELECT * FROM ai_calls WHERE id = ?", (call_id,)).fetchone()
|
|
164
|
+
if row is None:
|
|
165
|
+
raise NotFoundError(f"No AI call {call_id}")
|
|
166
|
+
return AiCall.from_row(row)
|
|
167
|
+
|
|
168
|
+
def count_calls(self, category: PromptCategory | None = None) -> int:
|
|
169
|
+
"""Count the stored calls, failed ones included; a None category means no filter."""
|
|
170
|
+
row = self.db.conn.execute(
|
|
171
|
+
"SELECT COUNT(*) FROM ai_calls WHERE (:category IS NULL OR category = :category)", {"category": category}
|
|
172
|
+
).fetchone()
|
|
173
|
+
return int(row[0])
|
|
174
|
+
|
|
175
|
+
def count_calls_by_category(self) -> dict[str, int]:
|
|
176
|
+
"""Count the call log per category, largest first."""
|
|
177
|
+
rows = self.db.conn.execute("SELECT category, COUNT(*) AS n FROM ai_calls GROUP BY category ORDER BY n DESC")
|
|
178
|
+
return {row["category"]: row["n"] for row in rows}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Correction: what the LLM corrects in a message — mistakes and style — and the review batch that finds them."""
|