File size: 8,739 Bytes
c14ceee
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
210
211
212
213
214
"""Store-backed task / worklist lifecycle β€” the writable PROCESS layer over the read-only ERP
(the Palantir split: subject layer = Odoo, read-only; process layer = this, in the HF store).

Store key 'tasks': {"seq": int, "items": {id: task}}. A task:
  id            't<seq>' β€” stable
  title         verb-first next action ("Call Smith Floral about reorder")
  detail        one-line evidence/why (frozen at flag time for rule tasks)
  entity        {'kind': customer|sku|agent|account, 'id': …, 'label': …} | None
  owner_user    username in the users registry
  due           'YYYY-MM-DD' | None
  state         open | done | snoozed | dismissed
  source        'user' | 'rule:<rule_id>'
  cond_key      rule tasks: fingerprint of the flagging condition β€” a dismissed task is NOT
                re-created until the condition RESETS (cond_key changes). The anti-fatigue rule.
  dismiss_reason  enumerated (required on dismiss); reviewed to tune/kill rules
  outcome       'self-resolved' when a rule flag cleared on its own (auto-closed)
  created_by / created_at / status_changed / snooze_until / history (append-only)

All mutations go through store.update (strict read-modify-write β€” never get()+put()).
"""
import datetime as dt

import core.store as store

KEY = 'tasks'
DISMISS_REASONS = ['Not relevant', 'Already handled', 'Data is wrong', 'Duplicate', 'Other']
STATES = ('open', 'done', 'snoozed', 'dismissed')


def _now():
    return dt.datetime.now().strftime('%Y-%m-%d %H:%M')


def _today():
    return dt.date.today().isoformat()


def _entity_key(entity):
    if not entity:
        return None
    return f"{entity.get('kind')}:{entity.get('id')}"


def all_tasks():
    d = store.get(KEY)
    return list((d.get('items') or {}).values())


def effective_state(t, today=None):
    """Snoozed past its wake date reads as open (Linear semantics β€” wake early on due)."""
    s = t.get('state', 'open')
    if s == 'snoozed' and (t.get('snooze_until') or '') <= (today or _today()):
        return 'open'
    return s


def for_owner(owner=None, states=('open',)):
    """Open queue for a user (None = everyone), due-date order, snooze-awakened."""
    today = _today()
    rows = [t for t in all_tasks()
            if (owner is None or t.get('owner_user') == owner)
            and effective_state(t, today) in states]
    return sorted(rows, key=lambda t: (t.get('due') or '9999-12-31', t.get('id', '')))


def counts_by_user():
    """{user: {'open': n, 'overdue': n, 'stale7': n}} β€” feeds the owner rollup + digests."""
    today = _today()
    week_ago = (dt.date.today() - dt.timedelta(days=7)).isoformat()
    out = {}
    for t in all_tasks():
        if effective_state(t, today) != 'open':
            continue
        u = t.get('owner_user') or 'β€”'
        rec = out.setdefault(u, {'open': 0, 'overdue': 0, 'stale7': 0})
        rec['open'] += 1
        if (t.get('due') or '9999') < today:
            rec['overdue'] += 1
        if (t.get('status_changed') or t.get('created_at') or '')[:10] <= week_ago:
            rec['stale7'] += 1
    return out


def create(title, owner_user, created_by, detail='', entity=None, due=None,
           source='user', cond_key=None):
    """Create one task (human producer). Returns the new task id."""
    created = {}

    def _add(d):
        d.setdefault('seq', 0)
        d.setdefault('items', {})
        d['seq'] += 1
        tid = f"t{d['seq']}"
        d['items'][tid] = {
            'id': tid, 'title': str(title).strip(), 'detail': str(detail or '').strip(),
            'entity': entity or None, 'owner_user': owner_user, 'due': due,
            'state': 'open', 'source': source, 'cond_key': cond_key,
            'dismiss_reason': None, 'outcome': None,
            'created_by': created_by, 'created_at': _now(), 'status_changed': _now(),
            'snooze_until': None,
            'history': [{'at': _now(), 'by': created_by, 'event': 'created'}],
        }
        created['id'] = tid
        return d
    store.update(KEY, _add)
    return created.get('id')


