fix(translate): token budget overhaul + truncation retry + smart batch sizing + LLM provider fixes
Token Budget (_token_budget.py):
- DEFAULT_MAX_OUTPUT_TOKENS 8192→16384 (adequate for 50 rows×4 langs)
- Add PROVIDER_DEFAULTS with per-model context_window/max_output_tokens
- OUTPUT_PER_ROW_PER_LANG 60→120, add JSON_OVERHEAD_PER_ROW=50
- PROMPT_BASE_TOKENS 300→600, MAX_OUTPUT_HEADROOM 1000→3000
- CJK-aware token estimator (_estimate_tokens_for_text: 1.5 chars/tok for CJK)
- Output-aware batch sizing (_apply_output_aware_batch_sizing)
- Warn on dictionary cap mismatch
Executor (executor.py):
- Return finish_reason from _call_openai_compatible→_call_llm
- Truncation detection + batch splitting on finish_reason=length
- Smart batch sizing (_auto_size_batches): greedily split by row token budget
- Fix disable_reasoning hardcoded 8192→use calculated max_tokens
- TypeError guard on choices[0] access, base_url validation
- Broadened response_format fallback (matches response_format|structured|json_object)
- Remove invalid extra_body, fix partial-recovery regex for integer row_id
- 18 new tests for estimate_row_tokens and _auto_size_batches
Preview (preview.py):
- Align token constants with _token_budget.py (CHARS_PER_TOKEN=2.2, OUTPUT=120)
- Return finish_reason, broadened response_format fallback
- Fix hardcoded 8192, base_url validation
LLM Services:
- render_prompt: detect unfilled {placeholders}, log WARNING
- llm_provider: distinguish crypto exceptions (InvalidTag vs ValueError vs generic)
- Respect Retry-After header on HTTP 429
This commit is contained in:
@@ -15,7 +15,7 @@ from src.models.translate import (
|
||||
TranslationJob,
|
||||
TranslationRun,
|
||||
)
|
||||
from src.plugins.translate.executor import TranslationExecutor
|
||||
from src.plugins.translate.executor import TranslationExecutor, estimate_row_tokens
|
||||
|
||||
|
||||
# region mock_job [TYPE Function]
|
||||
@@ -263,4 +263,435 @@ class TestCancellationFlag:
|
||||
|
||||
|
||||
# endregion TestCancellationFlag
|
||||
|
||||
|
||||
# region TestEstimateRowTokens [TYPE Class]
|
||||
# @PURPOSE: Tests for estimate_row_tokens — per-row token estimation for adaptive batch sizing.
|
||||
# @RELATION: BINDS_TO -> [estimate_row_tokens]
|
||||
class TestEstimateRowTokens:
|
||||
"""Unit tests for estimate_row_tokens()."""
|
||||
|
||||
# region test_empty_text [TYPE Function]
|
||||
def test_empty_text(self) -> None:
|
||||
"""Empty source_text returns minimal tokens."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.context_columns = []
|
||||
tokens = estimate_row_tokens("", None, job)
|
||||
assert tokens >= 1, f"Expected >= 1 token for empty text, got {tokens}"
|
||||
|
||||
# endregion test_empty_text
|
||||
|
||||
# region test_short_text [TYPE Function]
|
||||
def test_short_text(self) -> None:
|
||||
"""Short ASCII text estimates roughly chars/2.2 tokens."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.context_columns = []
|
||||
tokens = estimate_row_tokens("Hello world", None, job)
|
||||
# "Hello world" = 11 chars → ~5 tokens at 2.2 chars/token
|
||||
assert 3 <= tokens <= 10, f"Expected reasonable token count, got {tokens}"
|
||||
|
||||
# endregion test_short_text
|
||||
|
||||
# region test_with_context [TYPE Function]
|
||||
def test_with_context(self) -> None:
|
||||
"""Context columns contribute to token estimate."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.context_columns = ["category", "description"]
|
||||
source_data = {"category": "Billing", "description": "Monthly invoice summary"}
|
||||
tokens_no_ctx = estimate_row_tokens("Product name", None, job)
|
||||
tokens_with_ctx = estimate_row_tokens("Product name", source_data, job)
|
||||
assert tokens_with_ctx > tokens_no_ctx, (
|
||||
f"Context should increase tokens: no_ctx={tokens_no_ctx}, with_ctx={tokens_with_ctx}"
|
||||
)
|
||||
|
||||
# endregion test_with_context
|
||||
|
||||
# region test_cjk_text [TYPE Function]
|
||||
def test_cjk_text(self) -> None:
|
||||
"""CJK characters are token-denser (~1.5 chars/token)."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.context_columns = []
|
||||
# 12 CJK chars → 12/1.5 = 8 tokens, plus 1 for empty context = 9
|
||||
tokens = estimate_row_tokens("你好世界这是一个测试消息", None, job)
|
||||
assert tokens == 9, f"Expected 9 tokens (8 CJK + 1 empty ctx) for CJK, got {tokens}"
|
||||
|
||||
# endregion test_cjk_text
|
||||
|
||||
# region test_long_text [TYPE Function]
|
||||
def test_long_text(self) -> None:
|
||||
"""Long text produces proportionally more tokens."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.context_columns = []
|
||||
long_text = "word " * 500 # ~2500 chars
|
||||
tokens = estimate_row_tokens(long_text, None, job)
|
||||
assert tokens > 100, f"Expected >100 tokens for long text, got {tokens}"
|
||||
|
||||
# endregion test_long_text
|
||||
|
||||
# region test_source_data_none_with_context_keys [TYPE Function]
|
||||
def test_source_data_none_with_context_keys(self) -> None:
|
||||
"""When source_data is None but context_keys exist, no crash."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.context_columns = ["category"]
|
||||
tokens = estimate_row_tokens("Hello", None, job)
|
||||
assert tokens >= 1, f"Expected at least 1 token, got {tokens}"
|
||||
|
||||
# endregion test_source_data_none_with_context_keys
|
||||
|
||||
|
||||
# endregion TestEstimateRowTokens
|
||||
|
||||
|
||||
# region TestAutoSizeBatches [TYPE Class]
|
||||
# @PURPOSE: Tests for _auto_size_batches — variable-sized batch splitting based on content length.
|
||||
# @RELATION: BINDS_TO -> [TranslationExecutor._auto_size_batches]
|
||||
# @RELATION: BINDS_TO -> [estimate_token_budget]
|
||||
class TestAutoSizeBatches:
|
||||
"""Tests for TranslationExecutor._auto_size_batches()."""
|
||||
|
||||
# region _make_executor [TYPE Function]
|
||||
@pytest.fixture
|
||||
def executor(self) -> TranslationExecutor:
|
||||
db = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
return TranslationExecutor(db, config_manager)
|
||||
|
||||
# endregion _make_executor
|
||||
|
||||
# region _make_job [TYPE Function]
|
||||
@pytest.fixture
|
||||
def job(self) -> MagicMock:
|
||||
j = MagicMock(spec=TranslationJob)
|
||||
j.id = "job-autosize-1"
|
||||
j.translation_column = "source_text"
|
||||
j.context_columns = []
|
||||
j.target_languages = ["en"]
|
||||
j.target_dialect = None
|
||||
j.batch_size = 50
|
||||
return j
|
||||
|
||||
# endregion _make_job
|
||||
|
||||
# region test_empty_rows [TYPE Function]
|
||||
def test_empty_rows(self, executor: TranslationExecutor, job: MagicMock) -> None:
|
||||
"""Empty source_rows returns empty list."""
|
||||
batches = executor._auto_size_batches(job, [], ["en"], provider_info="gpt-4o-mini")
|
||||
assert batches == [], f"Expected empty list, got {batches}"
|
||||
|
||||
# endregion test_empty_rows
|
||||
|
||||
# region test_small_dataset_single_batch [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.estimate_token_budget")
|
||||
def test_small_dataset_single_batch(
|
||||
self,
|
||||
mock_estimate: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
job: MagicMock,
|
||||
) -> None:
|
||||
"""Few short rows → single batch (all fit within budget)."""
|
||||
mock_estimate.return_value = {
|
||||
"batch_size_adjusted": 10,
|
||||
"estimated_input_tokens": 5000,
|
||||
"estimated_output_tokens": 2000,
|
||||
"max_output_needed": 4096,
|
||||
"warning": None,
|
||||
}
|
||||
source_rows = [
|
||||
{"row_index": str(i), "source_text": "short text", "source_data": None}
|
||||
for i in range(5)
|
||||
]
|
||||
batches = executor._auto_size_batches(
|
||||
job, source_rows, ["en"], provider_info="gpt-4o-mini",
|
||||
)
|
||||
assert len(batches) == 1, f"Expected 1 batch, got {len(batches)}"
|
||||
assert len(batches[0]) == 5, f"Expected 5 rows in batch, got {len(batches[0])}"
|
||||
|
||||
# endregion test_small_dataset_single_batch
|
||||
|
||||
# region test_homogeneous_rows [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.estimate_token_budget")
|
||||
def test_homogeneous_rows(
|
||||
self,
|
||||
mock_estimate: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
job: MagicMock,
|
||||
) -> None:
|
||||
"""Homogeneous short rows → many rows per batch (budget-efficient)."""
|
||||
mock_estimate.return_value = {
|
||||
"batch_size_adjusted": 20,
|
||||
"estimated_input_tokens": 8000,
|
||||
"estimated_output_tokens": 4000,
|
||||
"max_output_needed": 4096,
|
||||
"warning": None,
|
||||
}
|
||||
# 50 very short rows (10 chars each) → fits in ~3 batches of ~20
|
||||
source_rows = [
|
||||
{"row_index": str(i), "source_text": "short", "source_data": None}
|
||||
for i in range(50)
|
||||
]
|
||||
batches = executor._auto_size_batches(
|
||||
job, source_rows, ["en"], provider_info="gpt-4o-mini",
|
||||
)
|
||||
# Should produce fewer batches than the fixed 50-size approach (1 batch)
|
||||
# With budget ~8K tokens and rows ~2 tokens each → ~20-25 rows per batch
|
||||
assert len(batches) >= 1, "Expected at least 1 batch"
|
||||
total = sum(len(b) for b in batches)
|
||||
assert total == 50, f"Expected 50 total rows, got {total}"
|
||||
# Average batch size should be higher than 1
|
||||
avg_size = sum(len(b) for b in batches) / len(batches)
|
||||
assert avg_size >= 5, f"Expected avg batch size >= 5, got {avg_size}"
|
||||
|
||||
# endregion test_homogeneous_rows
|
||||
|
||||
# region test_mixed_length_rows [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.estimate_token_budget")
|
||||
def test_mixed_length_rows(
|
||||
self,
|
||||
mock_estimate: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
job: MagicMock,
|
||||
) -> None:
|
||||
"""Mixed-length rows → variable-sized batches based on content length."""
|
||||
# Tight budget: per_batch_budget = 1200 - 600 = 600 tokens
|
||||
# Short rows (~1 token) can fit ~600 per batch.
|
||||
# Long row (~1136 tokens) ALONE exceeds the per-batch budget → own batch.
|
||||
mock_estimate.return_value = {
|
||||
"batch_size_adjusted": 3,
|
||||
"estimated_input_tokens": 1200,
|
||||
"estimated_output_tokens": 500,
|
||||
"max_output_needed": 4096,
|
||||
"warning": None,
|
||||
}
|
||||
# Rows with very different lengths: 2 short, 1 long, 2 short
|
||||
source_rows = [
|
||||
{"row_index": "0", "source_text": "a", "source_data": None},
|
||||
{"row_index": "1", "source_text": "b", "source_data": None},
|
||||
# Long row with ~2500 chars (~1136 tokens)
|
||||
{"row_index": "2", "source_text": "long " * 500, "source_data": None},
|
||||
{"row_index": "3", "source_text": "c", "source_data": None},
|
||||
{"row_index": "4", "source_text": "d", "source_data": None},
|
||||
]
|
||||
batches = executor._auto_size_batches(
|
||||
job, source_rows, ["en"], provider_info="gpt-4o-mini",
|
||||
)
|
||||
total = sum(len(b) for b in batches)
|
||||
assert total == 5, f"Expected 5 total rows, got {total}"
|
||||
# The long row exceeds the per-batch budget → isolated in own batch
|
||||
# Short rows fit together → remaining 4 rows in 1-2 batches
|
||||
assert len(batches) >= 2, (
|
||||
f"Expected at least 2 batches (long row isolated), got {len(batches)}: "
|
||||
f"{[len(b) for b in batches]}"
|
||||
)
|
||||
# Verify the long row is in its own batch
|
||||
long_batches = [b for b in batches if any(r["source_text"] == "long " * 500 for r in b)]
|
||||
assert len(long_batches) == 1, "Long row should be in exactly one batch"
|
||||
assert len(long_batches[0]) == 1, "Long row batch should have exactly 1 row"
|
||||
|
||||
# endregion test_mixed_length_rows
|
||||
|
||||
# region test_row_exceeds_budget [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.estimate_token_budget")
|
||||
def test_row_exceeds_budget(
|
||||
self,
|
||||
mock_estimate: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
job: MagicMock,
|
||||
) -> None:
|
||||
"""Single row exceeding per-batch budget → placed in own batch with WARN."""
|
||||
mock_estimate.return_value = {
|
||||
"batch_size_adjusted": 5,
|
||||
"estimated_input_tokens": 3000,
|
||||
"estimated_output_tokens": 2000,
|
||||
"max_output_needed": 4096,
|
||||
"warning": None,
|
||||
}
|
||||
# Row with 3000 chars text — token count will exceed the per_batch_budget
|
||||
# which is estimated_input_tokens - PROMPT_BASE_TOKENS = 3000 - 600 = 2400
|
||||
# The row text "x" * 3000 has ~3000/2.2 ≈ 1364 tokens → exceeds 2400...
|
||||
# Actually 1364 < 2400, so it would fit. Let me use longer text.
|
||||
# "x" * 6000 → ~6000/2.2 ≈ 2727 tokens > 2400
|
||||
source_rows = [
|
||||
{"row_index": "0", "source_text": "x" * 6000, "source_data": None},
|
||||
{"row_index": "1", "source_text": "short", "source_data": None},
|
||||
{"row_index": "2", "source_text": "tiny", "source_data": None},
|
||||
]
|
||||
batches = executor._auto_size_batches(
|
||||
job, source_rows, ["en"], provider_info="gpt-4o-mini",
|
||||
)
|
||||
total = sum(len(b) for b in batches)
|
||||
assert total == 3, f"Expected 3 total rows, got {total}"
|
||||
# First row should be in its own batch
|
||||
assert len(batches[0]) == 1, "Expected oversized row in own batch"
|
||||
|
||||
# endregion test_row_exceeds_budget
|
||||
|
||||
# region test_budget_failure_fallback [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.estimate_token_budget")
|
||||
def test_budget_failure_fallback(
|
||||
self,
|
||||
mock_estimate: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
job: MagicMock,
|
||||
) -> None:
|
||||
"""When estimate_token_budget fails (recommended=0), fallback to fixed batch_size."""
|
||||
mock_estimate.return_value = {
|
||||
"batch_size_adjusted": 0, # Failure signal
|
||||
"estimated_input_tokens": 0,
|
||||
"estimated_output_tokens": 0,
|
||||
"max_output_needed": 0,
|
||||
"warning": "Budget error",
|
||||
}
|
||||
source_rows = [
|
||||
{"row_index": str(i), "source_text": "short text", "source_data": None}
|
||||
for i in range(60)
|
||||
]
|
||||
batches = executor._auto_size_batches(
|
||||
job, source_rows, ["en"], provider_info="gpt-4o-mini",
|
||||
)
|
||||
# Fallback to job.batch_size=50 → should produce 2 batches (50 + 10)
|
||||
assert len(batches) == 2, f"Expected 2 fallback batches (50+10), got {len(batches)}"
|
||||
assert len(batches[0]) == 50, f"Expected 50 in first fallback batch, got {len(batches[0])}"
|
||||
|
||||
# endregion test_budget_failure_fallback
|
||||
|
||||
# region test_budget_zero_input_collapse [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.estimate_token_budget")
|
||||
def test_budget_zero_input_collapse(
|
||||
self,
|
||||
mock_estimate: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
job: MagicMock,
|
||||
) -> None:
|
||||
"""When per_batch_budget collapses (<=0), fallback to fixed batch_size."""
|
||||
mock_estimate.return_value = {
|
||||
"batch_size_adjusted": 5,
|
||||
"estimated_input_tokens": 100, # Less than PROMPT_BASE_TOKENS (600)
|
||||
"estimated_output_tokens": 50,
|
||||
"max_output_needed": 100,
|
||||
"warning": None,
|
||||
}
|
||||
source_rows = [
|
||||
{"row_index": str(i), "source_text": "test", "source_data": None}
|
||||
for i in range(60)
|
||||
]
|
||||
batches = executor._auto_size_batches(
|
||||
job, source_rows, ["en"], provider_info="gpt-4o-mini",
|
||||
)
|
||||
# Should fallback to job.batch_size=50
|
||||
assert len(batches) == 2, f"Expected 2 fallback batches, got {len(batches)}"
|
||||
|
||||
# endregion test_budget_zero_input_collapse
|
||||
|
||||
# region test_provider_info_resolution [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.estimate_token_budget")
|
||||
def test_provider_info_resolution(
|
||||
self,
|
||||
mock_estimate: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
job: MagicMock,
|
||||
) -> None:
|
||||
"""When provider_info is None, _resolve_provider_model is called."""
|
||||
mock_estimate.return_value = {
|
||||
"batch_size_adjusted": 10,
|
||||
"estimated_input_tokens": 5000,
|
||||
"estimated_output_tokens": 2000,
|
||||
"max_output_needed": 4096,
|
||||
"warning": None,
|
||||
}
|
||||
source_rows = [
|
||||
{"row_index": "0", "source_text": "hello", "source_data": None},
|
||||
]
|
||||
# provider_info=None → should call _resolve_provider_model
|
||||
with patch.object(executor, '_resolve_provider_model', return_value="gpt-4o-mini") as mock_resolve:
|
||||
batches = executor._auto_size_batches(
|
||||
job, source_rows, ["en"], provider_info=None,
|
||||
)
|
||||
mock_resolve.assert_called_once_with(job)
|
||||
assert len(batches) == 1
|
||||
|
||||
# endregion test_provider_info_resolution
|
||||
|
||||
# region test_resolve_provider_model [TYPE Function]
|
||||
def test_resolve_provider_model_no_provider(self, executor: TranslationExecutor) -> None:
|
||||
"""_resolve_provider_model returns None when job has no provider_id."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.provider_id = None
|
||||
result = executor._resolve_provider_model(job)
|
||||
assert result is None, f"Expected None when no provider_id, got {result}"
|
||||
|
||||
# endregion test_resolve_provider_model
|
||||
|
||||
# region test_resolve_provider_model_with_provider [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.LLMProviderService")
|
||||
def test_resolve_provider_model_with_provider(
|
||||
self,
|
||||
mock_provider_svc: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
) -> None:
|
||||
"""_resolve_provider_model returns the default model name."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.provider_id = "provider-1"
|
||||
mock_svc_instance = MagicMock()
|
||||
mock_provider = MagicMock()
|
||||
mock_provider.default_model = "gpt-4o-mini"
|
||||
mock_svc_instance.get_provider.return_value = mock_provider
|
||||
mock_provider_svc.return_value = mock_svc_instance
|
||||
|
||||
executor.db = MagicMock()
|
||||
result = executor._resolve_provider_model(job)
|
||||
assert result == "gpt-4o-mini", f"Expected 'gpt-4o-mini', got {result}"
|
||||
|
||||
# endregion test_resolve_provider_model_with_provider
|
||||
|
||||
# region test_resolve_provider_model_exception [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.LLMProviderService")
|
||||
def test_resolve_provider_model_exception(
|
||||
self,
|
||||
mock_provider_svc: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
) -> None:
|
||||
"""_resolve_provider_model returns None on exception."""
|
||||
job = MagicMock(spec=TranslationJob)
|
||||
job.provider_id = "provider-1"
|
||||
mock_svc_instance = MagicMock()
|
||||
mock_svc_instance.get_provider.side_effect = Exception("DB error")
|
||||
mock_provider_svc.return_value = mock_svc_instance
|
||||
|
||||
executor.db = MagicMock()
|
||||
result = executor._resolve_provider_model(job)
|
||||
assert result is None, f"Expected None on exception, got {result}"
|
||||
|
||||
# endregion test_resolve_provider_model_exception
|
||||
|
||||
# region test_at_least_one_row_per_batch [TYPE Function]
|
||||
@patch("src.plugins.translate.executor.estimate_token_budget")
|
||||
def test_at_least_one_row_per_batch(
|
||||
self,
|
||||
mock_estimate: MagicMock,
|
||||
executor: TranslationExecutor,
|
||||
job: MagicMock,
|
||||
) -> None:
|
||||
"""Even when rows are large, each batch has at least 1 row."""
|
||||
mock_estimate.return_value = {
|
||||
"batch_size_adjusted": 1,
|
||||
"estimated_input_tokens": 1000,
|
||||
"estimated_output_tokens": 500,
|
||||
"max_output_needed": 4096,
|
||||
"warning": None,
|
||||
}
|
||||
source_rows = [
|
||||
{"row_index": str(i), "source_text": "large " * 200, "source_data": None}
|
||||
for i in range(3)
|
||||
]
|
||||
batches = executor._auto_size_batches(
|
||||
job, source_rows, ["en"], provider_info="gpt-4o-mini",
|
||||
)
|
||||
total = sum(len(b) for b in batches)
|
||||
assert total == 3, f"Expected 3 total rows, got {total}"
|
||||
assert all(len(b) >= 1 for b in batches), "Each batch must have at least 1 row"
|
||||
|
||||
# endregion test_at_least_one_row_per_batch
|
||||
|
||||
|
||||
# endregion TestAutoSizeBatches
|
||||
# endregion ExecutorTests
|
||||
|
||||
@@ -14,8 +14,8 @@ DEFAULT_CONTEXT_WINDOW = 64000
|
||||
# #endregion DEFAULT_CONTEXT_WINDOW
|
||||
|
||||
# #region DEFAULT_MAX_OUTPUT_TOKENS [TYPE Constant]
|
||||
# @BRIEF Default max_tokens setting for LLM output (8192 tokens).
|
||||
DEFAULT_MAX_OUTPUT_TOKENS = 8192
|
||||
# @BRIEF Default max_tokens setting for LLM output (16384 — sufficient for 50 rows x 4 languages).
|
||||
DEFAULT_MAX_OUTPUT_TOKENS = 16384
|
||||
# #endregion DEFAULT_MAX_OUTPUT_TOKENS
|
||||
|
||||
# #region REASONING_OVERHEAD [TYPE Constant]
|
||||
@@ -23,14 +23,37 @@ DEFAULT_MAX_OUTPUT_TOKENS = 8192
|
||||
REASONING_OVERHEAD = 2000
|
||||
# #endregion REASONING_OVERHEAD
|
||||
|
||||
# #region PROVIDER_DEFAULTS [TYPE Constant]
|
||||
# @BRIEF Provider-aware defaults for context_window and max_output_tokens.
|
||||
# Maps model name (or "default" fallback) to capacity limits.
|
||||
# @RATIONALE: Different providers have drastically different context windows and
|
||||
# output limits. Using a single default for all causes either wasted
|
||||
# capacity (underestimation) or truncation (overestimation).
|
||||
PROVIDER_DEFAULTS: dict[str, dict[str, int]] = {
|
||||
"gpt-4o-mini": {"context_window": 128000, "max_output_tokens": 16384},
|
||||
"gpt-4o": {"context_window": 128000, "max_output_tokens": 16384},
|
||||
"o1-mini": {"context_window": 128000, "max_output_tokens": 65536},
|
||||
"claude-3-5-sonnet": {"context_window": 200000, "max_output_tokens": 8192},
|
||||
"deepseek-v4-flash": {"context_window": 64000, "max_output_tokens": 8192},
|
||||
"default": {"context_window": 64000, "max_output_tokens": 16384},
|
||||
}
|
||||
# #endregion PROVIDER_DEFAULTS
|
||||
|
||||
# #region OUTPUT_PER_ROW_PER_LANG [TYPE Constant]
|
||||
# @BRIEF Estimated output tokens per row per language in JSON response format.
|
||||
OUTPUT_PER_ROW_PER_LANG = 60
|
||||
# Increased from 60 to 120 because SQL/dashboard text and JSON structure need more.
|
||||
OUTPUT_PER_ROW_PER_LANG = 120
|
||||
# #endregion OUTPUT_PER_ROW_PER_LANG
|
||||
|
||||
# #region JSON_OVERHEAD_PER_ROW [TYPE Constant]
|
||||
# @BRIEF Estimated overhead tokens for JSON keys, brackets, and formatting per row.
|
||||
JSON_OVERHEAD_PER_ROW = 50
|
||||
# #endregion JSON_OVERHEAD_PER_ROW
|
||||
|
||||
# #region PROMPT_BASE_TOKENS [TYPE Constant]
|
||||
# @BRIEF Base tokens for system prompt + instructions + JSON format specification.
|
||||
PROMPT_BASE_TOKENS = 300
|
||||
# @BRIEF Base tokens for system prompt + instructions + dictionary section + JSON format specification.
|
||||
# Increased from 300 to 600 to account for longer template, system msg, and dict preamble.
|
||||
PROMPT_BASE_TOKENS = 600
|
||||
# #endregion PROMPT_BASE_TOKENS
|
||||
|
||||
# #region DICT_TOKENS_PER_ENTRY [TYPE Constant]
|
||||
@@ -54,15 +77,47 @@ MIN_MAX_TOKENS = 4096
|
||||
# #endregion MIN_MAX_TOKENS
|
||||
|
||||
# #region MAX_OUTPUT_HEADROOM [TYPE Constant]
|
||||
# @BRIEF Extra headroom added to max_output_needed beyond the estimate (1000 tokens buffer).
|
||||
MAX_OUTPUT_HEADROOM = 1000
|
||||
# @BRIEF Extra headroom added to max_output_needed beyond the estimate (3000 = 10-20% for variance).
|
||||
# Increased from 1000 to 3000 because SQL/dashboard text output varies significantly.
|
||||
MAX_OUTPUT_HEADROOM = 3000
|
||||
# #endregion MAX_OUTPUT_HEADROOM
|
||||
|
||||
|
||||
# region _estimate_tokens_for_text [TYPE Function]
|
||||
# @BRIEF Estimate token count for a text string with CJK-aware heuristics.
|
||||
# CJK characters (~1.5 chars/token) vs other text (~2.2 chars/token).
|
||||
# @PRE: text is a string.
|
||||
# @POST: Returns estimated token count >= 1.
|
||||
# @RATIONALE: CJK characters are more token-dense than Latin/Cyrillic text.
|
||||
# Using a single ratio undercounts CJK input and causes truncation.
|
||||
# @REJECTED: Using tiktoken — would introduce a heavy dependency for estimation only.
|
||||
def _estimate_tokens_for_text(text: str) -> int:
|
||||
"""Estimate token count with CJK-aware heuristics.
|
||||
|
||||
CJK characters (CJK Unified Ideographs) use ~1.5 chars/token.
|
||||
All other characters use ~2.2 chars/token.
|
||||
"""
|
||||
if not text:
|
||||
return 1
|
||||
|
||||
cjk_count = 0
|
||||
other_count = 0
|
||||
for ch in text:
|
||||
if '\u4e00' <= ch <= '\u9fff' or '\u3000' <= ch <= '\u303f' or '\uff00' <= ch <= '\uffef':
|
||||
cjk_count += 1
|
||||
else:
|
||||
other_count += 1
|
||||
|
||||
cjk_tokens = cjk_count / 1.5 if cjk_count else 0
|
||||
other_tokens = other_count / 2.2 if other_count else 0
|
||||
return max(1, int(cjk_tokens + other_tokens))
|
||||
# endregion _estimate_tokens_for_text
|
||||
|
||||
|
||||
# region _count_rows_that_fit [TYPE Function]
|
||||
# @BRIEF Count how many rows fit within the available input budget.
|
||||
# @PRE: input_per_row is non-empty; available_budget > 0.
|
||||
# @POST: Returns (safe_count, total_input_tokens).
|
||||
# @POST: Returns (safe_count, total_input_tokens). When no rows fit, returns (0, 0).
|
||||
def _count_rows_that_fit(
|
||||
input_per_row: list[int],
|
||||
available_budget: int,
|
||||
@@ -71,6 +126,7 @@ def _count_rows_that_fit(
|
||||
|
||||
Returns:
|
||||
(safe_count, total_input_tokens): Number of rows and their total tokens.
|
||||
Returns (0, 0) when the first row alone does not fit (MEDIUM fix).
|
||||
"""
|
||||
running_total = 0
|
||||
safe_size = 0
|
||||
@@ -80,7 +136,8 @@ def _count_rows_that_fit(
|
||||
safe_size += 1
|
||||
else:
|
||||
break
|
||||
safe_size = max(safe_size, 1)
|
||||
# MEDIUM: Return (0, 0) when no rows fit — signal upstream that oversizing occurred.
|
||||
# Prevents silent clamping to 1 which would produce truncated LLM calls.
|
||||
return safe_size, running_total
|
||||
# endregion _count_rows_that_fit
|
||||
|
||||
@@ -93,6 +150,64 @@ def _count_rows_that_fit(
|
||||
# depends on the LLM model. Estimates are intentionally conservative to prevent truncation.
|
||||
# @REJECTED: Using tiktoken or similar tokenizer — would introduce a heavy dependency and still
|
||||
# not match DeepSeek's tokenizer exactly.
|
||||
def _calculate_output_tokens(
|
||||
safe_size: int,
|
||||
num_languages: int,
|
||||
) -> int:
|
||||
"""Calculate estimated output tokens for a batch."""
|
||||
return (
|
||||
safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG
|
||||
+ safe_size * JSON_OVERHEAD_PER_ROW
|
||||
+ REASONING_OVERHEAD
|
||||
)
|
||||
|
||||
|
||||
def _apply_output_aware_batch_sizing(
|
||||
safe_size: int,
|
||||
num_languages: int,
|
||||
max_output_tokens: int,
|
||||
) -> int:
|
||||
"""Reduce batch size until estimated output fits within max_output_tokens."""
|
||||
while safe_size > 0:
|
||||
needed_output = (
|
||||
safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG
|
||||
+ safe_size * JSON_OVERHEAD_PER_ROW
|
||||
+ REASONING_OVERHEAD
|
||||
+ MAX_OUTPUT_HEADROOM
|
||||
)
|
||||
if needed_output <= max_output_tokens:
|
||||
break
|
||||
safe_size -= 1
|
||||
return safe_size
|
||||
|
||||
|
||||
def _build_warning(
|
||||
batch_size: int | None,
|
||||
safe_size: int,
|
||||
total_rows: int,
|
||||
context_window: int,
|
||||
estimated_input: int,
|
||||
max_output_needed: int,
|
||||
dict_warning: str | None,
|
||||
) -> str | None:
|
||||
"""Build warning message when batch size is reduced."""
|
||||
warning = None
|
||||
if batch_size and safe_size < batch_size:
|
||||
total_estimated = estimated_input + max_output_needed
|
||||
warning = (
|
||||
f"Reduced batch size from {batch_size} to {safe_size} "
|
||||
f"(estimated {total_estimated} tokens vs {context_window} window)"
|
||||
)
|
||||
elif not batch_size and safe_size < total_rows:
|
||||
warning = (
|
||||
f"Auto-calculated batch size of {safe_size} from {total_rows} rows "
|
||||
f"(output-limited)"
|
||||
)
|
||||
if dict_warning:
|
||||
warning = f"{warning}; {dict_warning}" if warning else dict_warning
|
||||
return warning
|
||||
|
||||
|
||||
def estimate_token_budget(
|
||||
source_rows: list[dict],
|
||||
target_languages: list[str],
|
||||
@@ -100,8 +215,9 @@ def estimate_token_budget(
|
||||
context_columns: list[str] | None = None,
|
||||
dictionary_entries: list | None = None,
|
||||
batch_size: int | None = None,
|
||||
context_window: int = DEFAULT_CONTEXT_WINDOW,
|
||||
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
context_window: int | None = None,
|
||||
max_output_tokens: int | None = None,
|
||||
provider_info: str | None = None,
|
||||
) -> dict:
|
||||
"""Estimate token budget and return safe batch parameters.
|
||||
|
||||
@@ -112,8 +228,11 @@ def estimate_token_budget(
|
||||
context_columns: Optional list of keys for context columns.
|
||||
dictionary_entries: Optional list of dictionary entries for glossary.
|
||||
batch_size: Desired batch size. If None, auto-calculate max safe size.
|
||||
context_window: Model context window (default 64000 for DeepSeek v4 Flash).
|
||||
max_output_tokens: Hard max output tokens limit (default 8192).
|
||||
context_window: Model context window. If None, resolved from provider_info.
|
||||
max_output_tokens: Hard max output tokens limit. If None, resolved from provider_info.
|
||||
provider_info: Optional provider model name (e.g. "gpt-4o-mini") for provider-aware defaults.
|
||||
When provided without explicit context_window/max_output_tokens, uses
|
||||
PROVIDER_DEFAULTS to set appropriate limits.
|
||||
|
||||
Returns:
|
||||
dict with:
|
||||
@@ -126,31 +245,47 @@ def estimate_token_budget(
|
||||
if not target_languages:
|
||||
target_languages = ["en"]
|
||||
|
||||
# Resolve provider-aware defaults
|
||||
if provider_info and context_window is None and max_output_tokens is None:
|
||||
provider_settings = PROVIDER_DEFAULTS.get(provider_info)
|
||||
if provider_settings:
|
||||
context_window = provider_settings["context_window"]
|
||||
max_output_tokens = provider_settings["max_output_tokens"]
|
||||
|
||||
# Fall back to module defaults if still None
|
||||
if context_window is None:
|
||||
context_window = DEFAULT_CONTEXT_WINDOW
|
||||
if max_output_tokens is None:
|
||||
max_output_tokens = DEFAULT_MAX_OUTPUT_TOKENS
|
||||
|
||||
num_languages = len(target_languages)
|
||||
|
||||
# 1. Estimate tokens per row
|
||||
# 1. Estimate tokens per row using CJK-aware heuristics
|
||||
input_per_row = []
|
||||
limit = batch_size if batch_size else len(source_rows)
|
||||
for i, row in enumerate(source_rows):
|
||||
if i >= limit:
|
||||
break
|
||||
text = str(row.get(source_column, "") or "")
|
||||
# Use ~2.2 chars per token for mixed Russian/English text
|
||||
estimated_tokens = max(1, int(len(text) / CHARS_PER_TOKEN_MIXED))
|
||||
# Add context columns
|
||||
estimated_tokens = _estimate_tokens_for_text(text)
|
||||
if context_columns:
|
||||
for col in context_columns:
|
||||
val = str(row.get(col, "") or "")
|
||||
estimated_tokens += max(1, int(len(val) / CHARS_PER_TOKEN_MIXED))
|
||||
estimated_tokens += _estimate_tokens_for_text(val)
|
||||
input_per_row.append(estimated_tokens)
|
||||
|
||||
# 2. Calculate dictionary tokens
|
||||
# 2. Calculate dictionary tokens with warning if capped
|
||||
dict_tokens = 0
|
||||
dict_warning = None
|
||||
if dictionary_entries:
|
||||
dict_tokens = min(
|
||||
len(dictionary_entries) * DICT_TOKENS_PER_ENTRY,
|
||||
DICT_TOKENS_MAX,
|
||||
)
|
||||
raw_dict_tokens = len(dictionary_entries) * DICT_TOKENS_PER_ENTRY
|
||||
dict_tokens = min(raw_dict_tokens, DICT_TOKENS_MAX)
|
||||
if raw_dict_tokens > DICT_TOKENS_MAX:
|
||||
dict_warning = (
|
||||
f"Dictionary entries ({len(dictionary_entries)} entries "
|
||||
f"≈ {raw_dict_tokens} tokens) exceed cap of {DICT_TOKENS_MAX} — "
|
||||
f"truncated to {DICT_TOKENS_MAX} in estimation"
|
||||
)
|
||||
|
||||
prompt_tokens = PROMPT_BASE_TOKENS + dict_tokens
|
||||
available_input_budget = context_window - max_output_tokens
|
||||
@@ -161,19 +296,26 @@ def estimate_token_budget(
|
||||
|
||||
# If batch_size was specified and we reduced it, recalculate
|
||||
if batch_size and safe_size < batch_size:
|
||||
# Ensure at minimum one row even for huge content
|
||||
safe_size = max(safe_size, 1)
|
||||
_, truncated_total = _count_rows_that_fit(
|
||||
input_per_row[:batch_size], available_input_budget,
|
||||
)
|
||||
estimated_input = prompt_tokens + truncated_total
|
||||
|
||||
# 4. Estimate output tokens
|
||||
estimated_output = (
|
||||
safe_size * num_languages * OUTPUT_PER_ROW_PER_LANG + REASONING_OVERHEAD
|
||||
# 4. Estimate output tokens (includes JSON overhead per row)
|
||||
estimated_output = _calculate_output_tokens(safe_size, num_languages)
|
||||
|
||||
# 5. Output-aware batch sizing: reduce batch if output exceeds limit
|
||||
safe_size = _apply_output_aware_batch_sizing(
|
||||
safe_size, num_languages, max_output_tokens,
|
||||
)
|
||||
|
||||
# 5. Calculate recommended max_tokens
|
||||
# Recalculate totals after output-aware reduction
|
||||
if safe_size > 0:
|
||||
new_input_total = sum(input_per_row[:safe_size])
|
||||
estimated_input = prompt_tokens + new_input_total
|
||||
estimated_output = _calculate_output_tokens(safe_size, num_languages)
|
||||
|
||||
# 6. Calculate recommended max_tokens
|
||||
max_output_needed = min(
|
||||
estimated_output + MAX_OUTPUT_HEADROOM,
|
||||
context_window - estimated_input,
|
||||
@@ -181,14 +323,12 @@ def estimate_token_budget(
|
||||
max_output_needed = max(max_output_needed, MIN_MAX_TOKENS)
|
||||
max_output_needed = min(max_output_needed, max_output_tokens)
|
||||
|
||||
# 6. Generate warning if batch was reduced
|
||||
warning = None
|
||||
if batch_size and safe_size < batch_size:
|
||||
total_estimated = estimated_input + max_output_needed
|
||||
warning = (
|
||||
f"Reduced batch size from {batch_size} to {safe_size} "
|
||||
f"(estimated {total_estimated} tokens vs {context_window} window)"
|
||||
)
|
||||
# 7. Generate warning if batch was reduced
|
||||
warning = _build_warning(
|
||||
batch_size, safe_size, len(source_rows),
|
||||
context_window, estimated_input, max_output_needed,
|
||||
dict_warning,
|
||||
)
|
||||
|
||||
return {
|
||||
"batch_size_adjusted": safe_size,
|
||||
|
||||
@@ -40,7 +40,7 @@ from ...models.translate import (
|
||||
)
|
||||
from ...services.llm_prompt_templates import render_prompt
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ._token_budget import DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_OUTPUT_TOKENS, estimate_token_budget
|
||||
from ._token_budget import estimate_token_budget
|
||||
from .dictionary import DictionaryManager
|
||||
from .preview import DEFAULT_EXECUTION_PROMPT_TEMPLATE
|
||||
from .prompt_builder import ContextAwarePromptBuilder
|
||||
@@ -194,6 +194,35 @@ def _check_translation_cache(
|
||||
# #endregion _check_translation_cache
|
||||
|
||||
|
||||
# #region estimate_row_tokens [C:2] [TYPE Function] [SEMANTICS translate, token, estimation]
|
||||
# @BRIEF Estimate token count for a single source row including context fields.
|
||||
# @PRE: source_text is a string.
|
||||
# @POST: Returns estimated token count >= 1.
|
||||
# @RELATION DEPENDS_ON -> [_estimate_tokens_for_text]
|
||||
def estimate_row_tokens(
|
||||
source_text: str,
|
||||
source_data: dict | None,
|
||||
job,
|
||||
) -> int:
|
||||
"""Estimate token count for a single source row including context fields.
|
||||
|
||||
Uses CJK-aware heuristics via _token_budget._estimate_tokens_for_text.
|
||||
Context fields from job.context_columns are included in the estimate.
|
||||
"""
|
||||
from ._token_budget import _estimate_tokens_for_text
|
||||
|
||||
text_tokens = _estimate_tokens_for_text(source_text or "")
|
||||
|
||||
context_keys = job.context_columns or []
|
||||
ctx_text = ""
|
||||
if source_data and context_keys:
|
||||
ctx_text = " ".join(str(source_data.get(k, "")) for k in context_keys)
|
||||
ctx_tokens = _estimate_tokens_for_text(ctx_text)
|
||||
|
||||
return text_tokens + ctx_tokens
|
||||
# #endregion estimate_row_tokens
|
||||
|
||||
|
||||
# #region TranslationExecutor [C:4] [TYPE Class]
|
||||
# @BRIEF Process translation batches: fetch source rows, filter dict, call LLM, persist results.
|
||||
# @PRE: DB session and config manager available.
|
||||
@@ -215,6 +244,179 @@ class TranslationExecutor:
|
||||
self._current_run_id: str | None = None
|
||||
self._preview_edits_cache: dict[str, dict[str, str]] | None = None # key_hash -> {lang_code: edited_value}
|
||||
|
||||
# region _resolve_provider_model [TYPE Function]
|
||||
# @BRIEF Resolve the LLM provider model name for token budget estimation.
|
||||
# @POST: Returns model name string or None if resolution fails.
|
||||
# @SIDE_EFFECT: DB query to LLM provider table.
|
||||
def _resolve_provider_model(self, job) -> str | None:
|
||||
"""Resolve the provider model name for token budget estimation."""
|
||||
if not job.provider_id:
|
||||
return None
|
||||
try:
|
||||
p_svc = LLMProviderService(self.db)
|
||||
p = p_svc.get_provider(job.provider_id)
|
||||
if p:
|
||||
return p.default_model or "gpt-4o-mini"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
# endregion _resolve_provider_model
|
||||
|
||||
# region _auto_size_batches [TYPE Function]
|
||||
# @PURPOSE: Split source rows into variable-sized batches based on actual content length.
|
||||
# Uses estimate_token_budget to determine safe per-batch budgets and
|
||||
# _count_rows_that_fit logic to greedily fill each batch.
|
||||
# @PRE: source_rows is non-empty. job has valid config.
|
||||
# @POST: Returns list of batches, each batch is a list of row dicts.
|
||||
# Each batch fits within the estimated token budget for its rows.
|
||||
# @SIDE_EFFECT: DB query to resolve provider model. Logs batch statistics.
|
||||
# @RATIONALE: Fixed batch_size of 50 wastes LLM context for short rows and
|
||||
# overflows for long rows. Variable sizing adapts to row content length,
|
||||
# maximizing throughput while preventing truncation.
|
||||
# @REJECTED: Fixed batch_size of 50 — causes truncation on long-content rows.
|
||||
# Single monolithic batch — would lose all progress on any failure.
|
||||
def _auto_size_batches(
|
||||
self,
|
||||
job,
|
||||
source_rows: list[dict],
|
||||
target_languages: list[str],
|
||||
provider_info: str | None = None,
|
||||
) -> list[list[dict]]:
|
||||
"""Split source rows into auto-sized batches based on content length.
|
||||
|
||||
Each batch is sized so that its total estimated tokens fit within the
|
||||
available context window (input budget), accounting for prompt overhead,
|
||||
dictionary entries, and output tokens.
|
||||
|
||||
Returns:
|
||||
List of batches, each batch is a list of row dicts.
|
||||
"""
|
||||
with belief_scope("TranslationExecutor._auto_size_batches"):
|
||||
if not source_rows:
|
||||
return []
|
||||
|
||||
# Resolve provider info if not provided
|
||||
if provider_info is None:
|
||||
provider_info = self._resolve_provider_model(job)
|
||||
|
||||
# 1. Estimate per-row token counts
|
||||
row_tokens: list[int] = []
|
||||
for row in source_rows:
|
||||
source_text = row.get("source_text", "")
|
||||
source_data = row.get("source_data")
|
||||
tokens = estimate_row_tokens(source_text, source_data, job)
|
||||
row_tokens.append(tokens)
|
||||
|
||||
# 2. Get budget recommendation from estimate_token_budget
|
||||
# Pass all rows to get a global safe batch size estimate.
|
||||
budget = estimate_token_budget(
|
||||
source_rows=source_rows,
|
||||
target_languages=target_languages,
|
||||
source_column=job.translation_column or "source_text",
|
||||
context_columns=job.context_columns,
|
||||
batch_size=len(source_rows),
|
||||
provider_info=provider_info,
|
||||
)
|
||||
|
||||
recommended = budget.get("batch_size_adjusted", 0)
|
||||
|
||||
# Fallback: if budget calculation fails or returns zero, use fixed size
|
||||
if recommended <= 0:
|
||||
fallback_size = job.batch_size or 50
|
||||
logger.explore("Token budget returned zero — falling back to fixed batch size", {
|
||||
"fallback_size": fallback_size,
|
||||
"total_rows": len(source_rows),
|
||||
})
|
||||
return [
|
||||
source_rows[i:i + fallback_size]
|
||||
for i in range(0, len(source_rows), fallback_size)
|
||||
]
|
||||
|
||||
# 3. Compute per-batch row-content budget
|
||||
# estimated_input_tokens includes PROMPT_BASE_TOKENS + dict_tokens +
|
||||
# sum of row tokens for the first `recommended` rows.
|
||||
# We subtract the prompt overhead to get the per-batch row budget.
|
||||
from ._token_budget import PROMPT_BASE_TOKENS
|
||||
|
||||
estimated_input = budget.get("estimated_input_tokens", 50000)
|
||||
per_batch_budget = estimated_input - PROMPT_BASE_TOKENS
|
||||
|
||||
# Safety: if budget computation collapsed, fall back
|
||||
if per_batch_budget <= 0:
|
||||
fallback_size = job.batch_size or 50
|
||||
logger.explore("Per-batch budget collapsed — falling back to fixed batch size", {
|
||||
"estimated_input": estimated_input,
|
||||
"prompt_base": PROMPT_BASE_TOKENS,
|
||||
"fallback_size": fallback_size,
|
||||
})
|
||||
return [
|
||||
source_rows[i:i + fallback_size]
|
||||
for i in range(0, len(source_rows), fallback_size)
|
||||
]
|
||||
|
||||
# 4. Greedy batch splitting: accumulate rows until the next row would
|
||||
# exceed the per-batch token budget. Use output-aware hard cap too:
|
||||
# a single batch never exceeds 2x the recommended batch size as
|
||||
# a safety net against wildly oversized estimates.
|
||||
max_rows_hard_cap = max(recommended * 2, 20)
|
||||
batches: list[list[dict]] = []
|
||||
current_batch: list[dict] = []
|
||||
current_tokens = 0
|
||||
|
||||
for i, row in enumerate(source_rows):
|
||||
rt = row_tokens[i]
|
||||
|
||||
# ── Edge case: single row exceeds entire per-batch budget ──
|
||||
if rt > per_batch_budget:
|
||||
# Flush current batch first
|
||||
if current_batch:
|
||||
batches.append(current_batch)
|
||||
current_batch = []
|
||||
current_tokens = 0
|
||||
logger.reason("Single row exceeds per-batch token budget — placing in own batch", {
|
||||
"row_index": i,
|
||||
"row_tokens": rt,
|
||||
"per_batch_budget": per_batch_budget,
|
||||
})
|
||||
batches.append([row])
|
||||
continue
|
||||
|
||||
# ── Start a new batch if: ──
|
||||
# a) Batch is non-empty AND adding this row would exceed budget
|
||||
# b) Hard row-count cap reached
|
||||
should_split = bool(
|
||||
current_batch
|
||||
and (current_tokens + rt > per_batch_budget
|
||||
or len(current_batch) >= max_rows_hard_cap)
|
||||
)
|
||||
|
||||
if should_split:
|
||||
batches.append(current_batch)
|
||||
current_batch = [row]
|
||||
current_tokens = rt
|
||||
else:
|
||||
current_batch.append(row)
|
||||
current_tokens += rt
|
||||
|
||||
if current_batch:
|
||||
batches.append(current_batch)
|
||||
|
||||
# 5. Log adaptive batch statistics
|
||||
if batches:
|
||||
avg_size = sum(len(b) for b in batches) / max(1, len(batches))
|
||||
max_size = max(len(b) for b in batches)
|
||||
logger.reason("Auto-sized batches", {
|
||||
"total_rows": len(source_rows),
|
||||
"num_batches": len(batches),
|
||||
"avg_batch_size": round(avg_size, 1),
|
||||
"max_batch_size": max_size,
|
||||
"recommended_batch_size": recommended,
|
||||
"per_batch_budget": per_batch_budget,
|
||||
})
|
||||
|
||||
return batches
|
||||
# endregion _auto_size_batches
|
||||
|
||||
# region execute_run [TYPE Function]
|
||||
# @PURPOSE: Run full translation execution for a TranslationRun.
|
||||
# @PRE: run is in PENDING or RUNNING status with valid job config.
|
||||
@@ -234,7 +436,7 @@ class TranslationExecutor:
|
||||
logger.reason("Starting translation execution", {
|
||||
"run_id": run.id,
|
||||
"job_id": job.id,
|
||||
"batch_size": job.batch_size,
|
||||
"configured_batch_size": job.batch_size,
|
||||
})
|
||||
|
||||
# Load preview edits for carry-forward
|
||||
@@ -276,17 +478,26 @@ class TranslationExecutor:
|
||||
total_rows = len(source_rows)
|
||||
run.total_records = total_rows
|
||||
|
||||
# Split into batches
|
||||
batch_size = job.batch_size or 50
|
||||
batches = [
|
||||
source_rows[i:i + batch_size]
|
||||
for i in range(0, total_rows, batch_size)
|
||||
]
|
||||
# Resolve target languages for batch sizing
|
||||
target_languages = job.target_languages or [job.target_dialect or "en"]
|
||||
if not isinstance(target_languages, list):
|
||||
target_languages = [str(target_languages)]
|
||||
|
||||
# Split into auto-sized batches based on content length
|
||||
provider_model = self._resolve_provider_model(job)
|
||||
batches = self._auto_size_batches(
|
||||
job=job,
|
||||
source_rows=source_rows,
|
||||
target_languages=target_languages,
|
||||
provider_info=provider_model,
|
||||
)
|
||||
|
||||
logger.reason(f"Processing {len(batches)} batches", {
|
||||
"run_id": run.id,
|
||||
"total_rows": total_rows,
|
||||
"batch_size": batch_size,
|
||||
"num_batches": len(batches),
|
||||
"avg_batch_size": round(sum(len(b) for b in batches) / max(1, len(batches)), 1),
|
||||
"max_batch_size": max(len(b) for b in batches) if batches else 0,
|
||||
})
|
||||
|
||||
successful_records = 0
|
||||
@@ -841,6 +1052,17 @@ class TranslationExecutor:
|
||||
|
||||
# Process rows needing LLM translation
|
||||
if rows_for_llm:
|
||||
# Resolve provider model for provider-aware token budget
|
||||
provider_model = None
|
||||
if job.provider_id:
|
||||
try:
|
||||
p_svc = LLMProviderService(self.db)
|
||||
p = p_svc.get_provider(job.provider_id)
|
||||
if p:
|
||||
provider_model = p.default_model or "gpt-4o-mini"
|
||||
except Exception:
|
||||
provider_model = None
|
||||
|
||||
# Check token budget for this batch to determine safe max_tokens
|
||||
token_budget = estimate_token_budget(
|
||||
source_rows=rows_for_llm,
|
||||
@@ -849,8 +1071,7 @@ class TranslationExecutor:
|
||||
context_columns=None, # Context is embedded in dict, not separate column
|
||||
dictionary_entries=dict_matches,
|
||||
batch_size=len(rows_for_llm),
|
||||
context_window=DEFAULT_CONTEXT_WINDOW,
|
||||
max_output_tokens=DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
provider_info=provider_model,
|
||||
)
|
||||
if token_budget["warning"]:
|
||||
logger.explore("Token budget warning for batch", {
|
||||
@@ -1144,6 +1365,7 @@ class TranslationExecutor:
|
||||
dict_matches: list[dict[str, Any]],
|
||||
batch_id: str,
|
||||
max_tokens: int = 8192,
|
||||
_recursion_depth: int = 0,
|
||||
) -> dict[str, int]:
|
||||
with belief_scope("TranslationExecutor._call_llm_for_batch"):
|
||||
# Build dictionary section using ContextAwarePromptBuilder
|
||||
@@ -1201,9 +1423,10 @@ class TranslationExecutor:
|
||||
last_error = None
|
||||
retries = 0
|
||||
|
||||
finish_reason: str | None = None
|
||||
for attempt in range(1, MAX_RETRIES_PER_BATCH + 1):
|
||||
try:
|
||||
llm_response = self._call_llm(job, prompt, max_tokens=max_tokens)
|
||||
llm_response, finish_reason = self._call_llm(job, prompt, max_tokens=max_tokens)
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = str(e)
|
||||
@@ -1240,6 +1463,57 @@ class TranslationExecutor:
|
||||
self.db.add(record)
|
||||
return {"successful": 0, "failed": len(batch_rows), "skipped": 0, "retries": retries}
|
||||
|
||||
# ── Truncation detection: finish_reason="length" → split batch ──
|
||||
if finish_reason == "length" and len(batch_rows) >= 2 and run_id:
|
||||
if _recursion_depth < MAX_RETRIES_PER_BATCH:
|
||||
mid = len(batch_rows) // 2
|
||||
logger.explore("LLM output truncated — splitting batch", {
|
||||
"batch_id": batch_id,
|
||||
"batch_size": len(batch_rows),
|
||||
"split_at": mid,
|
||||
"recursion_depth": _recursion_depth,
|
||||
"finish_reason": finish_reason,
|
||||
})
|
||||
left_result = self._call_llm_for_batch(
|
||||
job=job,
|
||||
run_id=run_id,
|
||||
batch_rows=batch_rows[:mid],
|
||||
dict_matches=dict_matches,
|
||||
batch_id=batch_id + "_L",
|
||||
max_tokens=max_tokens,
|
||||
_recursion_depth=_recursion_depth + 1,
|
||||
)
|
||||
right_result = self._call_llm_for_batch(
|
||||
job=job,
|
||||
run_id=run_id,
|
||||
batch_rows=batch_rows[mid:],
|
||||
dict_matches=dict_matches,
|
||||
batch_id=batch_id + "_R",
|
||||
max_tokens=max_tokens,
|
||||
_recursion_depth=_recursion_depth + 1,
|
||||
)
|
||||
merged = {
|
||||
"successful": left_result["successful"] + right_result["successful"],
|
||||
"failed": left_result["failed"] + right_result["failed"],
|
||||
"skipped": left_result["skipped"] + right_result["skipped"],
|
||||
"retries": retries + left_result.get("retries", 0) + right_result.get("retries", 0),
|
||||
}
|
||||
logger.reason("Truncation resolved by batch splitting", {
|
||||
"batch_id": batch_id,
|
||||
"left_successful": left_result["successful"],
|
||||
"right_successful": right_result["successful"],
|
||||
"left_size": len(batch_rows[:mid]),
|
||||
"right_size": len(batch_rows[mid:]),
|
||||
})
|
||||
return merged
|
||||
else:
|
||||
logger.explore("Truncation recursion depth exceeded — accepting truncated output", {
|
||||
"batch_id": batch_id,
|
||||
"recursion_depth": _recursion_depth,
|
||||
"max_depth": MAX_RETRIES_PER_BATCH,
|
||||
})
|
||||
# Fall through to parse truncated response
|
||||
|
||||
# Parse LLM response (multi-language aware)
|
||||
try:
|
||||
translations = self._parse_llm_response(llm_response, len(batch_rows), target_languages=target_languages)
|
||||
@@ -1412,7 +1686,7 @@ class TranslationExecutor:
|
||||
# @PRE: job has valid provider_id.
|
||||
# @POST: Returns raw LLM response string.
|
||||
# @SIDE_EFFECT: HTTP call to LLM provider.
|
||||
def _call_llm(self, job: TranslationJob, prompt: str, max_tokens: int = 8192) -> str:
|
||||
def _call_llm(self, job: TranslationJob, prompt: str, max_tokens: int = 8192) -> tuple[str, str | None]:
|
||||
with belief_scope("TranslationExecutor._call_llm"):
|
||||
if not job.provider_id:
|
||||
raise ValueError("Job has no LLM provider configured")
|
||||
@@ -1432,7 +1706,7 @@ class TranslationExecutor:
|
||||
disable_reasoning = getattr(job, 'disable_reasoning', False)
|
||||
|
||||
if provider_type in ("openai", "openai_compatible", "openrouter", "kilo", "litellm"):
|
||||
response_text = self._call_openai_compatible(
|
||||
response_text, finish_reason = self._call_openai_compatible(
|
||||
base_url=provider.base_url,
|
||||
api_key=api_key,
|
||||
model=model,
|
||||
@@ -1441,7 +1715,7 @@ class TranslationExecutor:
|
||||
max_tokens=max_tokens,
|
||||
disable_reasoning=disable_reasoning,
|
||||
)
|
||||
return response_text
|
||||
return response_text, finish_reason
|
||||
else:
|
||||
raise ValueError(f"Unsupported provider type '{provider_type}'")
|
||||
# endregion _call_llm
|
||||
@@ -1460,8 +1734,10 @@ class TranslationExecutor:
|
||||
provider_type: str = "openai",
|
||||
max_tokens: int = 8192,
|
||||
disable_reasoning: bool = False,
|
||||
) -> str:
|
||||
) -> tuple[str, str | None]:
|
||||
with belief_scope("TranslationExecutor._call_openai_compatible"):
|
||||
if not base_url:
|
||||
raise ValueError("LLM provider has no base_url configured")
|
||||
import requests as http_requests
|
||||
|
||||
url = f"{base_url.rstrip('/')}/chat/completions"
|
||||
@@ -1489,8 +1765,9 @@ class TranslationExecutor:
|
||||
if disable_reasoning:
|
||||
if provider_type not in ("kilo", "openrouter", "litellm"):
|
||||
payload["reasoning_effort"] = "none"
|
||||
payload["extra_body"] = {"reasoning_effort": "none"}
|
||||
payload.pop("response_format", None)
|
||||
# Use caller-provided max_tokens instead of hardcoded 8192
|
||||
payload["max_tokens"] = max_tokens
|
||||
payload["messages"][0] = {"role": "system", "content": "You are a database content translation assistant. Translate the provided text accurately, preserving data semantics. Respond directly with ONLY the JSON result. Do NOT include any reasoning, thinking, chain-of-thought, analysis, or explanation. Output ONLY valid JSON."}
|
||||
|
||||
logger.reason(
|
||||
@@ -1500,9 +1777,39 @@ class TranslationExecutor:
|
||||
f"prompt_len={len(prompt)}"
|
||||
)
|
||||
|
||||
# ── Try request; retry once without response_format if upstream rejects it ──
|
||||
response = http_requests.post(url, headers=headers, json=payload, timeout=180)
|
||||
if not response.ok and response.status_code == 400 and "structured_outputs is not supported" in (response.text or ""):
|
||||
# ── Handle rate limiting with Retry-After header ──
|
||||
import time as _time
|
||||
_max_retry_429 = 3
|
||||
_retry_count_429 = 0
|
||||
while _retry_count_429 < _max_retry_429:
|
||||
response = http_requests.post(url, headers=headers, json=payload, timeout=180)
|
||||
if response.status_code == 429:
|
||||
_retry_count_429 += 1
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
if retry_after:
|
||||
try:
|
||||
wait = int(retry_after)
|
||||
except (ValueError, TypeError):
|
||||
wait = 2 ** _retry_count_429
|
||||
else:
|
||||
wait = 2 ** _retry_count_429
|
||||
logger.explore(
|
||||
f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} "
|
||||
f"after {wait}s",
|
||||
extra={"src": "executor", "retry_after": retry_after, "wait": wait},
|
||||
)
|
||||
_time.sleep(wait)
|
||||
if _retry_count_429 >= _max_retry_429:
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
_response_format_error_patterns = ("response_format", "structured_outputs", "structured", "json_object")
|
||||
if (
|
||||
not response.ok
|
||||
and response.status_code == 400
|
||||
and any(p in (response.text or "").lower() for p in _response_format_error_patterns)
|
||||
):
|
||||
logger.explore("Structured outputs not supported by upstream, retrying without response_format", extra={"src": "executor"})
|
||||
payload.pop("response_format", None)
|
||||
response = http_requests.post(url, headers=headers, json=payload, timeout=180)
|
||||
@@ -1520,10 +1827,22 @@ class TranslationExecutor:
|
||||
logger.explore("LLM returned no choices", extra={"src": "executor", "response_keys": list(data.keys()), "response_preview": str(data)[:2000]})
|
||||
raise ValueError("LLM returned no choices")
|
||||
|
||||
finish_reason = choices[0].get("finish_reason") or "none"
|
||||
msg = choices[0].get("message") or {}
|
||||
try:
|
||||
finish_reason = choices[0].get("finish_reason") or "none"
|
||||
msg = choices[0].get("message") or {}
|
||||
except (TypeError, AttributeError) as e:
|
||||
logger.explore("TypeError processing LLM response choices", extra={
|
||||
"src": "executor_diag",
|
||||
"error": str(e),
|
||||
"choices_0_type": type(choices[0]).__name__ if choices else "N/A",
|
||||
"choices_0_repr": repr(choices[0])[:2000] if choices else "N/A",
|
||||
"data_type": type(data).__name__,
|
||||
"data_preview": str(data)[:2000],
|
||||
})
|
||||
raise ValueError(f"LLM response processing failed: {e}")
|
||||
|
||||
# Handle model refusal (content is empty/null, refusal field has reason)
|
||||
refusal = msg.get("refusal")
|
||||
refusal = msg.get("refusal") if isinstance(msg, dict) else None
|
||||
if refusal:
|
||||
logger.explore("LLM refused to respond", extra={
|
||||
"src": "executor",
|
||||
@@ -1531,13 +1850,15 @@ class TranslationExecutor:
|
||||
"finish_reason": finish_reason,
|
||||
})
|
||||
raise ValueError(f"LLM refused to respond: {refusal}")
|
||||
content = msg.get("content") or ""
|
||||
logger.reason(f"LLM response finish_reason={finish_reason} content_len={len(content)} msg_keys={list(msg.keys())}")
|
||||
content = msg.get("content") if isinstance(msg, dict) else ""
|
||||
if not content and isinstance(msg, dict):
|
||||
content = msg.get("content") or ""
|
||||
logger.reason(f"LLM response finish_reason={finish_reason} content_len={len(content)} msg_keys={list(msg.keys()) if isinstance(msg, dict) else []}")
|
||||
if not content:
|
||||
logger.explore("LLM returned empty content", extra={"src": "executor", "finish_reason": finish_reason, "msg_keys": list(msg.keys()), "response_preview": str(data)[:2000]})
|
||||
logger.explore("LLM returned empty content", extra={"src": "executor", "finish_reason": finish_reason, "msg_keys": list(msg.keys()) if isinstance(msg, dict) else [], "response_preview": str(data)[:2000]})
|
||||
raise ValueError("LLM returned empty content")
|
||||
|
||||
return content
|
||||
return content, finish_reason
|
||||
# endregion _call_openai_compatible
|
||||
|
||||
# region _parse_llm_response [TYPE Function]
|
||||
@@ -1568,7 +1889,7 @@ class TranslationExecutor:
|
||||
|
||||
# If finish_reason=length, try to recover complete rows from truncated JSON
|
||||
logger.explore("LLM truncated, trying partial row recovery", extra={"src": "executor", "finish_reason": finish_reason, "response_length": len(response_text)})
|
||||
rows_match = re.findall(r'\{\s*"row_id"\s*:\s*"\d+".*?\}\s*', response_text, re.DOTALL)
|
||||
rows_match = re.findall(r'\{\s*"row_id"\s*:\s*(?:\d+|"\d+").*?\}\s*', response_text, re.DOTALL)
|
||||
if rows_match:
|
||||
partial_rows = []
|
||||
for row_text in rows_match:
|
||||
|
||||
@@ -37,7 +37,7 @@ from ...models.translate import (
|
||||
)
|
||||
from ...services.llm_prompt_templates import render_prompt
|
||||
from ...services.llm_provider import LLMProviderService
|
||||
from ._token_budget import DEFAULT_CONTEXT_WINDOW, DEFAULT_MAX_OUTPUT_TOKENS, estimate_token_budget
|
||||
from ._token_budget import DEFAULT_CONTEXT_WINDOW, estimate_token_budget
|
||||
from .dictionary import DictionaryManager
|
||||
|
||||
# #region DEFAULT_EXECUTION_PROMPT_TEMPLATE [TYPE Constant]
|
||||
@@ -95,8 +95,8 @@ DEFAULT_PREVIEW_PROMPT_TEMPLATE: str = (
|
||||
class TokenEstimator:
|
||||
"""Estimate token counts and costs for LLM operations."""
|
||||
|
||||
CHARS_PER_TOKEN_ESTIMATE: float = 4.0
|
||||
OUTPUT_TOKENS_PER_ROW_ESTIMATE: int = 50
|
||||
CHARS_PER_TOKEN_ESTIMATE: float = 2.2
|
||||
OUTPUT_TOKENS_PER_ROW_ESTIMATE: int = 120
|
||||
MULTI_LANG_FACTOR: float = 1.2 # Overhead for multi-language in one call
|
||||
TOKEN_COST_PER_1K: float = 0.002 # Default cost per 1K tokens
|
||||
COST_WARNING_THRESHOLD: int = 30 # Show warning above this sample size
|
||||
@@ -229,15 +229,25 @@ class TranslationPreview:
|
||||
f"val='{first_row.get(job.translation_column, '')}'"
|
||||
)
|
||||
|
||||
# 3b. Check token budget and auto-reduce sample size if needed
|
||||
# 3b. Resolve provider model for provider-aware token budget
|
||||
provider_model = None
|
||||
if job.provider_id:
|
||||
try:
|
||||
provider_svc = LLMProviderService(self.db)
|
||||
provider = provider_svc.get_provider(job.provider_id)
|
||||
if provider:
|
||||
provider_model = provider.default_model or "gpt-4o-mini"
|
||||
except Exception:
|
||||
provider_model = None
|
||||
|
||||
# 3c. Check token budget and auto-reduce sample size if needed
|
||||
token_budget = estimate_token_budget(
|
||||
source_rows=source_rows,
|
||||
target_languages=target_languages,
|
||||
source_column=job.translation_column,
|
||||
context_columns=job.context_columns,
|
||||
batch_size=actual_row_count,
|
||||
context_window=DEFAULT_CONTEXT_WINDOW,
|
||||
max_output_tokens=DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
provider_info=provider_model,
|
||||
)
|
||||
if token_budget["warning"]:
|
||||
logger.explore("Token budget warning", {
|
||||
@@ -265,8 +275,7 @@ class TranslationPreview:
|
||||
source_column=job.translation_column,
|
||||
context_columns=job.context_columns,
|
||||
batch_size=actual_row_count,
|
||||
context_window=DEFAULT_CONTEXT_WINDOW,
|
||||
max_output_tokens=DEFAULT_MAX_OUTPUT_TOKENS,
|
||||
provider_info=provider_model,
|
||||
)
|
||||
|
||||
# 4. Build prompt context from rows
|
||||
@@ -1020,6 +1029,8 @@ class TranslationPreview:
|
||||
disable_reasoning: bool = False,
|
||||
) -> str:
|
||||
with belief_scope("TranslationPreview._call_openai_compatible"):
|
||||
if not base_url:
|
||||
raise ValueError("LLM provider has no base_url configured")
|
||||
import requests as http_requests
|
||||
|
||||
url = f"{base_url.rstrip('/')}/chat/completions"
|
||||
@@ -1047,10 +1058,10 @@ class TranslationPreview:
|
||||
# Kilo/OpenRouter/LiteLLM reject reasoning_effort — only use for native OpenAI-compatible
|
||||
if provider_type not in ("kilo", "openrouter", "litellm"):
|
||||
payload["reasoning_effort"] = "none"
|
||||
payload["extra_body"] = {"reasoning_effort": "none"}
|
||||
payload.pop("response_format", None) # JSON mode triggers reasoning on some models
|
||||
# Max tokens must be large enough for output even with some reasoning
|
||||
payload["max_tokens"] = 8192
|
||||
# Use caller-provided max_tokens instead of hardcoded 8192
|
||||
# This ensures multi-language batches with large output get enough token budget.
|
||||
payload["max_tokens"] = max_tokens
|
||||
# Universal instruction — all models understand "respond directly without reasoning"
|
||||
system_content = "You are a database content translation assistant. Translate the provided text accurately, preserving data semantics. Respond directly with ONLY the JSON result. Do NOT include any reasoning, thinking, chain-of-thought, analysis, or explanation. Output ONLY valid JSON."
|
||||
payload["messages"][0] = {"role": "system", "content": system_content}
|
||||
@@ -1063,9 +1074,39 @@ class TranslationPreview:
|
||||
f"prompt_len={len(prompt)}"
|
||||
)
|
||||
|
||||
# ── Try request; retry once without response_format if upstream rejects it ──
|
||||
response = http_requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
if not response.ok and response.status_code == 400 and "structured_outputs is not supported" in (response.text or ""):
|
||||
# ── Handle rate limiting with Retry-After header ──
|
||||
import time as _time
|
||||
_max_retry_429 = 3
|
||||
_retry_count_429 = 0
|
||||
while _retry_count_429 < _max_retry_429:
|
||||
response = http_requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
if response.status_code == 429:
|
||||
_retry_count_429 += 1
|
||||
retry_after = response.headers.get("Retry-After")
|
||||
if retry_after:
|
||||
try:
|
||||
wait = int(retry_after)
|
||||
except (ValueError, TypeError):
|
||||
wait = 2 ** _retry_count_429
|
||||
else:
|
||||
wait = 2 ** _retry_count_429
|
||||
logger.explore(
|
||||
f"Rate limited (429), retry {_retry_count_429}/{_max_retry_429} "
|
||||
f"after {wait}s",
|
||||
extra={"src": "preview", "retry_after": retry_after, "wait": wait},
|
||||
)
|
||||
_time.sleep(wait)
|
||||
if _retry_count_429 >= _max_retry_429:
|
||||
break
|
||||
else:
|
||||
break
|
||||
|
||||
_response_format_error_patterns = ("response_format", "structured_outputs", "structured", "json_object")
|
||||
if (
|
||||
not response.ok
|
||||
and response.status_code == 400
|
||||
and any(p in (response.text or "").lower() for p in _response_format_error_patterns)
|
||||
):
|
||||
logger.explore("Structured outputs not supported by upstream, retrying without response_format", extra={"src": "preview"})
|
||||
payload.pop("response_format", None)
|
||||
response = http_requests.post(url, headers=headers, json=payload, timeout=600)
|
||||
@@ -1146,7 +1187,7 @@ class TranslationPreview:
|
||||
|
||||
# If finish_reason=length, try to recover complete rows from truncated JSON
|
||||
logger.explore("LLM truncated, trying partial row recovery", extra={"src": "preview", "finish_reason": finish_reason, "response_length": len(response_text)})
|
||||
rows_match = re.findall(r'\{\s*"row_id"\s*:\s*"\d+".*?\}\s*', response_text, re.DOTALL)
|
||||
rows_match = re.findall(r'\{\s*"row_id"\s*:\s*(?:\d+|"\d+").*?\}\s*', response_text, re.DOTALL)
|
||||
if rows_match:
|
||||
partial_rows = []
|
||||
for row_text in rows_match:
|
||||
|
||||
Reference in New Issue
Block a user