"""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')