File size: 4,962 Bytes
838255e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
"""Targeted SJS edits with automatic citation relocation and no implicit deletion."""
from copy import deepcopy
from quality import resolve_pointer


def tokens(pointer):
    if not isinstance(pointer, str) or not pointer.startswith('/'):
        raise ValueError('Use a JSON pointer beginning with /.')
    return [p.replace('~1', '/').replace('~0', '~') for p in pointer[1:].split('/')]


def index(token, length, adding=False):
    if adding and token == '-':
        return length
    if not token.isdigit() or (len(token) > 1 and token.startswith('0')):
        raise ValueError('Use a nonnegative array index, or - to append.')
    value = int(token)
    if value >= length + int(adding):
        raise ValueError('Array index is out of range.')
    return value


def pointers(value, path=''):
    yield path, value
    children = enumerate(value) if isinstance(value, list) else value.items() if isinstance(value, dict) else []
    for key, child in children:
        escaped = str(key).replace('~', '~0').replace('/', '~1')
        yield from pointers(child, path + '/' + escaped)


def apply_edits(data, edits, citations):
    """Apply add/replace/remove to SJS fields; untouched objects retain their citations."""
    result = deepcopy(data)
    model = result['sjs']
    anchored = []
    for citation in result['citations']:
        try:
            _, target = resolve_pointer(model, citation['path'])
        except (ValueError, KeyError, IndexError, TypeError):
            continue  # A replacement citation can correct an invalid original pointer.
        if not isinstance(target, dict):
            raise ValueError('Citations must point to model objects, not scalar fields.')
        anchored.append((target, citation['source_passages']))
    for edit in edits:
        op, path = edit.get('op'), edit.get('path')
        parts = tokens(path)
        if op not in {'add', 'replace', 'remove'}:
            raise ValueError('Each edit needs op (add/replace/remove) and a concrete path.')
        if parts[0] not in {'sjs', 'assumptions', 'warnings'} or len(parts) < 2:
            raise ValueError('Edit specific /sjs/... fields or individual /assumptions/... and /warnings/... entries.')
        parent = result
        for position, part in enumerate(parts[:-1]):
            # Appending the first optional item creates its array. No model-specific defaults.
            if (op == 'add' and position == len(parts) - 2 and parts[-1] == '-'
                    and isinstance(parent, dict) and part not in parent):
                parent[part] = []
            try:
                parent = parent[index(part, len(parent))] if isinstance(parent, list) else parent[part]
            except (KeyError, TypeError) as error:
                raise ValueError(f'Parent of {path} is missing. Add the parent field before editing its children.') from error
        key = index(parts[-1], len(parent), adding=op == 'add') if isinstance(parent, list) else parts[-1]
        if op == 'add':
            if 'value' not in edit:
                raise ValueError('Add requires value.')
            if isinstance(parent, list):
                parent.insert(key, deepcopy(edit['value']))
            elif isinstance(parent, dict) and key not in parent:
                parent[key] = deepcopy(edit['value'])
            else:
                raise ValueError('Add cannot overwrite an existing field; use a targeted replace.')
            continue
        old = parent[key]
        if isinstance(old, list) and any(isinstance(v, (dict, list)) for v in old):
            raise ValueError('Do not replace or remove a collection of model items; edit its individual entries.')
        if op == 'replace':
            if isinstance(old, dict) or isinstance(edit.get('value'), dict) or 'value' not in edit:
                raise ValueError('Do not replace a model object; edit its individual fields.')
            if isinstance(edit['value'], list) and any(isinstance(v, (dict, list)) for v in edit['value']):
                raise ValueError('Add individual model items instead of replacing their collection.')
            parent[key] = deepcopy(edit['value'])
        else:
            if isinstance(old, dict) and not isinstance(parent, list):
                raise ValueError('Do not remove a model section; edit its individual fields.')
            del parent[key]
    locations = {id(value): path for path, value in pointers(model) if isinstance(value, dict)}
    relocated = {locations[id(target)]: evidence for target, evidence in anchored if id(target) in locations}
    for citation in citations:
        _, target = resolve_pointer(model, citation['path'])
        if not isinstance(target, dict):
            raise ValueError('Citations must point to model objects.')
        relocated[citation['path']] = citation['source_passages']
    result['citations'] = [{'path': path, 'source_passages': ids} for path, ids in relocated.items()]
    return result