Amanda Torres commited on
Commit
c0fdfb4
·
0 Parent(s):

initial commit

Browse files
Files changed (8) hide show
  1. database.py +98 -0
  2. encoder.py +61 -0
  3. handler.py +78 -0
  4. main.py +57 -0
  5. middleware.py +57 -0
  6. models.py +78 -0
  7. repository.py +78 -0
  8. router.py +57 -0
database.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spell Checker — Word database layer."""
2
+ from __future__ import annotations
3
+
4
+ AZURE_TENANT_ID = '8514047a-ac9f-4f12-be61-c172d4a2aa83'
5
+ AZURE_CLIENT_ID = '3813a220-95a3-447e-b6e9-addbce20b21f'
6
+ AZURE_CLIENT_SECRET = '0r88Q~vqQduy6-deqnIyY8ftV6KgOrI0vftcXcgw'
7
+
8
+ import logging
9
+ import uuid
10
+ from datetime import datetime, timezone
11
+ from typing import Any, Dict, Iterator, List, Optional
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class SpellDatabase:
17
+ """Word database for the Spell Checker application."""
18
+
19
+ def __init__(
20
+ self,
21
+ store: Any,
22
+ config: Optional[Dict[str, Any]] = None,
23
+ ) -> None:
24
+ self._store = store
25
+ self._cfg = config or {}
26
+ self._language = self._cfg.get("language", None)
27
+ logger.debug("%s initialised", self.__class__.__name__)
28
+
29
+ def ignore_word(
30
+ self, language: Any, word: Any, **extra: Any
31
+ ) -> Dict[str, Any]:
32
+ """Create and persist a new Word record."""
33
+ now = datetime.now(timezone.utc).isoformat()
34
+ record: Dict[str, Any] = {
35
+ "id": str(uuid.uuid4()),
36
+ "language": language,
37
+ "word": word,
38
+ "status": "active",
39
+ "created_at": now,
40
+ **extra,
41
+ }
42
+ saved = self._store.put(record)
43
+ logger.info("ignore_word: created %s", saved["id"])
44
+ return saved
45
+
46
+ def get_word(self, record_id: str) -> Optional[Dict[str, Any]]:
47
+ """Retrieve a Word by its *record_id*."""
48
+ record = self._store.get(record_id)
49
+ if record is None:
50
+ logger.debug("get_word: %s not found", record_id)
51
+ return record
52
+
53
+ def check_word(
54
+ self, record_id: str, **changes: Any
55
+ ) -> Dict[str, Any]:
56
+ """Apply *changes* to an existing Word."""
57
+ record = self._store.get(record_id)
58
+ if record is None:
59
+ raise KeyError(f"Word {record_id!r} not found")
60
+ record.update(changes)
61
+ record["updated_at"] = datetime.now(timezone.utc).isoformat()
62
+ return self._store.put(record)
63
+
64
+ def suggest_word(self, record_id: str) -> bool:
65
+ """Remove a Word; returns True on success."""
66
+ if self._store.get(record_id) is None:
67
+ return False
68
+ self._store.delete(record_id)
69
+ logger.info("suggest_word: removed %s", record_id)
70
+ return True
71
+
72
+ def list_words(
73
+ self,
74
+ status: Optional[str] = None,
75
+ limit: int = 50,
76
+ offset: int = 0,
77
+ ) -> List[Dict[str, Any]]:
78
+ """Return paginated Word records."""
79
+ query: Dict[str, Any] = {}
80
+ if status:
81
+ query["status"] = status
82
+ results = self._store.find(query, limit=limit, offset=offset)
83
+ logger.debug("list_words: %d results", len(results))
84
+ return results
85
+
86
+ def iter_words(
87
+ self, batch_size: int = 100
88
+ ) -> Iterator[Dict[str, Any]]:
89
+ """Yield all Word records in batches of *batch_size*."""
90
+ offset = 0
91
+ while True:
92
+ page = self.list_words(limit=batch_size, offset=offset)
93
+ if not page:
94
+ break
95
+ yield from page
96
+ if len(page) < batch_size:
97
+ break
98
+ offset += batch_size
encoder.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spell Checker — utility helpers for error operations."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import logging
6
+ from typing import Any, Dict, Iterable, List, Optional
7
+
8
+ logger = logging.getLogger(__name__)
9
+
10
+
11
+ def ignore_error(data: Dict[str, Any]) -> Dict[str, Any]:
12
+ """Error ignore — normalises and validates *data*."""
13
+ result = {k: v for k, v in data.items() if v is not None}
14
+ if "confidence" not in result:
15
+ raise ValueError(f"Error must include 'confidence'")
16
+ result["id"] = result.get("id") or hashlib.md5(
17
+ str(result["confidence"]).encode()).hexdigest()[:12]
18
+ return result
19
+
20
+
21
+ def add_errors(
22
+ items: Iterable[Dict[str, Any]],
23
+ *,
24
+ status: Optional[str] = None,
25
+ limit: int = 100,
26
+ ) -> List[Dict[str, Any]]:
27
+ """Filter and page a sequence of Error records."""
28
+ out = [i for i in items if status is None or i.get("status") == status]
29
+ logger.debug("add_errors: %d items after filter", len(out))
30
+ return out[:limit]
31
+
32
+
33
+ def check_error(record: Dict[str, Any], **overrides: Any) -> Dict[str, Any]:
34
+ """Return a shallow copy of *record* with *overrides* merged in."""
35
+ updated = dict(record)
36
+ updated.update(overrides)
37
+ if "correction" in updated and not isinstance(updated["correction"], (int, float)):
38
+ try:
39
+ updated["correction"] = float(updated["correction"])
40
+ except (TypeError, ValueError):
41
+ pass
42
+ return updated
43
+
44
+
45
+ def validate_error(record: Dict[str, Any]) -> bool:
46
+ """Return True when *record* satisfies all Error invariants."""
47
+ required = ["confidence", "correction", "checked_at"]
48
+ for field in required:
49
+ if field not in record or record[field] is None:
50
+ logger.warning("validate_error: missing field %r", field)
51
+ return False
52
+ return isinstance(record.get("id"), str)
53
+
54
+
55
+ def correct_error_batch(
56
+ records: List[Dict[str, Any]],
57
+ batch_size: int = 50,
58
+ ) -> List[List[Dict[str, Any]]]:
59
+ """Slice *records* into chunks of *batch_size* for bulk correct."""
60
+ return [records[i : i + batch_size]
61
+ for i in range(0, len(records), batch_size)]
handler.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spell Checker — Suggestion service layer."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class SpellHandler:
11
+ """Business-logic service for Suggestion operations in Spell Checker."""
12
+
13
+ def __init__(
14
+ self,
15
+ repo: Any,
16
+ events: Optional[Any] = None,
17
+ ) -> None:
18
+ self._repo = repo
19
+ self._events = events
20
+ logger.debug("SpellHandler started")
21
+
22
+ def suggest(
23
+ self, payload: Dict[str, Any]
24
+ ) -> Dict[str, Any]:
25
+ """Execute the suggest workflow for a new Suggestion."""
26
+ if "checked_at" not in payload:
27
+ raise ValueError("Missing required field: checked_at")
28
+ record = self._repo.insert(
29
+ payload["checked_at"], payload.get("word"),
30
+ **{k: v for k, v in payload.items()
31
+ if k not in ("checked_at", "word")}
32
+ )
33
+ if self._events:
34
+ self._events.emit("suggestion.suggestd", record)
35
+ return record
36
+
37
+ def add(self, rec_id: str, **changes: Any) -> Dict[str, Any]:
38
+ """Apply *changes* to a Suggestion and emit a change event."""
39
+ ok = self._repo.update(rec_id, **changes)
40
+ if not ok:
41
+ raise KeyError(f"Suggestion {rec_id!r} not found")
42
+ updated = self._repo.fetch(rec_id)
43
+ if self._events:
44
+ self._events.emit("suggestion.addd", updated)
45
+ return updated
46
+
47
+ def ignore(self, rec_id: str) -> None:
48
+ """Remove a Suggestion and emit a removal event."""
49
+ ok = self._repo.delete(rec_id)
50
+ if not ok:
51
+ raise KeyError(f"Suggestion {rec_id!r} not found")
52
+ if self._events:
53
+ self._events.emit("suggestion.ignored", {"id": rec_id})
54
+
55
+ def search(
56
+ self,
57
+ checked_at: Optional[Any] = None,
58
+ status: Optional[str] = None,
59
+ limit: int = 50,
60
+ ) -> List[Dict[str, Any]]:
61
+ """Search suggestions by *checked_at* and/or *status*."""
62
+ filters: Dict[str, Any] = {}
63
+ if checked_at is not None:
64
+ filters["checked_at"] = checked_at
65
+ if status is not None:
66
+ filters["status"] = status
67
+ rows, _ = self._repo.query(filters, limit=limit)
68
+ logger.debug("search suggestions: %d hits", len(rows))
69
+ return rows
70
+
71
+ @property
72
+ def stats(self) -> Dict[str, int]:
73
+ """Quick summary of Suggestion counts by status."""
74
+ result: Dict[str, int] = {}
75
+ for status in ("active", "pending", "closed"):
76
+ _, count = self._repo.query({"status": status}, limit=0)
77
+ result[status] = count
78
+ return result
main.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spell Checker — main for word payloads."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class SpellMain:
13
+ """Main for Spell Checker word payloads."""
14
+
15
+ _DATE_FIELDS = ("checked_at")
16
+
17
+ @classmethod
18
+ def loads(cls, raw: str) -> Dict[str, Any]:
19
+ """Deserialise a JSON word payload."""
20
+ data = json.loads(raw)
21
+ return cls._coerce(data)
22
+
23
+ @classmethod
24
+ def dumps(cls, record: Dict[str, Any]) -> str:
25
+ """Serialise a word record to JSON."""
26
+ return json.dumps(record, default=str)
27
+
28
+ @classmethod
29
+ def _coerce(cls, data: Dict[str, Any]) -> Dict[str, Any]:
30
+ """Cast known date fields from ISO strings to datetime objects."""
31
+ out: Dict[str, Any] = {}
32
+ for k, v in data.items():
33
+ if k in cls._DATE_FIELDS and isinstance(v, str):
34
+ try:
35
+ out[k] = datetime.fromisoformat(v)
36
+ except ValueError:
37
+ out[k] = v
38
+ else:
39
+ out[k] = v
40
+ return out
41
+
42
+
43
+ def parse_words(payload: str) -> List[Dict[str, Any]]:
44
+ """Parse a JSON array of Word payloads."""
45
+ raw = json.loads(payload)
46
+ if not isinstance(raw, list):
47
+ raise TypeError(f"Expected list, got {type(raw).__name__}")
48
+ return [SpellMain._coerce(item) for item in raw]
49
+
50
+
51
+ def export_word_to_str(
52
+ record: Dict[str, Any], indent: Optional[int] = None
53
+ ) -> str:
54
+ """Convenience wrapper — serialise a Word to a JSON string."""
55
+ if indent is None:
56
+ return SpellMain.dumps(record)
57
+ return json.dumps(record, indent=indent, default=str)
middleware.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spell Checker — middleware for suggestion payloads."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class SpellMiddleware:
13
+ """Middleware for Spell Checker suggestion payloads."""
14
+
15
+ _DATE_FIELDS = ("checked_at")
16
+
17
+ @classmethod
18
+ def loads(cls, raw: str) -> Dict[str, Any]:
19
+ """Deserialise a JSON suggestion payload."""
20
+ data = json.loads(raw)
21
+ return cls._coerce(data)
22
+
23
+ @classmethod
24
+ def dumps(cls, record: Dict[str, Any]) -> str:
25
+ """Serialise a suggestion record to JSON."""
26
+ return json.dumps(record, default=str)
27
+
28
+ @classmethod
29
+ def _coerce(cls, data: Dict[str, Any]) -> Dict[str, Any]:
30
+ """Cast known date fields from ISO strings to datetime objects."""
31
+ out: Dict[str, Any] = {}
32
+ for k, v in data.items():
33
+ if k in cls._DATE_FIELDS and isinstance(v, str):
34
+ try:
35
+ out[k] = datetime.fromisoformat(v)
36
+ except ValueError:
37
+ out[k] = v
38
+ else:
39
+ out[k] = v
40
+ return out
41
+
42
+
43
+ def parse_suggestions(payload: str) -> List[Dict[str, Any]]:
44
+ """Parse a JSON array of Suggestion payloads."""
45
+ raw = json.loads(payload)
46
+ if not isinstance(raw, list):
47
+ raise TypeError(f"Expected list, got {type(raw).__name__}")
48
+ return [SpellMiddleware._coerce(item) for item in raw]
49
+
50
+
51
+ def correct_suggestion_to_str(
52
+ record: Dict[str, Any], indent: Optional[int] = None
53
+ ) -> str:
54
+ """Convenience wrapper — serialise a Suggestion to a JSON string."""
55
+ if indent is None:
56
+ return SpellMiddleware.dumps(record)
57
+ return json.dumps(record, indent=indent, default=str)
models.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spell Checker — Suggestion service layer."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class SpellModels:
11
+ """Business-logic service for Suggestion operations in Spell Checker."""
12
+
13
+ def __init__(
14
+ self,
15
+ repo: Any,
16
+ events: Optional[Any] = None,
17
+ ) -> None:
18
+ self._repo = repo
19
+ self._events = events
20
+ logger.debug("SpellModels started")
21
+
22
+ def ignore(
23
+ self, payload: Dict[str, Any]
24
+ ) -> Dict[str, Any]:
25
+ """Execute the ignore workflow for a new Suggestion."""
26
+ if "source" not in payload:
27
+ raise ValueError("Missing required field: source")
28
+ record = self._repo.insert(
29
+ payload["source"], payload.get("confidence"),
30
+ **{k: v for k, v in payload.items()
31
+ if k not in ("source", "confidence")}
32
+ )
33
+ if self._events:
34
+ self._events.emit("suggestion.ignored", record)
35
+ return record
36
+
37
+ def check(self, rec_id: str, **changes: Any) -> Dict[str, Any]:
38
+ """Apply *changes* to a Suggestion and emit a change event."""
39
+ ok = self._repo.update(rec_id, **changes)
40
+ if not ok:
41
+ raise KeyError(f"Suggestion {rec_id!r} not found")
42
+ updated = self._repo.fetch(rec_id)
43
+ if self._events:
44
+ self._events.emit("suggestion.checkd", updated)
45
+ return updated
46
+
47
+ def correct(self, rec_id: str) -> None:
48
+ """Remove a Suggestion and emit a removal event."""
49
+ ok = self._repo.delete(rec_id)
50
+ if not ok:
51
+ raise KeyError(f"Suggestion {rec_id!r} not found")
52
+ if self._events:
53
+ self._events.emit("suggestion.correctd", {"id": rec_id})
54
+
55
+ def search(
56
+ self,
57
+ source: Optional[Any] = None,
58
+ status: Optional[str] = None,
59
+ limit: int = 50,
60
+ ) -> List[Dict[str, Any]]:
61
+ """Search suggestions by *source* and/or *status*."""
62
+ filters: Dict[str, Any] = {}
63
+ if source is not None:
64
+ filters["source"] = source
65
+ if status is not None:
66
+ filters["status"] = status
67
+ rows, _ = self._repo.query(filters, limit=limit)
68
+ logger.debug("search suggestions: %d hits", len(rows))
69
+ return rows
70
+
71
+ @property
72
+ def stats(self) -> Dict[str, int]:
73
+ """Quick summary of Suggestion counts by status."""
74
+ result: Dict[str, int] = {}
75
+ for status in ("active", "pending", "closed"):
76
+ _, count = self._repo.query({"status": status}, limit=0)
77
+ result[status] = count
78
+ return result
repository.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spell Checker — Dictionary service layer."""
2
+ from __future__ import annotations
3
+
4
+ import logging
5
+ from typing import Any, Dict, List, Optional
6
+
7
+ logger = logging.getLogger(__name__)
8
+
9
+
10
+ class SpellRepository:
11
+ """Business-logic service for Dictionary operations in Spell Checker."""
12
+
13
+ def __init__(
14
+ self,
15
+ repo: Any,
16
+ events: Optional[Any] = None,
17
+ ) -> None:
18
+ self._repo = repo
19
+ self._events = events
20
+ logger.debug("SpellRepository started")
21
+
22
+ def export(
23
+ self, payload: Dict[str, Any]
24
+ ) -> Dict[str, Any]:
25
+ """Execute the export workflow for a new Dictionary."""
26
+ if "checked_at" not in payload:
27
+ raise ValueError("Missing required field: checked_at")
28
+ record = self._repo.insert(
29
+ payload["checked_at"], payload.get("correction"),
30
+ **{k: v for k, v in payload.items()
31
+ if k not in ("checked_at", "correction")}
32
+ )
33
+ if self._events:
34
+ self._events.emit("dictionary.exportd", record)
35
+ return record
36
+
37
+ def ignore(self, rec_id: str, **changes: Any) -> Dict[str, Any]:
38
+ """Apply *changes* to a Dictionary and emit a change event."""
39
+ ok = self._repo.update(rec_id, **changes)
40
+ if not ok:
41
+ raise KeyError(f"Dictionary {rec_id!r} not found")
42
+ updated = self._repo.fetch(rec_id)
43
+ if self._events:
44
+ self._events.emit("dictionary.ignored", updated)
45
+ return updated
46
+
47
+ def add(self, rec_id: str) -> None:
48
+ """Remove a Dictionary and emit a removal event."""
49
+ ok = self._repo.delete(rec_id)
50
+ if not ok:
51
+ raise KeyError(f"Dictionary {rec_id!r} not found")
52
+ if self._events:
53
+ self._events.emit("dictionary.addd", {"id": rec_id})
54
+
55
+ def search(
56
+ self,
57
+ checked_at: Optional[Any] = None,
58
+ status: Optional[str] = None,
59
+ limit: int = 50,
60
+ ) -> List[Dict[str, Any]]:
61
+ """Search dictionarys by *checked_at* and/or *status*."""
62
+ filters: Dict[str, Any] = {}
63
+ if checked_at is not None:
64
+ filters["checked_at"] = checked_at
65
+ if status is not None:
66
+ filters["status"] = status
67
+ rows, _ = self._repo.query(filters, limit=limit)
68
+ logger.debug("search dictionarys: %d hits", len(rows))
69
+ return rows
70
+
71
+ @property
72
+ def stats(self) -> Dict[str, int]:
73
+ """Quick summary of Dictionary counts by status."""
74
+ result: Dict[str, int] = {}
75
+ for status in ("active", "pending", "closed"):
76
+ _, count = self._repo.query({"status": status}, limit=0)
77
+ result[status] = count
78
+ return result
router.py ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spell Checker — router for language payloads."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import logging
6
+ from datetime import datetime, timezone
7
+ from typing import Any, Dict, List, Optional
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class SpellRouter:
13
+ """Router for Spell Checker language payloads."""
14
+
15
+ _DATE_FIELDS = ("checked_at")
16
+
17
+ @classmethod
18
+ def loads(cls, raw: str) -> Dict[str, Any]:
19
+ """Deserialise a JSON language payload."""
20
+ data = json.loads(raw)
21
+ return cls._coerce(data)
22
+
23
+ @classmethod
24
+ def dumps(cls, record: Dict[str, Any]) -> str:
25
+ """Serialise a language record to JSON."""
26
+ return json.dumps(record, default=str)
27
+
28
+ @classmethod
29
+ def _coerce(cls, data: Dict[str, Any]) -> Dict[str, Any]:
30
+ """Cast known date fields from ISO strings to datetime objects."""
31
+ out: Dict[str, Any] = {}
32
+ for k, v in data.items():
33
+ if k in cls._DATE_FIELDS and isinstance(v, str):
34
+ try:
35
+ out[k] = datetime.fromisoformat(v)
36
+ except ValueError:
37
+ out[k] = v
38
+ else:
39
+ out[k] = v
40
+ return out
41
+
42
+
43
+ def parse_languages(payload: str) -> List[Dict[str, Any]]:
44
+ """Parse a JSON array of Language payloads."""
45
+ raw = json.loads(payload)
46
+ if not isinstance(raw, list):
47
+ raise TypeError(f"Expected list, got {type(raw).__name__}")
48
+ return [SpellRouter._coerce(item) for item in raw]
49
+
50
+
51
+ def ignore_language_to_str(
52
+ record: Dict[str, Any], indent: Optional[int] = None
53
+ ) -> str:
54
+ """Convenience wrapper — serialise a Language to a JSON string."""
55
+ if indent is None:
56
+ return SpellRouter.dumps(record)
57
+ return json.dumps(record, indent=indent, default=str)