Files
WiseGrade/backend/migrations/versions/0005_knowledge_pages.py
T

184 lines
7.9 KiB
Python

"""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")