feat: unify scoring and chat evidence pages
This commit is contained in:
@@ -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")
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,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):
|
||||
|
||||
@@ -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