完善导入确认与评分复核流程,修复证据检索和重试 #2

Open
aerospaceboy wants to merge 17 commits from aerospaceboy/WiseGrade:fix/import-confirmation into main
19 changed files with 759 additions and 38 deletions
+7 -1
View File
@@ -136,7 +136,7 @@ def test_scoring_uses_the_same_page_image_as_its_saved_evidence(db, environment)
sent_images.extend(images or []) sent_images.extend(images or [])
return ScoreResult(criterion_id=criterion["criterion_id"], score=1, return ScoreResult(criterion_id=criterion["criterion_id"], score=1,
max_score=criterion["max_score"], reason="测试评分", max_score=criterion["max_score"], reason="测试评分",
evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据"}], confidence=.9) evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据", "target_id": "t0"}], confidence=.9)
# The evidence page must be a substantive body page: a contents page cannot support # The evidence page must be a substantive body page: a contents page cannot support
# a content judgement, so the source page is classified accordingly during ingest. # a content judgement, so the source page is classified accordingly during ingest.
@@ -214,6 +214,12 @@ def test_duplicate_scan_requires_teacher_file_choice(environment, db):
generated_action = db.get(PendingAction, generated["pending_action"]["id"]) generated_action = db.get(PendingAction, generated["pending_action"]["id"])
assert generated_action.preview["requires_edit"] is True assert generated_action.preview["requires_edit"] is True
assert all(file["status"] == "ignored" for file in generated_action.payload["projects"][0]["files"]) assert all(file["status"] == "ignored" for file in generated_action.payload["projects"][0]["files"])
with pytest.raises(HTTPException) as blocked:
confirm(db, conversation, generated_action.id)
assert blocked.value.status_code == 409
with pytest.raises(HTTPException) as invalid_selection:
prepare(db, conversation, "import", generated_action.payload, teacher_edited=True)
assert invalid_selection.value.status_code == 400
proposed = prepare(db, conversation, "import", payload) proposed = prepare(db, conversation, "import", payload)
assert proposed.preview["requires_edit"] is True assert proposed.preview["requires_edit"] is True
with pytest.raises(HTTPException): with pytest.raises(HTTPException):
+2 -1
View File
@@ -388,7 +388,8 @@ def test_upgrade_from_legacy_0003_migrates_pages_and_citations(session_factory,
config.set_main_option('script_location', str(root / 'migrations')) config.set_main_option('script_location', str(root / 'migrations'))
command.upgrade(config, 'head') command.upgrade(config, 'head')
with session_factory() as db: with session_factory() as db:
assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0006' from alembic.script import ScriptDirectory
assert db.scalar(text('SELECT version_num FROM alembic_version')) == ScriptDirectory.from_config(config).get_current_head()
assert db.get(Conversation, conversation_id).title == '旧记录' assert db.get(Conversation, conversation_id).title == '旧记录'
assert db.scalar(select(ChatMessage.content).where(ChatMessage.conversation_id == conversation_id)) == '旧问题' assert db.scalar(select(ChatMessage.content).where(ChatMessage.conversation_id == conversation_id)) == '旧问题'
run = db.scalar(select(AgentRun).where(AgentRun.conversation_id == conversation_id)) run = db.scalar(select(AgentRun).where(AgentRun.conversation_id == conversation_id))
+2 -1
View File
@@ -428,13 +428,14 @@ def test_project_and_version_isolation_is_unchanged(db):
def test_batch_snapshot_records_the_evidence_policy(db): def test_batch_snapshot_records_the_evidence_policy(db):
from types import SimpleNamespace from types import SimpleNamespace
from wisegrade import services from wisegrade import services
from wisegrade.evidence import POLICY_VERSION
from wisegrade.models import ScoringBatch from wisegrade.models import ScoringBatch
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db) created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
assert created["id"] assert created["id"]
batch = db.get(ScoringBatch, created["id"]) batch = db.get(ScoringBatch, created["id"])
plan = batch.rubric_snapshot["evidence_plan"] plan = batch.rubric_snapshot["evidence_plan"]
assert plan["version"] == 1 assert plan["version"] == POLICY_VERSION
assert plan["content_allowed_page_types"] == ["body", "appendix"] assert plan["content_allowed_page_types"] == ["body", "appendix"]
targets = plan["criteria"]["stage3.data"] targets = plan["criteria"]["stage3.data"]
assert [target["use"] for target in targets] == ["content", "content"] assert [target["use"] for target in targets] == ["content", "content"]
@@ -24,6 +24,8 @@ def test_scan_selected_student_folder_prefills_cover_identity(tmp_path, monkeypa
assert project["status"] == "needs_confirmation" assert project["status"] == "needs_confirmation"
assert len(project["files"]) == 5 assert len(project["files"]) == 5
assert project["missing"] == [] assert project["missing"] == []
assert project["folder_identity"] == {"student_no": "", "name": "", "topic": ""}
assert all(file["detected_identity"]["student_no"] == "9000000001" for file in project["files"])
assert scan("test", root=tmp_path)["projects"] == result["projects"] assert scan("test", root=tmp_path)["projects"] == result["projects"]
@@ -39,6 +41,26 @@ def test_scan_conflicting_covers_need_confirmation(tmp_path, monkeypatch):
assert project["student_no"] == "" assert project["student_no"] == ""
assert project["warnings"] assert project["warnings"]
assert project["status"] == "needs_confirmation" assert project["status"] == "needs_confirmation"
assert "学号" in project["warnings"][0]
assert "student_no" not in project["warnings"][0]
assert {file["detected_identity"]["student_no"] for file in project["files"]} == {"9000000001", "9000000002"}
def test_scan_unreadable_cover_has_no_stale_identity(tmp_path, monkeypatch):
from wisegrade import storage
folder = tmp_path / "9000000001_学生甲_机器人系统"
folder.mkdir()
(folder / "阶段报告1.pdf").write_bytes(b"fixture")
(folder / "阶段报告2.pdf").write_bytes(b"broken")
def read(path):
if path.name.endswith("2.pdf"):
raise ValueError("损坏PDF")
return "学号:9000000001\n姓名:学生甲\n题目:机器人系统"
monkeypatch.setattr(storage, "first_text", read)
project = scan(".", root=tmp_path)["projects"][0]
assert project["folder_identity"]["student_no"] == "9000000001"
assert project["files"][0]["detected_identity"]["student_no"] == "9000000001"
assert project["files"][1]["detected_identity"] == {}
def test_scan_parent_skips_empty_folders(tmp_path, monkeypatch): def test_scan_parent_skips_empty_folders(tmp_path, monkeypatch):
+36
View File
@@ -0,0 +1,36 @@
import pytest
from pydantic import ValidationError
from wisegrade.api import ImportConfirm, ProjectConfirm
def project(**changes):
return {"relative_dir": "test/student", "student_no": "9000000001", "name": "学生甲", "topic": "机器人项目",
"files": [{"relative_path": "test/student/最终报告.pdf", "category": "final_report", "sha256": "a" * 64}], **changes}
def test_confirm_normalizes_identity_before_validating():
result = ProjectConfirm.model_validate(project(student_no=" 9000000001 ", name=" 学生甲 ", topic=" 机器人项目 "))
assert (result.student_no, result.name, result.topic) == ("9000000001", "学生甲", "机器人项目")
@pytest.mark.parametrize("changes", [
{"name": " "}, {"topic": " "}, {"student_no": "9000000001"}, {"files": []},
{"files": [{"relative_path": "test/student/a.pdf", "category": "unknown", "sha256": "a" * 64}]},
{"files": [{"relative_path": "test/student/a.pdf", "category": "final_report", "sha256": "a" * 64, "status": "ignored"}]},
{"files": [{"relative_path": f"test/student/{i}.pdf", "category": "final_report", "sha256": "a" * 64} for i in range(2)]},
])
def test_confirm_rejects_invalid_identity_or_selection(changes):
with pytest.raises(ValidationError):
ProjectConfirm.model_validate(project(**changes))
def test_empty_import_is_rejected():
with pytest.raises(ValidationError):
ImportConfirm(course_version_id=1, projects=[])
def test_ignored_unknown_material_does_not_block_valid_selection():
data = project()
data["files"].append({"relative_path": "test/student/foreign.pdf", "category": "unknown", "sha256": "b" * 64, "status": "ignored"})
assert len(ProjectConfirm.model_validate(data).files) == 2
+196 -1
View File
@@ -26,7 +26,7 @@ from wisegrade.pages import classify_document
from wisegrade.parsing import text_quality from wisegrade.parsing import text_quality
from wisegrade.rag import INDEX_VERSION, build_chunks, ingest, keyword_overlap, query_terms, retrieve, retrieve_targets from wisegrade.rag import INDEX_VERSION, build_chunks, ingest, keyword_overlap, query_terms, retrieve, retrieve_targets
from wisegrade.rules import Criterion, EvidenceTarget from wisegrade.rules import Criterion, EvidenceTarget
from wisegrade.scoring import _attach_page_types, _score_criterion from wisegrade.scoring import _attach_page_types, _body_navigator, _score_criterion
from wisegrade.seed import seed_initial from wisegrade.seed import seed_initial
from wisegrade.storage import extract_identity, sha256 from wisegrade.storage import extract_identity, sha256
from wisegrade.submissions import file_index_lock from wisegrade.submissions import file_index_lock
@@ -149,6 +149,38 @@ def test_shared_chunk_keeps_all_target_ids():
assert selection["evidence"][0]["target_ids"] == ["t0", "t1"] assert selection["evidence"][0]["target_ids"] == ["t0", "t1"]
def test_plan_navigation_finds_future_work_body_despite_nonempty_semantic_hits(sqlite_db):
db = sqlite_db
item = project(db)
doc = document(db, item, pages=[
{"page_number": 3, "text": "目录\n4.2 后续改进方向.......... 11\n4.3 参考文献.......... 12"},
{"page_number": 6, "text": "2.3 系统控制\n控制器通过闭环反馈调整电机转速,实现稳定的运动控制。"},
{"page_number": 14, "text": "4.2 后续改进方向\n后续将探索深度学习检测算法,提高复杂光照场景下的鲁棒性;"
"引入模型预测控制优化动态跟踪,并完善状态监控和数据回放功能。"},
])
noise = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == doc.id,
KnowledgeChunk.page == 6))
navigator = _body_navigator(db, course_version_id=1, project_id=item.id, category="stage3")
targets = normalize_targets(["计划", "章节"])
selected = retrieve_targets(db, course_version_id=1, project_id=item.id, category="stage3",
targets=targets, navigator=navigator, limit=2,
retrieve_fn=lambda *args, **kwargs: [{
"chunk_id": noise.id, "document_id": doc.id, "page": 6,
"page_type": "body", "text": noise.text, "rank": .03}])
assert any(hit["page"] == 14 and "t0" in hit["target_ids"] for hit in selected["evidence"])
assert all(hit["page_type"] == "body" for hit in selected["evidence"])
assert navigator({**targets[0], "allowed_page_types": ["appendix"]}) == []
def test_heading_navigation_supplements_existing_hits_for_custom_labels():
target = normalize_targets(["实验数据"])
raw = {"chunk_id": 1, "document_id": 1, "page": 2, "text": "仅描述装置。", "page_type": "body", "rank": .03}
body = {"chunk_id": 2, "document_id": 1, "page": 5, "text": "实测响应时间80毫秒。", "page_type": "body", "rank": .5}
result = retrieve_targets(None, course_version_id=1, project_id=1, targets=target, limit=1,
retrieve_fn=lambda *args, **kwargs: [raw], navigator=lambda _: [body])
assert result["evidence"][0]["chunk_id"] == 2
def test_document_filter_and_index_readiness_precede_limit(sqlite_db): def test_document_filter_and_index_readiness_precede_limit(sqlite_db):
db = sqlite_db db = sqlite_db
item = project(db) item = project(db)
@@ -341,6 +373,100 @@ def test_model_declining_score_records_original_gap(sqlite_db):
assert score.evidence_gaps[0]["reason"] == "只有装置介绍" assert score.evidence_gaps[0]["reason"] == "只有装置介绍"
@pytest.mark.parametrize("missing", [["误差分析(t1):缺少误差来源和统计数据"],
["尚缺 t1 对应的正文"], ["当前资料无法判断"]])
def test_declined_score_attributes_descriptive_missing_evidence_to_its_target(sqlite_db, missing):
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")
db.add(file)
db.commit()
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": "实验测得响应时间80毫秒,但未解释误差来源。"}], embedder=FLAT)
criterion = {"criterion_id": "stage3.data", "category": "stage3", "max_score": 20,
"description": "实验数据与误差分析", "required_evidence": ["实验数据", "误差分析"]}
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
class Client:
def json_call(self, *args, **kwargs):
return ScoreResult(criterion_id="stage3.data", score=None, max_score=20,
reason="误差分析尚不足以判断", missing_evidence=missing, confidence=.2)
def retriever(session, **kwargs):
if kwargs.get("public_only"):
return []
return retrieve(session, **{**kwargs, "embedder": FLAT})
score = _score_criterion(db, db.get(ScoringBatch, created["id"]), item, criterion, Client(), retriever=retriever)
expected = ["t0", "t1"] if missing == ["当前资料无法判断"] else ["t1"]
assert [gap["target_id"] for gap in score.evidence_gaps] == expected
assert score.status == "needs_review" and score.model_score is None
def test_failed_model_call_cannot_overwrite_a_concurrent_teacher_review(sqlite_db, monkeypatch):
from wisegrade import scoring
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"])
criterion = batch.rubric_snapshot["criteria"][0]
batch.rubric_snapshot = {**batch.rubric_snapshot, "criteria": [criterion]}
batch.status, batch.progress_total, batch.progress_done = "running", 1, 0
score = CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion["criterion_id"], status="pending")
db.add(score)
db.commit()
factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
def teacher_then_failure(*args, **kwargs):
with factory() as teacher_db:
services.review_score(score.id, SimpleNamespace(score=0, comment="教师核定零分", reason="已核对"), teacher_db)
raise ModelError("模型响应超时")
monkeypatch.setattr(scoring, "_project_facts", lambda *args, **kwargs: {})
monkeypatch.setattr(scoring, "_score_criterion", teacher_then_failure)
failures, reviews = scoring._run_project(batch.id, item.id, factory, lambda *args: None, FLAT, lambda *a, **k: [])
db.expire_all()
saved = db.get(CriterionScore, score.id)
assert saved.status == "reviewed"
assert saved.teacher_score == 0 and saved.teacher_comment == "教师核定零分"
assert saved.error == ""
assert failures == reviews == 0
assert db.get(ScoringBatch, batch.id).progress_done == 1
@pytest.mark.parametrize("complete", [True, False])
def test_retry_skips_facts_only_for_fully_graded_projects(sqlite_db, monkeypatch, complete):
from wisegrade import scoring
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"])
criteria = batch.rubric_snapshot["criteria"][:2]
batch.rubric_snapshot = {**batch.rubric_snapshot, "criteria": criteria}
batch.status, batch.progress_total, batch.progress_done = "running", 2, 0
item.facts = {"conclusions": ["已抽取并确认的事实"]}
db.add_all([
CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criteria[0]["criterion_id"], status="scored", model_score=0),
CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criteria[1]["criterion_id"],
status="reviewed" if complete else "needs_review", teacher_score=0 if complete else None),
])
db.commit()
calls = []
def client_factory(*args):
calls.append("client")
def facts(*args, **kwargs):
calls.append("facts")
return {}
def scored(session, batch, item, criterion, *args, **kwargs):
return session.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
CriterionScore.project_id == item.id, CriterionScore.criterion_id == criterion["criterion_id"]))
monkeypatch.setattr(scoring, "_project_facts", facts)
monkeypatch.setattr(scoring, "_score_criterion", scored)
factory = sessionmaker(bind=db.get_bind(), expire_on_commit=False)
failures, reviews = scoring._run_project(batch.id, item.id, factory, client_factory, FLAT, lambda *a, **k: [])
assert calls == ([] if complete else ["client", "facts"])
assert failures == 0 and reviews == (0 if complete else 1)
db.expire_all()
assert db.get(ScoringBatch, batch.id).progress_done == 2
assert db.get(Project, item.id).facts == {"conclusions": ["已抽取并确认的事实"]}
def test_unrebuilt_course_document_does_not_complete_task(sqlite_db): def test_unrebuilt_course_document_does_not_complete_task(sqlite_db):
db = sqlite_db db = sqlite_db
doc = KnowledgeDocument(course_version_id=1, title="无页面资料", document_type="course", doc = KnowledgeDocument(course_version_id=1, title="无页面资料", document_type="course",
@@ -532,6 +658,75 @@ def test_valid_target_cannot_be_attached_to_another_targets_chunk():
validate_score(result, criterion, [chunk]) validate_score(result, criterion, [chunk])
def test_legacy_missing_target_uses_unique_chunk_association():
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"]}])
assert validate_score(result, criterion, [chunk]).evidence[0].target_id == "t1"
def test_target_mismatch_feedback_identifies_the_legal_chunk_target():
criterion = {"criterion_id": "x", "max_score": 10, "required_evidence": ["实验数据", "实现方法"]}
chunk = {"chunk_id": 42, "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": 42, "page": 1, "quote": chunk["text"], "target_id": "t0"}])
with pytest.raises(ModelError) as failure:
validate_score(result, criterion, [chunk])
assert "chunk_id=42" in str(failure.value)
assert "允许 target_id=t1" in str(failure.value)
@pytest.mark.parametrize("rich", [False, True])
def test_missing_target_is_not_guessed_when_ambiguous_or_required(rich):
required = [{"label": "实验数据"}, {"label": "实现方法"}] if rich else ["实验数据", "实现方法"]
criterion = {"criterion_id": "x", "max_score": 10, "required_evidence": required}
chunk = {"chunk_id": 1, "page": 1, "page_type": "body", "text": "采用视觉传感器实现位置反馈。", "target_ids": ["t0", "t1"]}
result = ScoreResult(criterion_id="x", score=8, max_score=10, reason="需要明确目标", confidence=.8,
evidence=[{"chunk_id": 1, "page": 1, "quote": chunk["text"]}])
with pytest.raises(ModelError, match="缺少 target_id"):
validate_score(result, criterion, [chunk])
def test_scoring_retry_carries_target_contract_and_specific_repair_feedback(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")
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)
chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == doc.id))
criterion = {"criterion_id": "stage3.design", "category": "stage3", "max_score": 20,
"description": "实现方法", "required_evidence": ["实验数据", "实现方法"]}
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
batch = db.get(ScoringBatch, created["id"])
db.add(CriterionScore(batch_id=batch.id, project_id=item.id, criterion_id=criterion["criterion_id"],
status="needs_review", evidence_gaps=[{"target_id": "t1", "reason": "上次未命中正文"}]))
db.commit()
class Client:
calls = 0
def json_call(self, prompt, payload, *args, **kwargs):
self.calls += 1
assert "每条引文的 target_id 必须属于该片段的 target_ids" in prompt
if self.calls == 2:
assert payload["validation_feedback"]["error"].endswith("允许 target_id=t1")
return ScoreResult(criterion_id=criterion["criterion_id"], score=15, max_score=20,
reason="有实现方法", confidence=.8, evidence=[{
"chunk_id": chunk.id, "page": 1, "quote": chunk.text,
"target_id": "t0" if self.calls == 1 else "t1"}])
client = Client()
def selected(*args, **kwargs):
return {"evidence": [{"chunk_id": chunk.id, "document_id": doc.id, "page": 1,
"text": chunk.text, "page_type": "body", "target_ids": ["t1"]}],
"uncovered_targets": []}
score = _score_criterion(db, batch, item, criterion, client,
retriever=lambda *args, **kwargs: [], multi_retriever=selected)
assert score.status == "scored" and client.calls == 2
assert score.evidence_gaps == []
@pytest.mark.parametrize("version", [None, 1]) @pytest.mark.parametrize("version", [None, 1])
def test_worker_rejects_old_policy_without_touching_scores(sqlite_db, version): def test_worker_rejects_old_policy_without_touching_scores(sqlite_db, version):
from wisegrade.scoring import run_batch from wisegrade.scoring import run_batch
+9 -2
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException from fastapi import HTTPException
from pydantic import ValidationError
from sqlalchemy import select from sqlalchemy import select
from . import services from . import services
@@ -149,7 +150,12 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher
raise HTTPException(400, "整批 Excel 导出须在未限定学生项目的会话中准备") raise HTTPException(400, "整批 Excel 导出须在未限定学生项目的会话中准备")
if kind == "import": if kind == "import":
from .api import ImportConfirm from .api import ImportConfirm
data = ImportConfirm.model_validate(payload) try:
data = ImportConfirm.model_validate(payload, context={
"allow_unselected_import_draft": not teacher_edited})
except ValidationError as exc:
messages = [error["msg"] for error in exc.errors(include_input=False, include_context=False)]
raise HTTPException(400, "导入资料尚未满足确认条件:" + ";".join(messages)) from exc
payload = data.model_dump() payload = data.model_dump()
require_scope(db, conversation, version_id=data.course_version_id) require_scope(db, conversation, version_id=data.course_version_id)
if teacher_edited and any(item.student_no == "00000" or item.name == "待确认" or item.topic == "待确认" for item in data.projects): if teacher_edited and any(item.student_no == "00000" or item.name == "待确认" or item.topic == "待确认" for item in data.projects):
@@ -164,9 +170,10 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher
scanned.append(match) scanned.append(match)
if match["status"] != "recognized" or match["warnings"] or any(f["status"] != "recognized" for f in match["files"]): if match["status"] != "recognized" or match["warnings"] or any(f["status"] != "recognized" for f in match["files"]):
ambiguous.append(item.relative_dir) ambiguous.append(item.relative_dir)
unselected = any(not any(file.status != "ignored" for file in item.files) for item in data.projects)
preview = {"course_version_id": data.course_version_id, "projects": scanned, preview = {"course_version_id": data.course_version_id, "projects": scanned,
"selection": payload["projects"], "ambiguous": ambiguous, "selection": payload["projects"], "ambiguous": ambiguous,
"requires_edit": bool(ambiguous) and not teacher_edited} "requires_edit": (bool(ambiguous) or unselected) and not teacher_edited}
elif kind == "course_ingest": elif kind == "course_ingest":
require_scope(db, conversation, version_id=payload["course_version_id"]) require_scope(db, conversation, version_id=payload["course_version_id"])
preview = {"course_version_id": payload["course_version_id"], "templates": preview = {"course_version_id": payload["course_version_id"], "templates":
+25 -4
View File
@@ -3,7 +3,7 @@ from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field, ValidationInfo, field_validator, model_validator
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -67,16 +67,37 @@ class FileConfirm(BaseModel):
class ProjectConfirm(BaseModel): class ProjectConfirm(BaseModel):
relative_dir: str relative_dir: str
student_no: str = Field(pattern=r"^\d{5,20}$") student_no: str = Field(pattern=r"^[0-9]{5,20}$")
name: str = Field(min_length=1) name: str = Field(min_length=1)
topic: str = Field(min_length=1) topic: str = Field(min_length=1)
direction: str = "" direction: str = ""
files: list[FileConfirm] files: list[FileConfirm] = Field(min_length=1)
@field_validator("student_no", "name", "topic", "direction", mode="before")
@classmethod
def trim_identity(cls, value):
return value.strip() if isinstance(value, str) else value
@model_validator(mode="after")
def validate_selection(self, info: ValidationInfo):
selected = [file for file in self.files if file.status != "ignored"]
if not selected:
# Only the chat preview builder supplies this context. HTTP imports,
# edited previews and final execution always use strict validation.
if (info.context or {}).get("allow_unselected_import_draft"):
return self
raise ValueError("至少选择1份资料")
categories = [file.category for file in selected]
if any(category not in CATEGORIES for category in categories):
raise ValueError("请选择有效材料类型")
if len(set(categories)) != len(categories):
raise ValueError("每类材料只允许选择1份,其余请设为忽略")
return self
class ImportConfirm(BaseModel): class ImportConfirm(BaseModel):
course_version_id: int course_version_id: int
projects: list[ProjectConfirm] projects: list[ProjectConfirm] = Field(min_length=1)
class BatchInput(BaseModel): class BatchInput(BaseModel):
+9 -1
View File
@@ -238,11 +238,19 @@ def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict], *,
# Empty identifiers are accepted only for pre-object legacy rubrics. # Empty identifiers are accepted only for pre-object legacy rubrics.
if any(isinstance(value, dict) for value in criterion.get("required_evidence", [])): if any(isinstance(value, dict) for value in criterion.get("required_evidence", [])):
raise ModelError("模型证据缺少 target_id") raise ModelError("模型证据缺少 target_id")
associated = [by_target[key] for key in chunk.get("target_ids", []) if key in by_target]
if associated:
if len(associated) != 1:
raise ModelError(f"模型证据缺少 target_id:chunk_id={chunk['chunk_id']} 对应多个检索目标,请明确选择")
target = associated[0]
else:
target = default_content or (targets[0] if targets else None) target = default_content or (targets[0] if targets else None)
if target is None: if target is None:
raise ModelError("模型证据未关联任何必备证据目标") raise ModelError("模型证据未关联任何必备证据目标")
if chunk.get("target_ids") and target["target_id"] not in chunk["target_ids"]: if chunk.get("target_ids") and target["target_id"] not in chunk["target_ids"]:
raise ModelError("模型证据未关联该片段的检索目标") allowed = ",".join(str(key) for key in chunk["target_ids"] if key in by_target)
raise ModelError(f"模型证据未关联该片段的检索目标:chunk_id={chunk['chunk_id']},"
f"target_id={target['target_id']};允许 target_id={allowed}")
if not page_type_allowed(page_type, target["allowed_page_types"]): if not page_type_allowed(page_type, target["allowed_page_types"]):
raise ModelError(f"模型证据来自不允许的页面类型({page_type}),不能支持「{target['label']}」") raise ModelError(f"模型证据来自不允许的页面类型({page_type}),不能支持「{target['label']}」")
if target["use"] == USE_CONTENT and not content_evidence_allowed(page_type): if target["use"] == USE_CONTENT and not content_evidence_allowed(page_type):
+7 -2
View File
@@ -391,11 +391,16 @@ def retrieve_targets(db, *, course_version_id: int, project_id: int, targets: li
substantive_only=target.get("use") != "structure", substantive_only=target.get("use") != "structure",
semantic_pool=SEMANTIC_POOL, keyword_pool=KEYWORD_POOL) semantic_pool=SEMANTIC_POOL, keyword_pool=KEYWORD_POOL)
hits = [normalize_hit(hit) for hit in (raw_hits or [])] hits = [normalize_hit(hit) for hit in (raw_hits or [])]
if target.get("use") == "content" and not hits and navigator is not None: if target.get("use") == "content" and navigator is not None:
# The contents list may only be used to navigate: its headings are matched # The contents list may only be used to navigate: its headings are matched
# against headings that really occur in body text, and those sections and # against headings that really occur in body text, and those sections and
# their neighbouring pages are searched. The quotation still lands on body. # their neighbouring pages are searched. The quotation still lands on body.
hits = [dict(hit, via="toc_navigation") for hit in (navigator(target) or [])] # Semantic recall always returns neighbours, even for a short vague
# query. Supplement it with matching body sections rather than waiting
# for zero hits, and keep those sections ahead of incidental matches.
navigated = [normalize_hit(hit) for hit in (navigator(target) or [])]
seen_ids = {hit["chunk_id"] for hit in navigated}
hits = navigated + [hit for hit in hits if hit["chunk_id"] not in seen_ids]
per_target[target["target_id"]] = hits per_target[target["target_id"]] = hits
for hit in hits: for hit in hits:
if hit["chunk_id"] not in seen: if hit["chunk_id"] not in seen:
+41 -4
View File
@@ -1,4 +1,5 @@
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
import re
from sqlalchemy import and_, or_, select, update from sqlalchemy import and_, or_, select, update
@@ -97,14 +98,22 @@ def _body_navigator(db, *, course_version_id, project_id, category):
return None return None
def navigate(target): def navigate(target):
if "body" not in target.get("allowed_page_types", ["body"]):
return []
label = normalize_heading(target.get("label") or "") label = normalize_heading(target.get("label") or "")
if not label: if not label:
return [] return []
# A rubric often says simply "计划", while the actual section is future
# work. Expand only this concept; headings still have to exist in body text.
labels = [label]
plan_terms = ("计划", "后续改进", "后续工作", "未来工作", "下一阶段", "下一步")
if any(term in label for term in plan_terms):
labels.extend(plan_terms)
candidates = [] candidates = []
for heading, places in headings.items(): for heading, places in headings.items():
if not heading: if not heading:
continue continue
if heading.startswith(label) or label.startswith(heading) or label in heading or heading in label: if any(term in heading or heading in term for term in labels):
candidates.extend(places) candidates.extend(places)
if not candidates: if not candidates:
return [] return []
@@ -221,6 +230,8 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_
prompt = (batch.course_snapshot["prompts"]["score"] + prompt = (batch.course_snapshot["prompts"]["score"] +
" 证据quote必须从所引用chunk_id的text逐字复制连续片段,保留原文标点和换行;page必须等于该片段的page;" " 证据quote必须从所引用chunk_id的text逐字复制连续片段,保留原文标点和换行;page必须等于该片段的page;"
"target_id必须取自required_evidence_targets。不要改写、缩写或拼接引文。" "target_id必须取自required_evidence_targets。不要改写、缩写或拼接引文。"
"每条引文的 target_id 必须属于该片段的 target_ids;同一片段可对应多个目标,"
"请按实际内容选择,不要按证据列表顺序分配编号。"
"不得把目录、摘要或章节标题当作内容证据;无法据此判断内容时把score设为null。") "不得把目录、摘要或章节标题当作内容证据;无法据此判断内容时把score设为null。")
error = "" error = ""
result = None result = None
@@ -233,7 +244,11 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_
error = str(exc) error = str(exc)
if attempt: if attempt:
raise raise
prompt += f" 上次回答未通过校验({error});请改用允许页面类型中的实质正文片段,并逐字复制quote。" payload["validation_feedback"] = {
"error": error,
"instruction": "根据具体错误修正输出;引用目标只能从对应片段的 target_ids 选择,"
"quote须为该片段原文。没有足够依据时保留空分,不要编造引用。"}
prompt += " 上次回答未通过校验;请按 validation_feedback 修正,不要重复错误引用。"
score = db.scalar(select(CriterionScore).where(CriterionScore.id == score.id) score = db.scalar(select(CriterionScore).where(CriterionScore.id == score.id)
.with_for_update().execution_options(populate_existing=True)) .with_for_update().execution_options(populate_existing=True))
if score.status == STATUS_REVIEWED or score.teacher_score is not None: if score.status == STATUS_REVIEWED or score.teacher_score is not None:
@@ -244,13 +259,19 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_
if result.score is None: if result.score is None:
# The retrieval found body pages but the model judged them insufficient. # The retrieval found body pages but the model judged them insufficient.
score.status, score.error = STATUS_NEEDS_REVIEW, "模型认为现有正文证据不足以判断该项,转教师复核。" score.status, score.error = STATUS_NEEDS_REVIEW, "模型认为现有正文证据不足以判断该项,转教师复核。"
# Models normally write "误差分析(t1):缺少…", not just the bare
# label. Attribute those explanations to their target instead of marking
# every target as missing. Unknown explanations remain conservative.
declined = [target for target in targets if target["use"] == USE_CONTENT and declined = [target for target in targets if target["use"] == USE_CONTENT and
(not result.missing_evidence or target["label"] in result.missing_evidence)] any(target["label"] in note or re.search(
r"(?<![A-Za-z0-9_])" + re.escape(target["target_id"]) + r"(?![A-Za-z0-9_])", note)
for note in result.missing_evidence)]
_record_evidence_gaps(score, declined or [target for target in targets if target["use"] == USE_CONTENT]) _record_evidence_gaps(score, declined or [target for target in targets if target["use"] == USE_CONTENT])
for gap in score.evidence_gaps: for gap in score.evidence_gaps:
gap["reason"] = result.reason gap["reason"] = result.reason
else: else:
score.status, score.error = STATUS_SCORED, "" score.status, score.error = STATUS_SCORED, ""
score.evidence_gaps = []
for evidence in db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all(): for evidence in db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all():
db.delete(evidence) db.delete(evidence)
by_id = {chunk["chunk_id"]: chunk for chunk in chunks} by_id = {chunk["chunk_id"]: chunk for chunk in chunks}
@@ -343,6 +364,18 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
with session_factory() as db: with session_factory() as db:
batch = db.get(ScoringBatch, batch_id) batch = db.get(ScoringBatch, batch_id)
project = db.get(Project, project_id) project = db.get(Project, project_id)
existing = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch_id,
CriterionScore.project_id == project_id)).all()
graded = {score.criterion_id for score in existing
if score.status in (STATUS_SCORED, STATUS_REVIEWED)}
criteria = batch.rubric_snapshot["criteria"]
if all(criterion["criterion_id"] in graded for criterion in criteria):
# A retry in a multi-student batch must not spend two more model calls
# extracting facts for a student whose scores will all be skipped.
db.execute(update(ScoringBatch).where(ScoringBatch.id == batch_id).values(
progress_done=ScoringBatch.progress_done + len(criteria)))
db.commit()
return 0, 0
try: try:
client = client_factory(db, batch) client = client_factory(db, batch)
except Exception: except Exception:
@@ -372,10 +405,14 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
except Exception as exc: except Exception as exc:
db.rollback() db.rollback()
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id, score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"])) CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"])
.with_for_update().execution_options(populate_existing=True))
if not score: if not score:
score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"]) score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"])
db.add(score) db.add(score)
# A teacher may have committed a decision while the request was in
# flight. The exception path must honour it just like success does.
if score.status != STATUS_REVIEWED and score.teacher_score is None:
score.status, score.error = STATUS_FAILED, str(exc)[:500] score.status, score.error = STATUS_FAILED, str(exc)[:500]
db.commit() db.commit()
failures += 1 failures += 1
+7 -2
View File
@@ -11,6 +11,7 @@ from .config import get_settings
CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation") CATEGORIES = ("stage1", "stage2", "stage3", "final_report", "presentation")
SUPPORTED = {".pdf", ".docx", ".pptx"} SUPPORTED = {".pdf", ".docx", ".pptx"}
NAMES = {"stage1": "阶段报告1", "stage2": "阶段报告2", "stage3": "阶段报告3", "final_report": "最终报告", "presentation": "汇报PPT"} NAMES = {"stage1": "阶段报告1", "stage2": "阶段报告2", "stage3": "阶段报告3", "final_report": "最终报告", "presentation": "汇报PPT"}
IDENTITY_LABELS = {"student_no": "学号", "name": "姓名", "topic": "选题"}
def secure_path(relative: str, *, root: Path | None = None, must_exist=True) -> Path: def secure_path(relative: str, *, root: Path | None = None, must_exist=True) -> Path:
@@ -114,6 +115,7 @@ def scan(relative: str, *, root: Path | None = None):
if item.is_dir() and not item.is_symlink() and has_materials(item)] if item.is_dir() and not item.is_symlink() and has_materials(item)]
for folder in folders: for folder in folders:
info = parse_student_folder(folder.name) info = parse_student_folder(folder.name)
folder_identity = {key: info[key] for key in IDENTITY_LABELS}
files = [] files = []
extracted = {} extracted = {}
identity_values = {} identity_values = {}
@@ -124,6 +126,7 @@ def scan(relative: str, *, root: Path | None = None):
if candidate.suffix.lower() not in SUPPORTED: if candidate.suffix.lower() not in SUPPORTED:
continue continue
error = "" error = ""
identity = {}
try: try:
text = first_text(candidate) text = first_text(candidate)
category, status = classify(candidate, text) category, status = classify(candidate, text)
@@ -137,6 +140,7 @@ def scan(relative: str, *, root: Path | None = None):
files.append({"relative_path": candidate.relative_to(base).as_posix(), "category": category, "status": status, files.append({"relative_path": candidate.relative_to(base).as_posix(), "category": category, "status": status,
"size": stat.st_size, "mtime": stat.st_mtime, "sha256": sha256(candidate), "size": stat.st_size, "mtime": stat.st_mtime, "sha256": sha256(candidate),
"mime_type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream", "error": error, "mime_type": mimetypes.guess_type(candidate.name)[0] or "application/octet-stream", "error": error,
"detected_identity": identity,
"first_text": text[:500] if not error else ""}) "first_text": text[:500] if not error else ""})
for category in CATEGORIES: for category in CATEGORIES:
matches = [file for file in files if file["category"] == category] matches = [file for file in files if file["category"] == category]
@@ -145,17 +149,18 @@ def scan(relative: str, *, root: Path | None = None):
file["status"] = "duplicate" file["status"] = "duplicate"
for key, values in identity_values.items(): for key, values in identity_values.items():
if len(values) > 1: if len(values) > 1:
warnings.append(f"不同材料封面的{key}不一致,请核对后填写") warnings.append(f"不同材料封面的{IDENTITY_LABELS[key]}不一致,请核对后填写")
else: else:
extracted[key] = next(iter(values)) extracted[key] = next(iter(values))
for key, value in extracted.items(): for key, value in extracted.items():
if info.get(key) and value != info[key]: if info.get(key) and value != info[key]:
warnings.append(f"封面{key}“{value}”与目录名“{info[key]}”不一致") warnings.append(f"封面{IDENTITY_LABELS[key]}“{value}”与目录名“{info[key]}”不一致")
elif not info.get(key): elif not info.get(key):
info[key] = value info[key] = value
if warnings: if warnings:
info["status"] = "needs_confirmation" info["status"] = "needs_confirmation"
info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files, info.update({"relative_dir": folder.relative_to(base).as_posix(), "files": files,
"folder_identity": folder_identity,
"detected_identity": extracted, "warnings": warnings, "detected_identity": extracted, "warnings": warnings,
"missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]}) "missing": [category for category in CATEGORIES if not any(file["category"] == category for file in files)]})
projects.append(info) projects.append(info)
+12
View File
@@ -25,3 +25,15 @@
`test/示例/汇报PPT.pdf` 首页实际显示“课程导学/自动化专业综合实践”。应在评分前确认它是否属于学生汇报稿,而不是课程说明。本次只导入和索引该示例资料包,未创建或启动它的新评分批次。 `test/示例/汇报PPT.pdf` 首页实际显示“课程导学/自动化专业综合实践”。应在评分前确认它是否属于学生汇报稿,而不是课程说明。本次只导入和索引该示例资料包,未创建或启动它的新评分批次。
运行证据保存在工作区 `.wisegrade-testing/docker-preview/`,包括 `import-model-live.json`、`import-test-sample-2026-10-14.json`、浏览器截图和构建/部署日志。实际入库与批次重试的最终状态见该目录 `import-model-final-2026-10-14.json`。 运行证据保存在工作区 `.wisegrade-testing/docker-preview/`,包括 `import-model-live.json`、`import-test-sample-2026-10-14.json`、浏览器截图和构建/部署日志。实际入库与批次重试的最终状态见该目录 `import-model-final-2026-10-14.json`。
## 后续:确认导入的校验与封面来源
按钮被禁用的原因原来隐藏在网页布尔表达式中;扫描只返回合并后的身份,冲突时清空字段,但未提供逐文件来源,忽略文件后也不重新计算。这使用户无法从页面判断如何完成合法导入。
- `d7bd152`(2 个文件):扫描返回每份文件的封面身份和原目录身份,警告使用中文字段名;读取失败的文件不沿用上一份身份。
- `b24930b`(3 个文件):显示具体阻碍原因和逐文件身份;只根据当前选中文件重算身份和冲突,忽略异项目文件后可重新预填。保留人工修改,提供“使用所选资料的封面信息”入口。材料类别未知、同类多份或没有选中材料时直接提示。
- `36bf473`(2 个文件):API 在写入前统一去除身份字段首尾空白,要求 5–20 位 ASCII 数字学号,拒绝空白姓名/选题、空项目、全部忽略及无效/重复材料类型。忽略的未知类型文件不阻碍合法选择。
- 后端隔离测试 **74 passed**,网页测试 **17 passed**,TypeScript/Vite 构建通过;新增用例在修复前失败。API、网页和空闲 worker 已更新,本轮没有新增数据库迁移。
- 实际网页无接口模拟验证:输入 `1` 显示学号规则;忽略异项目 PPT 并采用剩余报告封面信息后,身份冲突清除且确认按钮可用。没有提交真实导入或调用评分模型。空项目 API 请求返回 422。证据见工作区 `docker-preview/import-root-live.json` 和对应截图。
本轮修复不放宽学号格式要求,也不把不同学生的材料自动改成同一身份。已有项目若先前被人工填写为测试身份,需要核对所选资料并重新确认导入才能更新数据库中的身份。
+46
View File
@@ -0,0 +1,46 @@
# 评分待复核与引用校验修复(2026-10-17)
## 触发场景与根因
- 批次 #8 的 `stage3.plan` 目标为“计划”。实际阶段报告3的 PDF 第14页有“4.2 后续改进方向”正文,但旧检索选出的14个片段没有这一页。短查询仍会得到语义近邻,导致原先仅在零命中时启用的章节导航不执行;“计划”与“后续改进方向”也没有按同一概念匹配。
- `stage2.experiment` 记录的错误为“模型证据未关联该片段的检索目标”。模型提示只要求使用规则中的目标编号,没有明确要求同时属于片段的 `target_ids`;重试也未指出具体可用的片段与目标关联。此外,旧字符串规则中省略编号时会错误地默认绑定第一个内容目标。
- 页面将所有 `failed` 统一标成“模型调用失败”,重试提示也一律指向网络;批次进度只显示待复核数量,没有显示失败数量。100% 表示处理结束,并不表示所有评分都成功。
## 修复
- 内容检索始终补充匹配的真实正文章节,兼容计划/后续改进/后续工作等表述,保留14片段预算、项目/材料范围、正文许可与引用校验。
- 只有旧规则省略编号且片段唯一关联某个目标时才补全;多目标歧义、显式错误编号、目录冒充内容证据仍然拒绝。重试带具体校验错误及允许编号,不通过忽略错误来获得分数。
- 成功重试清理旧证据缺口;教师复核记录仍按原逻辑保护。
- 页面区分输出校验失败、连接失败和一般评分失败,分别显示待复核/失败/材料异常数量,并解释处理完成不等于全部已评分。
## 验证
- 新增/扩展的导航、目标关联、重试反馈、旧缺口清理和页面测试均先观察到失败,再验证修复后通过。
- 后端4个相关测试文件:81 passed(隔离 SQLite 业务测试,不代表全量 PostgreSQL 测试通过)。
- Playwright:19 passed;TypeScript/Vite 构建通过。
- Docker API/Worker/Web 已更新并健康;更新前无运行中批次,队列和执行中任务均为0。
- 生产数据只读 PostgreSQL + 真实离线 BGE-M3 检索:相同规则修复前未选第14页,修复后选中该页正文,片段数量仍为14。
- 真实网页验证:批次 #8 显示“待复核2”“评分失败1”和未评分项提示;失败行显示“模型输出校验失败”;复核弹窗解释证据尚不足以自动评分。无页面错误、无业务写请求、无实际模型调用。
## 边界与使用
历史批次分数未自动改写。刷新网页后可重试 #8 的未成功项;已成功评分和教师复核项不重新评分。此次未实际调用收费模型重新给学生打分,因此不声称重试一定消除全部待复核项。
课程导学PPT缺少学生项目汇报内容、报告安全论证不足等情况仍应提示复核。修复检索遗漏和校验反馈,不将证据不足强制变成有效分数。
## 后续优化:重试开销、人工复核保护与快速筛选
- `050aa4e`:复现“教师保存零分后模型超时”的交错场景,修复异常路径覆盖人工复核状态的问题。异常处理重新加锁读取评分,保留教师分与评语。
- `14cef5c`:多学生批次重试时,已全部评分完成的学生直接跳过模型初始化、事实抽取和一致性检查,批次进度仍正确增加;有待复核项的学生仍正常处理。
- `5a6e5d4`:模型返回“误差分析(t1):缺少……”等描述时,证据缺口归到对应目标;无法识别目标的说明仍保守保留所有相关内容目标,分数继续为空。
- `4acc756`:评分页增加全部/待处理/待教师复核/失败/材料异常筛选,以及已评分、教师已复核、待处理数量汇总。仅在浏览器内筛选,不重新评分;零分正常保留,无结果时说明当前筛选下没有评分项。
- `a5cb40f`:聊天导入遇到重复文件时允许生成待编辑草稿;所有资料都被忽略的草稿强制要求编辑,正式导入与编辑后预览仍严格校验。非法编辑返回400,未修改草稿不能执行(409)。
- `f7db0a4`:修正扩展测试暴露的旧假模型缺目标编号、迁移头版本和证据政策版本断言,保留真实引用、迁移数据与证据权限的检查。
验证:
- Docker 独立 PostgreSQL/Redis 测试服务内执行完整非 `integration` 后端套件:146 passed,2 deselected;未运行在线模型调用测试。测试服务已停止,未使用正式数据库写入测试数据。
- Playwright:20 passed;TypeScript/Vite 构建通过。
- 已更新 API/Worker/Web,服务健康。更新前确认无排队和执行中的任务。
- 实际网页:批次8显示22项已评分,筛选待处理得到0项与明确空态;批次7筛选出1项待复核。无页面异常、无业务写请求、无模型调用。
- 批次8完整评分响应在更新前后规范化 JSON 的 SHA-256 一致,22项成绩未被改写。本轮无需重新运行已有评分。
+98 -14
View File
@@ -5,6 +5,7 @@ import { DashboardOutlined, BookOutlined, FileSearchOutlined, FolderOpenOutlined
import { api, post, put } from './api'; import { api, post, put } from './api';
import { Batches } from './Batches'; import { Batches } from './Batches';
import { projectLabel, projectsInBatch } from './batchProjects'; import { projectLabel, projectsInBatch } from './batchProjects';
import { fillImportIdentity, importErrors, reviewImport } from './importReview';
const Chat = lazy(() => import('./Chat')); const Chat = lazy(() => import('./Chat'));
const { Header, Sider, Content } = Layout; const { Header, Sider, Content } = Layout;
@@ -30,8 +31,14 @@ const evidencePlace = (evidence: any) => `第 ${evidence.page} ${isSlideEvidence
const evidencePreviewLabel = (evidence: any) => isSlideEvidence(evidence) ? '查看该张幻灯片的完整图像' : '查看该页的完整图像'; const evidencePreviewLabel = (evidence: any) => isSlideEvidence(evidence) ? '查看该张幻灯片的完整图像' : '查看该页的完整图像';
const pageTypeLabels: Record<string, string> = { cover: '封面', abstract: '摘要', toc: '目录', body: '正文', appendix: '附录', references: '参考文献', unknown: '识别不确定', mixed: '目录与正文混合页' }; const pageTypeLabels: Record<string, string> = { cover: '封面', abstract: '摘要', toc: '目录', body: '正文', appendix: '附录', references: '参考文献', unknown: '识别不确定', mixed: '目录与正文混合页' };
const pageTypeLabel = (value: string) => pageTypeLabels[value] || '未分类页面'; const pageTypeLabel = (value: string) => pageTypeLabels[value] || '未分类页面';
const statusLabels: Record<string, string> = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '模型调用失败' }; const statusLabels: Record<string, string> = { pending: '待评分', scored: '模型已评分', reviewed: '教师已复核', needs_review: '待教师复核', unscorable: '材料缺失或解析失败', failed: '评分失败' };
const statusLabel = (value: string) => statusLabels[value] || value; const statusLabel = (value: string) => statusLabels[value] || value;
const scoreStatusLabel = (row: any) => {
if (row.status !== 'failed') return statusLabel(row.status);
if (/模型证据|target_id|评分项或满分|JSON校验/.test(row.error || '')) return '模型输出校验失败';
if (/ConnectError|Timeout|Network|连接|超时|HTTP/i.test(row.error || '')) return '模型连接失败';
return '评分失败';
};
const statusColor = (value: string) => ({ scored: 'green', reviewed: 'green', needs_review: 'gold', unscorable: 'red', failed: 'red' }[value] || 'blue'); const statusColor = (value: string) => ({ scored: 'green', reviewed: 'green', needs_review: 'gold', unscorable: 'red', failed: 'red' }[value] || 'blue');
const batchStatusLabels: Record<string, string> = { created: '待启动', queued: '排队中', running: '运行中', completed: '已完成', review_required: '已完成,有待复核项', partial: '部分完成', failed: '失败' }; const batchStatusLabels: Record<string, string> = { created: '待启动', queued: '排队中', running: '运行中', completed: '已完成', review_required: '已完成,有待复核项', partial: '部分完成', failed: '失败' };
const batchStatusLabel = (value: string) => batchStatusLabels[value] || value; const batchStatusLabel = (value: string) => batchStatusLabels[value] || value;
@@ -51,7 +58,7 @@ function JsonField({ value, onChange, disabled, rows = 4, resetKey }: { value: a
function Dashboard() { function Dashboard() {
const [data, setData] = useState<any>({}); const [data, setData] = useState<any>({});
useEffect(() => { load('/dashboard', setData); }, []); useEffect(() => { load('/dashboard', setData); }, []);
return <><Title level={2}>工作概览</Title><Paragraph>先确认课程版本与规则,再扫描挂载目录,创建评分批次并逐项复核。</Paragraph><Row gutter={18}>{[['课程', data.courses], ['学生项目', data.projects], ['评分批次', data.batches], ['待处理评分项', data.needs_attention], ['待教师复核', data.needs_review]].map(([label, count]) => <Col xs={24} sm={12} lg={4} key={label}><Card><Statistic title={label} value={count ?? 0} /></Card></Col>)}</Row><Alert className="top-gap" type="info" showIcon message="学生材料仅从只读挂载目录读取;缺失和解析失败的材料会在项目与评分详情中标出。“待教师复核”表示该项只检索到目录、摘要或章节标题,没有可用于内容判断的正文证据,系统不会自动给低分。" /></>; return <><Title level={2}>工作概览</Title><Paragraph>先确认课程版本与规则,再扫描挂载目录,创建评分批次并逐项复核。</Paragraph><Row gutter={18}>{[['课程', data.courses], ['学生项目', data.projects], ['评分批次', data.batches], ['待处理评分项', data.needs_attention], ['待教师复核', data.needs_review]].map(([label, count]) => <Col xs={24} sm={12} lg={4} key={label}><Card><Statistic title={label} value={count ?? 0} /></Card></Col>)}</Row><Alert className="top-gap" type="info" showIcon message="学生材料仅从只读挂载目录读取;缺失和解析失败的材料会在项目与评分详情中标出。“待教师复核”表示未检索到可用正文,或模型认为当前证据不足以自动评分;请查看具体原因,系统不会自动给低分。" /></>;
} }
function Courses() { function Courses() {
@@ -103,14 +110,26 @@ function Import({ openImported, scoreImported }: { openImported: (ids: number[])
useEffect(() => { load('/courses', setCourses); }, []); useEffect(() => { load('/courses', setCourses); }, []);
useEffect(() => { setScanData(undefined); setImported(undefined); load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]); useEffect(() => { setScanData(undefined); setImported(undefined); load(`/storage/directories?relative=${encodeURIComponent(path)}`, setDirs); }, [path]);
useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]); useEffect(() => { if (courseId) load(`/courses/${courseId}/versions`, setVersions); }, [courseId]);
const editProject = (index: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[index][key] = value; setScanData(next); }; const errors = importErrors(scanData?.projects, versionId);
const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => { const next = structuredClone(scanData); next.projects[projectIndex].files[fileIndex][key] = value; setScanData(next); }; const editProject = (index: number, key: string, value: any) => {
const next = structuredClone(scanData), project = next.projects[index];
project[key] = value;
if (['student_no', 'name', 'topic'].includes(key)) project.identity_edits = { ...project.identity_edits, [key]: true };
setScanData(next);
};
const editFile = (projectIndex: number, fileIndex: number, key: string, value: any) => {
const next = structuredClone(scanData), project = next.projects[projectIndex];
project.files[fileIndex][key] = value; fillImportIdentity(project); setScanData(next);
};
const useCover = (index: number) => {
const next = structuredClone(scanData); fillImportIdentity(next.projects[index], true); setScanData(next);
};
const confirm = async () => { const confirm = async () => {
if (!versionId || !scanData?.projects?.length) return; if (errors.length || importing) { message.error(errors[0] || '正在导入,请稍候'); return; }
setImporting(true); setImporting(true);
try { try {
const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no, const projects = scanData.projects.map((p: any) => ({ relative_dir: p.relative_dir, student_no: p.student_no.trim(),
name: p.name, topic: p.topic, direction: p.direction || '', name: p.name.trim(), topic: p.topic.trim(), direction: (p.direction || '').trim(),
files: p.files.map((f: any) => ({ relative_path: f.relative_path, category: f.category, files: p.files.map((f: any) => ({ relative_path: f.relative_path, category: f.category,
status: f.status === 'ignored' ? 'ignored' : 'recognized', sha256: f.sha256 })) })); status: f.status === 'ignored' ? 'ignored' : 'recognized', sha256: f.sha256 })) }));
const result = await post('/import/confirm', { course_version_id: versionId, projects }); const result = await post('/import/confirm', { course_version_id: versionId, projects });
@@ -119,14 +138,60 @@ function Import({ openImported, scoreImported }: { openImported: (ids: number[])
} catch (error) { fail(error); } } catch (error) { fail(error); }
finally { setImporting(false); } finally { setImporting(false); }
}; };
return <><Title level={2}>挂载目录扫描与导入确认</Title><Paragraph>浏览 <code>/data/inbox</code> 下的目录,可选择包含学生子文件夹的总目录,也可直接进入放有 PDF、DOCX、PPTX 的资料目录扫描。封面信息会预填,请核对学号、姓名和选题后确认导入。</Paragraph><Space><Button disabled={path === '.'} onClick={() => setPath(path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')}>上一级</Button><Text code>{path}</Text><Button type="primary" onClick={async () => { try { setImported(undefined); setScanData(await post('/import/scan', { relative_dir: path })); } catch (e) { fail(e); } }}>扫描此目录</Button></Space><div className="folder-list">{dirs.map(dir => <Button key={dir.relative_path} onClick={() => setPath(dir.relative_path)}>📁 {dir.name}</Button>)}</div> const projectIndex = (project: any) => scanData.projects.findIndex((item: any) => item.relative_dir === project.relative_dir);
const identityInput = (project: any, key: string, label: string) => <Input aria-label={`${label} ${project.relative_dir}`}
status={key === 'student_no' ? (!/^[0-9]{5,20}$/.test((project[key] || '').trim()) ? 'error' : undefined) : (!project[key]?.trim() ? 'error' : undefined)}
placeholder={key === 'student_no' ? '5–20位数字' : `请填写${label}`} value={project[key]}
onChange={event => editProject(projectIndex(project), key, event.target.value)} />;
return <><Title level={2}>挂载目录扫描与导入确认</Title>
<Paragraph>浏览 <code>/data/inbox</code> 下的目录,可选择学生总目录或直接存放资料的目录。请核对封面身份;不属于当前项目的文件可设为“忽略”。</Paragraph>
<Space><Button disabled={path === '.'} onClick={() => setPath(path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')}>上一级</Button><Text code>{path}</Text>
<Button type="primary" onClick={async () => { try {
setImported(undefined); const data = await post('/import/scan', { relative_dir: path });
data.projects = data.projects.map((project: any) => fillImportIdentity(project)); setScanData(data);
} catch (error) { fail(error); } }}>扫描此目录</Button></Space>
<div className="folder-list">{dirs.map(dir => <Button key={dir.relative_path} onClick={() => setPath(dir.relative_path)}>📁 {dir.name}</Button>)}</div>
{imported && imported.projectIds.length > 0 && <Alert className="top-gap" type="success" showIcon {imported && imported.projectIds.length > 0 && <Alert className="top-gap" type="success" showIcon
message={`已导入 ${imported.projectIds.length} 个学生资料包`} message={`已导入 ${imported.projectIds.length} 个学生资料包`} description="查看已导入资料核对解析状态,或去评分选择这次导入的资料。"
description="点击查看已导入资料核对文件与解析状态,或去评分选择这次导入的资料。"
action={<Space wrap><Button onClick={() => openImported(imported.projectIds)}>查看已导入资料</Button> action={<Space wrap><Button onClick={() => openImported(imported.projectIds)}>查看已导入资料</Button>
<Button type="primary" onClick={() => scoreImported(imported.courseVersionId, imported.projectIds)}>去评分</Button></Space>} />} <Button type="primary" onClick={() => scoreImported(imported.courseVersionId, imported.projectIds)}>去评分</Button></Space>} />}
{scanData && <><Divider />{!scanData.projects.length && <Alert className="top-gap" type="warning" showIcon message="未找到可导入资料" description="当前目录及其下一级学生目录中没有 PDF、DOCX 或 PPTX;请进入放有这些文件的目录再扫描。" />}<Space><Select aria-label="导入课程" style={{ width: 260 }} placeholder="课程" options={options(courses, c => c.name)} value={courseId} onChange={id => { setCourseId(id); setVersionId(undefined); setVersions([]); setImported(undefined); }} /><Select aria-label="导入课程版本" style={{ width: 270 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} /><Button type="primary" loading={importing} disabled={!versionId || importing || !scanData.projects.length || scanData.projects.some((p: any) => !/^\d{5,20}$/.test(p.student_no) || !p.name?.trim() || !p.topic?.trim() || !p.files.some((f: any) => f.status !== 'ignored'))} onClick={confirm}>确认导入</Button></Space><Table className="top-gap" rowKey="relative_dir" dataSource={scanData.projects} pagination={{ pageSize: 10 }} columns={[{ title: '学号', render: (_, p, index) => <Input value={p.student_no} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'student_no', e.target.value)} /> }, { title: '姓名', render: (_, p, index) => <Input value={p.name} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'name', e.target.value)} /> }, { title: '选题', render: (_, p, index) => <Input value={p.topic} onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'topic', e.target.value)} /> }, { title: '选题方向', render: (_, p) => <Input value={p.direction || ''} placeholder="可修正" onChange={e => editProject(scanData.projects.findIndex((x: any) => x.relative_dir === p.relative_dir), 'direction', e.target.value)} /> }, { title: '待处理', render: (_, p) => <Space wrap>{p.status !== 'recognized' && <Tag color="orange">身份信息需确认</Tag>}{p.missing.map((c: string) => <Tag color="red" key={c}>缺{categories[c]}</Tag>)}{p.warnings?.map((w: string) => <Tag color="orange" key={w}>{w}</Tag>)}</Space> }]} {scanData && <><Divider />{errors.length > 0 && <Alert className="top-gap" type="warning" showIcon
expandable={{ defaultExpandAllRows: true, expandedRowRender: (p: any) => { const pi = scanData.projects.findIndex((item: any) => item.relative_dir === p.relative_dir); return <Table<any> size="small" rowKey="relative_path" pagination={false} dataSource={p.files} columns={[{ title: '文件', dataIndex: 'relative_path' }, { title: '类型', render: (_, f, fi) => <Select style={{ width: 170 }} value={f.category} options={[{ value: 'unknown', label: '未识别' }, ...Object.entries(categories).map(([value, label]) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> }, { title: '处理', render: (_, f, fi) => <Select style={{ width: 120 }} value={f.status === 'ignored' ? 'ignored' : 'recognized'} options={[{ value: 'recognized', label: '导入' }, { value: 'ignored', label: '忽略' }]} onChange={value => editFile(pi, fi, 'status', value)} /> }, { title: '扫描结果', render: (_, f) => <Tag color={f.status === 'recognized' ? 'green' : 'orange'}>{f.status}</Tag> }, { title: '异常', dataIndex: 'error' }]} />; } }} /></>} message={scanData.projects.length ? '暂不能确认导入' : '未找到可导入资料'}
description={<ul>{errors.map((error, index) => <li key={index}>{error}</li>)}</ul>} />}
<Space className="top-gap" wrap>
<Select aria-label="导入课程" style={{ width: 260 }} placeholder="课程" options={options(courses, c => c.name)} value={courseId}
onChange={id => { setCourseId(id); setVersionId(undefined); setVersions([]); setImported(undefined); }} />
<Select aria-label="导入课程版本" style={{ width: 270 }} placeholder="已发布课程版本" options={options(versions.filter(v => v.published), v => `v${v.version} ${v.title}`)} value={versionId} onChange={setVersionId} />
<Button type="primary" loading={importing} disabled={errors.length > 0 || importing} onClick={confirm}>确认导入</Button>
</Space>
<Table className="top-gap" rowKey="relative_dir" dataSource={scanData.projects} pagination={{ pageSize: 10 }} columns={[
{ title: '学号', render: (_, project) => identityInput(project, 'student_no', '学号') },
{ title: '姓名', render: (_, project) => identityInput(project, 'name', '姓名') },
{ title: '选题', render: (_, project) => identityInput(project, 'topic', '选题') },
{ title: '选题方向', render: (_, project) => <Input value={project.direction || ''} placeholder="可修正" onChange={event => editProject(projectIndex(project), 'direction', event.target.value)} /> },
{ title: '待处理', render: (_, project) => { const review = reviewImport(project); return <Space direction="vertical">
<Space wrap>{review.errors.length > 0 ? <Tag color="red">请修正导入信息</Tag> : <Tag color="green">导入信息已填写</Tag>}
{review.missing.map(category => <Tag key={category}>缺{categories[category]}</Tag>)}</Space>
{review.warnings.map(warning => <Text key={warning} type="warning">{warning}</Text>)}
{review.metadata && Object.values(review.identity).some(Boolean) && <Button size="small" onClick={() => useCover(projectIndex(project))}>使用所选资料的封面信息</Button>}
</Space>; } },
]} expandable={{ defaultExpandAllRows: true, expandedRowRender: (project: any) => {
const pi = projectIndex(project); return <Table<any> size="small" rowKey="relative_path" pagination={false} dataSource={project.files} columns={[
{ title: '文件', dataIndex: 'relative_path' },
{ title: '封面身份', render: (_, file) => <Space direction="vertical">
<Text>{file.detected_identity?.name ? `${file.detected_identity.name}(${file.detected_identity.student_no || '未识别学号'})` : '首页未识别身份'}</Text>
<Text type="secondary">{file.detected_identity?.topic || ''}</Text></Space> },
{ title: '类型', render: (_, file, fi) => <Select aria-label={`材料类型 ${file.relative_path}`} style={{ width: 170 }} value={file.category}
options={[{ value: 'unknown', label: '未识别' }, ...Object.entries(categories).map(([value, label]) => ({ value, label }))]} onChange={value => editFile(pi, fi, 'category', value)} /> },
{ title: '处理', render: (_, file, fi) => <Select aria-label={`处理 ${file.relative_path}`} style={{ width: 120 }} value={file.status === 'ignored' ? 'ignored' : 'recognized'}
options={[{ value: 'recognized', label: '导入' }, { value: 'ignored', label: '忽略' }]} onChange={value => editFile(pi, fi, 'status', value)} /> },
{ title: '扫描结果', render: (_, file) => <Tag color={file.status === 'recognized' ? 'green' : 'orange'}>
{({ recognized: '已识别', needs_confirmation: '需确认类型', duplicate: '同类多份', unparseable: '无法读取', ignored: '已忽略' } as Record<string, string>)[file.status] || file.status}</Tag> },
{ title: '异常', dataIndex: 'error' },
]} />;
} }} />
<Paragraph type="secondary">封面冲突请核对上表来源。允许缺少某类材料,相关评分项会标记为待处理。人工填写的信息会保留;点击“使用所选资料的封面信息”可重新采用识别值。</Paragraph>
</>}
</>; </>;
} }
@@ -140,6 +205,7 @@ function Projects({ openScores }: { openScores: (id: number) => void }) {
function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) { function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }) {
const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batch, setBatch] = useState<number | undefined>(batchId), [project, setProject] = useState<number | undefined>(projectId), [result, setResult] = useState<any>(), [review, setReview] = useState<any>(), [revisions, setRevisions] = useState<any[]>([]); const [batches, setBatches] = useState<any[]>([]), [projects, setProjects] = useState<any[]>([]), [batch, setBatch] = useState<number | undefined>(batchId), [project, setProject] = useState<number | undefined>(projectId), [result, setResult] = useState<any>(), [review, setReview] = useState<any>(), [revisions, setRevisions] = useState<any[]>([]);
const [scoreFilter, setScoreFilter] = useState('all');
const [form] = Form.useForm(); const [form] = Form.useForm();
const [sourcesReady, setSourcesReady] = useState(false); const [sourcesReady, setSourcesReady] = useState(false);
useEffect(() => { useEffect(() => {
@@ -154,6 +220,7 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
const scopedProjects = projectsInBatch(selectedBatch, projects); const scopedProjects = projectsInBatch(selectedBatch, projects);
useEffect(() => { useEffect(() => {
setResult(undefined); setReview(undefined); setRevisions([]); setResult(undefined); setReview(undefined); setRevisions([]);
setScoreFilter('all');
if (!sourcesReady || !selectedBatch) return; if (!sourcesReady || !selectedBatch) return;
if (!scopedProjects.some(item => item.id === project)) { if (!scopedProjects.some(item => item.id === project)) {
setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined); setProject(scopedProjects.length === 1 ? scopedProjects[0].id : undefined);
@@ -165,6 +232,12 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [batch, project, batches, projects, sourcesReady]); }, [batch, project, batches, projects, sourcesReady]);
const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult); const refresh = () => batch && project && load(`/projects/${project}/scores?batch_id=${batch}`, setResult);
const scoreRows: any[] = result?.rows || [];
const count = (status: string) => scoreRows.filter(row => row.status === status).length;
const unfinished = (row: any) => !['scored', 'reviewed'].includes(row.status);
const attentionCount = scoreRows.filter(unfinished).length;
const filteredScores = scoreRows.filter(row => scoreFilter === 'all' ||
(scoreFilter === 'attention' ? unfinished(row) : row.status === scoreFilter));
return <><Title level={2}>逐项评分与教师复核</Title> return <><Title level={2}>逐项评分与教师复核</Title>
<Space wrap> <Space wrap>
<Select aria-label="评分批次" style={{ width: 250 }} placeholder="评分批次" value={batch} <Select aria-label="评分批次" style={{ width: 250 }} placeholder="评分批次" value={batch}
@@ -174,8 +247,19 @@ function Scores({ batchId, projectId }: { batchId?: number; projectId?: number }
</Space> </Space>
{selectedBatch && <Alert className="top-gap" type="info" showIcon {selectedBatch && <Alert className="top-gap" type="info" showIcon
message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />} message={`批次 #${batch} 包含 ${scopedProjects.length} 个项目。下方分数仅对应当前选中的学生项目。`} />}
{result && <>{result.rows.some((r: any) => r.status === 'failed') && <Alert className="top-gap" type="error" showIcon message={`当前项目有 ${result.rows.filter((r: any) => r.status === 'failed').length} 项评分失败`} description="具体原因见下表。网络恢复后,可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}<Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={result.rows} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{r.criterion.name}</> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态 / 失败原因', render: (_, r) => <Space direction="vertical"><Tag color={statusColor(r.status)}>{statusLabel(r.status)}</Tag>{r.error && <Text type="secondary" style={{ maxWidth: 300 }}>{r.error}</Text>}</Space> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue(reviewFormValues(r)); }}>查看证据/复核</Button> }]} /></>} {result && <Space wrap className="top-gap">
<Modal open={!!review} title={review?.criterion?.name} width={850} onCancel={() => setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.status === 'needs_review' && <Alert type="warning" showIcon className="top-gap" message="本项缺少正文证据,需要教师复核" description={`未找到正文证据的目标:${(review.evidence_gaps || []).map((gap: any) => gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对材料后直接填写教师评分。`} />}<Descriptions column={1} items={[{ key: 'model', label: '模型分数/评语', children: `${review.model_score ?? '未给出'} · ${review.model_comment || ''}` }, { key: 'confidence', label: '模型置信度', children: review.confidence ?? '—' }, { key: 'missing', label: '缺失证据', children: review.missing_evidence?.join(';') || '无' }, { key: 'gaps', label: '缺正文证据的目标', children: (review.evidence_gaps || []).map((gap: any) => `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} /><Divider />{review.evidence?.map((ev: any, index: number) => <Card size="small" key={index} className="evidence"><Space wrap><Text strong>{ev.document} · {evidencePlace(ev)}</Text><Tag color={ev.page_type === 'body' || ev.page_type === 'appendix' ? 'green' : 'orange'}>{pageTypeLabel(ev.page_type)}</Tag>{ev.historical && <Tag>历史评分引用</Tag>}{ev.target_label && <Tag>{ev.target_label}</Tag>}</Space><Paragraph>{ev.quote}</Paragraph><Space wrap>{ev.url && <a href={ev.url} target="_blank" rel="noreferrer">查看页面文字</a>}{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">{evidencePreviewLabel(ev)}(核对引文)</a>}</Space></Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true, message: '请填写教师评分' }]}><InputNumber min={0} max={review.criterion.max_score} placeholder="请填写" /></Form.Item><Form.Item label="教师评语" name="comment" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item><Form.Item label="修改说明" name="reason"><Input /></Form.Item></Form>{review.score_id && <Button onClick={async () => { try { setRevisions(await api(`/criterion-scores/${review.score_id}/revisions`)); } catch (e) { fail(e); } }}>查看修改记录</Button>}{revisions.length > 0 && <pre className="json-view">{JSON.stringify(revisions, null, 2)}</pre>}</>}</Modal> <Select aria-label="评分项筛选" value={scoreFilter} onChange={setScoreFilter} style={{ width: 220 }}
options={[
{ value: 'all', label: `全部评分项(${scoreRows.length})` },
{ value: 'attention', label: `仅待处理(${attentionCount})` },
{ value: 'needs_review', label: `仅待教师复核(${count('needs_review')})` },
{ value: 'failed', label: `仅评分失败(${count('failed')})` },
{ value: 'unscorable', label: `仅材料异常(${count('unscorable')})` },
]} />
<Text>已评分 {count('scored')} 项 · 教师已复核 {count('reviewed')} 项 · 待处理 {attentionCount} 项</Text>
</Space>}
{result && <>{result.rows.some((r: any) => r.status === 'failed') && <Alert className="top-gap" type="error" showIcon message={`当前项目有 ${result.rows.filter((r: any) => r.status === 'failed').length} 项评分失败`} description="具体原因见下表。连接失败请检查模型端点和网络;输出校验失败可重试,若重复发生请核对具体校验原因。可在评分批次页点击启动/重试;已经成功的评分和教师复核会保留。" />}<Card className="top-gap" title={`${result.student_no} ${result.name} · ${result.topic}`}><Space wrap>{Object.entries(result.totals).map(([key, value]) => <Tag key={key} color={value === null ? 'orange' : 'blue'}>{key}: {String(value ?? '待处理')}</Tag>)}</Space>{result.facts?.consistency_issues?.length > 0 && <Alert className="top-gap" type="warning" message="跨文档一致性提醒" description={result.facts.consistency_issues.map((issue: any) => issue.description).join(';')} />}</Card><Table className="top-gap" rowKey={(row: any) => row.criterion.criterion_id} dataSource={filteredScores} locale={{ emptyText: '当前筛选下没有评分项' }} pagination={false} columns={[{ title: '评分项', render: (_, r) => <><Tag>{categories[r.criterion.category] || r.criterion.category}</Tag>{r.criterion.name}</> }, { title: '模型原始分', render: (_, r) => `${r.model_score ?? '—'} / ${r.criterion.max_score}` }, { title: '教师分', render: (_, r) => r.teacher_score ?? '—' }, { title: '最终分', render: (_, r) => r.final_score ?? '待处理' }, { title: '状态 / 失败原因', render: (_, r) => <Space direction="vertical"><Tag color={statusColor(r.status)}>{scoreStatusLabel(r)}</Tag>{r.error && <Text type="secondary" style={{ maxWidth: 300 }}>{r.error}</Text>}</Space> }, { title: '操作', render: (_, r) => <Button onClick={() => { setReview(r); form.setFieldsValue(reviewFormValues(r)); }}>查看证据/复核</Button> }]} /></>}
<Modal open={!!review} title={review?.criterion?.name} width={850} onCancel={() => setReview(undefined)} onOk={async () => { try { const values = await form.validateFields(); await put(`/criterion-scores/${review.score_id}/review`, values); setReview(undefined); refresh(); message.success('复核记录已保存'); } catch (e) { fail(e); } }} okText="保存教师复核" okButtonProps={{ disabled: !review?.score_id }}>{review && <>{review.status === 'needs_review' && <Alert type="warning" showIcon className="top-gap" message="本项证据不足以自动评分,需要教师复核" description={`缺少或尚不足以支撑判断的目标:${(review.evidence_gaps || []).map((gap: any) => gap.target_label).join('、') || review.missing_evidence?.join('、') || '见缺失证据'}。系统未给出模型分,也没有按低分处理;请核对下方证据,补充或更正材料后重试,或直接填写教师评分。`} />}<Descriptions column={1} items={[{ key: 'model', label: '模型分数/评语', children: `${review.model_score ?? '未给出'} · ${review.model_comment || ''}` }, { key: 'confidence', label: '模型置信度', children: review.confidence ?? '—' }, { key: 'missing', label: '缺失证据', children: review.missing_evidence?.join(';') || '无' }, { key: 'gaps', label: '待核对的证据目标', children: (review.evidence_gaps || []).map((gap: any) => `${gap.target_label}(检索范围:${(gap.searched_page_types || []).map(pageTypeLabel).join('、') || '正文'})`).join(';') || '无' }, { key: 'suggestion', label: '改进建议', children: review.suggestion || '无' }, { key: 'error', label: '异常', children: review.error || '无' }]} /><Divider />{review.evidence?.map((ev: any, index: number) => <Card size="small" key={index} className="evidence"><Space wrap><Text strong>{ev.document} · {evidencePlace(ev)}</Text><Tag color={ev.page_type === 'body' || ev.page_type === 'appendix' ? 'green' : 'orange'}>{pageTypeLabel(ev.page_type)}</Tag>{ev.historical && <Tag>历史评分引用</Tag>}{ev.target_label && <Tag>{ev.target_label}</Tag>}</Space><Paragraph>{ev.quote}</Paragraph><Space wrap>{ev.url && <a href={ev.url} target="_blank" rel="noreferrer">查看页面文字</a>}{ev.image_url && <a href={ev.image_url} target="_blank" rel="noreferrer">{evidencePreviewLabel(ev)}(核对引文)</a>}</Space></Card>)}<Divider /><Form form={form} layout="vertical"><Form.Item label="教师评分" name="score" rules={[{ required: true, message: '请填写教师评分' }]}><InputNumber min={0} max={review.criterion.max_score} placeholder="请填写" /></Form.Item><Form.Item label="教师评语" name="comment" rules={[{ required: true }]}><Input.TextArea rows={3} /></Form.Item><Form.Item label="修改说明" name="reason"><Input /></Form.Item></Form>{review.score_id && <Button onClick={async () => { try { setRevisions(await api(`/criterion-scores/${review.score_id}/revisions`)); } catch (e) { fail(e); } }}>查看修改记录</Button>}{revisions.length > 0 && <pre className="json-view">{JSON.stringify(revisions, null, 2)}</pre>}</>}</Modal>
</>; </>;
} }
+6 -1
View File
@@ -148,10 +148,15 @@ export function Batches({ openBatch }: { openBatch: (id: number, projectId?: num
{ title: '状态', width: 140, render: (_, batch) => <Space wrap> { title: '状态', width: 140, render: (_, batch) => <Space wrap>
<Tag color={batch.status === 'completed' ? 'green' : batch.status === 'review_required' ? 'gold' : ['partial', 'failed'].includes(batch.status) ? 'orange' : 'blue'}>{labels[batch.status] || batch.status}</Tag> <Tag color={batch.status === 'completed' ? 'green' : batch.status === 'review_required' ? 'gold' : ['partial', 'failed'].includes(batch.status) ? 'orange' : 'blue'}>{labels[batch.status] || batch.status}</Tag>
{batch.needs_review > 0 && <Tag color="gold">待复核 {batch.needs_review}</Tag>} {batch.needs_review > 0 && <Tag color="gold">待复核 {batch.needs_review}</Tag>}
{batch.failed > 0 && <Tag color="red">评分失败 {batch.failed}</Tag>}
{batch.unscorable > 0 && <Tag color="red">材料异常 {batch.unscorable}</Tag>}
</Space> }, </Space> },
{ title: '进度', width: 160, render: (_, batch) => <> { title: '处理进度', width: 190, render: (_, batch) => <>
<Typography.Text>{batch.progress_done} / {batch.progress_total} 项</Typography.Text> <Typography.Text>{batch.progress_done} / {batch.progress_total} 项</Typography.Text>
<Progress percent={batch.progress_total ? Math.round(batch.progress_done / batch.progress_total * 100) : 0} size="small" /> <Progress percent={batch.progress_total ? Math.round(batch.progress_done / batch.progress_total * 100) : 0} size="small" />
{batch.progress_total > 0 && batch.progress_done >= batch.progress_total &&
(batch.needs_review > 0 || batch.failed > 0 || batch.unscorable > 0) &&
<Typography.Text type="secondary">处理结束,仍有未评分项</Typography.Text>}
</> }, </> },
{ title: '异常', dataIndex: 'error', width: 150 }, { title: '异常', dataIndex: 'error', width: 150 },
{ title: '操作', width: 180, render: (_, batch) => <Space wrap> { title: '操作', width: 180, render: (_, batch) => <Space wrap>
+66
View File
@@ -0,0 +1,66 @@
const fields = ['student_no', 'name', 'topic'] as const;
type IdentityField = typeof fields[number];
const labels = { student_no: '学号', name: '姓名', topic: '选题' };
const categories: Record<string, string> = { stage1: '阶段报告1', stage2: '阶段报告2', stage3: '阶段报告3',
final_report: '最终报告', presentation: '汇报PPT/PDF' };
const text = (value: unknown) => String(value ?? '').trim();
const fileName = (file: any) => text(file.relative_path).split('/').pop() || '文件';
export function reviewImport(project: any) {
const selected = (project.files || []).filter((file: any) => file.status !== 'ignored');
const metadata = (project.files || []).some((file: any) => file.detected_identity != null);
const identity: Record<IdentityField, string> = { student_no: '', name: '', topic: '' };
const warnings: string[] = [];
for (const field of fields) {
const sources = new Map<string, string[]>();
for (const file of selected) {
const value = text(file.detected_identity?.[field]);
if (value) sources.set(value, [...(sources.get(value) || []), fileName(file)]);
}
const folderValue = text(project.folder_identity?.[field]);
const values = [...sources.keys()];
identity[field] = folderValue || (values.length === 1 ? values[0] : '');
if (values.length > 1) {
warnings.push(`${labels[field]}冲突:${values.map(value => `${value}(${sources.get(value)!.join('、')})`).join(';')}`);
} else if (folderValue && values.length === 1 && folderValue !== values[0]) {
warnings.push(`${labels[field]}与目录不一致:封面为${values[0]}(${sources.get(values[0])!.join('、')}),目录为${folderValue}`);
}
}
if (!metadata) warnings.push(...(project.warnings || []));
const errors: string[] = [];
if (!/^[0-9]{5,20}$/.test(text(project.student_no))) errors.push('学号必须为5–20位数字');
if (!text(project.name)) errors.push('请填写姓名');
if (!text(project.topic)) errors.push('请填写选题');
if (!selected.length) errors.push('至少选择1份资料');
const counts = new Map<string, number>();
for (const file of selected) {
if (!categories[file.category]) errors.push(`${fileName(file)}:请选择材料类型`);
else counts.set(file.category, (counts.get(file.category) || 0) + 1);
if (file.error) errors.push(`${fileName(file)}:无法读取,请修复文件或设为忽略`);
}
for (const [category, count] of counts) {
if (count > 1) errors.push(`${categories[category]}选了${count}份,请只保留1份,其余设为忽略`);
}
const missing = Object.keys(categories).filter(category => !selected.some((file: any) => file.category === category));
return { selected, metadata, identity, warnings, errors, missing };
}
export function fillImportIdentity(project: any, force = false) {
const review = reviewImport(project);
if (!review.metadata) return project;
for (const field of fields) {
if (force || !project.identity_edits?.[field]) project[field] = review.identity[field];
}
if (force) project.identity_edits = {};
return project;
}
export function importErrors(projects: any[] | undefined, versionId: number | undefined) {
const errors = versionId ? [] : ['请选择已发布课程版本'];
if (!projects?.length) return [...errors, '当前目录没有PDF、DOCX或PPTX资料,请重新扫描资料目录'];
for (const project of projects) {
errors.push(...reviewImport(project).errors.map(error => `${project.relative_dir}:${error}`));
}
return errors;
}
+66
View File
@@ -149,6 +149,72 @@ test('failed scoring shows the network error and retry guidance on the page', as
await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible(); await expect(page.getByText(/已经成功的评分和教师复核会保留/)).toBeVisible();
}); });
test('batch progress exposes failures separately from teacher review', async ({ page }) => {
await setup(page);
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
id: 71, course_version_id: 1, project_ids: [11], status: 'partial',
progress_done: 22, progress_total: 22, needs_review: 2, failed: 1, unscorable: 0,
}] }));
await page.goto('/?page=batches');
const row = page.getByRole('row').filter({ hasText: '#71' });
await expect(row).toContainText('评分失败 1');
await expect(row).toContainText('待复核 2');
await expect(row).toContainText('处理结束,仍有未评分项');
});
test('output validation and insufficient evidence are not presented as network failures', async ({ page }) => {
await setup(page);
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
rows: [
{ criterion: { criterion_id: 'stage2.experiment', category: 'stage2', name: '实验方案', max_score: 20 },
status: 'failed', error: '模型证据未关联该片段的检索目标', evidence: [] },
{ criterion: { criterion_id: 'stage3.plan', category: 'stage3', name: '下一阶段计划', max_score: 10 },
status: 'needs_review', error: '模型认为现有正文证据不足以判断该项,转教师复核。',
evidence: [], missing_evidence: ['计划'], evidence_gaps: [], model_comment: '当前片段不足。' },
],
} }));
await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect(page.getByText('模型输出校验失败', { exact: true })).toBeVisible();
await expect(page.getByText('模型调用失败', { exact: true })).toHaveCount(0);
await expect(page.getByText(/输出校验失败可重试/)).toBeVisible();
await page.getByRole('row').filter({ hasText: '下一阶段计划' }).getByRole('button', { name: '查看证据/复核' }).click();
await expect(page.getByText('本项证据不足以自动评分,需要教师复核', { exact: true })).toBeVisible();
await expect(page.getByText(/缺少或尚不足以支撑判断的目标:计划/)).toBeVisible();
});
test('score filters isolate actionable rows without hiding zero grades or changing data', async ({ page }) => {
await setup(page);
const writes: string[] = [];
page.on('request', request => { if (request.method() !== 'GET') writes.push(request.url()); });
await page.route('**/api/projects/11/scores?*', route => route.fulfill({ json: {
student_no: '9000000001', name: '学生甲', topic: '项目', totals: { overall: null }, facts: {},
rows: ['scored', 'reviewed', 'needs_review', 'failed', 'unscorable'].map((status, i) => ({
criterion: { criterion_id: `stage1.${i}`, category: 'stage1', name: `测试项目${i}`, max_score: 20 },
status, model_score: status === 'scored' ? 0 : null, teacher_score: status === 'reviewed' ? 0 : null,
final_score: ['scored', 'reviewed'].includes(status) ? 0 : null,
error: status === 'failed' ? '模型证据未能匹配检索原文' : '', evidence: [],
})),
} }));
await page.goto('/?page=scores&batch_id=71&project_id=11');
await expect(page.getByText('已评分 1 项 · 教师已复核 1 项 · 待处理 3 项', { exact: true })).toBeVisible();
const rows = page.getByRole('row').filter({ hasText: '测试项目' });
await expect(rows).toHaveCount(5);
for (const [option, count, name] of [
['仅待处理(3)', 3, '测试项目2'], ['仅待教师复核(1)', 1, '测试项目2'],
['仅评分失败(1)', 1, '测试项目3'], ['仅材料异常(1)', 1, '测试项目4'],
['全部评分项(5)', 5, '测试项目0'],
] as const) {
await page.keyboard.press('Escape');
await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0);
await choose(page, '评分项筛选', option);
await expect(rows).toHaveCount(count);
await expect(page.getByRole('row').filter({ hasText: name })).toBeVisible();
}
await expect(page.getByRole('row').filter({ hasText: '测试项目0' })).toContainText('0 / 20');
expect(writes).toEqual([]);
});
test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => { test('a slow project list keeps the student specified in a multi-project batch link', async ({ page }) => {
const state = await setup(page); const state = await setup(page);
await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{ await page.route('**/api/scoring-batches', route => route.fulfill({ json: [{
+98
View File
@@ -0,0 +1,98 @@
import { expect, test, type Page } from '@playwright/test';
const identity = { student_no: '9000000001', name: '学生甲', topic: '机器人项目' };
const mixed = () => ({ relative_dir: 'test/student', student_no: '', name: '', topic: '',
status: 'needs_confirmation', folder_identity: {}, detected_identity: {}, missing: [],
warnings: ['不同材料封面的学号不一致,请核对后填写'], files: [
{ relative_path: 'test/student/最终报告.pdf', category: 'final_report', status: 'recognized',
sha256: 'a'.repeat(64), detected_identity: identity },
{ relative_path: 'test/student/汇报PPT.pdf', category: 'presentation', status: 'needs_confirmation',
sha256: 'b'.repeat(64), detected_identity: { student_no: '9000000002', name: '学生乙', topic: '另一项目' } },
] });
async function setup(page: Page, project: any) {
const imports: any[] = [];
await page.route('**/api/**', async route => {
const request = route.request();
const path = new URL(request.url()).pathname.replace(/^\/api/, '');
let data: any = [];
if (path === '/courses') data = [{ id: 1, name: '课程甲' }];
if (path === '/courses/1/versions') data = [{ id: 1, published: true, version: 1, title: '初始版本' }];
if (path === '/import/scan') data = { projects: [project] };
if (path === '/import/confirm') { imports.push(request.postDataJSON()); data = { project_ids: [12] }; }
await route.fulfill({ json: data });
});
await page.goto('/?page=import');
await page.getByRole('button', { name: '扫描此目录', exact: true }).click();
await choose(page, '导入课程', '课程甲');
await choose(page, '导入课程版本', /初始版本/);
return imports;
}
async function choose(page: Page, label: string, option: string | RegExp) {
await page.keyboard.press('Escape');
await expect(page.locator('.ant-select-dropdown:visible')).toHaveCount(0);
const input = page.getByRole('combobox', { name: label, exact: true });
await input.focus(); await input.press('ArrowDown');
const listId = await input.getAttribute('aria-controls');
const dropdown = page.locator('.ant-select-dropdown').filter({ has: page.locator(`[id="${listId}"]`) });
await expect(dropdown).toBeVisible();
await dropdown.getByText(option, { exact: typeof option === 'string' }).click();
}
test('invalid student number explains why confirm is disabled and recovers after editing', async ({ page }) => {
const project = { ...mixed(), student_no: '1', name: '1', topic: '1' };
delete (project as any).folder_identity;
for (const file of project.files) delete (file as any).detected_identity;
await setup(page, project);
await expect(page.getByText(/学号必须为5–20位数字/)).toBeVisible();
await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled();
await page.getByRole('textbox', { name: '学号 test/student', exact: true }).fill('9000000001');
await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled();
});
test('ignoring a foreign PPT recalculates identity and submits the intended project', async ({ page }) => {
const imports = await setup(page, mixed());
await expect(page.getByText('学生乙(9000000002)', { exact: true })).toBeVisible();
await expect(page.getByText(/学号冲突:/)).toBeVisible();
await choose(page, '处理 test/student/汇报PPT.pdf', '忽略');
await expect(page.getByRole('textbox', { name: '学号 test/student', exact: true })).toHaveValue(identity.student_no);
await expect(page.getByRole('textbox', { name: '姓名 test/student', exact: true })).toHaveValue(identity.name);
await expect(page.getByText(/学号冲突:/)).toHaveCount(0);
await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled();
await page.getByRole('button', { name: '确认导入', exact: true }).click();
await expect.poll(() => imports.length).toBe(1);
expect(imports[0].projects[0].student_no).toBe(identity.student_no);
expect(imports[0].projects[0].files[1].status).toBe('ignored');
});
test('file selection preserves manual corrections until the user chooses cover values', async ({ page }) => {
await setup(page, mixed());
const name = page.getByRole('textbox', { name: '姓名 test/student', exact: true });
await name.fill('人工修正姓名');
await choose(page, '处理 test/student/汇报PPT.pdf', '忽略');
await expect(name).toHaveValue('人工修正姓名');
await page.getByRole('button', { name: '使用所选资料的封面信息', exact: true }).click();
await expect(name).toHaveValue(identity.name);
});
test('ignoring all files gives an explicit selected-material error', async ({ page }) => {
await setup(page, mixed());
await choose(page, '处理 test/student/汇报PPT.pdf', '忽略');
await choose(page, '处理 test/student/最终报告.pdf', '忽略');
await expect(page.getByText(/至少选择1份资料/)).toBeVisible();
await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled();
});
test('unknown and duplicate material types explain the block before sending a request', async ({ page }) => {
const project = mixed();
project.files[1].category = 'unknown';
project.files[1].detected_identity = identity;
await setup(page, project);
await expect(page.getByText(/请选择材料类型/)).toBeVisible();
await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeDisabled();
await choose(page, '材料类型 test/student/汇报PPT.pdf', '最终报告');
await expect(page.getByText(/最终报告选了2份/)).toBeVisible();
await choose(page, '处理 test/student/汇报PPT.pdf', '忽略');
await expect(page.getByRole('button', { name: '确认导入', exact: true })).toBeEnabled();
});