{"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:\" 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"}}}}