feat: separate contents, abstract and body evidence
This commit is contained in:
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user