diff --git a/src/wikibricks_remote/maintenance.py b/src/wikibricks_remote/maintenance.py index 796686d..3b4b278 100644 --- a/src/wikibricks_remote/maintenance.py +++ b/src/wikibricks_remote/maintenance.py @@ -3,7 +3,6 @@ from __future__ import annotations import hashlib -import json from collections.abc import Callable from typing import Any from uuid import UUID, uuid5 @@ -11,8 +10,9 @@ from psycopg import Connection from psycopg.types.json import Jsonb -from wikibricks.curation import build_manifest, create_patch, publish_manifest +from wikibricks.curation import build_manifest, publish_manifest from wikibricks.postgres_store import PostgresStore +from wikibricks_remote.proposals import _canonical_json, build_patches from wikibricks_remote.resources import ( RemotePolicy, load_prompt, @@ -26,26 +26,6 @@ CandidateSelection, ] _UNKNOWN_REPLICA = UUID(int=0) -_PROPOSAL_REQUIRED_FIELDS = { - "group", - "operation", - "path", - "title", - "page_type", - "summary", - "body", - "tags", - "source_ids", - "target_path", - "evidence_ids", - "reason", - "risk_class", -} -_PROPOSAL_FIELDS = _PROPOSAL_REQUIRED_FIELDS | {"link_type"} - - -def _canonical_json(value: Any) -> str: - return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) def _last_watermark(conn: Connection, replica_id: UUID) -> int: @@ -156,127 +136,6 @@ def _current_pages( return pages -def _page_proposal(raw: dict[str, Any]) -> dict[str, Any]: - summary = str(raw["summary"]).strip() - body = str(raw["body"]).strip() - return { - "title": str(raw["title"]).strip(), - "page_type": str(raw["page_type"]), - "content": {"summary": summary, "body": body}, - "content_text": f"{summary}\n\n{body}".strip(), - "tags": list(raw["tags"]), - "source_ids": list(raw["source_ids"]), - "parent_id": None, - "chunk_index": None, - } - - -def build_patches( - raw_result: dict[str, Any], - *, - run_id: UUID, - pages: list[dict[str, Any]], - evidence_ids: set[str], - policy: RemotePolicy, -) -> list[dict[str, Any]]: - if set(raw_result) != {"proposals"} or not isinstance(raw_result["proposals"], list): - raise ValueError("curator output must contain only a proposals array") - proposals = raw_result["proposals"] - if len(proposals) > policy.max_proposals_per_replica: - raise ValueError("curator output exceeds the proposal limit") - current = {page["path"]: page for page in pages} - known_evidence = evidence_ids | {page["evidence_id"] for page in pages} - group_positions: dict[str, int] = {} - patches = [] - for raw in proposals: - if ( - not isinstance(raw, dict) - or not _PROPOSAL_REQUIRED_FIELDS <= set(raw) - or not set(raw) <= _PROPOSAL_FIELDS - ): - raise ValueError("curation proposal fields do not match the schema") - if not all( - isinstance(raw[field], str) and raw[field].strip() - for field in ("group", "operation", "path", "reason", "risk_class") - ): - raise ValueError("curation proposal identifiers and reason must be non-empty") - if not all( - isinstance(raw[field], list) - and all(isinstance(value, str) and value for value in raw[field]) - for field in ("tags", "source_ids", "evidence_ids") - ): - raise ValueError("curation proposal lists must contain strings") - operation = str(raw["operation"]) - path = str(raw["path"]) - if operation not in policy.allowed_operations: - raise ValueError(f"operation is disabled by remote policy: {operation}") - cited = list(raw["evidence_ids"]) - if not cited or not set(cited) <= known_evidence: - raise ValueError("curation proposal cites unknown evidence") - existing = current.get(path) - link_type = raw.get("link_type") - if operation == "add_link": - if not isinstance(link_type, str) or link_type not in policy.allowed_link_types: - raise ValueError(f"unsupported remote link type: {link_type}") - elif link_type is not None: - raise ValueError(f"link type is only valid for add_link: {operation}") - if operation in {"create_page", "update_page"}: - if not all( - isinstance(raw[field], str) and raw[field].strip() - for field in ("title", "page_type", "summary", "body") - ): - raise ValueError("page proposals require title, type, summary, and body") - if raw["page_type"] not in {"entity", "concept", "synthesis", "comparison"}: - raise ValueError(f"unsupported page type: {raw['page_type']}") - if operation == "create_page": - if existing or raw["target_path"] is not None: - raise ValueError(f"create_page path already exists: {path}") - proposal = _page_proposal(raw) - base_version_id = None - base_content_hash = None - elif operation == "update_page": - if not existing or raw["target_path"] is not None: - raise ValueError(f"update_page path does not exist: {path}") - proposal = _page_proposal(raw) - base_version_id = existing["base_version_id"] - base_content_hash = existing["base_content_hash"] - elif operation == "add_link": - target_path = raw["target_path"] - if not existing or target_path not in current: - raise ValueError(f"link paths do not exist: {path} -> {target_path}") - proposal = {"target_path": target_path, "link_type": link_type} - base_version_id = existing["base_version_id"] - base_content_hash = existing["base_content_hash"] - else: - target_path = raw["target_path"] - if not existing or target_path not in current: - raise ValueError(f"cleanup paths do not exist: {path} -> {target_path}") - proposal = {"target_path": target_path} - base_version_id = existing["base_version_id"] - base_content_hash = existing["base_content_hash"] - group = str(raw["group"]) - position = group_positions.get(group, 0) - group_positions[group] = position + 1 - group_id = uuid5(run_id, f"group:{group}") - patch_id = uuid5(group_id, f"patch:{position}:{_canonical_json(raw)}") - patches.append( - create_patch( - operation=operation, - path=path, - proposal=proposal, - evidence_ids=cited, - reason=str(raw["reason"]), - base_version_id=base_version_id, - base_content_hash=base_content_hash, - patch_id=patch_id, - group_id=group_id, - position=position, - risk_class=str(raw["risk_class"]), - ) - ) - return patches - - def _record_run( store: PostgresStore, *, diff --git a/src/wikibricks_remote/proposals.py b/src/wikibricks_remote/proposals.py new file mode 100644 index 0000000..f8c0c18 --- /dev/null +++ b/src/wikibricks_remote/proposals.py @@ -0,0 +1,152 @@ +"""Model proposal validation and deterministic curation-patch planning.""" + +from __future__ import annotations + +import json +from typing import Any +from uuid import UUID, uuid5 + +from wikibricks.curation import create_patch +from wikibricks_remote.resources import RemotePolicy + +_PROPOSAL_REQUIRED_FIELDS = { + "group", + "operation", + "path", + "title", + "page_type", + "summary", + "body", + "tags", + "source_ids", + "target_path", + "evidence_ids", + "reason", + "risk_class", +} +_PROPOSAL_FIELDS = _PROPOSAL_REQUIRED_FIELDS | {"link_type"} + + +def _canonical_json(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":"), sort_keys=True) + + +def _page_proposal(raw: dict[str, Any]) -> dict[str, Any]: + summary = str(raw["summary"]).strip() + body = str(raw["body"]).strip() + return { + "title": str(raw["title"]).strip(), + "page_type": str(raw["page_type"]), + "content": {"summary": summary, "body": body}, + "content_text": f"{summary}\n\n{body}".strip(), + "tags": list(raw["tags"]), + "source_ids": list(raw["source_ids"]), + "parent_id": None, + "chunk_index": None, + } + + +def build_patches( + raw_result: dict[str, Any], + *, + run_id: UUID, + pages: list[dict[str, Any]], + evidence_ids: set[str], + policy: RemotePolicy, +) -> list[dict[str, Any]]: + if set(raw_result) != {"proposals"} or not isinstance(raw_result["proposals"], list): + raise ValueError("curator output must contain only a proposals array") + proposals = raw_result["proposals"] + if len(proposals) > policy.max_proposals_per_replica: + raise ValueError("curator output exceeds the proposal limit") + current = {page["path"]: page for page in pages} + known_evidence = evidence_ids | {page["evidence_id"] for page in pages} + group_positions: dict[str, int] = {} + patches = [] + for raw in proposals: + if ( + not isinstance(raw, dict) + or not _PROPOSAL_REQUIRED_FIELDS <= set(raw) + or not set(raw) <= _PROPOSAL_FIELDS + ): + raise ValueError("curation proposal fields do not match the schema") + if not all( + isinstance(raw[field], str) and raw[field].strip() + for field in ("group", "operation", "path", "reason", "risk_class") + ): + raise ValueError("curation proposal identifiers and reason must be non-empty") + if not all( + isinstance(raw[field], list) + and all(isinstance(value, str) and value for value in raw[field]) + for field in ("tags", "source_ids", "evidence_ids") + ): + raise ValueError("curation proposal lists must contain strings") + operation = str(raw["operation"]) + path = str(raw["path"]) + if operation not in policy.allowed_operations: + raise ValueError(f"operation is disabled by remote policy: {operation}") + cited = list(raw["evidence_ids"]) + if not cited or not set(cited) <= known_evidence: + raise ValueError("curation proposal cites unknown evidence") + existing = current.get(path) + link_type = raw.get("link_type") + if operation == "add_link": + if not isinstance(link_type, str) or link_type not in policy.allowed_link_types: + raise ValueError(f"unsupported remote link type: {link_type}") + elif link_type is not None: + raise ValueError(f"link type is only valid for add_link: {operation}") + if operation in {"create_page", "update_page"}: + if not all( + isinstance(raw[field], str) and raw[field].strip() + for field in ("title", "page_type", "summary", "body") + ): + raise ValueError("page proposals require title, type, summary, and body") + if raw["page_type"] not in {"entity", "concept", "synthesis", "comparison"}: + raise ValueError(f"unsupported page type: {raw['page_type']}") + if operation == "create_page": + if existing or raw["target_path"] is not None: + raise ValueError(f"create_page path already exists: {path}") + proposal = _page_proposal(raw) + base_version_id = None + base_content_hash = None + elif operation == "update_page": + if not existing or raw["target_path"] is not None: + raise ValueError(f"update_page path does not exist: {path}") + proposal = _page_proposal(raw) + base_version_id = existing["base_version_id"] + base_content_hash = existing["base_content_hash"] + elif operation == "add_link": + target_path = raw["target_path"] + if not existing or target_path not in current: + raise ValueError(f"link paths do not exist: {path} -> {target_path}") + proposal = {"target_path": target_path, "link_type": link_type} + base_version_id = existing["base_version_id"] + base_content_hash = existing["base_content_hash"] + else: + target_path = raw["target_path"] + if not existing or target_path not in current: + raise ValueError(f"cleanup paths do not exist: {path} -> {target_path}") + proposal = {"target_path": target_path} + base_version_id = existing["base_version_id"] + base_content_hash = existing["base_content_hash"] + group = str(raw["group"]) + position = group_positions.get(group, 0) + group_positions[group] = position + 1 + group_id = uuid5(run_id, f"group:{group}") + patch_id = uuid5(group_id, f"patch:{position}:{_canonical_json(raw)}") + patches.append( + create_patch( + operation=operation, + path=path, + proposal=proposal, + evidence_ids=cited, + reason=str(raw["reason"]), + base_version_id=base_version_id, + base_content_hash=base_content_hash, + patch_id=patch_id, + group_id=group_id, + position=position, + risk_class=str(raw["risk_class"]), + ) + ) + return patches diff --git a/tests/test_package_boundaries.py b/tests/test_package_boundaries.py index 09795e8..2be8aa6 100644 --- a/tests/test_package_boundaries.py +++ b/tests/test_package_boundaries.py @@ -78,6 +78,8 @@ def find_spec(self, fullname, path=None, target=None): import wikibricks.curation import wikibricks.maintenance import wikibricks.remote.lakebase +import wikibricks_remote.proposals +import wikibricks_remote.resources print('ok') """ result = subprocess.run(