cat-stack 2.2.0__tar.gz → 2.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. {cat_stack-2.2.0 → cat_stack-2.4.0}/PKG-INFO +5 -2
  2. {cat_stack-2.2.0 → cat_stack-2.4.0}/pyproject.toml +9 -1
  3. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/__about__.py +1 -1
  4. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_batch.py +8 -2
  5. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_chunked.py +5 -1
  6. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_providers.py +33 -14
  7. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_tiebreaker.py +9 -4
  8. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_utils.py +9 -0
  9. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_wrapper_helpers.py +4 -1
  10. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/image_CoVe.py +79 -75
  11. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/stepback.py +3 -2
  12. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/classify.py +30 -4
  13. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/image_functions.py +53 -9
  14. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/pdf_functions.py +41 -1
  15. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/prompt_tune.py +9 -2
  16. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/summarize.py +20 -5
  17. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/text_functions.py +6 -5
  18. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/text_functions_ensemble.py +104 -34
  19. {cat_stack-2.2.0 → cat_stack-2.4.0}/.gitignore +0 -0
  20. {cat_stack-2.2.0 → cat_stack-2.4.0}/LICENSE +0 -0
  21. {cat_stack-2.2.0 → cat_stack-2.4.0}/README.md +0 -0
  22. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/cat_stack/__init__.py +0 -0
  23. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/__init__.py +0 -0
  24. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_category_analysis.py +0 -0
  25. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_embeddings.py +0 -0
  26. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_formatter.py +0 -0
  27. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_pilot_test.py +0 -0
  28. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_prompts.py +0 -0
  29. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_review_ui.py +0 -0
  30. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_web_fetch.py +0 -0
  31. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/CoVe.py +0 -0
  32. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/__init__.py +0 -0
  33. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/image_stepback.py +0 -0
  34. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/pdf_CoVe.py +0 -0
  35. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/pdf_stepback.py +0 -0
  36. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/top_n.py +0 -0
  37. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/collapse_themes.py +0 -0
  38. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/explore.py +0 -0
  39. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/extract.py +0 -0
  40. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/circle.png +0 -0
  41. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/cube.png +0 -0
  42. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/diamond.png +0 -0
  43. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/overlapping_pentagons.png +0 -0
  44. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/rectangles.png +0 -0
  45. {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/model_reference_list.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cat-stack
3
- Version: 2.2.0
3
+ Version: 2.4.0
4
4
  Summary: Domain-agnostic text, image, PDF, and DOCX classification engine powered by LLMs
5
5
  Project-URL: Documentation, https://github.com/chrissoria/cat-stack#readme
6
6
  Project-URL: Issues, https://github.com/chrissoria/cat-stack/issues
@@ -19,11 +19,14 @@ Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Programming Language :: Python :: Implementation :: CPython
20
20
  Classifier: Programming Language :: Python :: Implementation :: PyPy
21
21
  Requires-Python: >=3.8
22
+ Requires-Dist: jellyfish
22
23
  Requires-Dist: pandas
23
24
  Requires-Dist: requests
24
25
  Requires-Dist: tqdm
25
26
  Provides-Extra: agent
26
- Requires-Dist: cat-claws>=0.1.0; extra == 'agent'
27
+ Requires-Dist: cat-claws[claude]>=0.3.0; extra == 'agent'
28
+ Provides-Extra: codex-agent
29
+ Requires-Dist: cat-claws[codex]>=0.3.0; extra == 'codex-agent'
27
30
  Provides-Extra: docx
28
31
  Requires-Dist: python-docx>=1.0.0; extra == 'docx'
29
32
  Provides-Extra: embeddings
@@ -28,6 +28,10 @@ dependencies = [
28
28
  "pandas",
29
29
  "tqdm",
30
30
  "requests",
31
+ # collapse_themes.py imports jellyfish at module top (and catstack/__init__
32
+ # imports collapse_themes eagerly), so every fresh install needs it —
33
+ # undeclared, it broke `import catstack` in clean environments.
34
+ "jellyfish",
31
35
  ]
32
36
 
33
37
  [project.optional-dependencies]
@@ -35,7 +39,11 @@ pdf = ["PyMuPDF>=1.23.0"]
35
39
  docx = ["python-docx>=1.0.0"]
36
40
  formatter = ["torch>=2.0.0", "transformers>=4.40.0", "accelerate>=0.27.0"]
37
41
  embeddings = ["sentence-transformers>=2.2.0"]
38
- agent = ["cat-claws>=0.1.0"]
42
+ # `agent` keeps its historical meaning (the Claude backend) so every shipped
43
+ # install hint stays true; `codex-agent` matches the provider string so the
44
+ # error-message hint is copy-pasteable.
45
+ agent = ["cat-claws[claude]>=0.3.0"]
46
+ codex-agent = ["cat-claws[codex]>=0.3.0"]
39
47
 
40
48
  [project.urls]
41
49
  Documentation = "https://github.com/chrissoria/cat-stack#readme"
@@ -1,7 +1,7 @@
1
1
  # SPDX-FileCopyrightText: 2025-present Christopher Soria <chrissoria@berkeley.edu>
2
2
  #
3
3
  # SPDX-License-Identifier: GPL-3.0-or-later
4
- __version__ = "2.2.0"
4
+ __version__ = "2.4.0"
5
5
  __author__ = "Chris Soria"
6
6
  __email__ = "chrissoria@berkeley.edu"
7
7
  __title__ = "cat-stack"
@@ -65,7 +65,7 @@ BATCH_ENDPOINTS = {
65
65
  },
66
66
  }
