Files
yawan-workbench/server/tests/test_training_map_background.py

308 lines
15 KiB
Python

"""Background assessments use real SQLite and a local neutral knowledge index.
Only model generation/embedding are faked. Quick retrieval is run in process so
it uses the test's isolated index instead of starting the application's worker.
"""
import threading
import time
from types import SimpleNamespace
import pytest
from fastapi import HTTPException
from pydantic import ValidationError
from sqlmodel import SQLModel, Session, create_engine
from app import config, training_map as tm
from app.models import AppUser, KnowledgeBase, KnowledgeBaseGrant, KnowledgeFile, ModelProvider
from app.rag import embed, pipeline, store
@pytest.fixture
def assessment_env(tmp_path, monkeypatch):
monkeypatch.setattr(config, 'DATA_DIR', tmp_path)
fallback = tmp_path / 'vectors'
fallback.mkdir()
monkeypatch.setattr(store, 'FALLBACK_DIR', fallback)
# The separate RAG process cannot inherit monkeypatched filesystem paths.
monkeypatch.setattr(tm, 'search_kbs_remote', pipeline.search_kbs)
monkeypatch.setattr(embed, 'embed_query', lambda text: [1.0, 0.0])
monkeypatch.setattr(tm, '_assessment_slots', threading.BoundedSemaphore(2))
monkeypatch.setattr(tm, '_assessment_jobs', set())
try:
from app import training_corpus
monkeypatch.setattr(training_corpus, 'CORPUS_DIR', tmp_path / 'corpus')
except ImportError:
pass
engine = create_engine(f'sqlite:///{tmp_path / "test.sqlite"}',
connect_args={'check_same_thread': False, 'timeout': 2})
SQLModel.metadata.create_all(engine)
folder = tmp_path / 'knowledge'
folder.mkdir()
text = '连续三天记录任务时间与打断来源,再按任务类别复盘时间分配并选择一个改进动作。'
(folder / 'lesson.txt').write_text(text, encoding='utf-8')
with Session(engine) as session:
user = AppUser(username='assessment-reader', role='editor')
kb = KnowledgeBase(name='中性时间管理资料', owner_user_id=99, visibility='shared',
source_dir=str(folder), file_count=1, chunk_count=1, index_status='ready')
provider = ModelProvider(name='test-model', default_model='neutral', api_key='unit-test-only',
base_url='http://127.0.0.1:9/v1', enabled=True)
session.add_all([user, kb, provider]); session.commit()
session.refresh(user); session.refresh(kb); session.refresh(provider)
grant = KnowledgeBaseGrant(kb_id=kb.id, user_id=user.id, permission='use')
session.add_all([grant, KnowledgeFile(kb_id=kb.id, file_name='lesson.txt')])
session.commit(); session.refresh(grant)
request = SimpleNamespace(state=SimpleNamespace(user=user))
row = tm.create_draft(tm.DraftInput(topic='时间管理训练营', kb_ids=[kb.id]), request, session)
ids = (row.id, kb.id, user.id, grant.id, provider.id)
store._write_fallback(kb.id, [{'id': 'time-1', 'text': text,
'meta': {'file': 'lesson.txt'}, 'embedding': [1.0, 0.0]}])
monkeypatch.setattr(tm, '_pick_model', lambda session: (ids[4], 'neutral'))
yield SimpleNamespace(engine=engine, draft_id=ids[0], kb_id=ids[1], user_id=ids[2], grant_id=ids[3])
engine.dispose()
def request_for(session, env):
return SimpleNamespace(state=SimpleNamespace(user=session.get(AppUser, env.user_id)))
def neutral_plan(evidence):
return {'recommended_days': 1, 'rationale': '资料支持一天的记录和复盘练习。', 'insufficiencies': [],
'days': [{'day': 1, 'title': '记录时间分配', 'source_ids': [evidence[0]['id']],
'support': '存在具体记录步骤', 'prerequisite': '从零开始', 'outcome': '完成记录并选出改进动作'}]}
def capture_worker(monkeypatch):
finished = threading.Event()
failures = []
original = tm._background_assessment
def worker(*args):
try:
original(*args)
except BaseException as exc:
failures.append(exc)
finally:
finished.set()
monkeypatch.setattr(tm, '_background_assessment', worker)
return finished, failures
def test_modes_validate_and_default_to_full():
assert tm.AssessmentInput(version=1).mode == 'full'
assert tm.AssessmentInput(version=1, mode='quick').mode == 'quick'
with pytest.raises(ValidationError):
tm.AssessmentInput(version=1, mode='everything')
@pytest.mark.parametrize('mode', ['quick', 'full'])
def test_background_returns_progress_and_rejects_duplicate_or_edit_without_wait(assessment_env, monkeypatch, mode):
env = assessment_env
entered, release = threading.Event(), threading.Event()
finished, failures = capture_worker(monkeypatch)
def ai(session, row, schema, instructions, evidence):
assert schema is tm.Plan
entered.set()
assert release.wait(5), 'test did not release model'
return neutral_plan(evidence)
monkeypatch.setattr(tm, 'model_json', ai)
try:
with Session(env.engine) as session:
started = time.monotonic()
accepted = tm.assess(env.draft_id, tm.AssessmentInput(version=1, mode=mode),
request_for(session, env), session, background=True)
assert time.monotonic() - started < 1
assert accepted['status'] == 'assessing' and accepted['coverage']['mode'] == mode
assert entered.wait(5)
with Session(env.engine) as session:
request = request_for(session, env)
row = tm.get_draft(env.draft_id, request, session)
assert row.status == 'assessing' and row.coverage['stage'] == 'planning'
assert row.evidence and row.coverage['evidence_count'] >= 1
if mode == 'full':
assert row.coverage['scanned_chunks'] >= 1
started = time.monotonic()
with pytest.raises(HTTPException) as duplicate:
tm.assess(row.id, tm.AssessmentInput(version=row.version, mode=mode), request, session, True)
assert duplicate.value.status_code == 409
with pytest.raises(HTTPException) as editing:
tm.update_draft(row.id, tm.UpdateInput(version=row.version, topic='调整时间安排', kb_ids=[env.kb_id]), request, session)
assert editing.value.status_code == 409
assert time.monotonic() - started < 1
finally:
release.set()
assert finished.wait(5), 'assessment worker failed to finish'
assert not failures
with Session(env.engine) as session:
row = session.get(tm.TrainingMap, env.draft_id)
assert row.status == 'proposed'
assert row.coverage['stage'] == 'complete' and row.coverage['read_chunks'] >= 1
assert not tm._assessment_jobs
@pytest.mark.parametrize('revoked', ['user', 'kb'])
def test_background_rechecks_permissions_before_publishing(assessment_env, monkeypatch, revoked):
env = assessment_env
entered, release = threading.Event(), threading.Event()
finished, failures = capture_worker(monkeypatch)
def ai(session, row, schema, instructions, evidence):
entered.set()
assert release.wait(5)
return neutral_plan(evidence)
monkeypatch.setattr(tm, 'model_json', ai)
try:
with Session(env.engine) as session:
tm.assess(env.draft_id, tm.AssessmentInput(version=1, mode='quick'), request_for(session, env), session, True)
assert entered.wait(5)
with Session(env.engine) as session:
target = session.get(AppUser, env.user_id) if revoked == 'user' else session.get(KnowledgeBaseGrant, env.grant_id)
target.enabled = False
session.add(target); session.commit()
finally:
release.set()
assert finished.wait(5)
assert not failures
with Session(env.engine) as session:
row = session.get(tm.TrainingMap, env.draft_id)
assert row.status == 'failed' and row.plan == {}
assert row.coverage['stage'] == 'failed'
assert row.coverage['evidence_count'] == 1
def test_interrupted_background_is_retryable_and_keeps_progress(assessment_env, monkeypatch):
env = assessment_env
with Session(env.engine) as session:
row = session.get(tm.TrainingMap, env.draft_id)
row.status = 'assessing'
row.coverage = {'mode': 'full', 'started_epoch': time.time() - 60,
'stage': 'reading', 'read_chunks': 25, 'scanned_chunks': 30}
session.add(row); session.commit()
row = tm.get_draft(row.id, request_for(session, env), session)
assert row.status == 'failed' and row.coverage['read_chunks'] == 25
monkeypatch.setattr(tm, 'model_json', lambda session, row, schema, instructions, evidence: neutral_plan(evidence))
restarted = tm.assess(row.id, tm.AssessmentInput(version=row.version, mode='quick'), request_for(session, env), session)
assert restarted.status == 'proposed'
@pytest.mark.parametrize('mode', ['quick', 'full'])
def test_model_failure_retains_persisted_coverage(assessment_env, monkeypatch, mode):
env = assessment_env
def ai(*args, **kwargs):
raise ValueError('neutral model failure')
monkeypatch.setattr(tm, 'model_json', ai)
with Session(env.engine) as session:
with pytest.raises(HTTPException):
tm.assess(env.draft_id, tm.AssessmentInput(version=1, mode=mode), request_for(session, env), session)
with Session(env.engine) as session:
row = session.get(tm.TrainingMap, env.draft_id)
assert row.status == 'failed' and row.coverage['stage'] == 'failed'
assert row.coverage['mode'] == mode
assert row.coverage['evidence_count'] == 1
assert row.evidence
def test_cancel_during_final_model_prevents_publication(assessment_env, monkeypatch):
env = assessment_env
entered, release = threading.Event(), threading.Event()
finished, failures = capture_worker(monkeypatch)
def ai(session, row, schema, instructions, evidence):
entered.set()
assert release.wait(5)
return neutral_plan(evidence)
monkeypatch.setattr(tm, 'model_json', ai)
try:
with Session(env.engine) as session:
accepted = tm.assess(env.draft_id, tm.AssessmentInput(version=1, mode='quick'), request_for(session, env), session, True)
assert entered.wait(5)
with Session(env.engine) as session:
started = time.monotonic()
cancelled = tm.cancel_assessment(env.draft_id, tm.VersionInput(version=accepted['version']), request_for(session, env), session)
assert cancelled.assessment_cancel_requested
assert time.monotonic() - started < 1
finally:
release.set()
assert finished.wait(5)
assert not failures
with Session(env.engine) as session:
row = session.get(tm.TrainingMap, env.draft_id)
assert row.status == 'failed' and row.plan == {}
assert row.coverage['stage'] == 'cancelled'
def test_sources_require_current_version_owner_and_live_kb_grant(assessment_env, monkeypatch):
from app import training_corpus
env = assessment_env
monkeypatch.setattr(tm, 'model_json', lambda session, row, schema, instructions, evidence: neutral_plan(evidence))
with Session(env.engine) as session:
request = request_for(session, env)
row = tm.assess(env.draft_id, tm.AssessmentInput(version=1, mode='full'), request, session)
full_version, evidence_id = row.version, row.evidence[0]['id']
source = tm.evidence_sources(row.id, evidence_id, request, version=full_version, offset=0, limit=20, s=session)
assert source['total'] == 1 and source['items'][0]['kb_id'] == env.kb_id
assert '连续三天记录' in source['items'][0]['text']
stranger = SimpleNamespace(state=SimpleNamespace(user=AppUser(id=90, username='stranger', role='admin')))
with pytest.raises(HTTPException) as denied:
tm.evidence_sources(row.id, evidence_id, stranger, version=full_version, offset=0, limit=20, s=session)
assert denied.value.status_code == 404
# Switching to quick mode creates a new version, without overwriting the full snapshot.
row = tm.assess(row.id, tm.AssessmentInput(version=full_version, mode='quick'), request, session)
assert (training_corpus.CORPUS_DIR / str(row.id) / f'v{full_version}.sqlite').is_file()
with pytest.raises(HTTPException) as stale:
tm.evidence_sources(row.id, evidence_id, request, version=full_version, offset=0, limit=20, s=session)
assert stale.value.status_code == 409
current_version = row.version
with Session(env.engine) as session:
grant = session.get(KnowledgeBaseGrant, env.grant_id)
grant.enabled = False
session.add(grant); session.commit()
with Session(env.engine) as session:
with pytest.raises(HTTPException) as revoked:
tm.evidence_sources(env.draft_id, 'E1', request_for(session, env), version=current_version, offset=0, limit=20, s=session)
assert revoked.value.status_code == 404
@pytest.mark.parametrize('stop', ['user', 'kb', 'cancel'])
def test_full_reader_stops_before_another_batch_after_revocation_or_cancel(assessment_env, monkeypatch, stop):
env = assessment_env
# Three long, distinct entries force multiple real corpus batches.
store._write_fallback(env.kb_id, [
{'id': f'long-{i}', 'text': f'第{i}份时间记录。' + (f'记录第{i}类任务的时长和来源。' * 700),
'meta': {'file': 'lesson.txt'}, 'embedding': [1.0, 0.0]} for i in range(1, 4)])
entered, release = threading.Event(), threading.Event()
finished, failures = capture_worker(monkeypatch)
calls = []
def ai(session, row, schema, instructions, evidence):
calls.append(schema.__name__)
assert schema is not tm.Plan, 'should stop before planning'
entered.set()
assert release.wait(5)
return {'text': '记录任务时长、任务类别和干扰来源,再用记录对照并选择改进动作。'}
monkeypatch.setattr(tm, 'model_json', ai)
try:
with Session(env.engine) as session:
accepted = tm.assess(env.draft_id, tm.AssessmentInput(version=1), request_for(session, env), session, True)
assert entered.wait(5)
with Session(env.engine) as session:
if stop == 'cancel':
tm.cancel_assessment(env.draft_id, tm.VersionInput(version=accepted['version']), request_for(session, env), session)
else:
target = session.get(AppUser, env.user_id) if stop == 'user' else session.get(KnowledgeBaseGrant, env.grant_id)
target.enabled = False
session.add(target); session.commit()
finally:
release.set()
assert finished.wait(5)
assert not failures
assert len(calls) == 1
with Session(env.engine) as session:
row = session.get(tm.TrainingMap, env.draft_id)
assert row.status == 'failed' and row.plan == {}
assert row.coverage['scanned_chunks'] == 3
assert row.coverage['stage'] == ('cancelled' if stop == 'cancel' else 'failed')