@@ -0,0 +1,808 @@
""" Regressions runnable without model credentials or a production database.
SQLite exercises business state and scope. PostgreSQL/pgvector acceptance remains
covered by the dedicated-database suite, not by these tests.
"""
from concurrent . futures import ThreadPoolExecutor
from pathlib import Path
from threading import Event
from types import SimpleNamespace
import pytest
from pydantic import ValidationError
from sqlalchemy import create_engine , select , event
from sqlalchemy . orm import sessionmaker
from wisegrade import agent_tools , chat_api , services
from wisegrade . config import get_settings
from wisegrade . db import Base
from wisegrade . evidence import normalize_targets
from wisegrade . exports import _html_report , export_excel
from wisegrade . indexing import reindex_stale , run_index_task
from wisegrade . llm import ModelError , ScoreResult , validate_score
from wisegrade . models import ( Conversation , CriterionScore , KnowledgeChunk , KnowledgeDocument , ScoreEvidence ,
KnowledgePage , Project , ScoringBatch , Student , SubmissionFile , IndexTask )
from wisegrade . pages import classify_document
from wisegrade . parsing import text_quality
from wisegrade . rag import INDEX_VERSION , build_chunks , ingest , keyword_overlap , query_terms , retrieve , retrieve_targets
from wisegrade . rules import Criterion , EvidenceTarget
from wisegrade . scoring import _attach_page_types , _score_criterion
from wisegrade . seed import seed_initial
from wisegrade . storage import extract_identity , sha256
from wisegrade . submissions import file_index_lock
FLAT = lambda texts : [ [ .1 ] * 1024 for _ in texts ]
@pytest.fixture
def sqlite_db ( environment , tmp_path ) :
engine = create_engine ( f " sqlite:/// { tmp_path / ' isolated.sqlite3 ' } " )
@event.listens_for ( engine , " connect " )
def foreign_keys ( connection , _record ) :
connection . execute ( " PRAGMA foreign_keys=ON " )
Base . metadata . create_all ( engine )
factory = sessionmaker ( bind = engine , expire_on_commit = False )
with factory ( ) as session :
seed_initial ( session )
yield session
engine . dispose ( )
def project ( db ) :
student = Student ( student_no = " 9999900001 " , name = " 测试学生 " )
db . add ( student )
db . flush ( )
item = Project ( course_version_id = 1 , student_id = student . id , topic = " 测试 " , source_dir = " test " )
db . add ( item )
db . commit ( )
return item
def document ( db , item , title = " 报告 " , pages = None ) :
return ingest ( db , course_version_id = 1 , project_id = item . id if item else None ,
submission_file_id = None , title = title , category = " stage3 " , source_hash = title ,
pages = pages or [ { " page_number " : 1 , " text " : " 实际实验测得系统响应时间为100毫秒。 " } ] , embedder = FLAT )
def test_abstract_below_running_title_is_navigation ( ) :
pages = [ { " page_number " : 1 , " text " : " 报告标题 \n 摘要 \n 本文声称实现了实时追踪系统。 \n 关键词:视觉;控制 " } ]
assert classify_document ( pages ) [ 0 ] [ " page_type " ] == " abstract "
assert all ( c [ " page_type " ] == " abstract " for c in build_chunks ( pages ) )
def test_mixed_body_is_retrieved_and_validated_as_body ( sqlite_db ) :
db = sqlite_db
item = project ( db )
pages = [ { " page_number " : 2 , " text " : " 目录 \n 第1 章 项目背景.......... 1 \n "
" 第1 章 项目背景 \n 本项目面向智能检测场景,通过多传感器融合实现目标识别。 " } ]
document ( db , item , pages = pages )
hits = retrieve ( db , course_version_id = 1 , project_id = item . id , query = " 多传感器融合 " ,
include_public = False , allowed_page_types = [ " body " ] , substantive_only = True , embedder = FLAT )
assert hits and all ( h [ " block_type " ] == " body " for h in hits )
hits = _attach_page_types ( db , hits )
assert hits [ 0 ] [ " page_type " ] == " body "
result = ScoreResult ( criterion_id = " x " , score = 8 , max_score = 10 , reason = " 有正文 " , confidence = .8 ,
evidence = [ { " chunk_id " : hits [ 0 ] [ " chunk_id " ] , " page " : 2 , " quote " : " 多传感器融合 " , " target_id " : " t0 " } ] )
assert validate_score ( result , { " criterion_id " : " x " , " max_score " : 10 , " required_evidence " : [ " 实现方法 " ] } , hits ) . score == 8
def test_section_chunks_do_not_inherit_the_last_heading ( ) :
pages = [ { " page_number " : 1 , " text " : " 1 系统设计 \n 1.1 硬件设计 \n 本系统通过传感器采集数据。 \n 1.2 软件设计 \n 程序使用状态机处理结果。 " } ]
chunks = build_chunks ( pages )
hardware = next ( c for c in chunks if " 传感器采集 " in c [ " text " ] )
assert hardware [ " section " ] == " 1.1 硬件设计 " and hardware [ " chapter " ] == " 1 系统设计 "
assert " 状态机 " not in hardware [ " text " ]
def test_independent_tables_are_not_dropped ( ) :
chunks = build_chunks ( [ { " page_number " : 1 , " text " : " 本页展示实验结果用于性能分析。 " ,
" tables " : [ { " rows " : [ [ " 指标 " , " 9876 " ] ] } ] } ] )
assert any ( " 9876 " in c [ " text " ] for c in chunks )
def test_cover_labels_may_contain_spacing ( ) :
value = extract_identity ( " 学 号:9999900001 \n 姓 名:测试学生 \n 题 目:运动控制系统 " )
assert value == { " student_no " : " 9999900001 " , " name " : " 测试学生 " , " topic " : " 运动控制系统 " }
def test_corrupt_text_is_preserved_flagged_and_excluded ( sqlite_db ) :
text = " 系统测试记录的响应时间为 \uffff 200ms,请核对原页。 "
assert text_quality ( text ) [ " needs_review " ]
db = sqlite_db
item = project ( db )
document ( db , item , pages = [ { " page_number " : 1 , " text " : text , " text_quality " : text_quality ( text ) } ] )
assert db . scalar ( select ( KnowledgePage ) ) . text == text
hits = retrieve ( db , course_version_id = 1 , project_id = item . id , query = " 响应时间 " ,
allowed_page_types = [ " body " ] , include_public = False , embedder = FLAT )
assert not hits
def test_invalid_target_and_structure_only_positive_are_rejected ( ) :
criterion = { " criterion_id " : " x " , " max_score " : 10 , " required_evidence " : [ " 实验数据 " , " 章节完整性 " ] }
toc = { " chunk_id " : 1 , " page " : 2 , " page_type " : " toc " , " text " : " 第1 章 项目背景.......... 1 " }
result = ScoreResult ( criterion_id = " x " , score = 8 , max_score = 10 , reason = " 目录 " , confidence = .8 ,
evidence = [ { " chunk_id " : 1 , " page " : 2 , " quote " : " 第1 章 项目背景 " , " target_id " : " t1 " } ] )
with pytest . raises ( ModelError , match = " 内容证据 " ) :
validate_score ( result , criterion , [ toc ] )
result . evidence [ 0 ] . target_id = " invalid "
with pytest . raises ( ModelError , match = " 未知 " ) :
validate_score ( result , criterion , [ toc ] )
def test_target_objects_round_trip_with_stable_ids_and_strict_permissions ( ) :
target = { " target_id " : " experiment " , " label " : " 实验数据 " , " use " : " content " , " allowed_page_types " : [ " body " , " appendix " ] }
value = Criterion ( criterion_id = " x " , category = " stage1 " , name = " 实验 " , max_score = 10 , description = " 核对实验 " ,
required_evidence = [ target ] , score_levels = [ { " min " : 0 , " max " : 10 , " description " : " 实验质量 " } ] )
stored = value . model_dump ( )
assert all ( stored [ " required_evidence " ] [ 0 ] [ key ] == field for key , field in target . items ( ) )
assert Criterion . model_validate ( stored ) . required_evidence [ 0 ] . target_id == " experiment "
with pytest . raises ( ValidationError ) :
EvidenceTarget ( label = " 数据 " , use = " content " , allowed_page_types = [ " toc " ] )
with pytest . raises ( ValidationError ) :
Criterion . model_validate ( { * * stored , " required_evidence " : [ target , target ] } )
def test_shared_chunk_keeps_all_target_ids ( ) :
targets = normalize_targets ( [ " 实验数据 " , " 系统设计 " ] )
hit = { " chunk_id " : 1 , " document_id " : 1 , " page " : 1 , " text " : " 真实内容 " , " page_type " : " body " }
selection = retrieve_targets ( None , course_version_id = 1 , project_id = 1 , targets = targets , retrieve_fn = lambda * a , * * k : [ hit ] )
assert selection [ " evidence " ] [ 0 ] [ " target_ids " ] == [ " t0 " , " t1 " ]
def test_document_filter_and_index_readiness_precede_limit ( sqlite_db ) :
db = sqlite_db
item = project ( db )
target = document ( db , item , " 目标 " )
other = document ( db , item , " 其他 " )
other . status = " indexing "
db . commit ( )
hits = retrieve ( db , course_version_id = 1 , project_id = item . id , query = " 响应时间 " , limit = 1 ,
document_id = target . id , include_public = False , embedder = FLAT )
assert len ( hits ) == 1 and hits [ 0 ] [ " document_id " ] == target . id
target . index_version = INDEX_VERSION - 1
db . commit ( )
assert retrieve ( db , course_version_id = 1 , project_id = item . id , query = " 响应时间 " , include_public = False , embedder = FLAT ) == [ ]
def test_ascii_keywords_respect_token_boundaries ( ) :
tokens , bigrams = query_terms ( " PID " )
assert keyword_overlap ( " RAPID " , tokens , bigrams ) == 0
assert keyword_overlap ( " PID 控制 " , tokens , bigrams ) == 1
def test_same_page_number_does_not_attach_another_document ( ) :
citations = [ { " document_id " : 1 , " title " : " 甲报告.pdf " , " page " : 11 , " quote " : " 甲报告独有原文证据 " } ,
{ " document_id " : 2 , " title " : " 乙报告.pdf " , " page " : 11 , " quote " : " 乙报告独有原文证据 " } ]
assert [ c [ " document_id " ] for c in chat_api . _answer_citations ( citations , " 甲报告第11页介绍测试过程。 " ) ] == [ 1 ]
assert chat_api . _answer_citations ( citations , " 第11页有结果。 " ) == [ ]
def test_explicit_citation_link_selects_only_that_chunk ( ) :
items = [ { " document_id " : 1 , " chunk_id " : i , " title " : " 报告 " , " page " : 11 ,
" quote " : f " 不同原文段落 { i } " , " answer_url " : f " /api/knowledge/documents/1/pages/11#chunk- { i } " } for i in ( 1 , 2 ) ]
# A linked source must not activate a broad page-number fallback for other hits.
answer = " 结果见[报告,第11页](/api/knowledge/documents/1/pages/11#chunk-2)。 "
assert [ c [ " chunk_id " ] for c in chat_api . _answer_citations ( items , answer ) ] == [ 2 ]
def test_page_inventory_includes_pages_without_chunks ( sqlite_db ) :
db = sqlite_db
item = project ( db )
doc = document ( db , item )
db . add ( KnowledgePage ( document_id = doc . id , page = 2 , text = " " , page_type = " unknown " ) )
conversation = Conversation ( course_version_id = 1 , project_id = item . id )
result = agent_tools . get_document_pages ( db , conversation , SimpleNamespace ( document_id = doc . id ) , " " )
assert [ row [ " page " ] for row in result [ " pages " ] ] == [ 1 , 2 ]
def test_last_review_updates_batch_and_keeps_zero ( sqlite_db ) :
db = sqlite_db
item = project ( db )
data = services . create_batch ( SimpleNamespace ( course_version_id = 1 , rubric_version_id = 1 ) , db )
batch = db . get ( ScoringBatch , data [ " id " ] )
batch . status = " review_required "
for criterion in batch . rubric_snapshot [ " criteria " ] :
db . add ( CriterionScore ( batch_id = batch . id , project_id = item . id , criterion_id = criterion [ " criterion_id " ] ,
status = " scored " , model_score = 0 ) )
db . flush ( )
score = db . scalar ( select ( CriterionScore ) . where ( CriterionScore . batch_id == batch . id ) )
score . model_score , score . status = None , " needs_review "
db . commit ( )
services . review_score ( score . id , SimpleNamespace ( score = 0 , comment = " 核对 " , reason = " 确认零分 " ) , db )
assert batch . status == " completed " and score . final_score == 0
task = export_excel ( db , batch . id )
from openpyxl import load_workbook
with Path ( task . path ) . open ( " rb " ) as file :
sheet = load_workbook ( file , read_only = True ) . active
assert sheet [ " D2 " ] . value == 0
def test_pdf_html_preserves_zero_and_none ( ) :
batch = SimpleNamespace ( id = 1 , course_version_id = 1 , rubric_version_id = 1 , model_config_snapshot = { } ,
embedding_model_name = " test " , embedding_model_version = " test " )
row = { " criterion " : { " name " : " 实验 " , " max_score " : 10 } , " final_score " : 0 , " model_score " : 0 ,
" teacher_score " : None , " status " : " scored " , " evidence " : [ ] , " missing_evidence " : [ ] ,
" model_comment " : " 核对 " , " teacher_comment " : None , " suggestion " : " " , " error " : " " }
result = { " student_no " : " test " , " name " : " test " , " topic " : " test " , " rows " : [ row ] ,
" totals " : { " stage1 " : 0 , " stage2 " : None , " stage3 " : 0 , " final " : 0 , " overall " : None } }
html = _html_report ( result , batch )
assert " 实验(0 / 10) " in html and " 模型原始分:0 " in html
def test_public_reindex_uses_saved_pages ( sqlite_db ) :
db = sqlite_db
doc = document ( db , None , " 课程资料 " )
doc . index_version = INDEX_VERSION - 1
db . commit ( )
result = reindex_stale ( db , 1 , embedder = FLAT )
assert result [ " remaining " ] == 0 and result [ " rebuilt " ] [ 0 ] [ " document_id " ] == doc . id
assert doc . status == " superseded " and doc . index_version == INDEX_VERSION - 1
replacement = db . get ( KnowledgeDocument , result [ " rebuilt " ] [ 0 ] [ " replacement_document_id " ] )
assert replacement . id != doc . id and replacement . index_version == INDEX_VERSION
def test_student_reindex_reuses_parse_output ( sqlite_db , environment , monkeypatch ) :
db = sqlite_db
item = project ( db )
path = environment / " inbox " / " saved.pdf "
path . write_bytes ( b " source hash only; parser must not be called " )
file = SubmissionFile ( project_id = item . id , category = " stage3 " , relative_path = " saved.pdf " , mime_type = " application/pdf " ,
size = path . stat ( ) . st_size , mtime = path . stat ( ) . st_mtime , sha256 = sha256 ( path ) ,
recognition_status = " recognized " , parse_status = " ready " ,
parsed = { " pages " : [ { " page_number " : 1 , " text " : " 已有正文解析结果供重复索引使用。 " } ] } )
db . add ( file )
db . commit ( )
doc = ingest ( db , course_version_id = 1 , project_id = item . id , submission_file_id = file . id , title = path . name ,
category = " stage3 " , source_hash = file . sha256 , pages = file . parsed [ " pages " ] , embedder = FLAT )
doc . index_version = INDEX_VERSION - 1
db . commit ( )
monkeypatch . setattr ( " wisegrade.submissions.parse_document " , lambda * a , * * k : pytest . fail ( " 不应重新解析 " ) )
assert reindex_stale ( db , 1 , embedder = FLAT ) [ " remaining " ] == 0
def test_file_lock_serializes_threads ( environment ) :
entered , waiting , acquired , release = Event ( ) , Event ( ) , Event ( ) , Event ( )
def first ( ) :
with file_index_lock ( 99 ) :
entered . set ( )
assert release . wait ( 2 )
def second ( ) :
assert entered . wait ( 2 )
waiting . set ( )
with file_index_lock ( 99 ) :
acquired . set ( )
return True
with ThreadPoolExecutor ( max_workers = 2 ) as pool :
one , two = pool . submit ( first ) , pool . submit ( second )
assert waiting . wait ( 2 )
try :
assert not acquired . wait ( .1 )
finally :
release . set ( )
one . result ( timeout = 5 )
assert two . result ( timeout = 5 )
def test_rule_api_and_batch_snapshot_preserve_object_targets ( sqlite_db , monkeypatch ) :
from fastapi . testclient import TestClient
from wisegrade import main
from wisegrade . db import get_db
db = sqlite_db
factory = sessionmaker ( bind = db . get_bind ( ) , expire_on_commit = False )
monkeypatch . setattr ( main , " SessionLocal " , factory )
def sessions ( ) :
with factory ( ) as session :
yield session
main . app . dependency_overrides [ get_db ] = sessions
target = { " target_id " : " measured " , " label " : " 实验数据 " , " use " : " content " , " allowed_page_types " : [ " body " ] }
try :
with TestClient ( main . app ) as client :
created = client . post ( " /api/course-versions/1/rubrics " , json = { " weights " : { " stage3 " : 1 } , " criteria " : [ {
" criterion_id " : " new.data " , " category " : " stage3 " , " name " : " 实验 " , " description " : " 实验依据 " ,
" max_score " : 100 , " required_evidence " : [ target ] ,
" score_levels " : [ { " min " : 0 , " max " : 100 , " description " : " 实验质量 " } ] } ] } )
assert created . status_code == 200 , created . text
rubric_id = created . json ( ) [ " id " ]
assert client . post ( f " /api/rubrics/ { rubric_id } /publish " ) . status_code == 200
batch = client . post ( " /api/scoring-batches " , json = { " course_version_id " : 1 , " rubric_version_id " : rubric_id } )
assert batch . status_code == 200
saved = db . get ( ScoringBatch , batch . json ( ) [ " id " ] )
assert saved . rubric_snapshot [ " criteria " ] [ 0 ] [ " required_evidence " ] [ 0 ] [ " target_id " ] == " measured "
assert saved . rubric_snapshot [ " evidence_plan " ] [ " criteria " ] [ " new.data " ] [ 0 ] [ " use " ] == " content "
finally :
main . app . dependency_overrides . clear ( )
def test_model_declining_score_records_original_gap ( sqlite_db ) :
db = sqlite_db
item = project ( db )
file = SubmissionFile ( project_id = item . id , category = " stage3 " , relative_path = " test.pdf " , mime_type = " application/pdf " ,
size = 1 , mtime = 1 , sha256 = " test " , recognition_status = " recognized " , parse_status = " ready " , parsed = { } )
db . add ( file )
db . commit ( )
doc = ingest ( db , course_version_id = 1 , project_id = item . id , submission_file_id = file . id , title = " test.pdf " ,
category = " stage3 " , source_hash = " test " , pages = [ { " page_number " : 1 , " text " : " 实验描述只列出装置,未给出可核对的测量数据。 " } ] , embedder = FLAT )
criterion = { " criterion_id " : " stage3.data " , " category " : " stage3 " , " max_score " : 20 ,
" description " : " 实验数据 " , " required_evidence " : [ " 实验数据 " ] }
data = services . create_batch ( SimpleNamespace ( course_version_id = 1 , rubric_version_id = 1 ) , db )
batch = db . get ( ScoringBatch , data [ " id " ] )
class Client :
def json_call ( self , * args , * * kwargs ) :
return ScoreResult ( criterion_id = " stage3.data " , score = None , max_score = 20 , reason = " 只有装置介绍 " ,
missing_evidence = [ " 实验数据 " ] , confidence = .2 )
def retriever ( session , * * kwargs ) :
if kwargs . get ( " public_only " ) :
return [ ]
kwargs [ " embedder " ] = FLAT
return retrieve ( session , * * kwargs )
score = _score_criterion ( db , batch , item , criterion , Client ( ) , retriever = retriever )
assert score . status == " needs_review " and score . model_score is None
assert score . evidence_gaps [ 0 ] [ " target_label " ] == " 实验数据 "
assert score . evidence_gaps [ 0 ] [ " reason " ] == " 只有装置介绍 "
def test_unrebuilt_course_document_does_not_complete_task ( sqlite_db ) :
db = sqlite_db
doc = KnowledgeDocument ( course_version_id = 1 , title = " 无页面资料 " , document_type = " course " ,
source_hash = " test " , index_version = INDEX_VERSION - 1 , status = " ready " )
task = IndexTask ( course_version_id = 1 , kind = " reindex " )
db . add_all ( [ doc , task ] )
db . commit ( )
factory = sessionmaker ( bind = db . get_bind ( ) , expire_on_commit = False )
run_index_task ( task . id , session_factory = factory , embedder = FLAT )
db . refresh ( task )
assert task . status == " failed " and task . result [ " remaining " ] == 1
def test_replacement_index_keeps_historical_evidence ( sqlite_db ) :
db = sqlite_db
item = project ( db )
old = document ( db , item , " 报告.pdf " )
created = services . create_batch ( SimpleNamespace ( course_version_id = 1 , rubric_version_id = 1 ) , db )
score = CriterionScore ( batch_id = created [ " id " ] , project_id = item . id , criterion_id = " stage3.data " , status = " scored " , model_score = 8 )
db . add ( score )
db . flush ( )
db . add ( ScoreEvidence ( score_id = score . id , document_id = old . id , page = 1 , quote = " 系统响应时间为100毫秒 " ) )
db . commit ( )
new = ingest ( db , course_version_id = 1 , project_id = item . id , submission_file_id = None , title = " 报告.pdf " ,
category = " stage3 " , source_hash = " changed " , pages = [ { " page_number " : 1 , " text " : " 新版本测得系统响应时间为80毫秒。 " } ] , embedder = FLAT )
assert new . id != old . id and old . status == " superseded "
assert db . get ( KnowledgePage , ( old . id , 1 ) ) . text == " 实际实验测得系统响应时间为100毫秒。 "
assert db . scalar ( select ( ScoreEvidence ) ) . document_id == old . id
assert all ( h [ " document_id " ] == new . id for h in retrieve ( db , course_version_id = 1 , project_id = item . id ,
query = " 响应时间 " , include_public = False , embedder = FLAT ) )
def test_three_line_table_is_bounded_and_code_listing_is_not_a_table ( tmp_path ) :
import pymupdf
from wisegrade . parsing import parse_document
pdf = pymupdf . open ( )
page = pdf . new_page ( )
for y in ( 200 , 225 , 290 ) :
page . draw_line ( ( 80 , y ) , ( 520 , y ) )
for x , label in ( ( 95 , " ID " ) , ( 230 , " Value " ) , ( 390 , " Result " ) ) :
page . insert_text ( ( x , 217 ) , label )
for y , values in ( ( 245 , ( " 1 " , " 85 " , " PASS " ) ) , ( 270 , ( " 2 " , " 178 " , " PASS " ) ) ) :
for x , value in zip ( ( 95 , 230 , 390 ) , values ) :
page . insert_text ( ( x , y ) , value )
page = pdf . new_page ( )
for y in ( 200 , 225 , 290 ) :
page . draw_line ( ( 80 , y ) , ( 520 , y ) )
page . insert_text ( ( 95 , 217 ) , " Listing 1: Sample code " )
page . insert_text ( ( 95 , 245 ) , " void process(void) { return; } " )
page . insert_text ( ( 95 , 270 ) , " // A second line of source code " )
path = tmp_path / " tables.pdf "
pdf . save ( path )
pdf . close ( )
pages = parse_document ( path , " final_report " , tmp_path / " assets " , ocr = False ) [ " pages " ]
assert len ( pages [ 0 ] [ " tables " ] ) == 1
values = str ( pages [ 0 ] [ " tables " ] [ 0 ] [ " rows " ] )
assert " 178 " in values and " PASS " in values
assert pages [ 0 ] [ " tables " ] [ 0 ] [ " warning " ]
assert pages [ 1 ] [ " tables " ] == [ ]
def test_mixed_citations_keep_block_type_when_page_is_toc ( sqlite_db , monkeypatch ) :
db = sqlite_db
item = project ( db )
document ( db , item , pages = [ { " page_number " : 1 , " text " : " 目录 \n 第1 章 系统设计.......... 1 \n "
" 第1 章 系统设计 \n 本系统通过视觉传感器获取目标位置并闭环控制电机。 " } ] )
def retriever ( session , * * kwargs ) :
kwargs [ " embedder " ] = FLAT
return retrieve ( session , * * kwargs )
monkeypatch . setattr ( agent_tools , " retrieve " , retriever )
result = agent_tools . _search ( db , 1 , item . id , " 视觉传感器 " )
assert result [ " found " ] and all ( c [ " page_type " ] == " body " for c in result [ " citations " ] )
def test_index_default_migration_replays_on_sqlite ( environment , monkeypatch ) :
from alembic import command
from alembic . config import Config
from sqlalchemy import inspect
backend = Path ( __file__ ) . resolve ( ) . parents [ 1 ]
url = f " sqlite:/// { environment / ' migration.sqlite3 ' } "
monkeypatch . setenv ( " DATABASE_URL " , url )
get_settings . cache_clear ( )
config = Config ( str ( backend / " alembic.ini " ) )
config . set_main_option ( " script_location " , str ( backend / " migrations " ) )
command . upgrade ( config , " head " )
command . downgrade ( config , " 0006 " )
command . upgrade ( config , " head " )
engine = create_engine ( url )
try :
columns = inspect ( engine ) . get_columns ( " knowledge_documents " )
default = next ( c [ " default " ] for c in columns if c [ " name " ] == " index_version " )
assert default . strip ( " ' " ) == str ( INDEX_VERSION )
finally :
engine . dispose ( )
get_settings . cache_clear ( )
def test_evidence_result_query_count_is_bounded ( sqlite_db ) :
from wisegrade . exports import project_result
db = sqlite_db
item = project ( db )
doc = document ( db , item )
created = services . create_batch ( SimpleNamespace ( course_version_id = 1 , rubric_version_id = 1 ) , db )
batch = db . get ( ScoringBatch , created [ " id " ] )
for criterion in batch . rubric_snapshot [ " criteria " ] :
score = CriterionScore ( batch_id = batch . id , project_id = item . id , criterion_id = criterion [ " criterion_id " ] , status = " scored " , model_score = 0 )
db . add ( score )
db . flush ( )
for _ in range ( 4 ) :
db . add ( ScoreEvidence ( score_id = score . id , document_id = doc . id , page = 1 , quote = " 响应时间为100毫秒 " ) )
db . commit ( )
queries = [ ]
def count ( _conn , _cursor , statement , _params , _context , _many ) :
if statement . lstrip ( ) . upper ( ) . startswith ( " SELECT " ) :
queries . append ( statement )
event . listen ( db . get_bind ( ) , " before_cursor_execute " , count )
try :
result = project_result ( db , batch , item )
finally :
event . remove ( db . get_bind ( ) , " before_cursor_execute " , count )
assert len ( result [ " rows " ] ) == len ( batch . rubric_snapshot [ " criteria " ] )
assert all ( len ( row [ " evidence " ] ) == 4 for row in result [ " rows " ] )
assert len ( queries ) < = 7
@pytest.mark.parametrize ( " kind " , [ " course " , " reindex " ] )
def test_open_index_task_is_reused_without_reenqueue ( sqlite_db , monkeypatch , kind ) :
from wisegrade . indexing import create_course_index_task , create_reindex_task
db = sqlite_db
task = IndexTask ( course_version_id = 1 , kind = kind , status = " running " )
db . add ( task )
db . commit ( )
monkeypatch . setattr ( services , " enqueue_index_task " , lambda * args : pytest . fail ( " 不能重复提交运行中的任务 " ) )
create = create_course_index_task if kind == " course " else create_reindex_task
assert create ( db , 1 ) . id == task . id
def test_reimport_reuses_running_file_task ( sqlite_db , environment , monkeypatch ) :
db = sqlite_db
item = project ( db )
folder = environment / " inbox " / " test "
folder . mkdir ( )
path = folder / " report.pdf "
path . write_bytes ( b " unchanged " )
file = SubmissionFile ( project_id = item . id , category = " stage3 " , relative_path = " test/report.pdf " , mime_type = " application/pdf " ,
size = path . stat ( ) . st_size , mtime = path . stat ( ) . st_mtime , sha256 = sha256 ( path ) ,
recognition_status = " recognized " , parse_status = " pending " )
db . add ( file )
db . flush ( )
task = IndexTask ( course_version_id = 1 , submission_file_id = file . id , kind = " project " , status = " running " )
db . add ( task )
db . commit ( )
monkeypatch . setattr ( services , " enqueue_index_task " , lambda * args : pytest . fail ( " 不能重复提交运行中的任务 " ) )
payload = SimpleNamespace ( course_version_id = 1 , projects = [ SimpleNamespace ( relative_dir = " test " , student_no = " 9999900001 " ,
name = " 测试学生 " , topic = " 测试 " , direction = " " , files = [ SimpleNamespace ( relative_path = " test/report.pdf " , category = " stage3 " ,
sha256 = file . sha256 , status = " recognized " ) ] ) ] )
assert services . confirm_import ( payload , db ) [ " index_task_ids " ] == [ task . id ]
def test_injected_embedder_is_used_for_every_target ( sqlite_db ) :
db = sqlite_db
item = project ( db )
document ( db , item )
result = retrieve_targets ( db , course_version_id = 1 , project_id = item . id , targets = normalize_targets ( [ " 响应时间 " ] ) , embedder = FLAT )
assert result [ " evidence " ]
def test_unselected_file_is_excluded_even_if_document_is_ready ( sqlite_db ) :
db = sqlite_db
item = project ( db )
file = SubmissionFile ( project_id = item . id , category = " stage3 " , relative_path = " old.pdf " , mime_type = " application/pdf " ,
size = 1 , mtime = 1 , sha256 = " test " , recognition_status = " recognized " , parse_status = " ready " )
db . add ( file )
db . commit ( )
ingest ( db , course_version_id = 1 , project_id = item . id , submission_file_id = file . id , title = " old.pdf " , category = " stage3 " ,
source_hash = " test " , pages = [ { " page_number " : 1 , " text " : " 该旧材料记录了系统测试与响应时间的实验数据。 " } ] , embedder = FLAT )
assert retrieve ( db , course_version_id = 1 , project_id = item . id , query = " 响应时间 " , embedder = FLAT , include_public = False )
file . recognition_status = " ignored "
db . commit ( )
assert retrieve ( db , course_version_id = 1 , project_id = item . id , query = " 响应时间 " , embedder = FLAT , include_public = False ) == [ ]
def test_valid_target_cannot_be_attached_to_another_targets_chunk ( ) :
criterion = { " criterion_id " : " x " , " max_score " : 10 , " required_evidence " : [ " 实验数据 " , " 实现方法 " ] }
chunk = { " chunk_id " : 1 , " page " : 1 , " page_type " : " body " , " text " : " 采用视觉传感器实现位置反馈。 " , " target_ids " : [ " t1 " ] }
result = ScoreResult ( criterion_id = " x " , score = 8 , max_score = 10 , reason = " 错误关联 " , confidence = .8 ,
evidence = [ { " chunk_id " : 1 , " page " : 1 , " quote " : chunk [ " text " ] , " target_id " : " t0 " } ] )
with pytest . raises ( ModelError , match = " 检索目标 " ) :
validate_score ( result , criterion , [ chunk ] )
@pytest.mark.parametrize ( " version " , [ None , 1 ] )
def test_worker_rejects_old_policy_without_touching_scores ( sqlite_db , version ) :
from wisegrade . scoring import run_batch
db = sqlite_db
item = project ( db )
created = services . create_batch ( SimpleNamespace ( course_version_id = 1 , rubric_version_id = 1 ) , db )
batch = db . get ( ScoringBatch , created [ " id " ] )
snapshot = dict ( batch . rubric_snapshot )
snapshot [ " evidence_plan " ] = { } if version is None else { " version " : version }
batch . rubric_snapshot = snapshot
score = CriterionScore ( batch_id = batch . id , project_id = item . id , criterion_id = " stage3.data " , teacher_score = 0 , status = " reviewed " )
db . add ( score )
db . commit ( )
factory = sessionmaker ( bind = db . get_bind ( ) , expire_on_commit = False )
result = run_batch ( batch . id , session_factory = factory , client_factory = lambda * args : pytest . fail ( " 旧政策不应调用模型 " ) )
assert result [ " status " ] == " failed "
db . refresh ( score )
assert score . teacher_score == 0 and score . status == " reviewed "
def test_reindex_preserves_original_pages_chunks_and_citations ( sqlite_db ) :
db = sqlite_db
old = document ( db , None , " 课程资料历史 " )
old . index_version = INDEX_VERSION - 1
chunk = db . scalar ( select ( KnowledgeChunk ) . where ( KnowledgeChunk . document_id == old . id ) )
chunk_id , original_text = chunk . id , chunk . text
original_page = db . get ( KnowledgePage , ( old . id , 1 ) ) . text
db . commit ( )
result = reindex_stale ( db , 1 , embedder = FLAT )
db . refresh ( old )
assert old . status == " superseded "
assert db . get ( KnowledgeChunk , chunk_id ) . text == original_text
assert db . get ( KnowledgePage , ( old . id , 1 ) ) . text == original_page
current = db . scalar ( select ( KnowledgeDocument ) . where ( KnowledgeDocument . title == old . title ,
KnowledgeDocument . status == " ready " ) )
assert current . id != old . id and current . source_hash == old . source_hash
assert result [ " rebuilt " ] [ 0 ] [ " replacement_document_id " ] == current . id
assert result [ " remaining " ] == 0
def test_reverting_same_file_hash_creates_a_new_revision ( sqlite_db ) :
db = sqlite_db
item = project ( db )
file = SubmissionFile ( project_id = item . id , category = " stage3 " , relative_path = " report.pdf " ,
mime_type = " application/pdf " , size = 1 , mtime = 1 , sha256 = " hash-a " ,
recognition_status = " recognized " , parse_status = " ready " )
db . add ( file )
db . commit ( )
def insert ( source_hash , category = " stage3 " ) :
return ingest ( db , course_version_id = 1 , project_id = item . id , submission_file_id = file . id ,
title = " report.pdf " , category = category , source_hash = source_hash ,
pages = [ { " page_number " : 1 , " text " : " 原始实验记录包含响应时间与可核对的数据。 " } ] , embedder = FLAT )
first = insert ( " hash-a " )
original_chunk_id = db . scalar ( select ( KnowledgeChunk . id ) . where ( KnowledgeChunk . document_id == first . id ) )
second = insert ( " hash-b " )
restored = insert ( " hash-a " , " final_report " )
db . refresh ( first )
db . refresh ( second )
assert restored . id not in ( first . id , second . id )
assert first . status == second . status == " superseded "
assert restored . status == " ready " and restored . document_type == " final_report "
assert db . get ( KnowledgeChunk , original_chunk_id ) is not None
assert first . document_type == " stage3 "
assert restored . revision > first . revision
assert insert ( " hash-a " , " final_report " ) . id == restored . id
def test_index_task_rolls_back_failed_database_transaction ( sqlite_db , monkeypatch ) :
db = sqlite_db
item = project ( db )
file = SubmissionFile ( project_id = item . id , category = " stage3 " , relative_path = " test.pdf " , mime_type = " application/pdf " ,
size = 1 , mtime = 1 , sha256 = " test " , recognition_status = " recognized " , parse_status = " pending " )
db . add ( file )
db . flush ( )
task = IndexTask ( course_version_id = 1 , submission_file_id = file . id , kind = " project " , status = " queued " )
db . add ( task )
db . commit ( )
def broken_write ( session , * args , * * kwargs ) :
session . add ( KnowledgeDocument ( course_version_id = 999999 , title = " 无效外键 " , document_type = " stage3 " , source_hash = " test " ) )
session . flush ( )
monkeypatch . setattr ( " wisegrade.indexing.parse_submission " , broken_write )
factory = sessionmaker ( bind = db . get_bind ( ) , expire_on_commit = False )
run_index_task ( task . id , session_factory = factory )
db . refresh ( task )
assert task . status == " failed " and " FOREIGN KEY " in task . error
assert db . scalar ( select ( KnowledgeDocument ) . where ( KnowledgeDocument . title == " 无效外键 " ) ) is None
def test_course_index_continues_after_a_database_write_failure ( sqlite_db , environment , monkeypatch ) :
from wisegrade . models import CourseVersion
db = sqlite_db
version = db . get ( CourseVersion , 1 )
version . templates = [ " broken.pdf " , " good.pdf " ]
db . commit ( )
root = environment / " course-docs "
root . mkdir ( )
for filename in version . templates :
( root / filename ) . write_bytes ( b " test " )
monkeypatch . setattr ( services , " Path " , lambda value : root if value == " /app/course-docs " else Path ( value ) )
monkeypatch . setattr ( services , " parse_document " , lambda * args : { " pages " : [ { " page_number " : 1 ,
" text " : " 课程说明包含实验任务和可核对的评价指标。 " } ] } )
def ingest_with_failure ( session , * * kwargs ) :
if kwargs [ " title " ] == " broken.pdf " :
session . add ( KnowledgeDocument ( course_version_id = 999999 , title = " 无效课程 " , document_type = " course " , source_hash = " test " ) )
session . flush ( )
return ingest ( session , * * kwargs , embedder = FLAT )
monkeypatch . setattr ( services , " ingest " , ingest_with_failure )
result = services . ingest_course ( 1 , db )
assert set ( result [ " errors " ] ) == { " broken.pdf " }
assert " good.pdf " in result [ " completed " ]
assert " 已发布评分规则 " in result [ " completed " ]
assert db . scalar ( select ( KnowledgeDocument ) . where ( KnowledgeDocument . title == " 无效课程 " ) ) is None
def test_retired_score_and_chat_citations_are_labelled_without_rewriting ( sqlite_db ) :
from wisegrade . exports import project_result
from wisegrade . models import ChatMessage
db = sqlite_db
item = project ( db )
old = document ( db , item , " 历史材料.pdf " )
created = services . create_batch ( SimpleNamespace ( course_version_id = 1 , rubric_version_id = 1 ) , db )
batch = db . get ( ScoringBatch , created [ " id " ] )
criterion = batch . rubric_snapshot [ " criteria " ] [ 0 ] [ " criterion_id " ]
score = CriterionScore ( batch_id = batch . id , project_id = item . id , criterion_id = criterion , status = " scored " , model_score = 8 )
db . add ( score )
db . flush ( )
db . add ( ScoreEvidence ( score_id = score . id , document_id = old . id , page = 1 , quote = " 响应时间为100毫秒 " ,
page_type = " body " , source_role = " content " , historical = False ) )
conversation = Conversation ( title = " 历史引用 " , course_version_id = 1 , project_id = item . id )
db . add ( conversation )
db . flush ( )
saved = { " document_id " : old . id , " page " : 1 , " historical " : False , " url " : f " /api/knowledge/documents/ { old . id } /pages/1 " }
message = ChatMessage ( conversation_id = conversation . id , role = " assistant " , content = " 原始回答 " , citations = [ saved ] )
db . add ( message )
db . commit ( )
ingest ( db , course_version_id = 1 , project_id = item . id , submission_file_id = None , title = old . title ,
category = " stage3 " , source_hash = " new-version " , pages = [ { " page_number " : 1 , " text " : " 新实验测得响应时间为80毫秒。 " } ] , embedder = FLAT )
result = project_result ( db , batch , item )
assert result [ " rows " ] [ 0 ] [ " evidence " ] [ 0 ] [ " historical " ] is True
detail = chat_api . _detail ( db , conversation )
assert detail [ " messages " ] [ 0 ] [ " citations " ] [ 0 ] [ " historical " ] is True
assert detail [ " messages " ] [ 0 ] [ " citations " ] [ 0 ] [ " url " ] == saved [ " url " ]
db . refresh ( message )
assert message . citations == [ saved ]
def test_document_revision_migration_preserves_legacy_rows_and_blocks_lossy_downgrade ( environment , monkeypatch ) :
from alembic import command
from alembic . config import Config
from sqlalchemy import inspect , text
backend = Path ( __file__ ) . resolve ( ) . parents [ 1 ]
url = f " sqlite:/// { environment / ' legacy-revisions.sqlite3 ' } "
monkeypatch . setenv ( " DATABASE_URL " , url )
get_settings . cache_clear ( )
config = Config ( str ( backend / " alembic.ini " ) )
config . set_main_option ( " script_location " , str ( backend / " migrations " ) )
engine = create_engine ( url )
try :
with engine . begin ( ) as connection :
connection . execute ( text ( """ CREATE TABLE knowledge_documents (
id INTEGER PRIMARY KEY, course_version_id INTEGER NOT NULL, project_id INTEGER,
submission_file_id INTEGER, title VARCHAR(500), document_type VARCHAR(80),
source_hash VARCHAR(64) NOT NULL, index_version INTEGER NOT NULL DEFAULT 4,
status VARCHAR(40), UNIQUE(course_version_id, project_id, submission_file_id, source_hash)) """ ) )
connection . execute ( text ( " CREATE TABLE knowledge_pages (document_id INTEGER REFERENCES knowledge_documents(id), page INTEGER, text TEXT) " ) )
connection . execute ( text ( " INSERT INTO knowledge_documents VALUES (10, 1, 2, 3, ' legacy.pdf ' , ' stage3 ' , ' same-hash ' , 3, ' ready ' ) " ) )
connection . execute ( text ( " INSERT INTO knowledge_pages VALUES (10, 1, ' original citation text ' ) " ) )
command . stamp ( config , " 0007 " )
command . upgrade ( config , " 0008 " )
with engine . begin ( ) as connection :
assert connection . execute ( text ( " SELECT revision FROM knowledge_documents WHERE id=10 " ) ) . scalar_one ( ) == 1
assert connection . execute ( text ( " SELECT text FROM knowledge_pages WHERE document_id=10 " ) ) . scalar_one ( ) == " original citation text "
connection . execute ( text ( """ INSERT INTO knowledge_documents
(id, course_version_id, project_id, submission_file_id, title, document_type, source_hash, index_version, status, revision)
VALUES (11, 1, 2, 3, ' legacy.pdf ' , ' stage3 ' , ' same-hash ' , 4, ' ready ' , 2) """ ) )
with pytest . raises ( RuntimeError , match = " 不能无损降级 " ) :
command . downgrade ( config , " 0007 " )
with engine . connect ( ) as connection :
assert connection . execute ( text ( " SELECT COUNT(*) FROM knowledge_documents " ) ) . scalar_one ( ) == 2
assert connection . execute ( text ( " SELECT version_num FROM alembic_version " ) ) . scalar_one ( ) == " 0008 "
assert any ( c [ " name " ] == " revision " for c in inspect ( engine ) . get_columns ( " knowledge_documents " ) )
finally :
engine . dispose ( )
get_settings . cache_clear ( )
def test_failed_rebuild_keeps_previous_citation_sources_and_retryable_index ( sqlite_db ) :
db = sqlite_db
old = document ( db , None , " 失败后可重试 " )
old . index_version = INDEX_VERSION - 1
chunk_id = db . scalar ( select ( KnowledgeChunk . id ) . where ( KnowledgeChunk . document_id == old . id ) )
db . commit ( )
def fail_embedding ( texts ) :
raise RuntimeError ( " 测试嵌入失败 " )
failed = reindex_stale ( db , 1 , embedder = fail_embedding )
assert failed [ " remaining " ] == 1 and len ( failed [ " failed " ] ) == 1
db . refresh ( old )
assert old . status == " ready " and db . get ( KnowledgeChunk , chunk_id ) is not None
assert reindex_stale ( db , 1 , embedder = FLAT ) [ " remaining " ] == 0
assert db . get ( KnowledgeChunk , chunk_id ) is not None
def test_overlapping_public_reindex_jobs_create_only_one_current_revision ( sqlite_db , monkeypatch ) :
from contextlib import contextmanager
from threading import Barrier
from wisegrade import indexing
db = sqlite_db
old = document ( db , None , " 并发课程重建 " )
old . index_version = INDEX_VERSION - 1
db . commit ( )
factory = sessionmaker ( bind = db . get_bind ( ) , expire_on_commit = False )
barrier = Barrier ( 2 )
real_lock = indexing . file_index_lock
@contextmanager
def overlapping ( lock_id ) :
# Both jobs have selected the same stale snapshot before either acquires
# the real filesystem lock, reproducing an overlapping queue delivery.
barrier . wait ( timeout = 10 )
with real_lock ( lock_id ) :
yield
monkeypatch . setattr ( indexing , " file_index_lock " , overlapping )
def rebuild ( _ ) :
with factory ( ) as session :
return reindex_stale ( session , 1 , embedder = FLAT )
with ThreadPoolExecutor ( max_workers = 2 ) as pool :
results = list ( pool . map ( rebuild , range ( 2 ) ) )
assert all ( result [ " remaining " ] == 0 and not result [ " failed " ] for result in results )
states = db . scalars ( select ( KnowledgeDocument . status ) . where ( KnowledgeDocument . title == old . title ) ) . all ( )
assert len ( states ) == 2
assert states . count ( " ready " ) == 1
def test_knowledge_inventory_batches_chunk_counts_and_flags_failed_indexes ( sqlite_db ) :
from wisegrade . api import knowledge
db = sqlite_db
item = project ( db )
for number in range ( 25 ) :
document ( db , item , f " 材料- { number } .pdf " )
failed = KnowledgeDocument ( course_version_id = 1 , project_id = item . id , title = " 失败索引 " , document_type = " stage3 " ,
source_hash = " failed " , index_version = INDEX_VERSION , status = " failed " )
db . add ( failed )
db . commit ( )
queries = [ ]
def capture ( connection , cursor , statement , parameters , context , executemany ) :
queries . append ( statement )
event . listen ( db . get_bind ( ) , " before_cursor_execute " , capture )
try :
rows = knowledge ( 1 , item . id , db )
finally :
event . remove ( db . get_bind ( ) , " before_cursor_execute " , capture )
assert len ( queries ) < = 2
assert len ( rows ) == 26
assert next ( row for row in rows if row [ " id " ] == failed . id ) [ " requires_reindex " ] is True
assert all ( row [ " chunks " ] > 0 and row [ " revision " ] == 1 for row in rows if row [ " id " ] != failed . id )
@pytest.mark.parametrize ( " gap " , [ " " , " " * 50 , " \t " ] )
def test_undotted_contents_titles_are_navigation_for_every_alignment ( gap ) :
from wisegrade . rag import build_chunks
pages = [ { " page_number " : 1 , " text " : f " 目录 \n 1 装置设计 { gap } 3 \n 2 控制策略 { gap } 4 \n 3 验证结果 { gap } 5 " } ,
{ " page_number " : 2 , " text " : " 1 装置设计 \n 本装置采用位置传感器完成电机闭环反馈,实验记录响应时间。 " } ]
classification = classify_document ( pages )
assert classification [ 0 ] [ " page_type " ] == " toc " and classification [ 0 ] [ " mixed " ] is False
chunks = build_chunks ( pages )
assert all ( chunk [ " page_type " ] == " toc " for chunk in chunks if chunk [ " page " ] == 1 )
assert any ( chunk [ " page_type " ] == " body " for chunk in chunks if chunk [ " page " ] == 2 )
def test_undotted_contents_context_stops_when_actual_body_begins ( ) :
pages = [ { " page_number " : 1 , " text " : " 目录 \n 1 装置设计 3 \n 2 控制策略 4 \n "
" 1 装置设计 \n 本装置通过位置传感器反馈电机状态,测量数据如下。 \n 1 供电电压 3 " } ]
classified = classify_document ( pages ) [ 0 ]
assert classified [ " page_type " ] == " toc " and classified [ " mixed " ] is True
assert any ( " 供电电压 " in block [ " text " ] and block [ " kind " ] == " body " for block in classified [ " blocks " ] )