ollama-classifier 0.1.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,30 @@
1
+ """Ollama Classifier - A wrapper around Ollama Python SDK for text classification.
2
+
3
+ This package provides a classifier for text classification with constrained output
4
+ and confidence scoring using the Ollama Python SDK.
5
+
6
+ Example usage:
7
+ from ollama import Client
8
+ from ollama_classifier import OllamaClassifier, ClassificationResult
9
+
10
+ client = Client()
11
+ classifier = OllamaClassifier(client, model="llama3.2")
12
+
13
+ result = classifier.classify(
14
+ text="I love this product!",
15
+ choices=["positive", "negative", "neutral"]
16
+ )
17
+ print(f"Prediction: {result.prediction}")
18
+ print(f"Confidence: {result.confidence:.2%}")
19
+ """
20
+
21
+ from .types import ClassificationResult, ChoicesType
22
+ from .classifier import OllamaClassifier
23
+
24
+ __all__ = [
25
+ "OllamaClassifier",
26
+ "ClassificationResult",
27
+ "ChoicesType",
28
+ ]
29
+
30
+ __version__ = "0.1.0"
@@ -0,0 +1,845 @@
1
+ """Main classifier module for ollama-classifier."""
2
+
3
+ import json
4
+ import math
5
+ from typing import Union, List, Dict, Any
6
+
7
+ from ollama import Client, AsyncClient
8
+
9
+ from .types import ClassificationResult, ChoicesType
10
+ from .prompts import (
11
+ build_classification_prompt,
12
+ get_choice_labels,
13
+ build_json_schema_for_choices,
14
+ )
15
+
16
+
17
+ class OllamaClassifier:
18
+ """A classifier wrapper around Ollama client for text classification.
19
+
20
+ This class provides methods for classifying text into a set of predefined choices
21
+ with support for both fast (single-call) and complete (multi-call) scoring methods.
22
+
23
+ Attributes:
24
+ _client: The Ollama client (sync or async).
25
+ _model: The model name to use for classification.
26
+ """
27
+
28
+ def __init__(self, client: Union[Client, AsyncClient], model: str):
29
+ """Initialize the classifier.
30
+
31
+ Args:
32
+ client: An initialized Ollama Client or AsyncClient instance.
33
+ model: The model name to use for classification (e.g., "llama3.2").
34
+ """
35
+ self._client = client
36
+ self._model = model
37
+
38
+ # =========================================================================
39
+ # Sync Methods - Generate
40
+ # =========================================================================
41
+
42
+ def generate(
43
+ self,
44
+ text: str,
45
+ choices: ChoicesType,
46
+ system_prompt: str | None = None,
47
+ ) -> str:
48
+ """Generate a constrained classification for a single text.
49
+
50
+ Uses JSON schema with enum constraint to ensure only valid choices are generated.
51
+ This is the fastest method as it only makes one API call and doesn't compute
52
+ confidence scores.
53
+
54
+ Args:
55
+ text: The text to classify.
56
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
57
+ system_prompt: Optional custom system prompt.
58
+
59
+ Returns:
60
+ The predicted choice label.
61
+ """
62
+ labels = get_choice_labels(choices)
63
+ system, user = build_classification_prompt(text, choices, system_prompt)
64
+ schema = build_json_schema_for_choices(labels)
65
+
66
+ messages = [
67
+ {"role": "system", "content": system},
68
+ {"role": "user", "content": user},
69
+ ]
70
+
71
+ response = self._client.chat(
72
+ model=self._model,
73
+ messages=messages,
74
+ format=schema,
75
+ options={"temperature": 0.0},
76
+ )
77
+
78
+ content = response.get("message", {}).get("content", "{}")
79
+ result = json.loads(content)
80
+ return result.get("label", "")
81
+
82
+ def batch_generate(
83
+ self,
84
+ texts: List[str],
85
+ choices: ChoicesType,
86
+ system_prompt: str | None = None,
87
+ ) -> List[str]:
88
+ """Generate constrained classifications for multiple texts.
89
+
90
+ Args:
91
+ texts: List of texts to classify.
92
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
93
+ system_prompt: Optional custom system prompt.
94
+
95
+ Returns:
96
+ List of predicted choice labels, one per input text.
97
+ """
98
+ return [self.generate(text, choices, system_prompt) for text in texts]
99
+
100
+ # =========================================================================
101
+ # Sync Methods - Score Fast (Single-call logprob extraction)
102
+ # =========================================================================
103
+
104
+ def score_fast(
105
+ self,
106
+ text: str,
107
+ choices: ChoicesType,
108
+ system_prompt: str | None = None,
109
+ ) -> ClassificationResult:
110
+ """Score a classification using single-call logprob extraction.
111
+
112
+ Makes one API call with logprobs enabled and extracts the probability
113
+ distribution from the token distribution at the decision point.
114
+ This is faster but may be less reliable for capturing all choice probabilities.
115
+
116
+ Args:
117
+ text: The text to classify.
118
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
119
+ system_prompt: Optional custom system prompt.
120
+
121
+ Returns:
122
+ ClassificationResult with prediction, confidence, and probabilities.
123
+ """
124
+ labels = get_choice_labels(choices)
125
+ system, user = build_classification_prompt(text, choices, system_prompt)
126
+ schema = build_json_schema_for_choices(labels)
127
+
128
+ messages = [
129
+ {"role": "system", "content": system},
130
+ {"role": "user", "content": user},
131
+ ]
132
+
133
+ response = self._client.chat(
134
+ model=self._model,
135
+ messages=messages,
136
+ format=schema,
137
+ logprobs=True,
138
+ top_logprobs=20,
139
+ options={"temperature": 0.0},
140
+ )
141
+
142
+ # Extract prediction from response
143
+ content = response.get("message", {}).get("content", "{}")
144
+ result = json.loads(content)
145
+ prediction = result.get("label", "")
146
+
147
+ # Extract logprobs from token distribution
148
+ probabilities = self._extract_probabilities_from_logprobs(
149
+ response, labels
150
+ )
151
+
152
+ return ClassificationResult(
153
+ prediction=prediction,
154
+ confidence=probabilities.get(prediction, 0.0),
155
+ probabilities=probabilities,
156
+ raw_response=response,
157
+ )
158
+
159
+ def batch_score_fast(
160
+ self,
161
+ texts: List[str],
162
+ choices: ChoicesType,
163
+ system_prompt: str | None = None,
164
+ ) -> List[ClassificationResult]:
165
+ """Score multiple texts using fast single-call method.
166
+
167
+ Args:
168
+ texts: List of texts to classify.
169
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
170
+ system_prompt: Optional custom system prompt.
171
+
172
+ Returns:
173
+ List of ClassificationResults, one per input text.
174
+ """
175
+ return [self.score_fast(text, choices, system_prompt) for text in texts]
176
+
177
+ # =========================================================================
178
+ # Sync Methods - Score Complete (Multi-call evaluation)
179
+ # =========================================================================
180
+
181
+ def score_complete(
182
+ self,
183
+ text: str,
184
+ choices: ChoicesType,
185
+ system_prompt: str | None = None,
186
+ ) -> ClassificationResult:
187
+ """Score a classification using multi-call evaluation with softmax.
188
+
189
+ Makes separate API calls for each choice to compute log P(choice|context),
190
+ then applies softmax for calibrated probabilities.
191
+ This is more accurate but slower (N API calls for N choices).
192
+
193
+ Args:
194
+ text: The text to classify.
195
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
196
+ system_prompt: Optional custom system prompt.
197
+
198
+ Returns:
199
+ ClassificationResult with prediction, confidence, and probabilities.
200
+ """
201
+ labels = get_choice_labels(choices)
202
+ system, user = build_classification_prompt(text, choices, system_prompt)
203
+
204
+ # Compute logprob for each choice
205
+ logprobs: Dict[str, float] = {}
206
+ for label in labels:
207
+ logprobs[label] = self._get_choice_logprob(
208
+ system, user, label
209
+ )
210
+
211
+ # Apply softmax to get probabilities
212
+ probabilities = self._softmax(logprobs)
213
+
214
+ # Get prediction (highest probability)
215
+ prediction = max(probabilities, key=probabilities.get)
216
+
217
+ return ClassificationResult(
218
+ prediction=prediction,
219
+ confidence=probabilities[prediction],
220
+ probabilities=probabilities,
221
+ raw_response={"logprobs": logprobs},
222
+ )
223
+
224
+ def batch_score_complete(
225
+ self,
226
+ texts: List[str],
227
+ choices: ChoicesType,
228
+ system_prompt: str | None = None,
229
+ ) -> List[ClassificationResult]:
230
+ """Score multiple texts using complete multi-call method.
231
+
232
+ Args:
233
+ texts: List of texts to classify.
234
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
235
+ system_prompt: Optional custom system prompt.
236
+
237
+ Returns:
238
+ List of ClassificationResults, one per input text.
239
+ """
240
+ return [self.score_complete(text, choices, system_prompt) for text in texts]
241
+
242
+ # =========================================================================
243
+ # Sync Methods - Classify (Generate + Score)
244
+ # =========================================================================
245
+
246
+ def classify(
247
+ self,
248
+ text: str,
249
+ choices: ChoicesType,
250
+ system_prompt: str | None = None,
251
+ ) -> ClassificationResult:
252
+ """Classify text with constrained generation and fast scoring.
253
+
254
+ Combines generate() and score_fast() for a single classification
255
+ with confidence scores.
256
+
257
+ Args:
258
+ text: The text to classify.
259
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
260
+ system_prompt: Optional custom system prompt.
261
+
262
+ Returns:
263
+ ClassificationResult with prediction, confidence, and probabilities.
264
+ """
265
+ return self.score_fast(text, choices, system_prompt)
266
+
267
+ def classify_complete(
268
+ self,
269
+ text: str,
270
+ choices: ChoicesType,
271
+ system_prompt: str | None = None,
272
+ ) -> ClassificationResult:
273
+ """Classify text with constrained generation and complete scoring.
274
+
275
+ Combines generate() and score_complete() for accurate classification
276
+ with calibrated confidence scores.
277
+
278
+ Args:
279
+ text: The text to classify.
280
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
281
+ system_prompt: Optional custom system prompt.
282
+
283
+ Returns:
284
+ ClassificationResult with prediction, confidence, and probabilities.
285
+ """
286
+ # Use generate for constrained prediction
287
+ prediction = self.generate(text, choices, system_prompt)
288
+
289
+ # Use score_complete for probabilities
290
+ result = self.score_complete(text, choices, system_prompt)
291
+
292
+ # Override prediction with constrained output
293
+ result.prediction = prediction
294
+ result.confidence = result.probabilities.get(prediction, 0.0)
295
+
296
+ return result
297
+
298
+ def batch_classify(
299
+ self,
300
+ texts: List[str],
301
+ choices: ChoicesType,
302
+ system_prompt: str | None = None,
303
+ ) -> List[ClassificationResult]:
304
+ """Classify multiple texts with fast scoring.
305
+
306
+ Args:
307
+ texts: List of texts to classify.
308
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
309
+ system_prompt: Optional custom system prompt.
310
+
311
+ Returns:
312
+ List of ClassificationResults, one per input text.
313
+ """
314
+ return [self.classify(text, choices, system_prompt) for text in texts]
315
+
316
+ def batch_classify_complete(
317
+ self,
318
+ texts: List[str],
319
+ choices: ChoicesType,
320
+ system_prompt: str | None = None,
321
+ ) -> List[ClassificationResult]:
322
+ """Classify multiple texts with complete scoring.
323
+
324
+ Args:
325
+ texts: List of texts to classify.
326
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
327
+ system_prompt: Optional custom system prompt.
328
+
329
+ Returns:
330
+ List of ClassificationResults, one per input text.
331
+ """
332
+ return [self.classify_complete(text, choices, system_prompt) for text in texts]
333
+
334
+ # =========================================================================
335
+ # Async Methods - Generate
336
+ # =========================================================================
337
+
338
+ async def agenerate(
339
+ self,
340
+ text: str,
341
+ choices: ChoicesType,
342
+ system_prompt: str | None = None,
343
+ ) -> str:
344
+ """Async version of generate().
345
+
346
+ Generate a constrained classification for a single text.
347
+
348
+ Args:
349
+ text: The text to classify.
350
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
351
+ system_prompt: Optional custom system prompt.
352
+
353
+ Returns:
354
+ The predicted choice label.
355
+ """
356
+ labels = get_choice_labels(choices)
357
+ system, user = build_classification_prompt(text, choices, system_prompt)
358
+ schema = build_json_schema_for_choices(labels)
359
+
360
+ messages = [
361
+ {"role": "system", "content": system},
362
+ {"role": "user", "content": user},
363
+ ]
364
+
365
+ response = await self._client.chat(
366
+ model=self._model,
367
+ messages=messages,
368
+ format=schema,
369
+ options={"temperature": 0.0},
370
+ )
371
+
372
+ content = response.get("message", {}).get("content", "{}")
373
+ result = json.loads(content)
374
+ return result.get("label", "")
375
+
376
+ async def abatch_generate(
377
+ self,
378
+ texts: List[str],
379
+ choices: ChoicesType,
380
+ system_prompt: str | None = None,
381
+ ) -> List[str]:
382
+ """Async version of batch_generate().
383
+
384
+ Generate constrained classifications for multiple texts.
385
+
386
+ Args:
387
+ texts: List of texts to classify.
388
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
389
+ system_prompt: Optional custom system prompt.
390
+
391
+ Returns:
392
+ List of predicted choice labels, one per input text.
393
+ """
394
+ import asyncio
395
+ return await asyncio.gather(*[
396
+ self.agenerate(text, choices, system_prompt) for text in texts
397
+ ])
398
+
399
+ # =========================================================================
400
+ # Async Methods - Score Fast
401
+ # =========================================================================
402
+
403
+ async def ascore_fast(
404
+ self,
405
+ text: str,
406
+ choices: ChoicesType,
407
+ system_prompt: str | None = None,
408
+ ) -> ClassificationResult:
409
+ """Async version of score_fast().
410
+
411
+ Score a classification using single-call logprob extraction.
412
+
413
+ Args:
414
+ text: The text to classify.
415
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
416
+ system_prompt: Optional custom system prompt.
417
+
418
+ Returns:
419
+ ClassificationResult with prediction, confidence, and probabilities.
420
+ """
421
+ labels = get_choice_labels(choices)
422
+ system, user = build_classification_prompt(text, choices, system_prompt)
423
+ schema = build_json_schema_for_choices(labels)
424
+
425
+ messages = [
426
+ {"role": "system", "content": system},
427
+ {"role": "user", "content": user},
428
+ ]
429
+
430
+ response = await self._client.chat(
431
+ model=self._model,
432
+ messages=messages,
433
+ format=schema,
434
+ logprobs=True,
435
+ top_logprobs=20,
436
+ options={"temperature": 0.0},
437
+ )
438
+
439
+ # Extract prediction from response
440
+ content = response.get("message", {}).get("content", "{}")
441
+ result = json.loads(content)
442
+ prediction = result.get("label", "")
443
+
444
+ # Extract logprobs from token distribution
445
+ probabilities = self._extract_probabilities_from_logprobs(
446
+ response, labels
447
+ )
448
+
449
+ return ClassificationResult(
450
+ prediction=prediction,
451
+ confidence=probabilities.get(prediction, 0.0),
452
+ probabilities=probabilities,
453
+ raw_response=response,
454
+ )
455
+
456
+ async def abatch_score_fast(
457
+ self,
458
+ texts: List[str],
459
+ choices: ChoicesType,
460
+ system_prompt: str | None = None,
461
+ ) -> List[ClassificationResult]:
462
+ """Async version of batch_score_fast().
463
+
464
+ Score multiple texts using fast single-call method.
465
+
466
+ Args:
467
+ texts: List of texts to classify.
468
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
469
+ system_prompt: Optional custom system prompt.
470
+
471
+ Returns:
472
+ List of ClassificationResults, one per input text.
473
+ """
474
+ import asyncio
475
+ return await asyncio.gather(*[
476
+ self.ascore_fast(text, choices, system_prompt) for text in texts
477
+ ])
478
+
479
+ # =========================================================================
480
+ # Async Methods - Score Complete
481
+ # =========================================================================
482
+
483
+ async def ascore_complete(
484
+ self,
485
+ text: str,
486
+ choices: ChoicesType,
487
+ system_prompt: str | None = None,
488
+ ) -> ClassificationResult:
489
+ """Async version of score_complete().
490
+
491
+ Score a classification using multi-call evaluation with softmax.
492
+
493
+ Args:
494
+ text: The text to classify.
495
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
496
+ system_prompt: Optional custom system prompt.
497
+
498
+ Returns:
499
+ ClassificationResult with prediction, confidence, and probabilities.
500
+ """
501
+ import asyncio
502
+
503
+ labels = get_choice_labels(choices)
504
+ system, user = build_classification_prompt(text, choices, system_prompt)
505
+
506
+ # Compute logprob for each choice concurrently
507
+ logprobs_tasks = [
508
+ self._aget_choice_logprob(system, user, label)
509
+ for label in labels
510
+ ]
511
+ logprob_values = await asyncio.gather(*logprobs_tasks)
512
+
513
+ logprobs = dict(zip(labels, logprob_values))
514
+
515
+ # Apply softmax to get probabilities
516
+ probabilities = self._softmax(logprobs)
517
+
518
+ # Get prediction (highest probability)
519
+ prediction = max(probabilities, key=probabilities.get)
520
+
521
+ return ClassificationResult(
522
+ prediction=prediction,
523
+ confidence=probabilities[prediction],
524
+ probabilities=probabilities,
525
+ raw_response={"logprobs": logprobs},
526
+ )
527
+
528
+ async def abatch_score_complete(
529
+ self,
530
+ texts: List[str],
531
+ choices: ChoicesType,
532
+ system_prompt: str | None = None,
533
+ ) -> List[ClassificationResult]:
534
+ """Async version of batch_score_complete().
535
+
536
+ Score multiple texts using complete multi-call method.
537
+
538
+ Args:
539
+ texts: List of texts to classify.
540
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
541
+ system_prompt: Optional custom system prompt.
542
+
543
+ Returns:
544
+ List of ClassificationResults, one per input text.
545
+ """
546
+ import asyncio
547
+ return await asyncio.gather(*[
548
+ self.ascore_complete(text, choices, system_prompt) for text in texts
549
+ ])
550
+
551
+ # =========================================================================
552
+ # Async Methods - Classify
553
+ # =========================================================================
554
+
555
+ async def aclassify(
556
+ self,
557
+ text: str,
558
+ choices: ChoicesType,
559
+ system_prompt: str | None = None,
560
+ ) -> ClassificationResult:
561
+ """Async version of classify().
562
+
563
+ Classify text with constrained generation and fast scoring.
564
+
565
+ Args:
566
+ text: The text to classify.
567
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
568
+ system_prompt: Optional custom system prompt.
569
+
570
+ Returns:
571
+ ClassificationResult with prediction, confidence, and probabilities.
572
+ """
573
+ return await self.ascore_fast(text, choices, system_prompt)
574
+
575
+ async def aclassify_complete(
576
+ self,
577
+ text: str,
578
+ choices: ChoicesType,
579
+ system_prompt: str | None = None,
580
+ ) -> ClassificationResult:
581
+ """Async version of classify_complete().
582
+
583
+ Classify text with constrained generation and complete scoring.
584
+
585
+ Args:
586
+ text: The text to classify.
587
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
588
+ system_prompt: Optional custom system prompt.
589
+
590
+ Returns:
591
+ ClassificationResult with prediction, confidence, and probabilities.
592
+ """
593
+ # Use generate for constrained prediction
594
+ prediction = await self.agenerate(text, choices, system_prompt)
595
+
596
+ # Use score_complete for probabilities
597
+ result = await self.ascore_complete(text, choices, system_prompt)
598
+
599
+ # Override prediction with constrained output
600
+ result.prediction = prediction
601
+ result.confidence = result.probabilities.get(prediction, 0.0)
602
+
603
+ return result
604
+
605
+ async def abatch_classify(
606
+ self,
607
+ texts: List[str],
608
+ choices: ChoicesType,
609
+ system_prompt: str | None = None,
610
+ ) -> List[ClassificationResult]:
611
+ """Async version of batch_classify().
612
+
613
+ Classify multiple texts with fast scoring.
614
+
615
+ Args:
616
+ texts: List of texts to classify.
617
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
618
+ system_prompt: Optional custom system prompt.
619
+
620
+ Returns:
621
+ List of ClassificationResults, one per input text.
622
+ """
623
+ import asyncio
624
+ return await asyncio.gather(*[
625
+ self.aclassify(text, choices, system_prompt) for text in texts
626
+ ])
627
+
628
+ async def abatch_classify_complete(
629
+ self,
630
+ texts: List[str],
631
+ choices: ChoicesType,
632
+ system_prompt: str | None = None,
633
+ ) -> List[ClassificationResult]:
634
+ """Async version of batch_classify_complete().
635
+
636
+ Classify multiple texts with complete scoring.
637
+
638
+ Args:
639
+ texts: List of texts to classify.
640
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
641
+ system_prompt: Optional custom system prompt.
642
+
643
+ Returns:
644
+ List of ClassificationResults, one per input text.
645
+ """
646
+ import asyncio
647
+ return await asyncio.gather(*[
648
+ self.aclassify_complete(text, choices, system_prompt) for text in texts
649
+ ])
650
+
651
+ # =========================================================================
652
+ # Private Helper Methods
653
+ # =========================================================================
654
+
655
+ def _extract_probabilities_from_logprobs(
656
+ self,
657
+ response: Dict[str, Any],
658
+ labels: List[str],
659
+ ) -> Dict[str, float]:
660
+ """Extract probabilities from logprobs in the response.
661
+
662
+ Looks for the decision token in the logprobs and extracts
663
+ the probability distribution for the valid choices.
664
+
665
+ Args:
666
+ response: The raw API response.
667
+ labels: List of valid choice labels.
668
+
669
+ Returns:
670
+ Dict mapping choice labels to probabilities.
671
+ """
672
+ # Initialize with zero logprobs
673
+ choice_logprobs: Dict[str, float] = {label: float("-inf") for label in labels}
674
+
675
+ # Navigate the logprobs payload to find the decision token
676
+ logprobs_data = response.get("logprobs", [])
677
+
678
+ if not logprobs_data:
679
+ # No logprobs available, return uniform distribution
680
+ return {label: 1.0 / len(labels) for label in labels}
681
+
682
+ for token_data in logprobs_data:
683
+ # Check if this is a dict or list format
684
+ if isinstance(token_data, dict):
685
+ top_candidates = token_data.get("top_logprobs", [])
686
+ elif isinstance(token_data, (list, tuple)) and len(token_data) > 0:
687
+ # Might be in different format
688
+ top_candidates = token_data if isinstance(token_data[0], dict) else []
689
+ else:
690
+ continue
691
+
692
+ found_decision_token = False
693
+
694
+ for candidate in top_candidates:
695
+ if isinstance(candidate, dict):
696
+ token = candidate.get("token", "")
697
+ # Normalize the token
698
+ clean_token = token.strip(' \n\r\t"\'').lower()
699
+
700
+ # Check if this matches any of our labels
701
+ for label in labels:
702
+ if label.lower() == clean_token or clean_token in label.lower():
703
+ logprob = candidate.get("logprob", float("-inf"))
704
+ if logprob > choice_logprobs[label]:
705
+ choice_logprobs[label] = logprob
706
+ found_decision_token = True
707
+
708
+ if found_decision_token:
709
+ break
710
+
711
+ # Apply softmax to convert logprobs to probabilities
712
+ return self._softmax(choice_logprobs)
713
+
714
+ def _get_choice_logprob(
715
+ self,
716
+ system: str,
717
+ user: str,
718
+ choice: str,
719
+ ) -> float:
720
+ """Compute log P(choice | context) for a single choice.
721
+
722
+ Appends the choice as assistant response and measures
723
+ how "expected" those tokens were.
724
+
725
+ Args:
726
+ system: System prompt.
727
+ user: User prompt.
728
+ choice: The choice label to evaluate.
729
+
730
+ Returns:
731
+ Log probability of the choice given the context.
732
+ """
733
+ messages = [
734
+ {"role": "system", "content": system},
735
+ {"role": "user", "content": user},
736
+ {"role": "assistant", "content": json.dumps({"label": choice})},
737
+ ]
738
+
739
+ response = self._client.chat(
740
+ model=self._model,
741
+ messages=messages,
742
+ logprobs=True,
743
+ options={"num_predict": 0},
744
+ )
745
+
746
+ return self._extract_logprob_from_response(response)
747
+
748
+ async def _aget_choice_logprob(
749
+ self,
750
+ system: str,
751
+ user: str,
752
+ choice: str,
753
+ ) -> float:
754
+ """Async version of _get_choice_logprob().
755
+
756
+ Compute log P(choice | context) for a single choice.
757
+
758
+ Args:
759
+ system: System prompt.
760
+ user: User prompt.
761
+ choice: The choice label to evaluate.
762
+
763
+ Returns:
764
+ Log probability of the choice given the context.
765
+ """
766
+ messages = [
767
+ {"role": "system", "content": system},
768
+ {"role": "user", "content": user},
769
+ {"role": "assistant", "content": json.dumps({"label": choice})},
770
+ ]
771
+
772
+ response = await self._client.chat(
773
+ model=self._model,
774
+ messages=messages,
775
+ logprobs=True,
776
+ options={"num_predict": 0},
777
+ )
778
+
779
+ return self._extract_logprob_from_response(response)
780
+
781
+ def _extract_logprob_from_response(self, response: Dict[str, Any]) -> float:
782
+ """Extract log probability from Ollama response.
783
+
784
+ Tries various fields where Ollama might return logprobs.
785
+
786
+ Args:
787
+ response: The raw API response.
788
+
789
+ Returns:
790
+ Extracted log probability, or 0.0 if not found.
791
+ """
792
+ msg = response.get("message", {})
793
+
794
+ # Field: message.logprob (most common in newer versions)
795
+ if "logprob" in msg:
796
+ return msg["logprob"]
797
+
798
+ # Field: eval_prob
799
+ if "eval_prob" in response:
800
+ p = response["eval_prob"]
801
+ return math.log(p) if p > 0 else float("-inf")
802
+
803
+ # Field: logprobs list
804
+ if "logprobs" in msg:
805
+ lp = msg["logprobs"]
806
+ if isinstance(lp, list) and lp:
807
+ # Sum or average the logprobs
808
+ return sum(lp) / len(lp)
809
+
810
+ # Check in response logprobs
811
+ logprobs = response.get("logprobs", [])
812
+ if logprobs and isinstance(logprobs, list):
813
+ # Try to extract from first token's logprob
814
+ if isinstance(logprobs[0], dict) and "logprob" in logprobs[0]:
815
+ return logprobs[0]["logprob"]
816
+
817
+ return 0.0 # Default neutral
818
+
819
+ def _softmax(self, logprobs: Dict[str, float]) -> Dict[str, float]:
820
+ """Apply numerically stable softmax to log probabilities.
821
+
822
+ Args:
823
+ logprobs: Dict mapping labels to log probabilities.
824
+
825
+ Returns:
826
+ Dict mapping labels to probabilities (summing to 1.0).
827
+ """
828
+ # Filter out -inf values for computation
829
+ valid_logprobs = {k: v for k, v in logprobs.items() if v > float("-inf")}
830
+
831
+ if not valid_logprobs:
832
+ # If all are -inf, return uniform distribution
833
+ n = len(logprobs)
834
+ return {k: 1.0 / n for k in logprobs}
835
+
836
+ max_lp = max(valid_logprobs.values())
837
+ exp_vals = {k: math.exp(v - max_lp) if v > float("-inf") else 0.0
838
+ for k, v in logprobs.items()}
839
+ total = sum(exp_vals.values())
840
+
841
+ if total == 0:
842
+ n = len(logprobs)
843
+ return {k: 1.0 / n for k in logprobs}
844
+
845
+ return {k: v / total for k, v in exp_vals.items()}
@@ -0,0 +1,97 @@
1
+ """Prompt building utilities for classification."""
2
+
3
+ from typing import List, Dict, Union
4
+
5
+
6
+ def build_classification_prompt(
7
+ text: str,
8
+ choices: Union[List[str], Dict[str, str]],
9
+ system_prompt: str | None = None,
10
+ ) -> tuple[str, str]:
11
+ """Build the system and user prompts for classification.
12
+
13
+ Args:
14
+ text: The text to classify.
15
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
16
+ system_prompt: Optional custom system prompt.
17
+
18
+ Returns:
19
+ Tuple of (system_prompt, user_prompt) strings.
20
+ """
21
+ # Build the choices section of the prompt
22
+ choices_text = _format_choices(choices)
23
+
24
+ # Default system prompt
25
+ if system_prompt is None:
26
+ system_prompt = (
27
+ "You are a precise text classifier. "
28
+ "Your task is to classify the given text into exactly one of the provided categories. "
29
+ "Respond with only the category label, nothing else."
30
+ )
31
+
32
+ # Build user prompt
33
+ user_prompt = f"""Classify the following text into one of these categories:
34
+
35
+ {choices_text}
36
+
37
+ Text to classify:
38
+ {text}
39
+
40
+ Respond with only the category label."""
41
+
42
+ return system_prompt, user_prompt
43
+
44
+
45
+ def _format_choices(choices: Union[List[str], Dict[str, str]]) -> str:
46
+ """Format choices for the prompt.
47
+
48
+ Args:
49
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
50
+
51
+ Returns:
52
+ Formatted string representation of choices.
53
+ """
54
+ if isinstance(choices, dict):
55
+ # Choices with descriptions
56
+ lines = []
57
+ for label, description in choices.items():
58
+ lines.append(f"- {label}: {description}")
59
+ return "\n".join(lines)
60
+ else:
61
+ # Simple list of choices
62
+ return "\n".join(f"- {choice}" for choice in choices)
63
+
64
+
65
+ def get_choice_labels(choices: Union[List[str], Dict[str, str]]) -> List[str]:
66
+ """Extract the choice labels from either format.
67
+
68
+ Args:
69
+ choices: Either a list of choice labels, or a dict mapping labels to descriptions.
70
+
71
+ Returns:
72
+ List of choice labels.
73
+ """
74
+ if isinstance(choices, dict):
75
+ return list(choices.keys())
76
+ return list(choices)
77
+
78
+
79
+ def build_json_schema_for_choices(choices: List[str]) -> dict:
80
+ """Build a JSON schema that constrains output to the given choices.
81
+
82
+ Args:
83
+ choices: List of valid choice labels.
84
+
85
+ Returns:
86
+ JSON schema dict for structured output.
87
+ """
88
+ return {
89
+ "type": "object",
90
+ "properties": {
91
+ "label": {
92
+ "type": "string",
93
+ "enum": choices,
94
+ }
95
+ },
96
+ "required": ["label"],
97
+ }
File without changes
@@ -0,0 +1,24 @@
1
+ """Type definitions for ollama-classifier."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Dict, Any, Union, List
5
+
6
+
7
+ @dataclass
8
+ class ClassificationResult:
9
+ """Result of a classification operation.
10
+
11
+ Attributes:
12
+ prediction: The predicted class label.
13
+ confidence: Confidence score for the prediction (0.0 to 1.0).
14
+ probabilities: Probability distribution over all choices.
15
+ raw_response: Raw response from Ollama API for debugging.
16
+ """
17
+ prediction: str
18
+ confidence: float
19
+ probabilities: Dict[str, float]
20
+ raw_response: Dict[str, Any] = field(default_factory=dict)
21
+
22
+
23
+ # Type alias for choices - can be a list of labels or dict mapping labels to descriptions
24
+ ChoicesType = Union[List[str], Dict[str, str]]
@@ -0,0 +1,247 @@
1
+ Metadata-Version: 2.3
2
+ Name: ollama-classifier
3
+ Version: 0.1.0
4
+ Summary: A wrapper around Ollama Python SDK for text classification with constrained output and confidence scoring
5
+ Author: Luigi Palumbo, Mengting Yu
6
+ Author-email: Luigi Palumbo <paluigi@gmail.com>, Mengting Yu <mengting.yu@unitus.it>
7
+ Requires-Dist: ollama>=0.4.0
8
+ Requires-Dist: sphinx>=7.0.0 ; extra == 'docs'
9
+ Requires-Dist: sphinx-rtd-theme>=2.0.0 ; extra == 'docs'
10
+ Requires-Dist: myst-parser>=2.0.0 ; extra == 'docs'
11
+ Requires-Python: >=3.11
12
+ Provides-Extra: docs
13
+ Description-Content-Type: text/markdown
14
+
15
+ # ollama-classifier
16
+
17
+ A Python wrapper around the Ollama Python SDK for text classification with constrained output and confidence scoring.
18
+
19
+ ## Features
20
+
21
+ - **Constrained Output**: Uses JSON schema with enum constraints to ensure only valid choices are generated
22
+ - **Confidence Scoring**: Two methods available:
23
+ - **Fast**: Single API call with logprob extraction
24
+ - **Complete**: Multi-call evaluation with softmax for calibrated probabilities
25
+ - **Sync & Async**: Full support for both synchronous and asynchronous operations
26
+ - **Batch Processing**: Classify multiple texts efficiently
27
+ - **Flexible Choices**: Support for simple labels or labels with descriptions
28
+ - **Custom Prompts**: Override the default system prompt for specialized tasks
29
+
30
+ ## Installation
31
+
32
+ ```bash
33
+ pip install ollama-classifier
34
+ ```
35
+
36
+ Or with uv:
37
+
38
+ ```bash
39
+ uv add ollama-classifier
40
+ ```
41
+
42
+ ## Prerequisites
43
+
44
+ - [Ollama](https://ollama.com/download) installed and running
45
+ - A model pulled (e.g., `ollama pull llama3.2`)
46
+
47
+ ## Quick Start
48
+
49
+ ```python
50
+ from ollama import Client
51
+ from ollama_classifier import OllamaClassifier
52
+
53
+ client = Client()
54
+ classifier = OllamaClassifier(client, model="llama3.2")
55
+
56
+ result = classifier.classify(
57
+ text="I love this product!",
58
+ choices=["positive", "negative", "neutral"]
59
+ )
60
+
61
+ print(f"Prediction: {result.prediction}")
62
+ print(f"Confidence: {result.confidence:.2%}")
63
+ print(f"Probabilities: {result.probabilities}")
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ ### Basic Classification
69
+
70
+ ```python
71
+ from ollama import Client
72
+ from ollama_classifier import OllamaClassifier
73
+
74
+ client = Client()
75
+ classifier = OllamaClassifier(client, model="llama3.2")
76
+
77
+ result = classifier.classify(
78
+ text="The goalkeeper made an incredible save!",
79
+ choices=["sports", "politics", "technology", "entertainment"]
80
+ )
81
+ ```
82
+
83
+ ### Classification with Label Descriptions
84
+
85
+ Providing descriptions helps the model understand each category better:
86
+
87
+ ```python
88
+ choices = {
89
+ "positive": "Text expresses happiness, satisfaction, or approval",
90
+ "negative": "Text expresses anger, disappointment, or disapproval",
91
+ "mixed": "Text contains both positive and negative sentiments",
92
+ "neutral": "Text is factual without strong emotional content",
93
+ }
94
+
95
+ result = classifier.classify(
96
+ text="The food was amazing but the service was terrible.",
97
+ choices=choices
98
+ )
99
+ ```
100
+
101
+ ### Custom System Prompt
102
+
103
+ ```python
104
+ result = classifier.classify(
105
+ text="The quarterly earnings exceeded analyst expectations.",
106
+ choices=["bullish", "bearish", "neutral"],
107
+ system_prompt="You are a financial sentiment analyzer. "
108
+ "Classify financial news based on market sentiment."
109
+ )
110
+ ```
111
+
112
+ ### Scoring Methods
113
+
114
+ #### Fast Scoring (Single API Call)
115
+
116
+ ```python
117
+ result = classifier.score_fast(
118
+ text="The movie was fantastic!",
119
+ choices=["positive", "negative", "neutral"]
120
+ )
121
+ ```
122
+
123
+ #### Complete Scoring (Multi-Call with Softmax)
124
+
125
+ More accurate but makes N API calls for N choices:
126
+
127
+ ```python
128
+ result = classifier.score_complete(
129
+ text="The movie was fantastic!",
130
+ choices=["positive", "negative", "neutral"]
131
+ )
132
+ ```
133
+
134
+ ### Generate Only (Fastest)
135
+
136
+ When you only need the prediction without confidence scores:
137
+
138
+ ```python
139
+ prediction = classifier.generate(
140
+ text="The team won the championship!",
141
+ choices=["sports", "finance", "politics"]
142
+ )
143
+ ```
144
+
145
+ ### Batch Classification
146
+
147
+ ```python
148
+ texts = [
149
+ "The goalkeeper made an incredible save!",
150
+ "The central bank raised interest rates.",
151
+ "The new smartphone features a revolutionary camera.",
152
+ ]
153
+
154
+ # Fast batch classification
155
+ results = classifier.batch_classify(
156
+ texts=texts,
157
+ choices=["sports", "finance", "technology"]
158
+ )
159
+
160
+ # Complete batch classification (more accurate)
161
+ results = classifier.batch_classify_complete(
162
+ texts=texts,
163
+ choices=["sports", "finance", "technology"]
164
+ )
165
+
166
+ for text, result in zip(texts, results):
167
+ print(f"{text} -> {result.prediction} ({result.confidence:.2%})")
168
+ ```
169
+
170
+ ### Async Usage
171
+
172
+ ```python
173
+ import asyncio
174
+ from ollama import AsyncClient
175
+ from ollama_classifier import OllamaClassifier
176
+
177
+ async def main():
178
+ client = AsyncClient()
179
+ classifier = OllamaClassifier(client, model="llama3.2")
180
+
181
+ # Single classification
182
+ result = await classifier.aclassify(
183
+ text="The concert was amazing!",
184
+ choices=["positive", "negative", "neutral"]
185
+ )
186
+
187
+ # Batch classification (concurrent)
188
+ results = await classifier.abatch_classify(
189
+ texts=["Text 1", "Text 2", "Text 3"],
190
+ choices=["positive", "negative", "neutral"]
191
+ )
192
+
193
+ asyncio.run(main())
194
+ ```
195
+
196
+ ## API Reference
197
+
198
+ ### ClassificationResult
199
+
200
+ ```python
201
+ @dataclass
202
+ class ClassificationResult:
203
+ prediction: str # The predicted choice label
204
+ confidence: float # Confidence score (0.0 to 1.0)
205
+ probabilities: Dict[str, float] # Probability distribution over all choices
206
+ raw_response: Dict # Raw Ollama response for debugging
207
+ ```
208
+
209
+ ### OllamaClassifier Methods
210
+
211
+ | Method | Async | Description |
212
+ |--------|-------|-------------|
213
+ | `generate(text, choices, system_prompt)` | `agenerate` | Constrained output only (fastest) |
214
+ | `score_fast(text, choices, system_prompt)` | `ascore_fast` | Single-call logprob extraction |
215
+ | `score_complete(text, choices, system_prompt)` | `ascore_complete` | Multi-call evaluation with softmax |
216
+ | `classify(text, choices, system_prompt)` | `aclassify` | Generate + score_fast |
217
+ | `classify_complete(text, choices, system_prompt)` | `aclassify_complete` | Generate + score_complete |
218
+ | `batch_generate(texts, choices, system_prompt)` | `abatch_generate` | Batch constrained output |
219
+ | `batch_score_fast(texts, choices, system_prompt)` | `abatch_score_fast` | Batch fast scoring |
220
+ | `batch_score_complete(texts, choices, system_prompt)` | `abatch_score_complete` | Batch complete scoring |
221
+ | `batch_classify(texts, choices, system_prompt)` | `abatch_classify` | Batch classify (fast) |
222
+ | `batch_classify_complete(texts, choices, system_prompt)` | `abatch_classify_complete` | Batch classify (complete) |
223
+
224
+ ### Parameters
225
+
226
+ - **text** (str): The text to classify
227
+ - **texts** (List[str]): List of texts to classify (batch methods)
228
+ - **choices** (Union[List[str], Dict[str, str]]): Either a list of choice labels, or a dict mapping labels to descriptions
229
+ - **system_prompt** (str | None): Optional custom system prompt
230
+
231
+ ## Choosing a Method
232
+
233
+ | Use Case | Recommended Method |
234
+ |----------|-------------------|
235
+ | Speed is critical, no confidence needed | `generate` |
236
+ | Speed with confidence scores | `classify` / `score_fast` |
237
+ | Accurate confidence scores | `classify_complete` / `score_complete` |
238
+ | Batch processing | `batch_classify` or `batch_classify_complete` |
239
+ | Concurrent processing | Async variants (`aclassify`, etc.) |
240
+
241
+ ## License
242
+
243
+ MIT License
244
+
245
+ ## Development
246
+
247
+ This project just started! Looking forward to suggestions, issues, and pull requests!
@@ -0,0 +1,8 @@
1
+ ollama_classifier/__init__.py,sha256=r5LFD-YlcVQKMJ1RUTtaZV6WlyHCFdsWwFN-12tKhLE,862
2
+ ollama_classifier/classifier.py,sha256=4XwDRqJ8EIbnNlL8Zp0TP0Ff6QMRJkYczxhiDe2jL40,30140
3
+ ollama_classifier/prompts.py,sha256=54YuHN7941LXtY-S7-krfPurzzvULunCbA84PMyVhk0,2746
4
+ ollama_classifier/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ ollama_classifier/types.py,sha256=UU9R8TksYfjKKJ1ZRoyr5FTsFlCv2rLx2Kt6K2-P1o0,773
6
+ ollama_classifier-0.1.0.dist-info/WHEEL,sha256=mydTeHxOpFHo-DnYhAd_3ATePms-g4rrYvM7wJK8P-U,80
7
+ ollama_classifier-0.1.0.dist-info/METADATA,sha256=JEHvA1nLU5JYE0g1v35GfqnTt6_dqjKjQiQHCRq_YYw,7414
8
+ ollama_classifier-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.10.9
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any