summaryrefslogtreecommitdiff
path: root/monkey/action.py
blob: b8bbc40c6a04341b29b07193bd1c9de1b2152a62 (plain)
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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
#!/usr/bin/python3

# CodeQ: an online programming tutor.
# Copyright (C) 2015 UL FRI
#
# This program is free software: you can redistribute it and/or modify it under
# the terms of the GNU Affero General Public License as published by the Free
# Software Foundation, either version 3 of the License, or (at your option) any
# later version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.

class Action:
    # type ∈ ['insert', 'remove', 'solve', 'solve_all', 'next', 'stop', 'test', 'hint']
    # time: absolute elapsed time since the attempt started, in ms
    # offset: position of the first inserted/removed character
    # text: inserted/removed text or query
    # total, passed: number of test cases
    def __init__(self, type, time, offset=0, text='', total=0, passed=0):
        self.type = type
        self.time = time
        if type in {'insert', 'remove'}:
            self.offset = offset
            self.length = len(text)
            self.text = text
        elif type in {'prolog_solve', 'solve_all'}:
            self.query = text
        elif type == 'test':
            self.total = total
            self.passed = passed

    def __str__(self):
        s = 't = ' + str(self.time/1000.0) + '  ' + self.type
        if self.type in {'insert', 'remove'}:
            s += ' "' + self.text.replace('\n', '\\n').replace('\t', '\\t') + '" at ' + str(self.offset)
        elif self.type in {'prolog_solve', 'solve_all'}:
            s += ' "' + self.query + '"'
        elif self.type == 'test':
            s += ' {0} / {1}'.format(self.passed, self.total)
        return s

    # apply this action to text
    def apply(self, text):
        if self.type == 'insert':
            return text[:self.offset] + self.text + text[self.offset:]
        elif self.type == 'remove':
            return text[:self.offset] + text[self.offset+self.length:]
        else:
            return text

    # reverse the application of this action
    def unapply(self, text):
        if self.type == 'insert':
            return text[:self.offset] + text[self.offset+self.length:]
        elif self.type == 'remove':
            return text[:self.offset] + self.text + text[self.offset:]
        else:
            return text

_packet_action_map = {
    'ins':  lambda packet, time, code: Action('insert', time, offset=packet['off'], text=packet['txt']),
    'rm':   lambda packet, time, code: Action('remove', time, offset=packet['off'], text=packet['txt']),
    'tst':  lambda packet, time, code: Action('test', time, total=packet['tot'], passed=packet['pas']),
    'hnt':  lambda packet, time, code: Action('hint', time),
    'slva': lambda packet, time, code: Action('solve_all', time, text=packet['qry']),
    'prolog_solve':  lambda packet, time, code: Action('prolog_solve', time, text=packet['query']),
    'prolog_next':   lambda packet, time, code: Action('next', time),
    'prolog_end':    lambda packet, time, code: Action('stop', time),
}
# parse log from database into a list of actions, cleaning up some fluff.
# ignore non-text actions (queries and tests)
def parse(data):
    if data == None:
        return [], []

    actions = []
    incorrect = set()

    time = 0
    code = ''
    for packet in data:
        try:
            time += packet['dt']
            action = _packet_action_map[packet['typ']](packet, time, code)
        except:
            # ignore any errors while decoding a packet
            continue

        # skip normalization if this is the first action
        if actions == []:
            actions.append(action)
            code = action.apply(code)
            continue

        # add to list of actions; modify previously added action if necessary
        prev = actions[-1]

        # remove superfluous REMOVE action when newline is inserted (due to editor auto-indent)
        if prev.type == 'remove' and action.type == 'insert' and \
           action.time == prev.time and \
           action.offset == prev.offset and action.length > prev.length and \
           action.text[action.length-prev.length:] == prev.text:
            # discard last REMOVE action
            code = prev.unapply(code)
            actions.pop()

            # replace current action with something better
            length = action.length - prev.length
            new = Action('insert', prev.time, offset = prev.offset, text = action.text[:length])
            actions.append(new)
            code = new.apply(code)

        # remove superfluous INSERT action when newline is removed (due to editor auto-indent)
        elif prev.type == 'remove' and action.type == 'insert' and \
             action.time == prev.time and \
             action.offset == prev.offset and action.length < prev.length and \
             prev.text[prev.length-action.length:] == action.text:
            # discard last INSERT action
            code = prev.unapply(code)
            actions.pop()

            # replace current action with something better
            length = prev.length - action.length
            new = Action('remove', prev.time, offset = prev.offset, text = prev.text[:length])
            actions.append(new)
            code = new.apply(code)

        # discard INSERT/REMOVE pairs (typos)
        elif prev.type == 'insert' and action.type == 'remove' and \
             action.time - prev.time < 10000 and \
             action.offset == prev.offset and action.text == prev.text:
            # discard last and current actions
            code = prev.unapply(code)
            actions.pop()

        # discard REMOVE/INSERT pairs (deleted char then typed back)
        elif prev.type == 'remove' and action.type == 'insert' and \
             action.offset == prev.offset and action.text == prev.text:
            # discard last and current actions
            code = prev.unapply(code)
            actions.pop()

        # otherwise, simply append the current action
        else:
            actions.append(action)
            code = action.apply(code)

    return actions

# expand any multi-char actions (does not do anything for the 99%)
def expand(actions):
    i = 0
    while i < len(actions):
        if actions[i].type == 'insert' and len(actions[i].text) > 1:
            a = actions.pop(i)
            for offset in range(len(a.text)):
                actions.insert(i+offset, Action('insert', a.time, a.offset+offset, a.text[offset]))
            i += len(a.text)
        elif actions[i].type == 'remove' and len(actions[i].text) > 1:
            a = actions.pop(i)
            for offset in range(len(a.text)):
                actions.insert(i, Action('remove', a.time, a.offset+offset, a.text[offset]))
            i += len(a.text)
        else:
            i += 1

# some sample code
if __name__ == '__main__':
    import sys, os.path
    sys.path.append(os.path.dirname(os.path.realpath(__file__)) + '/..') # the parent directory is the app directory

    from db.models import Problem, Solution

    # print all problem ids
    print('problems:')
    for problem in Problem.list():
        print('  {}\t{}'.format(problem.id, problem.identifier))
    print()

    pid = input('enter problem id: ')

    # print all attempt ids for the selected problem
    print('users solving problem ' + str(pid) + ':')
    attempts = Solution.filter(problem_id=pid)
    print(', '.join([str(attempt.codeq_user_id) for attempt in attempts]))
    print()

    uid = input('enter user id: ')
    attempt = Solution.get(problem_id=pid, codeq_user_id=uid)

    try:
        actions = parse(attempt.trace)
        print('read ' + str(len(actions)) + ' actions from log')

        print('code versions for this attempt:')
        code = ''
        for action in actions:
            code = action.apply(code)
            print(action)
            print(code.strip())
            print()
    except Exception as ex:
        sys.stderr.write('Error parsing action log: ' + str(ex))