taxtrace 0.1.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.
- taxtrace/__init__.py +3 -0
- taxtrace/answers.py +49 -0
- taxtrace/carryover.py +110 -0
- taxtrace/certificate.py +105 -0
- taxtrace/chat.py +344 -0
- taxtrace/cli.py +1910 -0
- taxtrace/data/vocab.txt +8052 -0
- taxtrace/data/vocab_extra.txt +64 -0
- taxtrace/estimates.py +192 -0
- taxtrace/f1040.py +95 -0
- taxtrace/f1098.py +245 -0
- taxtrace/f1099.py +204 -0
- taxtrace/f1116b.py +209 -0
- taxtrace/f8582.py +150 -0
- taxtrace/f8995.py +115 -0
- taxtrace/fbar.py +127 -0
- taxtrace/foreign.py +720 -0
- taxtrace/fx.py +153 -0
- taxtrace/gate.py +226 -0
- taxtrace/importer.py +146 -0
- taxtrace/ira.py +120 -0
- taxtrace/itr.py +279 -0
- taxtrace/k1.py +570 -0
- taxtrace/k3.py +261 -0
- taxtrace/macos/approval_check.swift +52 -0
- taxtrace/macos/keychain_gate.swift +152 -0
- taxtrace/mcp_server.py +128 -0
- taxtrace/nck1.py +191 -0
- taxtrace/passive.py +262 -0
- taxtrace/paystub.py +322 -0
- taxtrace/policy.py +75 -0
- taxtrace/prior.py +749 -0
- taxtrace/qbi.py +268 -0
- taxtrace/redact.py +103 -0
- taxtrace/review.py +196 -0
- taxtrace/schedule_e.py +341 -0
- taxtrace/service.py +130 -0
- taxtrace/statement.py +525 -0
- taxtrace/static/apple-touch-icon.png +0 -0
- taxtrace/static/icon-192.png +0 -0
- taxtrace/static/icon-512.png +0 -0
- taxtrace/static/icon-app.svg +33 -0
- taxtrace/static/icon-maskable-512.png +0 -0
- taxtrace/static/icon-maskable.svg +24 -0
- taxtrace/static/icon.svg +18 -0
- taxtrace/static/index.html +573 -0
- taxtrace/static/manifest.webmanifest +17 -0
- taxtrace/tds.py +134 -0
- taxtrace/text.py +285 -0
- taxtrace/todo.py +171 -0
- taxtrace/vul.py +115 -0
- taxtrace/web.py +180 -0
- taxtrace/workspace.py +136 -0
- taxtrace-0.1.0.dist-info/METADATA +804 -0
- taxtrace-0.1.0.dist-info/RECORD +58 -0
- taxtrace-0.1.0.dist-info/WHEEL +4 -0
- taxtrace-0.1.0.dist-info/entry_points.txt +3 -0
- taxtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
taxtrace/__init__.py
ADDED
taxtrace/answers.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Answers to a tax preparer's questions: "Rental income = $1,200/month", typed into the preparer's own document.
|
|
2
|
+
|
|
3
|
+
A preparer sends questions each year (an organizer, or a Word document of them) and the answers come back typed
|
|
4
|
+
under them. An answer that's an amount is a line of its own: what it is, "=" or ":", the amount, and how often
|
|
5
|
+
("/month", "per year"). Each is kept with the heading it's under ("Rental property:"), its page and its line exactly.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from decimal import Decimal
|
|
13
|
+
|
|
14
|
+
from .prior import amount
|
|
15
|
+
|
|
16
|
+
ANSWER = re.compile(r"^\s*(?P<what>[A-Za-z][A-Za-z0-9 &/'’()-]*?)\s*[=:]\s*\$\s?(?P<amount>\d[\d,]*(?:\.\d{1,2})?)"
|
|
17
|
+
r"\s*(?:(?:/|per\s+|a\s+)(?P<per>month|mo|year|yr|annum)\b)?", re.IGNORECASE)
|
|
18
|
+
HEADING = re.compile(r"^\s*(?P<heading>[A-Za-z][^=?$]{2,60}):\s*$")
|
|
19
|
+
PER = {"mo": "month", "yr": "year", "annum": "year"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Answer:
|
|
24
|
+
what: str # "Rental income"
|
|
25
|
+
amount: Decimal
|
|
26
|
+
per: str # "month", "year", or "" when the answer doesn't say
|
|
27
|
+
section: str # the heading it's under, "Rental property"
|
|
28
|
+
file: str
|
|
29
|
+
page: int
|
|
30
|
+
quote: str # its line, exactly as `text.pages` gives it
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def yearly(self) -> Decimal:
|
|
34
|
+
return self.amount * 12 if self.per == "month" else self.amount
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def read(name: str, pages: list[str]) -> list[Answer]:
|
|
38
|
+
"""Every answer that's an amount, in order."""
|
|
39
|
+
found = []
|
|
40
|
+
for n, page in enumerate(pages, 1):
|
|
41
|
+
section = ""
|
|
42
|
+
for line in page.splitlines():
|
|
43
|
+
if m := ANSWER.match(line):
|
|
44
|
+
per = (m.group("per") or "").lower()
|
|
45
|
+
found.append(Answer(m.group("what").strip(), amount(m.group("amount")), PER.get(per, per), section,
|
|
46
|
+
name, n, line))
|
|
47
|
+
elif m := HEADING.match(line):
|
|
48
|
+
section = m.group("heading").strip()
|
|
49
|
+
return found
|
taxtrace/carryover.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""The preparer's Carryover Worksheet: what a filed return carries to next year's, on one page.
|
|
2
|
+
|
|
3
|
+
Drake prints it after the return ("List of items that will carryover to the <year> tax return"), in sections
|
|
4
|
+
(Itemized Deductions, Expenses, Losses, Credits, Other, Passive Activity, At Risk Limitations). A row is a label
|
|
5
|
+
and its amounts: one under "Carryover Amount", or a regular tax and an AMT amount ("AMT ... Reg. Tax ..."), or one
|
|
6
|
+
each for the taxpayer and the spouse (IRA basis). Rows are read by position (`text.words`); a label ends at its
|
|
7
|
+
leader dots, and a number right after a word ("Estimated Tax Payment 3") is part of the label, not an amount.
|
|
8
|
+
Anything under Passive Activity or At Risk Limitations is asked about: no copy seen so far had rows there.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
from collections.abc import Iterator
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
from . import text
|
|
18
|
+
from .prior import Question, _rows, amount
|
|
19
|
+
|
|
20
|
+
HEADING = "Carryover Worksheet"
|
|
21
|
+
TO_YEAR = re.compile(r"carryover\s+to\s+the\s+((?:19|20)\d\d)\s+tax\s+return", re.IGNORECASE)
|
|
22
|
+
VALUE = re.compile(r"^-?[\d,]*\d(?:\.\d*)?$")
|
|
23
|
+
MARKERS = {"AMT": "amt", "AM": "amt", "Reg.": "regular", "Taxpayer": "taxpayer", "Spouse": "spouse"}
|
|
24
|
+
UNREAD = ("Passive Activity", "At Risk Limitations")
|
|
25
|
+
LEFT = 300 # amounts sit right of the labels' column
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class Item:
|
|
30
|
+
section: str # "Credits"
|
|
31
|
+
label: str # "Foreign Tax credit"
|
|
32
|
+
amounts: dict[str, object] # "amount", or "regular" and "amt", or "taxpayer" and "spouse": Decimal
|
|
33
|
+
page: int
|
|
34
|
+
quote: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def is_page(page: str) -> bool:
|
|
38
|
+
return any(HEADING in ln for ln in text.headings(page, 3))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def to_year(page: str) -> int | None:
|
|
42
|
+
"""The year the worksheet carries to, from its own heading."""
|
|
43
|
+
m = TO_YEAR.search(" ".join(page.split()[:40]))
|
|
44
|
+
return int(m.group(1)) if m else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _walk(words: list[dict]) -> Iterator[tuple[str, list[dict] | None]]:
|
|
48
|
+
"""(section, row) for each row under the "Carryover Amount" heading, and (section, None) as each section
|
|
49
|
+
starts: the first beside that heading, the rest at the left margin."""
|
|
50
|
+
started = False
|
|
51
|
+
for row in _rows(words):
|
|
52
|
+
texts = [w["text"] for w in row]
|
|
53
|
+
if not started:
|
|
54
|
+
started = "Carryover" in texts and "Amount" in texts
|
|
55
|
+
if started:
|
|
56
|
+
yield " ".join(w["text"] for w in row if w["x0"] < LEFT), None
|
|
57
|
+
continue
|
|
58
|
+
if len(row) == 1 and len(texts[0]) == 1:
|
|
59
|
+
continue # a letter of the "Client Copy" watermark
|
|
60
|
+
if row[0]["x0"] < 50: # a section's heading, or the form's id at the foot ("WK_CARRY.LD")
|
|
61
|
+
if all(t.isalpha() for t in texts):
|
|
62
|
+
yield " ".join(texts), None
|
|
63
|
+
continue
|
|
64
|
+
yield "", row
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sections(words: list[dict]) -> list[str]:
|
|
68
|
+
"""The sections the worksheet prints, with rows or without: an empty Passive Activity section says no passive
|
|
69
|
+
loss carries to next year."""
|
|
70
|
+
return [section for section, row in _walk(words) if row is None]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read(n: int, words: list[dict]) -> tuple[list[Item], list[Question]]:
|
|
74
|
+
items: list[Item] = []
|
|
75
|
+
questions: list[Question] = []
|
|
76
|
+
section = ""
|
|
77
|
+
for heading, row in _walk(words):
|
|
78
|
+
if row is None:
|
|
79
|
+
section = heading
|
|
80
|
+
continue
|
|
81
|
+
texts = [w["text"] for w in row]
|
|
82
|
+
label, amounts = _split(row)
|
|
83
|
+
quote = " ".join(texts)
|
|
84
|
+
if section in UNREAD:
|
|
85
|
+
questions.append(Question(f"The Carryover Worksheet lists something under {section}: read it by hand.",
|
|
86
|
+
n, quote))
|
|
87
|
+
continue
|
|
88
|
+
if amounts:
|
|
89
|
+
items.append(Item(section, label, amounts, n, quote))
|
|
90
|
+
return items, questions
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _split(row: list[dict]) -> tuple[str, dict]:
|
|
94
|
+
"""A row's label, cut at its leader dots, and its amounts, each under the marker left of it."""
|
|
95
|
+
label: list[str] = []
|
|
96
|
+
amounts: dict = {}
|
|
97
|
+
key, prev = "amount", None
|
|
98
|
+
for w in row:
|
|
99
|
+
t = w["text"]
|
|
100
|
+
if t in MARKERS and w["x0"] >= LEFT:
|
|
101
|
+
key = MARKERS[t]
|
|
102
|
+
elif t in ("T", "Tax") and prev is not None and prev["text"] in ("AM", "Reg."):
|
|
103
|
+
pass # "AM T", "Reg. Tax"
|
|
104
|
+
elif (w["x0"] >= LEFT and VALUE.match(t)
|
|
105
|
+
and (prev is None or prev["text"].endswith(".") or w["x0"] - prev["x1"] > 10)):
|
|
106
|
+
amounts[key] = amounts.get(key, 0) + amount(t)
|
|
107
|
+
elif not amounts and key == "amount":
|
|
108
|
+
label.append(t)
|
|
109
|
+
prev = w
|
|
110
|
+
return re.sub(r"\.{2,}.*$", "", " ".join(label)).strip(), amounts
|
taxtrace/certificate.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""An Indian bank's interest certificate on its deposits: each account's interest and the tax kept on it in a year.
|
|
2
|
+
|
|
3
|
+
A bank prints one a year, "for the financial year ended 31st March 2026": a row per deposit account, its number,
|
|
4
|
+
branch code and type, the balance on March 31, the interest paid and the interest accrued in the year, then the
|
|
5
|
+
"Total Tax Deducted" (the TDS, with cess and surcharge) and a second tax column, then a TOTAL row. It is what gives
|
|
6
|
+
a term deposit's TDS: a deposit that adds its interest to itself pays none out, so its statements show no TDS row,
|
|
7
|
+
only its value falling when the tax is taken from the interest at the year's end. The second column ("Overhead Tax
|
|
8
|
+
Deducted") is apart from the total: tax the bank charged but didn't take from the deposit, recovered later.
|
|
9
|
+
|
|
10
|
+
Checks: the rows' interest paid, interest accrued and both tax columns add up to the TOTAL row. Nothing that names
|
|
11
|
+
the holder is kept, and of an account number only its last four digits.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from decimal import Decimal
|
|
19
|
+
|
|
20
|
+
from .prior import Check, Question, _rows
|
|
21
|
+
from .statement import MONEY, _last_four, money
|
|
22
|
+
|
|
23
|
+
YEAR_ENDED = re.compile(r"financial year ended\s+(\d{1,2})(?:st|nd|rd|th)?\s+([A-Za-z]+)\.?,?\s+(\d{4})", re.I)
|
|
24
|
+
MONTHS = ("jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec")
|
|
25
|
+
# the tax column after "Total Tax Deducted": its own heading's first word, on the heading's first line
|
|
26
|
+
SECOND_TAX = re.compile(r"\bTOTAL TAX (\S+) TAX\b", re.I)
|
|
27
|
+
ACCOUNT_NUMBER = re.compile(r"[\dX]{8,}")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Deposit:
|
|
32
|
+
account: str # the last four digits
|
|
33
|
+
type: str # as the bank prints it
|
|
34
|
+
balance: Decimal # on the year's last day
|
|
35
|
+
paid: Decimal # interest paid in the year
|
|
36
|
+
accrued: Decimal # interest accrued in the year, not paid
|
|
37
|
+
tax: Decimal # the total tax deducted: the TDS, with cess and surcharge
|
|
38
|
+
second_tax: Decimal # the column after it, headed `Certificate.second_tax_label`: the overhead, not yet taken
|
|
39
|
+
page: int
|
|
40
|
+
quote: str
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class Certificate:
|
|
45
|
+
file: str
|
|
46
|
+
year_ended: str = "" # dd-mm-yyyy, the financial year's last day
|
|
47
|
+
second_tax_label: str = "" # the heading of the tax column after the total tax deducted
|
|
48
|
+
deposits: list[Deposit] = field(default_factory=list)
|
|
49
|
+
checks: list[Check] = field(default_factory=list)
|
|
50
|
+
questions: list[Question] = field(default_factory=list)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def failed(self) -> list[Check]:
|
|
54
|
+
return [c for c in self.checks if not c.ok]
|
|
55
|
+
|
|
56
|
+
def check(self, what: str, ok: bool, page: int, quote: str = "") -> None:
|
|
57
|
+
self.checks.append(Check(what, ok, page, quote))
|
|
58
|
+
|
|
59
|
+
def to_json(self) -> dict:
|
|
60
|
+
return {"form": "INTEREST CERTIFICATE", **asdict(self)}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def is_certificate(page: str) -> bool:
|
|
64
|
+
flat = " ".join(page.split()).lower()
|
|
65
|
+
return "interest" in flat and "tax deducted" in flat and bool(YEAR_ENDED.search(flat))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def read(name: str, pages: list[str], words: dict[int, list[dict]]) -> Certificate:
|
|
69
|
+
found = Certificate(name)
|
|
70
|
+
flat = " ".join(" ".join(pages).split())
|
|
71
|
+
if (m := YEAR_ENDED.search(flat)) and m.group(2)[:3].lower() in MONTHS:
|
|
72
|
+
found.year_ended = f"{int(m.group(1)):02d}-{MONTHS.index(m.group(2)[:3].lower()) + 1:02d}-{m.group(3)}"
|
|
73
|
+
else:
|
|
74
|
+
found.questions.append(Question("the financial year the certificate is for wasn't read", 1, ""))
|
|
75
|
+
totals: tuple[list[Decimal], int, str] | None = None
|
|
76
|
+
for n in sorted(words):
|
|
77
|
+
for row in _rows(words[n]):
|
|
78
|
+
texts = [w["text"] for w in row]
|
|
79
|
+
text = " ".join(texts)
|
|
80
|
+
if m := SECOND_TAX.search(text):
|
|
81
|
+
found.second_tax_label = f"{m.group(1)} TAX DEDUCTED"
|
|
82
|
+
amounts = [money(t) for t in texts if MONEY.match(t)]
|
|
83
|
+
if texts[0].upper() == "TOTAL" and amounts:
|
|
84
|
+
totals = (amounts, n, text)
|
|
85
|
+
elif ACCOUNT_NUMBER.fullmatch(texts[0]) and (account := _last_four(texts[0])):
|
|
86
|
+
if len(amounts) != 5:
|
|
87
|
+
found.questions.append(Question(
|
|
88
|
+
f"account {account}'s row doesn't read as the balance, interest paid, interest accrued and "
|
|
89
|
+
"two tax columns", n, text))
|
|
90
|
+
continue
|
|
91
|
+
# between the account and the balance: the branch code (digits), then the account's type
|
|
92
|
+
first = next(i for i, t in enumerate(texts) if MONEY.match(t))
|
|
93
|
+
kind = " ".join(t for t in texts[1:first] if not t.isdigit())
|
|
94
|
+
found.deposits.append(Deposit(account, kind, *amounts, page=n, quote=text))
|
|
95
|
+
if not found.deposits:
|
|
96
|
+
found.questions.append(Question("no deposit account's row was read", 1, ""))
|
|
97
|
+
elif totals is None:
|
|
98
|
+
found.questions.append(Question("the certificate's TOTAL row wasn't read", found.deposits[-1].page, ""))
|
|
99
|
+
else:
|
|
100
|
+
amounts, n, text = totals
|
|
101
|
+
added = [sum((getattr(d, f) for d in found.deposits), Decimal(0))
|
|
102
|
+
for f in ("paid", "accrued", "tax", "second_tax")]
|
|
103
|
+
found.check("the rows add up to the TOTAL row (interest paid, accrued and both tax columns)",
|
|
104
|
+
amounts == added, n, text)
|
|
105
|
+
return found
|
taxtrace/chat.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""Ask about your return in plain words, on the page `taxtrace ui` serves.
|
|
2
|
+
|
|
3
|
+
Three models, tried in order until one answers:
|
|
4
|
+
|
|
5
|
+
- claude: Anthropic's API (pip install 'taxtrace[chat]'), with ANTHROPIC_API_KEY or a macOS Keychain item
|
|
6
|
+
holding the key.
|
|
7
|
+
- agy: Google's Antigravity CLI, on its subscription's quota.
|
|
8
|
+
- local: an MLX model on this Mac (pip install 'taxtrace[local]').
|
|
9
|
+
|
|
10
|
+
Claude and agy only ever see masked text. Before anything is sent to them, the instructions, the workspace
|
|
11
|
+
listing and every message of the conversation go through the same masking `taxtrace redact` uses: digits
|
|
12
|
+
become #, and names, streets and towns become runs of *. A question that holds one of your values (an amount,
|
|
13
|
+
or a word from redact-terms.txt) isn't sent to them at all: the local model answers it, and the page says why.
|
|
14
|
+
|
|
15
|
+
The chat reads only what it's given and has no tools: it can't open a document, change a file or file anything.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import importlib.util
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import platform
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import tempfile
|
|
28
|
+
from collections.abc import Callable, Iterator
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from datetime import date
|
|
31
|
+
from functools import lru_cache
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
|
|
34
|
+
from . import redact, text, workspace
|
|
35
|
+
|
|
36
|
+
BACKENDS = ("claude", "agy", "local") # the default order: the strongest model first, this Mac's last
|
|
37
|
+
LABELS = {"claude": "Claude", "agy": "Antigravity (agy)", "local": "On this Mac"}
|
|
38
|
+
CLOUD = {"claude", "agy"}
|
|
39
|
+
KEYCHAIN = "anthropic" # the Keychain item (its service name) holding the Anthropic API key
|
|
40
|
+
# Where agy's installer and Homebrew put it: a launchd service's PATH has neither.
|
|
41
|
+
AGY_PLACES = ("~/.local/bin/agy", "/opt/homebrew/bin/agy", "/usr/local/bin/agy")
|
|
42
|
+
MAX_MESSAGE = 8000
|
|
43
|
+
MAX_LISTED = 200 # documents named in the instructions; the rest are counted
|
|
44
|
+
|
|
45
|
+
SYSTEM = """You are the assistant inside taxtrace, a tool that prepares the user's US federal and state income \
|
|
46
|
+
tax return from their own documents, on their own computer. Help them understand tax rules and forms, what each \
|
|
47
|
+
of their documents is for, and what to check or ask before they enter the return.
|
|
48
|
+
|
|
49
|
+
Rules:
|
|
50
|
+
- For a tax rule, name the form, line, schedule or IRS publication it comes from and the tax year it applies \
|
|
51
|
+
to. If the answer turns on facts you don't have, say which.
|
|
52
|
+
- Facts about the user come only from the data below and their messages. Never invent amounts, documents or \
|
|
53
|
+
circumstances.
|
|
54
|
+
- A judgment call (an election, a repair or an improvement, which year something belongs to) is the user's: \
|
|
55
|
+
lay out the options and what each needs, and leave the choice to them.
|
|
56
|
+
- You can't open documents, change files or file anything. Say so if asked.
|
|
57
|
+
- Earlier answers in the conversation may have come from a much smaller model on the user's computer, not \
|
|
58
|
+
from you. If one of them is wrong, say what's wrong and why, without calling it your own.
|
|
59
|
+
- {masking}
|
|
60
|
+
- Be concise and concrete: short paragraphs or bullets.
|
|
61
|
+
|
|
62
|
+
Today is {today}.
|
|
63
|
+
|
|
64
|
+
{context}"""
|
|
65
|
+
|
|
66
|
+
MASKED = ("Everything below and in the user's messages has been masked on their computer before reaching you: # "
|
|
67
|
+
"stands for a digit and a run of * for a word that could identify someone (a name, a street, a town). "
|
|
68
|
+
"Work with what's left, and never guess what was masked. If a question needs the real values, say "
|
|
69
|
+
"that the model on their Mac can answer it.")
|
|
70
|
+
UNMASKED = "The data below is the user's own and unmasked: it never leaves their computer."
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class Unavailable(RuntimeError):
|
|
74
|
+
"""A model can't answer here (not installed, no key, quota used up): the next one in the order is tried."""
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Settings:
|
|
79
|
+
order: tuple[str, ...] = BACKENDS
|
|
80
|
+
keychain: str = KEYCHAIN
|
|
81
|
+
claude_model: str = "claude-sonnet-5-5" # quick, conversational replies
|
|
82
|
+
agy_model: str = "claude-sonnet-5-5-medium" # `agy models` lists the ids
|
|
83
|
+
local_model: str = "mlx-community/Qwen3.5-9B-MLX-4bit"
|
|
84
|
+
max_tokens: int = 1500
|
|
85
|
+
max_turns: int = 24 # the most recent messages sent with each question
|
|
86
|
+
agy_timeout: float = 180.0
|
|
87
|
+
|
|
88
|
+
def model(self, backend: str) -> str:
|
|
89
|
+
return {"claude": self.claude_model, "agy": self.agy_model, "local": self.local_model}[backend]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def clean(messages, turns: int = Settings.max_turns) -> list[dict]:
|
|
93
|
+
"""The conversation from the page, its last `turns` messages: alternating user and assistant turns, ending
|
|
94
|
+
with the user's."""
|
|
95
|
+
if not isinstance(messages, list) or not messages:
|
|
96
|
+
raise ValueError("send messages: [{role, content}, ...]")
|
|
97
|
+
out: list[dict] = []
|
|
98
|
+
for m in messages[-turns:]:
|
|
99
|
+
if not (isinstance(m, dict) and m.get("role") in ("user", "assistant") and isinstance(m.get("content"), str)):
|
|
100
|
+
raise ValueError("each message needs a role (user or assistant) and text content")
|
|
101
|
+
body = m["content"].strip()[:MAX_MESSAGE]
|
|
102
|
+
if not body:
|
|
103
|
+
continue
|
|
104
|
+
if out and out[-1]["role"] == m["role"]:
|
|
105
|
+
out[-1]["content"] += "\n\n" + body
|
|
106
|
+
else:
|
|
107
|
+
out.append({"role": m["role"], "content": body})
|
|
108
|
+
while out and out[0]["role"] != "user":
|
|
109
|
+
out.pop(0)
|
|
110
|
+
if not out or out[-1]["role"] != "user":
|
|
111
|
+
raise ValueError("the last message must be the user's")
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def mask(body: str, terms: list[str]) -> str:
|
|
116
|
+
"""What a cloud model is sent in place of `body`. A year on its own is kept: in a question it's a tax year."""
|
|
117
|
+
return redact.redact(body, terms, bare_years=True).text
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def private_reason(question: str, terms: list[str]) -> str:
|
|
121
|
+
"""Why `question` stays on this Mac (it holds an amount or a configured name), or "" when masking it loses
|
|
122
|
+
nothing of yours."""
|
|
123
|
+
if redact.redact(question, terms, bare_years=True).numbers_masked:
|
|
124
|
+
return "Your question has an amount or account number in it, so the model on this Mac answers it."
|
|
125
|
+
if redact.mask_terms(question, terms) != question:
|
|
126
|
+
return "Your question names someone from redact-terms.txt, so the model on this Mac answers it."
|
|
127
|
+
return ""
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def inventory(terms: list[str], masked: bool) -> str:
|
|
131
|
+
"""The workspace's documents, folder by folder: masked for a cloud model, as they are for the local one.
|
|
132
|
+
Names only: nothing is opened, so no password is asked for."""
|
|
133
|
+
root = workspace.home()
|
|
134
|
+
docs = text.documents(workspace.sources())
|
|
135
|
+
if not docs:
|
|
136
|
+
return "## The user's documents\nNone in the workspace yet (taxtrace import adds them)."
|
|
137
|
+
lines = [f"## The user's documents ({len(docs)}, by folder)"]
|
|
138
|
+
for doc in docs[:MAX_LISTED]:
|
|
139
|
+
rel = str(doc.relative_to(root)) if doc.is_relative_to(root) else doc.name
|
|
140
|
+
lines.append("- " + (redact.redact(rel, terms, bare_years=True).text if masked else rel))
|
|
141
|
+
if len(docs) > MAX_LISTED:
|
|
142
|
+
lines.append(f"- …and {len(docs) - MAX_LISTED} more")
|
|
143
|
+
return "\n".join(lines)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def system_prompt(context: str, masked: bool, today: date | None = None) -> str:
|
|
147
|
+
return SYSTEM.format(masking=MASKED if masked else UNMASKED, today=(today or date.today()).isoformat(),
|
|
148
|
+
context=context.strip())
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def answer(messages: list[dict], settings: Settings | None = None, masked: bool = False) -> Iterator[str | dict]:
|
|
152
|
+
"""The reply in pieces as it's written, from the first model in the order that can answer. Dicts along the
|
|
153
|
+
way: {"note"} when a model is skipped and why, {"answering"} once one starts (which, its model, and for a cloud
|
|
154
|
+
model the masked question it was sent), and {"stats"} at the end.
|
|
155
|
+
|
|
156
|
+
A question holding the user's values goes to the model on this Mac, with {"offer"}: the question as a cloud
|
|
157
|
+
model would get it. The local model is far smaller, so the page offers to ask again with `masked`: the
|
|
158
|
+
question then goes down the whole order, its values masked like everything else a cloud model is sent."""
|
|
159
|
+
settings = settings or Settings()
|
|
160
|
+
terms = workspace.redact_terms()
|
|
161
|
+
question = messages[-1]["content"]
|
|
162
|
+
why_local = "" if masked else private_reason(question, terms)
|
|
163
|
+
order = ("local",) if why_local else tuple(b for b in settings.order if b in RUNNERS)
|
|
164
|
+
if why_local:
|
|
165
|
+
yield {"note": why_local}
|
|
166
|
+
if any(b in CLOUD for b in settings.order):
|
|
167
|
+
yield {"offer": mask(question, terms)}
|
|
168
|
+
skipped: list[str] = []
|
|
169
|
+
for backend in order:
|
|
170
|
+
cloud = backend in CLOUD
|
|
171
|
+
sent = [{"role": m["role"], "content": mask(m["content"], terms)} for m in messages] if cloud else messages
|
|
172
|
+
system = system_prompt(inventory(terms, masked=cloud), masked=cloud) # the user's data in it: the list
|
|
173
|
+
try:
|
|
174
|
+
pieces = RUNNERS[backend](system, sent, settings)
|
|
175
|
+
first = next(pieces, "")
|
|
176
|
+
except Exception as e: # nothing written yet, so the next model can still take the whole question
|
|
177
|
+
why = str(e) if isinstance(e, Unavailable) else f"{type(e).__name__}: {e}"
|
|
178
|
+
skipped.append(f"{LABELS[backend]}: {why}.")
|
|
179
|
+
yield {"note": f"{LABELS[backend]} couldn't answer: {why}"}
|
|
180
|
+
continue
|
|
181
|
+
yield {"answering": {"backend": backend, "label": LABELS[backend], "model": settings.model(backend),
|
|
182
|
+
"local": not cloud, **({"sent": sent[-1]["content"]} if cloud else {})}}
|
|
183
|
+
if first:
|
|
184
|
+
yield first
|
|
185
|
+
yield from pieces
|
|
186
|
+
return
|
|
187
|
+
raise Unavailable("no model could answer. " + " ".join(skipped))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# -- the models
|
|
191
|
+
|
|
192
|
+
def api_key(item: str) -> str:
|
|
193
|
+
"""The Anthropic API key: ANTHROPIC_API_KEY, or the Keychain item `item`. Kept in this process only."""
|
|
194
|
+
if key := os.environ.get("ANTHROPIC_API_KEY"):
|
|
195
|
+
return key
|
|
196
|
+
if sys.platform != "darwin" or not item:
|
|
197
|
+
raise Unavailable("set ANTHROPIC_API_KEY before taxtrace ui")
|
|
198
|
+
from . import gate
|
|
199
|
+
|
|
200
|
+
if gate.adopted(item, ""): # Apple Watch or Touch ID approves the read
|
|
201
|
+
key = gate.read(item, "", "read the Anthropic API key for the taxtrace chat") or ""
|
|
202
|
+
else:
|
|
203
|
+
r = subprocess.run(["security", "find-generic-password", "-s", item, "-w"], capture_output=True, text=True)
|
|
204
|
+
key = "" if r.returncode else r.stdout
|
|
205
|
+
if not key.strip():
|
|
206
|
+
raise Unavailable(f"no API key in the Keychain item {item!r}, or its read wasn't approved: add one with "
|
|
207
|
+
f"security add-generic-password -s {item} -a \"$USER\" -w")
|
|
208
|
+
os.environ["ANTHROPIC_API_KEY"] = key.strip()
|
|
209
|
+
return os.environ["ANTHROPIC_API_KEY"]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _claude(system: str, messages: list[dict], settings: Settings) -> Iterator[str | dict]:
|
|
213
|
+
try:
|
|
214
|
+
import anthropic
|
|
215
|
+
except ImportError:
|
|
216
|
+
raise Unavailable("needs: pip install 'taxtrace[chat]'") from None
|
|
217
|
+
client = anthropic.Anthropic(api_key=api_key(settings.keychain))
|
|
218
|
+
return _claude_pieces(client.messages.stream(model=settings.claude_model, max_tokens=settings.max_tokens,
|
|
219
|
+
system=system, messages=messages))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _claude_pieces(manager) -> Iterator[str | dict]:
|
|
223
|
+
with manager as stream: # the request goes out here: a bad key or an overload fails before any text
|
|
224
|
+
yield from stream.text_stream
|
|
225
|
+
usage = stream.get_final_message().usage
|
|
226
|
+
yield {"stats": {"input_tokens": usage.input_tokens, "output_tokens": usage.output_tokens}}
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def find_agy() -> str | None:
|
|
230
|
+
if found := shutil.which("agy"):
|
|
231
|
+
return found
|
|
232
|
+
for place in AGY_PLACES:
|
|
233
|
+
p = Path(place).expanduser()
|
|
234
|
+
if p.is_file() and os.access(p, os.X_OK):
|
|
235
|
+
return str(p)
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def transcript(system: str, messages: list[dict]) -> str:
|
|
240
|
+
"""One prompt for a model that takes no system prompt or turns: the instructions, then the conversation."""
|
|
241
|
+
turns = "\n\n".join(f"{'User' if m['role'] == 'user' else 'Assistant'}: {m['content']}" for m in messages)
|
|
242
|
+
return f"{system}\n\n---\n\n{turns}\n\nAssistant:"
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _agy(system: str, messages: list[dict], settings: Settings) -> Iterator[str | dict]:
|
|
246
|
+
exe = find_agy()
|
|
247
|
+
if not exe:
|
|
248
|
+
raise Unavailable("the Antigravity CLI (agy) isn't installed")
|
|
249
|
+
args = [exe, "-p", transcript(system, messages), "--output-format", "json", "--model", settings.agy_model,
|
|
250
|
+
"--sandbox", "--disable-slash-commands", "--print-timeout", f"{max(int(settings.agy_timeout) - 10, 10)}s"]
|
|
251
|
+
with tempfile.TemporaryDirectory(prefix="agy-") as empty: # nothing of the user's for it to read
|
|
252
|
+
try:
|
|
253
|
+
done = subprocess.run(args, cwd=empty, stdin=subprocess.DEVNULL, capture_output=True, text=True,
|
|
254
|
+
timeout=settings.agy_timeout)
|
|
255
|
+
except subprocess.TimeoutExpired:
|
|
256
|
+
raise Unavailable(f"no answer within {settings.agy_timeout:g}s") from None
|
|
257
|
+
except OSError as e:
|
|
258
|
+
raise Unavailable(f"couldn't start: {e}") from e
|
|
259
|
+
try:
|
|
260
|
+
reply = json.loads(done.stdout or "")
|
|
261
|
+
except ValueError:
|
|
262
|
+
reply = None
|
|
263
|
+
if done.returncode or not isinstance(reply, dict) or reply.get("status") != "SUCCESS":
|
|
264
|
+
detail = (done.stderr or done.stdout or "no output").strip()[:300]
|
|
265
|
+
raise Unavailable(f"exit {done.returncode}: {detail}")
|
|
266
|
+
used = reply.get("usage") or {}
|
|
267
|
+
return iter([str(reply.get("response") or "").strip(),
|
|
268
|
+
{"stats": {"input_tokens": int(used.get("input_tokens") or 0),
|
|
269
|
+
"output_tokens": int(used.get("output_tokens") or 0)}}])
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def apple_silicon() -> bool:
|
|
273
|
+
return sys.platform == "darwin" and platform.machine() == "arm64"
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
@lru_cache(maxsize=1)
|
|
277
|
+
def _local_model(name: str):
|
|
278
|
+
import mlx_lm
|
|
279
|
+
|
|
280
|
+
return mlx_lm.load(name)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _local(system: str, messages: list[dict], settings: Settings) -> Iterator[str | dict]:
|
|
284
|
+
if not apple_silicon():
|
|
285
|
+
raise Unavailable("needs a Mac with Apple Silicon")
|
|
286
|
+
try:
|
|
287
|
+
import mlx_lm # noqa: F401
|
|
288
|
+
except ImportError:
|
|
289
|
+
raise Unavailable("needs: pip install 'taxtrace[local]'") from None
|
|
290
|
+
return _local_pieces(system, messages, settings)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _local_pieces(system: str, messages: list[dict], settings: Settings) -> Iterator[str | dict]:
|
|
294
|
+
import mlx_lm
|
|
295
|
+
|
|
296
|
+
model, tokenizer = _local_model(settings.local_model)
|
|
297
|
+
prompt = tokenizer.apply_chat_template([{"role": "system", "content": system}, *messages],
|
|
298
|
+
add_generation_prompt=True, tokenize=False, enable_thinking=False)
|
|
299
|
+
last = None
|
|
300
|
+
for last in mlx_lm.stream_generate(model, tokenizer, prompt, max_tokens=settings.max_tokens):
|
|
301
|
+
if last.text:
|
|
302
|
+
yield last.text
|
|
303
|
+
if last:
|
|
304
|
+
yield {"stats": {"input_tokens": last.prompt_tokens, "output_tokens": last.generation_tokens,
|
|
305
|
+
"tokens_per_sec": round(last.generation_tps, 1)}}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
RUNNERS: dict[str, Callable[[str, list[dict], Settings], Iterator[str | dict]]] = {
|
|
309
|
+
"claude": _claude, "agy": _agy, "local": _local}
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def status(settings: Settings | None = None) -> list[dict]:
|
|
313
|
+
"""Each model in the order, whether it looks ready and why not. Only looks: no key is read, nothing is called."""
|
|
314
|
+
settings = settings or Settings()
|
|
315
|
+
out = []
|
|
316
|
+
for backend in settings.order:
|
|
317
|
+
why = ""
|
|
318
|
+
if backend == "claude":
|
|
319
|
+
if importlib_missing("anthropic"):
|
|
320
|
+
why = "needs: pip install 'taxtrace[chat]'"
|
|
321
|
+
elif not os.environ.get("ANTHROPIC_API_KEY") and not keychain_has(settings.keychain):
|
|
322
|
+
why = f"no ANTHROPIC_API_KEY, and no Keychain item {settings.keychain!r}"
|
|
323
|
+
elif backend == "agy":
|
|
324
|
+
why = "" if find_agy() else "the Antigravity CLI (agy) isn't installed"
|
|
325
|
+
elif backend == "local":
|
|
326
|
+
why = ("needs a Mac with Apple Silicon" if not apple_silicon() else
|
|
327
|
+
"needs: pip install 'taxtrace[local]'" if importlib_missing("mlx_lm") else "")
|
|
328
|
+
out.append({"backend": backend, "label": LABELS[backend], "model": settings.model(backend),
|
|
329
|
+
"cloud": backend in CLOUD, "ready": not why, "why": why})
|
|
330
|
+
return out
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def importlib_missing(module: str) -> bool:
|
|
334
|
+
try:
|
|
335
|
+
return importlib.util.find_spec(module) is None
|
|
336
|
+
except (ImportError, ValueError):
|
|
337
|
+
return True
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def keychain_has(item: str) -> bool:
|
|
341
|
+
"""Whether the Keychain holds `item`, asked without -w: its attributes only, so macOS doesn't prompt."""
|
|
342
|
+
if sys.platform != "darwin" or not item:
|
|
343
|
+
return False
|
|
344
|
+
return subprocess.run(["security", "find-generic-password", "-s", item], capture_output=True).returncode == 0
|