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.
Files changed (65) hide show
  1. defossil/__init__.py +1 -0
  2. defossil/__main__.py +38 -0
  3. defossil/core/__init__.py +1 -0
  4. defossil/core/core.py +68 -0
  5. defossil/core/db.py +55 -0
  6. defossil/core/errors.py +17 -0
  7. defossil/core/features/__init__.py +1 -0
  8. defossil/core/features/ai/__init__.py +1 -0
  9. defossil/core/features/ai/backends.py +85 -0
  10. defossil/core/features/ai/models.py +63 -0
  11. defossil/core/features/ai/prompts/explain.md +9 -0
  12. defossil/core/features/ai/prompts/report.md +63 -0
  13. defossil/core/features/ai/prompts/review.md +18 -0
  14. defossil/core/features/ai/service.py +178 -0
  15. defossil/core/features/correction/__init__.py +1 -0
  16. defossil/core/features/correction/models.py +182 -0
  17. defossil/core/features/correction/service.py +217 -0
  18. defossil/core/features/message/__init__.py +1 -0
  19. defossil/core/features/message/models.py +58 -0
  20. defossil/core/features/message/service.py +139 -0
  21. defossil/core/features/message/sources/__init__.py +1 -0
  22. defossil/core/features/message/sources/claude_code.py +43 -0
  23. defossil/core/features/message/sources/codex.py +64 -0
  24. defossil/core/features/report/__init__.py +1 -0
  25. defossil/core/features/report/models.py +43 -0
  26. defossil/core/features/report/service.py +123 -0
  27. defossil/core/features/setting/__init__.py +1 -0
  28. defossil/core/features/setting/models.py +32 -0
  29. defossil/core/features/setting/service.py +22 -0
  30. defossil/core/migrations.py +64 -0
  31. defossil/core/pipeline.py +141 -0
  32. defossil/core/service.py +26 -0
  33. defossil/web/__init__.py +1 -0
  34. defossil/web/app.py +53 -0
  35. defossil/web/routers/__init__.py +1 -0
  36. defossil/web/routers/ai.py +46 -0
  37. defossil/web/routers/correction.py +92 -0
  38. defossil/web/routers/message.py +55 -0
  39. defossil/web/routers/report.py +56 -0
  40. defossil/web/routers/setting.py +37 -0
  41. defossil/web/routers/system.py +94 -0
  42. defossil/web/static/favicon.svg +5 -0
  43. defossil/web/static/logo.svg +8 -0
  44. defossil/web/static/marked.min.js +69 -0
  45. defossil/web/static/purify.min.js +3 -0
  46. defossil/web/static/styles.css +266 -0
  47. defossil/web/templates/ai_call.html +31 -0
  48. defossil/web/templates/ai_calls.html +42 -0
  49. defossil/web/templates/base.html +46 -0
  50. defossil/web/templates/corrections.html +199 -0
  51. defossil/web/templates/developer.html +20 -0
  52. defossil/web/templates/message.html +24 -0
  53. defossil/web/templates/messages.html +43 -0
  54. defossil/web/templates/pipeline.html +44 -0
  55. defossil/web/templates/prompts.html +10 -0
  56. defossil/web/templates/report.html +25 -0
  57. defossil/web/templates/reports.html +27 -0
  58. defossil/web/templates/settings.html +69 -0
  59. defossil/web/templates/system_base.html +10 -0
  60. defossil/web/templating.py +66 -0
  61. defossil-0.0.1.dist-info/METADATA +66 -0
  62. defossil-0.0.1.dist-info/RECORD +65 -0
  63. defossil-0.0.1.dist-info/WHEEL +4 -0
  64. defossil-0.0.1.dist-info/entry_points.txt +3 -0
  65. 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")
@@ -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."""