def set_state(tid, state, user, dismiss_reason=None, snooze_until=None):
    """Move a task through its lifecycle. Dismiss REQUIRES an enumerated reason."""
    if state not in STATES:
        raise ValueError(f'bad state {state}')
    if state == 'dismissed' and not dismiss_reason:
        raise ValueError('dismiss requires a reason')

    def _set(d):
        t = (d.get('items') or {}).get(tid)
        if not t:
            return d
        t['state'] = state
        t['status_changed'] = _now()
        if state == 'dismissed':
            t['dismiss_reason'] = dismiss_reason
        if state == 'snoozed':
            t['snooze_until'] = snooze_until
        t.setdefault('history', []).append(
            {'at': _now(), 'by': user, 'event': state,
             **({'reason': dismiss_reason} if dismiss_reason else {}),
             **({'until': snooze_until} if snooze_until else {})})
        return d
    store.update(KEY, _set)


def sync_rule_tasks(rule_id, flags, default_owner=None):
    """Rule producer (the nightly job): reconcile the store against the CURRENT flag set.

    flags: [{entity, title, detail, owner_user?, due?, cond_key}] β€” cond_key fingerprints the
    condition (e.g. bucket/threshold hit), so:
      - open/snoozed task for the same (rule, entity): refresh detail (evidence stays current);
      - done/dismissed task with the SAME cond_key: skip (never re-nag until the condition resets);
      - cond_key changed: create a NEW task (the condition reset and re-fired);
      - open rule task whose entity no longer flags: auto-close as self-resolved (the
        stale-list/paid-invoice trust-killer).
    One store commit for the whole reconcile.
    """
    src = f'rule:{rule_id}'

    def _sync(d):
        d.setdefault('seq', 0)
        d.setdefault('items', {})
        items = d['items']
        by_entity = {}
        for t in items.values():
            if t.get('source') == src and t.get('entity'):
                by_entity.setdefault(_entity_key(t['entity']), []).append(t)
        seen = set()
        for f in flags:
            ek = _entity_key(f.get('entity'))
            if not ek:
                continue
            seen.add(ek)
            existing = sorted(by_entity.get(ek, []), key=lambda t: t['id'])
            live = [t for t in existing if t.get('state') in ('open', 'snoozed')]
            if live:
                live[-1]['detail'] = f.get('detail', live[-1].get('detail'))
                live[-1]['cond_key'] = f.get('cond_key', live[-1].get('cond_key'))
                continue
            closed_same = [t for t in existing
                           if t.get('cond_key') and t.get('cond_key') == f.get('cond_key')]
            if closed_same:
                continue                      # dismissed/done and unchanged β€” stay quiet
            d['seq'] += 1
            tid = f"t{d['seq']}"
            items[tid] = {
                'id': tid, 'title': f.get('title', rule_id), 'detail': f.get('detail', ''),
                'entity': f.get('entity'), 'owner_user': f.get('owner_user') or default_owner,
                'due': f.get('due'), 'state': 'open', 'source': src,
                'cond_key': f.get('cond_key'), 'dismiss_reason': None, 'outcome': None,
                'created_by': 'system', 'created_at': _now(), 'status_changed': _now(),
                'snooze_until': None,
                'history': [{'at': _now(), 'by': 'system', 'event': 'created'}],
            }
        for ek, ts in by_entity.items():
            if ek in seen:
                continue
            for t in ts:
                if t.get('state') in ('open', 'snoozed'):
                    t['state'] = 'done'
                    t['outcome'] = 'self-resolved'
                    t['status_changed'] = _now()
                    t.setdefault('history', []).append(
                        {'at': _now(), 'by': 'system', 'event': 'self-resolved'})
        return d
    store.update(KEY, _sync)


def rule_health():
    """fired / actioned(done by a person) / dismissed / self-resolved per rule β€” the noise budget.
    <20% actioned = the rule is noise: tighten or kill (SRE actionability test)."""
    out = {}
    for t in all_tasks():
        src = t.get('source') or ''
        if not src.startswith('rule:'):
            continue
        r = out.setdefault(src[5:], {'fired': 0, 'done': 0, 'dismissed': 0, 'self_resolved': 0})
        r['fired'] += 1
        if t.get('outcome') == 'self-resolved':
            r['self_resolved'] += 1
        elif t.get('state') == 'done':
            r['done'] += 1
        elif t.get('state') == 'dismissed':
            r['dismissed'] += 1
    return out