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.
- {cat_stack-2.2.0 → cat_stack-2.4.0}/PKG-INFO +5 -2
- {cat_stack-2.2.0 → cat_stack-2.4.0}/pyproject.toml +9 -1
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/__about__.py +1 -1
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_batch.py +8 -2
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_chunked.py +5 -1
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_providers.py +33 -14
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_tiebreaker.py +9 -4
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_utils.py +9 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_wrapper_helpers.py +4 -1
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/image_CoVe.py +79 -75
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/stepback.py +3 -2
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/classify.py +30 -4
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/image_functions.py +53 -9
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/pdf_functions.py +41 -1
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/prompt_tune.py +9 -2
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/summarize.py +20 -5
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/text_functions.py +6 -5
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/text_functions_ensemble.py +104 -34
- {cat_stack-2.2.0 → cat_stack-2.4.0}/.gitignore +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/LICENSE +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/README.md +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/cat_stack/__init__.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/__init__.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_category_analysis.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_embeddings.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_formatter.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_pilot_test.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_prompts.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_review_ui.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/_web_fetch.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/CoVe.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/__init__.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/image_stepback.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/pdf_CoVe.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/pdf_stepback.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/calls/top_n.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/collapse_themes.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/explore.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/extract.py +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/circle.png +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/cube.png +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/diamond.png +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/overlapping_pentagons.png +0 -0
- {cat_stack-2.2.0 → cat_stack-2.4.0}/src/catstack/images/rectangles.png +0 -0
- {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.
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
# (
|
|
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
|
|
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
|
|
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
|
|
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`,
|
|
1240
|
-
(no API key) and
|
|
1241
|
-
an optional dependency
|
|
1242
|
-
|
|
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='
|
|
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(
|
|
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
|
|
1325
|
-
return self.
|
|
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
|
-
#
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 (
|
|
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
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
|
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 /
|
|
203
|
-
which route through complete() rather than a
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|