Files
hack/tools/scripts/llm-key-hunter/results/_cache/content/biqibaomiaomiaowu_knowlink.json
T
OpenCode 68a9071cf1 feat: reconcile local master with origin (exa/hot-platform hunters, leaks ledger, latest results)
Local branch had diverged from origin/master (sibling commits on the same base). Rewrote local history linearly on top of origin/master, folding in all local content: exa/hot-platform discovery hunters, leaks ledger, nightly sweep orchestrator, updated .gitignore and skill docs, plus the latest hunt output and vault state. Remote-only files (vault keys, channel scripts) were restored rather than dropped, so the resulting tree is a full union of both sides.
2026-08-07 12:53:47 +08:00

1 line
84 KiB
JSON
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "biqibaomiaomiaowu/knowlink", "files": {"server/tests/test_contract_freeze.py": {"0255756db5ce3abd6d291e0ce63a6a6e5cd473f1": {"t": 1786068484.9549415, "n": 76381, "h": "0aff56d2fdc54770", "c": "import json\r\nimport re\r\nfrom pathlib import Path\r\n\r\nimport pytest\r\nfrom jsonschema import Draft202012Validator\nfrom jsonschema.exceptions import ValidationError\n\nfrom server.ai.handout_lazy import outline_timeline_issues\nfrom server.schemas.common import AsyncEntity\nfrom server.schemas.responses import HandoutBlock as HandoutBlockResponse\n\r\n\r\nROOT = Path(__file__).resolve().parents[2]\nARCHITECTURE_DOC = \"docs/v1/architecture.md\"\nTEAM_DIVISION_DOC = \"docs/v1/team-division.md\"\nWEEKLY_PLAN_DOC = \"docs/v1/weekly-plan.md\"\nSCAFFOLD_DOC = \"docs/engineering/development-scaffold.md\"\nDEMO_ASSETS_BASELINE_DOC = \"docs/v1/demo-assets-baseline.md\"\nDEMO_ASSETS_FIRST_EDITION_DOC = \"docs/v1/demo-assets-first-edition.md\"\nDEMO_ASSETS_BASELINE_NAV_LINK = \"v1/demo-assets-baseline.md\"\nDEMO_ASSETS_FIRST_EDITION_NAV_LINK = \"v1/demo-assets-first-edition.md\"\nDOCS_NAV_DOC = \"docs/README.md\"\n\r\n\r\ndef load_text(relative_path: str) -> str:\r\n return (ROOT / relative_path).read_text(encoding=\"utf-8\")\r\n\r\n\r\ndef load_json(relative_path: str) -> dict:\r\n return json.loads((ROOT / relative_path).read_text(encoding=\"utf-8\"))\r\n\r\n\r\ndef build_validator(relative_path: str) -> Draft202012Validator:\r\n schema = load_json(relative_path)\r\n Draft202012Validator.check_schema(schema)\r\n return Draft202012Validator(schema)\r\n\r\n\r\ndef test_week1_freeze_docs_are_linked_from_readme_and_contract():\n readme = load_text(\"README.md\")\n docs_nav = load_text(DOCS_NAV_DOC)\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n\n assert \"docs/contracts/week1-cao-le-freeze.md\" in readme\n assert \"docs/README.md\" in readme\n assert DEMO_ASSETS_BASELINE_NAV_LINK in docs_nav\n assert DEMO_ASSETS_FIRST_EDITION_NAV_LINK in docs_nav\n assert \"week1-cao-le-freeze.md\" in api_contract\n assert \"../v1/demo-assets-baseline.md\" in api_contract\n\n\ndef test_week2_parse_inquiry_contract_is_linked_from_api_contract():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n week2_contract = load_text(\"docs/contracts/week2-cao-le-parse-inquiry-contract.md\")\n\n assert \"week2-cao-le-parse-inquiry-contract.md\" in api_contract\n assert \"schemas/ai/handout_outline.schema.json\" in week2_contract\n assert \"schemas/ai/handout_block.schema.json\" in week2_contract\n assert \"schemas/ai/qa_response.schema.json\" in week2_contract\n assert \"schemas/ai/knowledge_point_extraction.schema.json\" in week2_contract\n assert \"schemas/parse/normalized_document.schema.json\" in week2_contract\n\n for token in (\n \"`generationMetadata.evidenceTier`\",\n \"`original_evidence`\",\n \"`handout_context`\",\n \"`course_prior`\",\n \"`out_of_scope`\",\n \"`citations=[]`\",\n \"服务端归一化后补充的响应元数据\",\n \"不是模型可自由返回的字段\",\n ):\n assert token in week2_contract\n\n for token in (\n \"current-block-prioritized course-wide Hybrid RAG\",\n \"current block 是 boost,不是 filter\",\n \"active parse run\",\n \"active handout version\",\n \"handout_context citations=[]\",\n \"source-fact intent 不进入 course_prior\",\n ):\n assert token in week2_contract\n\n for token in (\n \"`course_segments`\",\n \"`handout_outline`\",\n \"`handout_blocks`\",\n \"`knowledge_points`\",\n \"`segment_knowledge_points`\",\n \"`knowledge_point_evidences`\",\n \"`vector_documents`\",\n \"`learning_preferences`\",\n ):\n assert token in week2_contract\n\n for step_code in (\"resource_validate\", \"caption_extract\", \"document_parse\", \"knowledge_extract\", \"vectorize\"):\n assert step_code in api_contract\n assert step_code in week2_contract\n\n\ndef test_v2_course_lesson_workbench_contract_is_linked_from_api_contract():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n docs_nav = load_text(DOCS_NAV_DOC)\n\n assert \"v2-course-lesson-workbench-contract.md\" in api_contract\n assert \"v2-course-lesson-workbench-contract.md\" in docs_nav\n assert \"phase2-course-lesson-workbench-handoff.md\" in docs_nav\n\n\ndef test_backend_optimization_contract_freezes_idempotency_and_upload_fast_return():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n error_codes = load_text(\"docs/contracts/error-codes.md\")\n\n for token in (\n \"request fingerprint\",\n \"idempotency.body_mismatch\",\n \"同一 scope + `Idempotency-Key` + 不同请求体\",\n \"不得同步执行解析、OCR、向量化、讲义生成、B站下载合并或 AI 调用\",\n \"KNOWLINK_DRAMATIQ_PARSE_QUEUE\",\n \"KNOWLINK_DRAMATIQ_CONTENT_QUEUE\",\n \"KNOWLINK_DRAMATIQ_QUIZ_QUEUE\",\n \"KNOWLINK_DRAMATIQ_REVIEW_QUEUE\",\n \"KNOWLINK_DRAMATIQ_IMPORT_QUEUE\",\n \"KNOWLINK_DRAMATIQ_MAINTENANCE_QUEUE\",\n ):\n assert token in api_contract\n\n assert \"`idempotency.body_mismatch`\" in error_codes\n\n\ndef test_week2_video_outline_lazy_handout_semantics_are_frozen():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n week2_contract = load_text(\"docs/contracts/week2-cao-le-parse-inquiry-contract.md\")\n weekly_plan = load_text(WEEKLY_PLAN_DOC)\n env_example = load_text(\".env.example\")\n\n for token in (\n \"handout_outline\",\n \"outlineKey\",\n \"generationStatus\",\n \"sourceSegmentKeys\",\n \"pending\",\n \"generating\",\n \"ready\",\n \"failed\",\n \"enter_handout_outline\",\n \"outline_ready\",\n \"currentSec\",\n \"KNOWLINK_VIVO_OUTLINE_MODEL\",\n \"KNOWLINK_VIVO_HANDOUT_BLOCK_MODEL\",\n \"KNOWLINK_VIVO_HANDOUT_TIMEOUT_SEC\",\n \"KNOWLINK_VIVO_HANDOUT_BLOCK_TIMEOUT_SEC\",\n \"KNOWLINK_HANDOUT_OUTLINE_PROVIDER\",\n \"KNOWLINK_HANDOUT_BLOCK_PROVIDER\",\n \"KNOWLINK_QA_PROVIDER\",\n \"KNOWLINK_DEEPSEEK_API_KEY\",\n \"KNOWLINK_DEEPSEEK_BASE_URL\",\n \"KNOWLINK_DEEPSEEK_MODEL\",\n \"KNOWLINK_DEEPSEEK_REASONING_EFFORT\",\n \"KNOWLINK_DEEPSEEK_QUIZ_TIMEOUT_SEC\",\n \"deepseek-v4-flash\",\n \"https://api.deepseek.com\",\n \"KNOWLINK_ENABLE_VIVO_EMBEDDING\",\n \"KNOWLINK_VIVO_EMBEDDING_MODEL\",\n \"KNOWLINK_VIVO_EMBEDDING_TIMEOUT_SEC\",\n \"Doubao-Seed-2.0-mini\",\n \"Doubao-Seed-2.0-pro\",\n \"120\",\n \"m3e-base\",\n \"Volc-DeepSeek-V3.2\",\n \"qwen3.5-plus\",\n ):\n assert token in week2_contract or token in api_contract or token in env_example\n\n for token in (\n \"KNOWLINK_ENABLE_VIVO_EMBEDDING\",\n \"KNOWLINK_VIVO_EMBEDDING_MODEL\",\n \"KNOWLINK_VIVO_EMBEDDING_TIMEOUT_SEC\",\n \"model_name\",\n \"sentences\",\n \"requestId\",\n ):\n assert token in week2_contract\n\n assert \"不再表示解析阶段已经完成全量知识点抽取\" in week2_contract\n assert \"完整知识点随讲义 block 逐段补齐\" in api_contract\n assert \"完整 `knowledge_points` 不再作为进入问询页或讲义 outline 页的前置条件\" in weekly_plan\n assert \"视频链路需能保存 `handout_outline`\" in weekly_plan\n assert \"`knowledge_extract` 在视频优先链路中表示目录抽取 ready\" in weekly_plan\n assert \"schema valid 当成 timeline valid\" in week2_contract\n assert \"只描述 `ready` block 的 AI 生成结果\" in week2_contract\n assert \"视频引用必须落在该 outline child item 的 `startSec/endSec` 范围内\" in week2_contract\n assert '\"outlineReady\": true' in api_contract\n assert '\"generatedKnowledgePointCount\": 0' in api_contract\n assert '\"outlineStatus\": \"ready\"' in api_contract\n\n\ndef test_latest_outline_read_model_requires_source_segment_keys():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n\n outline_section = api_contract.split(\n \"### `GET /api/v1/courses/{courseId}/handouts/latest/outline`\", 1\n )[1].split(\"### `GET /api/v1/courses/{courseId}/handouts/latest/blocks`\", 1)[0]\n\n assert '\"children\": [' in outline_section\n assert '\"sourceSegmentKeys\": [\"mp4-c1\", \"mp4-c2\"]' in outline_section\n assert \"`items[*].children[*].sourceSegmentKeys` 是 API read model 必返字段\" in outline_section\n assert \"大标题没有 `blockId`\" in outline_section\n assert \"前端可忽略展示\" in outline_section\n\n\ndef test_latest_blocks_contract_exposes_generation_metadata_and_public_citations():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n\n blocks_section = api_contract.split(\n \"### `GET /api/v1/courses/{courseId}/handouts/latest/blocks`\", 1\n )[1].split(\"### `POST /api/v1/handout-blocks/{blockId}/generate`\", 1)[0]\n\n assert '\"generationMetadata\": {' in blocks_section\n assert \"`items[*].generationMetadata`\" in blocks_section\n assert \"`segmentId` / `segmentKey`\" in blocks_section\n\n\ndef test_knowledge_point_block_ownership_contract_is_frozen():\n architecture = load_text(ARCHITECTURE_DOC)\n week2_contract = load_text(\"docs/contracts/week2-cao-le-parse-inquiry-contract.md\")\n\n knowledge_points_section = week2_contract.split(\"### 1.3 `knowledge_points`\", 1)[1].split(\n \"### 1.4 `handout_block_knowledge_points`\", 1\n )[0]\n handout_block_kp_section = week2_contract.split(\"### 1.4 `handout_block_knowledge_points`\", 1)[1].split(\n \"### 1.5 `segment_knowledge_points`\", 1\n )[0]\n\n assert \"`handout_block_id`\" not in knowledge_points_section\n assert \"不直接保存 block 归属\" in knowledge_points_section\n assert \"`knowledge_points` 保留 `unique(course_id, parse_run_id, canonical_name)`\" in knowledge_points_section\n assert \"复用同一 `knowledge_point_id`\" in knowledge_points_section\n assert \"block 归属写入 `handout_block_knowledge_points`\" in knowledge_points_section\n\n for token in (\"`handout_block_id`\", \"`knowledge_point_id`\", \"`sort_no`\", \"`importance_score`\"):\n assert token in handout_block_kp_section\n assert token in architecture\n\n assert \"block 归属不写入 `knowledge_points` 主表\" in architecture\n\n\ndef test_week1_handout_blocks_schema_is_marked_as_legacy():\n freeze_doc = load_text(\"docs/contracts/week1-cao-le-freeze.md\")\n legacy_schema = load_json(\"schemas/ai/handout_blocks.schema.json\")\n\n assert \"Week 1 整包讲义 legacy schema\" in freeze_doc\n assert \"schemas/ai/handout_outline.schema.json\" in freeze_doc\n assert \"schemas/ai/handout_block.schema.json\" in freeze_doc\n assert \"Legacy Week 1 whole-handout schema\" in legacy_schema[\"description\"]\n assert \"authoritative handout generation schemas\" in legacy_schema[\"description\"]\n\n\ndef test_normalized_document_segment_types_match_week2_contract():\n week2_contract = load_text(\"docs/contracts/week2-cao-le-parse-inquiry-contract.md\")\n schema = load_json(\"schemas/parse/normalized_document.schema.json\")\n expected_segment_types = {\n \"video_caption\",\n \"pdf_page_text\",\n \"ppt_slide_text\",\n \"docx_block_text\",\n \"ocr_text\",\n \"formula\",\n \"image_caption\",\n }\n\n schema_segment_types = set(schema[\"properties\"][\"segments\"][\"items\"][\"properties\"][\"segmentType\"][\"enum\"])\n assert schema_segment_types == expected_segment_types\n\n for segment_type in expected_segment_types:\n assert f\"`{segment_type}`\" in week2_contract\n\n\n@pytest.mark.parametrize(\n (\"resource_type\", \"segment_type\", \"locator\"),\n [\n (\"pdf\", \"formula\", {\"pageNo\": 2}),\n (\"pptx\", \"formula\", {\"slideNo\": 3}),\n (\"docx\", \"formula\", {\"sectionPath\": [\"第 1 章\"]}),\n ],\n)\ndef test_normalized_document_schema_accepts_visual_metadata_for_document_resources(\n resource_type: str,\n segment_type: str,\n locator: dict[str, object],\n):\n validator = build_validator(\"schemas/parse/normalized_document.schema.json\")\n payload = {\n \"resourceType\": resource_type,\n \"segments\": [\n {\n \"segmentKey\": f\"{resource_type}-formula-1\",\n \"segmentType\": segment_type,\n \"orderNo\": 1,\n \"textContent\": \"x^2 - 5x + 6 = 0\",\n \"imageKey\": \"visual-assets/parse-run-1/asset-1.png\",\n \"formulaText\": \"x^2 - 5x + 6 = 0\",\n \"bboxJson\": {\"page\": 2, \"boxes\": [{\"x\": 0.1, \"y\": 0.2, \"w\": 0.5, \"h\": 0.1}]},\n **locator,\n }\n ],\n }\n\n validator.validate(payload)\n\n\ndef test_parse_contract_documents_quality_gate_and_vision_env_vars():\n week2_contract = load_text(\"docs/contracts/week2-cao-le-parse-inquiry-contract.md\")\n\n for token in (\n \"U+FFFF\",\n \"U+FFFD\",\n \"KNOWLINK_ENABLE_MARKITDOWN_OCR\",\n \"KNOWLINK_ENABLE_VIVO_OCR\",\n \"KNOWLINK_VIVO_APP_ID\",\n \"KNOWLINK_VIVO_APP_KEY\",\n \"KNOWLINK_VIVO_BASE_URL\",\n \"KNOWLINK_VIVO_OCR_TIMEOUT_SEC\",\n \"KNOWLINK_VIVO_OCR_BUSINESS_ID\",\n \"KNOWLINK_ENABLE_VIVO_VISION\",\n \"KNOWLINK_VIVO_VISION_MODEL\",\n \"KNOWLINK_VIVO_VISION_TIMEOUT_SEC\",\n \"KNOWLINK_VIVO_VISION_BATCH_SIZE\",\n \"vision_model_unsupported\",\n \"Doubao-Seed-2.0-mini\",\n \"Venn 图\",\n \"低质 OCR\",\n \"AUB\",\n ):\n assert token in week2_contract\n\n\ndef test_bilibili_reserved_contract_is_aligned_across_docs():\n architecture = load_text(ARCHITECTURE_DOC)\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n team_division = load_text(TEAM_DIVISION_DOC)\n freeze_doc = load_text(\"docs/contracts/week1-cao-le-freeze.md\")\n error_codes = load_text(\"docs/contracts/error-codes.md\")\n v2_contract = load_text(\"docs/contracts/v2-bilibili-import-contract.md\")\n\r\n for text in (architecture, api_contract, team_division, freeze_doc):\r\n assert \"/api/v1/courses/{courseId}/resources/imports/bilibili\" in text\r\n assert \"/api/v1/bilibili-import-runs/{importRunId}/status\" in text\r\n assert \"/api/v1/bilibili/auth/qr/sessions\" in text\r\n\r\n assert \"bilibili.not_implemented\" in api_contract\n assert \"bilibili.not_implemented\" in freeze_doc\n assert \"bilibili.not_implemented\" in error_codes\n assert \"B 站导入预留接口与扫码登录预留接口\" in team_division\n assert \"v2-bilibili-import-contract.md\" in api_contract\n assert \"`bilibili_import_run` 和 `async_tasks`\" in v2_contract\n assert \"POST /api/v1/courses/{courseId}/resources/imports/bilibili/preview\" in v2_contract\n\r\n\r\ndef test_bilibili_stub_owner_is_consistent_across_collaboration_docs():\n freeze_doc = load_text(\"docs/contracts/week1-cao-le-freeze.md\")\n team_division = load_text(TEAM_DIVISION_DOC)\n weekly_plan = load_text(WEEKLY_PLAN_DOC)\n\r\n assert \"由曹乐在第 2 周完成 stub 实现\" not in freeze_doc\r\n assert \"杨彩艺\" in freeze_doc\r\n assert \"`501` stub\" in freeze_doc\r\n assert \"接口主负责人是杨彩艺\" in team_division\r\n assert \"第 2 周由杨彩艺按冻结结果补当前 `501` stub\" in team_division\r\n assert \"当前阶段统一返回 `501`\" in weekly_plan\r\n assert \"由曹乐在第 2 周完成 stub 实现\" not in weekly_plan\r\n\r\n\r\ndef test_bilibili_reserved_contract_sections_keep_request_body_and_delete_shape():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n\r\n import_section = api_contract.split(\r\n \"### `POST /api/v1/courses/{courseId}/resources/imports/bilibili`\", 1\r\n )[1].split(\"### `GET /api/v1/courses/{courseId}/resources/imports/bilibili`\", 1)[0]\r\n delete_session_section = api_contract.split(\"### `DELETE /api/v1/bilibili/auth/session`\", 1)[1].split(\n \"V1 历史 stub 阶段统一返回:\", 1\n )[0]\n\r\n assert '\"videoUrl\"' in import_section\r\n assert \"`requestBody`\" in import_section\r\n assert '\"deleted\": true' in delete_session_section\n assert '\"answerCount\"' not in delete_session_section\n\n\ndef test_course_resource_playback_contract_is_frozen():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n error_codes = load_text(\"docs/contracts/error-codes.md\")\n team_division = load_text(TEAM_DIVISION_DOC)\n\n playback_section = api_contract.split(\n \"### `GET /api/v1/course-resources/{resourceId}/playback`\", 1\n )[1].split(\"### `DELETE /api/v1/courses/{courseId}/resources/{resourceId}`\", 1)[0]\n\n for token in (\n \"videoResourceId\",\n \"playbackUrl\",\n \"mimeType\",\n \"expiresAt\",\n \"durationSec\",\n \"KNOWLINK_MINIO_PUBLIC_ENDPOINT\",\n \"http://127.0.0.1:9000\",\n \"minio:9000\",\n \"`404 resource.not_found`\",\n \"`409 resource.not_video`\",\n \"`503 resource.playback_unavailable`\",\n ):\n assert token in playback_section\n\n for token in (\"resource.not_found\", \"resource.not_video\", \"resource.playback_unavailable\"):\n assert token in error_codes\n\n assert \"GET /api/v1/course-resources/{resourceId}/playback\" in team_division\n\n\ndef test_demo_token_and_statuses_are_consistent_across_docs():\n architecture = load_text(ARCHITECTURE_DOC)\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n freeze_doc = load_text(\"docs/contracts/week1-cao-le-freeze.md\")\r\n env_example = load_text(\".env.example\")\r\n\r\n texts = (architecture, api_contract, freeze_doc, env_example)\r\n for text in texts:\r\n assert \"KNOWLINK_DEMO_TOKEN\" in text\r\n\r\n lifecycle_statuses = (\r\n \"draft\",\r\n \"resource_ready\",\r\n \"inquiry_ready\",\r\n \"learning_ready\",\r\n \"archived\",\r\n \"failed\",\r\n )\r\n pipeline_stages = (\"idle\", \"upload\", \"parse\", \"inquiry\", \"handout\")\r\n pipeline_statuses = (\r\n \"idle\",\r\n \"queued\",\r\n \"running\",\r\n \"partial_success\",\r\n \"succeeded\",\r\n \"failed\",\r\n )\r\n async_task_statuses = (\r\n \"queued\",\r\n \"running\",\r\n \"succeeded\",\r\n \"failed\",\r\n \"retrying\",\r\n \"canceled\",\r\n \"skipped\",\r\n )\r\n\r\n for status in lifecycle_statuses + pipeline_stages + pipeline_statuses + async_task_statuses:\r\n assert f\"`{status}`\" in architecture\r\n assert f\"`{status}`\" in api_contract\r\n assert f\"`{status}`\" in freeze_doc\r\n\r\n assert \"`bilibili_import_run`\" in architecture\n assert \"`bilibili_import_run`\" in api_contract\n\n\ndef test_phase5_runtime_defaults_and_resource_delete_contract_are_documented():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n error_codes = load_text(\"docs/contracts/error-codes.md\")\n env_example = load_text(\".env.example\")\n readme = load_text(\"README.md\")\n\n assert \"KNOWLINK_TASK_QUEUE=dramatiq\" in env_example\n assert \"KNOWLINK_SCHEDULER_ENABLED=false\" in env_example\n assert \"KNOWLINK_ENV=development\" in env_example\n assert \"`409 resource.has_dependents`\" in api_contract\n assert \"resource.has_dependents\" in error_codes\n assert \"scheduler 默认禁用\" in readme\n assert \"Dramatiq worker 和 scheduler\" not in readme\n\n\ndef test_docker_runtime_does_not_depend_on_python_bytecode_cache():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n dockerfile = load_text(\"server/Dockerfile\")\n dockerignore = load_text(\".dockerignore\")\n scaffold_doc = load_text(SCAFFOLD_DOC)\n\n assert \"PYTHONDONTWRITEBYTECODE=1\" in dockerfile\n assert \"find /usr/local\" in dockerfile\n assert \"-name '*.pyc' -delete\" in dockerfile\n assert \"-name '__pycache__'\" in dockerfile\n first_pip_install = dockerfile.index(\"pip install\")\n assert dockerfile.index(\"-name '*.pyc' -delete\") < first_pip_install\n assert dockerfile.index(\"-name '__pycache__'\") < first_pip_install\n assert \"pip install --no-cache-dir --no-compile --upgrade pip\" in dockerfile\n assert \"pip install --no-cache-dir --no-compile .[dev]\" in dockerfile\n assert \"__pycache__/\" in dockerignore\n assert \"*.py[cod]\" in dockerignore\n assert \".venv/\" in dockerignore\n assert \"PYTHONDONTWRITEBYTECODE=1\" in api_contract\n assert \"清理镜像内 `.pyc` / `__pycache__`\" in api_contract\n assert \"首次 `pip install` 前\" in api_contract\n assert \"`pip install` 必须使用 `--no-compile`\" in api_contract\n assert \"Python 字节码缓存\" in scaffold_doc\n\n\ndef test_phase5_async_enqueue_and_retry_errors_are_documented():\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n error_codes = load_text(\"docs/contracts/error-codes.md\")\n\n expected_errors = (\n \"async_task.enqueue_failed\",\n \"pipeline.task_not_found\",\n \"pipeline.task_not_retryable\",\n \"pipeline.task_retry_unsupported\",\n \"pipeline.task_retry_stale\",\n )\n for error_code in expected_errors:\n assert error_code in api_contract\n assert error_code in error_codes\n\n assert \"`503 async_task.enqueue_failed`\" in api_contract\n assert \"`POST /api/v1/async-tasks/{taskId}/retry`\" in api_contract\n assert \"只有 `failed`、`queued` 状态可通过该接口重新入队\" in api_contract\n assert \"响应必须返回 `entity.type` / `entity.id`\" in api_contract\n assert \"B站导入重试固定返回 `entity.type=bilibili_import_run`\" in api_contract\n assert \"`retrying` 等可重试任务\" not in api_contract\n assert \"任务状态重置为 `queued` 时发现记录已变化或不可写\" in api_contract\n assert \"任务状态更新为 retry 时\" not in api_contract\n\n\ndef test_async_entity_accepts_bilibili_import_run():\n entity = AsyncEntity(type=\"bilibili_import_run\", id=9101)\n assert entity.type == \"bilibili_import_run\"\n assert entity.id == 9101\n\n\ndef test_lazy_handout_block_async_and_status_contract_are_frozen():\n architecture = load_text(ARCHITECTURE_DOC)\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n\n for text in (architecture, api_contract):\n assert \"`handout_block`\" in text\n assert \"/api/v1/handout-blocks/{blockId}/generate\" in text\n assert \"GET /api/v1/handout-blocks/{blockId}/status\" in text\n assert \"Idempotency-Key\" in text\n\n assert \"`POST /handout-blocks/{blockId}/generate` 返回 `entity.type = handout_block`\" in architecture\n assert \"`handout_block`:`GET /api/v1/handout-blocks/{blockId}/status`\" in architecture\n assert \"异步触发接口返回的 `entity.type` 白名单\" in api_contract\n assert \"不得创建重复 block 或重复任务\" in api_contract\n assert \"重复请求返回当前任务\" in api_contract\n assert \"重复请求返回当前 block 状态,不重新生成\" in api_contract\n\n\ndef test_handout_version_status_semantics_are_frozen():\n architecture = load_text(ARCHITECTURE_DOC)\n api_contract = load_text(\"docs/contracts/api-contract.md\")\n\n for text in (architecture, api_contract):\n assert \"outline_ready\" in text\n assert \"partial_success\" in text\n assert \"目录可展示但 block 正文未全生成\" in text\n assert \"必要 block 全 ready\" in text\n assert \"目录可用但部分 block 失败或降级\" in text\n\n assert \"`handout_versions.status`\" in api_contract\n assert \"`draft` `generating` `outline_ready` `ready` `partial_success` `failed` `superseded`\" in api_contract\n\n\ndef test_freeze_doc_tracks_seed_titles_and_fixed_manual_course_title():\n freeze_doc = load_text(\"docs/contracts/week1-cao-le-freeze.md\")\n catalog = load_json(\"server/seeds/course_catalog.json\")\n\r\n for item in catalog:\r\n assert item[\"title\"] in freeze_doc\r\n\r\n assert \"KnowLink 固定联调课\" in freeze_doc\r\n\r\n\r\ndef test_collaboration_docs_expose_change_flow_and_priority_matrices():\n readme = load_text(\"README.md\")\n team_division = load_text(TEAM_DIVISION_DOC)\n scaffold = load_text(SCAFFOLD_DOC)\n\r\n assert \"文档优先级矩阵\" in readme\r\n assert \"api-contract.md\" in readme\r\n assert ARCHITECTURE_DOC in readme\n assert TEAM_DIVISION_DOC in readme\n\r\n assert \"Schema / Contract 变更流程\" in team_division\r\n assert \"文档优先级矩阵\" in team_division\r\n assert \"当前完成度矩阵\" in scaffold\r\n\r\n\r\n@pytest.mark.parametrize(\r\n (\"schema_path\", \"payload\"),\r\n [\r\n (\r\n \"schemas/ai/handout_blocks.schema.json\",\r\n {\r\n \"title\": \"高数讲义\",\r\n \"summary\": \"固定联调版本\",\r\n \"blocks\": [\r\n {\r\n \"title\": \"PDF 引用块\",\r\n \"summary\": \"含页码\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 10,\r\n \"knowledgePointIds\": [\"kp-1\"],\r\n \"citations\": [{\"resourceId\": 501, \"refLabel\": \"PDF 第 2 页\", \"pageNo\": 2}],\r\n },\r\n {\r\n \"title\": \"PPTX 引用块\",\r\n \"summary\": \"含 slide\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 8,\r\n \"knowledgePointIds\": [\"kp-2\"],\r\n \"citations\": [{\"resourceId\": 502, \"refLabel\": \"PPT 第 6 页\", \"slideNo\": 6}],\r\n },\r\n {\r\n \"title\": \"DOCX 引用块\",\r\n \"summary\": \"含 anchor\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 6,\r\n \"knowledgePointIds\": [\"kp-3\"],\r\n \"citations\": [{\"resourceId\": 503, \"refLabel\": \"DOCX 积分部分\", \"anchorKey\": \"section-integral\"}],\r\n },\r\n {\r\n \"title\": \"视频引用块\",\r\n \"summary\": \"含时间\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 12,\r\n \"knowledgePointIds\": [\"kp-4\"],\r\n \"citations\": [\r\n {\r\n \"resourceId\": 504,\r\n \"refLabel\": \"视频 02:00-04:00\",\r\n \"startSec\": 120,\r\n \"endSec\": 240,\r\n }\r\n ],\r\n },\r\n ],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/qa_response.schema.json\",\r\n {\r\n \"answerMd\": \"回答内容\",\r\n \"answerType\": \"direct_answer\",\r\n \"citations\": [\n {\"resourceId\": 501, \"refLabel\": \"PDF 第 2 页\", \"pageNo\": 2},\n {\"resourceId\": 502, \"refLabel\": \"PPT 第 6 页\", \"slideNo\": 6},\n {\"resourceId\": 503, \"refLabel\": \"DOCX 积分部分\", \"anchorKey\": \"section-integral\"},\n {\"resourceId\": 504, \"refLabel\": \"视频 02:00-04:00\", \"startSec\": 120, \"endSec\": 240},\n ],\n \"generationMetadata\": {\n \"source\": \"model\",\n \"reason\": \"model_response\",\n \"evidenceTier\": \"original_evidence\",\n },\n },\n ),\n ],\n)\ndef test_citation_schemas_accept_all_reference_types(schema_path: str, payload: dict):\n build_validator(schema_path).validate(payload)\n\n\ndef test_qa_response_schema_accepts_generation_metadata_evidence_tier():\n validator = build_validator(\"schemas/ai/qa_response.schema.json\")\n validator.validate(\n {\n \"answerMd\": \"依据讲义内容回答,未找到可追溯原始资料引用。\\n\\n集合是确定对象组成的整体。\",\n \"answerType\": \"direct_answer\",\n \"citations\": [],\n \"generationMetadata\": {\n \"source\": \"fallback\",\n \"reason\": \"handout_context_match\",\n \"evidenceTier\": \"handout_context\",\n \"handoutContext\": {\n \"handoutBlockId\": 501,\n \"outlineKey\": \"ch1-def\",\n \"title\": \"集合的定义\",\n },\n },\n }\n )\n\n\ndef test_qa_response_schema_rejects_missing_generation_metadata_evidence_tier():\n validator = build_validator(\"schemas/ai/qa_response.schema.json\")\n with pytest.raises(ValidationError):\n validator.validate(\n {\n \"answerMd\": \"集合是确定对象组成的整体。\",\n \"answerType\": \"direct_answer\",\n \"citations\": [{\"resourceId\": 501, \"refLabel\": \"PDF 第 2 页\", \"pageNo\": 2}],\n \"generationMetadata\": {\n \"source\": \"model\",\n \"reason\": \"model_response\",\n },\n }\n )\n\n\n@pytest.mark.parametrize(\"evidence_tier\", [\"handout_context\", \"course_prior\", \"out_of_scope\"])\ndef test_qa_response_schema_rejects_non_original_tier_with_citations(evidence_tier: str):\n validator = build_validator(\"schemas/ai/qa_response.schema.json\")\n with pytest.raises(ValidationError):\n validator.validate(\n {\n \"answerMd\": \"依据讲义内容回答。\",\n \"answerType\": \"direct_answer\",\n \"citations\": [{\"resourceId\": 501, \"refLabel\": \"PDF 第 2 页\", \"pageNo\": 2}],\n \"generationMetadata\": {\n \"source\": \"fallback\",\n \"reason\": \"non_original_tier\",\n \"evidenceTier\": evidence_tier,\n },\n }\n )\n\n\n@pytest.mark.parametrize(\n \"generation_metadata\",\n [\n {\"source\": \"fallback\", \"reason\": \"bad_tier\", \"evidenceTier\": \"lecture_only\"},\n {\"source\": \"fallback\", \"reason\": \"extra_field\", \"unexpectedField\": True},\n ],\n)\ndef test_qa_response_schema_rejects_invalid_generation_metadata(generation_metadata: dict):\n validator = build_validator(\"schemas/ai/qa_response.schema.json\")\n with pytest.raises(ValidationError):\n validator.validate(\n {\n \"answerMd\": \"回答内容\",\n \"answerType\": \"direct_answer\",\n \"citations\": [],\n \"generationMetadata\": generation_metadata,\n }\n )\n\n\n@pytest.mark.parametrize(\n (\"schema_path\", \"payload\"),\n [\n (\r\n \"schemas/ai/handout_blocks.schema.json\",\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"blocks\": [\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 5,\r\n \"knowledgePointIds\": [\"kp-1\"],\r\n \"citations\": [{\"refLabel\": \"缺 resourceId\"}],\r\n }\r\n ],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/qa_response.schema.json\",\r\n {\r\n \"answerMd\": \"bad\",\r\n \"answerType\": \"direct_answer\",\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"bad\", \"unknownField\": True}],\r\n },\r\n ),\r\n ],\r\n)\r\ndef test_citation_schemas_reject_missing_required_or_unknown_fields(schema_path: str, payload: dict):\r\n with pytest.raises(ValidationError):\r\n build_validator(schema_path).validate(payload)\r\n\r\n\r\n@pytest.mark.parametrize(\r\n (\"schema_path\", \"payload\"),\r\n [\r\n (\r\n \"schemas/ai/handout_blocks.schema.json\",\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"blocks\": [\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 5,\r\n \"knowledgePointIds\": [\"kp-1\"],\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"缺定位\"}],\r\n }\r\n ],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/handout_blocks.schema.json\",\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"blocks\": [\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 5,\r\n \"knowledgePointIds\": [\"kp-1\"],\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"混合定位\", \"pageNo\": 2, \"slideNo\": 6}],\r\n }\r\n ],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/handout_blocks.schema.json\",\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"blocks\": [\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 5,\r\n \"knowledgePointIds\": [\"kp-1\"],\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"文档混入视频\", \"anchorKey\": \"sec-1\", \"startSec\": 30}],\r\n }\r\n ],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/handout_blocks.schema.json\",\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"blocks\": [\r\n {\r\n \"title\": \"bad\",\r\n \"summary\": \"bad\",\r\n \"contentMd\": \"content\",\r\n \"estimatedMinutes\": 5,\r\n \"knowledgePointIds\": [\"kp-1\"],\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"视频缺结束时间\", \"startSec\": 30}],\r\n }\r\n ],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/qa_response.schema.json\",\r\n {\r\n \"answerMd\": \"bad\",\r\n \"answerType\": \"direct_answer\",\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"缺定位\"}],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/qa_response.schema.json\",\r\n {\r\n \"answerMd\": \"bad\",\r\n \"answerType\": \"direct_answer\",\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"混合定位\", \"pageNo\": 2, \"slideNo\": 6}],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/qa_response.schema.json\",\r\n {\r\n \"answerMd\": \"bad\",\r\n \"answerType\": \"direct_answer\",\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"文档混入视频\", \"anchorKey\": \"sec-1\", \"startSec\": 30}],\r\n },\r\n ),\r\n (\r\n \"schemas/ai/qa_response.schema.json\",\r\n {\r\n \"answerMd\": \"bad\",\r\n \"answerType\": \"direct_answer\",\r\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"视频缺开始时间\", \"endSec\": 30}],\r\n },\r\n ),\r\n ],\r\n)\r\ndef test_citation_schemas_reject_missing_or_mixed_locator_fields(schema_path: str, payload: dict):\n with pytest.raises(ValidationError):\n build_validator(schema_path).validate(payload)\n\n\ndef test_handout_outline_schema_accepts_valid_payload():\n payload = {\n \"title\": \"集合的初见\",\n \"summary\": \"按视频时间线组织的讲义目录\",\n \"items\": [\n {\n \"outlineKey\": \"section-1\",\n \"title\": \"集合的概念与表示\",\n \"summary\": \"从集合定义过渡到集合表示方法\",\n \"startSec\": 0,\n \"endSec\": 360,\n \"sortNo\": 1,\n \"children\": [\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"集合的基本概念\",\n \"summary\": \"介绍集合、元素和属于关系\",\n \"startSec\": 0,\n \"endSec\": 180,\n \"sortNo\": 1,\n \"generationStatus\": \"pending\",\n \"sourceSegmentKeys\": [\"mp4-c1\", \"mp4-c2\"],\n \"topicTags\": [\"集合\"],\n },\n {\n \"outlineKey\": \"outline-2\",\n \"title\": \"集合的表示方法\",\n \"summary\": \"从列举法过渡到描述法\",\n \"startSec\": 180,\n \"endSec\": 360,\n \"sortNo\": 2,\n \"generationStatus\": \"ready\",\n \"sourceSegmentKeys\": [\"mp4-c3\"],\n \"topicTags\": [],\n },\n ],\n },\n ],\n }\n\n build_validator(\"schemas/ai/handout_outline.schema.json\").validate(payload)\n\n\ndef test_handout_outline_timeline_validator_catches_contract_invalid_payload():\n schema = load_json(\"schemas/ai/handout_outline.schema.json\")\n payload = {\n \"title\": \"集合的初见\",\n \"summary\": \"结构合法但时间线非法\",\n \"items\": [\n {\n \"outlineKey\": \"section-1\",\n \"title\": \"非法父级\",\n \"summary\": \"非法父级\",\n \"startSec\": 0,\n \"endSec\": 120,\n \"sortNo\": 1,\n \"children\": [\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"第一段\",\n \"summary\": \"第一段\",\n \"startSec\": 0,\n \"endSec\": 100,\n \"sortNo\": 1,\n \"generationStatus\": \"pending\",\n \"sourceSegmentKeys\": [\"mp4-c1\"],\n },\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"第二段\",\n \"summary\": \"第二段\",\n \"startSec\": 90,\n \"endSec\": 120,\n \"sortNo\": 1,\n \"generationStatus\": \"pending\",\n \"sourceSegmentKeys\": [\"mp4-c2\"],\n },\n ],\n },\n ],\n }\n\n assert \"outline_timeline_issues\" in schema[\"description\"]\n build_validator(\"schemas/ai/handout_outline.schema.json\").validate(payload)\n assert outline_timeline_issues(payload[\"items\"]) == [\n \"outline.key_duplicate\",\n \"outline.sort_not_increasing\",\n \"outline.time_overlap\",\n ]\n\n\n@pytest.mark.parametrize(\n \"payload\",\n [\n {\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"items\": [\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"startSec\": 0,\n \"endSec\": 180,\n \"sortNo\": 1,\n \"children\": [],\n }\n ],\n },\n {\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"items\": [\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"startSec\": 0,\n \"endSec\": 180,\n \"sortNo\": 1,\n \"children\": [\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"startSec\": 0,\n \"endSec\": 180,\n \"sortNo\": 1,\n \"generationStatus\": \"done\",\n \"sourceSegmentKeys\": [\"mp4-c1\"],\n }\n ],\n }\n ],\n },\n {\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"items\": [\n {\n \"outlineKey\": \"section-1\",\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"startSec\": 0,\n \"endSec\": 180,\n \"sortNo\": 1,\n \"children\": [\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"startSec\": 0,\n \"endSec\": 180,\n \"sortNo\": 1,\n \"generationStatus\": \"pending\",\n \"sourceSegmentKeys\": [\"mp4-c1\"],\n }\n ],\n \"sourceSegmentKeys\": [\"mp4-c1\"],\n }\n ],\n },\n ],\n)\ndef test_handout_outline_schema_rejects_invalid_payloads(payload: dict):\n with pytest.raises(ValidationError):\n build_validator(\"schemas/ai/handout_outline.schema.json\").validate(payload)\n\n\ndef test_handout_block_schema_accepts_valid_lazy_block_payload():\n schema = load_json(\"schemas/ai/handout_block.schema.json\")\n payload = {\n \"outlineKey\": \"outline-1\",\n \"title\": \"集合的基本概念\",\n \"summary\": \"介绍集合、元素和属于关系\",\n \"contentMd\": \"### 集合的基本概念\\n集合由确定对象组成。\",\n \"estimatedMinutes\": 6,\n \"sourceSegmentKeys\": [\"mp4-c1\", \"mp4-c2\"],\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-set-basic\",\n \"displayName\": \"集合\",\n \"description\": \"集合是确定对象组成的整体。\",\n \"difficultyLevel\": \"beginner\",\n \"importanceScore\": 90,\n \"sortNo\": 1,\n }\n ],\n \"citations\": [\n {\n \"resourceId\": 501,\n \"segmentKey\": \"mp4-c1\",\n \"refLabel\": \"视频 00:00-03:00\",\n \"startSec\": 0,\n \"endSec\": 180,\n },\n {\n \"resourceId\": 502,\n \"segmentKey\": \"pdf-p1\",\n \"refLabel\": \"PDF 第 1 页\",\n \"pageNo\": 1,\n },\n ],\n \"generationMetadata\": {\"source\": \"model\", \"reason\": \"model_response\"},\n }\n\n assert \"ready handout block\" in schema[\"description\"]\n build_validator(\"schemas/ai/handout_block.schema.json\").validate(payload)\n\n\n@pytest.mark.parametrize(\n \"payload\",\n [\n {\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"contentMd\": \"bad\",\n \"estimatedMinutes\": 3,\n \"sourceSegmentKeys\": [\"mp4-c1\"],\n \"knowledgePoints\": [],\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"视频\", \"startSec\": 0, \"endSec\": 10}],\n },\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"contentMd\": \"bad\",\n \"estimatedMinutes\": 3,\n \"sourceSegmentKeys\": [],\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-1\",\n \"displayName\": \"bad\",\n \"description\": \"bad\",\n \"difficultyLevel\": \"beginner\",\n \"importanceScore\": 1,\n \"sortNo\": 1,\n }\n ],\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"视频\", \"startSec\": 0, \"endSec\": 10}],\n },\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"contentMd\": \"bad\",\n \"estimatedMinutes\": 3,\n \"sourceSegmentKeys\": [\"mp4-c1\"],\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-1\",\n \"displayName\": \"bad\",\n \"description\": \"bad\",\n \"difficultyLevel\": \"expert\",\n \"importanceScore\": 1,\n \"sortNo\": 1,\n }\n ],\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"视频\", \"startSec\": 0, \"endSec\": 10}],\n },\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"bad\",\n \"summary\": \"bad\",\n \"contentMd\": \"bad\",\n \"estimatedMinutes\": 3,\n \"sourceSegmentKeys\": [\"mp4-c1\"],\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-1\",\n \"displayName\": \"bad\",\n \"description\": \"bad\",\n \"difficultyLevel\": \"beginner\",\n \"importanceScore\": 1,\n \"sortNo\": 1,\n }\n ],\n \"citations\": [{\"resourceId\": 1, \"refLabel\": \"混合定位\", \"pageNo\": 1, \"startSec\": 0, \"endSec\": 10}],\n },\n ],\n)\ndef test_handout_block_schema_rejects_invalid_lazy_block_payloads(payload: dict):\n with pytest.raises(ValidationError):\n build_validator(\"schemas/ai/handout_block.schema.json\").validate(payload)\n\n\n@pytest.mark.parametrize(\n (\"schema_path\", \"payload\"),\n [\n (\n \"schemas/ai/handout_block.schema.json\",\n {\n \"outlineKey\": \"outline-1\",\n \"title\": \"集合的基本概念\",\n \"summary\": \"介绍集合、元素和属于关系\",\n \"contentMd\": \"### 集合的基本概念\\n集合由确定对象组成。\",\n \"estimatedMinutes\": 6,\n \"sourceSegmentKeys\": [\"mp4-c1\"],\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-set-basic\",\n \"displayName\": \"集合\",\n \"description\": \"集合是确定对象组成的整体。\",\n \"difficultyLevel\": \"beginner\",\n \"importanceScore\": 90,\n \"sortNo\": 1,\n }\n ],\n \"citations\": [\n {\n \"resourceId\": 501,\n \"segmentKey\": \"mp4-c1\",\n \"refLabel\": \"视频 00:00-03:00\",\n \"startSec\": 0,\n \"endSec\": 180,\n }\n ],\n },\n ),\n (\n \"schemas/ai/qa_response.schema.json\",\n {\n \"answerMd\": \"回答内容\",\n \"answerType\": \"direct_answer\",\n \"citations\": [{\"resourceId\": 501, \"refLabel\": \"PDF 第 2 页\", \"pageNo\": 2}],\n },\n ),\n ],\n)\ndef test_phase4_ai_schemas_reject_missing_generation_metadata(schema_path: str, payload: dict):\n with pytest.raises(ValidationError):\n build_validator(schema_path).validate(payload)\n\n\ndef test_handout_block_response_dto_exposes_generation_metadata_alias():\n payload = HandoutBlockResponse(\n blockId=4001,\n handoutVersionId=3001,\n outlineKey=\"outline-1\",\n title=\"极限与连续\",\n summary=\"先抓必考定义和题型\",\n status=\"ready\",\n generationMetadata={\"source\": \"fallback\", \"reason\": \"model_unavailable\"},\n citations=[],\n ).model_dump(by_alias=True)\n\n assert payload[\"generationMetadata\"] == {\"source\": \"fallback\", \"reason\": \"model_unavailable\"}\n\n\n@pytest.mark.parametrize(\n (\"schema_path\", \"payload\"),\n [\n (\n \"schemas/ai/quiz_generation.schema.json\",\n {\n \"quizType\": \"exam_drill\",\n \"questions\": [\n {\n \"questionKey\": \"q1-kp-limit\",\n \"questionType\": \"single_choice\",\n \"stemMd\": \"关于极限定义,哪项说法正确?\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"基础算术。\",\n \"difficultyLevel\": \"easy\",\n \"knowledgePointKey\": \"kp-limit\",\n \"knowledgePointName\": \"极限定义\",\n \"sourceBlockKey\": \"block-limit\",\n \"sourceSegmentKeys\": [\"pdf-p1\"],\n },\n {\n \"questionKey\": \"q2-kp-continuity\",\n \"questionType\": \"single_choice\",\n \"stemMd\": \"关于连续性,哪项说法正确?\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"依据当前讲义块。\",\n \"difficultyLevel\": \"medium\",\n \"knowledgePointKey\": \"kp-continuity\",\n \"knowledgePointName\": \"连续性\",\n \"sourceBlockKey\": \"block-continuity\",\n \"sourceSegmentKeys\": [\"pdf-p2\"],\n },\n {\n \"questionKey\": \"q3-kp-derivative\",\n \"questionType\": \"single_choice\",\n \"stemMd\": \"关于导数,哪项说法正确?\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"依据当前讲义块。\",\n \"difficultyLevel\": \"hard\",\n \"knowledgePointKey\": \"kp-derivative\",\n \"knowledgePointName\": \"导数\",\n \"sourceBlockKey\": \"block-derivative\",\n \"sourceSegmentKeys\": [\"pdf-p3\"],\n }\n ],\n },\n ),\n (\n \"schemas/ai/review_tasks.schema.json\",\n {\n \"tasks\": [\n {\n \"taskKey\": \"review-kp-limit\",\n \"taskType\": \"redo_quiz\",\n \"priorityScore\": 80,\n \"reasonText\": \"错题集中。\",\n \"recommendedMinutes\": 15,\n \"knowledgePointKey\": \"kp-limit\",\n \"sourceQuestionKeys\": [\"q1-kp-limit\"],\n \"sourceBlockKey\": \"block-limit\",\n \"sourceSegmentKeys\": [\"pdf-p1\"],\n \"reviewOrder\": 1,\n \"reasonTags\": [\"recent_wrong\", \"low_mastery\"],\n \"recommendedAction\": {\n \"type\": \"redo_quiz\",\n \"targetBlockKey\": \"block-limit\",\n \"label\": \"再练同类题\",\n },\n }\n ],\n },\n ),\n (\n \"schemas/ai/review_tasks.schema.json\",\n {\"tasks\": []},\n ),\n ],\n)\ndef test_quiz_and_review_schemas_accept_valid_payloads(schema_path: str, payload: dict):\n build_validator(schema_path).validate(payload)\n\n\ndef _quiz_question_payload(index: int) -> dict:\n return {\n \"questionKey\": f\"q{index}\",\n \"questionType\": \"single_choice\",\n \"stemMd\": \"题干\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"解释\",\n \"difficultyLevel\": \"easy\",\n \"knowledgePointKey\": f\"kp-{index}\",\n \"knowledgePointName\": \"知识点\",\n \"sourceBlockKey\": f\"block-{index}\",\n \"sourceSegmentKeys\": [f\"seg-{index}\"],\n }\n\n\ndef test_quiz_schema_accepts_live_generation_question_count_bounds():\n validator = build_validator(\"schemas/ai/quiz_generation.schema.json\")\n for count in (1, 10):\n validator.validate(\n {\n \"quizType\": \"chapter_review\",\n \"questions\": [_quiz_question_payload(index) for index in range(1, count + 1)],\n }\n )\n\n\n@pytest.mark.parametrize(\n (\"schema_path\", \"payload\"),\r\n [\r\n (\r\n \"schemas/ai/quiz_generation.schema.json\",\r\n {\"quizType\": \"exam_drill\"},\r\n ),\r\n (\n \"schemas/ai/quiz_generation.schema.json\",\n {\"quizType\": \"exam_drill\", \"questions\": []},\n ),\n (\n \"schemas/ai/quiz_generation.schema.json\",\n {\n \"quizType\": \"exam_drill\",\n \"questions\": [_quiz_question_payload(index) for index in range(1, 12)],\n },\n ),\n (\n \"schemas/ai/quiz_generation.schema.json\",\n {\n \"quizType\": \"exam_drill\",\n \"questions\": [\n {\n \"questionKey\": \"q1\",\n \"questionType\": \"single_choice\",\n \"stemMd\": \"题干\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"解释\",\n \"difficultyLevel\": \"expert\",\n \"knowledgePointKey\": \"kp-1\",\n \"knowledgePointName\": \"知识点\",\n \"sourceBlockKey\": \"block-1\",\n \"sourceSegmentKeys\": [\"seg-1\"],\n },\n {\n \"questionKey\": \"q2\",\n \"questionType\": \"single_choice\",\n \"stemMd\": \"题干\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"解释\",\n \"difficultyLevel\": \"easy\",\n \"knowledgePointKey\": \"kp-2\",\n \"knowledgePointName\": \"知识点\",\n \"sourceBlockKey\": \"block-2\",\n \"sourceSegmentKeys\": [\"seg-2\"],\n },\n {\n \"questionKey\": \"q3\",\n \"questionType\": \"single_choice\",\n \"stemMd\": \"题干\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"解释\",\n \"difficultyLevel\": \"easy\",\n \"knowledgePointKey\": \"kp-3\",\n \"knowledgePointName\": \"知识点\",\n \"sourceBlockKey\": \"block-3\",\n \"sourceSegmentKeys\": [\"seg-3\"],\n }\n ],\n },\n ),\r\n (\r\n \"schemas/ai/quiz_generation.schema.json\",\r\n {\n \"quizType\": \"exam_drill\",\n \"questions\": [\n {\n \"questionKey\": \"q1\",\n \"stemMd\": \"题干\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"解释\",\n \"difficultyLevel\": \"easy\",\n \"questionType\": \"single_choice\",\n \"knowledgePointKey\": \"kp-1\",\n \"knowledgePointName\": \"知识点\",\n \"sourceBlockKey\": \"block-1\",\n \"sourceSegmentKeys\": [\"seg-1\"],\n \"pageNo\": 2,\n },\n {\n \"questionKey\": \"q2\",\n \"stemMd\": \"题干\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"解释\",\n \"difficultyLevel\": \"easy\",\n \"questionType\": \"single_choice\",\n \"knowledgePointKey\": \"kp-2\",\n \"knowledgePointName\": \"知识点\",\n \"sourceBlockKey\": \"block-2\",\n \"sourceSegmentKeys\": [\"seg-2\"],\n },\n {\n \"questionKey\": \"q3\",\n \"stemMd\": \"题干\",\n \"options\": [\"A\", \"B\", \"C\", \"D\"],\n \"correctAnswer\": \"A\",\n \"explanationMd\": \"解释\",\n \"difficultyLevel\": \"easy\",\n \"questionType\": \"single_choice\",\n \"knowledgePointKey\": \"kp-3\",\n \"knowledgePointName\": \"知识点\",\n \"sourceBlockKey\": \"block-3\",\n \"sourceSegmentKeys\": [\"seg-3\"],\n }\n ],\n },\n ),\r\n (\r\n \"schemas/ai/review_tasks.schema.json\",\r\n {},\r\n ),\r\n (\n \"schemas/ai/review_tasks.schema.json\",\n {\n \"tasks\": [\n {\r\n \"taskType\": \"memorize\",\r\n \"priorityScore\": 80,\r\n \"reasonText\": \"原因\",\r\n \"recommendedMinutes\": 15,\r\n }\r\n ]\r\n },\r\n ),\r\n (\r\n \"schemas/ai/review_tasks.schema.json\",\r\n {\r\n \"tasks\": [\r\n {\r\n \"taskType\": \"redo_quiz\",\r\n \"priorityScore\": 101,\r\n \"reasonText\": \"原因\",\r\n \"recommendedMinutes\": 15,\r\n }\r\n ]\r\n },\r\n ),\r\n (\r\n \"schemas/ai/review_tasks.schema.json\",\r\n {\r\n \"tasks\": [\r\n {\r\n \"taskType\": \"redo_quiz\",\r\n \"priorityScore\": 80,\r\n \"reasonText\": \"原因\",\r\n \"recommendedMinutes\": 0,\r\n }\r\n ]\r\n },\r\n ),\r\n (\r\n \"schemas/ai/review_tasks.schema.json\",\r\n {\r\n \"tasks\": [\r\n {\r\n \"taskType\": \"redo_quiz\",\r\n \"priorityScore\": 80,\r\n \"reasonText\": \"原因\",\r\n \"recommendedMinutes\": 15,\r\n \"intensity\": \"high\",\n }\n ]\n },\n ),\n (\n \"schemas/ai/review_tasks.schema.json\",\n {\n \"tasks\": [\n {\n \"taskKey\": \"review-kp-limit\",\n \"taskType\": \"redo_quiz\",\n \"priorityScore\": 80,\n \"reasonText\": \"错题集中。\",\n \"recommendedMinutes\": 15,\n \"knowledgePointKey\": \"kp-limit\",\n \"sourceQuestionKeys\": [\"q1-kp-limit\"],\n \"sourceBlockKey\": \"block-limit\",\n \"sourceSegmentKeys\": [\"pdf-p1\"],\n \"reviewOrder\": 1,\n \"reasonTags\": [\"recent_wrong\"],\n \"recommendedAction\": {\n \"type\": \"redo_quiz\",\n \"targetBlockKey\": \"block-limit\",\n \"label\": \"再练同类题\",\n },\n \"pageNo\": 2,\n }\n ]\n },\n ),\n ],\n)\ndef test_quiz_and_review_schemas_reject_invalid_payloads(schema_path: str, payload: dict):\n with pytest.raises(ValidationError):\n build_validator(schema_path).validate(payload)\n\n\ndef assert_knowledge_extraction_references_are_declared(payload: dict, known_segment_keys: set[str]) -> None:\n knowledge_point_keys = {item[\"knowledgePointKey\"] for item in payload[\"knowledgePoints\"]}\n\n for relation in payload[\"segmentKnowledgePoints\"]:\n assert relation[\"segmentKey\"] in known_segment_keys\n assert relation[\"knowledgePointKey\"] in knowledge_point_keys\n\n for evidence in payload[\"knowledgePointEvidences\"]:\n assert evidence[\"segmentKey\"] in known_segment_keys\n assert evidence[\"knowledgePointKey\"] in knowledge_point_keys\n\n\ndef test_knowledge_point_extraction_schema_accepts_valid_payload():\n payload = {\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-limit\",\n \"displayName\": \"函数极限\",\n \"canonicalName\": \"function_limit\",\n \"description\": \"函数在自变量趋近某点时的稳定趋势。\",\n \"difficultyLevel\": \"intermediate\",\n \"importanceScore\": 92,\n \"aliases\": [\"极限\", \"limit\"],\n \"sortNo\": 1,\n }\n ],\n \"segmentKnowledgePoints\": [\n {\n \"segmentKey\": \"seg-pdf-1\",\n \"knowledgePointKey\": \"kp-limit\",\n \"relevanceScore\": 0.92,\n \"sortNo\": 1,\n }\n ],\n \"knowledgePointEvidences\": [\n {\n \"segmentKey\": \"seg-pdf-1\",\n \"knowledgePointKey\": \"kp-limit\",\n \"evidenceType\": \"definition\",\n \"pageNo\": 2,\n \"sortNo\": 1,\n }\n ],\n }\n\n build_validator(\"schemas/ai/knowledge_point_extraction.schema.json\").validate(payload)\n assert_knowledge_extraction_references_are_declared(payload, {\"seg-pdf-1\"})\n\n\n@pytest.mark.parametrize(\n \"payload\",\n [\n {},\n {\n \"knowledgePoints\": [],\n \"segmentKnowledgePoints\": [],\n \"knowledgePointEvidences\": [],\n },\n {\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-limit\",\n \"displayName\": \"函数极限\",\n \"canonicalName\": \"function_limit\",\n \"description\": \"函数在自变量趋近某点时的稳定趋势。\",\n \"difficultyLevel\": \"medium\",\n \"importanceScore\": 92,\n \"aliases\": [],\n \"sortNo\": 1,\n }\n ],\n \"segmentKnowledgePoints\": [],\n \"knowledgePointEvidences\": [],\n },\n {\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-limit\",\n \"displayName\": \"函数极限\",\n \"canonicalName\": \"function_limit\",\n \"description\": \"函数在自变量趋近某点时的稳定趋势。\",\n \"difficultyLevel\": \"intermediate\",\n \"importanceScore\": 92,\n \"aliases\": [],\n \"sortNo\": 1,\n \"unexpected\": True,\n }\n ],\n \"segmentKnowledgePoints\": [],\n \"knowledgePointEvidences\": [],\n },\n {\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-limit\",\n \"displayName\": \"函数极限\",\n \"canonicalName\": \"function_limit\",\n \"description\": \"函数在自变量趋近某点时的稳定趋势。\",\n \"difficultyLevel\": \"intermediate\",\n \"importanceScore\": 92,\n \"aliases\": [],\n \"sortNo\": 1,\n }\n ],\n \"segmentKnowledgePoints\": [\n {\n \"segmentKey\": \"seg-pdf-1\",\n \"knowledgePointKey\": \"kp-limit\",\n \"relevanceScore\": 1.1,\n \"sortNo\": 1,\n }\n ],\n \"knowledgePointEvidences\": [],\n },\n {\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-limit\",\n \"displayName\": \"函数极限\",\n \"canonicalName\": \"function_limit\",\n \"description\": \"函数在自变量趋近某点时的稳定趋势。\",\n \"difficultyLevel\": \"intermediate\",\n \"importanceScore\": 92,\n \"aliases\": [],\n \"sortNo\": 1,\n }\n ],\n \"segmentKnowledgePoints\": [],\n \"knowledgePointEvidences\": [\n {\n \"segmentKey\": \"seg-pdf-1\",\n \"knowledgePointKey\": \"kp-limit\",\n \"evidenceType\": \"definition\",\n \"pageNo\": 2,\n \"slideNo\": 3,\n \"sortNo\": 1,\n }\n ],\n },\n ],\n)\ndef test_knowledge_point_extraction_schema_rejects_invalid_payloads(payload: dict):\n with pytest.raises(ValidationError):\n build_validator(\"schemas/ai/knowledge_point_extraction.schema.json\").validate(payload)\n\n\ndef test_knowledge_point_extraction_references_must_be_declared():\n payload = {\n \"knowledgePoints\": [\n {\n \"knowledgePointKey\": \"kp-limit\",\n \"displayName\": \"函数极限\",\n \"canonicalName\": \"function_limit\",\n \"description\": \"函数在自变量趋近某点时的稳定趋势。\",\n \"difficultyLevel\": \"intermediate\",\n \"importanceScore\": 92,\n \"aliases\": [],\n \"sortNo\": 1,\n }\n ],\n \"segmentKnowledgePoints\": [\n {\n \"segmentKey\": \"seg-missing\",\n \"knowledgePointKey\": \"kp-missing\",\n \"relevanceScore\": 0.9,\n \"sortNo\": 1,\n }\n ],\n \"knowledgePointEvidences\": [\n {\n \"segmentKey\": \"seg-missing\",\n \"knowledgePointKey\": \"kp-limit\",\n \"evidenceType\": \"definition\",\n \"pageNo\": 2,\n \"sortNo\": 1,\n }\n ],\n }\n\n build_validator(\"schemas/ai/knowledge_point_extraction.schema.json\").validate(payload)\n with pytest.raises(AssertionError):\n assert_knowledge_extraction_references_are_declared(payload, {\"seg-pdf-1\"})\n\n\ndef test_normalized_document_schema_enforces_resource_specific_locations():\n validator = build_validator(\"schemas/parse/normalized_document.schema.json\")\n\n valid_payloads = [\n {\n \"resourceType\": \"pdf\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pdf-1\",\n \"segmentType\": \"pdf_page_text\",\n \"orderNo\": 1,\n \"textContent\": \"limit\",\n \"pageNo\": 2,\n }\n ],\n },\n {\n \"resourceType\": \"pptx\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pptx-1\",\n \"segmentType\": \"ppt_slide_text\",\n \"orderNo\": 1,\n \"textContent\": \"matrix\",\n \"slideNo\": 6,\n }\n ],\n },\n {\n \"resourceType\": \"docx\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-docx-1\",\n \"segmentType\": \"docx_block_text\",\n \"orderNo\": 1,\n \"textContent\": \"integral\",\n \"sectionPath\": [\"第 1 章\", \"积分\"],\n }\n ],\n },\n {\r\n \"resourceType\": \"mp4\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-video-1\",\n \"segmentType\": \"video_caption\",\n \"orderNo\": 1,\n \"textContent\": \"video\",\n \"startSec\": 0,\n \"endSec\": 30,\r\n }\r\n ],\r\n },\r\n {\r\n \"resourceType\": \"srt\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-srt-1\",\n \"segmentType\": \"video_caption\",\n \"orderNo\": 1,\n \"textContent\": \"subtitle\",\n \"startSec\": 30,\n \"endSec\": 45,\r\n }\r\n ],\r\n },\r\n ]\r\n\r\n invalid_payloads = [\n {\n \"resourceType\": \"pdf\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pdf-bad-1\",\n \"segmentType\": \"pdf_page_text\",\n \"orderNo\": 1,\n \"textContent\": \"limit\",\n \"anchorKey\": \"bad\",\n }\n ],\n },\n {\n \"resourceType\": \"pdf\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pdf-bad-2\",\n \"segmentType\": \"pdf_page_text\",\n \"orderNo\": 1,\n \"textContent\": \"limit\",\n \"startSec\": 10,\n }\n ],\n },\n {\n \"resourceType\": \"pdf\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pdf-bad-3\",\n \"segmentType\": \"pdf_page_text\",\n \"orderNo\": 1,\n \"textContent\": \"limit\",\n \"pageNo\": 2,\n \"sectionPath\": [\"第 1 章\"],\n }\n ],\n },\n {\n \"resourceType\": \"pptx\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pptx-bad-1\",\n \"segmentType\": \"ppt_slide_text\",\n \"orderNo\": 1,\n \"textContent\": \"matrix\",\n \"pageNo\": 2,\n }\n ],\n },\n {\n \"resourceType\": \"pptx\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pptx-bad-2\",\n \"segmentType\": \"ppt_slide_text\",\n \"orderNo\": 1,\n \"textContent\": \"matrix\",\n \"slideNo\": 3,\n \"sectionPath\": [\"第 1 章\"],\n }\n ],\n },\n {\n \"resourceType\": \"docx\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-docx-bad-1\",\n \"segmentType\": \"docx_block_text\",\n \"orderNo\": 1,\n \"textContent\": \"integral\",\n \"slideNo\": 6,\n }\n ],\n },\n {\n \"resourceType\": \"docx\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-docx-bad-2\",\n \"segmentType\": \"docx_block_text\",\n \"orderNo\": 1,\n \"textContent\": \"integral\",\n \"sectionPath\": [\"第 1 章\"],\n \"anchorKey\": \"section-integral\",\n }\n ],\n },\n {\n \"resourceType\": \"mp4\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-video-bad-1\",\n \"segmentType\": \"video_caption\",\n \"orderNo\": 1,\n \"textContent\": \"video\",\n \"pageNo\": 2,\n }\n ],\n },\n {\n \"resourceType\": \"mp4\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-video-bad-2\",\n \"segmentType\": \"video_caption\",\n \"orderNo\": 1,\n \"textContent\": \"video\",\n \"startSec\": 0,\n }\n ],\n },\n {\n \"resourceType\": \"mp4\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-video-bad-3\",\n \"segmentType\": \"video_caption\",\n \"orderNo\": 1,\n \"textContent\": \"video\",\n \"startSec\": 0,\n \"endSec\": 1,\n \"sectionPath\": [\"第 1 章\"],\n }\n ],\n },\n {\n \"resourceType\": \"srt\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-srt-bad-1\",\n \"segmentType\": \"video_caption\",\n \"orderNo\": 1,\n \"textContent\": \"subtitle\",\n \"endSec\": 45,\n }\n ],\n },\n {\n \"resourceType\": \"srt\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-srt-bad-2\",\n \"segmentType\": \"video_caption\",\n \"orderNo\": 1,\n \"textContent\": \"subtitle\",\n \"startSec\": 1,\n \"endSec\": 2,\n \"sectionPath\": [\"第 1 章\"],\n }\n ],\n },\n {\n \"resourceType\": \"pdf\",\n \"segments\": [{\"segmentType\": \"pdf_page_text\", \"orderNo\": 1, \"textContent\": \"missing key\", \"pageNo\": 2}],\n },\n {\n \"resourceType\": \"pdf\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pdf-old-type\",\n \"segmentType\": \"pdf_text\",\n \"orderNo\": 1,\n \"textContent\": \"old type\",\n \"pageNo\": 2,\n }\n ],\n },\n {\n \"resourceType\": \"pptx\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pptx-old-type\",\n \"segmentType\": \"slide_text\",\n \"orderNo\": 1,\n \"textContent\": \"old type\",\n \"slideNo\": 1,\n }\n ],\n },\n {\n \"resourceType\": \"docx\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-docx-old-type\",\n \"segmentType\": \"doc_paragraph\",\n \"orderNo\": 1,\n \"textContent\": \"old type\",\n \"sectionPath\": [\"第 1 章\"],\n }\n ],\n },\n {\n \"resourceType\": \"mp4\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-video-old-type\",\n \"segmentType\": \"video_transcript\",\n \"orderNo\": 1,\n \"textContent\": \"old type\",\n \"startSec\": 0,\n \"endSec\": 1,\n }\n ],\n },\n ]\n\r\n for payload in valid_payloads:\r\n validator.validate(payload)\r\n\r\n for payload in invalid_payloads:\n with pytest.raises(ValidationError):\n validator.validate(payload)\n\n\n@pytest.mark.parametrize(\"segment_type\", [\"docx_block_text\", \"ocr_text\", \"formula\", \"image_caption\"])\ndef test_normalized_document_schema_accepts_docx_visual_segments_with_section_path(segment_type: str):\n validator = build_validator(\"schemas/parse/normalized_document.schema.json\")\n\n validator.validate(\n {\n \"resourceType\": \"docx\",\n \"segments\": [\n {\n \"segmentKey\": f\"seg-docx-{segment_type.replace('_', '-')}\",\n \"segmentType\": segment_type,\n \"orderNo\": 1,\n \"textContent\": \"clean text\",\n \"sectionPath\": [\"第 1 章\"],\n }\n ],\n }\n )\n\n\n@pytest.mark.parametrize(\"bad_location\", [\"pageNo\", \"slideNo\", \"startSec\", \"endSec\"])\ndef test_normalized_document_schema_rejects_docx_non_section_locations(bad_location: str):\n validator = build_validator(\"schemas/parse/normalized_document.schema.json\")\n payload = {\n \"resourceType\": \"docx\",\n \"segments\": [\n {\n \"segmentKey\": f\"seg-docx-bad-{bad_location}\",\n \"segmentType\": \"image_caption\",\n \"orderNo\": 1,\n \"textContent\": \"clean text\",\n \"sectionPath\": [\"第 1 章\"],\n bad_location: 1,\n }\n ],\n }\n\n with pytest.raises(ValidationError):\n validator.validate(payload)\n\n\n@pytest.mark.parametrize(\"bad_text\", [\"bad\\ufffftext\", \"bad\\ufffdtext\", \"bad\\x00text\", \"bad\\x01text\", \"bad\\x19text\"])\ndef test_normalized_document_schema_rejects_garbled_text_content(bad_text: str):\n validator = build_validator(\"schemas/parse/normalized_document.schema.json\")\n payload = {\n \"resourceType\": \"pdf\",\n \"segments\": [\n {\n \"segmentKey\": \"seg-pdf-garbled\",\n \"segmentType\": \"pdf_page_text\",\n \"orderNo\": 1,\n \"textContent\": bad_text,\n \"pageNo\": 1,\n }\n ],\n }\n\n with pytest.raises(ValidationError):\n validator.validate(payload)\n\r\n\r\ndef test_demo_asset_baseline_covers_fixed_joint_test_set():\n baseline_doc = load_text(DEMO_ASSETS_BASELINE_DOC)\n\r\n for token in (\n \"knowlink-demo-main.mp4\",\n \"knowlink-demo-handout.pdf\",\n \"knowlink-demo-slides.pptx\",\n \"knowlink-demo-docx.docx\",\n ):\n assert token in baseline_doc\n\r\n assert \"sha256:<hex>\" in baseline_doc\r\n assert \"不在仓库中提交任何演示二进制文件\" in baseline_doc\r\n\r\n\r\ndef test_first_edition_manifest_matches_first_edition_doc():\n first_edition_doc = load_text(DEMO_ASSETS_FIRST_EDITION_DOC)\n manifest = load_json(\"server/seeds/demo_assets_manifest.json\")\r\n\r\n assert manifest[\"assetSetId\"] == \"first-edition-what-is-set\"\r\n assert manifest[\"manualImportCourseTitle\"] == \"KnowLink 固定联调课\"\r\n assert manifest[\"localBaseDir\"] == \"local_assets/first-edition/what-is-set\"\r\n assert \"local_assets/first-edition/what-is-set/\" in first_edition_doc\r\n\r\n expected_assets = {\r\n \"mp4\": (\"knowlink-demo-main.mp4\", \"集合的初见.mp4\", \"video/mp4\", 38985139),\r\n \"pdf\": (\"knowlink-demo-handout.pdf\", \"1_1_what_is_set.pdf\", \"application/pdf\", 135310),\r\n \"pptx\": (\r\n \"knowlink-demo-slides.pptx\",\r\n \"1_1_what_is_set.pptx\",\r\n \"application/vnd.openxmlformats-officedocument.presentationml.presentation\",\r\n 88576,\r\n ),\r\n \"docx\": (\n \"knowlink-demo-docx.docx\",\n \"集合论基础_讲义.docx\",\n \"application/vnd.openxmlformats-officedocument.wordprocessingml.document\",\n 62255,\n ),\n }\r\n\r\n assert len(manifest[\"assets\"]) == len(expected_assets)\r\n assert {asset[\"resourceType\"] for asset in manifest[\"assets\"]} == set(expected_assets)\r\n for asset in manifest[\"assets\"]:\r\n normalized_name, original_name, mime_type, size_bytes = expected_assets[asset[\"resourceType\"]]\r\n assert asset[\"normalizedName\"] == normalized_name\r\n assert asset[\"originalName\"] == original_name\r\n assert asset[\"relativePath\"] == normalized_name\r\n assert asset[\"mimeType\"] == mime_type\r\n assert asset[\"sizeBytes\"] == size_bytes\r\n assert asset[\"trackedInGit\"] is False\r\n assert asset[\"sourceKind\"] == \"original\"\r\n assert re.fullmatch(r\"sha256:[0-9a-f]{64}\", asset[\"checksum\"])\r\n expected_row = (\r\n f\"| `{asset['resourceType']}` | `{original_name}` | `{normalized_name}` | `{mime_type}` | \"\r\n f\"{size_bytes} | `{asset['checksum']}` |\"\r\n )\r\n assert expected_row in first_edition_doc\r\n"}}}}