feat: Optimize caching for Anthropic and OpenAI prompts, restructure prompt variables for efficiency
Browse files- CHANGES.md +64 -0
- main.py +32 -16
- prompts.py +8 -8
CHANGES.md
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Changelog - Додано редагування промптів з ізоляцією сесій
|
| 2 |
|
| 3 |
## Дата: 2025-12-28
|
|
|
|
| 1 |
+
# Changelog - Anthropic Prompt Caching
|
| 2 |
+
|
| 3 |
+
## Дата: 2026-02-25
|
| 4 |
+
|
| 5 |
+
## Зміни
|
| 6 |
+
|
| 7 |
+
### ⚡ Оптимізація: Anthropic Prompt Caching
|
| 8 |
+
|
| 9 |
+
#### Проблема
|
| 10 |
+
Кожен запит до Anthropic API повністю перераховував токени системного промпту та інструктажу, хоча ця частина є статичною між запитами.
|
| 11 |
+
|
| 12 |
+
#### Рішення
|
| 13 |
+
|
| 14 |
+
**1. Увімкнення автоматичного кешування (`main.py`)**
|
| 15 |
+
|
| 16 |
+
Додано параметр `cache_control={"type": "ephemeral"}` на верхній рівень обох Anthropic-викликів:
|
| 17 |
+
- `LLMAnalyzer._analyze_with_anthropic()` — для аналізу прецедентів
|
| 18 |
+
- `generate_legal_position()` — для генерації правових позицій
|
| 19 |
+
|
| 20 |
+
API автоматично визначає найдовший відповідний префікс, переміщує точку кешу до останнього кешованого блоку та повторно використовує її на кожному наступному кроці.
|
| 21 |
+
|
| 22 |
+
**2. Реструктуризація промпту (`prompts.py`)**
|
| 23 |
+
|
| 24 |
+
Змінні частини промпту (`<court_decision>`, `<comment>`) переміщено в кінець `LEGAL_POSITION_PROMPT`:
|
| 25 |
+
|
| 26 |
+
```
|
| 27 |
+
До: Після:
|
| 28 |
+
<task> статичний <task> статичний
|
| 29 |
+
<court_decision> ЗМІННИЙ <strategy> статичний
|
| 30 |
+
<comment> ЗМІННИЙ <rules_do> статичний ← кешується
|
| 31 |
+
<strategy> статичний <rules_dont> статичний
|
| 32 |
+
<rules_do> статичний <output_format> статичний
|
| 33 |
+
<rules_dont> статичний ─── точка кешу ───────────
|
| 34 |
+
<output_format> статичний <court_decision> ЗМІННИЙ
|
| 35 |
+
<comment> ЗМІННИЙ
|
| 36 |
+
```
|
| 37 |
+
|
| 38 |
+
Тепер весь статичний інструктаж (~1500 токенів) кешується між запитами. Повторне обчислення лише змінних блоків наприкінці.
|
| 39 |
+
|
| 40 |
+
### 📝 Змінені файли
|
| 41 |
+
|
| 42 |
+
#### `main.py`
|
| 43 |
+
- `LLMAnalyzer._analyze_with_anthropic()` — додано `cache_control={"type": "ephemeral"}`
|
| 44 |
+
- `generate_legal_position()` (Anthropic branch) — додано `cache_control={"type": "ephemeral"}` в `message_params`
|
| 45 |
+
|
| 46 |
+
#### `prompts.py`
|
| 47 |
+
- `LEGAL_POSITION_PROMPT` — переміщено `<court_decision>` та `<comment>` в кінець промпту після `</output_format>`
|
| 48 |
+
|
| 49 |
+
**3. OpenAI Prompt Caching (`main.py`)**
|
| 50 |
+
|
| 51 |
+
OpenAI кешує автоматично для запитів ≥ 1024 токенів — жодних параметрів API вмикати не потрібно. Реструктуризація промпту (п. 2) вже забезпечує максимальний prefix для cache hit.
|
| 52 |
+
|
| 53 |
+
Додано логування cache hits через `usage.prompt_tokens_details.cached_tokens`:
|
| 54 |
+
- `LLMAnalyzer._analyze_with_openai()` — `[CACHE] OpenAI analysis: X/Y input tokens from cache`
|
| 55 |
+
- `generate_legal_position()` (OpenAI branch) — `[CACHE] OpenAI generation: X/Y input tokens from cache`
|
| 56 |
+
|
| 57 |
+
### 💰 Очікуваний ефект
|
| 58 |
+
| Провайдер | Механізм | Зниження вартості | Зниження latency |
|
| 59 |
+
|-----------|----------|-------------------|-----------------|
|
| 60 |
+
| Anthropic | `cache_control` (ephemeral) + змінні блоки в кінці | до 90% | до 85% |
|
| 61 |
+
| OpenAI | автоматичне (≥1024 токенів) + змінні блоки в кінці | до 50% | до 80% |
|
| 62 |
+
|
| 63 |
+
---
|
| 64 |
+
|
| 65 |
# Changelog - Додано редагування промптів з ізоляцією сесій
|
| 66 |
|
| 67 |
## Дата: 2025-12-28
|
main.py
CHANGED
|
@@ -166,30 +166,32 @@ def download_s3_folder(bucket_name: str, prefix: str, local_dir: Path) -> None:
|
|
| 166 |
|
| 167 |
def initialize_components() -> bool:
|
| 168 |
"""Initialize all necessary components for the application."""
|
|
|
|
| 169 |
try:
|
| 170 |
# Create local directory if it doesn't exist
|
| 171 |
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 172 |
|
| 173 |
-
#
|
| 174 |
missing_files = [f for f in REQUIRED_FILES if not (LOCAL_DIR / f).exists()]
|
| 175 |
-
|
| 176 |
if missing_files:
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 183 |
else:
|
| 184 |
print(f"All required files found locally in {LOCAL_DIR}")
|
| 185 |
|
| 186 |
-
|
| 187 |
-
raise FileNotFoundError(f"Directory not found: {LOCAL_DIR}")
|
| 188 |
-
|
| 189 |
-
# Check for required files again
|
| 190 |
missing_files = [f for f in REQUIRED_FILES if not (LOCAL_DIR / f).exists()]
|
| 191 |
if missing_files:
|
| 192 |
-
raise FileNotFoundError(f"Missing required files: {', '.join(missing_files)}")
|
| 193 |
|
| 194 |
# Initialize search components if any embedding model is available
|
| 195 |
if embed_model:
|
|
@@ -394,7 +396,13 @@ class LLMAnalyzer:
|
|
| 394 |
raise last_error
|
| 395 |
|
| 396 |
response_text = response.choices[0].message.content
|
| 397 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 398 |
# Verify it's valid JSON
|
| 399 |
json_data = extract_json_from_text(response_text)
|
| 400 |
return json.dumps(json_data, ensure_ascii=False) if json_data else response_text
|
|
@@ -465,7 +473,8 @@ class LLMAnalyzer:
|
|
| 465 |
max_tokens=self.max_tokens or MAX_TOKENS_ANALYSIS,
|
| 466 |
temperature=self.temperature,
|
| 467 |
system=SYSTEM_PROMPT,
|
| 468 |
-
messages=[{"role": "user", "content": prompt}]
|
|
|
|
| 469 |
)
|
| 470 |
response_text = response.content[0].text
|
| 471 |
|
|
@@ -837,6 +846,12 @@ def generate_legal_position(
|
|
| 837 |
|
| 838 |
response_text = response.choices[0].message.content
|
| 839 |
print(f"[DEBUG] OpenAI response length: {len(response_text) if response_text else 0}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
|
| 841 |
json_response = extract_json_from_text(response_text)
|
| 842 |
if json_response:
|
|
@@ -973,7 +988,8 @@ def generate_legal_position(
|
|
| 973 |
"max_tokens": max_tokens or MAX_TOKENS_CONFIG["anthropic"],
|
| 974 |
"system": system_prompt,
|
| 975 |
"messages": messages,
|
| 976 |
-
"temperature": temperature
|
|
|
|
| 977 |
}
|
| 978 |
|
| 979 |
# Add thinking config if enabled
|
|
|
|
| 166 |
|
| 167 |
def initialize_components() -> bool:
|
| 168 |
"""Initialize all necessary components for the application."""
|
| 169 |
+
from index_loader import load_indexes_with_fallback
|
| 170 |
try:
|
| 171 |
# Create local directory if it doesn't exist
|
| 172 |
LOCAL_DIR.mkdir(parents=True, exist_ok=True)
|
| 173 |
|
| 174 |
+
# Check if required files are present
|
| 175 |
missing_files = [f for f in REQUIRED_FILES if not (LOCAL_DIR / f).exists()]
|
| 176 |
+
|
| 177 |
if missing_files:
|
| 178 |
+
print(f"Missing index files: {', '.join(missing_files)}")
|
| 179 |
+
print(f"Attempting to load indexes via fallback (local → HF Dataset → S3)...")
|
| 180 |
+
indexes_ok = load_indexes_with_fallback(str(LOCAL_DIR))
|
| 181 |
+
if not indexes_ok:
|
| 182 |
+
# Last resort: try S3 directly if client is available
|
| 183 |
+
if s3_client:
|
| 184 |
+
print("Fallback failed, trying S3 directly...")
|
| 185 |
+
download_s3_folder(BUCKET_NAME, PREFIX_RETRIEVER, LOCAL_DIR)
|
| 186 |
+
else:
|
| 187 |
+
print(f"Warning: No S3 client and fallback failed for: {', '.join(missing_files)}")
|
| 188 |
else:
|
| 189 |
print(f"All required files found locally in {LOCAL_DIR}")
|
| 190 |
|
| 191 |
+
# Final check
|
|
|
|
|
|
|
|
|
|
| 192 |
missing_files = [f for f in REQUIRED_FILES if not (LOCAL_DIR / f).exists()]
|
| 193 |
if missing_files:
|
| 194 |
+
raise FileNotFoundError(f"Missing required files after all attempts: {', '.join(missing_files)}")
|
| 195 |
|
| 196 |
# Initialize search components if any embedding model is available
|
| 197 |
if embed_model:
|
|
|
|
| 396 |
raise last_error
|
| 397 |
|
| 398 |
response_text = response.choices[0].message.content
|
| 399 |
+
|
| 400 |
+
# Log cache hit stats (automatic caching, no config needed)
|
| 401 |
+
if hasattr(response, 'usage') and hasattr(response.usage, 'prompt_tokens_details'):
|
| 402 |
+
cached = getattr(response.usage.prompt_tokens_details, 'cached_tokens', 0)
|
| 403 |
+
total = response.usage.prompt_tokens
|
| 404 |
+
print(f"[CACHE] OpenAI analysis: {cached}/{total} input tokens from cache")
|
| 405 |
+
|
| 406 |
# Verify it's valid JSON
|
| 407 |
json_data = extract_json_from_text(response_text)
|
| 408 |
return json.dumps(json_data, ensure_ascii=False) if json_data else response_text
|
|
|
|
| 473 |
max_tokens=self.max_tokens or MAX_TOKENS_ANALYSIS,
|
| 474 |
temperature=self.temperature,
|
| 475 |
system=SYSTEM_PROMPT,
|
| 476 |
+
messages=[{"role": "user", "content": prompt}],
|
| 477 |
+
cache_control={"type": "ephemeral"}
|
| 478 |
)
|
| 479 |
response_text = response.content[0].text
|
| 480 |
|
|
|
|
| 846 |
|
| 847 |
response_text = response.choices[0].message.content
|
| 848 |
print(f"[DEBUG] OpenAI response length: {len(response_text) if response_text else 0}")
|
| 849 |
+
|
| 850 |
+
# Log cache hit stats (automatic caching, no config needed)
|
| 851 |
+
if hasattr(response, 'usage') and hasattr(response.usage, 'prompt_tokens_details'):
|
| 852 |
+
cached = getattr(response.usage.prompt_tokens_details, 'cached_tokens', 0)
|
| 853 |
+
total = response.usage.prompt_tokens
|
| 854 |
+
print(f"[CACHE] OpenAI generation: {cached}/{total} input tokens from cache")
|
| 855 |
|
| 856 |
json_response = extract_json_from_text(response_text)
|
| 857 |
if json_response:
|
|
|
|
| 988 |
"max_tokens": max_tokens or MAX_TOKENS_CONFIG["anthropic"],
|
| 989 |
"system": system_prompt,
|
| 990 |
"messages": messages,
|
| 991 |
+
"temperature": temperature,
|
| 992 |
+
"cache_control": {"type": "ephemeral"}
|
| 993 |
}
|
| 994 |
|
| 995 |
# Add thinking config if enabled
|
prompts.py
CHANGED
|
@@ -18,14 +18,6 @@ LEGAL_POSITION_PROMPT = """
|
|
| 18 |
правових позицій Верховного Суду (lpd.court.gov.ua).
|
| 19 |
</task>
|
| 20 |
|
| 21 |
-
<court_decision>
|
| 22 |
-
{court_decision_text}
|
| 23 |
-
</court_decision>
|
| 24 |
-
|
| 25 |
-
<comment>
|
| 26 |
-
{comment}
|
| 27 |
-
</comment>
|
| 28 |
-
|
| 29 |
<strategy>
|
| 30 |
Постанова Верховного Суду має типову структуру. Для формулювання правової позиції
|
| 31 |
зосередься ВИКЛЮЧНО на розділах:
|
|
@@ -150,6 +142,14 @@ LEGAL_POSITION_PROMPT = """
|
|
| 150 |
}}
|
| 151 |
</output_format>
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
"""
|
| 154 |
|
| 155 |
PRECEDENT_ANALYSIS_TEMPLATE = PromptTemplate(
|
|
|
|
| 18 |
правових позицій Верховного Суду (lpd.court.gov.ua).
|
| 19 |
</task>
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
<strategy>
|
| 22 |
Постанова Верховного Суду має типову структуру. Для формулювання правової позиції
|
| 23 |
зосередься ВИКЛЮЧНО на розділах:
|
|
|
|
| 142 |
}}
|
| 143 |
</output_format>
|
| 144 |
|
| 145 |
+
<court_decision>
|
| 146 |
+
{court_decision_text}
|
| 147 |
+
</court_decision>
|
| 148 |
+
|
| 149 |
+
<comment>
|
| 150 |
+
{comment}
|
| 151 |
+
</comment>
|
| 152 |
+
|
| 153 |
"""
|
| 154 |
|
| 155 |
PRECEDENT_ANALYSIS_TEMPLATE = PromptTemplate(
|