"""Cheap, advisory estimates and progress tied to completed corpus/model work.""" from __future__ import annotations import math import copy import json import threading import time import os from datetime import datetime, timedelta from sqlmodel import select from .models import KnowledgeBase, LLMCall, ModelProvider # Estimation is advisory and the same selection is commonly requested several # times while the user changes a form or keeps the page open. Keep a very # short in-process cache so it never waits behind the long-running automatic # reading writer. The cache is deliberately small and expires quickly so # newly completed reading batches are reflected without a restart. _ESTIMATE_CACHE: dict[str, tuple[float, dict]] = {} _ESTIMATE_CACHE_LOCK = threading.RLock() _ESTIMATE_CACHE_TTL = 12.0 def _cache_key(kb_ids, mode, organization_id, selected_model, requested_days, kb_category_ids, user_id): return json.dumps({ 'kb_ids': sorted({int(value) for value in kb_ids}), 'mode': mode, 'organization_id': int(organization_id or 0), 'selected_model': list(selected_model) if selected_model else None, 'requested_days': requested_days, 'kb_category_ids': kb_category_ids or {}, 'user_id': int(user_id or 0), }, ensure_ascii=False, sort_keys=True, separators=(',', ':')) def assessment_estimate(session, kb_ids, mode, organization_id, selected_model=None, requested_days=None, kb_category_ids=None, user_id=0): """Advisory metadata-only estimate; current exact cache is checked during scanning.""" cache_key = _cache_key(kb_ids, mode, organization_id, selected_model, requested_days, kb_category_ids, user_id) now = time.monotonic() with _ESTIMATE_CACHE_LOCK: cached = _ESTIMATE_CACHE.get(cache_key) if cached and now - cached[0] < _ESTIMATE_CACHE_TTL: return copy.deepcopy(cached[1]) from .training_scope import scope_counts from .training_reading import reading_parallelism, reading_cache_signature from .training_knowledge import reuse_inventory scoped = scope_counts(session, kb_ids, kb_category_ids or {}) libraries = len(scoped) total_chunks = sum(item['chunk_count'] for item in scoped) total_files = sum(item['file_count'] for item in scoped) parallelism = reading_parallelism(session, user_id, organization_id) reuse = {'reusable_chunks': 0, 'reusable_files': 0, 'reusable_batches': 0} if mode == 'reuse': reuse = reuse_inventory(session, kb_ids, kb_category_ids or {}, reading_cache_signature(session, selected_model)) reuse['reusable_chunks'] = min(total_chunks, reuse['reusable_chunks']) reuse['reusable_files'] = min(total_files, reuse['reusable_files']) sizes = [item['chunk_count'] * (max(120, item['chunk_target'] or 500) + 250) for item in scoped] try: batch_chars = max(16000, min(32000, int(os.environ.get('YAWAN_AUTO_READ_BATCH_CHARS', '24000')))) except (TypeError, ValueError): batch_chars = 24000 reading_batches = reducing_batches = reading_waves = reducing_waves = 0 if mode != 'quick' and (mode == 'reuse' or sum(sizes) > 16000): quota = max(1, ((32000 if mode == 'reuse' else 40000) - 2) // max(1, libraries) - 2) warm_fraction = reuse['reusable_chunks'] / max(1, total_chunks) for item, size in zip(scoped, sizes): batches = max(item['file_count'] if mode == 'reuse' else 0, math.ceil(size / batch_chars)) if item['chunk_count'] else 0 reading_batches += batches reading_waves += math.ceil(batches * (1 - warm_fraction) / parallelism) summary_size = batches * min(4000 if mode == 'reuse' else 2800, quota) while summary_size > quota: batches = math.ceil(summary_size / 14000) reducing_batches += batches reducing_waves += math.ceil(batches / parallelism) next_size = batches * min(2800, quota) if next_size >= summary_size: break summary_size = next_size provider = session.get(ModelProvider, selected_model[0]) if selected_model else None def call_history(agent_name): if not provider: return [] cutoff = (datetime.now() - timedelta(days=30)).strftime('%Y-%m-%d %H:%M:%S') calls = session.exec(select(LLMCall.duration_ms).where( LLMCall.organization_id == organization_id, LLMCall.provider == provider.name, LLMCall.model == selected_model[1], LLMCall.agent_name == agent_name, LLMCall.status == 'ok', LLMCall.duration_ms > 0, LLMCall.created_at >= cutoff, ).order_by(LLMCall.id.desc()).limit(30)).all() return sorted(value / 1000 for value in calls) def bounds(values): if len(values) < 3: return 25, 120 return max(5, values[int((len(values) - 1) * .2)] * .65), max(30, values[math.ceil((len(values) - 1) * .8)] * 1.7) durations, read_durations = call_history('训练营导图'), call_history('训练营资料阅读') call_low, call_high = bounds(durations) read_low, read_high = bounds(read_durations) basis = (f'按所选范围的索引计数和当前模型最近 {len(durations)} 次成功导图调用估算' if len(durations) >= 3 else '按所选范围的索引计数估算;当前模型成功导图记录不足,采用每次 AI 调用 25–120 秒的参考范围') scan_low, scan_high = (2 + libraries, 15 + libraries * 4) if mode == 'quick' else (2 + total_chunks / 1500, 10 + total_chunks / 150) stages = [{'id': 'scanning', 'label': '检索相关资料' if mode == 'quick' else '核对所选资料与阅读记录', 'seconds_low': math.ceil(scan_low), 'seconds_high': math.ceil(scan_high)}, {'id': 'reading', 'label': '并行阅读与汇总', 'seconds_low': math.ceil((reading_waves + reducing_waves) * read_low), 'seconds_high': math.ceil((reading_waves + reducing_waves) * read_high)}] if mode == 'reuse': stages.append({'id': 'rechecking', 'label': '按主题回查原文', 'seconds_low': 3, 'seconds_high': 45}) basis += f";可复用约 {reuse['reusable_files']} 个文件、{reuse['reusable_chunks']} 段的已读底稿,开始后核验精确内容" stages.append({'id': 'planning', 'label': '生成推荐天数与课程方案', 'seconds_low': math.ceil(call_low), 'seconds_high': math.ceil(call_high)}) if not total_chunks: for stage in stages: stage['seconds_low'] = stage['seconds_high'] = 0 basis = '所选范围尚无已索引片段,请调整分类选择或先完成资料索引;当前无法估算评估耗时' else: basis += f';阅读按最多 {parallelism} 批并行估算,模型限流、排队、重试和其他运行任务会延长时间;不含后续逐日导图' new_batches = max(0, reading_batches - reuse['reusable_batches']) result = {'mode': mode, 'total_chunks': total_chunks, 'total_files': total_files, 'parallelism': parallelism, **reuse, 'new_chunks': total_chunks - reuse['reusable_chunks'], 'new_batches': new_batches, 'estimated_batches': reading_batches + reducing_batches, 'estimated_reading_batches': reading_batches, 'estimated_reducing_batches': reducing_batches, 'seconds_low': sum(stage['seconds_low'] for stage in stages), 'seconds_high': sum(stage['seconds_high'] for stage in stages), 'basis': basis, 'stages': stages, 'per_day_seconds_low': math.ceil(call_low), 'per_day_seconds_high': math.ceil(call_high * 2), 'history_samples': len(durations), 'reading_history_samples': len(read_durations), 'reading_call_seconds_low': read_low, 'reading_call_seconds_high': read_high, 'kb_category_ids': kb_category_ids or {}, 'batch_chars': batch_chars} with _ESTIMATE_CACHE_LOCK: _ESTIMATE_CACHE[cache_key] = (time.monotonic(), result) # Avoid retaining old selections indefinitely in a long-lived worker. if len(_ESTIMATE_CACHE) > 128: cutoff = time.monotonic() - _ESTIMATE_CACHE_TTL for key, (created, _) in list(_ESTIMATE_CACHE.items()): if created < cutoff: _ESTIMATE_CACHE.pop(key, None) return copy.deepcopy(result) def update_timing(coverage, current_epoch=None): """Monotone work-based progress. Elapsed time affects ETA, never completion.""" data = dict(coverage or {}) current = time.time() if current_epoch is None else current_epoch estimate = data.get('estimate') started = data.get('started_epoch') if not isinstance(estimate, dict) or not started: return data # Historical drafts have no trustworthy estimate. stage = data.get('stage', 'scanning') stopped = stage in ('complete', 'failed', 'cancelled') if stopped: current = data.setdefault('finished_epoch', current) elapsed = max(0, current - started) data['elapsed_seconds'] = round(elapsed, 1) if stage == 'complete': data.update(progress_percent=100, remaining_seconds_low=0, remaining_seconds_high=0, overdue=False) return data if stopped: data.pop('remaining_seconds_low', None) data.pop('remaining_seconds_high', None) data['overdue'] = False return data stages = {item['id']: item for item in estimate.get('stages', [])} scanning = stages.get('scanning', {}) planning = stages.get('planning', {}) call_low = max(1, planning.get('seconds_low') or 25) call_high = max(call_low, planning.get('seconds_high') or 120) reading_low = max(1, estimate.get('reading_call_seconds_low', call_low)) reading_high = max(reading_low, estimate.get('reading_call_seconds_high', call_high)) total = max(estimate.get('total_chunks', 0), data.get('scanned_chunks', 0), 1) scanned = max(0, data.get('scanned_chunks', 0)) read = max(0, data.get('read_chunks', 0)) done = max(0, data.get('processed_batches', data.get('batches_done', 0))) estimated_batches = estimate.get('estimated_batches', 0) timings = data.get('timing') or {} actual_calls = timings.get('summary_calls_completed', 0) if actual_calls >= 2: average = timings.get('summary_model_seconds', 0) / actual_calls reading_low, reading_high = max(1, average * .65), max(5, average * 1.7) in_flight = max(0, current - timings.get('current_model_started_epoch', current)) stage_elapsed = max(0, current - data.get('stage_started_epoch', started)) if stage == 'planning': progress = 95 if data.get('mode') != 'quick' else 75 low, high = max(1, call_low - stage_elapsed), max(1, call_high - stage_elapsed) overdue = stage_elapsed > call_high elif stage == 'rechecking': progress = 92 low, high = max(1, 3 - stage_elapsed) + call_low, max(1, 45 - stage_elapsed) + call_high overdue = stage_elapsed > 45 elif data.get('mode') == 'quick': progress = 0 # Vector retrieval has no per-item completion callback. low = max(1, scanning.get('seconds_low', 2) - elapsed) + call_low high = max(1, scanning.get('seconds_high', 20) - elapsed) + call_high overdue = elapsed > scanning.get('seconds_high', 20) else: raw_total = max(1, (scanned if stage != 'scanning' else total) - data.get('duplicate_chunks', 0)) batch_fraction = min(1, done / max(done + 1, estimated_batches, 1)) progress = 10 * min(1, scanned / total) + 65 * min(1, read / raw_total) + 15 * batch_fraction remaining_batches = max(0, estimated_batches - done) if data.get('mode') == 'reuse': # Expected hits are never billed as fresh model calls in the ETA. # Actual progress still advances only when each cache entry is verified. expected_raw = data.get('total_read_batches') or estimate.get('estimated_reading_batches', 0) raw_remaining = max(0, raw_total - read) reusable_remaining = max(0, estimate.get('reusable_chunks', 0) - data.get('reused_read_chunks', 0)) fresh_remaining = max(0, raw_remaining - reusable_remaining) remaining_batches = math.ceil(fresh_remaining * expected_raw / raw_total) + max( 0, estimate.get('estimated_reducing_batches', 0) - timings.get('reducing_calls_completed', 0)) parallelism = max(1, data.get('effective_parallelism', data.get('parallelism', estimate.get('parallelism', 1)))) # After real reading samples, use actual scanned size and throughput; source counters # remain authoritative even when different libraries alternate reading/reducing. if data.get('mode') != 'reuse' and read > 0 and done >= 2 and stage != 'scanning': raw_remaining = max(0, raw_total - read) reading_calls = max(1, data.get('new_read_batches', 0) + data.get('cached_batches', 0), timings.get('reading_calls_completed', 0)) reading_remaining = math.ceil(raw_remaining * reading_calls / read) reduction_remaining = max(0, estimate.get('estimated_reducing_batches', 0) - timings.get('reducing_calls_completed', 0)) remaining_batches = reading_remaining + reduction_remaining # The current call has not returned, so at least this batch still remains. if timings.get('current_model_started_epoch'): remaining_batches = max(1, remaining_batches) scan_left_low = max(0, scanning.get('seconds_low', 2) - elapsed) if stage == 'scanning' else 0 scan_left_high = max(0, scanning.get('seconds_high', 10) - elapsed) if stage == 'scanning' else 0 low = scan_left_low + max(0, math.ceil(remaining_batches / parallelism) * reading_low - min(in_flight, reading_low)) + call_low high = scan_left_high + max(0, math.ceil(remaining_batches / parallelism) * reading_high - min(in_flight, reading_high)) + call_high if data.get('mode') == 'reuse': low += stages.get('rechecking', {}).get('seconds_low', 3) high += stages.get('rechecking', {}).get('seconds_high', 45) overdue = (stage == 'scanning' and elapsed > scanning.get('seconds_high', 10)) or in_flight > reading_high data.update(progress_percent=round(min(99, max(data.get('progress_percent', 0), progress)), 1), remaining_seconds_low=math.ceil(low), remaining_seconds_high=math.ceil(max(low, high)), overdue=bool(overdue), estimated_total_chunks=estimate.get('total_chunks', 0), estimated_total_batches=estimated_batches) return data