import copy import importlib.util from pathlib import Path import pytest from pydantic import ValidationError _SPEC = importlib.util.spec_from_file_location("semantic_review_test_helpers", Path(__file__).with_name("test_draft_semantic_review.py")) assert _SPEC and _SPEC.loader _HELPERS = importlib.util.module_from_spec(_SPEC) _SPEC.loader.exec_module(_HELPERS) output, request = _HELPERS.output, _HELPERS.request from yuqei_sdk.contract_drafting import ContractDraftRequest from yuqei_sdk.draft_repair import ( DraftRepairBudget, DraftRepairReviewBinding, _sha, build_draft_repair_context, ) from yuqei_sdk.draft_semantic_review import ( DraftSemanticReviewRequest, assess_draft_semantic_review, draft_semantic_review_output_schema, ) def repair_context(): req = request() metadata = {"id": "meta-risk", "field": "risk_notes", "index": 0, "exact_excerpt": req.candidate_advisories.risk_notes[0], "candidate_excerpt": "实际损失", "candidate_occurrence": None, "disposition": "inaccurate", "explanation": "概括不完整。"} instruction = {"finding_ids": [], "metadata_finding_ids": ["meta-risk"], "action": "修正风险说明", "constraints": ["不得改变正文"]} out = output(req, status="repairable", metadata_findings=[metadata], repair_instructions=[instruction]) out.coverage.advisory_checks[0].metadata_finding_ids = ["meta-risk"] assessment = assess_draft_semantic_review(req, out) return build_draft_repair_context(parent_candidate_id=req.candidate_id, parent_input_revision=req.input_revision, parent_draft_request_sha256=req.draft_request_sha256, semantic_review_id="review-1", semantic_job_id="job-1", semantic_audit_run_id="audit-1", semantic_receipt_sha256="9" * 64, semantic_request=req, semantic_output=out, semantic_assessment=assessment) def test_legacy_request_omits_repair_context_and_hash_shape(): req = ContractDraftRequest(contract_title="测试", contract_type="服务", base_document="正文") assert "repair_context" not in req.model_dump(mode="json") assert "repair_context" not in req.model_dump_json() def test_repair_context_recomputes_assessment_and_binds_parent_body(): context = repair_context() assert context.parent.candidate_body_sha256 == _sha(context.semantic_request["candidate_document"]) tampered = context.model_dump(mode="json") tampered["semantic_assessment"]["errors"] = ["provider_claim"] with pytest.raises(ValidationError): type(context).model_validate(tampered) for field, value in (("candidate_id", "other"), ("input_revision", 99), ("draft_request_sha256", "0" * 64)): tampered = context.model_dump(mode="json") tampered["parent"][field] = value with pytest.raises(ValidationError): type(context).model_validate(tampered) @pytest.mark.parametrize("value", [True, 1.0, "1"]) def test_repair_budget_rejects_non_integer_literal_one(value): with pytest.raises(ValidationError): DraftRepairBudget(draft_attempts=value, followup_semantic_review_attempts=1) with pytest.raises(ValidationError): DraftRepairReviewBinding(repair_context_sha256="1" * 64, parent_candidate_id="candidate", max_attempts=value) def test_post_repair_review_requires_full_context_and_matching_binding(): context = repair_context() parent = DraftSemanticReviewRequest.model_validate(context.semantic_request) payload = parent.model_dump(mode="json") payload["base_document"] = parent.candidate_document payload["candidate_document"] = parent.candidate_document payload["candidate_body_sha256"] = _sha(parent.candidate_document) payload["repair_context"] = context.model_dump(mode="json") binding = DraftRepairReviewBinding(repair_context_sha256=_sha(context.model_dump(mode="json")), parent_candidate_id=context.parent.candidate_id) payload["repair_binding"] = binding.model_dump(mode="json") assert DraftSemanticReviewRequest.model_validate(payload).repair_context is not None post_request = DraftSemanticReviewRequest.model_validate(payload) schema = draft_semantic_review_output_schema(post_request) assert schema["properties"]["repair_resolution_checks"]["type"] == "array" assert "repair_resolution_checks" not in draft_semantic_review_output_schema()["properties"] for field in ("repair_context", "repair_binding"): broken = copy.deepcopy(payload) broken.pop(field) with pytest.raises(ValidationError): DraftSemanticReviewRequest.model_validate(broken) def test_post_repair_resolution_checks_are_exact_and_gate_passed(): context = repair_context() parent = DraftSemanticReviewRequest.model_validate(context.semantic_request) payload = parent.model_dump(mode="json") payload.update(base_document=parent.candidate_document, repair_context=context.model_dump(mode="json"), repair_binding=DraftRepairReviewBinding( repair_context_sha256=_sha(context.model_dump(mode="json")), parent_candidate_id=context.parent.candidate_id).model_dump(mode="json")) post = DraftSemanticReviewRequest.model_validate(payload) check = {"finding_id": "meta-risk", "status": "resolved", "candidate_excerpt": "实际损失", "candidate_occurrence": None, "metadata_field": "risk_notes", "metadata_index": 0, "metadata_excerpt": post.candidate_advisories.risk_notes[0], "explanation": "已重新核对说明。"} result = output(post, repair_resolution_checks=[check]) assert assess_draft_semantic_review(post, result).status == "valid" missing = output(post, repair_resolution_checks=[]) assert "repair_resolution_coverage_invalid" in assess_draft_semantic_review(post, missing).errors unresolved = output(post, repair_resolution_checks=[{**check, "status": "unresolved"}]) assert "passed_with_unresolved_repair" in assess_draft_semantic_review(post, unresolved).errors broken = copy.deepcopy(payload) broken["repair_binding"]["repair_context_sha256"] = "0" * 64 with pytest.raises(ValidationError): DraftSemanticReviewRequest.model_validate(broken) for mutate in ( lambda value: value["facts"].__setitem__(0, {**value["facts"][0], "value": "十一日"}), lambda value: value["template"].__setitem__("id", "other-template"), lambda value: value.__setitem__("playbook_sha256", "0" * 64), ): broken = copy.deepcopy(payload) mutate(broken) with pytest.raises(ValidationError): DraftSemanticReviewRequest.model_validate(broken)