Files
yawan-workbench/server/tests/test_training_estimate.py
T

187 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Advisory timing must remain cheap, scoped and grounded in completed work."""
import copy
import time
import pytest
from fastapi import HTTPException
from sqlmodel import Session
from app import training_estimate as timing, training_map as tm
from app.models import KnowledgeBase, KnowledgeBaseGrant, LLMCall
from tests.test_training_map_background import assessment_env, neutral_plan, request_for
def test_estimate_uses_counters_without_reading_vectors_or_calling_ai(assessment_env, monkeypatch):
def forbidden(*args, **kwargs):
raise AssertionError('selection must not read corpus or call AI')
monkeypatch.setattr(tm, 'search_kbs_remote', forbidden)
monkeypatch.setattr(tm, 'model_json', forbidden)
from app import training_corpus
monkeypatch.setattr(training_corpus, 'iter_chunks', forbidden)
with Session(assessment_env.engine) as session:
kb = session.get(KnowledgeBase, assessment_env.kb_id)
kb.chunk_count, kb.chunk_target, kb.file_count = 62000, 2200, 800
session.add(kb); session.commit()
body = tm.EstimateInput(kb_ids=[kb.id, kb.id], mode='full', requested_days=30)
estimate = tm.estimate_assessment(body, request_for(session, assessment_env), session)
quick = tm.estimate_assessment(tm.EstimateInput(kb_ids=[kb.id], mode='quick'), request_for(session, assessment_env), session)
assert estimate['total_chunks'] == 62000 and estimate['total_files'] == 800
assert estimate['estimated_batches'] > 10000
assert estimate['seconds_low'] > 86400 # Do not clamp enormous jobs to five minutes.
assert quick['estimated_batches'] == 0 and quick['seconds_high'] < 300
assert '参考范围' in estimate['basis'] and estimate['history_samples'] == 0
assert estimate['seconds_low'] == sum(stage['seconds_low'] for stage in estimate['stages'])
def test_estimate_requires_use_access_for_every_selected_library(assessment_env):
env = assessment_env
with Session(env.engine) as session:
grant = session.get(KnowledgeBaseGrant, env.grant_id)
grant.enabled = False
session.add(grant); session.commit()
with pytest.raises(HTTPException) as denied:
tm.estimate_assessment(tm.EstimateInput(kb_ids=[env.kb_id]), request_for(session, env), session)
assert denied.value.status_code == 404
def test_history_only_uses_current_org_provider_model_and_successes(assessment_env):
with Session(assessment_env.engine) as session:
for index in range(4):
session.add(LLMCall(organization_id=1, provider='test-model', model='neutral',
agent_name='训练营导图', status='ok', duration_ms=10000))
for excluded in ({'organization_id': 2}, {'provider': 'other'}, {'model': 'other'},
{'agent_name': 'content'}, {'status': 'timeout'}, {'created_at': '2020-01-01 00:00:00'}):
session.add(LLMCall(**{'organization_id': 1, 'provider': 'test-model', 'model': 'neutral',
'agent_name': '训练营导图', 'status': 'ok', 'duration_ms': 999999, **excluded}))
session.commit()
result = tm.estimate_assessment(tm.EstimateInput(kb_ids=[assessment_env.kb_id]), request_for(session, assessment_env), session)
assert result['history_samples'] == 4
assert result['per_day_seconds_high'] == 60
assert result['estimated_batches'] == 0
assert '最近 4 次' in result['basis']
def test_empty_index_has_no_actionable_timing(assessment_env):
with Session(assessment_env.engine) as session:
kb = session.get(KnowledgeBase, assessment_env.kb_id)
kb.chunk_count = 0
session.add(kb); session.commit()
result = tm.estimate_assessment(tm.EstimateInput(kb_ids=[kb.id]), request_for(session, assessment_env), session)
assert result['seconds_low'] == result['seconds_high'] == 0
assert '先完成资料索引' in result['basis']
def coverage(mode='full'):
return {'mode': mode, 'stage': 'reading', 'started_epoch': 1000, 'stage_started_epoch': 1005,
'scanned_chunks': 1000, 'read_chunks': 100, 'processed_batches': 2,
'estimate': {'total_chunks': 1000, 'estimated_batches': 20, 'estimated_reducing_batches': 1,
'stages': [{'id': 'scanning', 'seconds_low': 2, 'seconds_high': 15},
{'id': 'planning', 'seconds_low': 25, 'seconds_high': 120}]}}
def test_actual_work_progress_does_not_move_with_elapsed_time_or_stage_reversal():
base = coverage()
first = timing.update_timing(base, 1020)
later = timing.update_timing(first, 1400)
assert first['progress_percent'] == later['progress_percent'] < 100
reduced = timing.update_timing({**later, 'stage': 'reducing', 'processed_batches': 5}, 1405)
next_kb = timing.update_timing({**reduced, 'stage': 'reading', 'read_chunks': 200, 'processed_batches': 6}, 1410)
assert next_kb['progress_percent'] >= reduced['progress_percent'] >= first['progress_percent']
def test_measured_throughput_replaces_initial_call_reference_after_real_samples():
base = coverage()
measured = {**base, 'timing': {'summary_calls_completed': 2, 'summary_model_seconds': 8,
'reading_calls_completed': 2, 'reducing_calls_completed': 0}}
measured_result = timing.update_timing(measured, 1020)
baseline_result = timing.update_timing(base, 1020)
assert measured_result['remaining_seconds_high'] < baseline_result['remaining_seconds_high']
assert measured_result['progress_percent'] == baseline_result['progress_percent']
@pytest.mark.parametrize('mode', ['full', 'quick'])
def test_planning_never_finishes_from_elapsed_and_reports_overdue(mode):
base = {**coverage(mode), 'stage': 'planning', 'stage_started_epoch': 1020}
running = timing.update_timing(base, 2000)
assert running['overdue'] and running['progress_percent'] < 100
assert running['remaining_seconds_high'] > 0
done = timing.update_timing({**running, 'stage': 'complete'}, 2020)
assert done['progress_percent'] == 100
assert done['remaining_seconds_low'] == done['remaining_seconds_high'] == 0
def test_failed_and_legacy_progress_do_not_fabricate_a_countdown():
base = timing.update_timing(coverage(), 1020)
stopped = timing.update_timing({**base, 'stage': 'cancelled'}, 1025)
later = timing.update_timing(stopped, 2000)
assert later['elapsed_seconds'] == stopped['elapsed_seconds']
assert later['progress_percent'] == stopped['progress_percent']
assert 'remaining_seconds_low' not in stopped
old = {'stage': 'planning', 'started_epoch': 1000}
assert timing.update_timing(old, 2000) == old
@pytest.mark.parametrize('mode', ['full', 'quick'])
def test_assessment_persists_estimate_and_only_completes_progress_after_plan(assessment_env, monkeypatch, mode):
observations = []
def fake_ai(session, row, schema, instructions, evidence):
session.refresh(row)
observations.append(copy.deepcopy(row.coverage))
assert row.coverage['stage'] == 'planning'
assert row.coverage['progress_percent'] < 100
assert row.coverage['estimate']['mode'] == mode
return neutral_plan(evidence)
monkeypatch.setattr(tm, 'model_json', fake_ai)
with Session(assessment_env.engine) as session:
row = tm.assess(assessment_env.draft_id, tm.AssessmentInput(version=1, mode=mode),
request_for(session, assessment_env), session)
assert row.coverage['progress_percent'] == 100
assert row.coverage['remaining_seconds_high'] == 0
assert len(observations) == 1
def test_first_full_batch_marks_reading_before_ai_and_records_measured_calls(assessment_env, monkeypatch):
from app.rag import store
env = assessment_env
store._write_fallback(env.kb_id, [
{'id': f'long-{index}', 'text': f'时间记录{index}:' + (f'按第{index}类任务记录耗时再复盘分配。' * 700),
'meta': {'file': 'lesson.txt'}, 'embedding': [1.0, 0.0]} for index in range(1, 4)])
seen = []
def fake_ai(session, row, schema, instructions, evidence):
seen.append(copy.deepcopy(row.coverage))
if schema is tm.Plan:
return neutral_plan(evidence)
assert row.coverage['stage'] == 'reading'
assert row.coverage['timing']['current_model_started_epoch'] > 0
return {'text': '记录不同任务的耗时和打断来源,按任务分类复盘,并选择具体改进动作。'}
monkeypatch.setattr(tm, 'model_json', fake_ai)
with Session(env.engine) as session:
row = tm.assess(env.draft_id, tm.AssessmentInput(version=1), request_for(session, env), session)
assert row.coverage['timing']['summary_calls_completed'] == len(seen) - 1
assert row.coverage['timing']['summary_model_seconds'] > 0
assert row.coverage['processed_batches'] == len(seen) - 1
assert 'current_model_started_epoch' not in row.coverage['timing']
assert row.coverage['progress_percent'] == 100
assert len(seen) > 2
assert all(item['progress_percent'] < 100 for item in seen)
def test_progress_get_does_not_autoflush_over_new_worker_progress(assessment_env):
env = assessment_env
with Session(env.engine) as session:
row = session.get(tm.TrainingMap, env.draft_id)
row.status = 'assessing'
row.coverage = {**coverage(), 'started_epoch': time.time() - 2,
'stage_started_epoch': time.time() - 1}
session.add(row); session.commit()
result = tm.get_draft(row.id, request_for(session, env), session)
assert result.coverage['elapsed_seconds'] >= 2
assert not session.is_modified(result)
with Session(env.engine) as worker:
current = worker.get(tm.TrainingMap, row.id)
current.coverage = {**current.coverage, 'read_chunks': 777}
worker.add(current); worker.commit()
session.commit()
with Session(env.engine) as verify:
assert verify.get(tm.TrainingMap, env.draft_id).coverage['read_chunks'] == 777