language-ninja 2.2.0__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.
@@ -0,0 +1,3 @@
1
+ """Language Trainer Ninja TUI."""
2
+
3
+ __version__ = "2.2.0"
language_ninja/app.py ADDED
@@ -0,0 +1,420 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ from pathlib import Path
5
+
6
+ from textual.app import App, ComposeResult
7
+ from textual.containers import Horizontal, Vertical
8
+ from textual.widgets import (
9
+ Button,
10
+ DataTable,
11
+ Footer,
12
+ Header,
13
+ Input,
14
+ Label,
15
+ Select,
16
+ Static,
17
+ TabbedContent,
18
+ TabPane,
19
+ TextArea,
20
+ )
21
+
22
+ from .ml import load_dataset, load_model, predict, save_model, train_and_evaluate
23
+ from .storage import Storage, export_records, export_table
24
+
25
+
26
+ ROOT = Path.cwd()
27
+ DATA_DIR = ROOT / "data"
28
+ MODEL_DIR = ROOT / "models"
29
+ EXPORT_DIR = ROOT / "exports"
30
+ DB_PATH = ROOT / "language_ninja.db"
31
+ DEFAULT_DATASET = DATA_DIR / "propositions.csv"
32
+ DEFAULT_MODEL = MODEL_DIR / "sentiment_model.pkl"
33
+
34
+
35
+ class LanguageNinja(App):
36
+ TITLE = "🥷 Language Trainer Ninja"
37
+ SUB_TITLE = "NLP Training • Analysis • Notes • Export"
38
+
39
+ CSS = """
40
+ Screen { layout: vertical; }
41
+ TabPane { padding: 1 2; }
42
+ .section { height: auto; margin-bottom: 1; }
43
+ .panel { border: round $primary; padding: 1; height: auto; }
44
+ .instructions { border: round $secondary; padding: 1; margin-bottom: 1; height: auto; }
45
+ .grow { height: 1fr; }
46
+ .status { height: 7; border: round $accent; padding: 1; }
47
+ #train_status { height: 14; }
48
+ .compact { width: 1fr; }
49
+ Button { margin-right: 1; }
50
+ DataTable { height: 1fr; }
51
+ TextArea { height: 10; }
52
+ #analysis_input { height: 8; }
53
+ #note_body { height: 12; }
54
+ #sql_input { height: 5; }
55
+ """
56
+
57
+ BINDINGS = [
58
+ ("a", "tab('analyze')", "Analyze"),
59
+ ("t", "tab('train')", "Train"),
60
+ ("d", "tab('dataset')", "Dataset"),
61
+ ("n", "tab('notes')", "Notes"),
62
+ ("h", "tab('history')", "History"),
63
+ ("e", "tab('export')", "Export"),
64
+ ("q", "quit", "Quit"),
65
+ ]
66
+
67
+ def __init__(self):
68
+ super().__init__()
69
+ self.storage = Storage(DB_PATH)
70
+ self.classifier = None
71
+ self.vectorizer = None
72
+ self.active_model = DEFAULT_MODEL
73
+ self.last_prediction_id: int | None = None
74
+ self.query_columns: list[str] = []
75
+ self.query_rows: list[tuple] = []
76
+
77
+ def compose(self) -> ComposeResult:
78
+ yield Header(show_clock=True)
79
+ with TabbedContent(initial="analyze", id="tabs"):
80
+ with TabPane("Analyze", id="analyze"):
81
+ yield Static(
82
+ "HOW TO USE • Type or paste a sentence below, then choose Analyze. "
83
+ "The highest calibrated probability becomes the prediction. HIGH means 75%+, MODERATE 55–74%, and LOW below 55%. "
84
+ "All three probabilities are saved to History so uncertainty stays visible.",
85
+ classes="instructions",
86
+ )
87
+ yield Label("Text to analyze")
88
+ yield TextArea(id="analysis_input")
89
+ with Horizontal(classes="section"):
90
+ yield Button("Analyze", id="analyze_btn", variant="primary")
91
+ yield Button("Clear", id="clear_analysis")
92
+ yield Static("Ready. Train or load a model to begin.", id="analysis_result", classes="status")
93
+
94
+ with TabPane("Train", id="train"):
95
+ yield Static(
96
+ "HOW TO USE • Choose a CSV containing text,label columns and select Train Model. "
97
+ "The v2.2 trainer uses word/phrase TF-IDF + character TF-IDF feeding a calibrated Linear SVM. "
98
+ "It evaluates on a stratified holdout set, reports per-class quality, then retrains the active model on all rows.",
99
+ classes="instructions",
100
+ )
101
+ yield Label("Dataset path")
102
+ yield Input(str(DEFAULT_DATASET), id="dataset_path")
103
+ yield Label("Model output path")
104
+ yield Input(str(DEFAULT_MODEL), id="model_path")
105
+ with Horizontal(classes="section"):
106
+ yield Button("Train Model", id="train_btn", variant="primary")
107
+ yield Button("Load Model", id="load_model_btn")
108
+ yield Static("No training run yet.", id="train_status", classes="status")
109
+
110
+ with TabPane("Dataset", id="dataset"):
111
+ yield Static(
112
+ "HOW TO USE • Review the examples that teach the model. Each row needs text and a sentiment label. "
113
+ "The bundled corpus contains 1,500 balanced conversational examples, including negation and mixed-language cases. "
114
+ "Refresh after editing the CSV externally, or export a copy for backup.",
115
+ classes="instructions",
116
+ )
117
+ with Horizontal(classes="section"):
118
+ yield Button("Refresh", id="dataset_refresh", variant="primary")
119
+ yield Button("Export CSV", id="dataset_export_csv")
120
+ yield Button("Export JSON", id="dataset_export_json")
121
+ yield DataTable(id="dataset_table", zebra_stripes=True)
122
+
123
+ with TabPane("Notes", id="notes"):
124
+ yield Static(
125
+ "HOW TO USE • Record observations, experiments, or corrections. Save Note stores a general note. "
126
+ "Link to Last Prediction attaches the note to the most recent analysis so you can document mistakes or edge cases.",
127
+ classes="instructions",
128
+ )
129
+ yield Label("Title")
130
+ yield Input(placeholder="Experiment, correction, observation...", id="note_title")
131
+ yield Label("Note")
132
+ yield TextArea(id="note_body")
133
+ with Horizontal(classes="section"):
134
+ yield Button("Save Note", id="save_note", variant="primary")
135
+ yield Button("Link to Last Prediction", id="link_note")
136
+ yield Button("Export Notes", id="notes_export")
137
+ yield DataTable(id="notes_table", zebra_stripes=True)
138
+
139
+ with TabPane("History", id="history"):
140
+ yield Static(
141
+ "HOW TO USE • Review past predictions, confidence scores, and the model used. "
142
+ "Use this page to spot repeated low-confidence cases and export prediction history for deeper analysis.",
143
+ classes="instructions",
144
+ )
145
+ with Horizontal(classes="section"):
146
+ yield Button("Refresh", id="history_refresh", variant="primary")
147
+ yield Button("Export CSV", id="history_export_csv")
148
+ yield Button("Export JSON", id="history_export_json")
149
+ yield DataTable(id="history_table", zebra_stripes=True)
150
+
151
+ with TabPane("Database", id="database"):
152
+ yield Static(
153
+ "HOW TO USE • Run read-only SELECT queries against the app database. "
154
+ "Use this for advanced inspection of predictions and notes. Query results can be exported to CSV or JSON.",
155
+ classes="instructions",
156
+ )
157
+ yield Label("Read-only SQL")
158
+ yield TextArea("SELECT * FROM predictions ORDER BY id DESC LIMIT 25;", id="sql_input")
159
+ with Horizontal(classes="section"):
160
+ yield Button("Run Query", id="query_run", variant="primary")
161
+ yield Button("Export Query CSV", id="query_export_csv")
162
+ yield Button("Export Query JSON", id="query_export_json")
163
+ yield DataTable(id="query_table", zebra_stripes=True)
164
+
165
+ with TabPane("Export", id="export"):
166
+ yield Static(
167
+ "HOW TO USE • Choose what you want to export and select CSV or JSON, then choose Export Now. "
168
+ "Files are written to the project's ./exports/ directory.",
169
+ classes="instructions",
170
+ )
171
+ yield Static(
172
+ "Exports are written to ./exports/\n\n"
173
+ "Available exports:\n"
174
+ "• Prediction history → CSV / JSON\n"
175
+ "• Notes → JSON\n"
176
+ "• Dataset → CSV / JSON\n"
177
+ "• Database query results → CSV / JSON\n",
178
+ classes="panel",
179
+ )
180
+ with Horizontal(classes="section"):
181
+ yield Select(
182
+ [("Predictions", "predictions"), ("Notes", "notes"), ("Dataset", "dataset")],
183
+ value="predictions",
184
+ id="export_source",
185
+ )
186
+ yield Select([("CSV", "csv"), ("JSON", "json")], value="csv", id="export_format")
187
+ yield Button("Export Now", id="export_now", variant="primary")
188
+ yield Static("Nothing exported yet.", id="export_status", classes="status")
189
+ yield Footer()
190
+
191
+ def on_mount(self) -> None:
192
+ self._setup_tables()
193
+ self.refresh_dataset()
194
+ self.refresh_history()
195
+ self.refresh_notes()
196
+ if self.active_model.exists():
197
+ self._load_model(self.active_model)
198
+
199
+ def _setup_tables(self) -> None:
200
+ dataset = self.query_one("#dataset_table", DataTable)
201
+ dataset.add_columns("#", "Text", "Label")
202
+ history = self.query_one("#history_table", DataTable)
203
+ history.add_columns("ID", "Time", "Text", "Result", "POS", "NEU", "NEG", "Level")
204
+ notes = self.query_one("#notes_table", DataTable)
205
+ notes.add_columns("ID", "Time", "Title", "Body", "Prediction")
206
+
207
+ def action_tab(self, tab_id: str) -> None:
208
+ self.query_one("#tabs", TabbedContent).active = tab_id
209
+
210
+ def _notify_error(self, message: str) -> None:
211
+ self.notify(message, severity="error", timeout=5)
212
+
213
+ def _load_model(self, path: Path) -> None:
214
+ try:
215
+ self.classifier, self.vectorizer = load_model(path)
216
+ self.active_model = path
217
+ self.query_one("#train_status", Static).update(f"Loaded model: {path}")
218
+ except Exception as exc:
219
+ self._notify_error(f"Could not load model: {exc}")
220
+
221
+ def refresh_dataset(self) -> None:
222
+ table = self.query_one("#dataset_table", DataTable)
223
+ table.clear()
224
+ path = Path(self.query_one("#dataset_path", Input).value or DEFAULT_DATASET)
225
+ try:
226
+ with path.open("r", encoding="utf-8", newline="") as handle:
227
+ reader = csv.reader(handle)
228
+ next(reader, None)
229
+ for index, row in enumerate(reader, start=1):
230
+ if len(row) >= 2:
231
+ table.add_row(str(index), row[0], row[1])
232
+ except Exception as exc:
233
+ self._notify_error(f"Dataset: {exc}")
234
+
235
+ def refresh_history(self) -> None:
236
+ table = self.query_one("#history_table", DataTable)
237
+ table.clear()
238
+ for row in self.storage.recent_predictions():
239
+ table.add_row(
240
+ str(row["id"]),
241
+ row["timestamp"],
242
+ row["text"],
243
+ row["sentiment"].upper(),
244
+ f'{row["positive_probability"]:.1%}',
245
+ f'{row["neutral_probability"]:.1%}',
246
+ f'{row["negative_probability"]:.1%}',
247
+ row["confidence_level"],
248
+ )
249
+
250
+ def refresh_notes(self) -> None:
251
+ table = self.query_one("#notes_table", DataTable)
252
+ table.clear()
253
+ for row in self.storage.recent_notes():
254
+ table.add_row(
255
+ str(row["id"]), row["timestamp"], row["title"], row["body"], str(row["linked_prediction_id"] or "")
256
+ )
257
+
258
+ def _export_path(self, stem: str, fmt: str) -> Path:
259
+ EXPORT_DIR.mkdir(parents=True, exist_ok=True)
260
+ return EXPORT_DIR / f"{stem}.{fmt}"
261
+
262
+ def export_predictions(self, fmt: str) -> Path:
263
+ return export_records(self.storage.recent_predictions(100000), self._export_path("predictions", fmt), fmt)
264
+
265
+ def export_notes(self, fmt: str) -> Path:
266
+ return export_records(self.storage.recent_notes(100000), self._export_path("notes", fmt), fmt)
267
+
268
+ def export_dataset(self, fmt: str) -> Path:
269
+ path = Path(self.query_one("#dataset_path", Input).value or DEFAULT_DATASET)
270
+ with path.open("r", encoding="utf-8", newline="") as handle:
271
+ reader = csv.DictReader(handle)
272
+ records = list(reader)
273
+ return export_records(records, self._export_path("dataset", fmt), fmt)
274
+
275
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
276
+ button_id = event.button.id
277
+ try:
278
+ if button_id == "analyze_btn":
279
+ self.analyze_text()
280
+ elif button_id == "clear_analysis":
281
+ self.query_one("#analysis_input", TextArea).text = ""
282
+ self.query_one("#analysis_result", Static).update("Ready.")
283
+ elif button_id == "train_btn":
284
+ self.train_active_dataset()
285
+ elif button_id == "load_model_btn":
286
+ self._load_model(Path(self.query_one("#model_path", Input).value))
287
+ elif button_id == "dataset_refresh":
288
+ self.refresh_dataset()
289
+ elif button_id == "history_refresh":
290
+ self.refresh_history()
291
+ elif button_id == "save_note":
292
+ self.save_note(link=False)
293
+ elif button_id == "link_note":
294
+ self.save_note(link=True)
295
+ elif button_id == "notes_export":
296
+ path = self.export_notes("json")
297
+ self.notify(f"Exported {path}")
298
+ elif button_id == "dataset_export_csv":
299
+ self.notify(f"Exported {self.export_dataset('csv')}")
300
+ elif button_id == "dataset_export_json":
301
+ self.notify(f"Exported {self.export_dataset('json')}")
302
+ elif button_id == "history_export_csv":
303
+ self.notify(f"Exported {self.export_predictions('csv')}")
304
+ elif button_id == "history_export_json":
305
+ self.notify(f"Exported {self.export_predictions('json')}")
306
+ elif button_id == "query_run":
307
+ self.run_query()
308
+ elif button_id == "query_export_csv":
309
+ self.export_query("csv")
310
+ elif button_id == "query_export_json":
311
+ self.export_query("json")
312
+ elif button_id == "export_now":
313
+ self.export_selected()
314
+ except Exception as exc:
315
+ self._notify_error(str(exc))
316
+
317
+ def train_active_dataset(self) -> None:
318
+ dataset_path = Path(self.query_one("#dataset_path", Input).value)
319
+ model_path = Path(self.query_one("#model_path", Input).value)
320
+ texts, labels = load_dataset(dataset_path)
321
+ classifier, vectorizer, report = train_and_evaluate(texts, labels)
322
+ save_model(model_path, classifier, vectorizer)
323
+ self.classifier, self.vectorizer = classifier, vectorizer
324
+ self.active_model = model_path
325
+ if report.accuracy is None:
326
+ metrics = "Evaluation: add more examples per class for a reliable holdout score"
327
+ per_class = ""
328
+ else:
329
+ metrics = f"Holdout accuracy: {report.accuracy:.1%} • Macro F1: {report.macro_f1:.1%}"
330
+ per_class = "\n".join(
331
+ f"{label.upper():8} P {values['precision']:.1%} R {values['recall']:.1%} F1 {values['f1']:.1%}"
332
+ for label, values in report.class_metrics.items()
333
+ )
334
+ self.query_one("#train_status", Static).update(
335
+ "TRAINING COMPLETE\n"
336
+ f"Examples: {report.rows} • {report.labels}\n"
337
+ f"Model: {report.algorithm}\n"
338
+ f"Features: {report.feature_count:,} • Word TF-IDF 1–3 grams • Char TF-IDF 3–5 grams\n"
339
+ f"{metrics}\n{per_class}\n"
340
+ f"Saved: {model_path}"
341
+ )
342
+ self.notify("Model trained and activated.")
343
+
344
+ def analyze_text(self) -> None:
345
+ text = self.query_one("#analysis_input", TextArea).text.strip()
346
+ if not text:
347
+ raise ValueError("Enter text to analyze.")
348
+ if self.classifier is None or self.vectorizer is None:
349
+ raise ValueError("Train or load a model first.")
350
+ result = predict(text, self.classifier, self.vectorizer)
351
+ probabilities = result.probabilities
352
+ self.query_one("#analysis_result", Static).update(
353
+ f"Prediction: {result.label.upper()} • {result.confidence_level} CONFIDENCE\n"
354
+ f"Winning probability: {result.confidence:.1%}\n"
355
+ f"POS {probabilities.get('positive', 0.0):.1%} "
356
+ f"NEU {probabilities.get('neutral', 0.0):.1%} "
357
+ f"NEG {probabilities.get('negative', 0.0):.1%}"
358
+ )
359
+ self.last_prediction_id = self.storage.add_prediction(
360
+ self.active_model.name,
361
+ text,
362
+ result.label,
363
+ result.confidence,
364
+ probabilities=result.probabilities,
365
+ confidence_level=result.confidence_level,
366
+ )
367
+ self.refresh_history()
368
+
369
+ def save_note(self, link: bool) -> None:
370
+ title = self.query_one("#note_title", Input).value.strip() or "Untitled note"
371
+ body = self.query_one("#note_body", TextArea).text.strip()
372
+ if not body:
373
+ raise ValueError("Write a note before saving.")
374
+ linked = self.last_prediction_id if link else None
375
+ if link and linked is None:
376
+ raise ValueError("There is no prediction to link this note to yet.")
377
+ self.storage.add_note(title, body, linked)
378
+ self.query_one("#note_title", Input).value = ""
379
+ self.query_one("#note_body", TextArea).text = ""
380
+ self.refresh_notes()
381
+ self.notify("Note saved.")
382
+
383
+ def run_query(self) -> None:
384
+ query = self.query_one("#sql_input", TextArea).text.strip()
385
+ columns, rows = self.storage.execute_read_query(query)
386
+ self.query_columns = list(columns)
387
+ self.query_rows = [tuple(row) for row in rows]
388
+ table = self.query_one("#query_table", DataTable)
389
+ table.clear(columns=True)
390
+ if columns:
391
+ table.add_columns(*columns)
392
+ for row in rows:
393
+ table.add_row(*[str(value) for value in row])
394
+ self.notify(f"Query returned {len(rows)} row(s).")
395
+
396
+ def export_query(self, fmt: str) -> None:
397
+ if not self.query_columns:
398
+ self.run_query()
399
+ path = export_table(self.query_columns, self.query_rows, self._export_path("query-results", fmt), fmt)
400
+ self.notify(f"Exported {path}")
401
+
402
+ def export_selected(self) -> None:
403
+ source = self.query_one("#export_source", Select).value
404
+ fmt = self.query_one("#export_format", Select).value
405
+ if source == "predictions":
406
+ path = self.export_predictions(str(fmt))
407
+ elif source == "notes":
408
+ path = self.export_notes(str(fmt))
409
+ else:
410
+ path = self.export_dataset(str(fmt))
411
+ self.query_one("#export_status", Static).update(f"Export complete:\n{path}")
412
+ self.notify(f"Exported {path}")
413
+
414
+
415
+ def main() -> None:
416
+ LanguageNinja().run()
417
+
418
+
419
+ if __name__ == "__main__":
420
+ main()
language_ninja/ml.py ADDED
@@ -0,0 +1,224 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import pickle
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Iterable
8
+
9
+ from sklearn.calibration import CalibratedClassifierCV
10
+ from sklearn.feature_extraction.text import TfidfVectorizer
11
+ from sklearn.linear_model import LogisticRegression
12
+ from sklearn.metrics import accuracy_score, classification_report, confusion_matrix, f1_score
13
+ from sklearn.model_selection import train_test_split
14
+ from sklearn.pipeline import FeatureUnion
15
+ from sklearn.svm import LinearSVC
16
+
17
+ MODEL_VERSION = "2.2"
18
+
19
+
20
+ @dataclass(slots=True)
21
+ class Prediction:
22
+ label: str
23
+ confidence: float
24
+ probabilities: dict[str, float]
25
+
26
+ @property
27
+ def confidence_level(self) -> str:
28
+ if self.confidence >= 0.75:
29
+ return "HIGH"
30
+ if self.confidence >= 0.55:
31
+ return "MODERATE"
32
+ return "LOW"
33
+
34
+
35
+ @dataclass(slots=True)
36
+ class TrainingReport:
37
+ rows: int
38
+ labels: dict[str, int]
39
+ accuracy: float | None
40
+ macro_f1: float | None
41
+ report: str
42
+ feature_count: int
43
+ algorithm: str
44
+ class_metrics: dict[str, dict[str, float]] = field(default_factory=dict)
45
+ confusion: list[list[int]] = field(default_factory=list)
46
+ label_order: list[str] = field(default_factory=list)
47
+
48
+
49
+ def load_dataset(path: str | Path) -> tuple[list[str], list[str]]:
50
+ texts: list[str] = []
51
+ labels: list[str] = []
52
+ with Path(path).open("r", encoding="utf-8", newline="") as handle:
53
+ reader = csv.DictReader(handle)
54
+ if not reader.fieldnames or "text" not in reader.fieldnames or "label" not in reader.fieldnames:
55
+ raise ValueError("Dataset must contain text,label columns.")
56
+ for row in reader:
57
+ text = (row.get("text") or "").strip()
58
+ label = (row.get("label") or "").strip().lower()
59
+ if not text or not label:
60
+ continue
61
+ texts.append(text)
62
+ labels.append(label)
63
+ if not texts:
64
+ raise ValueError("Dataset contains no usable text/label rows.")
65
+ if len(set(labels)) < 2:
66
+ raise ValueError("Dataset needs at least two different labels.")
67
+ return texts, labels
68
+
69
+
70
+ def build_vectorizer() -> FeatureUnion:
71
+ # Word n-grams capture phrases such as "not good", "really love", and
72
+ # contrast constructions. Character n-grams improve resilience to spelling,
73
+ # contractions, punctuation, and unseen word forms.
74
+ word = TfidfVectorizer(
75
+ lowercase=True,
76
+ analyzer="word",
77
+ ngram_range=(1, 3),
78
+ sublinear_tf=True,
79
+ min_df=1,
80
+ max_df=0.995,
81
+ max_features=30000,
82
+ strip_accents="unicode",
83
+ )
84
+ char = TfidfVectorizer(
85
+ lowercase=True,
86
+ analyzer="char_wb",
87
+ ngram_range=(3, 5),
88
+ sublinear_tf=True,
89
+ min_df=1,
90
+ max_features=30000,
91
+ strip_accents="unicode",
92
+ )
93
+ return FeatureUnion(
94
+ [("word", word), ("char", char)],
95
+ transformer_weights={"word": 1.4, "char": 0.7},
96
+ )
97
+
98
+
99
+ def _fit_classifier(matrix, labels: list[str]):
100
+ counts = {label: labels.count(label) for label in set(labels)}
101
+ min_class = min(counts.values())
102
+
103
+ if min_class >= 5:
104
+ # Linear SVM gives a strong text decision boundary. Sigmoid calibration
105
+ # converts its margins into probabilities that are more meaningful for
106
+ # the TUI confidence display.
107
+ base = LinearSVC(C=1.5, class_weight="balanced", random_state=42)
108
+ classifier = CalibratedClassifierCV(base, method="sigmoid", cv=5)
109
+ classifier.fit(matrix, labels)
110
+ return classifier, "Calibrated Linear SVM (5-fold sigmoid)"
111
+
112
+ # Small custom datasets cannot support 5-fold calibration. Keep a reliable
113
+ # probability-capable fallback so users can still train their own corpus.
114
+ classifier = LogisticRegression(
115
+ max_iter=3000,
116
+ C=2.0,
117
+ class_weight="balanced",
118
+ solver="lbfgs",
119
+ random_state=42,
120
+ )
121
+ classifier.fit(matrix, labels)
122
+ return classifier, "Balanced Logistic Regression (small-data fallback)"
123
+
124
+
125
+ def train_model(texts: Iterable[str], labels: Iterable[str]):
126
+ texts = list(texts)
127
+ labels = list(labels)
128
+ vectorizer = build_vectorizer()
129
+ matrix = vectorizer.fit_transform(texts)
130
+ classifier, algorithm = _fit_classifier(matrix, labels)
131
+ return classifier, vectorizer, matrix.shape[1], algorithm
132
+
133
+
134
+ def train_and_evaluate(texts: Iterable[str], labels: Iterable[str]):
135
+ texts = list(texts)
136
+ labels = list(labels)
137
+ label_order = sorted(set(labels))
138
+ label_counts = {label: labels.count(label) for label in label_order}
139
+
140
+ accuracy = None
141
+ macro_f1 = None
142
+ report_text = "Dataset is too small for a reliable holdout evaluation. Add more examples per label."
143
+ class_metrics: dict[str, dict[str, float]] = {}
144
+ matrix_rows: list[list[int]] = []
145
+
146
+ min_class = min(label_counts.values())
147
+ if len(texts) >= 30 and min_class >= 8:
148
+ x_train, x_test, y_train, y_test = train_test_split(
149
+ texts,
150
+ labels,
151
+ test_size=0.20,
152
+ random_state=31415,
153
+ stratify=labels,
154
+ )
155
+ eval_clf, eval_vec, _, _ = train_model(x_train, y_train)
156
+ transformed = eval_vec.transform(x_test)
157
+ predictions = eval_clf.predict(transformed)
158
+ accuracy = float(accuracy_score(y_test, predictions))
159
+ macro_f1 = float(f1_score(y_test, predictions, average="macro", zero_division=0))
160
+ report_text = classification_report(y_test, predictions, labels=label_order, zero_division=0)
161
+ report_dict = classification_report(
162
+ y_test, predictions, labels=label_order, output_dict=True, zero_division=0
163
+ )
164
+ class_metrics = {
165
+ label: {
166
+ "precision": float(report_dict[label]["precision"]),
167
+ "recall": float(report_dict[label]["recall"]),
168
+ "f1": float(report_dict[label]["f1-score"]),
169
+ "support": float(report_dict[label]["support"]),
170
+ }
171
+ for label in label_order
172
+ }
173
+ matrix_rows = confusion_matrix(y_test, predictions, labels=label_order).tolist()
174
+
175
+ classifier, vectorizer, feature_count, algorithm = train_model(texts, labels)
176
+ return classifier, vectorizer, TrainingReport(
177
+ rows=len(texts),
178
+ labels=label_counts,
179
+ accuracy=accuracy,
180
+ macro_f1=macro_f1,
181
+ report=report_text,
182
+ feature_count=feature_count,
183
+ algorithm=algorithm,
184
+ class_metrics=class_metrics,
185
+ confusion=matrix_rows,
186
+ label_order=label_order,
187
+ )
188
+
189
+
190
+ def save_model(path: str | Path, classifier, vectorizer) -> None:
191
+ path = Path(path)
192
+ path.parent.mkdir(parents=True, exist_ok=True)
193
+ payload = {
194
+ "version": MODEL_VERSION,
195
+ "classifier": classifier,
196
+ "vectorizer": vectorizer,
197
+ }
198
+ with path.open("wb") as handle:
199
+ pickle.dump(payload, handle)
200
+
201
+
202
+ def load_model(path: str | Path):
203
+ with Path(path).open("rb") as handle:
204
+ payload = pickle.load(handle)
205
+ # Backward compatibility with v2.0/v2.1 tuple model files.
206
+ if isinstance(payload, tuple) and len(payload) == 2:
207
+ return payload
208
+ if isinstance(payload, dict) and "classifier" in payload and "vectorizer" in payload:
209
+ return payload["classifier"], payload["vectorizer"]
210
+ raise ValueError("Unsupported model file format.")
211
+
212
+
213
+ def predict(text: str, classifier, vectorizer) -> Prediction:
214
+ matrix = vectorizer.transform([text])
215
+ label = str(classifier.predict(matrix)[0])
216
+ probabilities: dict[str, float] = {}
217
+ confidence = 1.0
218
+ if hasattr(classifier, "predict_proba"):
219
+ values = classifier.predict_proba(matrix)[0]
220
+ probabilities = {
221
+ str(name): float(value) for name, value in zip(classifier.classes_, values)
222
+ }
223
+ confidence = probabilities.get(label, max(probabilities.values(), default=1.0))
224
+ return Prediction(label=label, confidence=confidence, probabilities=probabilities)
@@ -0,0 +1,145 @@
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import sqlite3
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Iterable, Mapping, Sequence
9
+
10
+
11
+ class Storage:
12
+ def __init__(self, db_path: str | Path):
13
+ self.db_path = Path(db_path)
14
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
15
+ self.connection = sqlite3.connect(self.db_path)
16
+ self.connection.row_factory = sqlite3.Row
17
+ self._migrate()
18
+
19
+ def _columns(self, table: str) -> set[str]:
20
+ rows = self.connection.execute(f"PRAGMA table_info({table})").fetchall()
21
+ return {str(row["name"]) for row in rows}
22
+
23
+ def _migrate(self) -> None:
24
+ self.connection.executescript(
25
+ """
26
+ CREATE TABLE IF NOT EXISTS predictions (
27
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
28
+ timestamp TEXT NOT NULL,
29
+ model TEXT NOT NULL,
30
+ text TEXT NOT NULL,
31
+ sentiment TEXT NOT NULL,
32
+ confidence REAL NOT NULL DEFAULT 0
33
+ );
34
+ CREATE TABLE IF NOT EXISTS notes (
35
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
36
+ timestamp TEXT NOT NULL,
37
+ title TEXT NOT NULL,
38
+ body TEXT NOT NULL,
39
+ linked_prediction_id INTEGER,
40
+ FOREIGN KEY(linked_prediction_id) REFERENCES predictions(id)
41
+ );
42
+ """
43
+ )
44
+ prediction_columns = self._columns("predictions")
45
+ additions = {
46
+ "positive_probability": "REAL NOT NULL DEFAULT 0",
47
+ "neutral_probability": "REAL NOT NULL DEFAULT 0",
48
+ "negative_probability": "REAL NOT NULL DEFAULT 0",
49
+ "confidence_level": "TEXT NOT NULL DEFAULT 'LOW'",
50
+ }
51
+ for name, definition in additions.items():
52
+ if name not in prediction_columns:
53
+ self.connection.execute(f"ALTER TABLE predictions ADD COLUMN {name} {definition}")
54
+ self.connection.commit()
55
+
56
+ def add_prediction(
57
+ self,
58
+ model: str,
59
+ text: str,
60
+ sentiment: str,
61
+ confidence: float,
62
+ probabilities: Mapping[str, float] | None = None,
63
+ confidence_level: str = "LOW",
64
+ ) -> int:
65
+ probabilities = probabilities or {}
66
+ cursor = self.connection.execute(
67
+ """
68
+ INSERT INTO predictions(
69
+ timestamp, model, text, sentiment, confidence,
70
+ positive_probability, neutral_probability, negative_probability,
71
+ confidence_level
72
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
73
+ """,
74
+ (
75
+ datetime.now().isoformat(timespec="seconds"),
76
+ model,
77
+ text,
78
+ sentiment,
79
+ confidence,
80
+ float(probabilities.get("positive", 0.0)),
81
+ float(probabilities.get("neutral", 0.0)),
82
+ float(probabilities.get("negative", 0.0)),
83
+ confidence_level,
84
+ ),
85
+ )
86
+ self.connection.commit()
87
+ return int(cursor.lastrowid)
88
+
89
+ def add_note(self, title: str, body: str, linked_prediction_id: int | None = None) -> int:
90
+ cursor = self.connection.execute(
91
+ "INSERT INTO notes(timestamp, title, body, linked_prediction_id) VALUES (?, ?, ?, ?)",
92
+ (datetime.now().isoformat(timespec="seconds"), title, body, linked_prediction_id),
93
+ )
94
+ self.connection.commit()
95
+ return int(cursor.lastrowid)
96
+
97
+ def recent_predictions(self, limit: int = 250):
98
+ return self.connection.execute(
99
+ """
100
+ SELECT id, timestamp, model, text, sentiment, confidence,
101
+ positive_probability, neutral_probability, negative_probability,
102
+ confidence_level
103
+ FROM predictions ORDER BY id DESC LIMIT ?
104
+ """,
105
+ (limit,),
106
+ ).fetchall()
107
+
108
+ def recent_notes(self, limit: int = 250):
109
+ return self.connection.execute(
110
+ "SELECT id, timestamp, title, body, linked_prediction_id FROM notes ORDER BY id DESC LIMIT ?",
111
+ (limit,),
112
+ ).fetchall()
113
+
114
+ def execute_read_query(self, query: str):
115
+ statement = query.strip().lower()
116
+ if not (statement.startswith("select") or statement.startswith("pragma") or statement.startswith("with")):
117
+ raise ValueError("Database tab is read-only. Use SELECT, WITH, or PRAGMA queries.")
118
+ cursor = self.connection.execute(query)
119
+ columns = [item[0] for item in cursor.description or []]
120
+ rows = cursor.fetchall()
121
+ return columns, rows
122
+
123
+
124
+ def export_records(records: Iterable[Mapping], path: str | Path, fmt: str) -> Path:
125
+ path = Path(path)
126
+ path.parent.mkdir(parents=True, exist_ok=True)
127
+ rows = [dict(row) for row in records]
128
+ fmt = fmt.lower()
129
+ if fmt == "json":
130
+ path.write_text(json.dumps(rows, indent=2, ensure_ascii=False), encoding="utf-8")
131
+ return path
132
+ if fmt == "csv":
133
+ fields = list(rows[0].keys()) if rows else []
134
+ with path.open("w", newline="", encoding="utf-8") as handle:
135
+ writer = csv.DictWriter(handle, fieldnames=fields)
136
+ if fields:
137
+ writer.writeheader()
138
+ writer.writerows(rows)
139
+ return path
140
+ raise ValueError(f"Unsupported export format: {fmt}")
141
+
142
+
143
+ def export_table(columns: Sequence[str], rows: Sequence[Sequence], path: str | Path, fmt: str) -> Path:
144
+ dictionaries = [dict(zip(columns, row)) for row in rows]
145
+ return export_records(dictionaries, path, fmt)
@@ -0,0 +1,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: language-ninja
3
+ Version: 2.2.0
4
+ Summary: Keyboard-first Textual NLP sentiment training, analysis, notes, and export workstation.
5
+ Requires-Python: >=3.10
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: textual>=0.70
8
+ Requires-Dist: scikit-learn>=1.3
9
+
10
+ # 🥷 Language Trainer Ninja v2.2
11
+
12
+ A keyboard-first Textual NLP workstation for sentiment analysis, model training, dataset inspection, notes, history, SQLite queries, and CSV/JSON export.
13
+
14
+ ## v2.2 model
15
+
16
+ The bundled model is now a calibrated classical NLP pipeline rather than the earlier small Naive Bayes/logistic baseline:
17
+
18
+ ```text
19
+ Word TF-IDF (1–3 grams) ─┐
20
+ ├─ FeatureUnion ─ Linear SVM ─ 5-fold sigmoid calibration ─ probabilities
21
+ Char TF-IDF (3–5 grams) ─┘
22
+ ```
23
+
24
+ Why both feature families:
25
+
26
+ - Word n-grams capture phrases such as `not good`, `really love`, and longer contrast patterns.
27
+ - Character n-grams improve handling of contractions, punctuation, spelling variation, and unseen word forms.
28
+ - A balanced Linear SVM supplies a strong decision boundary for sparse text.
29
+ - Sigmoid calibration turns SVM margins into class probabilities for the confidence display.
30
+
31
+ The starter corpus contains **1,500 balanced examples**:
32
+
33
+ ```text
34
+ positive 500
35
+ neutral 500
36
+ negative 500
37
+ ```
38
+
39
+ It includes straightforward sentiment, conversational phrasing, negation, contrast, and neutral operational language.
40
+
41
+ > The built-in holdout score is useful as a regression check, but the starter corpus contains generated/template-assisted examples. Treat its very high holdout accuracy as an internal sanity metric, not as proof of production-level real-world accuracy.
42
+
43
+ ## Install
44
+
45
+ ```bash
46
+ cd ~/Downloads/language-trainer-ninja-master
47
+ python3 -m venv .venv
48
+ source .venv/bin/activate
49
+ python -m pip install --upgrade pip
50
+ python -m pip install -e .
51
+ language-ninja
52
+ ```
53
+
54
+ A trained `models/sentiment_model.pkl` is included, so Analyze can work immediately. Use **Train → Train Model** after changing the dataset.
55
+
56
+ ## Confidence
57
+
58
+ The TUI displays all three calibrated probabilities:
59
+
60
+ ```text
61
+ Prediction: NEGATIVE • HIGH CONFIDENCE
62
+ Winning probability: 82.8%
63
+ POS 9.5% NEU 7.6% NEG 82.8%
64
+ ```
65
+
66
+ UI guidance:
67
+
68
+ ```text
69
+ HIGH 75–100%
70
+ MODERATE 55–74%
71
+ LOW below 55%
72
+ ```
73
+
74
+ These labels describe the model's probability separation, not a guarantee of correctness.
75
+
76
+ ## Pages
77
+
78
+ ### Analyze
79
+ Paste conversational text and inspect the winning class plus positive, neutral, and negative probabilities. Every prediction is written to History.
80
+
81
+ ### Train
82
+ Train from a `text,label` CSV. The status panel reports row counts, algorithm, feature count, holdout accuracy, macro F1, and per-class precision/recall/F1.
83
+
84
+ ### Dataset
85
+ Inspect the examples used to train the model and export them to CSV or JSON.
86
+
87
+ ### Notes
88
+ Save experiment notes or attach a note to the most recent prediction.
89
+
90
+ ### History
91
+ Review every prediction with **POS / NEU / NEG probabilities** and a HIGH/MODERATE/LOW confidence level.
92
+
93
+ ### Database
94
+ Run read-only `SELECT`, `WITH`, and `PRAGMA` queries against the SQLite database.
95
+
96
+ ### Export
97
+ Export predictions, notes, datasets, and query results to `./exports/`.
98
+
99
+ ## Rebuild the bundled corpus
100
+
101
+ The deterministic corpus generator is included:
102
+
103
+ ```bash
104
+ python tools/build_starter_corpus.py
105
+ ```
106
+
107
+ Then retrain from the TUI, or use the Python ML module.
108
+
109
+ ## Run tests
110
+
111
+ ```bash
112
+ python -m pytest -q
113
+ ```
@@ -0,0 +1,9 @@
1
+ language_ninja/__init__.py,sha256=C1jnASu9vAAYi0tbSoYd0iS6fhn17rGmG0MAfjz_hzc,57
2
+ language_ninja/app.py,sha256=ZBf15ldRH3ap0R2TJkhT76fMpxf0kKwjOSBwvg0JUSk,19182
3
+ language_ninja/ml.py,sha256=nf17EjjgZX8gcydxPipF_kjGu4PUUgrA9aACu9jJw48,8025
4
+ language_ninja/storage.py,sha256=Inl3JJWYBYmj76QSVlOPEJ-j223Z652pQSl2J3gf1IY,5611
5
+ language_ninja-2.2.0.dist-info/METADATA,sha256=JO0iVvNwYsmJGQFLzWLYIvJvWIArQCs8QP2cc9SoOWk,3433
6
+ language_ninja-2.2.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ language_ninja-2.2.0.dist-info/entry_points.txt,sha256=sTumvmVtmHMisDscoY9wSp9WOfOqwb-YJL71tlYdw6g,59
8
+ language_ninja-2.2.0.dist-info/top_level.txt,sha256=6eGQJCGVGOEDWRUHKG_-A__CD5JNvSbu36YcBEZxxmQ,15
9
+ language_ninja-2.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ language-ninja = language_ninja.app:main
@@ -0,0 +1 @@
1
+ language_ninja