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