"""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' — 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:' 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