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.
Files changed (58) hide show
  1. taxtrace/__init__.py +3 -0
  2. taxtrace/answers.py +49 -0
  3. taxtrace/carryover.py +110 -0
  4. taxtrace/certificate.py +105 -0
  5. taxtrace/chat.py +344 -0
  6. taxtrace/cli.py +1910 -0
  7. taxtrace/data/vocab.txt +8052 -0
  8. taxtrace/data/vocab_extra.txt +64 -0
  9. taxtrace/estimates.py +192 -0
  10. taxtrace/f1040.py +95 -0
  11. taxtrace/f1098.py +245 -0
  12. taxtrace/f1099.py +204 -0
  13. taxtrace/f1116b.py +209 -0
  14. taxtrace/f8582.py +150 -0
  15. taxtrace/f8995.py +115 -0
  16. taxtrace/fbar.py +127 -0
  17. taxtrace/foreign.py +720 -0
  18. taxtrace/fx.py +153 -0
  19. taxtrace/gate.py +226 -0
  20. taxtrace/importer.py +146 -0
  21. taxtrace/ira.py +120 -0
  22. taxtrace/itr.py +279 -0
  23. taxtrace/k1.py +570 -0
  24. taxtrace/k3.py +261 -0
  25. taxtrace/macos/approval_check.swift +52 -0
  26. taxtrace/macos/keychain_gate.swift +152 -0
  27. taxtrace/mcp_server.py +128 -0
  28. taxtrace/nck1.py +191 -0
  29. taxtrace/passive.py +262 -0
  30. taxtrace/paystub.py +322 -0
  31. taxtrace/policy.py +75 -0
  32. taxtrace/prior.py +749 -0
  33. taxtrace/qbi.py +268 -0
  34. taxtrace/redact.py +103 -0
  35. taxtrace/review.py +196 -0
  36. taxtrace/schedule_e.py +341 -0
  37. taxtrace/service.py +130 -0
  38. taxtrace/statement.py +525 -0
  39. taxtrace/static/apple-touch-icon.png +0 -0
  40. taxtrace/static/icon-192.png +0 -0
  41. taxtrace/static/icon-512.png +0 -0
  42. taxtrace/static/icon-app.svg +33 -0
  43. taxtrace/static/icon-maskable-512.png +0 -0
  44. taxtrace/static/icon-maskable.svg +24 -0
  45. taxtrace/static/icon.svg +18 -0
  46. taxtrace/static/index.html +573 -0
  47. taxtrace/static/manifest.webmanifest +17 -0
  48. taxtrace/tds.py +134 -0
  49. taxtrace/text.py +285 -0
  50. taxtrace/todo.py +171 -0
  51. taxtrace/vul.py +115 -0
  52. taxtrace/web.py +180 -0
  53. taxtrace/workspace.py +136 -0
  54. taxtrace-0.1.0.dist-info/METADATA +804 -0
  55. taxtrace-0.1.0.dist-info/RECORD +58 -0
  56. taxtrace-0.1.0.dist-info/WHEEL +4 -0
  57. taxtrace-0.1.0.dist-info/entry_points.txt +3 -0
  58. taxtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
taxtrace/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Prepare a US federal + state return from your own documents, every number traced to the page it came from."""
2
+
3
+ __version__ = "0.1.0"
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
@@ -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