feat: separate contents, abstract and body evidence

This commit is contained in:
2026-10-01 00:07:40 +08:00
parent 540d0e2918
commit b7634c8374
25 changed files with 2592 additions and 172 deletions
+134 -10
View File
@@ -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),