feat: unify scoring and chat evidence pages

This commit is contained in:
2026-09-27 17:47:32 +08:00
parent 039e93d0de
commit 83aa0e84a2
21 changed files with 511 additions and 132 deletions
@@ -0,0 +1,183 @@
"""Persist canonical knowledge page text and image locations.
Revision ID: 0005
Revises: 0004
"""
import hashlib
import json
import re
from collections import defaultdict
from pathlib import Path
from alembic import op
import sqlalchemy as sa
from wisegrade.config import get_settings
revision = "0005"
down_revision = "0004"
branch_labels = None
depends_on = None
def _json(value):
return json.loads(value) if isinstance(value, str) else value or {}
def _text(source):
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(root, value):
if not value:
return None
path = Path(value).resolve()
return str(path.relative_to(root)) if path.is_relative_to(root) and path.is_file() else None
def _restore_chunk_text(parts):
"""Undo the 100-character overlap used by index version 2."""
if not parts:
return ""
blocks = []
current = previous = parts[0]
for part in parts[1:]:
if len(previous) == 650 and previous[-100:] == part[:100]:
current += part[100:]
else:
blocks.append(current)
current = part
previous = part
blocks.append(current)
return "\n".join(blocks)
_PAGE_URL = re.compile(r"/api/knowledge/documents/(\d+)/pages/(\d+)/?")
_ASSET_URL = re.compile(r"/api/assets/(\d+)/(\d+)/?")
def _migrate_references(db):
"""Rewrite saved citations once; API reads the stored canonical links as-is."""
pages = {(row.document_id, row.page): row for row in db.execute(sa.text(
"SELECT document_id, page, text, image_path FROM knowledge_pages")).mappings()}
evidence = defaultdict(list)
for row in db.execute(sa.text(
"SELECT submission_file_id, page, quote, document_id FROM score_evidences "
"WHERE submission_file_id IS NOT NULL AND document_id IS NOT NULL")).mappings():
evidence[row.submission_file_id, row.page].append((row.quote, row.document_id))
def normalize(item):
if not isinstance(item, dict):
return item
old_url = item.get("url")
raw_page = item.get("page")
identity = None
if isinstance(item.get("document_id"), int) and isinstance(raw_page, int):
identity = (item["document_id"], raw_page)
for raw in (item.get("text_url"), old_url):
match = _PAGE_URL.fullmatch(str(raw or ""))
if match:
from_url = (int(match[1]), int(match[2]))
if identity and identity != from_url:
raise RuntimeError("旧引用的文档 ID 与页面链接冲突")
identity = from_url
asset = next((match for raw in (old_url, item.get("image_url"))
if (match := _ASSET_URL.fullmatch(str(raw or "")))), None)
if not identity and asset and isinstance(item.get("quote"), str) and item["quote"]:
file_id, page = int(asset[1]), int(asset[2])
quote = item["quote"]
matches = {document_id for saved, document_id in evidence[file_id, page]
if saved.startswith(quote) and (record := pages.get((document_id, page)))
and quote in record.text}
if len(matches) != 1:
raise RuntimeError("旧评分引用无法唯一对应资料页,请先核对评分证据")
identity = (matches.pop(), page)
if not identity:
return item
if raw_page is not None and raw_page != identity[1]:
raise RuntimeError("旧引用的页码与来源链接冲突")
record = pages.get(identity)
if not record:
raise RuntimeError("旧引用对应的资料页不存在")
base = f"/api/knowledge/documents/{identity[0]}/pages/{identity[1]}"
value = {**item, "document_id": identity[0], "page": identity[1], "url": base,
"image_url": base + "/image" if record.image_path else None}
if isinstance(old_url, str) and old_url.startswith("/?page=scores"):
value["score_url"] = old_url
value.pop("text_url", None)
return value
def walk(value):
if isinstance(value, list):
return [walk(item) for item in value]
if isinstance(value, dict):
return normalize({key: walk(item) for key, item in value.items()})
return value
for table, columns in (("chat_messages", ("citations", "tool_calls")),
("agent_runs", ("tool_calls", "events")),
("pending_actions", ("preview", "result"))):
for row in db.execute(sa.text(f"SELECT id, {', '.join(columns)} FROM {table}")).mappings():
changed = {}
for column in columns:
old = row[column]
new = walk(old)
if new != old:
changed[column] = new
if changed:
assignments = ", ".join(f"{column} = :{column}" for column in changed)
statement = sa.text(f"UPDATE {table} SET {assignments} WHERE id = :id")
for column in changed:
statement = statement.bindparams(sa.bindparam(column, type_=sa.JSON()))
db.execute(statement, {"id": row.id, **changed})
def upgrade():
if "knowledge_pages" not in sa.inspect(op.get_bind()).get_table_names():
op.create_table(
"knowledge_pages",
sa.Column("document_id", sa.Integer(), sa.ForeignKey("knowledge_documents.id", ondelete="CASCADE"), primary_key=True),
sa.Column("page", sa.Integer(), primary_key=True),
sa.Column("text", sa.Text(), nullable=False),
sa.Column("image_path", sa.String(1200), nullable=True),
)
db = op.get_bind()
root = get_settings().processed_path.resolve()
documents = db.execute(sa.text(
"SELECT id, course_version_id, submission_file_id, title FROM knowledge_documents")).mappings().all()
files = {row.id: _json(row.parsed) for row in db.execute(sa.text(
"SELECT id, parsed FROM submission_files")).mappings()}
templates = {row.id: _json(row.templates) for row in db.execute(sa.text(
"SELECT id, templates FROM course_versions")).mappings()}
chunks = defaultdict(list)
for row in db.execute(sa.text(
"SELECT document_id, page, text FROM knowledge_chunks ORDER BY document_id, page, id")).mappings():
chunks[row.document_id, row.page].append(row.text)
insert = sa.text("INSERT INTO knowledge_pages (document_id, page, text, image_path) "
"VALUES (:document_id, :page, :text, :image_path) ON CONFLICT DO NOTHING")
pages_by_document = defaultdict(list)
for (document_id, page), parts in chunks.items():
pages_by_document[document_id].append((page, parts))
for doc in documents:
parsed_pages = {item.get("page_number"): item for item in files.get(doc.submission_file_id, {}).get("pages", [])}
for page in parsed_pages:
pages_by_document[doc.id].append((page, chunks.get((doc.id, page), [])))
seen = set()
for page, parts in pages_by_document[doc.id]:
if page in seen:
continue
seen.add(page)
source = parsed_pages.get(page)
text = _text(source) if source else _restore_chunk_text(parts)
image = _relative_image(root, source.get("rendered_image_path")) if source else None
if not image and doc.submission_file_id is None and doc.title in templates.get(doc.course_version_id, []):
folder = hashlib.sha256(doc.title.encode()).hexdigest()[:16]
image = _relative_image(root, root / f"course-{doc.course_version_id}" / folder / f"page-{page}.png")
db.execute(insert, {"document_id": doc.id, "page": page, "text": text, "image_path": image})
_migrate_references(db)
def downgrade():
op.drop_table("knowledge_pages")
+42 -1
View File
@@ -1,9 +1,12 @@
from fastapi.testclient import TestClient
from sqlalchemy import select
from wisegrade import agent_tools
from wisegrade.db import get_db
from wisegrade.main import app
from wisegrade.models import CourseVersion, CriterionScore, Project, RubricVersion, Student
from wisegrade.models import (Conversation, CourseVersion, CriterionScore, KnowledgeChunk,
KnowledgePage, Project, RubricVersion, Student)
from wisegrade.rag import ingest
from wisegrade.scoring import run_batch
@@ -15,6 +18,44 @@ def client_for(session_factory):
return TestClient(app)
def test_file_page_has_text_and_image_virtual_page_has_text_only(session_factory, environment, monkeypatch):
image = environment / "processed" / "course-1" / "report" / "page-49.png"
image.parent.mkdir(parents=True)
image.write_bytes(b"\x89PNG\r\n\x1a\n")
vectors = lambda texts: [[0.1] * 1024 for _ in texts]
with session_factory() as db:
document = ingest(db, course_version_id=1, project_id=None, submission_file_id=None,
title="课程报告.pdf", category="course", source_hash="file",
pages=[{"page_number": 49, "text": "第四十九页完整文字", "tables": [{"rows": [["指标", "结果"]]}],
"rendered_image_path": str(image)}], embedder=vectors)
virtual = ingest(db, course_version_id=1, project_id=None, submission_file_id=None,
title="系统生成规则", category="course", source_hash="virtual",
pages=[{"page_number": 1, "text": "规则文字"}], embedder=vectors)
assert db.get(KnowledgePage, (document.id, 49)).image_path == "course-1/report/page-49.png"
chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
monkeypatch.setattr(agent_tools, "retrieve", lambda *_, **__: [{"chunk_id": chunk.id,
"document_id": document.id, "project_id": None, "page": 49, "text": chunk.text}])
citation = agent_tools.execute(db, Conversation(course_version_id=1), "search_course_material",
{"course_version_id": 1, "query": "完整文字"}, "")["citations"][0]
assert citation["url"] == f"/api/knowledge/documents/{document.id}/pages/49"
assert citation["image_url"] == citation["url"] + "/image"
document_id, virtual_id = document.id, virtual.id
client = client_for(session_factory)
try:
resource = client.get(f"/api/knowledge/documents/{document_id}/pages/49")
assert resource.status_code == 200
assert resource.json()["text"] == "第四十九页完整文字\n表格: [['指标', '结果']]"
image_url = resource.json()["image_url"]
assert image_url == f"/api/knowledge/documents/{document_id}/pages/49/image"
assert client.get(image_url).content.startswith(b"\x89PNG")
virtual_resource = client.get(f"/api/knowledge/documents/{virtual_id}/pages/1")
assert virtual_resource.json()["text"] == "规则文字"
assert virtual_resource.json()["image_url"] is None
assert client.get(f"/api/knowledge/documents/{virtual_id}/pages/1/image").status_code == 404
finally:
app.dependency_overrides.clear()
def test_course_and_rubric_versions_are_immutable(session_factory):
client = client_for(session_factory)
try:
+45 -6
View File
@@ -14,12 +14,13 @@ from wisegrade.actions import confirm, prepare
from wisegrade.agent_tools import execute
from wisegrade.db import get_db
from wisegrade.indexing import run_index_task
from wisegrade.llm import DeepSeekClient, ModelError
from wisegrade.llm import DeepSeekClient, ModelError, ScoreResult
from wisegrade.main import app
from wisegrade.models import (AgentRun, ChatMessage, Conversation, CourseVersion, CriterionScore, IndexTask, PendingAction,
Project, RubricVersion, ScoringBatch, Student, TeacherRevision, now)
Project, RubricVersion, ScoreEvidence, ScoringBatch, Student, SubmissionFile,
TeacherRevision, now)
from wisegrade.rag import ingest
from wisegrade.scoring import run_batch
from wisegrade.scoring import _score_criterion, run_batch
from wisegrade.storage import scan
@@ -93,9 +94,9 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
assert first_page["results"][0]["project_id"] == first.id
assert len(first_page["results"][0]["rows"]) == 6
assert first_page["next_criterion_offset"] == 6 and first_page["next_project_offset"] == 1
assert first_page["citations"][0]["url"] == f"/?page=scores&batch_id={created['id']}&project_id={first.id}"
assert first_page["citations"][0]["image_url"] == f"/api/assets/{first.id}/1"
assert first_page["citations"][0]["text_url"] == "/api/knowledge/documents/100/pages/1"
assert first_page["citations"][0]["url"] is None
assert first_page["citations"][0]["image_url"] is None
assert first_page["citations"][0]["score_url"] == f"/?page=scores&batch_id={created['id']}&project_id={first.id}"
assert first_page["citations"][1]["url"] == first_page["citations"][0]["url"]
assert first_page["citations"][1]["image_url"] is None
assert len(json.dumps(first_page, ensure_ascii=False)) < 12000
@@ -106,6 +107,44 @@ def test_get_scores_pages_projects_and_criteria(db, monkeypatch):
assert later["next_project_offset"] is None and later["next_criterion_offset"] is None
def test_scoring_uses_the_same_page_image_as_its_saved_evidence(db, environment):
project = add_project(db, "123450090")
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
batch = db.get(ScoringBatch, created["id"])
criterion = batch.rubric_snapshot["criteria"][0]
file = SubmissionFile(project_id=project.id, category=criterion["category"],
relative_path="test/score/source.pdf", mime_type="application/pdf", size=1,
mtime=1, sha256="b" * 64, recognition_status="recognized", parse_status="ready")
db.add(file)
db.flush()
image = environment / "processed" / f"file-{file.id}" / "page-2.png"
image.parent.mkdir(parents=True)
image.write_bytes(b"\x89PNG\r\n\x1a\n")
source = {"page_number": 2, "text": "评分所用原文证据", "rendered_image_path": str(image)}
file.parsed = {"pages": [source]}
document = ingest(db, course_version_id=1, project_id=project.id, submission_file_id=file.id,
title="报告.pdf", category=criterion["category"], source_hash=file.sha256,
pages=[source], embedder=lambda texts: [[0.1] * 1024 for _ in texts])
from wisegrade.models import KnowledgeChunk
chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
evidence = {"chunk_id": chunk.id, "document_id": document.id, "project_id": project.id,
"page": 2, "text": chunk.text}
sent_images = []
class FakeClient:
def json_call(self, _prompt, _payload, _schema, *, images=None):
sent_images.extend(images or [])
return ScoreResult(criterion_id=criterion["criterion_id"], score=1,
max_score=criterion["max_score"], reason="测试评分",
evidence=[{"chunk_id": chunk.id, "page": 2, "quote": "原文证据"}], confidence=.9)
scored = _score_criterion(db, batch, project, criterion, FakeClient(),
retriever=lambda *_, **kwargs: [] if kwargs.get("public_only") else [evidence])
saved = db.scalar(select(ScoreEvidence).where(ScoreEvidence.score_id == scored.id))
assert sent_images == [str(image)]
assert saved.document_id == document.id and saved.page == 2 and saved.image_path == str(image)
def test_import_confirmation_hash_change_index_and_idempotency(environment, db, monkeypatch, session_factory):
folder = environment / "inbox" / "batch" / "123450003_张三_机器人"
folder.mkdir(parents=True)
+84 -24
View File
@@ -12,8 +12,9 @@ from wisegrade import agent_tools, chat_api, services
from wisegrade.db import get_db
from wisegrade.main import app
from wisegrade.models import (AgentRun, AuditLog, ChatMessage, Conversation, CourseVersion, CriterionScore,
KnowledgeChunk, KnowledgeDocument, PendingAction, Project, ScoreEvidence,
KnowledgeChunk, KnowledgeDocument, KnowledgePage, PendingAction, Project, ScoreEvidence,
ScoringBatch, Student, SubmissionFile, now)
from wisegrade.rag import ingest, split_pages
def client_for(session_factory):
@@ -179,7 +180,7 @@ def test_tool_record_keeps_bounded_query_but_redacts_review_text_and_secrets():
assert 'secret' not in str(search)
def test_score_citations_link_text_and_restore_legacy_image_only(session_factory):
def test_score_citations_link_the_canonical_text_and_image_page(session_factory, environment):
with session_factory() as db:
student = Student(student_no="987654320", name="测试学生")
db.add(student)
@@ -192,13 +193,15 @@ def test_score_citations_link_text_and_restore_legacy_image_only(session_factory
recognition_status="recognized", parse_status="ready")
db.add(file)
db.flush()
document = KnowledgeDocument(course_version_id=1, project_id=project.id, submission_file_id=file.id,
title="报告.pdf", document_type="final_report", source_hash="a" * 64,
index_version=2, status="ready")
db.add(document)
db.flush()
db.add(KnowledgeChunk(document_id=document.id, course_version_id=1, project_id=project.id,
document_type="final_report", page=2, section="测试", text="这里有原文证据和分析"))
image_path = environment / "processed" / f"file-{file.id}" / "page-2.png"
image_path.parent.mkdir(parents=True)
image_path.write_bytes(b"\x89PNG\r\n\x1a\n")
source = {"page_number": 2, "section": "测试", "text": "这里有原文证据和分析",
"rendered_image_path": str(image_path)}
file.parsed = {"pages": [source]}
document = ingest(db, course_version_id=1, project_id=project.id, submission_file_id=file.id,
title="报告.pdf", category="final_report", source_hash="a" * 64,
pages=[source], embedder=lambda texts: [[0.1] * 1024 for _ in texts])
created = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)
batch = db.get(ScoringBatch, created["id"])
score = CriterionScore(batch_id=batch.id, project_id=project.id,
@@ -207,32 +210,33 @@ def test_score_citations_link_text_and_restore_legacy_image_only(session_factory
db.add(score)
db.flush()
db.add(ScoreEvidence(score_id=score.id, document_id=document.id, submission_file_id=file.id,
page=2, quote="原文证据", image_path="page-2.png"))
conversation = Conversation(title="旧评分引用", course_version_id=1, batch_id=batch.id, project_id=project.id)
page=2, quote="原文证据", image_path=str(image_path)))
conversation = Conversation(title="评分引用", course_version_id=1, batch_id=batch.id, project_id=project.id)
db.add(conversation)
db.flush()
image_url = f"/api/assets/{file.id}/2"
db.add(ChatMessage(conversation_id=conversation.id, role="assistant", content="历史回答", citations=[
{"title": "报告.pdf", "page": 2, "quote": "原文证据", "url": image_url},
{"title": "报告.pdf", "page": 2, "quote": "不匹配的引文", "url": image_url}]))
db.commit()
expected = f"/api/knowledge/documents/{document.id}/pages/2"
db.add(ChatMessage(conversation_id=conversation.id, role="assistant", content="历史回答", citations=[
{"title": "报告.pdf", "document_id": document.id, "page": 2, "quote": "原文证据",
"url": expected, "image_url": expected + "/image"}]))
db.commit()
generated = agent_tools.execute(db, conversation, "get_scores",
{"batch_id": batch.id, "project_id": project.id}, "")
assert generated["citations"][0]["text_url"] == expected
assert chat_api._answer_citations(generated["citations"])[0]["text_url"] == expected
assert generated["citations"][0]["image_url"] == image_url
assert generated["citations"][0]["url"] == expected
assert generated["citations"][0]["image_url"] == expected + "/image"
assert generated["citations"][0]["score_url"].startswith("/?page=scores")
assert chat_api._answer_citations(generated["citations"])[0]["url"] == expected
conversation_id = conversation.id
client = client_for(session_factory)
try:
detail = client.get(f"/api/conversations/{conversation_id}").json()
citations = detail["messages"][0]["citations"]
assert citations[0]["text_url"] == expected
assert "text_url" not in citations[1]
assert citations[0]["url"] == expected
assert citations[0]["image_url"] == expected + "/image"
assert "原文证据" in client.get(expected).json()["text"]
assert client.get(expected + "/image").content.startswith(b"\x89PNG")
with session_factory() as db:
stored = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id))
assert "text_url" not in stored.citations[0]
assert stored.citations[0]["url"] == expected
finally:
app.dependency_overrides.clear()
@@ -307,7 +311,7 @@ def test_large_score_result_keeps_structured_rows_for_model_and_history(session_
app.dependency_overrides.clear()
def test_upgrade_from_legacy_0003_preserves_history(session_factory):
def test_upgrade_from_legacy_0003_migrates_pages_and_citations(session_factory, environment):
from pathlib import Path
from alembic import command
from alembic.config import Config
@@ -319,21 +323,77 @@ def test_upgrade_from_legacy_0003_preserves_history(session_factory):
db.flush()
db.add(ChatMessage(conversation_id=row.id, role="user", content="旧问题"))
db.add(AgentRun(conversation_id=row.id, status="completed"))
document = KnowledgeDocument(course_version_id=1, title="旧课程资料", document_type="course",
source_hash="legacy", index_version=2, status="ready")
db.add(document)
db.flush()
legacy_text = "旧资料第三页的完整文字。" * 80
for part in split_pages([{"page_number": 3, "text": legacy_text}]):
db.add(KnowledgeChunk(document_id=document.id, course_version_id=1, project_id=None,
document_type="course", page=3, text=part["text"]))
student = Student(student_no="987654322", name="迁移测试")
db.add(student)
db.flush()
project = Project(course_version_id=1, student_id=student.id, topic="迁移", source_dir="test/migration")
db.add(project)
db.flush()
file = SubmissionFile(project_id=project.id, category="final_report", relative_path="test/migration/report.pdf",
mime_type="application/pdf", size=1, mtime=1, sha256="b" * 64,
recognition_status="recognized", parse_status="ready")
db.add(file)
db.flush()
image = environment / "processed" / "migration" / "page-2.png"
image.parent.mkdir(parents=True)
image.write_bytes(b"\x89PNG\r\n\x1a\n")
file.parsed = {"pages": [{"page_number": 2, "text": "迁移评分证据原文", "rendered_image_path": str(image)}]}
student_doc = KnowledgeDocument(course_version_id=1, project_id=project.id,
submission_file_id=file.id, title="迁移报告.pdf", document_type="final_report",
source_hash="student", index_version=2, status="ready")
db.add(student_doc)
db.flush()
db.add(KnowledgeChunk(document_id=student_doc.id, course_version_id=1, project_id=project.id,
document_type="final_report", page=2, text="迁移评分证据原文"))
batch_id = services.create_batch(SimpleNamespace(course_version_id=1, rubric_version_id=1), db)["id"]
batch = db.get(ScoringBatch, batch_id)
score = CriterionScore(batch_id=batch_id, project_id=project.id,
criterion_id=batch.rubric_snapshot["criteria"][0]["criterion_id"], status="scored")
db.add(score)
db.flush()
db.add(ScoreEvidence(score_id=score.id, document_id=student_doc.id, submission_file_id=file.id,
page=2, quote="迁移评分证据", image_path=str(image)))
assistant = ChatMessage(conversation_id=row.id, role="assistant", content="旧回答", citations=[
{"title": "旧课程资料", "page": 3, "quote": legacy_text[:30],
"url": f"/api/knowledge/documents/{document.id}/pages/3"},
{"title": "迁移报告.pdf", "page": 2, "quote": "迁移评分证据",
"url": f"/api/assets/{file.id}/2"}])
db.add(assistant)
db.commit()
conversation_id = row.id
document_id = document.id
student_doc_id = student_doc.id
engine = session_factory.kw['bind']
with engine.begin() as connection:
connection.exec_driver_sql('ALTER TABLE conversations DROP COLUMN first_request_id')
connection.exec_driver_sql('ALTER TABLE agent_runs DROP COLUMN tool_calls')
connection.exec_driver_sql('ALTER TABLE agent_runs DROP COLUMN events')
connection.exec_driver_sql('DROP TABLE knowledge_pages')
connection.exec_driver_sql("UPDATE alembic_version SET version_num = '0003'")
root = Path(__file__).resolve().parents[1]
config = Config(str(root / 'alembic.ini'))
config.set_main_option('script_location', str(root / 'migrations'))
command.upgrade(config, 'head')
with session_factory() as db:
assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0004'
assert db.scalar(text('SELECT version_num FROM alembic_version')) == '0005'
assert db.get(Conversation, conversation_id).title == '旧记录'
assert db.scalar(select(ChatMessage.content).where(ChatMessage.conversation_id == conversation_id)) == '旧问题'
run = db.scalar(select(AgentRun).where(AgentRun.conversation_id == conversation_id))
assert run.tool_calls == [] and run.events == []
assert db.get(KnowledgePage, (document_id, 3)).text == legacy_text
saved = db.scalar(select(ChatMessage).where(ChatMessage.conversation_id == conversation_id,
ChatMessage.role == "assistant"))
assert saved.citations[0]["document_id"] == document_id
assert saved.citations[0]["url"] == f"/api/knowledge/documents/{document_id}/pages/3"
score_citation = saved.citations[1]
assert score_citation["document_id"] == student_doc_id
assert score_citation["url"] == f"/api/knowledge/documents/{student_doc_id}/pages/2"
assert score_citation["image_url"] == score_citation["url"] + "/image"
+1 -1
View File
@@ -10,7 +10,7 @@ from wisegrade.storage import classify, extract_identity, parse_student_folder,
def test_postgresql_migrations_and_vector_extension(db):
assert db.bind.dialect.name == "postgresql"
assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0004"
assert db.scalar(text("SELECT version_num FROM alembic_version")) == "0005"
assert db.scalar(text("SELECT extname FROM pg_extension WHERE extname = 'vector'")) == "vector"
+3 -1
View File
@@ -3,7 +3,7 @@ from PIL import Image, ImageDraw
from sqlalchemy import select
from wisegrade.llm import DeepSeekClient, FactResult, ModelError, ScoreResult, validate_score
from wisegrade.models import AppSetting, KnowledgeChunk, Project, Student
from wisegrade.models import AppSetting, KnowledgeChunk, KnowledgePage, Project, Student
from wisegrade.rag import ingest, local_encoder, retrieve, split_pages
from wisegrade.settings_store import model_config, save_model_config
@@ -82,12 +82,14 @@ def test_stale_knowledge_index_reuses_document_id(db):
title="报告", category="stage3", source_hash="same-source", pages=pages)
old_chunk = db.scalar(select(KnowledgeChunk).where(KnowledgeChunk.document_id == document.id))
old_chunk.text = "过时片段"
db.get(KnowledgePage, (document.id, 1)).text = "过时整页"
document.index_version = 1
db.commit()
reindexed = ingest(db, course_version_id=1, project_id=project_id, submission_file_id=None,
title="报告", category="stage3", source_hash="same-source", pages=pages)
assert reindexed.id == document.id and reindexed.index_version == 2
assert db.scalar(select(KnowledgeChunk.text).where(KnowledgeChunk.document_id == document.id)) == pages[0]["text"]
assert db.get(KnowledgePage, (document.id, 1)).text == pages[0]["text"]
def test_model_key_is_encrypted_and_hidden(db):
+2 -1
View File
@@ -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":
+6 -7
View File
@@ -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),
+16 -6
View File
@@ -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}")
+4 -49
View File
@@ -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)
+8 -4
View File
@@ -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}
+53
View File
@@ -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)
+8
View File
@@ -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)
+6 -1
View File
@@ -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))
+10 -3
View File
@@ -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