67
67
 
68
- UNSUPPORTED_BATCH_PROVIDERS = {"huggingface", "huggingface-together", "perplexity", "ollama", "claude-code", "claude-agent"}
68
+ UNSUPPORTED_BATCH_PROVIDERS = {"huggingface", "huggingface-together", "perplexity", "ollama", "claude-code", "claude-agent", "codex-agent"}
69
69
 
70
70
  # Terminal states per provider
71
71
  _TERMINAL_STATES = {
@@ -928,11 +928,17 @@ def _run_one_sync_model(
928
928
  system_prompt=prompt_params.get("system_prompt", ""),
929
929
  )
930
930
  try:
931
+ # Pass thinking_budget through unchanged (including explicit 0 =
932
+ # "reasoning off") — apply_model_params translates 0 into each
933
+ # provider's off form, exactly like the non-batch sync path, and
934
+ # complete() has the 400 strip-and-retry net for providers that
935
+ # reject it. Nullifying 0 here left Qwen3/Ollama-family rows in
936
+ # the batch sync fallback reasoning at provider default.
931
937
  raw, err = client.complete(
932
938
  messages=messages,
933
939
  json_schema=json_schema,
934
940
  creativity=creativity,
935
- thinking_budget=thinking_budget if thinking_budget and thinking_budget > 0 else None,
941
+ thinking_budget=thinking_budget,
936
942
  )
937
943
  if err:
938
944
  item_results[idx] = (None, err)
@@ -48,6 +48,7 @@ def run_chunked_classification(
48
48
  thinking_budget,
49
49
  max_retries,
50
50
  multi_label,
51
+ system_prompt="",
51
52
  categories_per_call,
52
53
  add_unified_other=False,
53
54
  formatter_fallback_fn,
@@ -149,6 +150,7 @@ Categorize text responses {cove_categorize}:
149
150
  thinking_budget=thinking_budget,
150
151
  max_retries=max_retries,
151
152
  multi_label=multi_label,
153
+ system_prompt=system_prompt,
152
154
  formatter_fallback_fn=formatter_fallback_fn,
153
155
  is_pdf_mode=is_pdf_mode,
154
156
  is_image_mode=is_image_mode,
@@ -228,6 +230,7 @@ def _run_single_chunk_call(
228
230
  thinking_budget,
229
231
  max_retries,
230
232
  multi_label,
233
+ system_prompt="",
231
234
  formatter_fallback_fn,
232
235
  is_pdf_mode,
233
236
  is_image_mode,
@@ -366,7 +369,7 @@ def _run_single_chunk_call(
366
369
  response_text = item
367
370
 
368
371
  if cfg["use_two_step"]: # Ollama
369
- json_result, error = ollama_two_step_classify(
372
+ json_result, _step1_raw, error = ollama_two_step_classify(
370
373
  client=client,
371
374
  response_text=response_text,
372
375
  categories=chunk_cats,
@@ -390,6 +393,7 @@ def _run_single_chunk_call(
390
393
  stepback_insights=stepback_insights,
391
394
  model_name=cfg["model"],
392
395
  multi_label=multi_label,
396
+ system_prompt=system_prompt,
393
397
  )
394
398
  reply, error = client.complete(
395
399
  messages=messages,
@@ -726,22 +726,35 @@ PROVIDER_CONFIG = {
726
726
  "auth_header": None,
727
727
  "auth_prefix": "",
728
728
  },
729
+ "codex-agent": {
730
+ "endpoint": None, # Uses the cat-claws SDK adapter, not HTTP
731
+ "auth_header": None,
732
+ "auth_prefix": "",
733
+ },
729
734
  }
730
735
 
731
736
 
732
737
  # Providers that route through complete() with no HTTP endpoint of their own
733
- # (Claude subscription / CLI). Features that build a direct HTTP request
738
+ # (subscription logins / CLI). Features that build a direct HTTP request
734
739
  # (image, PDF) can't use them — guard with a clear error, not a deep crash.
735
- _SUBSCRIPTION_PROVIDERS = ("claude-code", "claude-agent")
740
+ _SUBSCRIPTION_PROVIDERS = ("claude-code", "claude-agent", "codex-agent")
741
+
742
+ # Agent-SDK backends routed through cat-claws: provider -> (adapter name,
743
+ # install hint). Both run on subscription logins (no API key); the hint names
744
+ # the cat-stack extra that pulls the matching cat-claws SDK extra.
745
+ _AGENT_BACKENDS = {
746
+ "claude-agent": ("claude", "pip install cat-stack[agent]"),
747
+ "codex-agent": ("codex", 'pip install "cat-stack[codex-agent]"'),
748
+ }
736
749
 
737
750
 
738
751
  def _require_http_provider(model_source, feature):
739
752
  """Raise a clear error when an HTTP-only feature is used with a
740
- subscription/CLI provider (claude-code / claude-agent)."""
753
+ subscription/CLI provider (claude-code / claude-agent / codex-agent)."""
741
754
  if model_source in _SUBSCRIPTION_PROVIDERS:
742
755
  raise ValueError(
743
756
  f"{feature} is not supported with model_source='{model_source}'. "
744
- "The Claude subscription/CLI backend supports text classification, "
757
+ "The subscription/CLI backends support text classification, "
745
758
  "extraction, and summarization, but not " + feature.lower() + ". "
746
759
  "Use an API-key provider (e.g. model_source='anthropic') instead."
747
760
  )
@@ -1229,29 +1242,33 @@ class UnifiedLLMClient:
1229
1242
  # contract that callers depend on.
1230
1243
  return None, f"Claude CLI subprocess failed: {e} (prompt may be too large for argv)"
1231
1244
 
1232
- def _call_claude_agent(
1245
+ def _call_agent_backend(
1233
1246
  self,
1234
1247
  messages: list,
1235
1248
  thinking_budget: int = None,
1236
1249
  ) -> tuple[str, str | None]:
1237
- """Route one completion through the cat-claws SDK adapter.
1250
+ """Route one completion through a cat-claws SDK adapter (the
1251
+ `claude-agent` and `codex-agent` providers — see _AGENT_BACKENDS).
1238
1252
 
1239
- Like `_call_claude_cli`, this runs on the user's Claude subscription
1240
- (no API key) and returns the same (text, error) contract. cat-claws is
1241
- an optional dependency (the `[agent]` extra); a missing install
1242
- degrades to a clear install hint rather than an ImportError traceback.
1253
+ Like `_call_claude_cli`, these run on the user's subscription login
1254
+ (no API key) and return the same (text, error) contract. cat-claws is
1255
+ an optional dependency; a missing install degrades to a clear install
1256
+ hint rather than an ImportError traceback. One shared body, table-
1257
+ driven: near-duplicate provider branches are how the image-CoVe
1258
+ silent no-op happened.
1243
1259
 
1244
1260
  The adapter is async. complete() is sync and may run inside ensemble
1245
1261
  worker threads, so we drive one sealed call per invocation with
1246
1262
  asyncio.run (a fresh loop per call) - never a shared/module-global
1247
1263
  loop. Message flattening mirrors _call_claude_cli exactly.
1248
1264
  """
1265
+ adapter_name, install_hint = _AGENT_BACKENDS[self.provider]
1249
1266
  try:
1250
1267
  from catclaws._adapters import get_adapter
1251
1268
  except ImportError:
1252
1269
  return None, (
1253
1270
  "cat-claws is not installed. Install it to use "
1254
- "model_source='claude-agent': pip install cat-stack[agent]"
1271
+ f"model_source='{self.provider}': {install_hint}"
1255
1272
  )
1256
1273
  import asyncio
1257
1274
 
@@ -1265,7 +1282,7 @@ class UnifiedLLMClient:
1265
1282
  system_prompt = "\n\n".join(system_parts) if system_parts else None
1266
1283
  user_prompt = "\n\n".join(user_parts)
1267
1284
 
1268
- adapter = get_adapter("claude")
1285
+ adapter = get_adapter(adapter_name)
1269
1286
  try:
1270
1287
  return asyncio.run(
1271
1288
  adapter.one_shot(
@@ -1321,8 +1338,8 @@ class UnifiedLLMClient:
1321
1338
  if self.provider == "claude-code":
1322
1339
  return self._call_claude_cli(messages, max_retries=max_retries, initial_delay=initial_delay)
1323
1340
 
1324
- if self.provider == "claude-agent":
1325
- return self._call_claude_agent(messages, thinking_budget=thinking_budget)
1341
+ if self.provider in _AGENT_BACKENDS:
1342
+ return self._call_agent_backend(messages, thinking_budget=thinking_budget)
1326
1343
 
1327
1344
  headers = self._get_headers()
1328
1345
  payload = self._build_payload(messages, json_schema, creativity, thinking_budget=thinking_budget, force_json=force_json)
@@ -1818,6 +1835,8 @@ def _detect_model_source(user_model, model_source):
1818
1835
  return "claude-code"
1819
1836
  if model_source and model_source.lower() == "claude-agent":
1820
1837
  return "claude-agent"
1838
+ if model_source and model_source.lower() == "codex-agent":
1839
+ return "codex-agent"
1821
1840
  return detect_provider(user_model, provider=model_source)
1822
1841
 
1823
1842
 
@@ -89,15 +89,20 @@ def _find_confident_and_tied_rows(all_results, category_key, threshold):
89
89
  positive_count = sum(votes)
90
90
  positive_rate = positive_count / num_models
91
91
 
92
- # Check for true tie: positive_rate == threshold exactly
93
- if abs(positive_rate - threshold) < 1e-9:
94
- tied.append(row_idx)
95
- elif positive_rate == 1.0:
92
+ # Unanimous rows are confident by definition and must be bucketed
93
+ # BEFORE the tie test: with consensus_threshold="unanimous" (the
94
+ # classify() default, threshold=1.0) every all-positive row sits
95
+ # exactly at the threshold, and testing ties first classified them
96
+ # all as "tied" — leaving zero confident rows to build centroids from.
97
+ if positive_rate == 1.0:
96
98
  # All models agree positive
97
99
  confident_pos.append(row_idx)
98
100
  elif positive_rate == 0.0:
99
101
  # All models agree negative
100
102
  confident_neg.append(row_idx)
103
+ # Check for true tie: positive_rate == threshold exactly
104
+ elif abs(positive_rate - threshold) < 1e-9:
105
+ tied.append(row_idx)
101
106
 
102
107
  return confident_pos, confident_neg, tied
103
108
 
@@ -249,6 +249,14 @@ def ollama_two_step_classify(
249
249
  Returns:
250
250
  tuple: (json_string, error_message or None)
251
251
  """
252
+ # Lazy import — extract_json lives in text_functions (which imports this
253
+ # module at its top, so a module-level import here would be circular).
254
+ # Without it this function raised NameError on every successful Step 2.
255
+ # NOTE: text_functions.ollama_two_step_classify is the maintained copy
256
+ # (3-tuple return, exposes the step-1 raw reply); this 2-tuple variant is
257
+ # kept only so the _utils export keeps working.
258
+ from .text_functions import extract_json
259
+
252
260
  num_categories = len(categories)
253
261
  survey_context = f"Context: {survey_question}." if survey_question else ""
254
262
 
@@ -376,6 +384,7 @@ def _get_stepback_insight(model_source, stepback, api_key, user_model, creativit
376
384
  "mistral": get_stepback_insight_mistral,
377
385
  "claude-code": get_stepback_insight_via_complete,
378
386
  "claude-agent": get_stepback_insight_via_complete,
387
+ "codex-agent": get_stepback_insight_via_complete,
379
388
  }
380
389
 
381
390
  func = stepback_functions.get(model_source)
@@ -143,7 +143,10 @@ def parse_kwargs_string(s: Optional[str]) -> Dict[str, Any]:
143
143
  buf.append(ch)
144
144
  if ch == quote_char:
145
145
  quote_char = None
146
- elif ch in ('"', "'"):
146
+ elif ch in ('"', "'") and (not buf or buf[-1] in " =,([{"):
147
+ # Open a quote only at a value boundary — an apostrophe inside
148
+ # unquoted prose ("don't", "it's") must not start a "string"
149
+ # that swallows every following top-level comma and key=val pair.
147
150
  quote_char = ch
148
151
  buf.append(ch)
149
152
  elif ch in "([{":
@@ -7,38 +7,59 @@ def image_chain_of_verification_openai(
7
7
  step2_prompt,
8
8
  step3_prompt,
9
9
  step4_prompt,
10
- client,
10
+ client, # Deprecated, kept for backward compatibility
11
11
  user_model,
12
12
  creativity,
13
13
  remove_numbering,
14
- image_content
14
+ image_content,
15
+ api_key=None,
16
+ base_url=None,
15
17
  ):
16
18
  """
17
- Execute Chain of Verification (CoVe) process for images with OpenAI.
18
- The image is included in verification steps for accurate assessment.
19
+ Execute Chain of Verification (CoVe) process for images with OpenAI-compatible
20
+ providers. The image is included in verification steps for accurate assessment.
19
21
  Returns the verified reply or initial reply if error occurs.
20
22
 
23
+ Uses direct HTTP requests instead of the OpenAI SDK so the function works for
24
+ any OpenAI-compatible provider (OpenAI, Perplexity, HuggingFace, xAI, ...) given
25
+ the right `base_url`.
26
+
21
27
  Args:
22
- image_content: The image content in OpenAI format (list with image_url dict)
28
+ image_content: The image content in OpenAI format (image_url dict)
29
+ api_key: Bearer token for the provider.
30
+ base_url: Provider root URL (e.g. https://api.openai.com/v1). Defaults to OpenAI.
23
31
  """
24
- try:
32
+ import requests
33
+
34
+ if api_key is None:
35
+ return initial_reply
36
+
37
+ endpoint = (base_url or "https://api.openai.com/v1").rstrip("/") + "/chat/completions"
38
+ headers = {
39
+ "Content-Type": "application/json",
40
+ "Authorization": f"Bearer {api_key}",
41
+ }
42
+
43
+ def make_openai_request(messages, json_mode=False):
44
+ payload = {"model": user_model, "messages": messages}
25
45
  # Sampling params via the shared shaper (skips temperature for OpenAI
26
- # reasoning models, which reject non-default values). Computed once,
27
- # reused across the 3 steps.
46
+ # reasoning models, which reject non-default values).
28
47
  from cat_stack._providers import apply_model_params
29
- _params = apply_model_params({}, "openai", user_model, creativity=creativity)
48
+ apply_model_params(payload, "openai", user_model, creativity=creativity)
49
+ if json_mode:
50
+ payload["response_format"] = {"type": "json_object"}
51
+ response = requests.post(endpoint, headers=headers, json=payload, timeout=120)
52
+ response.raise_for_status()
53
+ result = response.json()
54
+ return result["choices"][0]["message"]["content"]
30
55
 
56
+ try:
31
57
  # STEP 2: Generate verification questions (text only - questions about the categorization)
32
58
  step2_filled = step2_prompt.replace('<<INITIAL_REPLY>>', initial_reply)
33
-
34
- verification_response = client.chat.completions.create(
35
- model=user_model,
36
- messages=[{'role': 'user', 'content': step2_filled}],
37
- **_params
59
+ verification_questions = make_openai_request(
60
+ [{'role': 'user', 'content': step2_filled}]
38
61
  )
39
62
 
40
- verification_questions = verification_response.choices[0].message.content
41
-
42
63
  # STEP 3: Answer verification questions WITH the image
43
64
  questions_list = [
44
65
  remove_numbering(q)
@@ -46,50 +67,33 @@ def image_chain_of_verification_openai(
46
67
  if q.strip()
47
68
  ]
48
69
  verification_qa = []
49
-
50
70
  for question in questions_list:
51
71
  step3_filled = step3_prompt.replace('<<QUESTION>>', question)
52
-
53
- # Include image in the verification question
54
72
  message_content = [
55
73
  {"type": "text", "text": step3_filled},
56
74
  image_content
57
75
  ]
58
-
59
- answer_response = client.chat.completions.create(
60
- model=user_model,
61
- messages=[{'role': 'user', 'content': message_content}],
62
- **_params
76
+ answer = make_openai_request(
77
+ [{'role': 'user', 'content': message_content}]
63
78
  )
64
-
65
- answer = answer_response.choices[0].message.content
66
79
  verification_qa.append(f"Q: {question}\nA: {answer}")
67
80
 
68
81
  # STEP 4: Final corrected categorization WITH the image
69
82
  verification_qa_text = "\n\n".join(verification_qa)
70
-
71
83
  step4_filled = (step4_prompt
72
84
  .replace('<<INITIAL_REPLY>>', initial_reply)
73
85
  .replace('<<VERIFICATION_QA>>', verification_qa_text))
74
-
75
- # Include image in final categorization
76
86
  final_message_content = [
77
87
  {"type": "text", "text": step4_filled},
78
88
  image_content
79
89
  ]
80
-
81
- final_response = client.chat.completions.create(
82
- model=user_model,
83
- messages=[{'role': 'user', 'content': final_message_content}],
84
- response_format={"type": "json_object"},
85
- **_params
90
+ verified_reply = make_openai_request(
91
+ [{'role': 'user', 'content': final_message_content}],
92
+ json_mode=True,
86
93
  )
87
-
88
- verified_reply = final_response.choices[0].message.content
89
-
90
94
  return verified_reply
91
95
 
92
- except Exception as e:
96
+ except Exception:
93
97
  return initial_reply
94
98
 
95
99
 
@@ -318,37 +322,54 @@ def image_chain_of_verification_mistral(
318
322
  step2_prompt,
319
323
  step3_prompt,
320
324
  step4_prompt,
321
- client,
325
+ client, # Deprecated, kept for backward compatibility
322
326
  user_model,
323
327
  creativity,
324
328
  remove_numbering,
325
- image_content
329
+ image_content,
330
+ api_key=None,
326
331
  ):
327
332
  """
328
333
  Execute Chain of Verification (CoVe) process for images with Mistral AI.
329
334
  The image is included in verification steps for accurate assessment.
330
335
  Returns the verified reply or initial reply if error occurs.
331
336
 
337
+ Uses direct HTTP requests instead of the mistralai SDK.
338
+
332
339
  Args:
333
340
  image_content: The image content in Mistral format (dict with image_url)
341
+ api_key: Mistral API key.
334
342
  """
335
- try:
336
- # Sampling params via the shared shaper. Computed once, reused
337
- # across the 3 steps.
343
+ import requests
344
+
345
+ if api_key is None:
346
+ return initial_reply
347
+
348
+ endpoint = "https://api.mistral.ai/v1/chat/completions"
349
+ headers = {
350
+ "Content-Type": "application/json",
351
+ "Authorization": f"Bearer {api_key}",
352
+ }
353
+
354
+ def make_mistral_request(messages, json_mode=False):
355
+ payload = {"model": user_model, "messages": messages}
356
+ # Sampling params via the shared shaper.
338
357
  from cat_stack._providers import apply_model_params
339
- _params = apply_model_params({}, "mistral", user_model, creativity=creativity)
358
+ apply_model_params(payload, "mistral", user_model, creativity=creativity)
359
+ if json_mode:
360
+ payload["response_format"] = {"type": "json_object"}
361
+ response = requests.post(endpoint, headers=headers, json=payload, timeout=120)
362
+ response.raise_for_status()
363
+ result = response.json()
364
+ return result["choices"][0]["message"]["content"]
340
365
 
366
+ try:
341
367
  # STEP 2: Generate verification questions (text only)
342
368
  step2_filled = step2_prompt.replace('<<INITIAL_REPLY>>', initial_reply)
343
-
344
- verification_response = client.chat.complete(
345
- model=user_model,
346
- messages=[{'role': 'user', 'content': step2_filled}],
347
- **_params
369
+ verification_questions = make_mistral_request(
370
+ [{'role': 'user', 'content': step2_filled}]
348
371
  )
349
372
 
350
- verification_questions = verification_response.choices[0].message.content
351
-
352
373
  # STEP 3: Answer verification questions WITH the image
353
374
  questions_list = [
354
375
  remove_numbering(q)
@@ -356,48 +377,31 @@ def image_chain_of_verification_mistral(
356
377
  if q.strip()
357
378
  ]
358
379
  verification_qa = []
359
-
360
380
  for question in questions_list:
361
381
  step3_filled = step3_prompt.replace('<<QUESTION>>', question)
362
-
363
- # Include image in the verification question
364
382
  message_content = [
365
383
  {"type": "text", "text": step3_filled},
366
384
  image_content
367
385
  ]
368
-
369
- answer_response = client.chat.complete(
370
- model=user_model,
371
- messages=[{'role': 'user', 'content': message_content}],
372
- **_params
386
+ answer = make_mistral_request(
387
+ [{'role': 'user', 'content': message_content}]
373
388
  )
374
-
375
- answer = answer_response.choices[0].message.content
376
389
  verification_qa.append(f"Q: {question}\nA: {answer}")
377
390
 
378
391
  # STEP 4: Final corrected categorization WITH the image
379
392
  verification_qa_text = "\n\n".join(verification_qa)
380
-
381
393
  step4_filled = (step4_prompt
382
394
  .replace('<<INITIAL_REPLY>>', initial_reply)
383
395
  .replace('<<VERIFICATION_QA>>', verification_qa_text))
384
-
385
- # Include image in final categorization
386
396
  final_message_content = [
387
397
  {"type": "text", "text": step4_filled},
388
398
  image_content
389
399
  ]
390
-
391
- final_response = client.chat.complete(
392
- model=user_model,
393
- messages=[{'role': 'user', 'content': final_message_content}],
394
- response_format={"type": "json_object"},
395
- **_params
400
+ verified_reply = make_mistral_request(
401
+ [{'role': 'user', 'content': final_message_content}],
402
+ json_mode=True,
396
403
  )
397
-
398
- verified_reply = final_response.choices[0].message.content
399
-
400
404
  return verified_reply
401
405
 
402
- except Exception as e:
406
+ except Exception:
403
407
  return initial_reply
@@ -199,8 +199,9 @@ def get_stepback_insight_via_complete(
199
199
  ):
200
200
  """Stepback insight via the central UnifiedLLMClient.complete().
201
201
 
202
- For providers with no direct HTTP endpoint (claude-agent / claude-code),
203
- which route through complete() rather than a provider-specific requests.post.
202
+ For providers with no direct HTTP endpoint (claude-agent / codex-agent /
203
+ claude-code), which route through complete() rather than a
204
+ provider-specific requests.post.
204
205
  Returns (insight_text, True) on success, (None, False) otherwise.
205
206
  """
206
207
  from cat_stack._providers import UnifiedLLMClient
@@ -483,6 +483,7 @@ def classify(
483
483
  if check_verbosity and categories and categories != "auto":
484
484
  # Extract API key and provider from first model entry
485
485
  first_entry = models[0]
486
+ check_model = first_entry[0] if len(first_entry) >= 1 else None
486
487
  check_key = first_entry[2] if len(first_entry) >= 3 else None
487
488
  check_source = first_entry[1] if len(first_entry) >= 2 else "auto"
488
489
 
@@ -491,6 +492,7 @@ def classify(
491
492
  verbosity = check_category_verbosity(
492
493
  categories,
493
494
  api_key=check_key,
495
+ user_model=check_model,
494
496
  model_source=check_source,
495
497
  )
496
498
  lacking = [r for r in verbosity if not r["is_verbose"]]
@@ -530,7 +532,9 @@ def classify(
530
532
  if pilot_test and categories and categories != "auto":
531
533
  from ._pilot_test import run_pilot_test
532
534
 
533
- pilot_sample_size = pilot_test if isinstance(pilot_test, int) else 10
535
+ # bool is an int subclass — pilot_test=True must mean "10 items",
536
+ # not a sample of size 1
537
+ pilot_sample_size = pilot_test if isinstance(pilot_test, int) and not isinstance(pilot_test, bool) else 10
534
538
 
535
539
  # Build kwargs that mirror what classify() passes to classify_ensemble
536
540
  _pilot_ensemble_kwargs = dict(
@@ -564,6 +568,9 @@ def classify(
564
568
  consensus_threshold=consensus_threshold,
565
569
  multi_label=multi_label,
566
570
  input_mode=input_mode,
571
+ system_prompt=system_prompt,
572
+ two_step_classify=two_step_classify,
573
+ categories_per_call=categories_per_call,
567
574
  )
568
575
 
569
576
  pilot_result = run_pilot_test(
@@ -584,7 +591,9 @@ def classify(
584
591
  if prompt_tune and categories and categories != "auto":
585
592
  from .prompt_tune import prompt_tune as _prompt_tune
586
593
 
587
- tune_sample_size = prompt_tune if isinstance(prompt_tune, int) else 10
594
+ # bool is an int subclass — prompt_tune=True must mean "10 items",
595
+ # not a sample of size 1
596
+ tune_sample_size = prompt_tune if isinstance(prompt_tune, int) and not isinstance(prompt_tune, bool) else 10
588
597
 
589
598
  tune_result = _prompt_tune(
590
599
  input_data=input_data,
@@ -916,8 +925,19 @@ def classify(
916
925
  from ._batch import UNSUPPORTED_BATCH_PROVIDERS, run_batch_classify
917
926
  from .text_functions_ensemble import prepare_json_schemas, prepare_model_configs
918
927
 
928
+ # Guard: batch mode can't auto-discover categories (that happens in
929
+ # classify_ensemble, which batch mode bypasses) — without this,
930
+ # enumerate("auto") would silently classify into the four one-letter
931
+ # categories "a", "u", "t", "o".
932
+ if categories == "auto":
933
+ raise ValueError(
934
+ "categories='auto' is not supported with batch_mode=True. "
935
+ "Discover categories first (e.g. with cat_stack.explore()) and pass "
936
+ "the resulting list, or set batch_mode=False."
937
+ )
938
+
919
939
  # Guard: text input only (auto-detect)
920
- from .text_functions_ensemble import _detect_input_type
940
+ from .text_functions_ensemble import _convert_docx_to_text, _detect_input_type
921
941
  detected_type = _detect_input_type(input_data)
922
942
  if detected_type in ("pdf", "image"):
923
943
  if input_mode == "text":
@@ -929,6 +949,12 @@ def classify(
929
949
  f"batch_mode=True only supports text input, but detected input type is '{detected_type}'. "
930
950
  "Set batch_mode=False for PDF/image classification."
931
951
  )
952
+ if detected_type == "docx":
953
+ # Mirror the sync path (classify_ensemble): convert to text first.
954
+ print("Converting DOCX files to text...")
955
+ input_data = _convert_docx_to_text(input_data)
956
+ if isinstance(input_data, str):
957
+ input_data = [input_data]
932
958
 
933
959
  # Warn if embedding_tiebreaker was provided (not supported in batch mode yet)
934
960
  if _embedding_tiebreaker_state is not None:
@@ -974,7 +1000,7 @@ def classify(
974
1000
  "stepback_insights": {},
975
1001
  "system_prompt": system_prompt,
976
1002
  "json_schema": json_schemas[cfg["model"]],
977
- "creativity": creativity,
1003
+ "creativity": cfg["creativity"] if cfg["creativity"] is not None else creativity,
978
1004
  "thinking_budget": thinking_budget,
979
1005
  "multi_label": multi_label,
980
1006
  }