feat: unify scoring and chat evidence pages
This commit is contained in:
@@ -10,6 +10,7 @@ from sqlalchemy import select
|
||||
|
||||
from . import services
|
||||
from .exports import batch_projects, export_excel, export_project_pdf, project_result
|
||||
from .knowledge_pages import evidence_links
|
||||
from .models import (AuditLog, Conversation, CourseVersion, CriterionScore, IndexTask,
|
||||
PendingAction, Project, RubricVersion, ScoreEvidence, ScoringBatch, Student, SubmissionFile, now)
|
||||
from .storage import CATEGORIES, scan, secure_path, sha256
|
||||
@@ -194,7 +195,7 @@ def prepare(db, conversation: Conversation, kind: str, payload: dict, *, teacher
|
||||
"old_status": score.status, "evidence_warning": score.error or score.missing_evidence,
|
||||
"evidence": [{"document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料",
|
||||
"page": item.page, "quote": item.quote,
|
||||
"url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None}
|
||||
**evidence_links(db, item)}
|
||||
for item in evidence],
|
||||
"new_score": payload["score"], "comment": payload["comment"], "reason": payload["reason"]}
|
||||
elif kind == "export":
|
||||
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import select
|
||||
|
||||
from .actions import prepare, require_scope
|
||||
from .exports import batch_projects, project_result
|
||||
from .knowledge_pages import page_links
|
||||
from .models import (Conversation, Course, CourseVersion, IndexTask, KnowledgeDocument,
|
||||
Project, RubricVersion, ScoringBatch, Student, SubmissionFile)
|
||||
from .rag import retrieve
|
||||
@@ -206,12 +207,11 @@ def _search(db, version_id, project_id, query, *, public_only=False):
|
||||
citations = []
|
||||
for chunk in chunks:
|
||||
doc = db.get(KnowledgeDocument, chunk["document_id"])
|
||||
file_id = doc.submission_file_id if doc else None
|
||||
links = page_links(db, chunk["document_id"], chunk["page"])
|
||||
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"],
|
||||
"url": f"/api/knowledge/documents/{chunk['document_id']}/pages/{chunk['page']}",
|
||||
"image_url": f"/api/assets/{file_id}/{chunk['page']}" if file_id else None})
|
||||
**links})
|
||||
return {"citations": citations, "found": bool(citations)}
|
||||
|
||||
|
||||
@@ -283,11 +283,10 @@ def scores(db, convo, args, _user):
|
||||
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],
|
||||
"url": url, "image_url": evidence["image_url"],
|
||||
"text_url": f"/api/knowledge/documents/{evidence['document_id']}/pages/{evidence['page']}"
|
||||
if evidence.get("document_id") else None})
|
||||
"quote": evidence["quote"][:320], "document_id": evidence.get("document_id"),
|
||||
**links, "score_url": url})
|
||||
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),
|
||||
|
||||
@@ -11,8 +11,9 @@ 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 .knowledge_pages import page_image_path, page_links
|
||||
from .models import (AuditLog, Course, CourseVersion, CriterionScore, ExportTask,
|
||||
IndexTask, KnowledgeChunk, KnowledgeDocument, Project, RubricVersion, ScoringBatch,
|
||||
IndexTask, KnowledgeChunk, KnowledgeDocument, KnowledgePage, Project, RubricVersion, ScoringBatch,
|
||||
Student, SubmissionFile, TeacherRevision)
|
||||
from .materialized import sync_course_entities, sync_rubric_entities
|
||||
from .rules import RubricData
|
||||
@@ -330,13 +331,22 @@ def knowledge(course_version_id: int, project_id: int | None = None, db: Session
|
||||
@router.get("/knowledge/documents/{document_id}/pages/{page}")
|
||||
def knowledge_page(document_id: int, page: int, db: Session = Depends(get_db)):
|
||||
doc = found(db, KnowledgeDocument, document_id)
|
||||
chunks = db.scalars(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document_id,
|
||||
KnowledgeChunk.page == page).order_by(KnowledgeChunk.id)).all()
|
||||
if not chunks:
|
||||
record = db.get(KnowledgePage, (document_id, page))
|
||||
if not record:
|
||||
raise HTTPException(404, "资料页面不存在")
|
||||
return {"document_id": doc.id, "title": doc.title, "course_version_id": doc.course_version_id,
|
||||
"project_id": doc.project_id, "page": page, "text": "\n".join(chunk.text for chunk in chunks),
|
||||
"image_url": f"/api/assets/{doc.submission_file_id}/{page}" if doc.submission_file_id else None}
|
||||
"project_id": doc.project_id, "page": page,
|
||||
"text": record.text,
|
||||
"image_url": page_links(db, document_id, page)["image_url"]}
|
||||
|
||||
|
||||
@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)
|
||||
path = page_image_path(db, doc, page)
|
||||
if not path:
|
||||
raise HTTPException(404, "页面图像不存在")
|
||||
return FileResponse(path, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/knowledge/ingest-course/{course_version_id}")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Persistent teacher conversations and SSE agent orchestration."""
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import timedelta
|
||||
from threading import Lock
|
||||
@@ -20,7 +18,7 @@ from .agent_tools import TOOLS, execute, tool_specs
|
||||
from .config import get_settings
|
||||
from .db import get_db
|
||||
from .llm import ModelError
|
||||
from .models import AgentRun, ChatMessage, Conversation, KnowledgeChunk, PendingAction, Project, ScoreEvidence, ScoringBatch, now
|
||||
from .models import AgentRun, ChatMessage, Conversation, PendingAction, Project, ScoringBatch, now
|
||||
from .settings_store import client_for_chat
|
||||
|
||||
router = APIRouter(prefix="/api/conversations")
|
||||
@@ -90,48 +88,6 @@ def _validated_scope(db, data):
|
||||
return scope
|
||||
|
||||
|
||||
def _restored_citations(db, messages):
|
||||
"""Resolve old score image links only when saved evidence identifies one live text page."""
|
||||
refs = []
|
||||
originals = {msg.id: msg.citations or [] for msg in messages}
|
||||
for msg in messages:
|
||||
for index, item in enumerate(originals[msg.id]):
|
||||
if not isinstance(item, dict) or item.get("text_url") or re.fullmatch(
|
||||
r"/api/knowledge/documents/\d+/pages/\d+/?", str(item.get("url") or "")):
|
||||
continue
|
||||
match = re.fullmatch(r"/api/assets/(\d+)/(\d+)/?", str(item.get("image_url") or item.get("url") or ""))
|
||||
if match and isinstance(item.get("quote"), str) and item["quote"]:
|
||||
refs.append((msg.id, index, item, int(match[1]), int(match[2])))
|
||||
if not refs:
|
||||
return {}
|
||||
file_ids = {file_id for _, _, _, file_id, _ in refs}
|
||||
pages = {page for _, _, _, _, page in refs}
|
||||
evidence = defaultdict(list)
|
||||
for file_id, page, quote, document_id in db.execute(select(
|
||||
ScoreEvidence.submission_file_id, ScoreEvidence.page, ScoreEvidence.quote, ScoreEvidence.document_id).where(
|
||||
ScoreEvidence.submission_file_id.in_(file_ids), ScoreEvidence.page.in_(pages),
|
||||
ScoreEvidence.document_id.is_not(None))):
|
||||
evidence[file_id, page].append((quote, document_id))
|
||||
document_ids = {document_id for rows in evidence.values() for _, document_id in rows}
|
||||
if not document_ids:
|
||||
return {}
|
||||
chunks = defaultdict(list)
|
||||
for document_id, page, content in db.execute(select(
|
||||
KnowledgeChunk.document_id, KnowledgeChunk.page, KnowledgeChunk.text).where(
|
||||
KnowledgeChunk.document_id.in_(document_ids), KnowledgeChunk.page.in_(pages))):
|
||||
chunks[document_id, page].append(content)
|
||||
restored = {}
|
||||
for message_id, index, item, file_id, page in refs:
|
||||
quote = item["quote"]
|
||||
matches = {document_id for saved_quote, document_id in evidence[file_id, page]
|
||||
if saved_quote.startswith(quote) and any(quote in content for content in chunks[document_id, page])}
|
||||
if len(matches) == 1:
|
||||
citations = restored.setdefault(message_id, [dict(value) if isinstance(value, dict) else value
|
||||
for value in originals[message_id]])
|
||||
citations[index]["text_url"] = f"/api/knowledge/documents/{matches.pop()}/pages/{page}"
|
||||
return restored
|
||||
|
||||
|
||||
def _detail(db, conversation):
|
||||
stale = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id,
|
||||
AgentRun.status == "running", AgentRun.created_at < now() - timedelta(minutes=15))).all()
|
||||
@@ -143,11 +99,10 @@ def _detail(db, conversation):
|
||||
db.commit()
|
||||
runs = db.scalars(select(AgentRun).where(AgentRun.conversation_id == conversation.id).order_by(AgentRun.id.desc()).limit(20)).all()
|
||||
messages = db.scalars(select(ChatMessage).where(ChatMessage.conversation_id == conversation.id).order_by(ChatMessage.id)).all()
|
||||
restored_citations = _restored_citations(db, messages)
|
||||
actions = db.scalars(select(PendingAction).where(PendingAction.conversation_id == conversation.id).order_by(PendingAction.id)).all()
|
||||
return {**_summary(conversation), "messages": [{**{key: getattr(msg, key) for key in
|
||||
("id", "role", "content", "tool_calls", "created_at")},
|
||||
"citations": restored_citations.get(msg.id, msg.citations)} for msg in messages],
|
||||
"citations": msg.citations} for msg in messages],
|
||||
"actions": [action_dict(action) for action in actions],
|
||||
"runs": [{key: getattr(run, key) for key in ("id", "status", "error", "tool_calls", "created_at", "finished_at")} for run in runs],
|
||||
"scope_locked": any(msg.role == "user" for msg in messages)}
|
||||
@@ -265,7 +220,7 @@ 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), ("text_url", 500))
|
||||
(("title", 200), ("quote", 500), ("url", 500), ("image_url", 500), ("score_url", 500))
|
||||
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)}
|
||||
@@ -280,7 +235,7 @@ def _answer_citations(items):
|
||||
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("text_url"))
|
||||
safe.get("page"), safe.get("quote"), safe.get("url"), safe.get("score_url"))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
citations.append(safe)
|
||||
|
||||
@@ -7,6 +7,7 @@ from sqlalchemy import select
|
||||
from weasyprint import HTML
|
||||
|
||||
from .config import get_settings
|
||||
from .knowledge_pages import evidence_links
|
||||
from .models import (CriterionScore, ExportTask, Project, ScoreEvidence, ScoringBatch, Student, SubmissionFile)
|
||||
from .rules import totals
|
||||
|
||||
@@ -18,6 +19,12 @@ 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)
|
||||
return {"page": item.page, "quote": item.quote, "file_id": item.submission_file_id,
|
||||
"document_id": item.document_id,
|
||||
"document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料",
|
||||
**links}
|
||||
for criterion in criteria:
|
||||
score = by_id.get(criterion["criterion_id"])
|
||||
evidence = db.scalars(select(ScoreEvidence).where(ScoreEvidence.score_id == score.id)).all() if score else []
|
||||
@@ -28,10 +35,7 @@ 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": [{"page": item.page, "quote": item.quote, "file_id": item.submission_file_id,
|
||||
"document_id": item.document_id,
|
||||
"document": db.get(SubmissionFile, item.submission_file_id).relative_path if item.submission_file_id else "课程资料",
|
||||
"image_url": f"/api/assets/{item.submission_file_id}/{item.page}" if item.image_path else None} for item in evidence]})
|
||||
"evidence": [evidence_dict(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}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Canonical page content and links shared by retrieval, scoring, and citations."""
|
||||
from pathlib import Path
|
||||
|
||||
from .config import get_settings
|
||||
from .models import KnowledgeDocument, KnowledgePage
|
||||
|
||||
|
||||
def text_url(document_id: int, page: int) -> str:
|
||||
return f"/api/knowledge/documents/{document_id}/pages/{page}"
|
||||
|
||||
|
||||
def image_url(document_id: int, page: int) -> str:
|
||||
return f"{text_url(document_id, page)}/image"
|
||||
|
||||
|
||||
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))
|
||||
|
||||
|
||||
def page_image_path(db, doc: KnowledgeDocument, page: int) -> Path | None:
|
||||
root = get_settings().processed_path.resolve()
|
||||
record = db.get(KnowledgePage, (doc.id, page))
|
||||
path = (root / record.image_path).resolve() if record and record.image_path else 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]:
|
||||
if not document_id or not page:
|
||||
return {"url": None, "image_url": None}
|
||||
doc = db.get(KnowledgeDocument, document_id)
|
||||
if not doc:
|
||||
return {"url": None, "image_url": None}
|
||||
record = db.get(KnowledgePage, (document_id, page))
|
||||
if not record:
|
||||
return {"url": None, "image_url": None}
|
||||
return {"url": text_url(document_id, page),
|
||||
"image_url": image_url(document_id, page) if page_image_path(db, doc, page) else None}
|
||||
|
||||
|
||||
def evidence_links(db, evidence) -> dict[str, str | None]:
|
||||
return page_links(db, evidence.document_id, evidence.page)
|
||||
@@ -170,6 +170,14 @@ class KnowledgeDocument(Base):
|
||||
__table_args__ = (UniqueConstraint("course_version_id", "project_id", "submission_file_id", "source_hash"),)
|
||||
|
||||
|
||||
class KnowledgePage(Base):
|
||||
__tablename__ = "knowledge_pages"
|
||||
document_id = Column(Integer, ForeignKey("knowledge_documents.id", ondelete="CASCADE"), primary_key=True)
|
||||
page = Column(Integer, primary_key=True)
|
||||
text = Column(Text, nullable=False, default="")
|
||||
image_path = Column(String(1200), nullable=True) # Relative to processed_path.
|
||||
|
||||
|
||||
class KnowledgeChunk(Base):
|
||||
__tablename__ = "knowledge_chunks"
|
||||
id = Column(Integer, primary_key=True)
|
||||
|
||||
@@ -4,7 +4,8 @@ from pathlib import Path
|
||||
from sqlalchemy import cast, delete, func, or_, select
|
||||
|
||||
from .config import get_settings
|
||||
from .models import KnowledgeChunk, KnowledgeDocument
|
||||
from .knowledge_pages import page_text, relative_image_path
|
||||
from .models import KnowledgeChunk, KnowledgeDocument, KnowledgePage
|
||||
|
||||
|
||||
INDEX_VERSION = 2
|
||||
@@ -60,6 +61,7 @@ def ingest(db, *, course_version_id: int, project_id: int | None, submission_fil
|
||||
if len(vectors) != len(chunks):
|
||||
raise RuntimeError("嵌入向量数量与知识片段数量不一致")
|
||||
if existing:
|
||||
db.execute(delete(KnowledgePage).where(KnowledgePage.document_id == existing.id))
|
||||
db.execute(delete(KnowledgeChunk).where(KnowledgeChunk.document_id == existing.id))
|
||||
doc = existing
|
||||
doc.document_type, doc.source_hash = category, source_hash
|
||||
@@ -69,6 +71,9 @@ 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 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))
|
||||
|
||||
@@ -6,6 +6,7 @@ from sqlalchemy import select, update
|
||||
|
||||
from .config import get_settings
|
||||
from .db import SessionLocal
|
||||
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)
|
||||
@@ -126,8 +127,13 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever):
|
||||
return score
|
||||
if client is None:
|
||||
raise ModelError("未配置或无法连接评分模型")
|
||||
page_paths = {page["page_number"]: page.get("rendered_image_path") for page in ready[0].parsed["pages"]}
|
||||
images = [page_paths[page] for page in dict.fromkeys(chunk["page"] for chunk in chunks[:2]) if page_paths.get(page)]
|
||||
image_sources = dict.fromkeys((chunk["document_id"], chunk["page"]) for chunk in chunks[:2])
|
||||
images = []
|
||||
for document_id, page in image_sources:
|
||||
doc = db.get(KnowledgeDocument, document_id)
|
||||
path = page_image_path(db, doc, page) if doc else None
|
||||
if path:
|
||||
images.append(str(path))
|
||||
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,
|
||||
@@ -153,8 +159,9 @@ def _score_criterion(db, batch, project, criterion, client, *, retriever):
|
||||
for evidence in result.evidence:
|
||||
chunk = by_id[evidence.chunk_id]
|
||||
doc = db.get(KnowledgeDocument, chunk["document_id"])
|
||||
path = page_image_path(db, doc, evidence.page)
|
||||
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=page_paths.get(evidence.page)))
|
||||
page=evidence.page, quote=evidence.quote, image_path=str(path) if path else None))
|
||||
db.commit()
|
||||
return score
|
||||
|
||||
|
||||
Reference in New Issue
Block a user