import copy import json from pathlib import Path import pytest from pydantic import ValidationError from test_support.external_qa import require_external_qa from yuqei_sdk.contract_drafting import ( ContractDraftOutput, ContractDraftRequest, draft_digest, draft_output_schema, validate_draft_output, ) from yuqei_sdk.draft_change_evidence import ( CHANGE_EVIDENCE_POLICY, DraftChangeDeclarations, assess_draft_changes, build_change_evidence_context, ) BASE = "第一条:价款为100元。\n\n第二条:通知应书面送达。" def request(*, enabled=True, base=BASE): context = build_change_evidence_context(base) snapshot = {"id": "tpl-1", "clause_assembly": {"items": [{"clause_id": "price"}]}} if enabled: snapshot.update(change_evidence_policy=CHANGE_EVIDENCE_POLICY, change_evidence_context=context) return ContractDraftRequest(contract_title="测试", contract_type="service", base_document=base, facts=[{"name": "价款", "value": "100元"}], template_snapshot=snapshot, playbook_snapshot={"rules": [{"id": "rule-1"}]}, retrieved_sources=[{"id": "source-1", "title": "来源", "content": "来源正文"}]) def output(body, declarations=None): return ContractDraftOutput(schema_version="contract-draft.v1", status="candidate_ready", draft_document=body, missing_fields=[], assumptions=[], risk_notes=[], fact_bindings=[{"field": "价款", "value": "100元", "document_excerpt": "100元"}], source_bindings=[], change_explanations=["说明"], quality_findings=[], change_declarations=declarations) def declaration(**updates): payload = {"schema_version": "draft-change-declarations.v1", "items": [{ "id": "change-1", "target_id": "base-0002", "operation": "replace", "before_excerpt": "书面送达", "after_excerpt": "电子邮件送达", "basis": [{"type": "ai_suggestion"}], "summary": "调整通知方式", "pending_confirmation": True, }]} payload["items"][0].update(updates) return payload def test_context_covers_every_codepoint_and_is_deterministic(): context = build_change_evidence_context(BASE) assert "".join(item["text"] for item in context["targets"]) == BASE assert [(item["start"], item["end"]) for item in context["targets"]] == [(0, 14), (14, len(BASE))] assert context == build_change_evidence_context(BASE) assert context["range_unit"] == "unicode_codepoint_half_open" def test_verified_replace_produces_server_hashes_ranges_and_false_disclaimers(): candidate = BASE.replace("书面送达", "电子邮件送达") assessment = assess_draft_changes(request(), output(candidate, declaration())) assert assessment.status == "complete" assert assessment.items[0].status == "verified" assert assessment.items[0].before_range is not None and assessment.items[0].after_range is not None assert assessment.uncovered_diff_ranges == [] assert assessment.semantic_equivalence_verified is False assert assessment.legal_correctness_verified is False assert assessment.authorizes_adoption is False @pytest.mark.parametrize("candidate", [ BASE.replace("通知应书面送达", "通知应书面或电子邮件送达"), BASE.replace("通知应书面送达", "通知应送达"), ]) def test_whole_paragraph_replace_accepts_internal_pure_insert_or_delete(candidate): evidence = declaration( before_excerpt="第二条:通知应书面送达。", after_excerpt=candidate.split("\n\n", 1)[1], basis=[{"type": "template", "ref": "tpl-1"}], pending_confirmation=False, ) assessment = assess_draft_changes(request(), output(candidate, evidence)) assert assessment.status == "complete" assert assessment.items[0].status == "verified" assert assessment.uncovered_diff_ranges == [] def test_saved_real_whole_paragraph_replacements_bind_to_their_internal_edits(): require_external_qa( "multiscene-closeout-20260910/draft-timeout-diagnostic-real-v1/01-draft-before.json", "multiscene-closeout-20260910/draft-timeout-diagnostic-real-v1/01-draft-after.json", ) root = Path(__file__).resolve().parents[2] evidence_dir = root / "docs/qa/multiscene-closeout-20260910/draft-timeout-diagnostic-real-v1" before = json.loads((evidence_dir / "01-draft-before.json").read_text(encoding="utf-8")) after = json.loads((evidence_dir / "01-draft-after.json").read_text(encoding="utf-8")) req = ContractDraftRequest.model_validate(before["request"]) out = ContractDraftOutput.model_validate(after["job"]["result"]["output"]) assessment = assess_draft_changes(req, out) affected = {item.declaration_id: item for item in assessment.items if item.declaration_id in {"chg-0003", "chg-0005"}} assert set(affected) == {"chg-0003", "chg-0005"} assert all(item.status == "verified" for item in affected.values()) assert all("replace_ranges_not_same_hunk" not in item.errors for item in affected.values()) def test_whole_paragraph_replace_cannot_bind_an_edit_from_an_unrelated_target(): candidate = BASE.replace("价款为100元", "价款为200元") evidence = declaration( before_excerpt="第二条:通知应书面送达。", after_excerpt="第一条:价款为200元。", basis=[{"type": "template", "ref": "tpl-1"}], pending_confirmation=False, ) assessment = assess_draft_changes(request(), output(candidate, evidence)) assert assessment.status == "invalid" assert assessment.items[0].status == "invalid" @pytest.mark.parametrize("operation,fields", [ ("insert", {"before_excerpt": "旧", "after_excerpt": "新"}), ("delete", {"before_excerpt": "旧", "after_excerpt": "新"}), ("replace", {"before_excerpt": "相同", "after_excerpt": "相同"}), ]) def test_operation_shape_is_strict(operation, fields): with pytest.raises(ValidationError): DraftChangeDeclarations.model_validate(declaration(operation=operation, **fields)) def test_repeated_excerpt_requires_independent_occurrence_or_anchor(): base = "重复。重复。" req = request(base=base) ambiguous = output("改。重复。", declaration(target_id="base-0001", before_excerpt="重复", after_excerpt="改", pending_confirmation=True)) assessment = assess_draft_changes(req, ambiguous) assert assessment.status == "invalid" assert assessment.items[0].status == "ambiguous" resolved = ambiguous.model_copy(deep=True) resolved.change_declarations.items[0].before_occurrence = 1 resolved.change_declarations.items[0].after_occurrence = 1 assert assess_draft_changes(req, resolved).status == "complete" @pytest.mark.parametrize("basis", [ {"type": "fact", "ref": "missing"}, {"type": "template", "ref": "unknown"}, {"type": "playbook_rule", "ref": "unknown"}, {"type": "retrieved_source", "ref": "unknown"}, {"type": "ai_suggestion", "ref": "fake"}, ]) def test_forged_basis_is_invalid_and_validate_returns_hard_error(basis): candidate = BASE.replace("书面送达", "电子邮件送达") out = output(candidate, declaration(basis=[basis])) assessment = assess_draft_changes(request(), out) assert assessment.status == "invalid" assert "change_declaration_invalid" in assessment.errors assert "draft_change_evidence_invalid" in [item.code for item in validate_draft_output(request(), out)] def test_uncovered_diff_is_incomplete_warning_not_hard_error(): candidate = BASE.replace("书面送达", "电子邮件送达") + "\n新增句。" out = output(candidate, declaration()) assessment = assess_draft_changes(request(), out) assert assessment.status == "incomplete" and assessment.uncovered_diff_ranges findings = validate_draft_output(request(), out) warning = next(item for item in findings if item.code == "draft_change_evidence_incomplete") assert warning.severity == "warning" assert "不代表语义或法律正确性" in warning.message def test_unknown_target_overlap_and_unrelated_insert_fail_closed(): candidate = BASE + "新增甲。新增乙。" bad = declaration(target_id="base-9999", operation="insert", before_excerpt=None, after_excerpt="新增甲", pending_confirmation=True) assert assess_draft_changes(request(), output(candidate, bad)).status == "invalid" unrelated = declaration(target_id="base-0001", operation="insert", before_excerpt=None, after_excerpt="新增甲", pending_confirmation=True) assert assess_draft_changes(request(), output(candidate, unrelated)).status == "invalid" def test_context_tamper_and_missing_declarations_are_invalid(): req = request() req.template_snapshot["change_evidence_context"]["targets"][0]["text"] = "tampered" assessment = assess_draft_changes(req, output(BASE)) assert assessment.status == "invalid" assert "change_evidence_context_invalid" in assessment.errors assert "change_declarations_missing" in assessment.errors def test_legacy_output_json_and_provider_schema_omit_new_contract(): legacy = request(enabled=False) out = output(BASE) dumped = out.model_dump(mode="json") assert "change_declarations" not in dumped assert "change_declarations" not in json.loads(out.model_dump_json()) schema = draft_output_schema() assert "change_declarations" not in schema["properties"] assert not any(name.startswith("DraftChange") for name in schema["$defs"]) assert draft_output_schema(legacy) == schema def test_policy_provider_schema_requires_declarations_without_changing_legacy_schema(): legacy = copy.deepcopy(draft_output_schema()) policy = draft_output_schema(request()) assert "change_declarations" in policy["properties"] assert "change_declarations" in policy["required"] assert policy["properties"]["change_declarations"].get("$ref") assert draft_output_schema() == legacy assert draft_digest(draft_output_schema()) == draft_digest(legacy) for definition in policy["$defs"].values(): if "properties" in definition: assert set(definition["required"]) == set(definition["properties"]) @pytest.mark.parametrize("base,candidate,expected", [ ("原文", "新增原文", [("after", 0, 2)]), ("原文", "原文新增", [("after", 2, 4)]), ("新增原文", "原文", [("before", 0, 2)]), ("原文新增", "原文", [("before", 2, 4)]), ]) def test_head_and_tail_insert_delete_ranges(base, candidate, expected): from yuqei_sdk.draft_change_evidence import _diff_ranges assert _diff_ranges(base, candidate) == expected def test_malformed_context_is_invalid_but_never_traversed(): req = request() req.template_snapshot["change_evidence_context"] = {"targets": [None, "secret"]} candidate = BASE.replace("书面送达", "电子邮件送达") assessment = assess_draft_changes(req, output(candidate, declaration())) assert assessment.status == "invalid" assert "change_evidence_context_invalid" in assessment.errors assert assessment.items[0].status == "verified" def test_partial_excerpt_does_not_cover_the_rest_of_one_diff_hunk(): candidate = BASE.replace("书面送达", "电子邮件及短信送达") evidence = declaration(after_excerpt="电子邮件") assessment = assess_draft_changes(request(), output(candidate, evidence)) assert assessment.status == "incomplete" assert any(item.side == "after" and item.end > item.start for item in assessment.uncovered_diff_ranges) def test_cross_paragraph_and_oversized_excerpts_are_rejected(): with pytest.raises(ValidationError): DraftChangeDeclarations.model_validate(declaration(before_excerpt="甲\n\n乙")) with pytest.raises(ValidationError): DraftChangeDeclarations.model_validate(declaration(before_excerpt="甲" * 2001)) @pytest.mark.parametrize("snapshot_patch,basis", [ ({"clause_assembly": "not-an-object"}, {"type": "template", "ref": "None"}), ({"id": "", "clause_assembly": []}, {"type": "template", "ref": "tpl-1"}), ]) def test_arbitrary_template_snapshot_shapes_and_empty_ids_fail_closed(snapshot_patch, basis): req = request() req.template_snapshot.update(snapshot_patch) candidate = BASE.replace("书面送达", "电子邮件送达") assessment = assess_draft_changes(req, output(candidate, declaration(basis=[basis]))) assert assessment.status == "invalid" assert "basis_invalid:template" in assessment.items[0].errors def test_missing_and_disabled_rule_ids_are_not_valid_bases(): req = request() req.playbook_snapshot = {"rules": [{"id": None}, {"id": "off", "enabled": False}]} candidate = BASE.replace("书面送达", "电子邮件送达") for ref in ("None", "off"): assessment = assess_draft_changes(req, output(candidate, declaration(basis=[{"type": "playbook_rule", "ref": ref}]))) assert assessment.status == "invalid" assert "basis_invalid:playbook_rule" in assessment.items[0].errors def test_ai_suggestion_cannot_be_silently_confirmed_and_pending_is_correlated(): candidate = BASE.replace("书面送达", "电子邮件送达") silent = assess_draft_changes(request(), output(candidate, declaration(pending_confirmation=False))) assert "ai_suggestion_requires_confirmation" in silent.items[0].errors unrelated = assess_draft_changes(request(), output(candidate, declaration(basis=[{"type": "template", "ref": "tpl-1"}], pending_confirmation=True))) assert "pending_confirmation_unjustified" in unrelated.items[0].errors def test_large_distant_edits_are_refined_into_their_own_targets(): base = "开头旧甲" + ("中" * 5000) + "结尾旧乙" candidate = "开头新甲" + ("中" * 5000) + "结尾新乙" req = request(base=base) changes = DraftChangeDeclarations.model_validate({ "schema_version": "draft-change-declarations.v1", "items": [ {"id": "change-a", "target_id": "base-0001", "operation": "replace", "before_excerpt": "旧甲", "after_excerpt": "新甲", "basis": [{"type": "ai_suggestion"}], "summary": "调整甲", "pending_confirmation": True}, {"id": "change-b", "target_id": "base-0003", "operation": "replace", "before_excerpt": "旧乙", "after_excerpt": "新乙", "basis": [{"type": "ai_suggestion"}], "summary": "调整乙", "pending_confirmation": True}, ], }) assessment = assess_draft_changes(req, output(candidate, changes)) assert assessment.status == "complete" assert [item.status for item in assessment.items] == ["verified", "verified"] def test_user_instruction_basis_requires_an_actual_request_instruction(): candidate = BASE.replace("书面送达", "电子邮件送达") evidence = declaration(basis=[{"type": "user_instruction"}]) absent = assess_draft_changes(request(), output(candidate, evidence)) assert "basis_invalid:user_instruction" in absent.items[0].errors req = request() req.drafting_instructions = "请调整通知方式。" assert assess_draft_changes(req, output(candidate, evidence)).status == "complete" def test_maximum_fragmented_context_ids_remain_valid_declaration_ids(): base = "x\n\n" * 33_333 context = build_change_evidence_context(base) assert len(base) == 99_999 assert len(context["targets"]) == 33_333 assert context["targets"][-1]["target_id"] == "base-33333" DraftChangeDeclarations.model_validate(declaration( target_id=context["targets"][-1]["target_id"])) def test_many_repeated_short_lines_use_bounded_token_matching(): from yuqei_sdk.draft_change_evidence import _diff_hunks base = "相同\n" * 25_000 candidate = base[:50_000] + "改" + base[50_001:] hunks = _diff_hunks(base, candidate) assert hunks assert all(0 <= left[0] <= left[1] <= len(base) for left, _ in hunks) def test_policy_disabled_returns_none_and_does_not_validate_declarations(): legacy = request(enabled=False) assert assess_draft_changes(legacy, output(BASE)) is None assert not any(item.code.startswith("draft_change_evidence") for item in validate_draft_output(legacy, output(BASE)))