import difflib import json import re from copy import deepcopy from typing import Any PROTECTED_FIELDS = { "program", "lecture_hours", "tutorial_hours", "practical_hours", "self_study", "credits", "course_type", } TOPIC_SPLIT = re.compile(r"[,;\n]+") SPACE = re.compile(r"\s+") def stable_json(value: Any) -> str: return json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) def build_patch(old: Any, new: Any, path: str = "") -> list[dict]: if isinstance(old, dict) and isinstance(new, dict): patch = [] old_keys = set(old) new_keys = set(new) for key in sorted(old_keys - new_keys): patch.append({"op": "remove", "path": f"{path}/{_escape(key)}"}) for key in sorted(new_keys - old_keys): patch.append({"op": "add", "path": f"{path}/{_escape(key)}", "value": new[key]}) for key in sorted(old_keys & new_keys): patch.extend(build_patch(old[key], new[key], f"{path}/{_escape(key)}")) return patch if old != new: return [{"op": "replace", "path": path or "/", "value": new}] return [] def apply_patch(value: Any, patch: list[dict]) -> Any: result = deepcopy(value) for op in patch: name = op.get("op") path = op.get("path") if name not in {"add", "remove", "replace"} or not isinstance(path, str): raise ValueError("Unsupported patch operation") parent, key = _resolve_parent(result, path) if isinstance(parent, list): index = int(key) if name == "remove": parent.pop(index) elif name == "replace": parent[index] = op.get("value") else: parent.insert(index, op.get("value")) continue if name == "remove": parent.pop(key, None) else: parent[key] = op.get("value") return result def diff_course(old: dict, new: dict) -> dict: old_text = stable_json(old) new_text = stable_json(new) patch = build_patch(old, new) syllabus_old = _syllabus_text(old) syllabus_new = _syllabus_text(new) topics_old = _topics(syllabus_old) topics_new = _topics(syllabus_new) return { "json_patch": patch, "patch_operations": len(patch), "change_percent": _change_percent(old_text, new_text), "syllabus_change_percent": _change_percent(syllabus_old, syllabus_new), "topics_added": sorted(topics_new - topics_old), "topics_removed": sorted(topics_old - topics_new), "protected_changes": sorted(field for field in PROTECTED_FIELDS if _field_value(old, field) != _field_value(new, field)), "unified_diff": "\n".join( difflib.unified_diff( old_text.splitlines(), new_text.splitlines(), fromfile="current", tofile="proposed", lineterm="", ) ), } def validate_draft(old: dict, new: dict) -> list[str]: issues = [] for field in PROTECTED_FIELDS: if _field_value(old, field) != _field_value(new, field): issues.append(f"{field} is deterministic and cannot be changed by an agent draft") return issues def merge_fields(base: dict, fields: dict) -> dict: merged = deepcopy(base) for key, value in fields.items(): if key in PROTECTED_FIELDS and str(base.get(key) or "") == str(value or ""): value = base.get(key) merged[key] = value return merged def _field_value(value: dict, field: str) -> str: return str(value.get(field) or "") def _escape(value: str) -> str: return value.replace("~", "~0").replace("/", "~1") def _unescape(value: str) -> str: return value.replace("~1", "/").replace("~0", "~") def _resolve_parent(value: Any, path: str) -> tuple[Any, str]: if path in {"", "/"}: raise ValueError("Root patch operations are not supported") parts = [_unescape(part) for part in path.strip("/").split("/")] current = value for part in parts[:-1]: current = current[int(part)] if isinstance(current, list) else current[part] return current, parts[-1] def _change_percent(old: str, new: str) -> float: if not old and not new: return 0.0 ratio = difflib.SequenceMatcher(None, old, new).ratio() return round((1 - ratio) * 100, 2) def _syllabus_text(value: dict) -> str: units = value.get("units") or [] if not isinstance(units, list): return "" parts = [] for unit in units: if isinstance(unit, dict): parts.append(str(unit.get("title") or "")) parts.append(str(unit.get("content") or "")) return SPACE.sub(" ", " ".join(parts)).strip() def _topics(text: str) -> set[str]: topics = set() for item in TOPIC_SPLIT.split(text): topic = SPACE.sub(" ", item).strip(" .:-").lower() if len(topic) >= 4: topics.add(topic) return topics