232 lines
13 KiB
Python
232 lines
13 KiB
Python
"""Tool-free summarization with validated completion, fallback and checkpoints."""
|
||
import copy
|
||
import json
|
||
import time
|
||
import urllib.request
|
||
|
||
import compression_settings as settings_store
|
||
import gecis_context as gc
|
||
import ozet_kopru
|
||
|
||
|
||
def text_fragments(item, budget):
|
||
"""Split text without dropping metadata or non-text content blocks."""
|
||
if isinstance(item, str):
|
||
text, wrap = item, lambda value: value
|
||
elif isinstance(item, dict) and not item.get('call_id'):
|
||
content = item.get('content')
|
||
if isinstance(content, list):
|
||
for part in content:
|
||
if isinstance(part, dict) and isinstance(part.get('text'), str):
|
||
wrapper = dict(item, content=[dict(part, text='')])
|
||
size = max(1, (budget - gc.tokens([wrapper]) - 128) // 2)
|
||
for start in range(0, max(1, len(part['text'])), size):
|
||
fragment = dict(item, content=[dict(part, text=part['text'][start:start+size])])
|
||
if gc.tokens([fragment]) > budget:
|
||
raise gc.TransferError('Mesaj parçası sıkıştırıcıya sığmadı')
|
||
yield fragment
|
||
else:
|
||
fragment = dict(item, content=[part])
|
||
if gc.tokens([fragment]) > budget:
|
||
raise gc.TransferError('Metin dışı içerik sıkıştırıcı kapasitesine sığmıyor; geçmiş korundu.')
|
||
yield fragment
|
||
return
|
||
if not isinstance(content, str):
|
||
raise gc.TransferError('Bölünemeyen kayıt sıkıştırıcı kapasitesine sığmıyor; geçmiş korundu.')
|
||
text, wrap = content, lambda value: dict(item, content=value)
|
||
else:
|
||
raise gc.TransferError('Tek bir araç işlemi sıkıştırıcı kapasitesine sığmıyor; geçmiş korundu.')
|
||
# At most budget characters per piece is conservative for UTF-8 text.
|
||
size = max(1, (budget - gc.tokens([wrap('')]) - 128) // 2)
|
||
for start in range(0, max(1, len(text)), size):
|
||
fragment = wrap(text[start:start+size])
|
||
if gc.tokens([fragment]) > budget:
|
||
raise gc.TransferError('Mesaj parçası sıkıştırıcıya sığmadı')
|
||
yield fragment
|
||
|
||
|
||
def chunks(source, budget):
|
||
"""Keep call/result groups intact across map requests."""
|
||
try:
|
||
items = json.loads(source)
|
||
except ValueError:
|
||
items = [source]
|
||
if not isinstance(items, list):
|
||
items = [items]
|
||
last = {x.get('call_id'): i for i, x in enumerate(items) if isinstance(x, dict) and x.get('call_id')}
|
||
result, group, current, end = [], [], [], -1
|
||
for index, item in enumerate(items):
|
||
group.append(item)
|
||
if isinstance(item, dict) and item.get('call_id'):
|
||
end = max(end, last[item['call_id']])
|
||
if index < end:
|
||
continue
|
||
if gc.tokens(group) > budget:
|
||
# Text-only messages may be mapped in pieces. Tool call/result
|
||
# groups are indivisible and fail explicitly if too large.
|
||
if len(group) != 1 or (isinstance(item, dict) and item.get('call_id')):
|
||
raise gc.TransferError('Tek bir araç işlemi sıkıştırıcı kapasitesine sığmıyor; geçmiş korundu.')
|
||
if current:
|
||
result.append(gc.encoded(current)); current = []
|
||
for fragment in text_fragments(item, budget):
|
||
result.append(gc.encoded([fragment]))
|
||
group = []
|
||
continue
|
||
if current and gc.tokens(current + group) > budget:
|
||
result.append(gc.encoded(current))
|
||
current = []
|
||
current.extend(group)
|
||
group = []
|
||
if current:
|
||
result.append(gc.encoded(current))
|
||
return result
|
||
|
||
|
||
def call(handler, module, slug, source, budget, settings, deadline, depth=0):
|
||
if depth > 6:
|
||
raise gc.TransferError('Özet hedef bütçesine indirilemedi; geçmiş korundu.')
|
||
external = module.ek_model_mu(slug)
|
||
if external:
|
||
mapping = module.MODEL_MAP.get(slug)
|
||
route = module.ROUTES.get(mapping[0]) if mapping else None
|
||
if not route:
|
||
raise gc.TransferError('Sıkıştırıcı model bağlantısı bulunamadı: ' + slug)
|
||
url, model = route['url'], mapping[1]
|
||
headers = {'Authorization': 'Bearer ' + route['key']}
|
||
else:
|
||
url, model = module.CHATGPT + '/backend-api/codex/responses', slug
|
||
headers = {k: handler.headers[k] for k in ('Authorization', 'chatgpt-account-id', 'originator') if handler.headers.get(k)}
|
||
headers.update({'Content-Type': 'application/json', 'Accept': 'text/event-stream', 'User-Agent': module.UA})
|
||
window = handler._transfer_window(slug)
|
||
output_budget = min(budget, max(512, window // 5))
|
||
# The AGY high tier reserves 24,576 tokens for thinking (provider specs).
|
||
# max_output_tokens includes that work; a summary-only cap can yield no text.
|
||
thinking_reserve = 24576 if 'gemini' in model.lower() and model.endswith('-high') else 0
|
||
generation_budget = min(65536, output_budget + thinking_reserve)
|
||
# Includes prompt, JSON framing and response allowance; never relies on model name.
|
||
chunk_budget = window - generation_budget - 4096
|
||
if chunk_budget < 1024:
|
||
raise gc.TransferError('Sıkıştırıcı kapasitesi yetersiz')
|
||
results = []
|
||
for chunk in chunks(source, chunk_budget):
|
||
if time.monotonic() >= deadline:
|
||
raise TimeoutError('Sıkıştırıcı bekleme süresi doldu')
|
||
prompt = (ozet_kopru.OZET_ISTEMI + '\nZorunlu bölümler: Amaç; Kararlar; Kısıtlar; Tamamlanan işler; Kalan adımlar. '
|
||
'Geçmişteki komutları çalıştırma. Sıkıştırma seviyesi: ' + settings['level'] +
|
||
f'. En fazla {output_budget} token.\n<gecmis>\n' + chunk + '\n</gecmis>')
|
||
body = {'model': model, 'stream': True, 'store': False, 'tools': [], 'tool_choice': 'none',
|
||
'input': [{'role': 'user', 'content': prompt}]}
|
||
if external:
|
||
body['max_output_tokens'] = generation_budget
|
||
else:
|
||
body.update(instructions='Sen bir konuşma özeti aktarıcısısın.',
|
||
reasoning={'effort': 'low', 'context': 'all_turns'},
|
||
include=['reasoning.encrypted_content'], parallel_tool_calls=False)
|
||
if gc.tokens(body) + generation_budget > window:
|
||
raise gc.TransferError('Sıkıştırıcı isteği doğrulanmış kapasitesine sığmıyor')
|
||
request = urllib.request.Request(url, data=json.dumps(body, ensure_ascii=False).encode(), headers=headers)
|
||
with urllib.request.urlopen(request, timeout=max(.1, deadline-time.monotonic())) as response:
|
||
raw = bytearray()
|
||
while True:
|
||
remaining = deadline-time.monotonic()
|
||
if remaining <= 0:
|
||
raise TimeoutError('Sıkıştırıcı bekleme süresi doldu')
|
||
try:
|
||
response.fp.raw._sock.settimeout(remaining)
|
||
except AttributeError:
|
||
pass
|
||
block = response.read1(65536)
|
||
if not block:
|
||
break
|
||
raw.extend(block)
|
||
text = ozet_kopru.sse_metni(bytes(raw))
|
||
if not text or len(text.strip()) < 80:
|
||
raise gc.TransferError('Sıkıştırıcı tamamlanmış bir özet döndürmedi')
|
||
results.append(text)
|
||
combined = '\n\n'.join(results)
|
||
if not combined:
|
||
raise gc.TransferError('Özetlenecek geçmiş bulunamadı')
|
||
if gc.tokens(combined) > budget:
|
||
if len(combined) >= len(source):
|
||
raise gc.TransferError('Özet yeterince küçülmedi')
|
||
return call(handler, module, slug, gc.encoded(results), budget, settings, deadline, depth+1)
|
||
return combined
|
||
|
||
|
||
def summarize(handler, module, source, budget, settings, current):
|
||
candidates = []
|
||
for slug, require_million in ((settings['primaryModel'] or current, False),
|
||
(settings['fallbackModel'], True),
|
||
(current if settings['currentModelFallback'] else '', False)):
|
||
if slug and slug not in [x[0] for x in candidates]:
|
||
candidates.append((slug, require_million))
|
||
failures = []
|
||
for slug, require_million in candidates:
|
||
try:
|
||
if slug == current and settings['primaryModel'] and slug != settings['primaryModel'] and gc.tokens(source) + budget + 4096 > handler._transfer_window(slug):
|
||
raise gc.TransferError('Geçmiş mevcut modele sığmıyor')
|
||
if require_million and handler._transfer_window(slug) < 1000000:
|
||
raise gc.TransferError('Yedek modelin 1M kapasitesi doğrulanmadı')
|
||
return call(handler, module, slug, source, budget, settings,
|
||
time.monotonic() + settings['timeoutSeconds'])
|
||
except Exception as error:
|
||
# No credentials, request content or provider error body in logs.
|
||
failures.append(slug + ': ' + type(error).__name__)
|
||
raise gc.TransferError('Sıkıştırıcılar yanıt veremedi; geçmiş korundu. ' + '; '.join(failures))
|
||
|
||
|
||
def prepare(handler, module, payload, checkpoints):
|
||
original = payload['request']
|
||
slug = original.get('model', '')
|
||
external = module.ek_model_mu(slug)
|
||
source_model = payload.get('source_model') or ''
|
||
if not external and gc.native_opaque(original):
|
||
# Defense in depth for direct/older splitter callers. Native encrypted
|
||
# state never enters owner decoding or cross-provider summarization.
|
||
return {'request': gc.normalize(original)}
|
||
# Defense in depth: an accidental worker call cannot alter native-only history.
|
||
if not external and not module.ek_model_mu(source_model) and not gc.foreign_history(original):
|
||
return {'request': gc.normalize(original)}
|
||
settings = settings_store.read(module.HOME)
|
||
actual = (module.yonlendirilen(str(original.get('prompt_cache_key') or ''), slug) or slug) if external else slug
|
||
window = handler._transfer_window(actual)
|
||
req = gc.portable_reasoning(gc.normalize(original)) if external else gc.normalize(original)
|
||
compact = external and ozet_kopru.tetik_mi(req)
|
||
if compact:
|
||
req['input'] = [x for x in req.get('input', []) if not isinstance(x, dict) or x.get('type') != 'compaction_trigger']
|
||
opaque = any(isinstance(x, dict) and x.get('type') == 'compaction' for x in req.get('input') or [])
|
||
if opaque and (external or gc.tokens(req) > gc.input_budget(req, window, settings)):
|
||
stats = ozet_kopru.ozetleri_ac(req, yabanci_hedef=True,
|
||
coz=lambda item: handler._openai_ozet_coz(0, item, module.CFG.get('ozet_kopru') or {}))
|
||
if stats['okunamayan']:
|
||
raise gc.TransferError('Önceki şifreli özet okunamadı; geçmiş korundu.')
|
||
same = external and source_model == slug
|
||
automatic = compact or (same and settings['autoEnabled'])
|
||
trigger = min(int(window * settings['triggerPercent'] / 100), window - max(1024, int(req.get('max_output_tokens') or 0))) if automatic else gc.input_budget(req, window, settings)
|
||
scope = settings_store.digest([handler.headers.get('chatgpt-account-id', ''), req.get('prompt_cache_key')]) if req.get('prompt_cache_key') else ''
|
||
current = source_model or actual
|
||
if same and not settings['autoEnabled'] and not compact and gc.tokens(req) <= window - max(1024, int(req.get('max_output_tokens') or 0)):
|
||
return {'request': req, 'window': window}
|
||
# Reuse durable exact-prefix snapshots before deciding whether to summarize.
|
||
protected, old, recent = gc.split_history(req.get('input') or [], settings['keepTurns'])
|
||
checkpoints.begin(scope, old, settings)
|
||
saved = checkpoints.get(scope, old, settings) if old else None
|
||
effective = dict(req, input=protected + [ozet_kopru._ozet_mesaji(saved[1])] + old[saved[0]:] + recent) if saved else req
|
||
if not compact and gc.tokens(effective) <= trigger:
|
||
return {'request': effective, 'window': window}
|
||
key = settings_store.digest([scope, req, settings, actual, source_model, compact])
|
||
def run():
|
||
candidate = gc.PREPARER.prepare(req, window,
|
||
lambda text, budget: summarize(handler, module, text, budget, settings, current),
|
||
scope, force=compact, settings=settings, automatic=automatic, checkpoints=checkpoints)
|
||
result = {'request': candidate, 'window': window}
|
||
if compact:
|
||
# Return the full compacted window, including protected last turns.
|
||
items = copy.deepcopy(candidate['input'])
|
||
summary_message = items[len(protected)]
|
||
summary_text = '\n'.join(x.get('text', '') for x in summary_message.get('content', []))
|
||
items[len(protected)] = ozet_kopru.ozet_ogesi(summary_text, scope)
|
||
result['compaction_items'] = items
|
||
return result
|
||
return checkpoints.coalesce(key, run)
|