taskuary 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- taskuary/__init__.py +2 -0
- taskuary/agents.py +218 -0
- taskuary/artifacts.py +209 -0
- taskuary/aws.py +226 -0
- taskuary/azure.py +334 -0
- taskuary/blackboard.py +150 -0
- taskuary/channels.py +768 -0
- taskuary/ci.py +237 -0
- taskuary/cli.py +47 -0
- taskuary/coder.py +132 -0
- taskuary/config.py +62 -0
- taskuary/db.py +63 -0
- taskuary/desktop.py +72 -0
- taskuary/devtools.py +352 -0
- taskuary/digest.py +104 -0
- taskuary/docsync.py +124 -0
- taskuary/github.py +136 -0
- taskuary/histgen.py +220 -0
- taskuary/imapmail.py +150 -0
- taskuary/ingest.py +344 -0
- taskuary/learn.py +181 -0
- taskuary/llm.py +175 -0
- taskuary/logs.py +17 -0
- taskuary/mcp.py +78 -0
- taskuary/messengers.py +205 -0
- taskuary/mssql.py +64 -0
- taskuary/outbound.py +221 -0
- taskuary/phone.py +88 -0
- taskuary/pm.py +310 -0
- taskuary/policy.py +59 -0
- taskuary/proof.py +194 -0
- taskuary/proposals.py +132 -0
- taskuary/reports.py +508 -0
- taskuary/reshape.py +201 -0
- taskuary/responder.py +199 -0
- taskuary/routing.py +115 -0
- taskuary/scopes.py +88 -0
- taskuary/server.py +1506 -0
- taskuary/store.py +711 -0
- taskuary/templates/coder.md +35 -0
- taskuary/templates/digest.md +5 -0
- taskuary/templates/learned.md +31 -0
- taskuary/templates/soul.md +44 -0
- taskuary/templates/style.md +14 -0
- taskuary/templates/triage.md +22 -0
- taskuary/terminal.py +971 -0
- taskuary/toil.py +57 -0
- taskuary/triage.py +123 -0
- taskuary/verdicts.py +73 -0
- taskuary/web/assets/index-6GBZ9nXN.css +32 -0
- taskuary/web/assets/index-Cjj87C2X.js +401 -0
- taskuary/web/favicon.ico +0 -0
- taskuary/web/favicon.png +0 -0
- taskuary/web/index.html +25 -0
- taskuary/whatsapp/bridge.mjs +101 -0
- taskuary/whatsapp/package.json +11 -0
- taskuary-0.2.0.dist-info/METADATA +424 -0
- taskuary-0.2.0.dist-info/RECORD +62 -0
- taskuary-0.2.0.dist-info/WHEEL +5 -0
- taskuary-0.2.0.dist-info/entry_points.txt +3 -0
- taskuary-0.2.0.dist-info/licenses/LICENSE +21 -0
- taskuary-0.2.0.dist-info/top_level.txt +1 -0
taskuary/__init__.py
ADDED
taskuary/agents.py
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Agent execution: any CLI is an agent. A profile ({cmd, args, resume_args, timeout, cwd,
|
|
2
|
+
cwd_map}) turns Claude Code, Codex, or your own wrapper into a Taskuary teammate: prompt
|
|
3
|
+
over STDIN (argv length limits are real on Windows), JSON output parsed when available
|
|
4
|
+
(Claude-style {result, session_id} -> resumable sessions), git diff captured around the
|
|
5
|
+
run so code changes are first-class, every run traced + audited.
|
|
6
|
+
"""
|
|
7
|
+
import json, os, re, shutil, subprocess, threading, time
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from loguru import logger
|
|
10
|
+
|
|
11
|
+
from .store import task_ref
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _git(cwd, *args):
|
|
15
|
+
try:
|
|
16
|
+
p = subprocess.run(['git', '-C', cwd or os.getcwd(), *args], capture_output=True, text=True,
|
|
17
|
+
encoding='utf-8', errors='replace', timeout=30)
|
|
18
|
+
return p.stdout.strip() if p.returncode == 0 else ''
|
|
19
|
+
except Exception:
|
|
20
|
+
return ''
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_cli_json(stdout: str):
|
|
24
|
+
"""Claude-style single JSON object -> (result, session_id); plain text falls through."""
|
|
25
|
+
try:
|
|
26
|
+
j = json.loads((stdout or '').strip())
|
|
27
|
+
return (j.get('result') or '').strip(), j.get('session_id')
|
|
28
|
+
except (ValueError, AttributeError):
|
|
29
|
+
return (stdout or '').strip(), None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _fresh_path() -> str:
|
|
33
|
+
"""PATH as it is NOW, not as it was when Taskuary started. A process keeps the environment
|
|
34
|
+
it was born with, so a CLI installed while the app was running said "command not found"
|
|
35
|
+
until a restart - the one thing the error told you to do that you should not have to.
|
|
36
|
+
Windows keeps the live value in the registry; elsewhere the inherited PATH is all there is."""
|
|
37
|
+
if os.name != 'nt': return os.environ.get('PATH', '')
|
|
38
|
+
import winreg
|
|
39
|
+
parts = [os.environ.get('PATH', '')]
|
|
40
|
+
for hive, key in ((winreg.HKEY_CURRENT_USER, 'Environment'),
|
|
41
|
+
(winreg.HKEY_LOCAL_MACHINE, r'SYSTEM\CurrentControlSet\Control\Session Manager\Environment')):
|
|
42
|
+
try:
|
|
43
|
+
with winreg.OpenKey(hive, key) as k:
|
|
44
|
+
parts.append(os.path.expandvars(winreg.QueryValueEx(k, 'Path')[0]))
|
|
45
|
+
except OSError:
|
|
46
|
+
pass
|
|
47
|
+
return os.pathsep.join(p for p in parts if p)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _shim_target(path: str) -> list:
|
|
51
|
+
"""What an npm .CMD shim actually runs. The shim is four lines of batch around one real
|
|
52
|
+
program - claude.CMD ends with "%dp0%\node_modules\@anthropic-ai\claude-code\bin\claude.exe" %* -
|
|
53
|
+
and going through cmd /c to reach it is what costs us the prompt: cmd.exe owns & | < > and
|
|
54
|
+
stray quotes, so the first prompt cannot be passed as an ARGUMENT and has to be TYPED into
|
|
55
|
+
the TUI instead, in 160-char bites that a busy input loop drops. Spawn the target directly
|
|
56
|
+
and the prompt travels as argv - atomically, or not at all. [] = could not tell, use cmd."""
|
|
57
|
+
try: txt = open(path, encoding='utf-8', errors='replace').read()
|
|
58
|
+
except OSError: return []
|
|
59
|
+
here = os.path.dirname(path)
|
|
60
|
+
found = []
|
|
61
|
+
for tok in re.findall(r'"([^"]+)"', txt):
|
|
62
|
+
real = os.path.normpath(tok.replace('%dp0%', here).replace('%~dp0', here))
|
|
63
|
+
if os.path.isfile(real) and real.lower().endswith(('.exe', '.js')): found.append(real)
|
|
64
|
+
exe = next((f for f in found if f.lower().endswith('.exe')), None)
|
|
65
|
+
js = next((f for f in found if f.lower().endswith('.js')), None)
|
|
66
|
+
if exe and js: return [exe, js] # node.exe + the cli script
|
|
67
|
+
if exe: return [exe]
|
|
68
|
+
if js:
|
|
69
|
+
node = shutil.which('node')
|
|
70
|
+
return [node, js] if node else []
|
|
71
|
+
return []
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _resolve_cmd(name: str) -> list:
|
|
75
|
+
"""Windows can't CreateProcess a bare 'claude': npm installs it as claude.cmd, which only
|
|
76
|
+
PATH-resolves via which(). Reaching THROUGH the shim beats running it under cmd /c - see
|
|
77
|
+
_shim_target for why that difference decides whether a prompt arrives whole."""
|
|
78
|
+
path = shutil.which(name) or shutil.which(name, path=_fresh_path())
|
|
79
|
+
if not path:
|
|
80
|
+
raise FileNotFoundError(f"'{name}' not found on PATH - is the CLI installed?")
|
|
81
|
+
if os.name == 'nt' and path.lower().endswith(('.cmd', '.bat')):
|
|
82
|
+
return _shim_target(path) or ['cmd', '/c', path]
|
|
83
|
+
return [path]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _fmt_input(inp) -> str:
|
|
87
|
+
"""The one field a human wants to see per tool call - command, path, pattern…"""
|
|
88
|
+
if not isinstance(inp, dict): return str(inp)[:140]
|
|
89
|
+
for k in ('command', 'file_path', 'path', 'pattern', 'url', 'query', 'description', 'prompt'):
|
|
90
|
+
if inp.get(k): return str(inp[k])[:140]
|
|
91
|
+
return json.dumps(inp)[:140]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _result_text(c) -> str:
|
|
95
|
+
"""A tool_result's content is a string or a list of {type: text} blocks."""
|
|
96
|
+
v = c.get('content')
|
|
97
|
+
if isinstance(v, list): v = ' '.join(str(b.get('text') or '') for b in v if isinstance(b, dict))
|
|
98
|
+
return re.sub(r'\s*\n\s*', ' ⏎ ', str(v or '').strip())
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _live_line(j):
|
|
102
|
+
"""One readable console line per claude stream-json event; None = not worth showing.
|
|
103
|
+
Tool RESULTS stream too (trimmed), so the console reads like the terminal you'd see
|
|
104
|
+
if you ran the CLI yourself - not just the commands it fired."""
|
|
105
|
+
t = j.get('type')
|
|
106
|
+
if t == 'system':
|
|
107
|
+
# only the real session init is news; the other system events (hooks, compaction,
|
|
108
|
+
# subagent starts) repeated 'session started' forever and said nothing
|
|
109
|
+
if j.get('subtype') not in (None, 'init'): return None
|
|
110
|
+
m = j.get('model') or (j.get('modelInfo') or {}).get('name') or ''
|
|
111
|
+
return 'session started' + (f' · model {m}' if m else '')
|
|
112
|
+
if t == 'assistant':
|
|
113
|
+
out = []
|
|
114
|
+
for c in (j.get('message') or {}).get('content') or []:
|
|
115
|
+
if c.get('type') == 'tool_use': out.append(f"→ {c.get('name')}: {_fmt_input(c.get('input'))}")
|
|
116
|
+
elif c.get('type') == 'text' and (c.get('text') or '').strip(): out.append(c['text'].strip()[:300])
|
|
117
|
+
return '\n'.join(out) or None
|
|
118
|
+
if t == 'user':
|
|
119
|
+
res = [c for c in (j.get('message') or {}).get('content') or []
|
|
120
|
+
if isinstance(c, dict) and c.get('type') == 'tool_result']
|
|
121
|
+
if not res: return None
|
|
122
|
+
if any(c.get('is_error') for c in res): return f"✗ {_result_text(next(c for c in res if c.get('is_error')))[:300]}"
|
|
123
|
+
txt = _result_text(res[0])
|
|
124
|
+
return f'· {txt[:240]}' if txt else None
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def run_cli(profile: dict, prompt: str, trace, resume: str = None):
|
|
129
|
+
"""One headless invocation of the configured CLI, output STREAMED line by line into
|
|
130
|
+
the run trace so the Board shows the agent working live. claude's stream-json events
|
|
131
|
+
render as readable tool/text lines; any other CLI's plain stdout streams as-is.
|
|
132
|
+
Returns (result, session_id, diff)."""
|
|
133
|
+
name = profile.get('cmd', 'claude')
|
|
134
|
+
cmd = _resolve_cmd(name) + list(profile.get('args') or ['-p'])
|
|
135
|
+
# which model works it: profile default, or a per-run override from the UI. The flag
|
|
136
|
+
# name is configurable because every CLI spells it differently (claude/codex: --model).
|
|
137
|
+
if profile.get('model'): cmd += [profile.get('model_arg') or '--model', str(profile['model'])]
|
|
138
|
+
if resume and profile.get('resume_args'): cmd += list(profile['resume_args']) + [resume]
|
|
139
|
+
cwd = profile.get('cwd')
|
|
140
|
+
head0 = _git(cwd, 'rev-parse', 'HEAD')
|
|
141
|
+
trace('prompt', 'prompt_sent_to_agent', prompt)
|
|
142
|
+
trace('tool', 'cli', f'{name} cwd={cwd or os.getcwd()}' + (f' resume={resume}' if resume else ''))
|
|
143
|
+
p = subprocess.Popen(cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
144
|
+
text=True, encoding='utf-8', errors='replace', cwd=cwd, shell=False)
|
|
145
|
+
timed = threading.Event()
|
|
146
|
+
killer = threading.Timer(profile.get('timeout', 1200), lambda: (timed.set(), p.kill()))
|
|
147
|
+
killer.start()
|
|
148
|
+
err_buf = []
|
|
149
|
+
threading.Thread(target=lambda: err_buf.append(p.stderr.read()), daemon=True).start()
|
|
150
|
+
# stdin feed on its own thread: writing a big prompt while the child is already
|
|
151
|
+
# emitting output can deadlock both pipes otherwise
|
|
152
|
+
def _feed():
|
|
153
|
+
try: p.stdin.write(prompt); p.stdin.close()
|
|
154
|
+
except Exception: pass
|
|
155
|
+
threading.Thread(target=_feed, daemon=True).start()
|
|
156
|
+
raw, final = [], None
|
|
157
|
+
try:
|
|
158
|
+
for line in p.stdout:
|
|
159
|
+
line = line.rstrip('\n')
|
|
160
|
+
if not line.strip(): continue
|
|
161
|
+
raw.append(line)
|
|
162
|
+
try: j = json.loads(line)
|
|
163
|
+
except ValueError: trace('live', name, line[:400]); continue
|
|
164
|
+
if isinstance(j, dict) and (j.get('type') == 'result' or ('result' in j and 'type' not in j)):
|
|
165
|
+
final = j; continue
|
|
166
|
+
shown = _live_line(j) if isinstance(j, dict) else None
|
|
167
|
+
if shown: trace('live', name, shown)
|
|
168
|
+
p.wait()
|
|
169
|
+
finally:
|
|
170
|
+
killer.cancel()
|
|
171
|
+
if p.returncode != 0:
|
|
172
|
+
why = f'timed out after {profile.get("timeout", 1200)}s' if timed.is_set() else \
|
|
173
|
+
((err_buf[0] if err_buf else '') or '\n'.join(raw[-5:]) or 'no output')[:500]
|
|
174
|
+
raise RuntimeError(f'{name} exit {p.returncode}: {why}')
|
|
175
|
+
if final is not None: out, sid = str(final.get('result') or '').strip(), final.get('session_id')
|
|
176
|
+
else: out, sid = parse_cli_json('\n'.join(raw))
|
|
177
|
+
trace('output', name, out[-1000:])
|
|
178
|
+
diff = ''
|
|
179
|
+
if head0:
|
|
180
|
+
head1 = _git(cwd, 'rev-parse', 'HEAD')
|
|
181
|
+
if head1 and head1 != head0: diff = _git(cwd, 'diff', f'{head0}..{head1}')
|
|
182
|
+
unc = _git(cwd, 'diff', 'HEAD')
|
|
183
|
+
if unc: diff = f'{diff}\n{unc}'.strip()
|
|
184
|
+
if diff: trace('tool', 'code_changes', f'{len(diff.splitlines())} diff lines captured')
|
|
185
|
+
return out, sid, (diff[:150000] or None)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def task_context(store, task_id: int) -> str:
|
|
189
|
+
d = store.task_detail(task_id)
|
|
190
|
+
t = d['task']
|
|
191
|
+
lines = [f"Task {d['ref']}: {t.get('Title')}", f"Kind: {t.get('Kind')} Status: {t.get('Status')}",
|
|
192
|
+
f"Summary: {t.get('Summary') or ''}", '', 'Messages:']
|
|
193
|
+
for m in d['messages']:
|
|
194
|
+
lines += [f"- [{m.get('SentAt')}] {m.get('FromName') or m.get('FromEmail')}: {m.get('Subject') or ''}",
|
|
195
|
+
f" {str(m.get('BodyText') or '')[:1500]}"]
|
|
196
|
+
lines += ['', 'Thread:'] + [f"- {c.get('Actor')}: {str(c.get('Body'))[:300]}" for c in d['comments']]
|
|
197
|
+
return '\n'.join(lines)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def memory_block(store, messages: list) -> str:
|
|
201
|
+
senders = {(m.get('FromEmail') or '').lower() for m in messages if m.get('FromEmail')}
|
|
202
|
+
domains = {s.rsplit('@', 1)[-1] for s in senders if '@' in s}
|
|
203
|
+
hits = [f"- {n['Note']}" for n in store.list_memories()
|
|
204
|
+
if n['Scope'] == 'global' or (n['Scope'] == 'sender' and (n.get('ScopeKey') or '').lower() in senders)
|
|
205
|
+
or (n['Scope'] == 'sender_domain' and (n.get('ScopeKey') or '').lower() in domains)]
|
|
206
|
+
return ('Standing notes (learned from the owner - FOLLOW these):\n' + '\n'.join(hits)) if hits else ''
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# dispatch() lived here: one open->close HEADLESS run on a task, the CLI working and closing
|
|
210
|
+
# where nobody could watch it, interrupt it or answer it. That is precisely the thing this app
|
|
211
|
+
# exists to replace, and every road that used it now opens a REAL session instead
|
|
212
|
+
# (terminal.start_on_task) or, for a two-sentence reply, asks the main AI directly
|
|
213
|
+
# (responder.write_draft). It is deleted rather than left dormant: a headless runner sitting
|
|
214
|
+
# in the module is a headless runner somebody wires back up.
|
|
215
|
+
#
|
|
216
|
+
# run_cli above STAYS, and is not the same thing: it is a one-shot "ask this CLI a question"
|
|
217
|
+
# used as a cheap BRAIN (llm.make_cli_llm - triage, drafts, summaries on an agent's light
|
|
218
|
+
# model) and by the connector test. No task, no run row, no work performed.
|
taskuary/artifacts.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Report output you can actually use: the spreadsheet and the chart, not just the prose.
|
|
2
|
+
|
|
3
|
+
A report already produces ROWS - the message body is one JSON object per line - so the same
|
|
4
|
+
run can hand back an .xlsx to open in Excel and an .svg chart to look at in the panel. Both
|
|
5
|
+
are written as attachments on the report message, which means the timeline and the task page
|
|
6
|
+
already know how to show them (images inline, files as chips).
|
|
7
|
+
|
|
8
|
+
Both writers are hand-rolled on the standard library on purpose: xlsx is a zip of four small
|
|
9
|
+
XML parts, and a bar chart is a few dozen rects. Neither is worth a dependency that has to be
|
|
10
|
+
frozen into the one-file exe.
|
|
11
|
+
"""
|
|
12
|
+
import json, re, zipfile
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from xml.sax.saxutils import escape
|
|
15
|
+
|
|
16
|
+
MAX_SHEET_ROWS, MAX_COLS, MAX_BARS = 5000, 40, 24
|
|
17
|
+
_SAFE = re.compile(r'[^A-Za-z0-9._ -]+')
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def attachment_dir(mid: int):
|
|
21
|
+
"""Where a message's files live. One folder per message, so deleting a message's bytes is
|
|
22
|
+
deleting a folder."""
|
|
23
|
+
from . import config
|
|
24
|
+
p = config.home() / 'attachments' / str(int(mid))
|
|
25
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
return p
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def rows_from_body(text: str) -> list:
|
|
30
|
+
"""The rows back out of a report body (one JSON object per line). Anything that is not a
|
|
31
|
+
JSON object is prose - a summary, an error - and is not data."""
|
|
32
|
+
out = []
|
|
33
|
+
for l in (text or '').splitlines():
|
|
34
|
+
l = l.strip()
|
|
35
|
+
if not l.startswith('{'): continue
|
|
36
|
+
try:
|
|
37
|
+
d = json.loads(l)
|
|
38
|
+
if isinstance(d, dict): out.append(d)
|
|
39
|
+
except ValueError:
|
|
40
|
+
pass
|
|
41
|
+
return out[:MAX_SHEET_ROWS]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def columns(rows: list) -> list:
|
|
45
|
+
"""Every key any row has, in the order they first appear - SQL rows are uniform, REST rows
|
|
46
|
+
are not, and a missing key must not shift a column."""
|
|
47
|
+
cols = []
|
|
48
|
+
for r in rows:
|
|
49
|
+
for k in r:
|
|
50
|
+
if k not in cols: cols.append(k)
|
|
51
|
+
return cols[:MAX_COLS]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _cell(ref: str, v) -> str:
|
|
55
|
+
# a missing value writes NO cell (refs are explicit, so nothing shifts): an empty inline
|
|
56
|
+
# string is a value, and Excel treats "" and blank differently in counts and filters
|
|
57
|
+
if v is None or v == '': return ''
|
|
58
|
+
if isinstance(v, bool): v = str(v)
|
|
59
|
+
if isinstance(v, (int, float)):
|
|
60
|
+
return f'<c r="{ref}"><v>{v}</v></c>'
|
|
61
|
+
s = json.dumps(v, default=str) if isinstance(v, (dict, list)) else str(v)
|
|
62
|
+
return f'<c r="{ref}" t="inlineStr"><is><t xml:space="preserve">{escape(s)}</t></is></c>'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _col_name(i: int) -> str:
|
|
66
|
+
s = ''
|
|
67
|
+
while True:
|
|
68
|
+
s, i = chr(65 + i % 26) + s, i // 26 - 1
|
|
69
|
+
if i < 0: return s
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def to_xlsx(rows: list, path, sheet: str = 'Report') -> bool:
|
|
73
|
+
"""Minimal but real xlsx: header row bold-free, inline strings, numbers as numbers so Excel
|
|
74
|
+
sums them. Returns False when there is nothing tabular to write."""
|
|
75
|
+
if not rows: return False
|
|
76
|
+
cols = columns(rows)
|
|
77
|
+
xml = ['<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
78
|
+
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"><sheetData>']
|
|
79
|
+
xml.append('<row r="1">' + ''.join(_cell(f'{_col_name(i)}1', c) for i, c in enumerate(cols)) + '</row>')
|
|
80
|
+
for n, r in enumerate(rows, start=2):
|
|
81
|
+
xml.append(f'<row r="{n}">' + ''.join(_cell(f'{_col_name(i)}{n}', r.get(c)) for i, c in enumerate(cols)) + '</row>')
|
|
82
|
+
xml.append('</sheetData></worksheet>')
|
|
83
|
+
name = escape(_SAFE.sub('', str(sheet))[:31] or 'Report')
|
|
84
|
+
with zipfile.ZipFile(path, 'w', zipfile.ZIP_DEFLATED) as z:
|
|
85
|
+
z.writestr('[Content_Types].xml',
|
|
86
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
87
|
+
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
|
88
|
+
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
|
89
|
+
'<Default Extension="xml" ContentType="application/xml"/>'
|
|
90
|
+
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
|
91
|
+
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
|
92
|
+
'</Types>')
|
|
93
|
+
z.writestr('_rels/.rels',
|
|
94
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
95
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
96
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
|
97
|
+
'</Relationships>')
|
|
98
|
+
z.writestr('xl/workbook.xml',
|
|
99
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
100
|
+
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
|
101
|
+
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
|
102
|
+
f'<sheets><sheet name="{name}" sheetId="1" r:id="rId1"/></sheets></workbook>')
|
|
103
|
+
z.writestr('xl/_rels/workbook.xml.rels',
|
|
104
|
+
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
|
105
|
+
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
|
106
|
+
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
|
107
|
+
'</Relationships>')
|
|
108
|
+
z.writestr('xl/worksheets/sheet1.xml', ''.join(xml))
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _num(v):
|
|
113
|
+
if isinstance(v, bool) or v is None: return None
|
|
114
|
+
if isinstance(v, (int, float)): return float(v)
|
|
115
|
+
try: return float(str(v).replace(',', '').replace('$', '').strip())
|
|
116
|
+
except ValueError: return None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
_CHART_LINE = re.compile(r'^[ \t]*CHART:[ \t]*([^|\r\n]+?)[ \t]*(?:\|[ \t]*([^\r\n]*))?$', re.M | re.I)
|
|
120
|
+
|
|
121
|
+
def chart_directive(text: str) -> tuple:
|
|
122
|
+
"""(value column, label column, title) if the summary asked for a particular chart. The AI
|
|
123
|
+
has just read every row, so it knows which column is the measure and which is the name -
|
|
124
|
+
better than a heuristic scanning for "all numeric". Format: `CHART: amount | vendor | Spend
|
|
125
|
+
by vendor`. Absent, or naming a column that is not there, falls back to the guess."""
|
|
126
|
+
mt = _CHART_LINE.search(text or '')
|
|
127
|
+
if not mt: return None, None, ''
|
|
128
|
+
bits = [b.strip() for b in ((mt.group(1) or '') + '|' + (mt.group(2) or '')).split('|')]
|
|
129
|
+
return (bits[0] or None), (bits[1] if len(bits) > 1 and bits[1] else None), (bits[2] if len(bits) > 2 else '')
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def strip_directive(text: str) -> str:
|
|
133
|
+
"""The CHART: line is an instruction to Taskuary, not prose for the reader."""
|
|
134
|
+
return _CHART_LINE.sub('', text or '').strip()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def chart_columns(rows: list) -> tuple:
|
|
138
|
+
"""(label column, value column) - the first column that reads as a name, and the first that
|
|
139
|
+
reads as a number in every row. No numbers means no chart; a table of ids and strings is not
|
|
140
|
+
a chart just because we can draw axes."""
|
|
141
|
+
cols = columns(rows)
|
|
142
|
+
vals = [c for c in cols if all(_num(r.get(c)) is not None for r in rows if c in r)
|
|
143
|
+
and any(_num(r.get(c)) for r in rows)]
|
|
144
|
+
labels = [c for c in cols if c not in vals]
|
|
145
|
+
return ((labels[0] if labels else None), (vals[0] if vals else None))
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# Taskuary's own indigo, and a chart that reads in both the panel and a screenshot of it.
|
|
149
|
+
def to_svg_chart(rows: list, path, title: str = '', want_val: str = None, want_lab: str = None) -> str:
|
|
150
|
+
"""Horizontal bars: label, bar, value. Horizontal because report labels are names and dates,
|
|
151
|
+
which do not fit under a vertical bar without turning sideways. Returns the caption, or ''
|
|
152
|
+
when the rows carry nothing to plot. `want_val`/`want_lab` are the AI's pick, honoured only
|
|
153
|
+
when the column is really in the rows - a hallucinated column must not lose the chart."""
|
|
154
|
+
cols = columns(rows)
|
|
155
|
+
lab, val = chart_columns(rows)
|
|
156
|
+
if want_val in cols and any(_num(r.get(want_val)) is not None for r in rows): val = want_val
|
|
157
|
+
if want_lab in cols: lab = want_lab
|
|
158
|
+
if not val: return ''
|
|
159
|
+
pts = [(str(r.get(lab, ''))[:42] if lab else f'#{i + 1}', _num(r.get(val)) or 0.0)
|
|
160
|
+
for i, r in enumerate(rows)][:MAX_BARS]
|
|
161
|
+
if not pts: return ''
|
|
162
|
+
top = max(abs(v) for _l, v in pts) or 1.0
|
|
163
|
+
W, LW, RH, PAD = 760, 210, 22, 18
|
|
164
|
+
H = PAD * 2 + 26 + RH * len(pts)
|
|
165
|
+
out = [f'<svg xmlns="http://www.w3.org/2000/svg" width="{W}" height="{H}" viewBox="0 0 {W} {H}" '
|
|
166
|
+
f'font-family="Segoe UI, Helvetica, Arial, sans-serif">',
|
|
167
|
+
f'<rect width="{W}" height="{H}" fill="#ffffff"/>',
|
|
168
|
+
f'<text x="{PAD}" y="{PAD + 12}" font-size="13" font-weight="700" fill="#1f2430">'
|
|
169
|
+
f'{escape(title or val)}</text>',
|
|
170
|
+
f'<text x="{W - PAD}" y="{PAD + 12}" font-size="10" fill="#8a94a6" text-anchor="end">'
|
|
171
|
+
f'{escape(val)} by {escape(lab or "row")} · {len(pts)} of {len(rows)}</text>']
|
|
172
|
+
bar_w = W - LW - PAD * 2 - 60
|
|
173
|
+
for i, (l, v) in enumerate(pts):
|
|
174
|
+
y = PAD + 26 + i * RH
|
|
175
|
+
w = max(1, int(bar_w * abs(v) / top))
|
|
176
|
+
out += [f'<text x="{LW}" y="{y + 14}" font-size="11" fill="#697386" text-anchor="end">{escape(l)}</text>',
|
|
177
|
+
f'<rect x="{LW + 8}" y="{y + 4}" width="{w}" height="{RH - 9}" rx="2" fill="#4f46e5" fill-opacity="0.85"/>',
|
|
178
|
+
f'<text x="{LW + 14 + w}" y="{y + 14}" font-size="10.5" fill="#1f2430">'
|
|
179
|
+
f'{escape(f"{v:,.2f}".rstrip("0").rstrip("."))}</text>']
|
|
180
|
+
out.append('</svg>')
|
|
181
|
+
svg = ''.join(out)
|
|
182
|
+
if path is not None: path.write_text(svg, encoding='utf-8')
|
|
183
|
+
return svg if path is None else f'{val} by {lab or "row"}'
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
XLSX_TYPE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
|
187
|
+
|
|
188
|
+
def attach_report_output(store, mid: int, title: str, body: str) -> list:
|
|
189
|
+
"""Turn one report's rows into files on its message: the spreadsheet, and the chart when the
|
|
190
|
+
data has a measure in it. Prose-only reports (an AI summary, a failure) produce neither, and
|
|
191
|
+
`report_images_enabled` = 0 turns the chart off without losing the spreadsheet."""
|
|
192
|
+
rows = rows_from_body(body)
|
|
193
|
+
if not rows: return []
|
|
194
|
+
want_val, want_lab, want_title = chart_directive(body)
|
|
195
|
+
charts_on = str(store.get_settings().get('report_images_enabled') or '1') == '1'
|
|
196
|
+
stem = _SAFE.sub('', title or 'report').strip()[:60] or 'report'
|
|
197
|
+
day = datetime.now().strftime('%Y-%m-%d')
|
|
198
|
+
made = []
|
|
199
|
+
d = attachment_dir(mid)
|
|
200
|
+
x = d / f'{stem} {day}.xlsx'
|
|
201
|
+
if to_xlsx(rows, x, stem[:31]):
|
|
202
|
+
made.append(store.add_attachment({'MessageId': mid, 'ExternalId': f'report:{mid}:xlsx', 'Name': x.name,
|
|
203
|
+
'ContentType': XLSX_TYPE, 'Size': x.stat().st_size, 'Path': str(x)}))
|
|
204
|
+
c = d / f'{stem} {day}.svg'
|
|
205
|
+
if charts_on and to_svg_chart(rows, c, want_title or stem, want_val, want_lab):
|
|
206
|
+
made.append(store.add_attachment({'MessageId': mid, 'ExternalId': f'report:{mid}:chart', 'Name': c.name,
|
|
207
|
+
'ContentType': 'image/svg+xml', 'Size': c.stat().st_size,
|
|
208
|
+
'Inline': 1, 'Path': str(c)}))
|
|
209
|
+
return made
|