diff --git a/.claude/skills/db/scripts/test_authorization_snapshot_ddl.py b/.claude/skills/db/scripts/test_authorization_snapshot_ddl.py index f19d087..ca8bca2 100644 --- a/.claude/skills/db/scripts/test_authorization_snapshot_ddl.py +++ b/.claude/skills/db/scripts/test_authorization_snapshot_ddl.py @@ -20,6 +20,11 @@ class AuthorizationSnapshotDdlTest(unittest.TestCase): self.assertIn("source_version", ddl) self.assertRegex(ddl, r"research_only.*public_domain.*licensed.*unauthorized") self.assertRegex(ddl, r"jsonb_typeof\(allowed_purpose\)\s*=\s*'array'") + self.assertRegex(ddl, r"jsonb_typeof\(forbidden_purpose\)\s*=\s*'array'") + self.assertRegex( + ddl, + r"CHECK\s*\(example_jsonb_text_arrays_disjoint\(allowed_purpose,\s*forbidden_purpose\)\)", + ) self.assertIn("authorization_basis <> 'user_authorization' OR copyright_status = 'research_only'", ddl) self.assertIn("allowed_purpose = '[\"offline_evaluation\"]'::jsonb", ddl) self.assertIsNotNone(re.search(r"RAISE\s+EXCEPTION", ddl, re.IGNORECASE)) diff --git a/.claude/skills/replay-eval/scripts/check_snapshot.py b/.claude/skills/replay-eval/scripts/check_snapshot.py index aa58e95..8f71853 100644 --- a/.claude/skills/replay-eval/scripts/check_snapshot.py +++ b/.claude/skills/replay-eval/scripts/check_snapshot.py @@ -213,6 +213,18 @@ def check_authorization(authorization: Mapping[str, Any] | None) -> dict[str, An return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照用途不包含 offline_evaluation"]) if set(snapshot_allowed) != set(allowed): return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照用途与运行用途不一致"]) + forbidden = _field(authorization, "forbiddenPurpose", "forbidden_purpose") + if not isinstance(forbidden, list): + return _result(STATUS_BLOCKED_AUTHORIZATION, ["forbiddenPurpose 不是用途列表"]) + snapshot_forbidden = snapshot.get("forbiddenPurpose", snapshot.get("forbidden_purpose")) + if not isinstance(snapshot_forbidden, list): + return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照 forbiddenPurpose 不是用途列表"]) + if set(snapshot_forbidden) != set(forbidden): + return _result(STATUS_BLOCKED_AUTHORIZATION, ["授权快照禁止用途与运行禁止用途不一致"]) + if "offline_evaluation" in forbidden: + return _result(STATUS_BLOCKED_AUTHORIZATION, ["forbiddenPurpose 禁止 offline_evaluation"]) + if set(allowed) & set(forbidden): + return _result(STATUS_BLOCKED_AUTHORIZATION, ["allowedPurpose 与 forbiddenPurpose 存在冲突"]) authorization_basis = str(snapshot.get("authorizationBasis") or "") if authorization_basis == "user_authorization": if copyright_status != "research_only": diff --git a/.claude/skills/replay-eval/scripts/load_reference_work.py b/.claude/skills/replay-eval/scripts/load_reference_work.py index 1db1bac..d164f5c 100644 --- a/.claude/skills/replay-eval/scripts/load_reference_work.py +++ b/.claude/skills/replay-eval/scripts/load_reference_work.py @@ -134,6 +134,7 @@ def project_authorization( "sourceHash": source_hash, "sourceVersion": source_version, "allowedPurpose": [], + "forbiddenPurpose": [], "authorizationSnapshot": {}, } if not isinstance(row, Mapping): @@ -172,6 +173,7 @@ def project_authorization( "sourceHash": source_hash, "sourceVersion": source_version, "allowedPurpose": copy.deepcopy(allowed), + "forbiddenPurpose": copy.deepcopy(forbidden), "authorizationSnapshot": snapshot, } @@ -583,9 +585,10 @@ def load_reference_rows( SELECT id,status,command_id,source_snapshot,deleted FROM muse_content_import_task WHERE tenant_id=%s AND work_id=%s AND status='succeeded' AND deleted=FALSE + AND source_snapshot->>'file' = %s ORDER BY id """, - (tenant_id, work_id), + (tenant_id, work_id, reference.get("source_file")), ).fetchall() document_rows = conn.execute( """ diff --git a/.claude/skills/replay-eval/scripts/test_check_snapshot.py b/.claude/skills/replay-eval/scripts/test_check_snapshot.py index 277ff64..84daf56 100644 --- a/.claude/skills/replay-eval/scripts/test_check_snapshot.py +++ b/.claude/skills/replay-eval/scripts/test_check_snapshot.py @@ -26,6 +26,7 @@ AUTH = { "sourceHash": "sha256:02cf1f8c1ca03c26e0b839d88fe536e83c0af20fd8972235b7aedca6a33becf4", "sourceVersion": "raw-file-v1:sha256:02cf1f8c1ca03c26e0b839d88fe536e83c0af20fd8972235b7aedca6a33becf4", "allowedPurpose": ["offline_evaluation"], + "forbiddenPurpose": ["external_distribution"], "authorizationSnapshot": { "id": "auth-1", "version": "v1", @@ -36,6 +37,7 @@ AUTH = { "copyrightStatus": "research_only", "authorizationBasis": "user_authorization", "allowedPurpose": ["offline_evaluation"], + "forbiddenPurpose": ["external_distribution"], "checkedAt": "2026-07-19T00:00:00Z", "revalidationAt": "2099-07-20T00:00:00Z", }, @@ -88,6 +90,54 @@ class CheckSnapshotTest(unittest.TestCase): bad_version = {**AUTH, "sourceVersion": "raw-file-v1:sha256:" + "0" * 64} self.assertEqual(check_authorization(bad_version)["status"], STATUS_BLOCKED_AUTHORIZATION) + def test_forbidden_purpose_must_be_arrays_and_match_snapshot(self): + outer_not_array = {**AUTH, "forbiddenPurpose": "external_distribution"} + self.assertEqual(check_authorization(outer_not_array)["status"], STATUS_BLOCKED_AUTHORIZATION) + + snapshot_not_array = { + **AUTH, + "authorizationSnapshot": { + **AUTH["authorizationSnapshot"], + "forbiddenPurpose": "external_distribution", + }, + } + self.assertEqual(check_authorization(snapshot_not_array)["status"], STATUS_BLOCKED_AUTHORIZATION) + + mismatch = { + **AUTH, + "authorizationSnapshot": { + **AUTH["authorizationSnapshot"], + "forbiddenPurpose": ["training"], + }, + } + self.assertEqual(check_authorization(mismatch)["status"], STATUS_BLOCKED_AUTHORIZATION) + + def test_forbidden_purpose_blocks_overlap_and_offline_evaluation(self): + overlapping = { + **AUTH, + "forbiddenPurpose": ["offline_evaluation"], + "authorizationSnapshot": { + **AUTH["authorizationSnapshot"], + "forbiddenPurpose": ["offline_evaluation"], + }, + } + self.assertEqual(check_authorization(overlapping)["status"], STATUS_BLOCKED_AUTHORIZATION) + + public_domain_overlap = { + **AUTH, + "copyrightStatus": "public_domain", + "allowedPurpose": ["offline_evaluation", "research"], + "forbiddenPurpose": ["research"], + "authorizationSnapshot": { + **AUTH["authorizationSnapshot"], + "copyrightStatus": "public_domain", + "authorizationBasis": "public_domain_record", + "allowedPurpose": ["offline_evaluation", "research"], + "forbiddenPurpose": ["research"], + }, + } + self.assertEqual(check_authorization(public_domain_overlap)["status"], STATUS_BLOCKED_AUTHORIZATION) + def test_expired_or_due_revalidation_is_blocked(self): expired = { **AUTH, diff --git a/.claude/skills/replay-eval/scripts/test_load_reference_work.py b/.claude/skills/replay-eval/scripts/test_load_reference_work.py index eef16a3..f45d419 100644 --- a/.claude/skills/replay-eval/scripts/test_load_reference_work.py +++ b/.claude/skills/replay-eval/scripts/test_load_reference_work.py @@ -8,6 +8,7 @@ import inspect import pathlib import sys import unittest +from unittest.mock import patch sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) import load_reference_work as loader # noqa: E402 @@ -94,6 +95,66 @@ def card_row(card_id=11126, name="苏铭"): } +class FakeQueryResult: + """提供 psycopg 查询结果所需的最小 fetch 接口。""" + + def __init__(self, rows): + self.rows = rows if isinstance(rows, list) else [rows] + + def fetchone(self): + return self.rows[0] if self.rows else None + + def fetchall(self): + return self.rows + + +class FakeReadOnlyConnection: + """模拟只读查询,并按 SQL 中的原文件条件过滤导入任务。""" + + def __init__(self, import_tasks): + self.import_tasks = import_tasks + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_value, traceback): + return False + + def execute(self, query, params=None): + sql = " ".join(query.split()) + if sql.startswith("SET TRANSACTION"): + return FakeQueryResult([]) + if "FROM muse_content_work" in sql: + return FakeQueryResult(WORK) + if "FROM example_reference_work" in sql: + return FakeQueryResult([REFERENCE]) + if "FROM muse_content_import_task" in sql: + rows = self.import_tasks + if "source_snapshot->>'file' = %s" in sql: + source_file = params[-1] + rows = [ + row + for row in rows + if row.get("source_snapshot", {}).get("file") == source_file + ] + return FakeQueryResult(rows) + if "FROM muse_knowledge_document" in sql: + return FakeQueryResult([DOCUMENT]) + if "FROM example_reference_authorization_snapshot" in sql: + return FakeQueryResult(AUTHORIZATION_ROW) + if "FROM example_parse_outline" in sql: + return FakeQueryResult([]) + if "FROM example_parse_scaffold" in sql and "ch.order_no<=%s" in sql: + return FakeQueryResult([]) + if "FROM example_parse_scaffold" in sql and "ch.order_no=%s" in sql: + return FakeQueryResult( + {"id": 11, "chapter": 489, "title": "目标", "outline_text": "目标事实"} + ) + if "FROM muse_knowledge_draft" in sql: + return FakeQueryResult([card_row(), card_row(11127, "赵宁")]) + raise AssertionError(f"未处理 SQL: {sql}") + + class LoadReferenceWorkTest(unittest.TestCase): def setUp(self): self.source = loader.validate_source_records([REFERENCE], [IMPORT_TASK], [DOCUMENT]) @@ -197,6 +258,38 @@ class LoadReferenceWorkTest(unittest.TestCase): with self.assertRaises(AdapterError): loader.validate_source_records(references, tasks, documents) + def test_import_task_query_ignores_successful_task_for_other_source_file(self): + unrelated = { + **IMPORT_TASK, + "id": 20, + "source_snapshot": {"file": "无关作品.txt"}, + } + connection = FakeReadOnlyConnection([IMPORT_TASK, unrelated]) + with patch.object(loader.psycopg, "connect", return_value=connection): + rows = loader.load_reference_rows( + dsn="postgresql://unused", + tenant_id=1, + work_id=8, + as_of=488, + target=489, + card_selection={"correctCardIds": [11126], "placeboCardIds": [11127]}, + ) + self.assertEqual(rows["source"]["importTaskId"], "19") + + def test_import_task_query_blocks_two_tasks_for_same_source_file(self): + duplicate = {**IMPORT_TASK, "id": 20} + connection = FakeReadOnlyConnection([IMPORT_TASK, duplicate]) + with patch.object(loader.psycopg, "connect", return_value=connection): + with self.assertRaises(AdapterError): + loader.load_reference_rows( + dsn="postgresql://unused", + tenant_id=1, + work_id=8, + as_of=488, + target=489, + card_selection={"correctCardIds": [11126], "placeboCardIds": [11127]}, + ) + def test_invalid_hash_fails_closed(self): with self.assertRaises(AdapterError): loader.validate_source_records([REFERENCE], [IMPORT_TASK], [{**DOCUMENT, "file_hash": "xyz"}]) diff --git a/.claude/skills/replay-eval/scripts/test_run_replay.py b/.claude/skills/replay-eval/scripts/test_run_replay.py index 8bd8b1a..df8f2a9 100644 --- a/.claude/skills/replay-eval/scripts/test_run_replay.py +++ b/.claude/skills/replay-eval/scripts/test_run_replay.py @@ -24,6 +24,7 @@ AUTH = { "sourceHash": SOURCE_HASH, "sourceVersion": SOURCE_VERSION, "allowedPurpose": ["offline_evaluation"], + "forbiddenPurpose": ["external_distribution"], "authorizationSnapshot": { "id": "auth-1", "version": "v1", @@ -34,6 +35,7 @@ AUTH = { "copyrightStatus": "research_only", "authorizationBasis": "user_authorization", "allowedPurpose": ["offline_evaluation"], + "forbiddenPurpose": ["external_distribution"], "checkedAt": "2026-07-19T00:00:00Z", "revalidationAt": "2099-07-20T00:00:00Z", }, diff --git a/db/ddl/96-example参考作品授权快照.sql b/db/ddl/96-example参考作品授权快照.sql index 577142e..0c3b2e1 100644 --- a/db/ddl/96-example参考作品授权快照.sql +++ b/db/ddl/96-example参考作品授权快照.sql @@ -1,5 +1,18 @@ -- example_reference_authorization_snapshot:参考作品用途授权的不可变快照。 -- 本表只允许 INSERT;授权变化通过新增版本表达,禁止覆盖或删除历史证据。 +CREATE FUNCTION example_jsonb_text_arrays_disjoint(left_values JSONB, right_values JSONB) +RETURNS BOOLEAN +LANGUAGE sql +IMMUTABLE +STRICT +AS $$ + SELECT NOT EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text(left_values) AS left_value(value) + JOIN jsonb_array_elements_text(right_values) AS right_value(value) USING (value) + ); +$$; + CREATE TABLE example_reference_authorization_snapshot ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, reference_work_id BIGINT NOT NULL, -- → example_reference_work.id(软引用) @@ -37,6 +50,8 @@ CREATE TABLE example_reference_authorization_snapshot ( CHECK (jsonb_typeof(allowed_purpose) = 'array'), CONSTRAINT chk_example_reference_auth_forbidden_purpose CHECK (jsonb_typeof(forbidden_purpose) = 'array'), + CONSTRAINT chk_example_reference_auth_purpose_disjoint + CHECK (example_jsonb_text_arrays_disjoint(allowed_purpose, forbidden_purpose)), CONSTRAINT chk_example_reference_auth_basis CHECK (authorization_basis IN ('user_authorization','public_domain_record','license_contract')), CONSTRAINT chk_example_reference_auth_basis_copyright