feat(server): add workbench API, DSH planner worker and RAG pipeline

This commit is contained in:
yi
2026-09-16 19:06:20 +08:00
parent 72fc5b47c7
commit d4b5b3f43a
122 changed files with 31906 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
"""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