feat: separate contents, abstract and body evidence
This commit is contained in:
@@ -210,7 +210,7 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher
|
||||
else:
|
||||
raise HTTPException(400, "不支持的操作")
|
||||
expected = _state(db, kind, payload)
|
||||
if kind == "start_batch" and expected["status"] not in ("created", "failed", "partial"):
|
||||
if kind == "start_batch" and expected["status"] not in ("created", "failed", "partial", "review_required"):
|
||||
raise HTTPException(409, "批次不可启动或重试")
|
||||
if kind == "index_retry" and expected["status"] != "failed":
|
||||
raise HTTPException(409, "仅失败任务可重试")
|
||||
|
||||
@@ -8,10 +8,12 @@ from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import select
|
||||
|
||||
from .actions import prepare, require_scope
|
||||
from .exports import batch_projects, project_result
|
||||
from .evidence import CONTENT_ALLOWED, STRUCTURE_ALLOWED
|
||||
from .exports import STATUS_LABELS, batch_projects, page_type_label, project_result
|
||||
from .knowledge_pages import page_links
|
||||
from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeDocument,
|
||||
from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeChunk, KnowledgeDocument,
|
||||
Project, RubricVersion, ScoringBatch, Student, SubmissionFile)
|
||||
from .pages import toc_entries
|
||||
from .rag import retrieve
|
||||
from .rules import totals
|
||||
from .storage import directories, scan
|
||||
@@ -52,6 +54,14 @@ class SearchProject(ProjectId):
|
||||
query: str = Field(min_length=2, max_length=300)
|
||||
|
||||
|
||||
class DocumentId(Args):
|
||||
document_id: int = Field(gt=0)
|
||||
|
||||
|
||||
class SearchDocument(DocumentId):
|
||||
query: str = Field(min_length=2, max_length=300)
|
||||
|
||||
|
||||
class Directory(Args):
|
||||
relative_dir: str = Field(min_length=1, max_length=500)
|
||||
|
||||
@@ -67,7 +77,7 @@ class BatchId(Args):
|
||||
|
||||
class ScoresFilterArgs(BatchId):
|
||||
project_id: int | None = Field(default=None, gt=0)
|
||||
status: Literal["pending", "scored", "reviewed", "failed", "unscorable"] | None = None
|
||||
status: Literal["pending", "scored", "reviewed", "needs_review", "failed", "unscorable"] | None = None
|
||||
|
||||
|
||||
class ScoresArgs(ScoresFilterArgs):
|
||||
@@ -201,9 +211,14 @@ def project(db, convo, args, _user):
|
||||
"url": f"/?page=projects&project_id={item.id}"}
|
||||
|
||||
|
||||
def _search(db, version_id, project_id, query, *, public_only=False):
|
||||
def _search(db, version_id, project_id, query, *, public_only=False, scopes=None, limit=6):
|
||||
"""Content search under the evidence policy: navigation pages are never body text."""
|
||||
scopes = scopes or ("content",)
|
||||
allowed = sorted({page_type for scope in scopes for page_type in STRUCTURE_ALLOWED} if "structure" in scopes
|
||||
else {page_type for scope in scopes for page_type in CONTENT_ALLOWED})
|
||||
chunks = retrieve(db, course_version_id=version_id, project_id=project_id or 0, query=query,
|
||||
limit=6, include_public=False, public_only=public_only)
|
||||
limit=limit, include_public=False, public_only=public_only,
|
||||
allowed_page_types=allowed, substantive_only="content" in scopes)
|
||||
citations = []
|
||||
for chunk in chunks:
|
||||
doc = db.get(KnowledgeDocument, chunk["document_id"])
|
||||
@@ -211,8 +226,15 @@ def _search(db, version_id, project_id, query, *, public_only=False):
|
||||
citations.append({"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"],
|
||||
"title": doc.title if doc else "资料", "page": chunk["page"],
|
||||
"quote": chunk["text"][:500], "project_id": chunk["project_id"],
|
||||
"page_type": chunk.get("page_type") or links.get("page_type") or "unknown",
|
||||
"page_type_label": page_type_label(chunk.get("page_type")),
|
||||
"section": chunk.get("section") or "",
|
||||
"block_type": chunk.get("block_type") or "body",
|
||||
"source_role": "structure" if "structure" in scopes else "content",
|
||||
# Stable identity so an answer can cite exactly what it used.
|
||||
"citation_id": f"chunk:{chunk['chunk_id']}",
|
||||
**links})
|
||||
return {"citations": citations, "found": bool(citations)}
|
||||
return {"citations": citations, "found": bool(citations), "scopes": list(scopes)}
|
||||
|
||||
|
||||
def search_course(db, convo, args, _user):
|
||||
@@ -225,6 +247,84 @@ def search_project(db, convo, args, _user):
|
||||
return _search(db, item.course_version_id, item.id, args.query)
|
||||
|
||||
|
||||
def search_structure(db, convo, args, _user):
|
||||
"""Structure and contents lookup: navigation pages plus body headings.
|
||||
|
||||
This tool answers questions about chapters, sections and report layout. Its results
|
||||
are explicitly labelled as structure, so they can never be mistaken for content
|
||||
evidence about what the student implemented or measured.
|
||||
"""
|
||||
item = _project(db, convo, args.project_id)
|
||||
chunks = retrieve(db, course_version_id=item.course_version_id, project_id=item.id, query=args.query,
|
||||
limit=8, include_public=False, allowed_page_types=sorted(STRUCTURE_ALLOWED),
|
||||
substantive_only=False)
|
||||
citations = []
|
||||
for chunk in chunks:
|
||||
doc = db.get(KnowledgeDocument, chunk["document_id"])
|
||||
links = page_links(db, chunk["document_id"], chunk["page"])
|
||||
page_type = chunk.get("page_type") or links.get("page_type") or "unknown"
|
||||
citations.append({
|
||||
"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"],
|
||||
"title": doc.title if doc else "资料", "page": chunk["page"],
|
||||
"quote": chunk["text"][:500], "project_id": chunk["project_id"],
|
||||
"page_type": page_type, "page_type_label": page_type_label(page_type),
|
||||
"section": chunk.get("section") or "", "block_type": chunk.get("block_type") or "body",
|
||||
"source_role": "structure",
|
||||
"citation_id": f"chunk:{chunk['chunk_id']}",
|
||||
"toc_entries": toc_entries([{"kind": "toc", "text": chunk["text"]}])[:40]
|
||||
if page_type == "toc" else [],
|
||||
**links})
|
||||
return {"citations": citations, "found": bool(citations), "scopes": ["structure"],
|
||||
"note": "这些是结构或目录信息,只能说明章节安排,不能证明章节内容。"}
|
||||
|
||||
|
||||
def get_document_pages(db, convo, args, _user):
|
||||
"""List a document's pages with page type, so a caller can navigate precisely."""
|
||||
_document(db, convo, args.document_id)
|
||||
rows = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.document_id == args.document_id)
|
||||
.order_by(KnowledgeChunk.page, KnowledgeChunk.id)).all()
|
||||
pages: dict[int, dict] = {}
|
||||
for chunk in rows:
|
||||
entry = pages.setdefault(chunk.page, {"page": chunk.page, "page_type": chunk.page_type,
|
||||
"page_type_label": page_type_label(chunk.page_type),
|
||||
"chapter": chunk.chapter, "section": chunk.section,
|
||||
"block_types": []})
|
||||
if chunk.block_type not in entry["block_types"]:
|
||||
entry["block_types"].append(chunk.block_type)
|
||||
if chunk.section:
|
||||
entry["section"] = chunk.section
|
||||
return {"document_id": args.document_id, "count": len(pages),
|
||||
"pages": [pages[key] for key in sorted(pages)]}
|
||||
|
||||
|
||||
def search_document(db, convo, args, _user):
|
||||
"""Content search restricted to one document, under the same page-type policy."""
|
||||
document = _document(db, convo, args.document_id)
|
||||
chunks = retrieve(db, course_version_id=document.course_version_id, project_id=document.project_id or 0,
|
||||
query=args.query, limit=6, include_public=False,
|
||||
allowed_page_types=sorted(CONTENT_ALLOWED), substantive_only=True)
|
||||
citations = []
|
||||
for chunk in chunks:
|
||||
if chunk["document_id"] != document.id:
|
||||
continue
|
||||
links = page_links(db, chunk["document_id"], chunk["page"])
|
||||
citations.append({"chunk_id": chunk["chunk_id"], "document_id": chunk["document_id"],
|
||||
"title": document.title, "page": chunk["page"], "quote": chunk["text"][:500],
|
||||
"project_id": chunk["project_id"], "page_type": chunk.get("page_type"),
|
||||
"page_type_label": page_type_label(chunk.get("page_type")),
|
||||
"section": chunk.get("section") or "", "source_role": "content",
|
||||
"citation_id": f"chunk:{chunk['chunk_id']}", **links})
|
||||
return {"citations": citations, "found": bool(citations), "scopes": ["content"]}
|
||||
|
||||
|
||||
def _document(db, convo, document_id):
|
||||
document = db.get(KnowledgeDocument, document_id)
|
||||
if not document:
|
||||
raise HTTPException(404, "资料不存在")
|
||||
require_scope(db, convo, version_id=document.course_version_id, project_id=document.project_id)
|
||||
return document
|
||||
|
||||
|
||||
def batch_list(db, convo, args, _user):
|
||||
query = select(ScoringBatch).order_by(ScoringBatch.id.desc())
|
||||
rows = []
|
||||
@@ -278,15 +378,36 @@ def scores(db, convo, args, _user):
|
||||
"max_score": row["criterion"]["max_score"], "score_id": row["score_id"],
|
||||
"model_score": row["model_score"], "teacher_score": row["teacher_score"],
|
||||
"final_score": row["final_score"], "status": row["status"],
|
||||
"error": str(row["error"] or "")[:300], "evidence": [{"page": ev["page"],
|
||||
"quote": ev["quote"][:160], "document": str(ev["document"])[:200]}
|
||||
"status_label": STATUS_LABELS.get(row["status"], row["status"]),
|
||||
"error": str(row["error"] or "")[:300],
|
||||
# Which body targets still lack evidence, so the answer can
|
||||
# explain a review item instead of reporting a score.
|
||||
"evidence_gaps": [{"target_label": str(gap.get("target_label") or "")[:120],
|
||||
"searched_page_types": gap.get("searched_page_types") or []}
|
||||
for gap in (row.get("evidence_gaps") or [])][:6],
|
||||
"evidence": [{"page": ev["page"],
|
||||
"quote": ev["quote"][:160], "document": str(ev["document"])[:200],
|
||||
"page_type": ev.get("page_type") or "unknown",
|
||||
"page_type_label": page_type_label(ev.get("page_type")),
|
||||
"target_label": str(ev.get("target_label") or "")[:120],
|
||||
"historical": bool(ev.get("historical"))}
|
||||
for ev in row["evidence"][:2]]} for row in rows]})
|
||||
for row in rows:
|
||||
for evidence in row["evidence"][:2]:
|
||||
links = page_links(db, evidence.get("document_id"), evidence["page"])
|
||||
citations.append({"title": str(evidence["document"])[:200], "page": evidence["page"],
|
||||
"quote": evidence["quote"][:320], "document_id": evidence.get("document_id"),
|
||||
"page_type": evidence.get("page_type") or links.get("page_type") or "unknown",
|
||||
"page_type_label": page_type_label(evidence.get("page_type")),
|
||||
"target_label": str(evidence.get("target_label") or "")[:120],
|
||||
"historical": bool(evidence.get("historical")),
|
||||
"source": "historical_score_evidence" if evidence.get("historical") else "score_evidence",
|
||||
**links, "score_url": url})
|
||||
if citations:
|
||||
# Scoring quotations are never presented as report body evidence in an answer.
|
||||
for citation in citations:
|
||||
citation["usage"] = ("历史评分引用" if citation.get("historical")
|
||||
else f"评分证据 · {citation.get('page_type_label')}")
|
||||
next_criterion_offset = args.criterion_offset + 6 if page and args.criterion_offset + 6 < len(page[0][1]["rows"]) else None
|
||||
next_project_offset = args.project_offset + 1 if args.project_offset + 1 < len(results) else None
|
||||
return {"batch_id": batch.id, "status_filter": args.status, "count": len(results),
|
||||
@@ -418,12 +539,15 @@ def prepare_import(db, convo, args, _user):
|
||||
TOOLS: dict[str, Tool] = {
|
||||
"list_courses": Tool("列出课程、版本和发布状态;名称或版本不清楚时先调用此工具。", Empty, courses),
|
||||
"get_course_rules": Tool("查询课程目标和规则。指定批次时只返回批次保存的快照。", RulesArgs, rules),
|
||||
"search_course_material": Tool("检索指定课程版本的课程资料,返回原文片段和引用。", SearchCourse, search_course),
|
||||
"search_course_material": Tool("检索指定课程版本的课程资料正文,返回原文片段和引用;不含目录、摘要和参考文献页。", SearchCourse, search_course),
|
||||
"list_directories": Tool("只读浏览 /data/inbox 下的相对目录。", Directory, browse_directories),
|
||||
"scan_directory": Tool("只读分页扫描学生总目录,返回材料候选、缺失、重复和识别异常。", ScanArgs, scan_directories),
|
||||
"list_projects": Tool("按课程版本及可选学号查询学生项目。", ProjectList, projects),
|
||||
"get_project": Tool("读取单个学生项目的元数据、材料和解析状态。", ProjectId, project),
|
||||
"search_project_material": Tool("只检索指定学生项目报告,返回原文片段和引用。", SearchProject, search_project),
|
||||
"search_project_material": Tool("只检索指定学生项目的报告正文,返回带页面类型和稳定引用标识的原文片段;不含目录、摘要和参考文献页。", SearchProject, search_project),
|
||||
"search_project_structure": Tool("查询学生报告的章节结构与目录,返回目录条目和页面类型;只能说明章节安排,不能证明章节内容。", SearchProject, search_structure),
|
||||
"get_document_pages": Tool("列出某份资料各页的页码、页面类型和章节,用于判断正文页位置。", DocumentId, get_document_pages),
|
||||
"search_document": Tool("在指定的一份资料内检索正文段落,返回带页面类型和引用标识的片段。", SearchDocument, search_document),
|
||||
"list_batches": Tool("列出可访问的评分批次。", Empty, batch_list),
|
||||
"get_batch_progress": Tool("查询评分批次进度、状态和固定项目范围。", BatchId, progress),
|
||||
"get_scores": Tool("分页查询单学生或单批次评分详情,可按状态筛选。每次返回一个项目、最多六项评分;如需其余评分项,用 next_criterion_offset 作为 criterion_offset 再查;如需下个项目,用 next_project_offset 作为 project_offset,并将 criterion_offset 设为 0。", ScoresArgs, scores),
|
||||
|
||||
@@ -10,13 +10,16 @@ from sqlalchemy.orm import Session
|
||||
from . import services
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .exports import batch_projects, export_excel, export_project_pdf, project_result
|
||||
from .evidence import CONTENT_ALLOWED
|
||||
from .exports import (BATCH_STATUS_LABELS, batch_projects, export_excel,
|
||||
export_project_pdf, page_type_label, project_result)
|
||||
from .knowledge_pages import page_image_path, page_links
|
||||
from .models import (AuditLog, Course, CourseVersion, CriterionScore, ExportTask,
|
||||
IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project, RubricVersion, ScoringBatch,
|
||||
Student, SubmissionFile, TeacherRevision)
|
||||
from .materialized import sync_course_entities, sync_rubric_entities
|
||||
from .rules import RubricData
|
||||
from .scoring import ATTENTION_STATUSES
|
||||
from .settings_store import key_configured, model_config, save_model_config
|
||||
from .storage import CATEGORIES, directories, scan
|
||||
|
||||
@@ -108,9 +111,20 @@ class ModelSettingsInput(BaseModel):
|
||||
|
||||
@router.get("/dashboard")
|
||||
def dashboard(db: Session = Depends(get_db)):
|
||||
"""Overview counts. Items awaiting teacher review are counted as attention needed.
|
||||
|
||||
A ``needs_review`` item is not a failure: retrieval ran and found only navigation
|
||||
pages where the rule requires body content, so a teacher decides it.
|
||||
"""
|
||||
counts = dict(db.execute(select(CriterionScore.status, func.count(CriterionScore.id))
|
||||
.group_by(CriterionScore.status)).all())
|
||||
return {"courses": db.scalar(select(func.count(Course.id))), "projects": db.scalar(select(func.count(Project.id))),
|
||||
"batches": db.scalar(select(func.count(ScoringBatch.id))),
|
||||
"needs_attention": db.scalar(select(func.count(CriterionScore.id)).where(CriterionScore.status.in_(["failed", "unscorable"]))) }
|
||||
"needs_attention": sum(counts.get(status, 0) for status in ATTENTION_STATUSES),
|
||||
"needs_review": counts.get("needs_review", 0),
|
||||
"unscorable": counts.get("unscorable", 0),
|
||||
"failed": counts.get("failed", 0),
|
||||
"status_counts": counts}
|
||||
|
||||
|
||||
@router.get("/courses")
|
||||
@@ -337,9 +351,32 @@ def knowledge_page(document_id: int, page: int, db: Session = Depends(get_db)):
|
||||
return {"document_id": doc.id, "title": doc.title, "course_version_id": doc.course_version_id,
|
||||
"project_id": doc.project_id, "page": page,
|
||||
"text": record.text,
|
||||
"page_type": record.page_type or "unknown",
|
||||
"page_type_label": page_type_label(record.page_type),
|
||||
"mixed": bool(record.mixed),
|
||||
"section": record.section or "", "chapter": record.chapter or "",
|
||||
"confidence": record.confidence,
|
||||
"classification": record.classification or {},
|
||||
"content_allowed": (record.page_type or "unknown") in CONTENT_ALLOWED,
|
||||
"image_url": page_links(db, document_id, page)["image_url"]}
|
||||
|
||||
|
||||
@router.get("/knowledge/documents/{document_id}/pages")
|
||||
def knowledge_document_pages(document_id: int, db: Session = Depends(get_db)):
|
||||
"""Page inventory with types, so the teacher can see how a document was classified."""
|
||||
doc = found(db, KnowledgeDocument, document_id)
|
||||
records = db.scalars(select(KnowledgePage).where(KnowledgePage.document_id == document_id)
|
||||
.order_by(KnowledgePage.page)).all()
|
||||
return {"document_id": doc.id, "title": doc.title, "index_version": doc.index_version,
|
||||
"pages": [{"page": item.page, "page_type": item.page_type or "unknown",
|
||||
"page_type_label": page_type_label(item.page_type),
|
||||
"mixed": bool(item.mixed), "section": item.section or "",
|
||||
"chapter": item.chapter or "", "confidence": item.confidence,
|
||||
"classification": item.classification or {},
|
||||
"content_allowed": (item.page_type or "unknown") in CONTENT_ALLOWED}
|
||||
for item in records]}
|
||||
|
||||
|
||||
@router.get("/knowledge/documents/{document_id}/pages/{page}/image")
|
||||
def knowledge_page_image(document_id: int, page: int, db: Session = Depends(get_db)):
|
||||
doc = found(db, KnowledgeDocument, document_id)
|
||||
@@ -354,6 +391,28 @@ def ingest_course(course_version_id: int, db: Session = Depends(get_db)):
|
||||
return services.ingest_course(course_version_id, db)
|
||||
|
||||
|
||||
@router.get("/knowledge/stale/{course_version_id}")
|
||||
def stale_knowledge(course_version_id: int, db: Session = Depends(get_db)):
|
||||
"""Documents still on an older index; they are excluded from retrieval until rebuilt."""
|
||||
from .indexing import stale_documents
|
||||
from .rag import INDEX_VERSION
|
||||
found(db, CourseVersion, course_version_id)
|
||||
documents = stale_documents(db, course_version_id)
|
||||
return {"index_version": INDEX_VERSION,
|
||||
"documents": [{"id": item.id, "title": item.title, "type": item.document_type,
|
||||
"project_id": item.project_id, "index_version": item.index_version,
|
||||
"status": item.status} for item in documents]}
|
||||
|
||||
|
||||
@router.post("/knowledge/reindex/{course_version_id}")
|
||||
def reindex_knowledge(course_version_id: int, db: Session = Depends(get_db)):
|
||||
"""Queue a rebuild of every stale index for this course version."""
|
||||
from .indexing import create_reindex_task
|
||||
found(db, CourseVersion, course_version_id)
|
||||
task = create_reindex_task(db, course_version_id)
|
||||
return get_index_task(task.id, db)
|
||||
|
||||
|
||||
@router.get("/index-tasks")
|
||||
def list_index_tasks(project_id: int | None = None, db: Session = Depends(get_db)):
|
||||
query = select(IndexTask).order_by(IndexTask.id.desc()).limit(100)
|
||||
@@ -381,10 +440,20 @@ def create_batch(data: BatchInput, db: Session = Depends(get_db)):
|
||||
return services.create_batch(data, db)
|
||||
|
||||
|
||||
def batch_status_counts(db, batch_id: int) -> dict:
|
||||
rows = db.execute(select(CriterionScore.status, func.count(CriterionScore.id))
|
||||
.where(CriterionScore.batch_id == batch_id).group_by(CriterionScore.status)).all()
|
||||
counts = {status: count for status, count in rows}
|
||||
return {"status_counts": counts, "needs_review": counts.get("needs_review", 0),
|
||||
"unscorable": counts.get("unscorable", 0), "failed": counts.get("failed", 0)}
|
||||
|
||||
|
||||
@router.get("/scoring-batches")
|
||||
def list_batches(db: Session = Depends(get_db)):
|
||||
return [{**{key: getattr(batch, key) for key in ("id", "course_version_id", "rubric_version_id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")},
|
||||
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None}
|
||||
"status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status),
|
||||
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None,
|
||||
**batch_status_counts(db, batch.id)}
|
||||
for batch in db.scalars(select(ScoringBatch).order_by(ScoringBatch.id.desc()))]
|
||||
|
||||
|
||||
@@ -392,7 +461,9 @@ def list_batches(db: Session = Depends(get_db)):
|
||||
def batch_progress(batch_id: int, db: Session = Depends(get_db)):
|
||||
batch = found(db, ScoringBatch, batch_id)
|
||||
return {**{key: getattr(batch, key) for key in ("id", "status", "progress_done", "progress_total", "error", "created_at", "finished_at")},
|
||||
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None}
|
||||
"status_label": BATCH_STATUS_LABELS.get(batch.status, batch.status),
|
||||
"project_ids": batch.project_ids, "legacy_membership": batch.project_ids is None,
|
||||
**batch_status_counts(db, batch_id)}
|
||||
|
||||
|
||||
@router.post("/scoring-batches/{batch_id}/start")
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Persistent teacher conversations and SSE agent orchestration."""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
import unicodedata
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import timedelta
|
||||
from threading import Lock
|
||||
@@ -170,7 +172,8 @@ def _safe_arguments(arguments):
|
||||
if not isinstance(arguments, dict):
|
||||
return {}
|
||||
numeric = {"course_version_id", "batch_id", "project_id", "score_id", "task_id",
|
||||
"rubric_version_id", "score", "offset", "limit", "project_offset", "criterion_offset"}
|
||||
"rubric_version_id", "score", "offset", "limit", "project_offset", "criterion_offset",
|
||||
"document_id"}
|
||||
text_limits = {"query": 300, "criterion_id": 100, "format": 20, "status": 30,
|
||||
"student_no": 50, "relative_dir": 500}
|
||||
result = {}
|
||||
@@ -219,26 +222,58 @@ def _result_summary(result):
|
||||
def _safe_citation(item):
|
||||
if not isinstance(item, dict):
|
||||
return None
|
||||
return {key: value[:limit] if isinstance(value, str) else value for key, limit in
|
||||
(("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("score_url", 500))
|
||||
# The evidence-policy fields travel with the citation so the interface can tell a
|
||||
# report body page from a contents page, and an answer's source from a historical
|
||||
# scoring quotation.
|
||||
label_limits = (("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("score_url", 500),
|
||||
("citation_id", 80), ("page_type", 20), ("page_type_label", 30),
|
||||
("source_role", 20), ("source", 40), ("usage", 60), ("target_label", 120))
|
||||
return {key: value[:limit] if isinstance(value, str) else value for key, limit in label_limits
|
||||
if isinstance((value := item.get(key)), str)} | {
|
||||
key: item[key] for key in ("chunk_id", "document_id", "page", "project_id")
|
||||
if isinstance(item.get(key), int)}
|
||||
key: item[key] for key in ("chunk_id", "document_id", "page", "project_id", "historical")
|
||||
if isinstance(item.get(key), (int, bool)) and not isinstance(item.get(key), str)}
|
||||
|
||||
|
||||
def _answer_citations(items):
|
||||
"""Keep each distinct source passage, including later tool calls and pages cited by the answer."""
|
||||
def _comparable_text(value):
|
||||
return "".join(char for char in unicodedata.normalize("NFKC", str(value or "")) if not char.isspace())
|
||||
|
||||
|
||||
def _cited_pages(content: str) -> set[int]:
|
||||
"""Page and slide numbers the answer actually refers to."""
|
||||
numbers = set()
|
||||
for match in re.finditer(r"第\s*(\d{1,4})\s*(?:页|张幻灯片|页幻灯片)", str(content or "")):
|
||||
numbers.add(int(match.group(1)))
|
||||
return numbers
|
||||
|
||||
|
||||
def _answer_citations(items, content: str = ""):
|
||||
"""Attach only the citations the answer actually used.
|
||||
|
||||
Collecting every tool hit would list material the answer never drew on. A passage is
|
||||
kept when the answer quotes it, or when the answer names the page it sits on. When
|
||||
the answer names no page and quotes nothing, no citation is attached: the answer made
|
||||
no specific evidential claim.
|
||||
"""
|
||||
seen = set()
|
||||
citations = []
|
||||
answer = _comparable_text(content)
|
||||
pages = _cited_pages(content)
|
||||
for item in items:
|
||||
safe = _safe_citation(item)
|
||||
if not safe:
|
||||
continue
|
||||
key = (safe.get("document_id"), safe.get("chunk_id"), safe.get("title"),
|
||||
safe.get("page"), safe.get("quote"), safe.get("url"), safe.get("score_url"))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
citations.append(safe)
|
||||
if key in seen:
|
||||
continue
|
||||
quote = _comparable_text(safe.get("quote"))
|
||||
used = bool(quote) and len(quote) >= 6 and quote[:80] in answer
|
||||
if not used and pages and isinstance(safe.get("page"), int):
|
||||
used = safe["page"] in pages
|
||||
if not used:
|
||||
continue
|
||||
seen.add(key)
|
||||
citations.append(safe)
|
||||
return citations
|
||||
|
||||
|
||||
@@ -326,7 +361,7 @@ def _run_agent(factory, conversation_id, run_id, user_text):
|
||||
content = reply.get("content")
|
||||
if not isinstance(content, str) or not content.strip():
|
||||
raise ModelError("会话模型未返回文字或工具调用")
|
||||
answer_citations = _answer_citations(citations)
|
||||
answer_citations = _answer_citations(citations, content)
|
||||
db.add(ChatMessage(conversation_id=conversation_id, role="assistant", content=content,
|
||||
citations=answer_citations, tool_calls=used))
|
||||
run.status, run.finished_at = "completed", now()
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Evidence policy: which page types may support which kind of judgement.
|
||||
|
||||
The policy is part of the versionable scoring rules. Each required-evidence target
|
||||
declares its ``use``:
|
||||
|
||||
* ``structure`` — the target may be satisfied by a cover page, table of contents or
|
||||
reference list (chapter completeness, bibliography, report layout).
|
||||
* ``content`` — the target may only be satisfied by substantive text, data or tables
|
||||
on body or appendix pages.
|
||||
|
||||
Rules still in the historical string form (``["问题定义", "需求分析"]``) are read with a
|
||||
permissive grammar: wording that names a structural artefact becomes a structure
|
||||
target, everything else becomes a content target. The exact grammar is recorded as
|
||||
``POLICY_VERSION`` in the batch snapshot so a batch can always be re-read against the
|
||||
rules it was scored with.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
from .pages import (CONTENT_PAGE_TYPES, NAVIGATION_PAGE_TYPES, STORE_PAGE_TYPES,
|
||||
STRUCTURE_PAGE_TYPES)
|
||||
|
||||
POLICY_VERSION = 1
|
||||
USE_STRUCTURE = "structure"
|
||||
USE_CONTENT = "content"
|
||||
|
||||
#: Wording that names report packaging rather than what the student did.
|
||||
_STRUCTURE_WORDS = (
|
||||
"标题", "封面", "目录", "章节", "参考文献", "引用规范", "格式", "规范性", "结构完整",
|
||||
"排版", "页码", "附录", "图表编号", "摘要",
|
||||
)
|
||||
|
||||
#: Appendix and body pages are the only pages allowed for a content judgement.
|
||||
CONTENT_ALLOWED = tuple(CONTENT_PAGE_TYPES)
|
||||
#: Structural targets may additionally use cover, contents and reference pages.
|
||||
STRUCTURE_ALLOWED = tuple(STRUCTURE_PAGE_TYPES)
|
||||
#: Navigation pages can never carry a content judgement.
|
||||
NAVIGATION = tuple(NAVIGATION_PAGE_TYPES)
|
||||
|
||||
|
||||
def _looks_structural(text: str) -> bool:
|
||||
value = str(text or "").strip()
|
||||
return bool(value) and any(word in value for word in _STRUCTURE_WORDS)
|
||||
|
||||
|
||||
def normalize_target(raw) -> dict:
|
||||
"""Read one required-evidence target in either the rich or the legacy string form."""
|
||||
if isinstance(raw, dict):
|
||||
label = str(raw.get("label") or raw.get("text") or raw.get("name") or "").strip()
|
||||
use = str(raw.get("use") or "").strip().lower()
|
||||
if use not in (USE_STRUCTURE, USE_CONTENT):
|
||||
use = USE_STRUCTURE if _looks_structural(label) else USE_CONTENT
|
||||
allowed = raw.get("allowed_page_types")
|
||||
if not isinstance(allowed, list) or not allowed:
|
||||
allowed = list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED)
|
||||
allowed = [str(item) for item in allowed if str(item) in STORE_PAGE_TYPES]
|
||||
if not allowed:
|
||||
allowed = list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED)
|
||||
if use == USE_CONTENT:
|
||||
# A content target never admits navigation pages, whatever a rule claims.
|
||||
allowed = [item for item in allowed if item not in NAVIGATION] or list(CONTENT_ALLOWED)
|
||||
return {"label": label or "证据", "use": use, "allowed_page_types": allowed,
|
||||
"query": str(raw.get("query") or label).strip() or (label or "证据")}
|
||||
label = str(raw or "").strip()
|
||||
use = USE_STRUCTURE if _looks_structural(label) else USE_CONTENT
|
||||
return {"label": label or "证据", "use": use,
|
||||
"allowed_page_types": list(STRUCTURE_ALLOWED if use == USE_STRUCTURE else CONTENT_ALLOWED),
|
||||
"query": label or "证据"}
|
||||
|
||||
|
||||
def normalize_targets(required_evidence) -> list[dict]:
|
||||
"""Normalize a criterion's required evidence, preserving order and identity."""
|
||||
values = required_evidence if isinstance(required_evidence, list) else [required_evidence]
|
||||
targets = []
|
||||
for index, raw in enumerate(values):
|
||||
if raw is None or (isinstance(raw, str) and not raw.strip()):
|
||||
continue
|
||||
target = normalize_target(raw)
|
||||
target["index"] = index
|
||||
target["target_id"] = str((raw.get("target_id") if isinstance(raw, dict) else "") or f"t{index}")
|
||||
targets.append(target)
|
||||
if not targets:
|
||||
targets.append({"label": "正文实质内容", "use": USE_CONTENT, "allowed_page_types": list(CONTENT_ALLOWED),
|
||||
"query": "正文实质内容", "index": 0, "target_id": "t0"})
|
||||
return targets
|
||||
|
||||
|
||||
def content_targets(criterion: dict) -> list[dict]:
|
||||
return [target for target in normalize_targets(criterion.get("required_evidence")) if target["use"] == USE_CONTENT]
|
||||
|
||||
|
||||
def any_content_target(criterion: dict) -> bool:
|
||||
return bool(content_targets(criterion))
|
||||
|
||||
|
||||
def page_type_allowed(page_type: str, allowed: list[str]) -> bool:
|
||||
"""An ``unknown`` page is a body page only when the target already allows body text.
|
||||
|
||||
``unknown`` marks a page whose reading is genuinely unsettled, so it may never be
|
||||
used as the sole support for a content judgement, and it never widens a structural
|
||||
target either. It stays retrievable so a teacher can inspect it.
|
||||
"""
|
||||
if page_type == "unknown":
|
||||
return "body" in allowed
|
||||
return page_type in allowed
|
||||
|
||||
|
||||
def content_evidence_allowed(page_type: str) -> bool:
|
||||
"""Only substantive body and appendix pages support a content judgement."""
|
||||
return page_type in CONTENT_ALLOWED
|
||||
|
||||
|
||||
def heading_only_text(text: str) -> bool:
|
||||
"""True when a passage is nothing but section titles, so it proves no content.
|
||||
|
||||
The test is structural, not a length test: a quotation is heading-only when every
|
||||
line of it is a chapter/section heading or a contents entry. A short technical
|
||||
quotation such as a measured value is content, so it must not be rejected here.
|
||||
"""
|
||||
from .pages import heading_level, is_toc_entry
|
||||
|
||||
lines = [line.strip() for line in str(text or "").splitlines() if line.strip()]
|
||||
if not lines:
|
||||
return False
|
||||
for line in lines:
|
||||
if is_toc_entry(line) or heading_level(line):
|
||||
continue
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
_PLACEHOLDER = re.compile(r"^\s*(待确认|待定|未知|无|none|null|-+|—+)\s*$", re.IGNORECASE)
|
||||
|
||||
|
||||
def meaningful(value) -> bool:
|
||||
text = str(value or "").strip()
|
||||
return bool(text) and not _PLACEHOLDER.match(text)
|
||||
|
||||
|
||||
def plan_snapshot(criteria: list[dict]) -> dict:
|
||||
"""The policy attached to a batch snapshot, so old batches stay re-readable."""
|
||||
return {
|
||||
"version": POLICY_VERSION,
|
||||
"content_allowed_page_types": list(CONTENT_ALLOWED),
|
||||
"structure_allowed_page_types": list(STRUCTURE_ALLOWED),
|
||||
"navigation_page_types": list(NAVIGATION),
|
||||
"criteria": {criterion["criterion_id"]: normalize_targets(criterion.get("required_evidence"))
|
||||
for criterion in criteria},
|
||||
}
|
||||
@@ -11,6 +11,32 @@ from .knowledge_pages import evidence_links
|
||||
from .models import (CriterionScore, ExportTask, Project, ScoreEvidence, ScoringBatch, Student, SubmissionFile)
|
||||
from .rules import totals
|
||||
|
||||
#: Teacher-facing wording for a criterion status, shared by exports and the API.
|
||||
STATUS_LABELS = {
|
||||
"pending": "待评分",
|
||||
"scored": "模型已评分",
|
||||
"reviewed": "教师已复核",
|
||||
"needs_review": "待教师复核",
|
||||
"unscorable": "材料缺失或解析失败",
|
||||
"failed": "模型调用失败",
|
||||
}
|
||||
PAGE_TYPE_LABELS = {
|
||||
"cover": "封面", "abstract": "摘要", "toc": "目录", "body": "正文",
|
||||
"appendix": "附录", "references": "参考文献", "unknown": "识别不确定",
|
||||
"mixed": "目录与正文混合页",
|
||||
}
|
||||
#: Batch status wording. ``review_required`` means grading finished but some criteria
|
||||
#: still need a teacher decision, which is not the same as an incomplete batch.
|
||||
BATCH_STATUS_LABELS = {
|
||||
"created": "待启动", "queued": "排队中", "running": "运行中",
|
||||
"completed": "已完成", "review_required": "已完成,有待复核项",
|
||||
"partial": "部分完成", "failed": "失败",
|
||||
}
|
||||
|
||||
|
||||
def page_type_label(value) -> str:
|
||||
return PAGE_TYPE_LABELS.get(str(value or "unknown"), "未分类页面")
|
||||
|
||||
|
||||
def project_result(db, batch: ScoringBatch, project: Project):
|
||||
scores = db.scalars(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
|
||||
@@ -19,10 +45,15 @@ def project_result(db, batch: ScoringBatch, project: Project):
|
||||
criteria = batch.rubric_snapshot["criteria"]
|
||||
totals_ = totals({key: score.final_score for key, score in by_id.items()}, criteria, batch.rubric_snapshot["weights"])
|
||||
rows = []
|
||||
def evidence_dict(item):
|
||||
links = evidence_links(db, item)
|
||||
def evidence_dict(item, links):
|
||||
return {"page": item.page, "quote": item.quote, "file_id": item.submission_file_id,
|
||||
"document_id": item.document_id,
|
||||
"target_id": item.target_id or "", "target_label": item.target_label or "",
|
||||
"page_type": item.page_type or links.get("page_type") or "unknown",
|
||||
"source_role": item.source_role or "content",
|
||||
# A quotation saved before the evidence policy existed is labelled as a
|
||||
# historical citation and never presented as report body evidence.
|
||||
"historical": bool(item.historical),
|
||||
"document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料",
|
||||
**links}
|
||||
for criterion in criteria:
|
||||
@@ -35,7 +66,8 @@ def project_result(db, batch: ScoringBatch, project: Project):
|
||||
"model_comment": score.model_comment if score else "", "teacher_comment": score.teacher_comment if score else None,
|
||||
"missing_evidence": score.missing_evidence if score else [], "suggestion": score.suggestion if score else "",
|
||||
"status": score.status if score else "pending", "error": score.error if score else "",
|
||||
"evidence": [evidence_dict(item) for item in evidence]})
|
||||
"evidence_gaps": (score.evidence_gaps or []) if score else [],
|
||||
"evidence": [evidence_dict(item, evidence_links(db, item)) for item in evidence]})
|
||||
student = db.get(Student, project.student_id)
|
||||
return {"project_id": project.id, "student_no": student.student_no, "name": student.name, "topic": project.topic,
|
||||
"totals": totals_, "rows": rows, "facts": project.facts}
|
||||
@@ -97,14 +129,21 @@ def _html_report(result, batch):
|
||||
for row in result["rows"]:
|
||||
criterion = row["criterion"]
|
||||
evidences = "".join(
|
||||
f"<li>{esc(e['document'])} · 第{e['page']}{'张幻灯片' if str(e['document']).lower().endswith('.pptx') else '页'}:{esc(e['quote'])}</li>"
|
||||
f"<li>{esc(e['document'])} · 第{e['page']}{'张幻灯片' if str(e['document']).lower().endswith('.pptx') else '页'}"
|
||||
f"({esc(page_type_label(e.get('page_type')))}{',历史评分引用' if e.get('historical') else ''})"
|
||||
f"{' 支持:' + esc(e['target_label']) if e.get('target_label') else ''}:{esc(e['quote'])}</li>"
|
||||
for e in row["evidence"]
|
||||
)
|
||||
missing = "、".join(row["missing_evidence"] or [])
|
||||
gaps = ";".join(f"{gap.get('target_label')}(检索范围:{'、'.join(page_type_label(t) for t in gap.get('searched_page_types') or []) or '正文'})"
|
||||
for gap in row.get("evidence_gaps") or [])
|
||||
review = "本项缺少正文证据,已转教师复核。" if row["status"] == "needs_review" else ""
|
||||
rows.append(f"<section><h3>{esc(criterion['name'])}({esc(row['final_score'])} / {criterion['max_score']})</h3>"
|
||||
f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(row['status'])}</p>"
|
||||
f"<p>模型原始分:{esc(row['model_score'])};教师修改分:{esc(row['teacher_score'])};状态:{esc(STATUS_LABELS.get(row['status'], row['status']))}</p>"
|
||||
f"<p>模型评语:{esc(row['model_comment'])}</p><p>教师评语:{esc(row['teacher_comment'])}</p>"
|
||||
f"<p>缺失证据:{esc(missing)}</p><p>建议:{esc(row['suggestion'])}</p><p>异常:{esc(row['error'])}</p>"
|
||||
f"<p>{esc(review)}</p>"
|
||||
f"<p>缺失证据:{esc(missing)}</p><p>缺正文证据的目标:{esc(gaps)}</p>"
|
||||
f"<p>建议:{esc(row['suggestion'])}</p><p>异常:{esc(row['error'])}</p>"
|
||||
f"<ul>{evidences}</ul></section>")
|
||||
totals_ = result["totals"]
|
||||
return f"""<!doctype html><html lang="zh"><meta charset="utf-8"><style>
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
from sqlalchemy import select
|
||||
|
||||
from .db import SessionLocal
|
||||
from .models import IndexTask, SubmissionFile, now
|
||||
from .models import IndexTask, KnowledgeDocument, SubmissionFile, now
|
||||
from .rag import INDEX_VERSION
|
||||
from .scoring import parse_submission
|
||||
|
||||
|
||||
@@ -14,7 +15,13 @@ def run_index_task(task_id: int, *, session_factory=SessionLocal, embedder=None)
|
||||
task.status, task.error = "running", ""
|
||||
db.commit()
|
||||
try:
|
||||
if task.kind == "project":
|
||||
if task.kind == "reindex":
|
||||
# Rebuild documents whose index predates the current chunking, so a
|
||||
# legacy index is never silently used as classified body text.
|
||||
task.result = reindex_stale(db, task.course_version_id, embedder=embedder)
|
||||
if task.result["failed"]:
|
||||
raise ValueError("部分资料重建索引失败:" + "、".join(item["title"] for item in task.result["failed"]))
|
||||
elif task.kind == "project":
|
||||
file = db.get(SubmissionFile, task.submission_file_id)
|
||||
if not file or file.recognition_status != "recognized":
|
||||
raise ValueError("材料已取消选择,请重新预览")
|
||||
@@ -35,6 +42,74 @@ def run_index_task(task_id: int, *, session_factory=SessionLocal, embedder=None)
|
||||
db.commit()
|
||||
|
||||
|
||||
def stale_documents(db, course_version_id: int | None = None, *, limit: int | None = None):
|
||||
"""Documents whose stored index predates the current chunking or is not ready."""
|
||||
query = select(KnowledgeDocument).where(
|
||||
(KnowledgeDocument.index_version != INDEX_VERSION) | (KnowledgeDocument.status != "ready"))
|
||||
if course_version_id is not None:
|
||||
query = query.where(KnowledgeDocument.course_version_id == course_version_id)
|
||||
query = query.order_by(KnowledgeDocument.id)
|
||||
if limit:
|
||||
query = query.limit(limit)
|
||||
return db.scalars(query).all()
|
||||
|
||||
|
||||
def reindex_stale(db, course_version_id: int | None = None, *, embedder=None, limit: int | None = None):
|
||||
"""Rebuild every stale index from the saved parse output. Retryable and auditable.
|
||||
|
||||
Student material is rebuilt from ``submission_files.parsed``, which the parser saved
|
||||
on disk; the original file is never re-read or modified. Course material follows the
|
||||
normal ingest path, because its pages come from the mounted course documents.
|
||||
"""
|
||||
from .services import parse_submission
|
||||
from .storage import sha256, secure_path
|
||||
|
||||
documents = stale_documents(db, course_version_id, limit=limit)
|
||||
rebuilt, skipped, failed = [], [], []
|
||||
for document in documents:
|
||||
try:
|
||||
if document.submission_file_id:
|
||||
file = db.get(SubmissionFile, document.submission_file_id)
|
||||
if not file:
|
||||
skipped.append({"document_id": document.id, "title": document.title,
|
||||
"reason": "来源材料已不存在"})
|
||||
continue
|
||||
if sha256(secure_path(file.relative_path)) != file.sha256:
|
||||
skipped.append({"document_id": document.id, "title": document.title,
|
||||
"reason": "源文件已变化,请重新扫描并确认导入"})
|
||||
continue
|
||||
file.parse_status = "pending"
|
||||
db.commit()
|
||||
parsed = parse_submission(db, file, embedder=embedder) if embedder else parse_submission(db, file)
|
||||
rebuilt.append({"document_id": document.id, "title": document.title,
|
||||
"pages": len(parsed.get("pages", []))})
|
||||
elif document.project_id is None:
|
||||
# Course material is re-ingested by the course task, not per document.
|
||||
skipped.append({"document_id": document.id, "title": document.title,
|
||||
"reason": "课程资料请通过课程入库重建"})
|
||||
else:
|
||||
skipped.append({"document_id": document.id, "title": document.title,
|
||||
"reason": "缺少可重建的解析结果"})
|
||||
except Exception as exc:
|
||||
failed.append({"document_id": document.id, "title": document.title, "error": str(exc)[:300]})
|
||||
db.rollback()
|
||||
return {"index_version": INDEX_VERSION, "rebuilt": rebuilt, "skipped": skipped,
|
||||
"failed": failed, "remaining": len(stale_documents(db, course_version_id))}
|
||||
|
||||
|
||||
def create_reindex_task(db, course_version_id: int):
|
||||
"""Queue one reindex job for a course version, reusing an open task when there is one."""
|
||||
from .services import enqueue_index_task
|
||||
existing = db.scalar(select(IndexTask).where(IndexTask.course_version_id == course_version_id,
|
||||
IndexTask.kind == "reindex", IndexTask.status.in_(["queued", "running"])).order_by(IndexTask.id.desc()))
|
||||
task = existing or IndexTask(course_version_id=course_version_id, kind="reindex", status="queued")
|
||||
if not existing:
|
||||
db.add(task)
|
||||
db.commit()
|
||||
enqueue_index_task(db, task.id)
|
||||
return task
|
||||
|
||||
|
||||
def create_course_index_task(db, course_version_id: int):
|
||||
from .services import enqueue_index_task
|
||||
existing = db.scalar(select(IndexTask).where(IndexTask.course_version_id == course_version_id,
|
||||
|
||||
@@ -36,18 +36,33 @@ def page_image_path(db, doc: KnowledgeDocument, page: int) -> Path | None:
|
||||
return path if path and path.is_relative_to(root) and path.is_file() else None
|
||||
|
||||
|
||||
def page_links(db, document_id: int | None, page: int | None) -> dict[str, str | None]:
|
||||
def page_links(db, document_id: int | None, page: int | None) -> dict:
|
||||
"""Stable citation links plus the page type, so callers can label the source."""
|
||||
empty = {"url": None, "image_url": None, "page_type": None, "section": None}
|
||||
if not document_id or not page:
|
||||
return {"url": None, "image_url": None}
|
||||
return empty
|
||||
doc = db.get(KnowledgeDocument, document_id)
|
||||
if not doc:
|
||||
return {"url": None, "image_url": None}
|
||||
return empty
|
||||
record = db.get(KnowledgePage, (document_id, page))
|
||||
if not record:
|
||||
return {"url": None, "image_url": None}
|
||||
return empty
|
||||
return {"url": text_url(document_id, page),
|
||||
"image_url": image_url(document_id, page) if page_image_path(db, doc, page) else None}
|
||||
"image_url": image_url(document_id, page) if page_image_path(db, doc, page) else None,
|
||||
"page_type": record.page_type or "unknown",
|
||||
"section": record.section or ""}
|
||||
|
||||
|
||||
def evidence_links(db, evidence) -> dict[str, str | None]:
|
||||
def evidence_links(db, evidence) -> dict:
|
||||
return page_links(db, evidence.document_id, evidence.page)
|
||||
|
||||
|
||||
def page_types(db, document_ids) -> dict[int, str]:
|
||||
"""Page type per page of the given documents, for labelling citations in bulk."""
|
||||
from sqlalchemy import select as _select
|
||||
ids = [item for item in dict.fromkeys(document_ids or []) if item]
|
||||
if not ids:
|
||||
return {}
|
||||
rows = db.execute(_select(KnowledgePage.document_id, KnowledgePage.page, KnowledgePage.page_type)
|
||||
.where(KnowledgePage.document_id.in_(ids))).all()
|
||||
return {(document_id, page): (page_type or "unknown") for document_id, page, page_type in rows}
|
||||
|
||||
+78
-27
@@ -15,25 +15,39 @@ class EvidenceResult(BaseModel):
|
||||
chunk_id: int
|
||||
page: int = Field(ge=1)
|
||||
quote: str = Field(min_length=1)
|
||||
#: Which required-evidence target this quotation is offered to support.
|
||||
target_id: str = ""
|
||||
|
||||
|
||||
class ScoreResult(BaseModel):
|
||||
criterion_id: str
|
||||
score: float = Field(ge=0)
|
||||
#: ``None`` means the model declined to score because admissible evidence is absent.
|
||||
score: float | None = Field(default=None, ge=0)
|
||||
max_score: float = Field(gt=0)
|
||||
reason: str = Field(min_length=1)
|
||||
evidence: list[EvidenceResult]
|
||||
evidence: list[EvidenceResult] = Field(default_factory=list)
|
||||
missing_evidence: list[str] = Field(default_factory=list)
|
||||
suggestion: str = ""
|
||||
confidence: float = Field(ge=0, le=1)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def valid_range(self):
|
||||
if self.score > self.max_score or (self.score > 0 and not self.evidence):
|
||||
raise ValueError("score exceeds maximum or lacks evidence")
|
||||
if self.score is not None and self.score > self.max_score:
|
||||
raise ValueError("score exceeds maximum")
|
||||
if self.score and not self.evidence:
|
||||
raise ValueError("a positive score requires evidence")
|
||||
return self
|
||||
|
||||
|
||||
class EvidenceGap(BaseModel):
|
||||
"""A content target that still has no body evidence after targeted re-checking."""
|
||||
|
||||
target_id: str = ""
|
||||
target_label: str = ""
|
||||
searched_page_types: list[str] = Field(default_factory=list)
|
||||
reason: str = ""
|
||||
|
||||
|
||||
class FactResult(BaseModel):
|
||||
student_no: str = ""
|
||||
name: str = ""
|
||||
@@ -132,33 +146,58 @@ class DeepSeekClient:
|
||||
raise ModelError("当前端点或模型未返回原生 tool_calls;请配置支持工具调用的端点和模型")
|
||||
|
||||
|
||||
def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict]):
|
||||
def locate_quote(text: str, quote: str):
|
||||
"""Locate a model-written quotation inside the retrieved text.
|
||||
|
||||
PDF line breaks and punctuation differ from what the model reproduces, so matching
|
||||
is done on NFKC-normalized text with whitespace and punctuation removed, while the
|
||||
returned string is still the exact source substring.
|
||||
"""
|
||||
if not text or not quote:
|
||||
return None
|
||||
if quote in text:
|
||||
return quote
|
||||
|
||||
def comparable(value: str):
|
||||
return "".join(char for original in value for char in unicodedata.normalize("NFKC", original)
|
||||
if not char.isspace() and not unicodedata.category(char).startswith("P"))
|
||||
|
||||
needle = comparable(quote)
|
||||
if len(needle) < 4:
|
||||
return None
|
||||
positions = []
|
||||
normalized = []
|
||||
for index, original in enumerate(text):
|
||||
for char in unicodedata.normalize("NFKC", original):
|
||||
if not char.isspace() and not unicodedata.category(char).startswith("P"):
|
||||
normalized.append(char)
|
||||
positions.append(index)
|
||||
start = "".join(normalized).find(needle)
|
||||
if start < 0:
|
||||
return None
|
||||
return text[positions[start]:positions[start + len(needle) - 1] + 1]
|
||||
|
||||
|
||||
def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict], *, targets=None,
|
||||
require_content_evidence: bool | None = None):
|
||||
"""Check identity, quotation fidelity and the evidence-source policy.
|
||||
|
||||
The model may understand evidence and propose re-checks; the page permissions are
|
||||
enforced here, in code, and cannot be argued away by the model's own reasoning.
|
||||
"""
|
||||
from .evidence import (USE_CONTENT, content_evidence_allowed, heading_only_text,
|
||||
normalize_targets, page_type_allowed)
|
||||
|
||||
if result.criterion_id != criterion["criterion_id"] or result.max_score != criterion["max_score"]:
|
||||
raise ModelError("模型评分项或满分与规则版本不一致")
|
||||
if require_content_evidence is None:
|
||||
require_content_evidence = any(target["use"] == USE_CONTENT
|
||||
for target in normalize_targets(criterion.get("required_evidence")))
|
||||
targets = targets if targets is not None else normalize_targets(criterion.get("required_evidence"))
|
||||
by_target = {target["target_id"]: target for target in targets}
|
||||
default_content = next((target for target in targets if target["use"] == USE_CONTENT), None)
|
||||
by_id = {chunk["chunk_id"]: chunk for chunk in chunks}
|
||||
|
||||
def locate_quote(text: str, quote: str):
|
||||
def comparable(value: str):
|
||||
return "".join(char for original in value for char in unicodedata.normalize("NFKC", original)
|
||||
if not char.isspace() and not unicodedata.category(char).startswith("P"))
|
||||
|
||||
if quote in text:
|
||||
return quote
|
||||
needle = comparable(quote)
|
||||
if len(needle) < 4:
|
||||
return None
|
||||
positions = []
|
||||
normalized = []
|
||||
for index, original in enumerate(text):
|
||||
for char in unicodedata.normalize("NFKC", original):
|
||||
if not char.isspace() and not unicodedata.category(char).startswith("P"):
|
||||
normalized.append(char)
|
||||
positions.append(index)
|
||||
start = "".join(normalized).find(needle)
|
||||
if start < 0:
|
||||
return None
|
||||
return text[positions[start]:positions[start + len(needle) - 1] + 1]
|
||||
|
||||
for evidence in result.evidence:
|
||||
chunk = by_id.get(evidence.chunk_id)
|
||||
quote = locate_quote(chunk["text"], evidence.quote) if chunk else None
|
||||
@@ -169,5 +208,17 @@ def validate_score(result: ScoreResult, criterion: dict, chunks: list[dict]):
|
||||
chunk, quote = matches[0]
|
||||
if quote is None:
|
||||
raise ModelError("模型证据未能匹配检索原文")
|
||||
page_type = chunk.get("page_type") or "unknown"
|
||||
target = by_target.get(evidence.target_id) or default_content or (targets[0] if targets else None)
|
||||
if target is None:
|
||||
raise ModelError("模型证据未关联任何必备证据目标")
|
||||
if not page_type_allowed(page_type, target["allowed_page_types"]):
|
||||
raise ModelError(f"模型证据来自不允许的页面类型({page_type}),不能支持「{target['label']}」")
|
||||
if target["use"] == USE_CONTENT and not content_evidence_allowed(page_type):
|
||||
raise ModelError(f"目录、摘要或参考文献页不能作为「{target['label']}」的内容证据")
|
||||
if target["use"] == USE_CONTENT and heading_only_text(quote):
|
||||
raise ModelError(f"「{target['label']}」的证据只有章节标题,没有实质内容")
|
||||
evidence.chunk_id, evidence.page, evidence.quote = chunk["chunk_id"], chunk["page"], quote
|
||||
evidence.target_id = target["target_id"]
|
||||
return result
|
||||
|
||||
|
||||
@@ -176,6 +176,14 @@ class KnowledgePage(Base):
|
||||
page = Column(Integer, primary_key=True)
|
||||
text = Column(Text, nullable=False, default="")
|
||||
image_path = Column(String(1200), nullable=True) # Relative to processed_path.
|
||||
# Page-level classification: cover, abstract, toc, body, references or unknown.
|
||||
page_type = Column(String(20), nullable=False, default="unknown", server_default="unknown")
|
||||
mixed = Column(Boolean, nullable=False, default=False, server_default="false")
|
||||
section = Column(String(500), default="")
|
||||
chapter = Column(String(500), default="")
|
||||
confidence = Column(Float, nullable=True)
|
||||
# Bounded classifier evidence: which signals decided the type, kept for audit.
|
||||
classification = Column(JSON, default=dict, nullable=False)
|
||||
|
||||
|
||||
class KnowledgeChunk(Base):
|
||||
@@ -187,6 +195,9 @@ class KnowledgeChunk(Base):
|
||||
document_type = Column(String(80), nullable=False)
|
||||
page = Column(Integer, nullable=False)
|
||||
section = Column(String(500), default="")
|
||||
chapter = Column(String(500), default="")
|
||||
page_type = Column(String(20), nullable=False, default="unknown", server_default="unknown")
|
||||
block_type = Column(String(20), nullable=False, default="body", server_default="body")
|
||||
text = Column(Text, nullable=False)
|
||||
embedding = Column(Embedding, nullable=True)
|
||||
|
||||
@@ -225,6 +236,9 @@ class CriterionScore(Base):
|
||||
suggestion = Column(Text, default="")
|
||||
status = Column(String(40), default="pending")
|
||||
error = Column(Text, default="")
|
||||
#: Content targets that still lacked body evidence, with the search that was run.
|
||||
#: Kept after teacher review so the original retrieval problem stays auditable.
|
||||
evidence_gaps = Column(JSON, default=list)
|
||||
__table_args__ = (UniqueConstraint("batch_id", "project_id", "criterion_id"),)
|
||||
|
||||
@property
|
||||
@@ -241,6 +255,14 @@ class ScoreEvidence(Base):
|
||||
page = Column(Integer, nullable=False)
|
||||
quote = Column(Text, nullable=False)
|
||||
image_path = Column(String(1200), nullable=True)
|
||||
# Which required-evidence target this quotation is offered to support.
|
||||
target_id = Column(String(50), default="", server_default="")
|
||||
target_label = Column(String(300), default="", server_default="")
|
||||
# Page type of the source passage, and its role in the judgement.
|
||||
page_type = Column(String(20), default="unknown", server_default="unknown")
|
||||
source_role = Column(String(20), default="content", server_default="content")
|
||||
# True when the quotation was written before this retrieval policy existed.
|
||||
historical = Column(Boolean, default=False, nullable=False, server_default="false")
|
||||
|
||||
|
||||
class TeacherRevision(Base):
|
||||
|
||||
@@ -0,0 +1,443 @@
|
||||
"""Page-level classification, block splitting and real section paths.
|
||||
|
||||
A page carries one ``page_type``: cover, abstract, toc, body, references, unknown.
|
||||
A page may additionally be ``mixed`` when a table of contents and body text truly
|
||||
share one page; only that page is split into blocks so the body part is not lost.
|
||||
|
||||
Classification never depends on a fixed page number or on the single word "目录".
|
||||
It combines the page's own signals (heading title, dot-leader entry density,
|
||||
trailing page numbers, abstract/reference wording, cover fields, footer style)
|
||||
with document-level continuity for continuation pages (TOC continued, abstract
|
||||
continued, references spanning pages).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
CLASSIFIER_VERSION = 3
|
||||
|
||||
PAGE_TYPES = ("cover", "abstract", "toc", "body", "appendix", "references", "unknown")
|
||||
|
||||
#: Page types whose text may support a content judgement about what a student did.
|
||||
CONTENT_PAGE_TYPES = ("body", "appendix")
|
||||
#: Page types whose text may support a structural judgement (chapter completeness etc.).
|
||||
STRUCTURE_PAGE_TYPES = ("cover", "abstract", "toc", "references", "body", "appendix")
|
||||
#: Page types that may never be used as content evidence on their own.
|
||||
NAVIGATION_PAGE_TYPES = ("cover", "abstract", "toc", "references")
|
||||
#: Page types a page may be stored as, used to validate rule configuration.
|
||||
STORE_PAGE_TYPES = PAGE_TYPES
|
||||
|
||||
BLOCK_BODY = "body"
|
||||
BLOCK_TOC = "toc"
|
||||
BLOCK_TITLE = "title"
|
||||
|
||||
_DOT_LEADER = re.compile(r"(?:[.·•…․…]\s*){3,}")
|
||||
_TRAILING_ANCHOR = re.compile(r"(\d{1,4})\s*$")
|
||||
_CHAPTER = re.compile(r"^第\s*([0-9一二三四五六七八九十百]+)\s*章\s*(.*)$")
|
||||
_APPENDIX = re.compile(r"^附\s*录\s*([A-Za-z0-9一二三四五六七八九十]+)?\s*(.*)$")
|
||||
_NUMBERED = re.compile(r"^(\d+(?:\.\d+)*)[\s、..]+(\S.*)$")
|
||||
_COVER_FIELD = re.compile(r"(学\s*号|姓\s*名|题\s*目|指导教师|专\s*业|学\s*院|日\s*期)\s*[::]")
|
||||
_PAGE_NUMBER_LINE = re.compile(r"^\s*(?:\d{1,3}|[IVXLC]{1,5}|第\s*\d+\s*页)\s*$")
|
||||
_ABSTRACT_TITLE = re.compile(r"^\s*(摘\s*要|abstract)\s*$", re.IGNORECASE)
|
||||
_REFERENCE_TITLE = re.compile(r"^\s*(参\s*考\s*文\s*献|references?|参考文献与资料)\s*$", re.IGNORECASE)
|
||||
_ACK_TITLE = re.compile(r"^\s*(致\s*谢|acknowledge?ments?)\s*$", re.IGNORECASE)
|
||||
_TOC_TITLE = re.compile(r"^\s*(目\s*录|contents?|table\s+of\s+contents)\s*$", re.IGNORECASE)
|
||||
_REFERENCE_ENTRY = re.compile(r"^\s*\[\d{1,3}\]")
|
||||
_CJK = re.compile(r"[㐀-䶿一-鿿]")
|
||||
_HEADING_NUMBER = re.compile(r"^\s*(第\s*[0-9一二三四五六七八九十百]+\s*章|\d+(\.\d+)*|附\s*录\s*[A-Za-z0-9]?)\s*\S")
|
||||
|
||||
#: A body block must carry at least this many non-space characters to count as substance.
|
||||
MIN_BODY_CHARS = 60
|
||||
#: Heading-only text shorter than this never counts as content evidence on its own.
|
||||
MIN_CONTENT_QUOTE_CHARS = 25
|
||||
#: A line at least this long, outside a heading, reads as running prose. The bar is low
|
||||
#: on purpose: the rule separates text from headings and labels, not long from short
|
||||
#: text, so a brief but real sentence is still content.
|
||||
MIN_PROSE_CHARS = 4
|
||||
#: A numbered heading title must stay this short; longer text is prose or a list item.
|
||||
MAX_HEADING_TITLE_CHARS = 40
|
||||
#: Wording that marks the line as running prose rather than a heading.
|
||||
_PROSE_PUNCTUATION = "。,;!?,;"
|
||||
|
||||
#: The documents are Chinese engineering reports; the keyword recall uses these units.
|
||||
BIGRAM_MIN = 2
|
||||
|
||||
|
||||
def _lines(text: str) -> list[str]:
|
||||
return [line.strip() for line in (text or "").splitlines() if line.strip()]
|
||||
|
||||
|
||||
def normalize_heading(text: str) -> str:
|
||||
"""Compare headings across the TOC and the body: unified width, no spaces/dots."""
|
||||
value = unicodedata.normalize("NFKC", text or "")
|
||||
value = _DOT_LEADER.sub("", value)
|
||||
value = _TRAILING_ANCHOR.sub("", value.strip())
|
||||
return "".join(char for char in value if not char.isspace() and char not in "..·•…")
|
||||
|
||||
|
||||
def is_toc_entry(line: str) -> bool:
|
||||
"""A dotted leader plus a trailing page or slide number."""
|
||||
stripped = (line or "").strip()
|
||||
if not stripped or not _DOT_LEADER.search(stripped):
|
||||
return False
|
||||
return bool(_TRAILING_ANCHOR.search(stripped))
|
||||
|
||||
|
||||
def heading_syntax(line: str) -> tuple[int, str] | None:
|
||||
"""Return ``(level, title)`` when the line has the shape of a heading.
|
||||
|
||||
This is a purely syntactic reading. Numbered prose such as
|
||||
``4. 实时图像传输:将摄像头画面…`` and enumerated list items are not headings: a
|
||||
numbered heading has a short Chinese title, no sentence punctuation and no trailing
|
||||
colon. Equation and matrix rows such as ``3.3.3`` or ``1 0 0 1 Zc`` are never
|
||||
headings, and a line cut mid-formula (trailing combining mark) is not either.
|
||||
"""
|
||||
stripped = (line or "").strip()
|
||||
if not stripped or is_toc_entry(stripped) or len(stripped) > 120:
|
||||
return None
|
||||
match = _CHAPTER.match(stripped)
|
||||
if match:
|
||||
return 1, stripped
|
||||
match = _APPENDIX.match(stripped)
|
||||
if match and not _COVER_FIELD.search(stripped):
|
||||
return 2, stripped
|
||||
if _ABSTRACT_TITLE.match(stripped) or _REFERENCE_TITLE.match(stripped) or _ACK_TITLE.match(stripped):
|
||||
return 1, stripped
|
||||
match = _NUMBERED.match(stripped)
|
||||
if not match:
|
||||
return None
|
||||
title = match.group(2).strip()
|
||||
if not title or title.endswith(":") or title.endswith(":"):
|
||||
return None
|
||||
if len(title) > MAX_HEADING_TITLE_CHARS or any(char in title for char in _PROSE_PUNCTUATION):
|
||||
return None
|
||||
if not _CJK.search(title):
|
||||
return None
|
||||
if unicodedata.category(title[-1]).startswith("M"):
|
||||
return None
|
||||
return 1 + match.group(1).count("."), stripped
|
||||
|
||||
|
||||
def heading_level(line: str, toc_titles: set[str] | None = None) -> tuple[int, str] | None:
|
||||
"""A heading for the section path: syntactic shape, confirmed by the contents list.
|
||||
|
||||
When the document carries its own table of contents, that list is the authority on
|
||||
which numbered lines are real headings. That is what keeps an enumerated list item or
|
||||
a numbered paragraph from becoming a chapter in the extracted section path. Callers
|
||||
that only need to know whether a line reads as a heading use ``heading_syntax``.
|
||||
"""
|
||||
found = heading_syntax(line)
|
||||
if not found:
|
||||
return None
|
||||
if toc_titles and normalize_heading(line) not in toc_titles:
|
||||
return None
|
||||
return found
|
||||
|
||||
|
||||
def _footer_style(lines: list[str]) -> str:
|
||||
"""Classify the trailing line so repeated headers/footers can be removed."""
|
||||
if not lines:
|
||||
return "none"
|
||||
last = lines[-1]
|
||||
if re.fullmatch(r"[IVXLC]{1,5}", last):
|
||||
return "roman"
|
||||
if re.fullmatch(r"\d{1,3}", last):
|
||||
return "arabic"
|
||||
if re.fullmatch(r"第\s*\d+\s*页", last):
|
||||
return "page_cn"
|
||||
return "text"
|
||||
|
||||
|
||||
def _signals(page: dict, *, index: int, total: int, toc_titles: set[str] | None = None,
|
||||
repeated: set[str] | None = None) -> dict:
|
||||
text = str(page.get("text") or "")
|
||||
lines = _lines(text)
|
||||
# Prose detection ignores repeated page headers and footers: a running header is not
|
||||
# something the student wrote, so it must not make an empty page look like content.
|
||||
prose_source = [line for line in lines if line not in (repeated or ())]
|
||||
entries = [line for line in lines if is_toc_entry(line)]
|
||||
headings = [item for item in (heading_level(line, toc_titles) for line in lines) if item]
|
||||
body_lines = [line for line in lines if line not in set(entries)]
|
||||
plain_chars = sum(len(line) for line in body_lines)
|
||||
first = lines[0] if lines else ""
|
||||
reference_entries = [line for line in lines if _REFERENCE_ENTRY.match(line)]
|
||||
titles = [position for position, line in enumerate(lines)
|
||||
if _REFERENCE_TITLE.match(line) or _ACK_TITLE.match(line)]
|
||||
# A bibliography page is dense reference entries, or a reference title near the top.
|
||||
reference_ratio = len(reference_entries) / len(lines) if lines else 0.0
|
||||
reference_page = (len(reference_entries) >= 5 and reference_ratio >= 0.5
|
||||
or any(position <= 2 for position in titles))
|
||||
return {
|
||||
"chars": len(text.strip()),
|
||||
"lines": len(lines),
|
||||
"toc_entries": len(entries),
|
||||
"toc_ratio": round(len(entries) / len(lines), 3) if lines else 0.0,
|
||||
"headings": len(headings),
|
||||
"chapter_headings": sum(1 for level, _ in headings if level == 1),
|
||||
"body_chars": plain_chars,
|
||||
"first_line": first[:120],
|
||||
"position": index,
|
||||
"total": total,
|
||||
"footer": _footer_style(lines),
|
||||
"cover_fields": len(_COVER_FIELD.findall(text)),
|
||||
"has_table": bool(page.get("tables")),
|
||||
"reference_entries": len(reference_entries),
|
||||
"reference_ratio": round(reference_ratio, 3),
|
||||
"reference_page": reference_page,
|
||||
"appendix_title": any(_APPENDIX.match(line.strip()) for line in lines),
|
||||
# Prose punctuation separates running text from a label-style cover page.
|
||||
"prose_punctuation": any(char in text for char in _PROSE_PUNCTUATION),
|
||||
# Lines that read as running text rather than headings, labels or dotted entries.
|
||||
"prose_lines": [line for line in prose_source if _is_prose_line(line)],
|
||||
}
|
||||
|
||||
|
||||
def strip_repeated_lines(pages: list[dict]) -> set[str]:
|
||||
"""Header/footer lines repeated across many pages, removed from searchable text."""
|
||||
counts: dict[str, int] = {}
|
||||
for page in pages:
|
||||
lines = _lines(str(page.get("text") or ""))
|
||||
if not lines:
|
||||
continue
|
||||
candidates = {lines[0][:120]}
|
||||
if len(lines) > 1:
|
||||
candidates.add(lines[-1][:60])
|
||||
for candidate in candidates:
|
||||
if candidate and not is_toc_entry(candidate):
|
||||
counts[candidate] = counts.get(candidate, 0) + 1
|
||||
threshold = max(3, int(len(pages) * 0.4 + 0.5))
|
||||
return {line for line, count in counts.items() if count >= threshold}
|
||||
|
||||
|
||||
def clean_page_lines(page: dict, repeated: set[str]) -> list[str]:
|
||||
"""Lines kept for indexing: repeated headers/footers and bare page numbers removed."""
|
||||
lines = _lines(str(page.get("text") or ""))
|
||||
kept = []
|
||||
for position, line in enumerate(lines):
|
||||
if line in repeated and (position == 0 or position == len(lines) - 1):
|
||||
continue
|
||||
if _PAGE_NUMBER_LINE.match(line) and position == len(lines) - 1:
|
||||
continue
|
||||
kept.append(line)
|
||||
return kept
|
||||
|
||||
|
||||
def _is_prose_line(line: str) -> bool:
|
||||
"""A line that reads as running text rather than a heading, label or dotted entry.
|
||||
|
||||
The syntactic heading check is used, not the contents-confirmed one: a heading that
|
||||
the contents list happens not to mention is still a heading, so it must not be
|
||||
counted as prose when deciding whether a page carries content.
|
||||
"""
|
||||
stripped = line.strip()
|
||||
if is_toc_entry(stripped) or _TOC_TITLE.match(stripped) or heading_syntax(stripped):
|
||||
return False
|
||||
if _PAGE_NUMBER_LINE.match(stripped):
|
||||
return False
|
||||
return len(stripped) >= MIN_PROSE_CHARS
|
||||
|
||||
|
||||
def split_blocks(lines: list[str]) -> list[dict]:
|
||||
"""Split page lines into TOC and body blocks so a mixed page keeps both parts."""
|
||||
blocks: list[dict] = []
|
||||
current: dict | None = None
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
# A contents title and its dotted entries are one navigation block. A lone
|
||||
# dotted line with no title is not a table of contents, so it stays as body.
|
||||
kind = BLOCK_TOC if is_toc_entry(line) or _TOC_TITLE.match(stripped) else BLOCK_BODY
|
||||
if current is None or current["kind"] != kind:
|
||||
current = {"kind": kind, "lines": []}
|
||||
blocks.append(current)
|
||||
current["lines"].append(line)
|
||||
output = []
|
||||
for block in blocks:
|
||||
text = "\n".join(block["lines"]).strip()
|
||||
if not text:
|
||||
continue
|
||||
has_entry = any(is_toc_entry(line) for line in block["lines"])
|
||||
if block["kind"] == BLOCK_TOC and not has_entry:
|
||||
# Only a title line, with no entries under it: this is a heading, not contents.
|
||||
output.append({"kind": BLOCK_BODY, "text": text, "substantive": False})
|
||||
continue
|
||||
output.append({"kind": block["kind"], "text": text})
|
||||
# A block made only of headings carries navigation, not content.
|
||||
for block in output:
|
||||
if block["kind"] == BLOCK_BODY:
|
||||
block["substantive"] = (any(_is_prose_line(line) for line in block["text"].splitlines())
|
||||
or len(block["text"]) >= MIN_BODY_CHARS)
|
||||
else:
|
||||
block["substantive"] = False
|
||||
return output
|
||||
|
||||
|
||||
def classifier_signals(pages: list[dict]) -> list[dict]:
|
||||
"""Per-page signals, shared by classification and by audit output."""
|
||||
total = len(pages)
|
||||
return [_signals(page, index=position, total=total) for position, page in enumerate(pages, start=1)]
|
||||
|
||||
|
||||
def _is_cover(signal: dict) -> bool:
|
||||
"""A cover page carries identity fields, not prose.
|
||||
|
||||
The weak rule requires a first page of a document that actually has a body, with
|
||||
label-like lines and no sentence punctuation. Without those guards a short
|
||||
single-page document would be mistaken for a cover page and lose all its content.
|
||||
"""
|
||||
if signal["cover_fields"] >= 2 and signal["body_chars"] < 600:
|
||||
return True
|
||||
return (signal["position"] == 1 and signal["total"] >= 3 and signal["chars"] <= 400
|
||||
and signal["lines"] <= 8 and signal["toc_entries"] == 0 and signal["headings"] == 0
|
||||
and not signal["has_table"] and not signal["prose_punctuation"])
|
||||
|
||||
|
||||
def _is_toc_page(signal: dict, *, previous_is_toc: bool) -> bool:
|
||||
if signal["toc_entries"] >= 4 and signal["toc_ratio"] >= 0.4:
|
||||
return True
|
||||
if _TOC_TITLE.match(signal["first_line"]) and signal["toc_entries"] >= 2:
|
||||
return True
|
||||
# Continuation page: the previous page is a table of contents and this page is
|
||||
# still almost nothing but dotted entries.
|
||||
return bool(previous_is_toc and signal["toc_entries"] >= 2 and signal["toc_ratio"] >= 0.5)
|
||||
|
||||
|
||||
def _looks_heading_only(signal: dict, blocks: list[dict]) -> bool:
|
||||
"""A page that carries no running prose at all, so it proves no content.
|
||||
|
||||
The test is structural, not a length test: a short page with one real sentence is
|
||||
body text, a page with a table carries data, while a page of nothing but headings
|
||||
or labels cannot support a judgement about what was done.
|
||||
"""
|
||||
if signal["has_table"]:
|
||||
return False
|
||||
if any(block["kind"] == BLOCK_BODY and block.get("substantive") for block in blocks):
|
||||
return False
|
||||
return not signal["prose_lines"]
|
||||
|
||||
|
||||
def classify_document(pages: list[dict]) -> list[dict]:
|
||||
"""Classify every page and extract real section paths from body headings only."""
|
||||
repeated = strip_repeated_lines(pages)
|
||||
# The document's own contents list is the authority on which numbered lines are
|
||||
# real headings; this keeps enumerated list items and equations out of the path.
|
||||
toc_titles = set()
|
||||
for page in pages:
|
||||
for line in _lines(str(page.get("text") or "")):
|
||||
if is_toc_entry(line):
|
||||
title = normalize_heading(line)
|
||||
if title:
|
||||
toc_titles.add(title)
|
||||
toc_titles = toc_titles or None
|
||||
signals = [_signals(page, index=position, total=len(pages), toc_titles=toc_titles, repeated=repeated)
|
||||
for position, page in enumerate(pages, start=1)]
|
||||
results: list[dict] = []
|
||||
previous = None
|
||||
chapter = ""
|
||||
section = ""
|
||||
for position, page in enumerate(pages, start=1):
|
||||
signal = signals[position - 1]
|
||||
lines = clean_page_lines(page, repeated)
|
||||
blocks = split_blocks(lines)
|
||||
toc_blocks = [block for block in blocks if block["kind"] == BLOCK_TOC]
|
||||
body_blocks = [block for block in blocks if block["kind"] == BLOCK_BODY and block["substantive"]]
|
||||
first = signal["first_line"]
|
||||
|
||||
appendix = signal["appendix_title"]
|
||||
if _TOC_TITLE.match(first) and signal["toc_entries"] >= 1:
|
||||
page_type, confidence = "toc", 0.95
|
||||
elif _ABSTRACT_TITLE.match(first) or (previous == "abstract" and signal["footer"] in ("roman", "none")
|
||||
and signal["headings"] == 0 and signal["chars"] >= 100):
|
||||
page_type, confidence = "abstract", 0.9
|
||||
elif _REFERENCE_TITLE.match(first) or _ACK_TITLE.match(first):
|
||||
page_type, confidence = "references", 0.9
|
||||
elif _is_toc_page(signal, previous_is_toc=(previous == "toc")):
|
||||
page_type, confidence = "toc", 0.85 if signal["toc_ratio"] >= 0.4 else 0.75
|
||||
elif previous == "references" and signal["headings"] == 0 and signal["toc_entries"] == 0:
|
||||
page_type, confidence = "references", 0.7
|
||||
elif signal["reference_page"] and signal["toc_entries"] == 0 and signal["chapter_headings"] <= 1:
|
||||
page_type, confidence = "references", 0.8
|
||||
elif appendix:
|
||||
# An appendix carries substantive material, so it counts as content, but it
|
||||
# is labelled separately so the interface can show where a quotation came from.
|
||||
page_type, confidence = "appendix", 0.8
|
||||
elif _is_cover(signal):
|
||||
# Checked before the heading-only rule: a cover page carries identity fields
|
||||
# and no prose, so it would otherwise be mistaken for an unsettled page.
|
||||
page_type, confidence = "cover", 0.8
|
||||
elif _looks_heading_only(signal, blocks) and signal["toc_entries"] == 0:
|
||||
# A body page that carries only a section title cannot support a content
|
||||
# judgement on its own, so it is not silently treated as ordinary body text.
|
||||
page_type, confidence = "unknown", 0.5
|
||||
else:
|
||||
page_type, confidence = "body", 0.6
|
||||
confidence = 0.85 if body_blocks and signal["toc_entries"] == 0 else confidence
|
||||
|
||||
mixed = page_type == "toc" and bool(body_blocks)
|
||||
if mixed:
|
||||
confidence = 0.7
|
||||
if page_type in ("body", "appendix", "unknown") or mixed:
|
||||
# Structure-extracted headings come from body text only, never from a TOC.
|
||||
for block in body_blocks:
|
||||
for line in block["text"].splitlines():
|
||||
found = heading_level(line, toc_titles)
|
||||
if not found:
|
||||
continue
|
||||
level, title = found
|
||||
if level == 1:
|
||||
chapter, section = title, title
|
||||
else:
|
||||
section = title
|
||||
# A table of contents may only name the chapter path; the body supplies the
|
||||
# section wording. Navigation pages therefore carry the chapter, not a section.
|
||||
section_for_page = section if page_type in ("body", "unknown") or mixed else chapter
|
||||
results.append({
|
||||
"page": position,
|
||||
"page_type": page_type,
|
||||
"mixed": mixed,
|
||||
"chapter": chapter,
|
||||
"section": section_for_page,
|
||||
"blocks": blocks,
|
||||
"confidence": round(confidence, 2),
|
||||
"signals": {**signal, "repeated_line_count": len(repeated), "toc_blocks": len(toc_blocks)},
|
||||
"classifier_version": CLASSIFIER_VERSION,
|
||||
})
|
||||
previous = page_type
|
||||
return results
|
||||
|
||||
|
||||
def block_text(blocks: list[dict], *, kinds: tuple[str, ...] = (BLOCK_BODY,)) -> str:
|
||||
return "\n".join(block["text"] for block in blocks if block["kind"] in kinds).strip()
|
||||
|
||||
|
||||
def toc_entries(blocks: list[dict]) -> list[str]:
|
||||
"""Heading-shaped entries read out of a table of contents page."""
|
||||
entries = []
|
||||
for block in blocks:
|
||||
if block["kind"] != BLOCK_TOC:
|
||||
continue
|
||||
for line in block["text"].splitlines():
|
||||
if is_toc_entry(line):
|
||||
entries.append(line)
|
||||
return entries
|
||||
|
||||
|
||||
def searchable_pages(pages: list[dict]) -> list[dict]:
|
||||
"""Pages prepared for indexing: cleaned lines, typed blocks and section paths."""
|
||||
classification = classify_document(pages)
|
||||
output = []
|
||||
for page, item in zip(pages, classification):
|
||||
body = block_text(item["blocks"])
|
||||
output.append({
|
||||
"page_number": page.get("page_number"),
|
||||
"section": item["section"] or page.get("section", ""),
|
||||
"chapter": item["chapter"],
|
||||
"text": item.get("text", ""),
|
||||
"tables": page.get("tables", []),
|
||||
"rendered_image_path": page.get("rendered_image_path"),
|
||||
"page_type": item["page_type"],
|
||||
"blocks": item["blocks"],
|
||||
"classification": item,
|
||||
"index_text": body,
|
||||
})
|
||||
return output
|
||||
+362
-39
@@ -1,31 +1,130 @@
|
||||
"""Indexing and hybrid retrieval with page-type aware candidate filtering.
|
||||
|
||||
Chinese keyword recall does not use PostgreSQL ``to_tsvector('simple', …)``. That
|
||||
configuration has no Chinese segmenter, so a whole run of Chinese characters becomes a
|
||||
single token; measured on the course reports, the query ``文献调研`` does not match the
|
||||
page that contains ``第2章文献调研与技术现状``. Recall therefore uses character bigrams
|
||||
plus whole ASCII/number tokens, which needs no extension and behaves the same on
|
||||
PostgreSQL and SQLite.
|
||||
|
||||
Semantic and keyword scores are fused by rank (reciprocal rank fusion), never by adding
|
||||
raw numbers: a cosine similarity and a bigram overlap are not on a common scale.
|
||||
"""
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
from sqlalchemy import cast, delete, func, or_, select
|
||||
from sqlalchemy import case, cast, delete, func, literal, or_, select
|
||||
|
||||
from .config import get_settings
|
||||
from .knowledge_pages import page_text, relative_image_path
|
||||
from .models import KnowledgeChunk, KnowledgeDocument, KnowledgePage
|
||||
from .pages import (BLOCK_BODY, BLOCK_TOC, classify_document, is_toc_entry,
|
||||
normalize_heading, searchable_pages)
|
||||
|
||||
INDEX_VERSION = 3
|
||||
|
||||
#: Fusion constant for reciprocal rank fusion; the usual value from the IR literature.
|
||||
RRF_K = 60
|
||||
#: Candidate pools are gathered independently so both retrievers get a chance.
|
||||
SEMANTIC_POOL = 60
|
||||
KEYWORD_POOL = 60
|
||||
#: A chunk must be at least this long to count as substantive content evidence.
|
||||
MIN_CHUNK_CHARS = 60
|
||||
#: At most this many chunks from one page in the first retrieval pass.
|
||||
DEFAULT_PAGE_LIMIT = 2
|
||||
#: Soft cap on chunks from one section once a reliable section path exists.
|
||||
DEFAULT_SECTION_CAP = 3
|
||||
#: Maximum chunks handed to the model for one criterion, across all targets.
|
||||
SCORE_EVIDENCE_LIMIT = 14
|
||||
|
||||
_ASCII_TOKEN = re.compile(r"[A-Za-z][A-Za-z0-9_+\-.]*|\d+(?:\.\d+)*")
|
||||
_CJK_RUN = re.compile(r"[㐀-䶿一-鿿]+")
|
||||
|
||||
|
||||
INDEX_VERSION = 2
|
||||
def query_terms(query: str) -> tuple[list[str], list[str]]:
|
||||
"""Split a query into (ascii tokens lowercased, CJK character bigrams).
|
||||
|
||||
Single-character CJK runs are dropped: they carry too little signal and would make
|
||||
every chunk with a common character look relevant.
|
||||
"""
|
||||
value = str(query or "")
|
||||
ascii_tokens = [token.lower() for token in _ASCII_TOKEN.findall(value)]
|
||||
bigrams: list[str] = []
|
||||
seen = set()
|
||||
for run in _CJK_RUN.findall(value):
|
||||
for index in range(len(run) - 1):
|
||||
bigram = run[index:index + 2]
|
||||
if bigram not in seen:
|
||||
seen.add(bigram)
|
||||
bigrams.append(bigram)
|
||||
return sorted(set(ascii_tokens)), bigrams
|
||||
|
||||
|
||||
def _keyword_query(db, filters, query: str, limit: int):
|
||||
"""Keyword recall ranked by bigram/token overlap over the already-filtered rows.
|
||||
|
||||
PostgreSQL's ``to_tsvector('simple', …)`` cannot segment Chinese, so it is not used
|
||||
for recall. The overlap is computed with ``strpos``/``substr``/``generate_series``
|
||||
so the same expression works on every dialect SQLAlchemy targets here.
|
||||
"""
|
||||
ascii_tokens, bigrams = query_terms(query)
|
||||
if not ascii_tokens and not bigrams:
|
||||
return []
|
||||
total = len(ascii_tokens) + len(bigrams)
|
||||
score = None
|
||||
for token in ascii_tokens:
|
||||
hit = case((func.strpos(func.lower(KnowledgeChunk.text), literal(token)) > 0, 1.0), else_=0.0)
|
||||
score = hit if score is None else score + hit
|
||||
if bigrams:
|
||||
series = select(func.generate_series(1, func.greatest(func.length(KnowledgeChunk.text) - 1, 0)).label("i")).subquery("series")
|
||||
matches = (select(func.count()).select_from(series)
|
||||
.where(func.substr(KnowledgeChunk.text, series.c.i, 2).in_(bigrams)).scalar_subquery())
|
||||
score = matches if score is None else score + matches
|
||||
score = (score / float(total)).label("keyword")
|
||||
rows = db.execute(select(KnowledgeChunk.id, score).where(*filters)
|
||||
.order_by(score.desc(), KnowledgeChunk.id.asc()).limit(limit)).all()
|
||||
return [row[0] for row in rows if float(row[1] or 0) > 0]
|
||||
|
||||
|
||||
def split_pages(pages: list[dict], *, size=650, overlap=100):
|
||||
"""Split pages into searchable chunks carrying page type and section path."""
|
||||
for page in pages:
|
||||
text = page.get("text", "").strip()
|
||||
blocks = [text] if text else []
|
||||
for table in page.get("tables", []):
|
||||
blocks.append("表格: " + str(table.get("rows", table)))
|
||||
page_type = page.get("page_type") or "body"
|
||||
chapter = page.get("chapter", "")
|
||||
section = page.get("section", "")
|
||||
blocks = page.get("blocks")
|
||||
if not blocks:
|
||||
blocks = [{"kind": BLOCK_BODY, "text": str(page.get("text") or "").strip()}]
|
||||
blocks.extend({"kind": BLOCK_BODY, "text": "表格: " + str(table.get("rows", table))}
|
||||
for table in page.get("tables", []))
|
||||
for block in blocks:
|
||||
if block["kind"] == BLOCK_TOC:
|
||||
# A contents page is navigation, so its text is not body content.
|
||||
yield {"page": page["page_number"], "page_type": "toc", "block_type": BLOCK_TOC,
|
||||
"chapter": chapter, "section": section, "text": block["text"]}
|
||||
continue
|
||||
text_block = block["text"].strip()
|
||||
if not text_block:
|
||||
continue
|
||||
for table in ([block["tables"]] if block.get("tables") else []):
|
||||
for table_item in table:
|
||||
text_block += "\n表格: " + str(table_item.get("rows", table_item))
|
||||
# Table rows of a page are appended once, on the page's first body chunk.
|
||||
start = 0
|
||||
while start < len(block):
|
||||
yield {"page": page["page_number"], "section": page.get("section", ""), "text": block[start:start + size]}
|
||||
if start + size >= len(block):
|
||||
while start < len(text_block):
|
||||
yield {"page": page["page_number"], "page_type": page_type, "block_type": BLOCK_BODY,
|
||||
"chapter": chapter, "section": section, "text": text_block[start:start + size]}
|
||||
if start + size >= len(text_block):
|
||||
break
|
||||
start += size - overlap
|
||||
|
||||
|
||||
def build_chunks(pages: list[dict]) -> list[dict]:
|
||||
"""Chunks for one document, classifying pages first when they are not yet typed."""
|
||||
prepared = pages if pages and "page_type" in (pages[0] or {}) else searchable_pages(pages)
|
||||
return list(split_pages(prepared))
|
||||
|
||||
|
||||
@lru_cache(maxsize=2)
|
||||
def local_encoder(model_path: str):
|
||||
path = Path(model_path)
|
||||
@@ -56,7 +155,8 @@ def ingest(db, *, course_version_id: int, project_id: int | None, submission_fil
|
||||
KnowledgeDocument.title == title))
|
||||
if existing and existing.source_hash == source_hash and existing.index_version == INDEX_VERSION and existing.status == "ready":
|
||||
return existing
|
||||
chunks = list(split_pages(pages))
|
||||
prepared = searchable_pages(pages)
|
||||
chunks = list(split_pages(prepared))
|
||||
vectors = embedder([chunk["text"] for chunk in chunks])
|
||||
if len(vectors) != len(chunks):
|
||||
raise RuntimeError("嵌入向量数量与知识片段数量不一致")
|
||||
@@ -71,42 +171,265 @@ def ingest(db, *, course_version_id: int, project_id: int | None, submission_fil
|
||||
db.add(doc)
|
||||
doc.index_version, doc.status = INDEX_VERSION, "indexing"
|
||||
db.flush()
|
||||
for source in pages:
|
||||
db.add(KnowledgePage(document_id=doc.id, page=source["page_number"], text=page_text(source),
|
||||
image_path=relative_image_path(source.get("rendered_image_path"))))
|
||||
for source, prepared_page in zip(pages, prepared):
|
||||
classification = prepared_page["classification"]
|
||||
db.add(KnowledgePage(
|
||||
document_id=doc.id, page=source["page_number"], text=page_text(source),
|
||||
image_path=relative_image_path(source.get("rendered_image_path")),
|
||||
page_type=classification["page_type"], mixed=bool(classification["mixed"]),
|
||||
section=classification["section"], chapter=classification["chapter"],
|
||||
confidence=classification["confidence"],
|
||||
classification={**classification["signals"], "classifier_version": classification["classifier_version"],
|
||||
"blocks": [{"kind": block["kind"], "chars": len(block["text"]),
|
||||
"substantive": block.get("substantive", False)}
|
||||
for block in classification["blocks"]]}))
|
||||
for chunk, vector in zip(chunks, vectors):
|
||||
db.add(KnowledgeChunk(document_id=doc.id, course_version_id=course_version_id, project_id=project_id,
|
||||
document_type=category, page=chunk["page"], section=chunk["section"], text=chunk["text"], embedding=vector))
|
||||
document_type=category, page=chunk["page"], section=chunk["section"],
|
||||
chapter=chunk["chapter"], page_type=chunk["page_type"],
|
||||
block_type=chunk["block_type"], text=chunk["text"], embedding=vector))
|
||||
doc.status = "ready"
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def retrieve(db, *, course_version_id: int, project_id: int, query: str, category: str | None = None,
|
||||
limit=10, embedder=embed, include_public=True, public_only=False):
|
||||
"""Hybrid retrieval with project isolation and an optional public course namespace."""
|
||||
scope = (KnowledgeChunk.project_id.is_(None) if public_only else
|
||||
or_(KnowledgeChunk.project_id == project_id, KnowledgeChunk.project_id.is_(None)) if include_public else
|
||||
KnowledgeChunk.project_id == project_id)
|
||||
filters = [KnowledgeChunk.course_version_id == course_version_id, scope]
|
||||
def _scope_filter(project_id: int | None, include_public: bool, public_only: bool):
|
||||
if public_only:
|
||||
return KnowledgeChunk.project_id.is_(None)
|
||||
if include_public:
|
||||
return or_(KnowledgeChunk.project_id == project_id, KnowledgeChunk.project_id.is_(None))
|
||||
return KnowledgeChunk.project_id == project_id
|
||||
|
||||
|
||||
def _base_filters(*, course_version_id: int, project_id: int | None, category, categories,
|
||||
include_public: bool, public_only: bool, allowed_page_types, exclude_document_ids):
|
||||
filters = [KnowledgeChunk.course_version_id == course_version_id,
|
||||
_scope_filter(project_id, include_public, public_only)]
|
||||
if category:
|
||||
filters.append(KnowledgeChunk.document_type == category)
|
||||
vector = embedder([query])[0]
|
||||
if categories:
|
||||
filters.append(KnowledgeChunk.document_type.in_(list(categories)))
|
||||
if allowed_page_types is not None:
|
||||
filters.append(KnowledgeChunk.page_type.in_(list(allowed_page_types)))
|
||||
if exclude_document_ids:
|
||||
filters.append(KnowledgeChunk.document_id.not_in(list(exclude_document_ids)))
|
||||
return filters
|
||||
|
||||
|
||||
def _keyword_query(db, filters, query: str, limit: int):
|
||||
"""Keyword recall ranked by bigram/token overlap over the already-filtered rows.
|
||||
|
||||
PostgreSQL's ``to_tsvector('simple', …)`` cannot segment Chinese, so it is not used
|
||||
for recall. The overlap is computed with ``strpos``/``substr`` so the same expression
|
||||
works on every dialect SQLAlchemy targets here.
|
||||
"""
|
||||
ascii_tokens, bigrams = query_terms(query)
|
||||
if not ascii_tokens and not bigrams:
|
||||
return []
|
||||
total = len(ascii_tokens) + len(bigrams)
|
||||
score = None
|
||||
for token in ascii_tokens:
|
||||
term = literal(token)
|
||||
hit = case((func.strpos(func.lower(KnowledgeChunk.text), term) > 0, 1.0), else_=0.0)
|
||||
score = hit if score is None else score + hit
|
||||
if bigrams:
|
||||
start = func.generate_series(1, func.greatest(func.length(KnowledgeChunk.text) - 1, 0))
|
||||
series = select(start.label("i")).subquery("series")
|
||||
matches = (select(func.count()).select_from(series)
|
||||
.where(func.substr(KnowledgeChunk.text, series.c.i, 2).in_(bigrams))
|
||||
.scalar_subquery())
|
||||
score = matches if score is None else score + matches
|
||||
score = (score / float(total)).label("keyword")
|
||||
rows = db.execute(select(KnowledgeChunk.id, score).where(*filters)
|
||||
.order_by(score.desc(), KnowledgeChunk.id.asc()).limit(limit)).all()
|
||||
return [row[0] for row in rows if float(row[1] or 0) > 0]
|
||||
|
||||
|
||||
def keyword_overlap(text_value: str, ascii_tokens: list[str], bigrams: list[str]) -> float:
|
||||
"""Bigram/token overlap used by the non-PostgreSQL path and by tests."""
|
||||
value = str(text_value or "")
|
||||
lower = value.lower()
|
||||
hits = sum(1 for token in ascii_tokens if token in lower)
|
||||
total = len(ascii_tokens)
|
||||
hits += sum(1 for bigram in bigrams if bigram in value)
|
||||
total += len(bigrams)
|
||||
return hits / total if total else 0.0
|
||||
|
||||
|
||||
def _semantic_query(db, filters, vector, limit: int):
|
||||
if db.bind.dialect.name == "postgresql":
|
||||
from pgvector.sqlalchemy import Vector
|
||||
semantic = (1 - cast(KnowledgeChunk.embedding, Vector(1024)).cosine_distance(vector)).label("semantic")
|
||||
lexical = func.ts_rank(func.to_tsvector("simple", KnowledgeChunk.text), func.plainto_tsquery("simple", query)).label("lexical")
|
||||
rows = db.execute(select(KnowledgeChunk, semantic, lexical).where(*filters).order_by(semantic.desc(), lexical.desc()).limit(limit * 3)).all()
|
||||
ranked = [(row[0], float(row.semantic or 0) * .7 + float(row.lexical or 0) * .3) for row in rows]
|
||||
else:
|
||||
chunks = db.scalars(select(KnowledgeChunk).where(*filters)).all()
|
||||
terms = [query[i:i+2] for i in range(max(0, len(query)-1)) if query[i:i+2].strip()]
|
||||
ranked = []
|
||||
for chunk in chunks:
|
||||
semantic = sum(a*b for a, b in zip(chunk.embedding or [], vector))
|
||||
lexical = sum(term in chunk.text for term in terms) / max(len(terms), 1)
|
||||
ranked.append((chunk, semantic * .7 + lexical * .3))
|
||||
ranked.sort(key=lambda pair: pair[1], reverse=True)
|
||||
return [{"chunk_id": chunk.id, "document_id": chunk.document_id, "project_id": chunk.project_id,
|
||||
"document_type": chunk.document_type, "page": chunk.page, "section": chunk.section,
|
||||
"text": chunk.text, "rank": float(rank)} for chunk, rank in ranked[:limit]]
|
||||
distance = cast(KnowledgeChunk.embedding, Vector(1024)).cosine_distance(vector)
|
||||
rows = db.execute(select(KnowledgeChunk.id).where(*filters)
|
||||
.order_by(distance.asc(), KnowledgeChunk.id.asc()).limit(limit)).all()
|
||||
return [row[0] for row in rows]
|
||||
rows = db.execute(select(KnowledgeChunk.id, KnowledgeChunk.embedding).where(*filters)).all()
|
||||
scored = [(chunk_id, sum(a * b for a, b in zip(embedding or [], vector)))
|
||||
for chunk_id, embedding in rows]
|
||||
scored.sort(key=lambda pair: (-pair[1], pair[0]))
|
||||
return [chunk_id for chunk_id, _ in scored[:limit]]
|
||||
|
||||
|
||||
def _fuse(db, ids: list[int], semantic_ids: list[int], keyword_ids: list[int]):
|
||||
"""Reciprocal rank fusion over the two ranked lists, keeping discovery order stable."""
|
||||
scores: dict[int, float] = {chunk_id: 0.0 for chunk_id in ids}
|
||||
for ranked in (semantic_ids, keyword_ids):
|
||||
for rank, chunk_id in enumerate(ranked):
|
||||
if chunk_id in scores:
|
||||
scores[chunk_id] += 1.0 / (RRF_K + rank + 1)
|
||||
return scores
|
||||
|
||||
|
||||
def _row_dict(chunk):
|
||||
return {"chunk_id": chunk.id, "document_id": chunk.document_id, "project_id": chunk.project_id,
|
||||
"document_type": chunk.document_type, "page": chunk.page, "section": chunk.section,
|
||||
"chapter": chunk.chapter, "page_type": chunk.page_type, "block_type": chunk.block_type,
|
||||
"text": chunk.text}
|
||||
|
||||
|
||||
def normalize_hit(hit: dict) -> dict:
|
||||
"""Fill the fields the rest of the pipeline reads, so one shape flows through.
|
||||
|
||||
An alternate retriever or a test double may return only the essentials. Filling the
|
||||
defaults here keeps every consumer from re-deriving them; the authoritative page type
|
||||
is still re-read from storage when a score is produced.
|
||||
"""
|
||||
return {"chunk_id": hit.get("chunk_id"), "document_id": hit.get("document_id"),
|
||||
"project_id": hit.get("project_id"), "document_type": hit.get("document_type"),
|
||||
"page": hit.get("page"), "text": hit.get("text") or "",
|
||||
"section": hit.get("section") or "", "chapter": hit.get("chapter") or "",
|
||||
"page_type": hit.get("page_type") or "unknown", "block_type": hit.get("block_type") or "legacy",
|
||||
"rank": float(hit.get("rank") or 0.0)}
|
||||
|
||||
|
||||
def retrieve(db, *, course_version_id: int, project_id: int, query: str, category: str | None = None,
|
||||
categories=None, limit=10, embedder=embed, include_public=True, public_only=False,
|
||||
allowed_page_types=None, exclude_document_ids=(), substantive_only=False,
|
||||
semantic_pool: int | None = None, keyword_pool: int | None = None):
|
||||
"""Filtered hybrid retrieval, then rank fusion over the two independent recalls.
|
||||
|
||||
Page filtering happens inside the query, so it always precedes the candidate limit:
|
||||
a contents page can never displace a body page simply by scoring well.
|
||||
"""
|
||||
filters = _base_filters(course_version_id=course_version_id, project_id=project_id, category=category,
|
||||
categories=categories, include_public=include_public, public_only=public_only,
|
||||
allowed_page_types=allowed_page_types, exclude_document_ids=exclude_document_ids)
|
||||
if substantive_only:
|
||||
filters.append(KnowledgeChunk.block_type != BLOCK_TOC)
|
||||
vector = embedder([query])[0]
|
||||
semantic_ids = _semantic_query(db, filters, vector, semantic_pool or SEMANTIC_POOL)
|
||||
keyword_ids = _keyword_query(db, filters, query, keyword_pool or KEYWORD_POOL)
|
||||
ids = list(dict.fromkeys(semantic_ids + keyword_ids))
|
||||
if not ids:
|
||||
return []
|
||||
scores = _fuse(db, ids, semantic_ids, keyword_ids)
|
||||
chunks = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.id.in_(ids))).all()
|
||||
usable = []
|
||||
for chunk in chunks:
|
||||
if substantive_only and (chunk.block_type == BLOCK_TOC
|
||||
or (chunk.page_type == "body" and is_toc_entry(chunk.text)
|
||||
and len(chunk.text.strip()) < MIN_CHUNK_CHARS)):
|
||||
continue
|
||||
usable.append(chunk)
|
||||
usable.sort(key=lambda chunk: (-scores.get(chunk.id, 0.0), chunk.id))
|
||||
return [{**_row_dict(chunk), "rank": float(scores.get(chunk.id, 0.0))} for chunk in usable[:limit]]
|
||||
|
||||
|
||||
def _page_diverse(chunks: list[dict], limit: int, *, page_limit: int, section_cap: int | None):
|
||||
"""Spread the first pass over pages, then allow a bounded number per section."""
|
||||
picked: list[dict] = []
|
||||
per_page: dict[tuple, int] = {}
|
||||
per_section: dict[str, int] = {}
|
||||
remaining = []
|
||||
for chunk in chunks:
|
||||
page_key = (chunk.get("document_id"), chunk.get("page"))
|
||||
section = str(chunk.get("section") or "")
|
||||
if len(picked) < limit and per_page.get(page_key, 0) < page_limit:
|
||||
if section_cap and section and per_section.get(section, 0) >= section_cap:
|
||||
remaining.append(chunk)
|
||||
continue
|
||||
picked.append(chunk)
|
||||
per_page[page_key] = per_page.get(page_key, 0) + 1
|
||||
if section:
|
||||
per_section[section] = per_section.get(section, 0) + 1
|
||||
else:
|
||||
remaining.append(chunk)
|
||||
for chunk in remaining:
|
||||
if len(picked) >= limit:
|
||||
break
|
||||
picked.append(chunk)
|
||||
return picked[:limit]
|
||||
|
||||
|
||||
def retrieve_targets(db, *, course_version_id: int, project_id: int, targets: list[dict], category=None,
|
||||
categories=None, limit=SCORE_EVIDENCE_LIMIT, embedder=embed, include_public=False,
|
||||
page_limit=DEFAULT_PAGE_LIMIT, section_cap=DEFAULT_SECTION_CAP,
|
||||
per_target_limit: int | None = None, navigator=None, retrieve_fn=retrieve):
|
||||
"""Search once per required-evidence target and merge with page-level diversity.
|
||||
|
||||
Every target is searched separately with its own query and its own allowed page
|
||||
types, so a contents page permitted for a structural target can never flood a
|
||||
content target's evidence. A target counts as unmet when its *own* search found
|
||||
nothing admissible, which is what decides a teacher review.
|
||||
"""
|
||||
merged: list[dict] = []
|
||||
seen: set[int] = set()
|
||||
per_target: dict[str, list[dict]] = {}
|
||||
for target in targets:
|
||||
allowed = target.get("allowed_page_types")
|
||||
raw_hits = retrieve_fn(db, course_version_id=course_version_id, project_id=project_id, category=category,
|
||||
categories=categories, query=target.get("query") or target.get("label") or "",
|
||||
limit=per_target_limit or max(limit, 8), embedder=embed,
|
||||
include_public=include_public, allowed_page_types=allowed,
|
||||
substantive_only=target.get("use") != "structure",
|
||||
semantic_pool=SEMANTIC_POOL, keyword_pool=KEYWORD_POOL)
|
||||
hits = [normalize_hit(hit) for hit in (raw_hits or [])]
|
||||
if target.get("use") == "content" and not hits and navigator is not None:
|
||||
# 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
|
||||
# their neighbouring pages are searched. The quotation still lands on body.
|
||||
hits = [dict(hit, via="toc_navigation") for hit in (navigator(target) or [])]
|
||||
per_target[target["target_id"]] = hits
|
||||
for hit in hits:
|
||||
if hit["chunk_id"] not in seen:
|
||||
seen.add(hit["chunk_id"])
|
||||
merged.append({**hit, "target_id": target["target_id"], "target_label": target["label"],
|
||||
"target_use": target["use"]})
|
||||
merged.sort(key=lambda chunk: -chunk["rank"])
|
||||
selected = _page_diverse(merged, limit, page_limit=page_limit, section_cap=section_cap)
|
||||
uncovered = [target for target in targets if not per_target.get(target["target_id"])]
|
||||
return {"evidence": selected, "per_target": per_target, "uncovered_targets": uncovered}
|
||||
|
||||
|
||||
def body_heading_index(db, *, course_version_id: int, project_id: int, category: str | None = None):
|
||||
"""Sections present in real body text, used to navigate from a contents page."""
|
||||
filters = [KnowledgeChunk.course_version_id == course_version_id, KnowledgeChunk.project_id == project_id,
|
||||
KnowledgeChunk.page_type == "body"]
|
||||
if category:
|
||||
filters.append(KnowledgeChunk.document_type == category)
|
||||
rows = db.execute(select(KnowledgeChunk.document_id, KnowledgeChunk.page, KnowledgeChunk.section,
|
||||
KnowledgeChunk.chapter).where(*filters).distinct()).all()
|
||||
index: dict[str, list[dict]] = {}
|
||||
for document_id, page, section, chapter in rows:
|
||||
for value in (section, chapter):
|
||||
key = normalize_heading(value or "")
|
||||
if key:
|
||||
index.setdefault(key, []).append({"document_id": document_id, "page": page,
|
||||
"section": section, "chapter": chapter})
|
||||
return index
|
||||
|
||||
|
||||
def page_text(source: dict) -> str:
|
||||
blocks = [str(source.get("text") or "").strip()]
|
||||
blocks.extend("表格: " + str(table.get("rows", table)) for table in source.get("tables", []))
|
||||
return "\n".join(block for block in blocks if block)
|
||||
|
||||
|
||||
def relative_image_path(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
root = get_settings().processed_path.resolve()
|
||||
path = Path(value).resolve()
|
||||
if not path.is_relative_to(root):
|
||||
raise ValueError("页面图像路径不在处理目录内")
|
||||
return str(path.relative_to(root))
|
||||
|
||||
+234
-34
@@ -6,15 +6,30 @@ from sqlalchemy import select, update
|
||||
|
||||
from .config import get_settings
|
||||
from .db import SessionLocal
|
||||
from .evidence import USE_CONTENT, content_targets, normalize_targets
|
||||
from .knowledge_pages import page_image_path
|
||||
from .llm import ConsistencyResult, FactResult, ModelError, ScoreResult, validate_score
|
||||
from .models import (CriterionScore, KnowledgeDocument, Project, ScoreEvidence, ScoringBatch,
|
||||
SubmissionFile, now)
|
||||
from .models import (CriterionScore, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project,
|
||||
ScoreEvidence, ScoringBatch, SubmissionFile, now)
|
||||
from .pages import normalize_heading
|
||||
from .parsing import parse_document
|
||||
from .rag import INDEX_VERSION, ingest, retrieve
|
||||
from .rag import (INDEX_VERSION, body_heading_index, ingest, retrieve, retrieve_targets)
|
||||
from .storage import secure_path, sha256
|
||||
from .settings_store import client_for_batch
|
||||
|
||||
#: Criterion statuses. ``needs_review`` is deliberately distinct from a missing
|
||||
#: material, a parse failure and a model failure: the retrieval ran but only found
|
||||
#: navigation pages where the rule requires body content.
|
||||
STATUS_NEEDS_REVIEW = "needs_review"
|
||||
STATUS_UNSCORABLE = "unscorable"
|
||||
STATUS_FAILED = "failed"
|
||||
STATUS_SCORED = "scored"
|
||||
STATUS_REVIEWED = "reviewed"
|
||||
#: Statuses that a batch counts as finished work.
|
||||
DONE_STATUSES = (STATUS_SCORED, STATUS_REVIEWED, STATUS_NEEDS_REVIEW)
|
||||
#: Statuses the dashboard shows as needing teacher attention.
|
||||
ATTENTION_STATUSES = (STATUS_FAILED, STATUS_UNSCORABLE, STATUS_NEEDS_REVIEW)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def file_index_lock(file_id: int):
|
||||
@@ -72,12 +87,21 @@ def _parse_submission_locked(db, file: SubmissionFile, *, embedder=None):
|
||||
|
||||
|
||||
def _project_facts(db, project, course_snapshot, client, *, retriever):
|
||||
"""Extract project facts from body text only.
|
||||
|
||||
Implementation facts must not be inferred from a contents heading or an abstract
|
||||
claim, so the search admits body and appendix pages exclusively. Project identity
|
||||
(name, student number, topic) legitimately lives on the cover page and is read from
|
||||
the teacher-confirmed import record instead of from retrieved text.
|
||||
"""
|
||||
prompts = course_snapshot["prompts"]
|
||||
categories = ("stage1", "stage2", "stage3", "final_report", "presentation")
|
||||
snippets = []
|
||||
for category in categories:
|
||||
snippets.extend(retriever(db, course_version_id=project.course_version_id, project_id=project.id,
|
||||
category=category, query="项目名称 目标 指标 硬件 软件 实验 数据 成本 结论", limit=3, include_public=False))
|
||||
category=category, query="项目名称 目标 指标 硬件 软件 实验 数据 成本 结论",
|
||||
limit=3, include_public=False, allowed_page_types=["body", "appendix"],
|
||||
substantive_only=True))
|
||||
if not snippets:
|
||||
return {}
|
||||
if client is None:
|
||||
@@ -100,10 +124,54 @@ def _project_facts(db, project, course_snapshot, client, *, retriever):
|
||||
return project.facts
|
||||
|
||||
|
||||
def _score_criterion(db, batch, project, criterion, client, *, retriever):
|
||||
def _body_navigator(db, *, course_version_id, project_id, category):
|
||||
"""Find real body pages for a target whose contents entries name missing sections.
|
||||
|
||||
The contents page is used only as a map: its headings are matched against headings
|
||||
that actually occur in body text, and the body sections and their neighbouring pages
|
||||
are then searched. The final quotation always lands on a real body page.
|
||||
"""
|
||||
headings = body_heading_index(db, course_version_id=course_version_id, project_id=project_id, category=category)
|
||||
if not headings:
|
||||
return None
|
||||
|
||||
def navigate(target):
|
||||
label = normalize_heading(target.get("label") or "")
|
||||
if not label:
|
||||
return []
|
||||
candidates = []
|
||||
for heading, places in headings.items():
|
||||
if not heading:
|
||||
continue
|
||||
if heading.startswith(label) or label.startswith(heading) or label in heading or heading in label:
|
||||
candidates.extend(places)
|
||||
if not candidates:
|
||||
return []
|
||||
pages = sorted({(place["document_id"], place["page"]) for place in candidates})
|
||||
neighbours = set()
|
||||
for document_id, page in pages:
|
||||
neighbours.update({(document_id, page), (document_id, page + 1)})
|
||||
wanted = sorted(neighbours)
|
||||
rows = db.scalars(select(KnowledgeChunk).where(
|
||||
KnowledgeChunk.course_version_id == course_version_id, KnowledgeChunk.project_id == project_id,
|
||||
KnowledgeChunk.page_type == "body", KnowledgeChunk.block_type != "toc",
|
||||
KnowledgeChunk.document_id.in_([item[0] for item in wanted]),
|
||||
KnowledgeChunk.page.in_([item[1] for item in wanted])).order_by(KnowledgeChunk.id)).all()
|
||||
hits = []
|
||||
for chunk in rows:
|
||||
hits.append({"chunk_id": chunk.id, "document_id": chunk.document_id, "project_id": chunk.project_id,
|
||||
"document_type": chunk.document_type, "page": chunk.page, "section": chunk.section,
|
||||
"chapter": chunk.chapter, "page_type": chunk.page_type, "block_type": chunk.block_type,
|
||||
"text": chunk.text, "rank": 0.5})
|
||||
return hits
|
||||
|
||||
return navigate
|
||||
|
||||
|
||||
def _score_criterion(db, batch, project, criterion, client, *, retriever, multi_retriever=retrieve_targets):
|
||||
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
|
||||
CriterionScore.project_id == project.id, CriterionScore.criterion_id == criterion["criterion_id"]))
|
||||
if score and score.status in ("scored", "reviewed"):
|
||||
if score and score.status in (STATUS_SCORED, STATUS_REVIEWED):
|
||||
return score
|
||||
if not score:
|
||||
score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"])
|
||||
@@ -115,44 +183,103 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever):
|
||||
SubmissionFile.category == category, SubmissionFile.recognition_status == "recognized")).all()
|
||||
ready = [file for file in files if file.parse_status == "ready"]
|
||||
if len(files) != 1 or len(ready) != 1:
|
||||
score.status, score.error = "unscorable", "材料缺失、重复、待确认或解析失败"
|
||||
score.status, score.error = STATUS_UNSCORABLE, "材料缺失、重复、待确认或解析失败"
|
||||
db.commit()
|
||||
return score
|
||||
query = criterion["description"] + " " + " ".join(criterion["required_evidence"])
|
||||
chunks = retriever(db, course_version_id=batch.course_version_id, project_id=project.id,
|
||||
category=category, query=query, limit=10, include_public=False)
|
||||
|
||||
plan = batch.rubric_snapshot.get("evidence_plan") or {}
|
||||
targets = (plan.get("criteria") or {}).get(criterion["criterion_id"]) or normalize_targets(
|
||||
criterion.get("required_evidence"))
|
||||
navigator = _body_navigator(db, course_version_id=batch.course_version_id,
|
||||
project_id=project.id, category=category)
|
||||
# The single ``retriever`` stays the seam: each per-target search goes through it,
|
||||
# so a caller that injects a retriever (tests, alternate index) still applies.
|
||||
selection = multi_retriever(db, course_version_id=batch.course_version_id, project_id=project.id,
|
||||
targets=targets, category=category, navigator=navigator,
|
||||
retrieve_fn=retriever)
|
||||
chunks = _attach_page_types(db, selection["evidence"])
|
||||
gaps = selection["uncovered_targets"]
|
||||
if not chunks:
|
||||
score.status, score.error = "unscorable", "无可检索的材料证据"
|
||||
# Nothing admissible was found for any target. Content targets go to review;
|
||||
# a purely structural criterion only depends on navigation pages, which exist.
|
||||
score.status = STATUS_NEEDS_REVIEW if content_targets(criterion) else STATUS_UNSCORABLE
|
||||
score.error = "未检索到允许作为证据的正文页"
|
||||
score.model_score = None
|
||||
score.missing_evidence = [target["label"] for target in gaps]
|
||||
_record_evidence_gaps(score, gaps)
|
||||
db.commit()
|
||||
return score
|
||||
if client is None:
|
||||
raise ModelError("未配置或无法连接评分模型")
|
||||
image_sources = dict.fromkeys((chunk["document_id"], chunk["page"]) for chunk in chunks[:2])
|
||||
|
||||
content_gaps = [target for target in gaps if target["use"] == USE_CONTENT]
|
||||
if content_gaps:
|
||||
# The model is not asked to grade from navigation pages. Record the gap and keep
|
||||
# the model score null so a teacher decides, rather than asserting a low score.
|
||||
score.model_score, score.confidence, score.status = None, None, STATUS_NEEDS_REVIEW
|
||||
score.model_comment = "检索仅命中目录、摘要或章节标题,缺少可用于内容判断的正文证据,转教师复核。"
|
||||
score.missing_evidence = [target["label"] for target in content_gaps]
|
||||
score.suggestion = "请补充该章节的正文页,或由教师直接核定该项分数。"
|
||||
score.error = "缺少正文证据:" + "、".join(target["label"] for target in content_gaps)
|
||||
_record_evidence_gaps(score, content_gaps, searched=_searched_page_types(plan, content_gaps))
|
||||
db.commit()
|
||||
return score
|
||||
|
||||
# Page images go to the model as well, so prefer substantive body pages over the
|
||||
# navigation pages that may legitimately appear in a structural target's evidence.
|
||||
image_sources = list(dict.fromkeys(
|
||||
(chunk["document_id"], chunk["page"]) for chunk in chunks
|
||||
if chunk.get("page_type") == "body"))
|
||||
if not image_sources:
|
||||
image_sources = list(dict.fromkeys((chunk["document_id"], chunk["page"]) for chunk in chunks))
|
||||
images = []
|
||||
for document_id, page in image_sources:
|
||||
for document_id, page in image_sources[:3]:
|
||||
doc = db.get(KnowledgeDocument, document_id)
|
||||
path = page_image_path(db, doc, page) if doc else None
|
||||
if path:
|
||||
images.append(str(path))
|
||||
query = criterion["description"] + " " + " ".join(target["label"] for target in targets)
|
||||
public_context = retriever(db, course_version_id=batch.course_version_id, project_id=project.id,
|
||||
category=None, query=query, limit=6, public_only=True)
|
||||
payload = {"criterion": criterion, "project_facts": project.facts, "course_context": public_context,
|
||||
"evidence": chunks,
|
||||
"required_output": {"criterion_id": "string", "score": "number", "max_score": "number", "reason": "string",
|
||||
"evidence": [{"chunk_id": "integer", "page": "integer", "quote": "exact substring"}],
|
||||
"missing_evidence": ["string"], "suggestion": "string", "confidence": "0..1"}}
|
||||
payload = {"criterion": criterion,
|
||||
"required_evidence_targets": [{"target_id": target["target_id"], "label": target["label"],
|
||||
"use": target["use"],
|
||||
"allowed_page_types": target["allowed_page_types"]}
|
||||
for target in targets],
|
||||
"evidence_policy": {
|
||||
"structure": "目录、封面和参考文献页可以证明章节结构和报告完整性,不能证明章节内容或完成质量。",
|
||||
"content": "内容判断只能使用正文或附录中的实质段落、数据、表格;目录、摘要和单独章节标题不能作为内容证据。"},
|
||||
"project_facts": project.facts, "course_context": public_context, "evidence": chunks,
|
||||
"required_output": {"criterion_id": "string", "score": "number or null", "max_score": "number",
|
||||
"reason": "string",
|
||||
"evidence": [{"chunk_id": "integer", "page": "integer",
|
||||
"quote": "exact substring", "target_id": "string"}],
|
||||
"missing_evidence": ["string"], "suggestion": "string", "confidence": "0..1",
|
||||
"note": "证据不足以判断内容时 score 填 null,并在 missing_evidence 中列出缺少的正文目标。"}}
|
||||
prompt = (batch.course_snapshot["prompts"]["score"] +
|
||||
" 证据quote必须从所引用chunk_id的text逐字复制连续片段,保留原文标点和换行;page必须等于该片段的page。不要改写、缩写或拼接引文。")
|
||||
" 证据quote必须从所引用chunk_id的text逐字复制连续片段,保留原文标点和换行;page必须等于该片段的page;"
|
||||
"target_id必须取自required_evidence_targets。不要改写、缩写或拼接引文。"
|
||||
"不得把目录、摘要或章节标题当作内容证据;无法据此判断内容时把score设为null。")
|
||||
error = ""
|
||||
result = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
result = validate_score(client.json_call(prompt, payload, ScoreResult, images=images), criterion, chunks)
|
||||
result = validate_score(client.json_call(prompt, payload, ScoreResult, images=images), criterion, chunks,
|
||||
targets=targets)
|
||||
break
|
||||
except ModelError:
|
||||
except ModelError as exc:
|
||||
error = str(exc)
|
||||
if attempt:
|
||||
raise
|
||||
prompt += " 上次回答的证据未通过原文和页码校验;请重新选择片段并逐字复制quote。"
|
||||
score.model_score, score.model_comment, score.confidence = result.score, result.reason, result.confidence
|
||||
score.missing_evidence, score.suggestion, score.status, score.error = result.missing_evidence, result.suggestion, "scored", ""
|
||||
prompt += f" 上次回答未通过校验({error});请改用允许页面类型中的实质正文片段,并逐字复制quote。"
|
||||
score.model_score, score.model_comment = result.score, result.reason
|
||||
score.confidence = result.confidence
|
||||
score.missing_evidence, score.suggestion = result.missing_evidence, result.suggestion
|
||||
if result.score is None:
|
||||
# The retrieval found body pages but the model judged them insufficient.
|
||||
score.status, score.error = STATUS_NEEDS_REVIEW, "模型认为现有正文证据不足以判断该项,转教师复核。"
|
||||
else:
|
||||
score.status, score.error = STATUS_SCORED, ""
|
||||
for evidence in db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all():
|
||||
db.delete(evidence)
|
||||
by_id = {chunk["chunk_id"]: chunk for chunk in chunks}
|
||||
@@ -160,14 +287,75 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever):
|
||||
chunk = by_id[evidence.chunk_id]
|
||||
doc = db.get(KnowledgeDocument, chunk["document_id"])
|
||||
path = page_image_path(db, doc, evidence.page)
|
||||
target = next((item for item in targets if item["target_id"] == evidence.target_id), None)
|
||||
db.add(ScoreEvidence(score_id=score.id, document_id=doc.id, submission_file_id=doc.submission_file_id,
|
||||
page=evidence.page, quote=evidence.quote, image_path=str(path) if path else None))
|
||||
page=evidence.page, quote=evidence.quote, image_path=str(path) if path else None,
|
||||
target_id=evidence.target_id, target_label=target["label"] if target else "",
|
||||
page_type=chunk.get("page_type") or "unknown",
|
||||
source_role=target["use"] if target else USE_CONTENT))
|
||||
db.commit()
|
||||
return score
|
||||
|
||||
|
||||
def _run_project(batch_id, project_id, session_factory, client_factory, embedder, retriever):
|
||||
def _attach_page_types(db, chunks: list[dict]) -> list[dict]:
|
||||
"""Attach the stored page classification to each retrieved chunk.
|
||||
|
||||
The page type recorded in ``knowledge_pages`` is the authority for the evidence
|
||||
policy. Reading it here, rather than trusting whatever a retriever happened to put
|
||||
in the dict, means a statement about what a page is cannot be forged by a caller
|
||||
or lost by an alternate index.
|
||||
"""
|
||||
keys = {(chunk.get("document_id"), chunk.get("page")) for chunk in chunks}
|
||||
keys.discard((None, None))
|
||||
if not keys:
|
||||
return chunks
|
||||
documents = {document_id for document_id, _ in keys}
|
||||
pages = {page for _, page in keys}
|
||||
records = db.scalars(select(KnowledgePage).where(KnowledgePage.document_id.in_(documents),
|
||||
KnowledgePage.page.in_(pages))).all()
|
||||
by_key = {(item.document_id, item.page): item for item in records}
|
||||
output = []
|
||||
for chunk in chunks:
|
||||
record = by_key.get((chunk.get("document_id"), chunk.get("page")))
|
||||
if record is None:
|
||||
output.append({**chunk, "page_type": chunk.get("page_type") or "unknown"})
|
||||
continue
|
||||
output.append({**chunk, "page_type": record.page_type or "unknown",
|
||||
"section": chunk.get("section") or record.section or "",
|
||||
"chapter": chunk.get("chapter") or record.chapter or ""})
|
||||
return output
|
||||
|
||||
|
||||
def _searched_page_types(plan, targets):
|
||||
allowed = set()
|
||||
for target in targets:
|
||||
allowed.update(target.get("allowed_page_types") or [])
|
||||
return sorted(allowed or (plan.get("content_allowed_page_types") or []))
|
||||
|
||||
|
||||
def _record_evidence_gaps(score, gaps, searched=None):
|
||||
"""Store which content targets lacked body evidence and where retrieval looked.
|
||||
|
||||
The record is kept through teacher review so the original retrieval problem stays
|
||||
auditable next to the score the teacher finally entered.
|
||||
"""
|
||||
score.evidence_gaps = [
|
||||
{"target_id": target.get("target_id"), "target_label": target.get("label"),
|
||||
"searched_page_types": searched or target.get("allowed_page_types") or [],
|
||||
"reason": "补检后仍未找到正文证据"}
|
||||
for target in gaps]
|
||||
|
||||
|
||||
def _run_project(batch_id, project_id, session_factory, client_factory, embedder, retriever,
|
||||
multi_retriever=retrieve_targets):
|
||||
"""Score one project. Returns ``(failures, reviews)``.
|
||||
|
||||
Failures are missing material, parse problems and model errors. Reviews count the
|
||||
items that only found navigation pages where content evidence is required; those
|
||||
need a teacher decision rather than a retry.
|
||||
"""
|
||||
failures = 0
|
||||
reviews = 0
|
||||
with session_factory() as db:
|
||||
batch = db.get(ScoringBatch, batch_id)
|
||||
project = db.get(Project, project_id)
|
||||
@@ -190,8 +378,11 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
|
||||
failures += 1
|
||||
for criterion in batch.rubric_snapshot["criteria"]:
|
||||
try:
|
||||
result = _score_criterion(db, batch, project, criterion, client, retriever=retriever)
|
||||
if result.status not in ("scored", "reviewed"):
|
||||
result = _score_criterion(db, batch, project, criterion, client, retriever=retriever,
|
||||
multi_retriever=multi_retriever)
|
||||
if result.status == STATUS_NEEDS_REVIEW:
|
||||
reviews += 1
|
||||
elif result.status not in (STATUS_SCORED, STATUS_REVIEWED):
|
||||
failures += 1
|
||||
except Exception as exc:
|
||||
score = db.scalar(select(CriterionScore).where(CriterionScore.batch_id == batch.id,
|
||||
@@ -199,15 +390,16 @@ def _run_project(batch_id, project_id, session_factory, client_factory, embedder
|
||||
if not score:
|
||||
score = CriterionScore(batch_id=batch.id, project_id=project.id, criterion_id=criterion["criterion_id"])
|
||||
db.add(score)
|
||||
score.status, score.error = "failed", str(exc)[:500]
|
||||
score.status, score.error = STATUS_FAILED, str(exc)[:500]
|
||||
db.commit()
|
||||
failures += 1
|
||||
db.execute(update(ScoringBatch).where(ScoringBatch.id == batch_id).values(progress_done=ScoringBatch.progress_done + 1))
|
||||
db.commit()
|
||||
return failures
|
||||
return failures, reviews
|
||||
|
||||
|
||||
def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=client_for_batch, embedder=None, retriever=retrieve):
|
||||
def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=client_for_batch, embedder=None,
|
||||
retriever=retrieve, multi_retriever=retrieve_targets):
|
||||
with session_factory() as db:
|
||||
batch = db.get(ScoringBatch, batch_id)
|
||||
if not batch:
|
||||
@@ -218,13 +410,21 @@ def run_batch(batch_id: int, *, session_factory=SessionLocal, client_factory=cli
|
||||
batch.status, batch.progress_total, batch.progress_done, batch.finished_at = "running", len(project_ids) * len(criteria), 0, None
|
||||
db.commit()
|
||||
failures = 0
|
||||
reviews = 0
|
||||
with ThreadPoolExecutor(max_workers=max(1, int(batch.model_config_snapshot.get("concurrency", 1)))) as pool:
|
||||
futures = [pool.submit(_run_project, batch_id, project_id, session_factory, client_factory, embedder, retriever) for project_id in project_ids]
|
||||
futures = [pool.submit(_run_project, batch_id, project_id, session_factory, client_factory,
|
||||
embedder, retriever, multi_retriever) for project_id in project_ids]
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
failures += future.result()
|
||||
project_failures, project_reviews = future.result()
|
||||
failures += project_failures
|
||||
reviews += project_reviews
|
||||
except Exception as exc:
|
||||
failures += 1
|
||||
batch.error = (batch.error + f" 项目任务失败: {type(exc).__name__}")[:1000]
|
||||
batch.status, batch.finished_at = ("partial" if failures else "completed"), now()
|
||||
# A batch with unresolved review items is finished running, but it is not a
|
||||
# completed grading result: the teacher still has to decide those criteria.
|
||||
batch.status = "partial" if failures else ("review_required" if reviews else "completed")
|
||||
batch.finished_at = now()
|
||||
db.commit()
|
||||
return {"failures": failures, "reviews": reviews, "status": batch.status}
|
||||
|
||||
@@ -9,6 +9,7 @@ from redis import Redis
|
||||
from rq import Queue, Retry
|
||||
from sqlalchemy import delete, func, select
|
||||
from .config import get_settings
|
||||
from .evidence import plan_snapshot
|
||||
from .exports import export_excel, export_project_pdf
|
||||
from .models import (AuditLog, CourseVersion, CriterionScore, IndexTask, KnowledgeChunk, KnowledgeDocument,
|
||||
Project, RubricVersion, ScoringBatch, Student, Submission, SubmissionFile, TeacherRevision)
|
||||
@@ -209,9 +210,12 @@ def create_batch(data, db):
|
||||
if not version.published or not rubric.published or rubric.course_version_id != version.id:
|
||||
raise HTTPException(400, "请选择同一课程版本下已发布的规则")
|
||||
settings = get_settings()
|
||||
# The evidence policy travels with the batch snapshot, so a batch can always be read
|
||||
# back against the rules and page permissions it was actually scored with.
|
||||
batch = ScoringBatch(course_version_id=version.id, rubric_version_id=rubric.id,
|
||||
course_snapshot={key: getattr(version, key) for key in ("version", "title", "objectives", "indicators", "directions", "prompts", "templates")},
|
||||
rubric_snapshot={"version": rubric.version, "criteria": rubric.criteria, "weights": rubric.weights},
|
||||
rubric_snapshot={"version": rubric.version, "criteria": rubric.criteria,
|
||||
"weights": rubric.weights, "evidence_plan": plan_snapshot(rubric.criteria)},
|
||||
model_config_snapshot=model_config(db), embedding_model_name=settings.embedding_model_name,
|
||||
embedding_model_version=settings.embedding_model_version,
|
||||
status="created", project_ids=db.scalars(select(Project.id).where(
|
||||
@@ -226,7 +230,7 @@ def start_batch(batch_id: int, db):
|
||||
batch = db.scalar(select(ScoringBatch).where(ScoringBatch.id == batch_id).with_for_update())
|
||||
if not batch:
|
||||
raise HTTPException(404, "评分批次不存在")
|
||||
if batch.status not in ("created", "failed", "partial"):
|
||||
if batch.status not in ("created", "failed", "partial", "review_required"):
|
||||
db.rollback()
|
||||
raise HTTPException(409, "批次已入队、运行中或已完成")
|
||||
try:
|
||||
@@ -244,15 +248,29 @@ def start_batch(batch_id: int, db):
|
||||
return {"id": batch.id, "status": batch.status}
|
||||
|
||||
def review_score(score_id: int, data, db):
|
||||
"""Record a teacher decision. Model scores, retrieval gaps and history are kept.
|
||||
|
||||
A teacher may review any criterion in any state, including one awaiting review or
|
||||
one whose material is missing: the teacher is the authority on a criterion the
|
||||
retrieval could not support. The gaps from the original retrieval stay on the record
|
||||
after the review, so the reason it reached the teacher remains auditable.
|
||||
"""
|
||||
score = found(db, CriterionScore, score_id)
|
||||
batch = found(db, ScoringBatch, score.batch_id)
|
||||
criterion = next((item for item in batch.rubric_snapshot["criteria"] if item["criterion_id"] == score.criterion_id), None)
|
||||
if not criterion or data.score > criterion["max_score"]:
|
||||
raise HTTPException(400, "教师分数超出规则满分")
|
||||
from_status = score.status
|
||||
db.add(TeacherRevision(score_id=score.id, old_score=score.teacher_score, new_score=data.score,
|
||||
old_comment=score.teacher_comment, new_comment=data.comment, reason=data.reason))
|
||||
score.teacher_score, score.teacher_comment, score.status = data.score, data.comment, "reviewed"
|
||||
audit(db, "review", "criterion_score", score.id, {"reason": data.reason})
|
||||
score.teacher_score, score.teacher_comment = data.score, data.comment
|
||||
# ``evidence_gaps`` is intentionally left untouched: it records why the criterion
|
||||
# reached the teacher, independently of the score the teacher then entered.
|
||||
score.status = "reviewed"
|
||||
audit(db, "review", "criterion_score", score.id,
|
||||
{"reason": data.reason, "from_status": from_status,
|
||||
"came_from_review": bool(score.evidence_gaps)})
|
||||
db.commit()
|
||||
return {"id": score.id, "model_score": score.model_score, "teacher_score": score.teacher_score,
|
||||
"final_score": score.final_score, "teacher_comment": score.teacher_comment}
|
||||
"final_score": score.final_score, "teacher_comment": score.teacher_comment,
|
||||
"status": score.status, "evidence_gaps": score.evidence_gaps or []}
|
||||
|
||||
Reference in New Issue
Block a user