Compare commits
77 Commits
59dbaf8a6c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| b9878e938c | |||
| b2e97a3036 | |||
| 809a3955e5 | |||
| fb868b9e5f | |||
| e2657f4850 | |||
| c30ad85e0d | |||
| ba5b0e4e67 | |||
| 9c7103770a | |||
| 57f6b132b2 | |||
| b3a59b5b45 | |||
| a796481875 | |||
| 8d2c162849 | |||
| 4919edcec1 | |||
| 9f3d080471 | |||
| efbdb25937 | |||
| b77134ced5 | |||
| ece8fc548f | |||
| 446d43cc78 | |||
| 54854edfc6 | |||
| 3335e96d35 | |||
| a7c318aca8 | |||
| 9caa8fa4f5 | |||
| c3e5f669ed | |||
| 5cffd0edf9 | |||
| 8fe451e8d0 | |||
| 7690dc259b | |||
| b1722a04fa | |||
| a88d960ec9 | |||
| d2d43df24d | |||
| 3d7ce7617c | |||
| 2deeb31369 | |||
| 33466079a3 | |||
| be4f829704 | |||
| 761bd2f083 | |||
| c68523386d | |||
| 7570f748f0 | |||
| 1f23eac52c | |||
| f8c998d579 | |||
| 301986fb25 | |||
| c58a2a43c8 | |||
| e6d987d725 | |||
| 954374ce48 | |||
| 19adb919fc | |||
| eeea0c8ef1 | |||
| e7c56fe760 | |||
| 35d3fede49 | |||
| f82980c4ed | |||
| 2c5d31f85e | |||
| 02cc5f8e7e | |||
| 8ec0237309 | |||
| 93247a5954 | |||
| c2ae79ccf0 | |||
| 90635bf8f2 | |||
| b13f272e48 | |||
| 075f5bd9a7 | |||
|
|
b2bb07fa90 | ||
|
|
b4f9666560 | ||
|
|
f219672a2e | ||
|
|
12838f7449 | ||
|
|
31ed267027 | ||
|
|
850e89d339 | ||
|
|
b520bdc998 | ||
| 7187ba9ea3 | |||
|
|
fdfc9f0996 | ||
|
|
9f650faf88 | ||
|
|
1be0317192 | ||
|
|
fb39fe76cb | ||
|
|
6c02e0b7c6 | ||
|
|
d3fd874330 | ||
|
|
10a9d40f1d | ||
|
|
82cf3d9010 | ||
|
|
decf3132d5 | ||
|
|
c3c0d85908 | ||
|
|
8f797b3a51 | ||
|
|
faff555757 | ||
|
|
0c2e34f2f0 | ||
|
|
291b729894 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1 +1,2 @@
|
||||
.worktrees/
|
||||
node_modules/
|
||||
|
||||
13
README.md
13
README.md
@@ -17,18 +17,27 @@ This repository contains practical OpenClaw skills and companion integrations. I
|
||||
| Skill | What it does | Path |
|
||||
|---|---|---|
|
||||
| `elevenlabs-stt` | Transcribe local audio files with ElevenLabs Speech-to-Text, with diarization, language hints, event tags, and JSON output. | `skills/elevenlabs-stt` |
|
||||
| `flight-finder` | Collect structured flight-search inputs, run bounded multi-source travel searches, rank results in USD, and gate fixed-template PDF/email delivery through Luke’s sender path. | `skills/flight-finder` |
|
||||
| `gitea-api` | Interact with Gitea via REST API (repos, issues, PRs, releases, branches, user info). | `skills/gitea-api` |
|
||||
| `nordvpn-client` | Install, log in to, connect, disconnect, and verify NordVPN sessions across Linux CLI and macOS NordLynx/WireGuard backends. | `skills/nordvpn-client` |
|
||||
| `portainer` | Manage Portainer stacks via API (list, start/stop/restart, update, prune images). | `skills/portainer` |
|
||||
| `property-assessor` | Assess a residential property from an address or listing URL with CAD/public-record enrichment, Zillow-first/HAR-fallback photo review, carry-cost/risk analysis, and fixed-template PDF output. | `skills/property-assessor` |
|
||||
| `searxng` | Search through a local or self-hosted SearXNG instance for web, news, images, and more. | `skills/searxng` |
|
||||
| `web-automation` | One-shot extraction plus broader browsing/scraping with Playwright-compatible CloakBrowser (auth flows, extraction, bot-protected sites). | `skills/web-automation` |
|
||||
| `us-cpa` | Federal individual 1040 workflow for tax questions, case intake, preparation, review, and draft e-file-ready export. | `skills/us-cpa` |
|
||||
| `web-automation` | One-shot extraction plus broader browsing/scraping with CloakBrowser, including unit-aware Zillow/HAR discovery and dedicated listing-photo extractors. | `skills/web-automation` |
|
||||
|
||||
## Integrations
|
||||
|
||||
| Integration | What it does | Path |
|
||||
|---|---|---|
|
||||
| `google-maps` | Traffic-aware ETA and leave-by calculations using Google Maps APIs. | `integrations/google-maps` |
|
||||
| `google-workspace` | Gmail and Google Calendar helper CLI for profile, mail, calendar search, and event creation. | `integrations/google-workspace` |
|
||||
| `google-workspace` | Gmail and Google Calendar helper CLI for profile, mail, attachment-capable send, calendar search, and event creation. | `integrations/google-workspace` |
|
||||
|
||||
## Operator docs
|
||||
|
||||
| Doc | What it covers |
|
||||
|---|---|
|
||||
| `docs/openclaw-acp-orchestration.md` | ACP orchestration setup for Codex and Claude Code on the gateway host |
|
||||
|
||||
## Install ideas
|
||||
|
||||
|
||||
@@ -5,13 +5,20 @@ This folder contains detailed docs for each skill in this repository.
|
||||
## Skills
|
||||
|
||||
- [`elevenlabs-stt`](elevenlabs-stt.md) — Local audio transcription through ElevenLabs Speech-to-Text
|
||||
- [`flight-finder`](flight-finder.md) — Typed flight-search intake, bounded source orchestration, PDF report rendering, and Luke-sender email delivery
|
||||
- [`gitea-api`](gitea-api.md) — REST-based Gitea automation (no `tea` CLI required)
|
||||
- [`nordvpn-client`](nordvpn-client.md) — Cross-platform NordVPN install, login, connect, disconnect, and verification with Linux CLI and macOS NordLynx/WireGuard support
|
||||
- [`portainer`](portainer.md) — Portainer stack management (list, lifecycle, updates, image pruning)
|
||||
- [`property-assessor`](property-assessor.md) — Residential property assessment with CAD/public-record enrichment, Zillow/HAR photo review, valuation workflow, and PDF delivery rules
|
||||
- [`searxng`](searxng.md) — Privacy-respecting metasearch via a local or self-hosted SearXNG instance
|
||||
- [`web-automation`](web-automation.md) — One-shot extraction plus Playwright-compatible CloakBrowser browser automation and scraping
|
||||
- [`us-cpa`](us-cpa.md) — Federal individual 1040 workflow for tax questions, case intake, preparation, review, and draft e-file-ready export
|
||||
- [`web-automation`](web-automation.md) — One-shot extraction plus CloakBrowser automation, including unit-aware Zillow/HAR discovery and dedicated photo extraction
|
||||
|
||||
## Integrations
|
||||
|
||||
- [`google-maps`](google-maps.md) — Traffic-aware ETA and leave-by calculations via Google Maps APIs
|
||||
- [`google-workspace`](google-workspace.md) — Gmail and Google Calendar helper CLI
|
||||
- [`google-workspace`](google-workspace.md) — Gmail and Google Calendar helper CLI with attachment-capable send support
|
||||
|
||||
## Operator Docs
|
||||
|
||||
- [`openclaw-acp-orchestration`](openclaw-acp-orchestration.md) — OpenClaw ACP orchestration for Codex and Claude Code on the gateway host
|
||||
|
||||
77
docs/flight-finder.md
Normal file
77
docs/flight-finder.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# flight-finder
|
||||
|
||||
Reusable flight-search report skill for OpenClaw. It replaces the brittle one-off `dfw-blq-2026.md` prompt with typed intake, bounded source orchestration, explicit report gates, fixed-template PDF output, and Luke-sender email delivery.
|
||||
|
||||
## Core behavior
|
||||
|
||||
`flight-finder` is designed to:
|
||||
|
||||
- collect missing trip inputs explicitly instead of relying on hardcoded prompt prose
|
||||
- support split returns, flexible dates, passenger groups, and exclusions
|
||||
- use bounded search phases across KAYAK, Skyscanner, Expedia, and a best-effort airline direct cross-check
|
||||
- normalize pricing to USD before ranking
|
||||
- produce a report payload first, then render PDF/email only when the report is complete
|
||||
- render booking links in the PDF for each recommended fare, including airline-direct links when they were actually captured
|
||||
- behave safely on WhatsApp-style chat surfaces by treating status nudges as updates, not resets
|
||||
- require a fresh bounded search run for every report instead of reusing earlier captures
|
||||
|
||||
## Important rules
|
||||
|
||||
- Recipient email is a delivery gate, not a search gate.
|
||||
- Cached flight-search data is forbidden as primary evidence for a new run.
|
||||
- same-day workspace captures must not be reused
|
||||
- previously rendered PDFs must not be treated as fresh search output
|
||||
- if fresh search fails, the skill must degrade honestly instead of recycling earlier artifacts
|
||||
- `marketCountry` is explicit-only in this implementation pass.
|
||||
- It must be an ISO 3166-1 alpha-2 uppercase code such as `TH` or `DE`.
|
||||
- If present, it activates VPN only for the bounded search phase.
|
||||
- If omitted, no VPN change happens.
|
||||
- If VPN connect or verification fails, the run falls back to the default market and records a degraded warning instead of hanging.
|
||||
- Direct-airline cross-checking is currently best-effort, not a hard blocker.
|
||||
- The PDF should show:
|
||||
- the captured booking/search link for every quoted fare
|
||||
- the airline-direct link only when `directBookingUrl` was actually captured
|
||||
- an explicit “not captured in this run” note when direct-airline booking was unavailable
|
||||
|
||||
## DFW ↔ BLQ daily automation
|
||||
|
||||
- The 6 AM DFW ↔ BLQ automation should invoke `flight-finder` directly with the structured JSON payload.
|
||||
- The legacy file at `workspace/docs/prompts/dfw-blq-2026.md` should mirror that exact direct skill invocation, not old prose instructions.
|
||||
|
||||
## Helper package
|
||||
|
||||
From `skills/flight-finder/`:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run normalize-request -- --legacy-dfw-blq
|
||||
npm run normalize-request -- --input "<request.json>"
|
||||
npm run report-status -- --input "<report-payload.json>"
|
||||
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
|
||||
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
|
||||
```
|
||||
|
||||
Expected report-payload provenance fields:
|
||||
|
||||
- `searchExecution.freshSearch: true`
|
||||
- `searchExecution.startedAt`
|
||||
- `searchExecution.completedAt`
|
||||
- `searchExecution.artifactsRoot`
|
||||
|
||||
## Delivery
|
||||
|
||||
- sender identity: `luke@fiorinis.com`
|
||||
- send path:
|
||||
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
|
||||
- if the user already provided the destination email, that counts as delivery authorization once the report is ready
|
||||
|
||||
## Source viability for implementation pass 1
|
||||
|
||||
Bounded checks on Stefano's MacBook Air showed:
|
||||
|
||||
- `KAYAK`: viable
|
||||
- `Skyscanner`: viable
|
||||
- `Expedia`: viable
|
||||
- airline direct-booking cross-check: degraded / best-effort
|
||||
|
||||
That means the first implementation pass should rely primarily on the three aggregator sources and treat direct-airline confirmation as additive evidence when it succeeds.
|
||||
@@ -41,7 +41,14 @@ Optional env:
|
||||
```bash
|
||||
node integrations/google-workspace/gw.js whoami
|
||||
node integrations/google-workspace/gw.js send --to "user@example.com" --subject "Hello" --body "Hi there"
|
||||
node integrations/google-workspace/gw.js send --to "user@example.com" --subject "Report" --body "Attached is the PDF." --attach /tmp/report.pdf
|
||||
node integrations/google-workspace/gw.js search-mail --query "from:someone@example.com newer_than:7d" --max 10
|
||||
node integrations/google-workspace/gw.js search-calendar --timeMin 2026-03-17T00:00:00-05:00 --timeMax 2026-03-18T00:00:00-05:00 --max 20
|
||||
node integrations/google-workspace/gw.js create-event --summary "Meeting" --start 2026-03-20T09:00:00-05:00 --end 2026-03-20T10:00:00-05:00
|
||||
```
|
||||
|
||||
## Attachments
|
||||
|
||||
- `send` now supports one or more `--attach /path/to/file` arguments.
|
||||
- Attachment content type is inferred from the filename extension; PDF attachments are sent as `application/pdf`.
|
||||
- The default impersonation remains `stefano@fiorinis.com`, so this is the correct helper for actions explicitly performed on Stefano's behalf.
|
||||
|
||||
@@ -69,13 +69,19 @@ Current macOS backend:
|
||||
- NordLynx/WireGuard
|
||||
- `wireguard-go`
|
||||
- `wireguard-tools`
|
||||
- NordVPN DNS in the generated WireGuard config:
|
||||
- explicit macOS DNS management on eligible physical services:
|
||||
- `103.86.96.100`
|
||||
- `103.86.99.100`
|
||||
|
||||
Important behavior:
|
||||
|
||||
- `NordVPN.app` may remain installed, but the automated backend does not reuse app login state.
|
||||
- the generated WireGuard config intentionally stays free of `DNS = ...` so `wg-quick` does not rewrite every macOS network service behind the skill’s back.
|
||||
- during `connect`, the skill first proves the tunnel is stable with a bounded persistence gate that reuses the allowed helper `probe` action and a verified public exit.
|
||||
- during `connect`, the skill snapshots current DNS/search-domain settings on eligible physical services and then applies NordVPN DNS only after that stable gate, one last liveness check, and a post-DNS system-hostname-resolution check succeed.
|
||||
- during `disconnect`, or after a failed/stale teardown, the skill restores the saved DNS/search-domain snapshot.
|
||||
- if persistence, exit verification, or post-DNS hostname resolution fails, the skill rolls back before treating the connect as successful and resumes Tailscale if it stopped it.
|
||||
- when the skill intentionally stops Tailscale for a VPN session, it writes a short-lived suppression marker so host watchdogs do not immediately run `tailscale up` and fight the VPN route change.
|
||||
- The skill automatically suspends Tailscale before connect if Tailscale is active.
|
||||
- The skill resumes Tailscale after disconnect, or after a failed connect, if it stopped it.
|
||||
- The Homebrew NordVPN app does not need to be uninstalled.
|
||||
@@ -144,6 +150,8 @@ Add this exact rule:
|
||||
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
|
||||
```
|
||||
|
||||
Do not add extra helper actions just for persistence checks unless you are also updating host sudoers. The current implementation intentionally rides the persistence check on `probe` so the existing `probe/up/down` rule remains sufficient.
|
||||
|
||||
If you run the repo copy directly instead of the installed OpenClaw skill, adjust the helper path accordingly.
|
||||
|
||||
## Common Flows
|
||||
@@ -188,7 +196,9 @@ Expected macOS behavior:
|
||||
- stop Tailscale if active
|
||||
- select a NordVPN server for the target
|
||||
- bring up the WireGuard tunnel
|
||||
- prove persistence of the live `utun*` runtime via the helper `probe` path
|
||||
- verify the public exit location
|
||||
- run one final liveness check before applying NordVPN DNS
|
||||
- return JSON describing the chosen server and final verified location
|
||||
|
||||
### Verify
|
||||
@@ -209,6 +219,7 @@ Expected macOS behavior:
|
||||
|
||||
- attempt `wg-quick down` whenever there is active or residual NordVPN WireGuard state
|
||||
- remove stale local NordVPN state files after teardown
|
||||
- restore automatic DNS when the saved DNS snapshot is obviously just NordVPN-pinned leftovers
|
||||
- resume Tailscale if the skill had suspended it
|
||||
|
||||
## Output Model
|
||||
@@ -238,7 +249,9 @@ For deeper troubleshooting, use:
|
||||
node skills/nordvpn-client/scripts/nordvpn-client.js status --debug
|
||||
```
|
||||
|
||||
`--debug` keeps the internal local paths and other low-level metadata in the JSON output.
|
||||
`--debug` keeps the internal local paths, helper-hardening diagnostics, and other low-level metadata in the JSON output.
|
||||
|
||||
If you also run local watchdogs such as `healthwatch.sh`, they should honor the NordVPN Tailscale suppression marker at `~/.nordvpn-client/tailscale-suppressed` and skip automatic `tailscale up` while the marker is fresh or the NordVPN WireGuard tunnel is active.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
375
docs/openclaw-acp-orchestration.md
Normal file
375
docs/openclaw-acp-orchestration.md
Normal file
@@ -0,0 +1,375 @@
|
||||
# OpenClaw ACP Orchestration
|
||||
|
||||
This document describes the local OpenClaw ACP setup used to orchestrate Codex and Claude Code from an OpenClaw agent on the gateway machine.
|
||||
|
||||
## Scope
|
||||
|
||||
The target workflow is:
|
||||
|
||||
- OpenClaw remains the orchestration brain
|
||||
- natural-language requests like `use codex for this` or `run this in claude code` are routed to ACP
|
||||
- the coding harness runs on the same gateway machine where the local `codex` and `claude` clients are installed
|
||||
- session lifecycle is handled through OpenClaw ACP rather than sub-agents or shell relay hacks
|
||||
|
||||
## Local Baseline Before ACP Enablement
|
||||
|
||||
Captured on 2026-03-29:
|
||||
|
||||
- OpenClaw: `2026.3.28 (f9b1079)`
|
||||
- bundled `acpx` plugin present locally but disabled and not in the plugin allowlist
|
||||
- local `codex`: `/opt/homebrew/bin/codex` `0.117.0`
|
||||
- local `claude`: `/opt/homebrew/bin/claude` `2.1.87`
|
||||
- gateway host: 8 CPU cores, 8 GB RAM
|
||||
- default OpenClaw agent workspace: `~/.openclaw/workspace`
|
||||
|
||||
## Architectural Decision
|
||||
|
||||
Primary architecture:
|
||||
|
||||
- OpenClaw ACP with `acpx`
|
||||
|
||||
Fallback architecture only if parity is not acceptable:
|
||||
|
||||
- `openclaw mcp serve` with Codex or Claude Code connected as external MCP clients to existing OpenClaw channel conversations
|
||||
|
||||
Why ACP is primary:
|
||||
|
||||
- this is the official OpenClaw architecture for "run this in Codex" / "start Claude Code in a thread"
|
||||
- it gives durable ACP sessions, resume, bindings, and programmatic `sessions_spawn runtime:"acp"`
|
||||
|
||||
## Important Runtime Caveat
|
||||
|
||||
The bundled `acpx` runtime supports Codex and Claude, but the stock aliases are adapter commands, not necessarily the bare local terminal binaries:
|
||||
|
||||
- `codex -> npx -y @zed-industries/codex-acp@0.9.5`
|
||||
- `claude -> npx -y @zed-industries/claude-agent-acp@0.21.0`
|
||||
|
||||
That means "same as terminal" behavior has to be validated explicitly. It is not guaranteed just because ACP works.
|
||||
|
||||
## Baseline Configuration Applied
|
||||
|
||||
The current host-local OpenClaw config keeps the native `main` orchestrator and adds ACP-backed agents alongside it:
|
||||
|
||||
- `agents.list[0] = main` with `runtime.type = "embedded"`
|
||||
- `agents.list[1] = codex` with `runtime.type = "acp"`
|
||||
- `agents.list[2] = claude` with `runtime.type = "acp"`
|
||||
- `acp.enabled = true`
|
||||
- `acp.dispatch.enabled = true`
|
||||
- `acp.backend = "acpx"`
|
||||
- `acp.defaultAgent = "codex"`
|
||||
- `acp.allowedAgents = ["claude", "codex"]`
|
||||
- `acp.maxConcurrentSessions = 2`
|
||||
- `plugins.allow += acpx`
|
||||
- `plugins.entries.acpx.enabled = true`
|
||||
- ACP-specific `cwd` values are absolute paths, not `~`-prefixed shortcuts
|
||||
|
||||
The `main` entry is intentional. Once `agents.list` is populated, OpenClaw treats that list as the agent inventory. If `main` is omitted, ACP targets can displace the native orchestrator and break the intended architecture.
|
||||
|
||||
## ACP Health Equivalents
|
||||
|
||||
The docs mention `/acp doctor`, but the operator-friendly local equivalents on this host are:
|
||||
|
||||
- `openclaw config validate`
|
||||
- `openclaw plugins inspect acpx --json`
|
||||
- `openclaw gateway status --json`
|
||||
- `openclaw status --deep`
|
||||
- `cd /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx && ./node_modules/.bin/acpx config show`
|
||||
|
||||
Healthy baseline on this machine means:
|
||||
|
||||
- config validates
|
||||
- `acpx` plugin status is `loaded`
|
||||
- gateway RPC is healthy
|
||||
- `openclaw status --deep` shows `Agents 3` with `default main`
|
||||
- `acpx config show` works without bootstrap errors
|
||||
- `plugins.installs` does not need an `acpx` record because `acpx` is bundled with OpenClaw, not separately installed
|
||||
|
||||
Important health nuance:
|
||||
|
||||
- `openclaw plugins inspect acpx --json` only tells you the plugin is loaded, not that the ACP backend is healthy enough for `sessions_spawn runtime:"acp"`
|
||||
- the actual readiness signal is the gateway log line `acpx runtime backend ready`
|
||||
- during rollout, the backend stayed unavailable until ACP-specific `cwd` values were changed from `~/.openclaw/workspace` to absolute paths
|
||||
- a later startup bug showed that pinning a custom `command` path disables the plugin-local managed install path and can leave ACP unavailable after a restart if the local `acpx` artifact is absent at boot
|
||||
- the current host fix is to leave `plugins.entries.acpx.config.command` unset so the bundled plugin can manage its own plugin-local `acpx` binary
|
||||
|
||||
Maintenance note:
|
||||
|
||||
- the current host intentionally uses the managed plugin-local default command path rather than a custom override
|
||||
- after any OpenClaw upgrade, re-run:
|
||||
- `openclaw config validate`
|
||||
- `openclaw plugins inspect acpx --json`
|
||||
- `openclaw logs --limit 80 --plain --timeout 10000 | rg 'acpx runtime backend (registered|ready|probe failed)'`
|
||||
- `ls -l /opt/homebrew/lib/node_modules/openclaw/dist/extensions/acpx/node_modules/.bin/acpx`
|
||||
- if ACP comes up unavailable at startup, check whether a custom `plugins.entries.acpx.config.command` override was reintroduced before debugging deeper
|
||||
|
||||
## Security Review
|
||||
|
||||
### Why this needs a review
|
||||
|
||||
ACP coding sessions are headless and non-interactive. If they are allowed to write files and run shell commands, the permission mode matters a lot.
|
||||
|
||||
### Leading rollout candidate
|
||||
|
||||
- `plugins.entries.acpx.config.permissionMode = "approve-all"`
|
||||
- `plugins.entries.acpx.config.nonInteractivePermissions = "deny"`
|
||||
|
||||
Why `deny` instead of `fail`:
|
||||
|
||||
- on this host, graceful degradation is better than crashing an otherwise useful ACP session at the first blocked headless permission prompt
|
||||
- the live `acpx` plugin schema for OpenClaw `2026.3.28` validates `deny`, so this is an intentional runtime choice rather than a placeholder
|
||||
|
||||
### What `approve-all` means here
|
||||
|
||||
On this gateway host, an ACP coding harness may:
|
||||
|
||||
- write files in the configured working tree
|
||||
- execute shell commands without an interactive prompt
|
||||
- access network resources that are already reachable from the host
|
||||
- read local home-directory configuration that the launched harness itself can reach
|
||||
|
||||
### Risk boundaries
|
||||
|
||||
This host already runs OpenClaw with:
|
||||
|
||||
- `tools.exec.host = "gateway"`
|
||||
- `tools.exec.security = "full"`
|
||||
- `tools.exec.ask = "off"`
|
||||
|
||||
So ACP `approve-all` does not create the first fully trusted execution path on this machine. It extends that trust to ACP-backed Codex/Claude sessions. That is still a meaningful trust expansion and should stay limited to trusted operators and trusted channels.
|
||||
|
||||
### First-wave rollout stance
|
||||
|
||||
Recommended first wave:
|
||||
|
||||
- enable ACP only for trusted direct operators
|
||||
- prefer explicit `agentId` routing and minimal bindings
|
||||
- defer broad persistent group bindings until parity and lifecycle behavior are proven
|
||||
- keep the plugin-tools bridge off unless there is a proven need for ACP harnesses to call OpenClaw plugin tools from inside the session
|
||||
|
||||
## Observability And Recovery
|
||||
|
||||
Minimum required operational checks:
|
||||
|
||||
- `openclaw config validate`
|
||||
- `openclaw plugins inspect acpx --json`
|
||||
- `openclaw gateway status --json`
|
||||
- `openclaw status --deep`
|
||||
- `openclaw logs --follow`
|
||||
- `/tmp/openclaw/openclaw-YYYY-MM-DD.log`
|
||||
|
||||
Operational questions this setup must answer:
|
||||
|
||||
- did an ACP session start
|
||||
- which harness was used
|
||||
- which session key is active
|
||||
- where a stall or permission denial first occurred
|
||||
- whether the gateway restart preserved resumable state
|
||||
|
||||
Current host signals:
|
||||
|
||||
- plugin status: `openclaw plugins inspect acpx --json`
|
||||
- gateway/runtime health: `openclaw gateway status --json`
|
||||
- agent inventory and active session count: `openclaw status --deep`
|
||||
- ACP adapter defaults and override file discovery: `acpx config show`
|
||||
- first runtime failure point: gateway log under `/tmp/openclaw/`
|
||||
|
||||
Claude adapter noise:
|
||||
|
||||
- the Claude ACP adapter currently emits `session/update` validation noise for `usage_update` after otherwise successful turns
|
||||
- when filtering logs during Claude ACP troubleshooting, separate that known noise from startup failures by focusing first on:
|
||||
- `acpx runtime backend ready`
|
||||
- `ACP runtime backend is currently unavailable`
|
||||
- `probe failed`
|
||||
- actual session spawn/close lines
|
||||
|
||||
## Concurrency Stance
|
||||
|
||||
This machine has 8 CPU cores and 8 GB RAM. A conservative initial ACP concurrency cap is better than the plan's generic placeholder of `8`.
|
||||
|
||||
Recommended initial cap:
|
||||
|
||||
- `acp.maxConcurrentSessions = 2`
|
||||
|
||||
Reason:
|
||||
|
||||
- enough for one Codex and one Claude session at the same time
|
||||
- low enough to reduce memory pressure and noisy contention on the same laptop-class host
|
||||
- if operators start using longer-lived persistent ACP sessions heavily, revisit this only after checking real memory pressure and swap behavior on the gateway host
|
||||
|
||||
## Plugin Tools Bridge
|
||||
|
||||
The planning material discussed `plugins.entries.acpx.config.pluginToolsMcpBridge`, but the local `2026.3.28` bundled `acpx` schema does not currently expose that key in `openclaw plugins inspect acpx --json`.
|
||||
|
||||
Current stance:
|
||||
|
||||
- treat plugin-tools bridge as unsupported unless the live runtime proves otherwise
|
||||
- do not add that key blindly to `openclaw.json`
|
||||
|
||||
## Default Workspace Root
|
||||
|
||||
The default ACP workspace root for this install is:
|
||||
|
||||
- `~/.openclaw/workspace`
|
||||
|
||||
Per-session or per-binding `cwd` values can narrow from there when a specific repository or skill workspace is known.
|
||||
|
||||
For ACP plugin/runtime config, use absolute paths instead of `~`-prefixed paths.
|
||||
|
||||
## Parity Results
|
||||
|
||||
### Codex ACP parity
|
||||
|
||||
Validated directly with `acpx codex` against a real project worktree.
|
||||
|
||||
Observed:
|
||||
|
||||
- correct `cwd`
|
||||
- `HOME=/Users/stefano`
|
||||
- access to `~/.codex`
|
||||
- access to `~/.openclaw/workspace`
|
||||
- access to installed Codex skills under `~/.codex/skills`
|
||||
- persistent named sessions retained state across turns
|
||||
- persistent named sessions retained state across an OpenClaw gateway restart
|
||||
|
||||
Assessment:
|
||||
|
||||
- Codex ACP is close enough to local terminal behavior for rollout
|
||||
|
||||
### Claude Code ACP parity
|
||||
|
||||
Validated directly with `acpx claude` against the same project worktree.
|
||||
|
||||
Observed:
|
||||
|
||||
- correct `cwd`
|
||||
- `HOME=/Users/stefano`
|
||||
- access to `~/.claude`
|
||||
- access to `~/.codex` when explicitly tested with shell commands
|
||||
- persistent named sessions retained state across turns
|
||||
- persistent named sessions retained state across an OpenClaw gateway restart
|
||||
|
||||
Known defect:
|
||||
|
||||
- the Claude ACP adapter emits an extra `session/update` validation error after otherwise successful turns:
|
||||
- `Invalid params`
|
||||
- `sessionUpdate: 'usage_update'`
|
||||
|
||||
Assessment:
|
||||
|
||||
- Claude ACP is usable, but noisier than Codex
|
||||
- this is an adapter/protocol mismatch to monitor, not a rollout blocker for trusted operators
|
||||
|
||||
## ACPX Override Decision
|
||||
|
||||
Decision:
|
||||
|
||||
- do **not** add `~/.acpx/config.json` agent overrides for Codex or Claude right now
|
||||
|
||||
Why:
|
||||
|
||||
- Codex parity already passes with the stock alias path
|
||||
- swapping Claude from the deprecated package name to `@agentclientprotocol/claude-agent-acp@0.24.2` did **not** remove the `session/update` validation noise
|
||||
- raw local `codex` and `claude` CLIs are not drop-in ACP servers, so an override would add maintenance cost without delivering materially better parity
|
||||
|
||||
## Natural-Language Routing Policy
|
||||
|
||||
The `main` agent is instructed to:
|
||||
|
||||
- stay native as the orchestrator
|
||||
- use `sessions_spawn` with `runtime: "acp"` when the user explicitly asks for Codex or Claude Code
|
||||
- choose `agentId: "codex"` or `agentId: "claude"` accordingly
|
||||
- use one-shot ACP runs for single tasks
|
||||
- use persistent ACP sessions only when the user clearly wants continued context
|
||||
- avoid silent fallback to ordinary local exec when ACP was explicitly requested
|
||||
|
||||
The live messaging tool surface had to be extended to expose:
|
||||
|
||||
- `sessions_spawn`
|
||||
- `sessions_yield`
|
||||
|
||||
without widening the whole profile beyond what was needed.
|
||||
|
||||
## Binding Policy
|
||||
|
||||
First-wave binding policy is intentionally conservative:
|
||||
|
||||
- no broad top-level persistent `bindings[]`
|
||||
- no automatic permanent channel/topic binds
|
||||
- prefer on-demand ACP spawn from the current conversation
|
||||
- only introduce persistent binds later if there is a clear operator need
|
||||
|
||||
Channel-specific note:
|
||||
|
||||
- WhatsApp does not support ACP thread-bound spawn in the tested path
|
||||
- use current-conversation or one-shot ACP behavior there, not thread-bound ACP assumptions
|
||||
|
||||
## Smoke-Test Findings
|
||||
|
||||
What worked:
|
||||
|
||||
- direct `acpx codex` runs
|
||||
- direct `acpx claude` runs
|
||||
- mixed Codex + Claude ACPX runs in parallel
|
||||
- persistent ACPX named sessions
|
||||
- named-session recall after a gateway restart
|
||||
|
||||
What failed and why:
|
||||
|
||||
- channel-less CLI-driven `openclaw agent` tests can fail ACP spawn with:
|
||||
- `Channel is required when multiple channels are configured: telegram, whatsapp, bluebubbles`
|
||||
- this is a context issue, not a backend-registration issue
|
||||
- synthetic CLI sessions are not a perfect substitute for a real inbound channel conversation when testing current-conversation ACP spawn
|
||||
|
||||
Operational interpretation:
|
||||
|
||||
- ACP backend + harness parity are good enough for rollout
|
||||
- final operator confidence should still come from a real inbound Telegram or WhatsApp conversation, not only a synthetic CLI turn
|
||||
|
||||
## Fallback Decision
|
||||
|
||||
Decision:
|
||||
|
||||
- keep ACP via `acpx` as the primary architecture
|
||||
- do **not** adopt `openclaw mcp serve` as the primary mode at this stage
|
||||
|
||||
Why fallback was not adopted:
|
||||
|
||||
- Codex parity is good
|
||||
- Claude parity is acceptable with one known noisy adapter defect
|
||||
- OpenClaw can now expose the ACP spawn tool in the messaging profile
|
||||
- the remaining limitation is real channel context for current-conversation spawn, not a fundamental mismatch between ACP and the installed gateway clients
|
||||
|
||||
## Rollback
|
||||
|
||||
Back up `~/.openclaw/openclaw.json` before any ACP change.
|
||||
|
||||
Current ACP implementation backup:
|
||||
|
||||
- `~/.openclaw/openclaw.json.bak.pre-acp-implementation-20260329-231818`
|
||||
|
||||
Rollback approach:
|
||||
|
||||
1. restore the backup config
|
||||
2. validate config
|
||||
3. restart the gateway
|
||||
4. confirm ACP plugin status and channel health
|
||||
|
||||
Example rollback:
|
||||
|
||||
```bash
|
||||
cp ~/.openclaw/openclaw.json.bak.pre-acp-implementation-20260329-231818 ~/.openclaw/openclaw.json
|
||||
openclaw config validate
|
||||
openclaw gateway restart
|
||||
openclaw status --deep
|
||||
```
|
||||
|
||||
## Implementation Hazards
|
||||
|
||||
Two local quirks were discovered during rollout:
|
||||
|
||||
- `openclaw config set` is not safe for parallel writes to the same config file. Concurrent `config set` calls can clobber each other.
|
||||
- host-local legacy keys can reappear if a write path round-trips older config state. For this rollout, atomic file edits plus explicit validation were safer than chaining many `config set` commands.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
This document is updated milestone by milestone as the ACP rollout is implemented and verified.
|
||||
180
docs/plans/2026-03-28-property-assessor-whatsapp-safe-runtime.md
Normal file
180
docs/plans/2026-03-28-property-assessor-whatsapp-safe-runtime.md
Normal file
@@ -0,0 +1,180 @@
|
||||
# Property Assessor WhatsApp-Safe Runtime Implementation Plan
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Keep WhatsApp property-assessor runs moving by failing fast on silent discovery/photo hangs, avoiding helper subprocesses during core analysis, and reserving subprocess use to the final PDF render attempt only.
|
||||
|
||||
**Architecture:** Add small timeout guards around the existing in-process listing discovery and photo extraction calls so one quiet browser-backed task cannot stall the entire assessment. Tighten the live skill and published docs so messaging runs treat chat-native source collection as the default path and helper commands as non-chat or final-render-only tools.
|
||||
|
||||
**Tech Stack:** TypeScript, Node test runner, existing OpenClaw property-assessor skill, existing OpenClaw web-automation modules, Markdown docs.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add failing timeout tests for discovery and photo extraction
|
||||
|
||||
**Files:**
|
||||
- Modify: `skills/property-assessor/tests/assessment.test.ts`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Add tests that stub discovery/photo functions with never-resolving promises and assert that:
|
||||
- listing discovery returns `null` URLs and records timeout attempts
|
||||
- photo extraction returns `not completed` instead of hanging forever
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- --test-name-pattern "times out"`
|
||||
Expected: FAIL because current code never times out or records timeout attempts.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Implement the smallest timeout wrapper needed for the tests to pass.
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- --test-name-pattern "times out"`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add skills/property-assessor/tests/assessment.test.ts
|
||||
git commit -m "test: cover stalled discovery and photo extraction"
|
||||
```
|
||||
|
||||
### Task 2: Implement hard timeout guards in the live assessment path
|
||||
|
||||
**Files:**
|
||||
- Create: `skills/property-assessor/src/async-timeout.ts`
|
||||
- Modify: `skills/property-assessor/src/listing-discovery.ts`
|
||||
- Modify: `skills/property-assessor/src/photo-review.ts`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
Use the tests from Task 1 as the red phase.
|
||||
|
||||
**Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `npm test -- --test-name-pattern "times out"`
|
||||
Expected: FAIL
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Add:
|
||||
- a shared timeout helper for async operations
|
||||
- timeout-wrapped Zillow/HAR discovery in `listing-discovery.ts`
|
||||
- timeout-wrapped Zillow/HAR photo extraction in `photo-review.ts`
|
||||
- clear timeout attempt messages so the assessment can continue honestly
|
||||
|
||||
**Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `npm test -- --test-name-pattern "times out"`
|
||||
Expected: PASS
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add skills/property-assessor/src/async-timeout.ts skills/property-assessor/src/listing-discovery.ts skills/property-assessor/src/photo-review.ts skills/property-assessor/tests/assessment.test.ts
|
||||
git commit -m "fix: fail fast on stalled property-assessor extraction steps"
|
||||
```
|
||||
|
||||
### Task 3: Tighten live skill instructions for WhatsApp-safe execution
|
||||
|
||||
**Files:**
|
||||
- Modify: `../skills/property-assessor/SKILL.md`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
No automated test. Use the documented runtime rule as the spec:
|
||||
- WhatsApp/messaging runs must avoid helper subprocesses for core analysis
|
||||
- only the final PDF render attempt may use the helper subprocess path
|
||||
- `update?` must remain status-only
|
||||
|
||||
**Step 2: Run verification to confirm current docs are wrong**
|
||||
|
||||
Run: `rg -n "scripts/property-assessor assess|node zillow-photos|node har-photos|Good:" ../skills/property-assessor/SKILL.md`
|
||||
Expected: current doc still presents helper commands as normal chat-safe core workflow.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Update the live skill doc to:
|
||||
- prefer `web_search`, `web_fetch`, and bounded `web-automation` for core assessment
|
||||
- forbid `scripts/property-assessor assess`, `node zillow-photos.js`, `node har-photos.js`, and ad hoc `curl` as the default WhatsApp core path
|
||||
- allow a single final PDF render attempt only after a decision-grade verdict exists
|
||||
|
||||
**Step 4: Run verification**
|
||||
|
||||
Run: `sed -n '1,220p' ../skills/property-assessor/SKILL.md`
|
||||
Expected: the WhatsApp-safe runtime rules are explicit and unambiguous.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add ../skills/property-assessor/SKILL.md
|
||||
git commit -m "docs: clarify whatsapp-safe property-assessor execution"
|
||||
```
|
||||
|
||||
### Task 4: Mirror the runtime guidance into the published repo docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/property-assessor.md`
|
||||
- Modify: `docs/web-automation.md`
|
||||
|
||||
**Step 1: Write the failing test**
|
||||
|
||||
No automated test. The spec is consistency with the live skill instructions.
|
||||
|
||||
**Step 2: Run verification to confirm current docs drift**
|
||||
|
||||
Run: `rg -n "node zillow-photos|node har-photos|assess --address" docs/property-assessor.md docs/web-automation.md`
|
||||
Expected: current docs still imply subprocess-heavy commands are the standard chat path.
|
||||
|
||||
**Step 3: Write minimal implementation**
|
||||
|
||||
Document:
|
||||
- chat-native assessment first
|
||||
- timeout-protected discovery/photo extraction behavior
|
||||
- final-render-only subprocess attempt from messaging runs
|
||||
|
||||
**Step 4: Run verification**
|
||||
|
||||
Run: `sed -n '1,220p' docs/property-assessor.md && sed -n '1,220p' docs/web-automation.md`
|
||||
Expected: published docs match the live skill behavior.
|
||||
|
||||
**Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/property-assessor.md docs/web-automation.md
|
||||
git commit -m "docs: document whatsapp-safe property assessment flow"
|
||||
```
|
||||
|
||||
### Task 5: Verify the focused runtime behavior
|
||||
|
||||
**Files:**
|
||||
- Modify: `skills/property-assessor/tests/assessment.test.ts`
|
||||
- Verify: `skills/property-assessor/src/*.ts`
|
||||
- Verify: `../skills/property-assessor/SKILL.md`
|
||||
- Verify: `docs/property-assessor.md`
|
||||
- Verify: `docs/web-automation.md`
|
||||
|
||||
**Step 1: Run focused tests**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: all `property-assessor` tests pass, including timeout coverage.
|
||||
|
||||
**Step 2: Run targeted source verification**
|
||||
|
||||
Run: `rg -n "withTimeout|timed out|final PDF render" skills/property-assessor/src ../skills/property-assessor/SKILL.md docs/property-assessor.md docs/web-automation.md`
|
||||
Expected: timeout guards and the final-render-only messaging rule are present.
|
||||
|
||||
**Step 3: Inspect git status**
|
||||
|
||||
Run: `git status --short`
|
||||
Expected: only intended files are modified.
|
||||
|
||||
**Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add skills/property-assessor/src/async-timeout.ts skills/property-assessor/src/listing-discovery.ts skills/property-assessor/src/photo-review.ts skills/property-assessor/tests/assessment.test.ts ../skills/property-assessor/SKILL.md docs/property-assessor.md docs/web-automation.md docs/plans/2026-03-28-property-assessor-whatsapp-safe-runtime.md
|
||||
git commit -m "fix: make property-assessor safer for whatsapp runs"
|
||||
```
|
||||
477
docs/property-assessor.md
Normal file
477
docs/property-assessor.md
Normal file
@@ -0,0 +1,477 @@
|
||||
# property-assessor
|
||||
|
||||
Decision-grade residential property assessment skill for OpenClaw, with official public-record enrichment and fixed-template PDF report rendering.
|
||||
|
||||
## Overview
|
||||
|
||||
`property-assessor` is for evaluating a condo, townhouse, house, or similar residential property from an address or listing URL and ending with a practical recommendation such as `buy`, `pass`, or `only below X`.
|
||||
|
||||
If the subject property has an apartment / unit / suite number, include it. Discovery is now unit-aware for Zillow and HAR when unit data is present, while still supporting plain single-family addresses that have no unit.
|
||||
|
||||
The skill is intended to:
|
||||
|
||||
- normalize the property across listing sources
|
||||
- review listing photos before making condition claims
|
||||
- incorporate official public-record / appraisal-district context when available
|
||||
- compare the property against comps and carrying costs
|
||||
- produce a fixed-format PDF report, not just ad hoc chat prose
|
||||
|
||||
## Standalone helper usage
|
||||
|
||||
This skill now ships with a small TypeScript helper package for three tasks:
|
||||
|
||||
- assembling an address-first preliminary assessment payload
|
||||
- locating official public-record jurisdiction from an address
|
||||
- rendering a fixed-template PDF report
|
||||
|
||||
From `skills/property-assessor/`:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
scripts/property-assessor --help
|
||||
```
|
||||
|
||||
The wrapper script uses the skill-local Node dependencies under `node_modules/`.
|
||||
|
||||
Delivery rule:
|
||||
|
||||
- If the user explicitly says to email or send the finished PDF to a stated target address, that counts as delivery authorization once the report is ready.
|
||||
- The agent should not ask for a second `send it` confirmation unless the user changed the destination or showed uncertainty.
|
||||
- Final property-assessor delivery should be sent as Luke via the Luke Google Workspace wrapper, while the destination remains whatever address the user specified.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property"
|
||||
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com"
|
||||
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf
|
||||
|
||||
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --parcel-id "14069438"
|
||||
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --listing-geo-id "233290"
|
||||
|
||||
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
|
||||
```
|
||||
|
||||
## Core workflow
|
||||
|
||||
Default operating sequence:
|
||||
|
||||
1. Normalize the address and property type.
|
||||
2. Resolve public-record jurisdiction from the address.
|
||||
3. Discover accessible listing sources for the same property.
|
||||
4. Build a baseline fact set.
|
||||
5. Review listing photos before making condition claims.
|
||||
6. Pull same-building or nearby comps.
|
||||
7. Underwrite carry costs and risk factors.
|
||||
8. Render the final report as a fixed-template PDF.
|
||||
|
||||
Operational rule:
|
||||
|
||||
- The helper returning a preliminary payload is not the end of the job.
|
||||
- For a user request that clearly asks for a full assessment or PDF delivery, the agent is expected to continue the missing analysis after the helper returns.
|
||||
- Preliminary helper output should be treated as structured scaffolding for the remaining work, not as a reason to stop and wait for another user nudge.
|
||||
- In chat/messaging runs, do not waste the turn on `npm install` or `npm ci` when the local skill dependencies are already present.
|
||||
- If the user asks `update?` or `and?` mid-run, treat that as a status request and continue the same assessment rather than restarting or stopping at the last checkpoint.
|
||||
- In WhatsApp or similar messaging runs, keep the core analysis on `web-automation` plus `web_fetch`. Treat `web_search` as a narrow fallback for alternate-URL discovery only, not the default path for Zillow/HAR/CAD work.
|
||||
- Do not start Zillow/HAR property discovery or photo review from Brave-backed `web_search` when `web-automation` can open the candidate listing source directly.
|
||||
- For CAD/public-record lookup, prefer official assessor/CAD pages via `web_fetch` first and `web-automation` second if the site needs rendered interaction.
|
||||
- In Texas runs, do not use `https://www.texas.gov/propertytaxes/search/` as the CAD lookup path; use the address-first CAD/helper path or the discovered county CAD pages directly.
|
||||
- In those messaging runs, reserve subprocess use for a single final `render-report` attempt after the verdict and fair-value range are complete.
|
||||
- In those messaging runs, do not start Gmail/email-send skill discovery or delivery tooling until the report content is complete and the PDF is ready to render or already rendered.
|
||||
- Property-assessor delivery emails should be sent as Luke from Luke's Google Workspace account, while still delivering to the user-specified destination.
|
||||
- Use `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"` for final PDF delivery.
|
||||
- Do not route property-assessor delivery through generic `gog` or the Stefano helper `node ~/.openclaw/workspace/integrations/google-workspace/gw.js`.
|
||||
- If the agent needs to confirm Luke auth before sending, use `zsh ~/.openclaw/workspace/bin/gog-luke auth list --check --plain`.
|
||||
- Treat a silent helper as a failed helper in messaging runs. If a helper produces no useful output within a short bound, abandon it and continue with the chat-native path instead of repeatedly polling it.
|
||||
- If Zillow photo extraction fails, immediately continue with HAR photo fallback or the next available rendered listing/photo source rather than stopping the assessment.
|
||||
- After a Zillow/HAR photo miss, continue the comp and CAD/public-record work in the same run. A photo-source miss is a fallback event, not a terminal state.
|
||||
- If the original request already authorized sending the finished PDF to a stated email address, do not pause for a redundant send-confirmation prompt after rendering.
|
||||
- If final PDF render/send fails, return the completed decision-grade report in chat and report delivery failure separately rather than restarting the whole assessment.
|
||||
|
||||
### `assess`
|
||||
|
||||
```bash
|
||||
scripts/property-assessor assess --address "<street-address>" --assessment-purpose "<purpose>"
|
||||
scripts/property-assessor assess --address "<street-address>" --assessment-purpose "<purpose>" --recipient-email "<target@example.com>"
|
||||
```
|
||||
|
||||
Current behavior:
|
||||
|
||||
- starts from the address
|
||||
- requires an assessment purpose for decision-grade analysis
|
||||
- does not assume the assessment purpose from prior thread context unless the user explicitly says the purpose is unchanged
|
||||
- automatically runs public-record / appraisal-district lookup
|
||||
- keeps CAD-site selection address-driven and jurisdiction-specific; it does not hardcode one county's CAD as the global source
|
||||
- when a supported official CAD detail host is found, captures direct property facts such as property ID/account, owner, legal description, assessed value, exemptions, and the official property-detail URL
|
||||
- automatically tries to discover Zillow and HAR listing URLs from the address when no listing URL is provided
|
||||
- starts Zillow and HAR listing discovery in parallel so HAR can already be in flight if Zillow misses or stalls
|
||||
- runs Zillow photo extraction first, then HAR as fallback when available
|
||||
- gives Zillow a longer source-specific discovery/photo window than the generic fallback path, because some exact-unit Zillow pages resolve more slowly than HAR or public-record lookups
|
||||
- reuses the OpenClaw web-automation logic in-process instead of spawning nested helper commands
|
||||
- fails fast when Zillow/HAR discovery or photo extraction stalls instead of hanging indefinitely
|
||||
- returns a structured preliminary report payload
|
||||
- does not require recipient email(s) for the analysis-only run
|
||||
- asks for recipient email(s) only when PDF rendering is explicitly requested
|
||||
- does not render/send the PDF from a preliminary helper payload with `decision: pending`
|
||||
- does not render/send the PDF when `photoReview.status` is not `completed`
|
||||
- only renders the fixed-template PDF after a decision-grade verdict and fair-value range are actually present
|
||||
|
||||
Expected agent behavior:
|
||||
|
||||
- if the user asked for the full assessment, continue beyond the preliminary helper output
|
||||
- fill the remaining gaps with listing facts, comp work, condition interpretation, and valuation logic
|
||||
- require completed subject-unit photo review before treating the report as decision-grade enough for PDF delivery
|
||||
- only stop early when there is a real blocker, not merely because the helper stopped at a checkpoint
|
||||
|
||||
Important limitation:
|
||||
|
||||
- this implementation now wires the address-first intake, purpose-aware framing, public-record lookup, listing discovery, and photo-source extraction
|
||||
- it still does not perform full comp analysis, pricing judgment, or completed carry underwriting inside the helper itself
|
||||
- those deeper decision steps are still governed by the skill workflow after the helper assembles the enriched payload
|
||||
|
||||
## Source priority
|
||||
|
||||
Unless the user says otherwise, preferred listing/source order is:
|
||||
|
||||
1. Zillow
|
||||
2. Redfin
|
||||
3. Realtor.com
|
||||
4. HAR / Homes.com / brokerage mirrors
|
||||
5. county or appraisal pages
|
||||
|
||||
Public-record / assessor data should be linked in the final result when available.
|
||||
|
||||
## Public-record enrichment
|
||||
|
||||
The skill should not rely on listing-site geo IDs as if they were assessor record identifiers.
|
||||
|
||||
Correct approach:
|
||||
|
||||
1. start from the street address
|
||||
2. resolve the address to state/county/FIPS/GEOID
|
||||
3. identify the official public-record jurisdiction
|
||||
4. use parcel/APN/account identifiers when available
|
||||
5. link the official jurisdiction page and any direct property page used
|
||||
|
||||
### `locate-public-records`
|
||||
|
||||
```bash
|
||||
scripts/property-assessor locate-public-records --address "<street-address>"
|
||||
```
|
||||
|
||||
Current behavior:
|
||||
|
||||
- uses the official Census geocoder
|
||||
- when Census address matching misses, falls back to an external address geocoder and then resolves official Census geographies from coordinates
|
||||
- retries fallback geocoding without the unit suffix when a condo/unit-qualified address is too specific for the fallback provider
|
||||
- returns matched address, county/state/FIPS, and block GEOID context
|
||||
- for Texas, returns:
|
||||
- Texas Comptroller county directory page
|
||||
- appraisal district contact/site details
|
||||
- tax assessor/collector contact/site details
|
||||
- official CAD property-detail facts when a supported county adapter can retrieve them from the discovered CAD site
|
||||
|
||||
Important rules:
|
||||
|
||||
- Zillow/Redfin/HAR geo IDs are hints only
|
||||
- parcel/APN/account IDs are stronger search keys than listing geo IDs
|
||||
- if Zillow exposes a parcel/APN/account number on the listing, capture it and use that identifier in CAD lookup before falling back to address-only matching
|
||||
- official jurisdiction pages should be linked in the final report
|
||||
- if a direct property detail page is accessible, its data should be labeled as official public-record evidence
|
||||
|
||||
### Texas support
|
||||
|
||||
Texas is the first-class public-record path in this implementation.
|
||||
|
||||
For Texas addresses, the helper resolves:
|
||||
|
||||
- the official Census geocoder link
|
||||
- the official Texas Comptroller county directory page
|
||||
- the appraisal district website
|
||||
- the tax assessor/collector website
|
||||
- the official CAD property-detail page when a supported adapter can identify and retrieve the subject record
|
||||
|
||||
That output should be used by the skill to:
|
||||
|
||||
- identify the correct CAD
|
||||
- attempt address / parcel / account lookup on the discovered CAD site for that county
|
||||
- prefer Zillow-exposed parcel/APN/account identifiers over address-only search when the listing provides them
|
||||
- capture official owner / legal / assessed-value evidence when a public detail page is available
|
||||
- treat county-specific CAD detail retrieval as an adapter layer on top of generic county/jurisdiction resolution
|
||||
|
||||
Nueces-specific note:
|
||||
|
||||
- when using Nueces CAD `By ID` / `Geographic ID`, insert a dash after the first 4 digits and again after the first 8 digits, for example `123456789012` -> `1234-5678-9012`
|
||||
|
||||
Recommended fields to capture from official records when accessible:
|
||||
|
||||
- account number
|
||||
- owner name
|
||||
- land value
|
||||
- improvement value
|
||||
- assessed total
|
||||
- exemptions
|
||||
- official property-detail URL
|
||||
|
||||
## Minimum data to capture
|
||||
|
||||
For the subject property, capture when available:
|
||||
|
||||
- address
|
||||
- list price or last known list price
|
||||
- property type
|
||||
- beds / baths
|
||||
- square footage
|
||||
- lot size if relevant
|
||||
- year built
|
||||
- HOA fee and included services
|
||||
- taxes
|
||||
- days on market
|
||||
- price history
|
||||
- parking
|
||||
- waterfront / flood clues
|
||||
- subdivision or building name
|
||||
- same-building or nearby active inventory
|
||||
- listing photos and visible condition cues
|
||||
- public-record jurisdiction and linked official source
|
||||
- account / parcel / tax ID if confirmed
|
||||
- official assessed values and exemptions if confirmed
|
||||
|
||||
## Photo-review rules
|
||||
|
||||
Photo review is mandatory when photos are exposed by a listing source.
|
||||
|
||||
Do not make strong condition claims from structured text alone if photos are available.
|
||||
|
||||
Preferred photo-access order:
|
||||
|
||||
1. Zillow extractor
|
||||
2. HAR extractor
|
||||
3. Realtor.com photo page
|
||||
4. brokerage mirror or other accessible listing mirror
|
||||
|
||||
Use the dedicated `web-automation` extractors first:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node zillow-photos.js "<zillow-listing-url>"
|
||||
node har-photos.js "<har-listing-url>"
|
||||
```
|
||||
|
||||
When those extractors return `imageUrls`, that returned set is the photo-review set.
|
||||
|
||||
## Approval-safe command shape
|
||||
|
||||
For local/manual runs, prefer file-based commands.
|
||||
|
||||
For chat-driven messaging runs, prefer native `web_search`, `web_fetch`, and bounded browser actions first. Treat these commands as local/manual helpers or non-chat fallbacks, not as the default core-analysis path.
|
||||
|
||||
Good:
|
||||
|
||||
- `scripts/property-assessor assess --address "..." --assessment-purpose "..."`
|
||||
- `node check-install.js`
|
||||
- `node zillow-discover.js "<street-address>"`
|
||||
- `node har-discover.js "<street-address>"`
|
||||
- `node zillow-photos.js "<url>"`
|
||||
- `node har-photos.js "<url>"`
|
||||
- `scripts/property-assessor locate-public-records --address "..."`
|
||||
- `scripts/property-assessor render-report --input ... --output ...`
|
||||
- `web_fetch` to read an official CAD / assessor page when the TypeScript helper needs a plain page fetch
|
||||
|
||||
Messaging default:
|
||||
|
||||
- `web_search` to discover listing and public-record URLs
|
||||
- `web_fetch` for official CAD / assessor pages and accessible listing pages
|
||||
- bounded `web-automation` actions for rendered listing/photo views
|
||||
- one final `scripts/property-assessor render-report ...` attempt only after the decision-grade report is complete
|
||||
|
||||
Avoid when possible:
|
||||
|
||||
- `node -e "..."`
|
||||
- `node --input-type=module -e "..."`
|
||||
- `python3 - <<'PY' ... PY`
|
||||
- `python -c "..."`
|
||||
- raw `bash -lc '...'` or `zsh -lc '...'` probes for CAD / public-record lookup
|
||||
|
||||
Reason:
|
||||
|
||||
- OpenClaw exec approvals are path-based, and inline shell / interpreter forms are treated conservatively in allowlist mode.
|
||||
- For `property-assessor`, CAD and public-record lookup should stay on the skill’s file-based TypeScript helper path or use `web_fetch`.
|
||||
- If the workflow drifts into an ad hoc shell snippet, that is not the approved skill path and can still trigger Control UI approval prompts.
|
||||
|
||||
## PDF report template
|
||||
|
||||
The final deliverable should be a fixed-template PDF, not a one-off layout.
|
||||
|
||||
Template reference:
|
||||
|
||||
- `skills/property-assessor/references/report-template.md`
|
||||
|
||||
Current renderer:
|
||||
|
||||
```bash
|
||||
scripts/property-assessor assess --address "<street-address>" --recipient-email "<target@example.com>"
|
||||
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
|
||||
```
|
||||
|
||||
The fixed template includes:
|
||||
|
||||
1. Report header
|
||||
2. Verdict panel
|
||||
3. Subject-property summary table
|
||||
4. Snapshot
|
||||
5. What I Like
|
||||
6. What I Do Not Like
|
||||
7. Comp View
|
||||
8. Underwriting / Carry View
|
||||
9. Risks and Diligence Items
|
||||
10. Photo Review
|
||||
11. Public Records
|
||||
12. Source Links
|
||||
13. Notes page
|
||||
|
||||
### Recipient email gate
|
||||
|
||||
The report must not be rendered or sent unless target recipient email address(es) are known.
|
||||
|
||||
This requirement applies when the operator is actually rendering or sending the PDF.
|
||||
It should not interrupt a normal analysis-only `assess` run.
|
||||
|
||||
If the prompt does not include recipient email(s), the skill should:
|
||||
|
||||
- stop
|
||||
- ask for target recipient email address(es)
|
||||
- not finalize the PDF workflow yet
|
||||
|
||||
The renderer enforces this. If `recipientEmails` is missing or empty, it fails with:
|
||||
|
||||
`Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.`
|
||||
|
||||
## Example payload
|
||||
|
||||
Sample payload:
|
||||
|
||||
- `skills/property-assessor/examples/report-payload.example.json`
|
||||
|
||||
This is the easiest way to test the renderer without building a report payload from scratch.
|
||||
|
||||
## Output contract
|
||||
|
||||
The assessment itself should remain concise but decision-grade.
|
||||
|
||||
Recommended narrative structure:
|
||||
|
||||
1. Snapshot
|
||||
2. What I like
|
||||
3. What I do not like
|
||||
4. Comp view
|
||||
5. Underwriting / carry view
|
||||
6. Risks and diligence items
|
||||
7. Verdict with fair-value range and offer guidance
|
||||
|
||||
It must also explicitly include:
|
||||
|
||||
- `Photo source attempts: ...`
|
||||
- `Photo review: completed via <source>` or `Photo review: not completed`
|
||||
- public-record / CAD evidence and links when available
|
||||
|
||||
## Validation flow
|
||||
|
||||
### 1. Install the helper package locally
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
npm install
|
||||
npm test
|
||||
```
|
||||
|
||||
### 2. Run address-first assess without recipient email
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property"
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
|
||||
- `needsAssessmentPurpose: false`
|
||||
- `needsRecipientEmails: false`
|
||||
- public-record / CAD jurisdiction included in the returned payload
|
||||
- `photoReview.imageUrls` populated when Zillow or HAR extraction succeeds
|
||||
- no PDF generated yet
|
||||
- explicit message saying the payload is ready and email is only needed when rendering or sending the PDF
|
||||
|
||||
### 3. Run public-record lookup directly
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor locate-public-records --address "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||
```
|
||||
|
||||
Expected shape:
|
||||
|
||||
- state/county/FIPS/GEOID present
|
||||
- official Census geocoder link present
|
||||
- for Texas: Comptroller county directory link present
|
||||
- for Texas: appraisal district and tax assessor/collector contacts present
|
||||
|
||||
### 4. Run assess with recipient email and render the PDF
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor assess --address "4141 Whiteley Dr, Corpus Christi, TX 78418" --assessment-purpose "investment property" --recipient-email "buyer@example.com" --output /tmp/property-assessment.pdf
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- `needsRecipientEmails: false`
|
||||
- JSON success payload with `outputPath`
|
||||
- a non-empty PDF written to `/tmp/property-assessment.pdf`
|
||||
|
||||
### 5. Run PDF render with the sample payload
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor render-report --input examples/report-payload.example.json --output /tmp/property-assessment.pdf
|
||||
```
|
||||
|
||||
Expected result:
|
||||
|
||||
- JSON success payload with `outputPath`
|
||||
- a non-empty PDF written to `/tmp/property-assessment.pdf`
|
||||
|
||||
### 6. Verify the email gate
|
||||
|
||||
Run the renderer with a payload that omits `recipientEmails`.
|
||||
|
||||
Expected result:
|
||||
|
||||
- non-zero exit
|
||||
- explicit message telling the operator to stop and ask for target recipient email(s)
|
||||
|
||||
### 7. Verify the end-to-end skill behavior
|
||||
|
||||
When testing `property-assessor` itself, confirm the assessment:
|
||||
|
||||
- starts from the address when available
|
||||
- uses Zillow first for photo extraction, HAR as fallback
|
||||
- frames the analysis around the stated assessment purpose
|
||||
- uses official public-record jurisdiction links when available
|
||||
- does not treat listing geo IDs as assessor keys
|
||||
- asks for assessment purpose if it was not provided
|
||||
- asks for recipient email(s) if they were not provided
|
||||
- renders the final report through the fixed PDF template once recipient email(s) are known
|
||||
|
||||
## Related files
|
||||
|
||||
- skill instructions:
|
||||
- `skills/property-assessor/SKILL.md`
|
||||
- underwriting heuristics:
|
||||
- `skills/property-assessor/references/underwriting-rules.md`
|
||||
- PDF template rules:
|
||||
- `skills/property-assessor/references/report-template.md`
|
||||
- sample report payload:
|
||||
- `skills/property-assessor/examples/report-payload.example.json`
|
||||
- photo extraction docs:
|
||||
- `docs/web-automation.md`
|
||||
302
docs/us-cpa.md
Normal file
302
docs/us-cpa.md
Normal file
@@ -0,0 +1,302 @@
|
||||
# us-cpa
|
||||
|
||||
`us-cpa` is a Python CLI plus OpenClaw skill wrapper for U.S. federal individual tax work.
|
||||
|
||||
## Standalone package usage
|
||||
|
||||
From `skills/us-cpa/`:
|
||||
|
||||
```bash
|
||||
python3 -m ensurepip --upgrade
|
||||
python3 -m pip install --upgrade pip setuptools wheel
|
||||
python3 -m pip install -e '.[dev]'
|
||||
us-cpa --help
|
||||
```
|
||||
|
||||
Without installing, the repo-local wrapper works directly:
|
||||
|
||||
```bash
|
||||
skills/us-cpa/scripts/us-cpa --help
|
||||
```
|
||||
|
||||
## OpenClaw installation
|
||||
|
||||
To install the skill for OpenClaw itself, copy the repo skill into the workspace skill directory and install its Python dependencies there.
|
||||
|
||||
1. Sync the repo copy into the workspace:
|
||||
|
||||
```bash
|
||||
rsync -a --delete --exclude '.venv' \
|
||||
~/.openclaw/workspace/projects/stef-openclaw-skills/skills/us-cpa/ \
|
||||
~/.openclaw/workspace/skills/us-cpa/
|
||||
```
|
||||
|
||||
2. Create a workspace-local virtualenv and install the package:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/us-cpa
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python3 -m ensurepip --upgrade
|
||||
python3 -m pip install --upgrade pip setuptools wheel
|
||||
python3 -m pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
3. Verify the installed workspace wrapper:
|
||||
|
||||
```bash
|
||||
~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa --help
|
||||
```
|
||||
|
||||
The wrapper prefers `.venv/bin/python` inside the skill directory when present, so OpenClaw can run the workspace copy without relying on global Python packages.
|
||||
|
||||
Keep the `--exclude '.venv'` flag on future syncs, otherwise `rsync --delete` will remove the workspace virtualenv.
|
||||
|
||||
## Current Milestone
|
||||
|
||||
Current implementation now includes:
|
||||
|
||||
- deterministic cache layout under `~/.cache/us-cpa` by default
|
||||
- `fetch-year` download flow for the bootstrap IRS corpus
|
||||
- source manifest with URL, hash, authority rank, and local path traceability
|
||||
- primary-law URL building for IRC and Treasury regulation escalation
|
||||
- case-folder intake, document registration, and machine-usable fact extraction from JSON, text, and PDF inputs
|
||||
- question workflow with conversation and memo output
|
||||
- prepare workflow for the current supported multi-form 1040 package
|
||||
- review workflow with findings-first output
|
||||
- fillable-PDF first rendering with overlay fallback
|
||||
- e-file-ready draft export payload generation
|
||||
|
||||
## CLI Surface
|
||||
|
||||
```bash
|
||||
skills/us-cpa/scripts/us-cpa question --question "What is the standard deduction?" --tax-year 2025
|
||||
skills/us-cpa/scripts/us-cpa question --question "What is the standard deduction?" --tax-year 2025 --style memo --format markdown
|
||||
skills/us-cpa/scripts/us-cpa prepare --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
|
||||
skills/us-cpa/scripts/us-cpa review --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
|
||||
skills/us-cpa/scripts/us-cpa fetch-year --tax-year 2025
|
||||
skills/us-cpa/scripts/us-cpa extract-docs --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe --create-case --case-label "Jane Doe" --facts-json ./facts.json
|
||||
skills/us-cpa/scripts/us-cpa render-forms --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
|
||||
skills/us-cpa/scripts/us-cpa export-efile-ready --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
|
||||
```
|
||||
|
||||
## Tax-Year Cache
|
||||
|
||||
Default cache root:
|
||||
|
||||
```text
|
||||
~/.cache/us-cpa
|
||||
```
|
||||
|
||||
Override for isolated runs:
|
||||
|
||||
```bash
|
||||
US_CPA_CACHE_DIR=/tmp/us-cpa-cache skills/us-cpa/scripts/us-cpa fetch-year --tax-year 2025
|
||||
```
|
||||
|
||||
Current `fetch-year` bootstrap corpus for tax year `2025` is verified against live IRS `irs-prior` PDFs for:
|
||||
|
||||
- Form 1040
|
||||
- Schedules 1, 2, 3, A, B, C, D, E, SE, and 8812
|
||||
- Forms 8949, 4562, 4797, 6251, 8606, 8863, 8889, 8959, 8960, 8995, 8995-A, 5329, 5695, and 1116
|
||||
- General Form 1040 instructions and selected schedule/form instructions
|
||||
|
||||
Current bundled tax-year computation data:
|
||||
|
||||
- 2024
|
||||
- 2025
|
||||
|
||||
Other years fetch/source correctly, but deterministic return calculations currently stop with an explicit unsupported-year error until rate tables are added.
|
||||
|
||||
Adding a new supported year is a deliberate data-table change in `tax_years.py`, not an automatic runtime discovery step. That is intentional for tax-engine correctness.
|
||||
|
||||
## Interaction Model
|
||||
|
||||
- `question`
|
||||
- stateless by default
|
||||
- optional case context
|
||||
- `prepare`
|
||||
- requires a case directory
|
||||
- if none exists, OpenClaw should ask whether to create one and where
|
||||
- `review`
|
||||
- requires a case directory
|
||||
- can operate on an existing or newly-created review case
|
||||
|
||||
## Planned Case Layout
|
||||
|
||||
```text
|
||||
<case-dir>/
|
||||
input/
|
||||
extracted/
|
||||
return/
|
||||
output/
|
||||
reports/
|
||||
issues/
|
||||
sources/
|
||||
```
|
||||
|
||||
Current implementation writes:
|
||||
|
||||
- `case-manifest.json`
|
||||
- `extracted/facts.json`
|
||||
- `issues/open-issues.json`
|
||||
|
||||
## Intake Flow
|
||||
|
||||
Current `extract-docs` supports:
|
||||
|
||||
- `--create-case`
|
||||
- `--case-label`
|
||||
- `--facts-json <path>`
|
||||
- repeated `--input-file <path>`
|
||||
|
||||
Behavior:
|
||||
|
||||
- creates the full case directory layout when `--create-case` is used
|
||||
- copies input documents into `input/`
|
||||
- stores normalized facts with source metadata in `extracted/facts.json`
|
||||
- extracts machine-usable facts from JSON/text/PDF documents where supported
|
||||
- appends document registry entries to `case-manifest.json`
|
||||
- stops with a structured issue and non-zero exit if a new fact conflicts with an existing stored fact
|
||||
|
||||
## Output Contract
|
||||
|
||||
- JSON by default
|
||||
- markdown available with `--format markdown`
|
||||
- `question` supports:
|
||||
- `--style conversation`
|
||||
- `--style memo`
|
||||
- `question` emits answered analysis output
|
||||
- `prepare` emits a prepared return package summary
|
||||
- `export-efile-ready` emits a draft e-file-ready payload
|
||||
- `review` emits a findings-first review result
|
||||
- `fetch-year` emits a downloaded manifest location and source count
|
||||
|
||||
## Question Engine
|
||||
|
||||
Current `question` implementation:
|
||||
|
||||
- loads the cached tax-year corpus
|
||||
- searches the downloaded IRS corpus for relevant authorities and excerpts
|
||||
- returns one canonical analysis object with:
|
||||
- authorities
|
||||
- excerpts
|
||||
- confidence / risk
|
||||
- primary-law escalation only when the IRS corpus is still insufficient
|
||||
- renders that analysis as:
|
||||
- conversational output
|
||||
- memo output
|
||||
|
||||
In OpenClaw, the model should answer the user from the returned IRS excerpts when `primaryLawRequired` is `false`, rather than merely repeating the CLI summary.
|
||||
|
||||
## Form Rendering
|
||||
|
||||
Current rendering path:
|
||||
|
||||
- official IRS PDFs from the cached tax-year corpus
|
||||
- deterministic field-fill when usable AcroForm fields are present
|
||||
- overlay rendering onto those official PDFs using `reportlab` + `pypdf` as fallback
|
||||
- artifact manifest written to `output/artifacts.json`
|
||||
|
||||
Current rendered form support:
|
||||
|
||||
- field-fill support for known mapped fillable forms
|
||||
- overlay generation for the current required-form set resolved by the return model
|
||||
|
||||
Current review rule:
|
||||
|
||||
- field-filled artifacts are not automatically flagged for review
|
||||
- overlay-rendered artifacts are marked `reviewRequired: true`
|
||||
|
||||
Overlay coordinates are currently a fallback heuristic and are not treated as line-perfect authoritative field maps. Overlay output must be visually reviewed before any filing/export handoff.
|
||||
|
||||
## Preparation Workflow
|
||||
|
||||
Current `prepare` implementation:
|
||||
|
||||
- loads case facts from `extracted/facts.json`
|
||||
- normalizes them into the current supported federal return model
|
||||
- preserves source provenance for normalized values
|
||||
- computes the current supported 1040 package
|
||||
- resolves required forms across the current supported subset
|
||||
- writes:
|
||||
- `return/normalized-return.json`
|
||||
- `output/artifacts.json`
|
||||
- `reports/prepare-summary.json`
|
||||
|
||||
Current supported calculation inputs:
|
||||
|
||||
- `filingStatus`
|
||||
- `spouse.fullName`
|
||||
- `dependents`
|
||||
- `wages`
|
||||
- `taxableInterest`
|
||||
- `businessIncome`
|
||||
- `capitalGainLoss`
|
||||
- `rentalIncome`
|
||||
- `federalWithholding`
|
||||
- `itemizedDeductions`
|
||||
- `hsaContribution`
|
||||
- `educationCredit`
|
||||
- `foreignTaxCredit`
|
||||
- `qualifiedBusinessIncome`
|
||||
- `traditionalIraBasis`
|
||||
- `additionalMedicareTax`
|
||||
- `netInvestmentIncomeTax`
|
||||
- `alternativeMinimumTax`
|
||||
- `additionalTaxPenalty`
|
||||
- `energyCredit`
|
||||
- `depreciationExpense`
|
||||
- `section1231GainLoss`
|
||||
|
||||
## E-file-ready Export
|
||||
|
||||
`export-efile-ready` writes:
|
||||
|
||||
- `output/efile-ready.json`
|
||||
|
||||
Current export behavior:
|
||||
|
||||
- draft-only
|
||||
- includes required forms
|
||||
- includes refund or balance due summary
|
||||
- includes attachment manifest
|
||||
- includes unresolved issues
|
||||
|
||||
## Review Workflow
|
||||
|
||||
Current `review` implementation:
|
||||
|
||||
- recomputes the return from current case facts
|
||||
- compares stored normalized return values to recomputed values
|
||||
- flags source-fact mismatches for key income fields
|
||||
- flags likely omitted income when document-extracted facts support an amount the stored return omits
|
||||
- checks whether required rendered artifacts are present
|
||||
- flags high-complexity forms for specialist follow-up
|
||||
- flags overlay-rendered artifacts as requiring human review
|
||||
- sorts findings by severity
|
||||
|
||||
Current render modes:
|
||||
|
||||
- `--style conversation`
|
||||
- `--style memo`
|
||||
|
||||
## Scope Rules
|
||||
|
||||
- U.S. federal individual returns only in v1
|
||||
- official IRS artifacts are the target output for compiled forms
|
||||
- conflicting facts must stop the workflow for user resolution
|
||||
|
||||
## Authority Ranking
|
||||
|
||||
Current authority classes are ranked to preserve source hierarchy:
|
||||
|
||||
- IRS forms
|
||||
- IRS instructions
|
||||
- IRS publications
|
||||
- IRS FAQs
|
||||
- Internal Revenue Code
|
||||
- Treasury regulations
|
||||
- other primary authority
|
||||
|
||||
Later research and review flows should consume this ranking rather than inventing their own.
|
||||
@@ -15,6 +15,13 @@ Automated web browsing and scraping using Playwright-compatible CloakBrowser, wi
|
||||
- Use `node skills/web-automation/scripts/extract.js "<URL>"` for one-shot extraction from a single URL
|
||||
- Use `npx tsx scrape.ts ...` for markdown scraping modes
|
||||
- Use `npx tsx browse.ts ...`, `auth.ts`, or `flow.ts` for interactive or authenticated flows
|
||||
- Use `node skills/web-automation/scripts/zillow-discover.js "<street-address>"` or `har-discover.js` to resolve a real-estate listing URL from an address
|
||||
- Use `node skills/web-automation/scripts/zillow-photos.js "<listing-url>"` or `har-photos.js` for real-estate photo extraction before attempting generic gallery automation
|
||||
|
||||
Messaging rule:
|
||||
- For WhatsApp or similar chat-driven runs, prefer native `web_search`, `web_fetch`, and bounded browser actions over shelling out to helper scripts for every core step.
|
||||
- Treat the dedicated Zillow/HAR scripts as local/manual helpers, regression checks, or non-chat fallbacks.
|
||||
- If a messaging workflow needs a subprocess at all, reserve it for a single final delivery step rather than the whole assessment.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -59,12 +66,63 @@ pnpm rebuild better-sqlite3 esbuild
|
||||
|
||||
Without this, helper scripts may fail before launch because the native bindings are missing.
|
||||
|
||||
## Prerequisite check
|
||||
|
||||
Before running automation, verify the local install and CloakBrowser wiring:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node check-install.js
|
||||
```
|
||||
|
||||
If this fails, stop and fix setup before troubleshooting site automation.
|
||||
|
||||
## Exec approvals allowlist
|
||||
|
||||
If OpenClaw keeps prompting for approval when running this skill, add a local allowlist for the main agent:
|
||||
|
||||
```bash
|
||||
openclaw approvals allowlist add --agent main "/opt/homebrew/bin/node"
|
||||
openclaw approvals allowlist add --agent main "/usr/bin/env"
|
||||
openclaw approvals allowlist add --agent main "~/.openclaw/workspace/skills/web-automation/scripts/*.js"
|
||||
openclaw approvals allowlist add --agent main "~/.openclaw/workspace/skills/web-automation/scripts/node_modules/.bin/*"
|
||||
```
|
||||
|
||||
Verify with:
|
||||
|
||||
```bash
|
||||
openclaw approvals get
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If `node` lives somewhere else, replace `/opt/homebrew/bin/node` with the output of `which node`.
|
||||
- If matching is inconsistent, replace `~/.openclaw/...` with the full absolute path for the machine.
|
||||
- Keep the allowlist scoped to the main agent unless there is a clear reason to widen it.
|
||||
- Prefer file-based commands like `node check-install.js`, `node zillow-photos.js ...`, and `node har-photos.js ...` over inline `node -e ...`. Inline interpreter eval is more likely to trigger approval friction.
|
||||
- The same applies to `zillow-discover.js` and `har-discover.js`: keep discovery file-based, not inline.
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
# Install / wiring check
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node check-install.js
|
||||
|
||||
# One-shot JSON extraction
|
||||
node skills/web-automation/scripts/extract.js "https://example.com"
|
||||
|
||||
# Zillow listing discovery from address
|
||||
node skills/web-automation/scripts/zillow-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||
|
||||
# HAR listing discovery from address
|
||||
node skills/web-automation/scripts/har-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||
|
||||
# Zillow photo extraction
|
||||
node skills/web-automation/scripts/zillow-photos.js "https://www.zillow.com/homedetails/..."
|
||||
|
||||
# HAR photo extraction
|
||||
node skills/web-automation/scripts/har-photos.js "https://www.har.com/homedetail/..."
|
||||
|
||||
# Browse a page with persistent profile
|
||||
npx tsx browse.ts --url "https://example.com"
|
||||
|
||||
@@ -78,6 +136,111 @@ npx tsx auth.ts --url "https://example.com/login"
|
||||
npx tsx flow.ts --instruction 'go to https://search.fiorinis.com then type "pippo" then press enter then wait 2s'
|
||||
```
|
||||
|
||||
## Real-estate listing discovery and photo extraction
|
||||
|
||||
Use the dedicated Zillow and HAR discovery/photo commands before trying a free-form gallery flow.
|
||||
Discovery is unit-aware when the address includes an apartment / unit / suite identifier, and still supports plain no-unit addresses for single-family homes.
|
||||
|
||||
### Zillow discovery
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node zillow-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||
```
|
||||
|
||||
What it does:
|
||||
- opens the Zillow address URL with CloakBrowser
|
||||
- resolves directly to a property page when Zillow supports the address slug
|
||||
- otherwise looks for a `homedetails` listing link in the rendered page
|
||||
- returns the discovered listing URL as JSON
|
||||
- fails fast with a timeout if the browser-backed discovery stalls
|
||||
|
||||
Operational note:
|
||||
- when imported by `property-assessor`, Zillow discovery is allowed a longer source-specific timeout than the generic helper default, because some exact-unit Zillow pages resolve more slowly than the basic search/listing flow
|
||||
|
||||
### HAR discovery
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node har-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||
```
|
||||
|
||||
What it does:
|
||||
- opens the HAR address search page
|
||||
- looks for a confident `homedetail` match in rendered results
|
||||
- returns the discovered listing URL when HAR exposes a strong enough match
|
||||
- returns `listingUrl: null` when HAR discovery is not confident enough
|
||||
- fails fast with a timeout if the browser-backed discovery stalls
|
||||
|
||||
### Zillow
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node zillow-photos.js "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/"
|
||||
```
|
||||
|
||||
What it does:
|
||||
- opens the listing page with CloakBrowser
|
||||
- first checks whether the rendered listing shell already exposes a complete photo set in Zillow's embedded `__NEXT_DATA__` payload
|
||||
- if the visible `See all XX photos` count is missing, still trusts the embedded set when the page metadata confirms the count or when the embedded set is already clearly substantial
|
||||
- only tries the `See all photos` / `See all X photos` entry point when the initial structured data is incomplete
|
||||
- returns direct `photos.zillowstatic.com` image URLs as JSON
|
||||
- fails fast with a timeout if the browser-backed extraction stalls
|
||||
|
||||
Operational note:
|
||||
- when imported by `property-assessor`, Zillow photo extraction is allowed a longer source-specific timeout than the generic helper default, because some exact-unit Zillow listings expose the correct photo set only after a slower render path
|
||||
|
||||
Expected success shape:
|
||||
- `complete: true`
|
||||
- `expectedPhotoCount` matches `photoCount`
|
||||
- `imageUrls` contains the listing photo set
|
||||
|
||||
### Zillow identifiers
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node zillow-identifiers.js "https://www.zillow.com/homedetails/6702-Everhart-Rd-APT-T106-Corpus-Christi-TX-78413/2067445642_zpid/"
|
||||
```
|
||||
|
||||
What it does:
|
||||
- opens the Zillow listing shell without forcing the photo workflow
|
||||
- inspects embedded `__NEXT_DATA__` plus visible listing text
|
||||
- extracts parcel/APN-style identifiers when Zillow exposes them
|
||||
- returns those identifiers so `property-assessor` can use them as stronger CAD lookup keys than listing geo IDs
|
||||
|
||||
### HAR
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node har-photos.js "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
|
||||
```
|
||||
|
||||
What it does:
|
||||
- opens the HAR listing page
|
||||
- clicks `Show all photos` / `View all photos`
|
||||
- extracts the direct `pics.harstatic.com` image URLs from the all-photos page
|
||||
- fails fast with a timeout if the browser-backed extraction stalls
|
||||
|
||||
Expected success shape:
|
||||
- `complete: true`
|
||||
- `expectedPhotoCount` matches `photoCount`
|
||||
- `imageUrls` contains the listing photo set
|
||||
|
||||
### Test commands
|
||||
|
||||
From `skills/web-automation/scripts`:
|
||||
|
||||
```bash
|
||||
node check-install.js
|
||||
npm run test:photos
|
||||
node zillow-discover.js "<street-address>"
|
||||
node har-discover.js "<street-address>"
|
||||
node zillow-photos.js "<zillow-listing-url>"
|
||||
node har-photos.js "<har-listing-url>"
|
||||
```
|
||||
|
||||
Use the live Zillow and HAR URLs above for a known-good regression check.
|
||||
|
||||
## One-shot extraction (`extract.js`)
|
||||
|
||||
Use `extract.js` when the task is just: open one URL, render it, and return structured content.
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Google Workspace helper CLI
|
||||
Commands:
|
||||
whoami
|
||||
send --to <email> --subject <text> --body <text> [--html]
|
||||
send --to <email> --subject <text> --body <text> [--html] [--attach <file>]
|
||||
search-mail --query <gmail query> [--max 10]
|
||||
search-calendar --query <text> [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary]
|
||||
create-event --summary <text> --start <ISO> --end <ISO> [--timeZone America/Chicago] [--description <text>] [--location <text>] [--calendar primary]
|
||||
@@ -11,7 +11,6 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { google } = require('googleapis');
|
||||
|
||||
const DEFAULT_SUBJECT = process.env.GW_IMPERSONATE || 'stefano@fiorinis.com';
|
||||
const DEFAULT_KEY_CANDIDATES = [
|
||||
@@ -45,7 +44,11 @@ function parseArgs(argv) {
|
||||
if (!next || next.startsWith('--')) {
|
||||
out[key] = true;
|
||||
} else {
|
||||
out[key] = next;
|
||||
if (Object.hasOwn(out, key)) {
|
||||
out[key] = Array.isArray(out[key]) ? out[key].concat(next) : [out[key], next];
|
||||
} else {
|
||||
out[key] = next;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
} else {
|
||||
@@ -63,7 +66,7 @@ Env (optional):
|
||||
|
||||
Commands:
|
||||
whoami
|
||||
send --to <email> --subject <text> --body <text> [--html]
|
||||
send --to <email> --subject <text> --body <text> [--html] [--attach <file>]
|
||||
search-mail --query <gmail query> [--max 10]
|
||||
search-calendar --query <text> [--max 10] [--timeMin ISO] [--timeMax ISO] [--calendar primary]
|
||||
create-event --summary <text> --start <ISO> --end <ISO> [--timeZone America/Chicago] [--description <text>] [--location <text>] [--calendar primary]
|
||||
@@ -77,17 +80,82 @@ function assertRequired(opts, required) {
|
||||
}
|
||||
}
|
||||
|
||||
function makeRawEmail({ from, to, subject, body, isHtml = false }) {
|
||||
function toArray(value) {
|
||||
if (value == null || value === false) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function getAttachmentContentType(filename) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
if (ext === '.pdf') return 'application/pdf';
|
||||
if (ext === '.txt') return 'text/plain; charset="UTF-8"';
|
||||
if (ext === '.html' || ext === '.htm') return 'text/html; charset="UTF-8"';
|
||||
if (ext === '.json') return 'application/json';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
function wrapBase64(base64) {
|
||||
return base64.match(/.{1,76}/g)?.join('\r\n') || '';
|
||||
}
|
||||
|
||||
function loadAttachments(attachArg) {
|
||||
return toArray(attachArg).map((filePath) => {
|
||||
const absolutePath = path.resolve(filePath);
|
||||
if (!fs.existsSync(absolutePath)) {
|
||||
throw new Error(`Attachment file not found: ${absolutePath}`);
|
||||
}
|
||||
|
||||
return {
|
||||
filename: path.basename(absolutePath),
|
||||
contentType: getAttachmentContentType(absolutePath),
|
||||
data: fs.readFileSync(absolutePath).toString('base64'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function makeRawEmail({ from, to, subject, body, isHtml = false, attachments = [] }) {
|
||||
const contentType = isHtml ? 'text/html; charset="UTF-8"' : 'text/plain; charset="UTF-8"';
|
||||
const msg = [
|
||||
`From: ${from}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: ${contentType}`,
|
||||
'',
|
||||
body,
|
||||
].join('\r\n');
|
||||
const normalizedAttachments = attachments.filter(Boolean);
|
||||
|
||||
let msg;
|
||||
if (normalizedAttachments.length === 0) {
|
||||
msg = [
|
||||
`From: ${from}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: ${contentType}`,
|
||||
'',
|
||||
body,
|
||||
].join('\r\n');
|
||||
} else {
|
||||
const boundary = `gw-boundary-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
||||
msg = [
|
||||
`From: ${from}`,
|
||||
`To: ${to}`,
|
||||
`Subject: ${subject}`,
|
||||
'MIME-Version: 1.0',
|
||||
`Content-Type: multipart/mixed; boundary="${boundary}"`,
|
||||
'',
|
||||
`--${boundary}`,
|
||||
`Content-Type: ${contentType}`,
|
||||
'Content-Transfer-Encoding: 7bit',
|
||||
'',
|
||||
body,
|
||||
'',
|
||||
...normalizedAttachments.flatMap((attachment) => [
|
||||
`--${boundary}`,
|
||||
`Content-Type: ${attachment.contentType}; name="${attachment.filename}"`,
|
||||
'Content-Transfer-Encoding: base64',
|
||||
`Content-Disposition: attachment; filename="${attachment.filename}"`,
|
||||
'',
|
||||
wrapBase64(attachment.data),
|
||||
'',
|
||||
]),
|
||||
`--${boundary}--`,
|
||||
'',
|
||||
].join('\r\n');
|
||||
}
|
||||
|
||||
return Buffer.from(msg)
|
||||
.toString('base64')
|
||||
@@ -97,6 +165,7 @@ function makeRawEmail({ from, to, subject, body, isHtml = false }) {
|
||||
}
|
||||
|
||||
async function getClients() {
|
||||
const { google } = require('googleapis');
|
||||
const keyPath = resolveKeyPath();
|
||||
if (!keyPath) {
|
||||
throw new Error('Service account key not found. Set GW_KEY_PATH or place the file in ~/.openclaw/workspace/.clawdbot/credentials/google-workspace/service-account.json');
|
||||
@@ -132,6 +201,7 @@ async function cmdSend(clients, opts) {
|
||||
subject: opts.subject,
|
||||
body: opts.body,
|
||||
isHtml: !!opts.html,
|
||||
attachments: loadAttachments(opts.attach),
|
||||
});
|
||||
|
||||
const res = await clients.gmail.users.messages.send({
|
||||
@@ -222,7 +292,7 @@ async function cmdCreateEvent(clients, opts) {
|
||||
console.log(JSON.stringify({ ok: true, id: res.data.id, htmlLink: res.data.htmlLink }, null, 2));
|
||||
}
|
||||
|
||||
(async function main() {
|
||||
async function main() {
|
||||
try {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cmd = args._[0];
|
||||
@@ -245,4 +315,19 @@ async function cmdCreateEvent(clients, opts) {
|
||||
console.error(`ERROR: ${err.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAttachmentContentType,
|
||||
loadAttachments,
|
||||
main,
|
||||
makeRawEmail,
|
||||
parseArgs,
|
||||
resolveKeyPath,
|
||||
toArray,
|
||||
wrapBase64,
|
||||
};
|
||||
|
||||
34
integrations/google-workspace/gw.test.js
Normal file
34
integrations/google-workspace/gw.test.js
Normal file
@@ -0,0 +1,34 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const path = require('node:path');
|
||||
const { makeRawEmail } = require('./gw.js');
|
||||
|
||||
test('help documents attachment support for send', () => {
|
||||
const gwPath = path.join(__dirname, 'gw.js');
|
||||
const result = spawnSync(process.execPath, [gwPath, 'help'], { encoding: 'utf8' });
|
||||
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /send --to <email> --subject <text> --body <text> \[--html\] \[--attach <file>\]/);
|
||||
});
|
||||
|
||||
test('makeRawEmail builds multipart messages when attachments are present', () => {
|
||||
const raw = makeRawEmail({
|
||||
from: 'stefano@fiorinis.com',
|
||||
to: 'stefano@fiorinis.com',
|
||||
subject: 'Attachment test',
|
||||
body: 'Attached PDF.',
|
||||
attachments: [
|
||||
{
|
||||
filename: 'report.pdf',
|
||||
contentType: 'application/pdf',
|
||||
data: Buffer.from('%PDF-1.4\n%test\n').toString('base64'),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const decoded = Buffer.from(raw, 'base64').toString('utf8');
|
||||
assert.match(decoded, /Content-Type: multipart\/mixed; boundary=/);
|
||||
assert.match(decoded, /Content-Disposition: attachment; filename="report\.pdf"/);
|
||||
assert.match(decoded, /Content-Type: application\/pdf; name="report\.pdf"/);
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
"description": "",
|
||||
"main": "gw.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"test": "node --test"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
|
||||
159
skills/flight-finder/SKILL.md
Normal file
159
skills/flight-finder/SKILL.md
Normal file
@@ -0,0 +1,159 @@
|
||||
---
|
||||
name: flight-finder
|
||||
description: Use when a user wants a flight-search report with explicit trip constraints, ranked options, and PDF/email delivery. Prefer when the request may include split returns, flexible dates, passenger groups, market-localized pricing, or a WhatsApp-safe workflow that should keep going after status nudges instead of hanging behind a long inline prompt.
|
||||
---
|
||||
|
||||
# Flight Finder
|
||||
|
||||
Use this skill instead of the old `docs/prompts/dfw-blq-2026.md` prompt when the user wants a flight-search report.
|
||||
|
||||
The deliverable target is always a PDF report plus email delivery when the report is complete.
|
||||
|
||||
PDF output rule:
|
||||
|
||||
- Render aggregator booking links for every quoted fare.
|
||||
- Render direct-airline booking links when they were actually captured.
|
||||
- If a direct-airline link was not captured for a quote, say so explicitly in the report instead of implying one exists.
|
||||
|
||||
Freshness rule:
|
||||
|
||||
- Never reuse cached flight-search captures, prior same-day report payloads, earlier workspace artifacts, or previously rendered PDFs as the primary evidence for a new run.
|
||||
- Every flight-finder run must execute a fresh bounded search before ranking, PDF render, or email delivery.
|
||||
- If a fresh search cannot be completed, say so clearly and stop with a degraded/incomplete outcome instead of silently reusing old captures.
|
||||
|
||||
## Inputs
|
||||
|
||||
Accept either:
|
||||
|
||||
- a natural-language trip request
|
||||
- a structured helper payload
|
||||
- a route plus partial constraints that still need follow-up questions
|
||||
|
||||
Collect these explicitly when missing:
|
||||
|
||||
- origin and destination
|
||||
- outbound date window
|
||||
- whether there are one or more return legs
|
||||
- passenger groups
|
||||
- which passenger groups travel on which legs
|
||||
- flexibility rules
|
||||
- cabin / stop / layover preferences when relevant
|
||||
- exclusions such as airlines, countries, or airports when relevant
|
||||
- recipient email before final render/send
|
||||
|
||||
Optional explicit input:
|
||||
|
||||
- `marketCountry`
|
||||
- if present, it must be a validated ISO 3166-1 alpha-2 uppercase country code such as `TH` or `DE`
|
||||
- if present, it activates the VPN / market-localized search path for the bounded search phase only
|
||||
- if absent, do not change VPN in this implementation pass
|
||||
|
||||
Do not silently infer or reuse missing trip-shape details from earlier runs just because the route looks similar.
|
||||
If the user already explicitly said where to email the finished PDF, that counts as delivery authorization once the report is complete. Do not ask for a second send confirmation unless the destination changed or the user sounded uncertain.
|
||||
|
||||
## Core workflow
|
||||
|
||||
1. Normalize the trip request into typed legs, passenger groups, assignments, and preferences.
|
||||
2. Ask targeted follow-up questions for missing search-critical inputs.
|
||||
3. Run the bounded search phase across the configured travel sources.
|
||||
4. Rank the viable combinations in USD.
|
||||
5. Assemble the final report payload.
|
||||
6. Render the PDF.
|
||||
7. Send the email from Luke to the user-specified recipient.
|
||||
|
||||
Completion rule:
|
||||
|
||||
- A partial source result is not completion.
|
||||
- A partial helper result is not completion.
|
||||
- The workflow is complete only when the final report payload exists, the PDF is rendered, the email was sent, and any VPN cleanup required by the run has been attempted.
|
||||
- An interim commentary/status line is not completion.
|
||||
- For isolated cron-style runs, do not leave the run with only a phase-update summary. If PDF render or email send did not happen, the terminal result must say so explicitly as a failed or incomplete outcome.
|
||||
|
||||
## WhatsApp-safe behavior
|
||||
|
||||
Follow the same operational style as `property-assessor`:
|
||||
|
||||
- missing required input should trigger a direct question, not an invented assumption
|
||||
- `update?`, `status?`, `and?`, and similar nudges mean “report status and keep going”
|
||||
- a silent helper/source path is a failed path and should be abandoned quickly
|
||||
- previously saved workspace captures are not a fallback; they are stale evidence and must not be reused for a new report
|
||||
- keep progress state by phase so a dropped session can resume or at least report the last completed phase
|
||||
- do not start email-tool exploration before the report payload is complete and the PDF is ready to render
|
||||
- if PDF render fails, return the completed report summary in chat and report delivery failure separately
|
||||
- if email send fails after render, say so clearly and keep the rendered PDF path
|
||||
|
||||
## Long-running command rule
|
||||
|
||||
- Do not fire a long-running local command and then abandon it.
|
||||
- If an `exec` call returns a running session/process handle instead of a completed result, immediately follow it with the relevant process polling/log calls until the command finishes, fails, or times out.
|
||||
- Treat VPN connect, fresh search sweeps, PDF render, and Gmail send as critical-path commands. They must be observed to completion; do not assume they succeeded just because the process started.
|
||||
- If a critical-path command cannot be observed to completion, stop claiming progress and report the run as incomplete.
|
||||
|
||||
## Isolated cron rule
|
||||
|
||||
- In isolated cron runs, success means all of the following are true:
|
||||
- fresh search artifacts exist for the current run
|
||||
- final `report-payload.json` exists for the current run
|
||||
- PDF render completed
|
||||
- Gmail send completed to the requested recipient
|
||||
- If the model/provider times out, the VPN step flakes, or the run ends before those artifacts exist, report a failure/incomplete outcome rather than an `ok`-sounding completion summary.
|
||||
- Do not end an isolated cron run on the VPN/connect phase alone.
|
||||
|
||||
## Search-source contract
|
||||
|
||||
Use the proven travel-source order from the local viability findings:
|
||||
|
||||
1. KAYAK
|
||||
2. Skyscanner
|
||||
3. Expedia
|
||||
4. airline direct-booking cross-check
|
||||
|
||||
Rules:
|
||||
|
||||
- treat source viability as evidence-based, not assumed
|
||||
- if a source is blocked or degraded, continue with the remaining sources and record the issue in the report
|
||||
- keep the bounded search phase observable with concise updates
|
||||
- normalize compared prices to USD before ranking
|
||||
- do not claim market-localized pricing succeeded unless VPN connect and post-connect verification succeeded
|
||||
- do not inspect prior `workspace/reports` flight artifacts as reusable search input for a new run
|
||||
|
||||
## VPN / market-country rules
|
||||
|
||||
- Use VPN only when `marketCountry` is explicitly provided in the typed request for this implementation pass.
|
||||
- Connect VPN only for the bounded search phase.
|
||||
- Bounded search phase means: from first travel-site navigation until the last search-result capture for the report.
|
||||
- If VPN connect or verification fails within the configured timeout, continue on the default market and mark the report as degraded.
|
||||
- Disconnect VPN immediately after the bounded search phase, before ranking/render/delivery.
|
||||
- On rollback or failure, attempt disconnect immediately.
|
||||
|
||||
## Delivery rules
|
||||
|
||||
- Sender must be Luke: `luke@fiorinis.com`
|
||||
- Delivery path must be Luke’s wrapper:
|
||||
- `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"`
|
||||
- If recipient email is missing, ask for it before the render/send phase.
|
||||
- If the user already provided the recipient email, do not ask for a redundant “send it” confirmation.
|
||||
|
||||
## Helper commands
|
||||
|
||||
From `~/.openclaw/workspace/skills/flight-finder/` or the repo mirror copy:
|
||||
|
||||
```bash
|
||||
npm run normalize-request -- --legacy-dfw-blq
|
||||
npm run normalize-request -- --input "<request.json>"
|
||||
npm run report-status -- --input "<report-payload.json>"
|
||||
npm run render-report -- --input "<report-payload.json>" --output "<report.pdf>"
|
||||
npm run delivery-plan -- --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `normalize-request` should report missing search inputs separately from delivery-only email gaps
|
||||
- `report-status` should expose whether the run is ready to search, ready for a chat summary, ready to render a PDF, or ready to email
|
||||
- `render-report` must reject incomplete report payloads
|
||||
- `render-report` must reject payloads that are not explicitly marked as the output of a fresh search run
|
||||
- `delivery-plan` must stay on the Luke sender path and must not silently fall back to another sender
|
||||
|
||||
## Prompt migration rule
|
||||
|
||||
The old DFW↔BLQ prompt is legacy input only. Do not treat it as freeform execution prose. Its file should contain a literal `use flight finder skill with the following json payload` request so manual runs and scheduled runs stay aligned.
|
||||
168
skills/flight-finder/examples/dfw-blq-2026-report-payload.json
Normal file
168
skills/flight-finder/examples/dfw-blq-2026-report-payload.json
Normal file
@@ -0,0 +1,168 @@
|
||||
{
|
||||
"request": {
|
||||
"tripName": "DFW ↔ BLQ flight report",
|
||||
"legs": [
|
||||
{
|
||||
"id": "outbound",
|
||||
"origin": "DFW",
|
||||
"destination": "BLQ",
|
||||
"earliest": "2026-05-30",
|
||||
"latest": "2026-06-07",
|
||||
"label": "Outbound"
|
||||
},
|
||||
{
|
||||
"id": "return-pair",
|
||||
"origin": "BLQ",
|
||||
"destination": "DFW",
|
||||
"relativeToLegId": "outbound",
|
||||
"minDaysAfter": 6,
|
||||
"maxDaysAfter": 10,
|
||||
"label": "Return for 2 adults"
|
||||
},
|
||||
{
|
||||
"id": "return-solo",
|
||||
"origin": "BLQ",
|
||||
"destination": "DFW",
|
||||
"earliest": "2026-06-28",
|
||||
"latest": "2026-07-05",
|
||||
"label": "Return for 1 adult"
|
||||
}
|
||||
],
|
||||
"passengerGroups": [
|
||||
{
|
||||
"id": "pair",
|
||||
"adults": 2,
|
||||
"label": "2 adults traveling together"
|
||||
},
|
||||
{
|
||||
"id": "solo",
|
||||
"adults": 1,
|
||||
"label": "1 adult returning separately"
|
||||
}
|
||||
],
|
||||
"legAssignments": [
|
||||
{
|
||||
"legId": "outbound",
|
||||
"passengerGroupIds": ["pair", "solo"]
|
||||
},
|
||||
{
|
||||
"legId": "return-pair",
|
||||
"passengerGroupIds": ["pair"]
|
||||
},
|
||||
{
|
||||
"legId": "return-solo",
|
||||
"passengerGroupIds": ["solo"]
|
||||
}
|
||||
],
|
||||
"recipientEmail": "stefano@fiorinis.com",
|
||||
"preferences": {
|
||||
"preferOneStop": true,
|
||||
"maxStops": 1,
|
||||
"maxLayoverHours": 6,
|
||||
"flexibleDates": true,
|
||||
"excludeAirlines": ["Turkish Airlines"],
|
||||
"excludeCountries": ["TR"],
|
||||
"requireAirlineDirectCrossCheck": true,
|
||||
"specialConstraints": [
|
||||
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||
"Start from a fresh browser/session profile for this search."
|
||||
],
|
||||
"geoPricingMarket": "Thailand",
|
||||
"marketCountry": "TH",
|
||||
"normalizeCurrencyTo": "USD"
|
||||
}
|
||||
},
|
||||
"searchExecution": {
|
||||
"freshSearch": true,
|
||||
"startedAt": "2026-03-30T21:00:00Z",
|
||||
"completedAt": "2026-03-30T21:20:00Z",
|
||||
"artifactsRoot": "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
|
||||
"notes": [
|
||||
"Fresh bounded Thailand-market search executed for this report payload.",
|
||||
"Prior workspace captures must not be reused across flight-finder runs."
|
||||
]
|
||||
},
|
||||
"sourceFindings": [
|
||||
{
|
||||
"source": "kayak",
|
||||
"status": "viable",
|
||||
"checkedAt": "2026-03-30T21:00:00Z",
|
||||
"notes": [
|
||||
"KAYAK returned multiple one-stop DFW -> BLQ options and exposed direct-booking hints for British Airways."
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "skyscanner",
|
||||
"status": "viable",
|
||||
"checkedAt": "2026-03-30T21:05:00Z",
|
||||
"notes": [
|
||||
"Skyscanner returned one-stop and multi-stop DFW -> BLQ results with total USD pricing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "expedia",
|
||||
"status": "viable",
|
||||
"checkedAt": "2026-03-30T21:10:00Z",
|
||||
"notes": [
|
||||
"Expedia returned one-way DFW -> BLQ options with clear per-traveler pricing."
|
||||
]
|
||||
},
|
||||
{
|
||||
"source": "airline-direct",
|
||||
"status": "degraded",
|
||||
"checkedAt": "2026-03-30T21:15:00Z",
|
||||
"notes": [
|
||||
"United's direct booking shell loaded with the route/date context but failed to complete the search."
|
||||
]
|
||||
}
|
||||
],
|
||||
"quotes": [
|
||||
{
|
||||
"id": "kayak-outbound-ba",
|
||||
"source": "kayak",
|
||||
"legId": "outbound",
|
||||
"passengerGroupIds": ["pair", "solo"],
|
||||
"bookingLink": "https://www.kayak.com/flights/DFW-BLQ/2026-05-30?adults=3&sort=bestflight_a&fs=stops=-2",
|
||||
"itinerarySummary": "British Airways via London Heathrow",
|
||||
"airlineName": "British Airways",
|
||||
"departureTimeLocal": "10:59 PM",
|
||||
"arrivalTimeLocal": "11:45 PM +1",
|
||||
"stopsText": "1 stop",
|
||||
"layoverText": "6h 15m in London Heathrow",
|
||||
"totalDurationText": "17h 46m",
|
||||
"totalPriceUsd": 2631,
|
||||
"displayPriceUsd": "$2,631 total",
|
||||
"directBookingUrl": "https://www.britishairways.com/",
|
||||
"crossCheckStatus": "failed",
|
||||
"notes": [
|
||||
"Observed on KAYAK at $877 per traveler for 3 adults.",
|
||||
"Airline-direct cross-check remains degraded in this implementation pass."
|
||||
]
|
||||
}
|
||||
],
|
||||
"rankedOptions": [
|
||||
{
|
||||
"id": "primary-outbound",
|
||||
"title": "Best observed outbound baseline",
|
||||
"quoteIds": ["kayak-outbound-ba"],
|
||||
"totalPriceUsd": 2631,
|
||||
"rationale": "Lowest observed one-stop fare in the bounded spike while still meeting the layover constraint."
|
||||
}
|
||||
],
|
||||
"executiveSummary": [
|
||||
"The bounded implementation-pass smoke test produced a viable report payload from real DFW -> BLQ search evidence.",
|
||||
"KAYAK, Skyscanner, and Expedia all returned usable results on this machine.",
|
||||
"Direct-airline cross-checking is currently best-effort and should be reported honestly when it fails."
|
||||
],
|
||||
"reportWarnings": [
|
||||
"This smoke-test payload captures the bounded implementation-pass workflow, not the full old prompt's multi-leg optimization."
|
||||
],
|
||||
"degradedReasons": [
|
||||
"Airline direct-booking cross-check is still degraded and must be treated as best-effort in this pass."
|
||||
],
|
||||
"comparisonCurrency": "USD",
|
||||
"marketCountryUsed": "TH",
|
||||
"lastCompletedPhase": "ranking",
|
||||
"generatedAt": "2026-03-30T21:20:00Z"
|
||||
}
|
||||
74
skills/flight-finder/examples/dfw-blq-2026-request.json
Normal file
74
skills/flight-finder/examples/dfw-blq-2026-request.json
Normal file
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"tripName": "DFW ↔ BLQ flight report",
|
||||
"legs": [
|
||||
{
|
||||
"id": "outbound",
|
||||
"origin": "DFW",
|
||||
"destination": "BLQ",
|
||||
"earliest": "2026-05-30",
|
||||
"latest": "2026-06-07",
|
||||
"label": "Outbound"
|
||||
},
|
||||
{
|
||||
"id": "return-pair",
|
||||
"origin": "BLQ",
|
||||
"destination": "DFW",
|
||||
"relativeToLegId": "outbound",
|
||||
"minDaysAfter": 6,
|
||||
"maxDaysAfter": 10,
|
||||
"label": "Return for 2 adults"
|
||||
},
|
||||
{
|
||||
"id": "return-solo",
|
||||
"origin": "BLQ",
|
||||
"destination": "DFW",
|
||||
"earliest": "2026-06-28",
|
||||
"latest": "2026-07-05",
|
||||
"label": "Return for 1 adult"
|
||||
}
|
||||
],
|
||||
"passengerGroups": [
|
||||
{
|
||||
"id": "pair",
|
||||
"adults": 2,
|
||||
"label": "2 adults traveling together"
|
||||
},
|
||||
{
|
||||
"id": "solo",
|
||||
"adults": 1,
|
||||
"label": "1 adult returning separately"
|
||||
}
|
||||
],
|
||||
"legAssignments": [
|
||||
{
|
||||
"legId": "outbound",
|
||||
"passengerGroupIds": ["pair", "solo"]
|
||||
},
|
||||
{
|
||||
"legId": "return-pair",
|
||||
"passengerGroupIds": ["pair"]
|
||||
},
|
||||
{
|
||||
"legId": "return-solo",
|
||||
"passengerGroupIds": ["solo"]
|
||||
}
|
||||
],
|
||||
"recipientEmail": "stefano@fiorinis.com",
|
||||
"preferences": {
|
||||
"preferOneStop": true,
|
||||
"maxStops": 1,
|
||||
"maxLayoverHours": 6,
|
||||
"flexibleDates": true,
|
||||
"excludeAirlines": ["Turkish Airlines"],
|
||||
"excludeCountries": ["TR"],
|
||||
"requireAirlineDirectCrossCheck": true,
|
||||
"specialConstraints": [
|
||||
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||
"Start from a fresh browser/session profile for this search."
|
||||
],
|
||||
"geoPricingMarket": "Thailand",
|
||||
"marketCountry": "TH",
|
||||
"normalizeCurrencyTo": "USD"
|
||||
}
|
||||
}
|
||||
791
skills/flight-finder/package-lock.json
generated
Normal file
791
skills/flight-finder/package-lock.json
generated
Normal file
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"name": "flight-finder-scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "flight-finder-scripts",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.20",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
|
||||
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz",
|
||||
"integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
skills/flight-finder/package.json
Normal file
23
skills/flight-finder/package.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "flight-finder-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "Flight finder helpers for request normalization, readiness gating, and fixed-template PDF rendering",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"normalize-request": "tsx src/cli.ts normalize-request",
|
||||
"report-status": "tsx src/cli.ts report-status",
|
||||
"render-report": "tsx src/cli.ts render-report",
|
||||
"delivery-plan": "tsx src/cli.ts delivery-plan",
|
||||
"test": "node --import tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
75
skills/flight-finder/references/source-viability.md
Normal file
75
skills/flight-finder/references/source-viability.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# Flight Finder Source Viability
|
||||
|
||||
This note records bounded live checks for the first implementation pass.
|
||||
|
||||
It is intentionally operational, not aspirational: if a source is blocked here, the skill must treat it as blocked unless later evidence replaces this note.
|
||||
|
||||
## Required sources
|
||||
|
||||
- KAYAK
|
||||
- Skyscanner
|
||||
- Expedia
|
||||
- airline direct-booking cross-check
|
||||
|
||||
## Status
|
||||
|
||||
Checked on `2026-03-30` from Stefano's MacBook Air, with no VPN active, using the existing local `web-automation` / CloakBrowser probe scripts.
|
||||
|
||||
Route used for bounded checks:
|
||||
|
||||
- `DFW -> BLQ`
|
||||
- outbound date: `2026-05-30`
|
||||
- travelers: `3 adults`
|
||||
|
||||
### KAYAK
|
||||
|
||||
- Status: `viable`
|
||||
- Probe: `node flight_kayak_sweep.mjs DFW BLQ 3 /tmp/flight-finder-kayak-dates.json ...`
|
||||
- Evidence:
|
||||
- title returned as `DFW to BLQ, 5/30`
|
||||
- results included multiple one-stop itineraries with parsed USD fares such as `$877`, `$949`, `$955`
|
||||
- direct-booking hints were visible for British Airways on at least some results
|
||||
- Implementation note:
|
||||
- KAYAK can be a primary source in this first implementation pass
|
||||
- parsed text is workable, but still brittle enough that bounded retries and status fallback remain necessary
|
||||
|
||||
### Skyscanner
|
||||
|
||||
- Status: `viable`
|
||||
- Probe: `node tmp_skyscanner_probe.mjs 'https://www.skyscanner.com/transport/flights/dfw/blq/260530/?adultsv2=3&cabinclass=economy&rtn=0'`
|
||||
- Evidence:
|
||||
- title returned as `Cheap flights from Dallas to Bologna on Skyscanner`
|
||||
- results page exposed concrete prices, total trip prices, stops, and itinerary text
|
||||
- one-stop and multi-stop options were visible in the captured text
|
||||
- Implementation note:
|
||||
- Skyscanner is viable for bounded result capture in this first pass
|
||||
- itinerary extraction should still be treated as text-scrape, not a stable API
|
||||
|
||||
### Expedia
|
||||
|
||||
- Status: `viable`
|
||||
- Probe: `node tmp_expedia_probe.mjs 'https://www.expedia.com/Flights-Search?...'`
|
||||
- Evidence:
|
||||
- title returned as `DFW to BLQ flights`
|
||||
- results page exposed current lowest price, airline/stops filters, and concrete per-traveler options such as `$877`, `$949`, `$961`
|
||||
- Expedia text already surfaced some itinerary summaries in a report-friendly format
|
||||
- Implementation note:
|
||||
- Expedia is viable for bounded result capture in this first pass
|
||||
- as with the other aggregators, source-specific timeouts and fallback rules are still required
|
||||
|
||||
### Airline direct-booking cross-check
|
||||
|
||||
- Status: `degraded`
|
||||
- Probe: `node tmp_skyscanner_probe.mjs 'https://www.united.com/en/us/fsr/choose-flights?...'`
|
||||
- Evidence:
|
||||
- United's booking shell loaded and recognized the route / date context
|
||||
- the search then returned `united.com was unable to complete your request. Please try again later.`
|
||||
- Implementation note:
|
||||
- direct-airline cross-checking remains in scope, but it should be treated as best-effort in the first pass
|
||||
- when a direct site fails or refuses completion, the skill should record the failure explicitly instead of hanging or pretending a clean cross-check happened
|
||||
|
||||
## Scope decision for implementation pass 1
|
||||
|
||||
- Primary bounded search sources: `KAYAK`, `Skyscanner`, `Expedia`
|
||||
- Direct-airline cross-check: `best-effort / degraded`
|
||||
- The skill should continue if the direct-airline step fails, but the report must say that the direct cross-check was not fully completed
|
||||
86
skills/flight-finder/src/cli.ts
Normal file
86
skills/flight-finder/src/cli.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import fs from "node:fs/promises";
|
||||
import minimist from "minimist";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "./request-normalizer.js";
|
||||
import { getFlightReportStatus } from "./report-status.js";
|
||||
import { renderFlightReportPdf } from "./report-pdf.js";
|
||||
import { buildLukeDeliveryPlan } from "./report-delivery.js";
|
||||
import type { FlightReportPayload, FlightReportRequestDraft } from "./types.js";
|
||||
|
||||
function usage(): never {
|
||||
throw new Error(`Usage:
|
||||
flight-finder normalize-request --input "<request.json>"
|
||||
flight-finder normalize-request --legacy-dfw-blq
|
||||
flight-finder report-status --input "<report-payload.json>"
|
||||
flight-finder render-report --input "<report-payload.json>" --output "<report.pdf>"
|
||||
flight-finder delivery-plan --to "<recipient@example.com>" --subject "<subject>" --body "<body>" --attach "<report.pdf>"`);
|
||||
}
|
||||
|
||||
async function readJsonFile<T>(filePath: string): Promise<T> {
|
||||
return JSON.parse(await fs.readFile(filePath, "utf8")) as T;
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
string: ["input", "output", "to", "subject", "body", "attach"],
|
||||
boolean: ["legacy-dfw-blq"]
|
||||
});
|
||||
|
||||
const command = argv._[0];
|
||||
if (!command) {
|
||||
usage();
|
||||
}
|
||||
|
||||
if (command === "normalize-request") {
|
||||
const draft = argv["legacy-dfw-blq"]
|
||||
? DFW_BLQ_2026_PROMPT_DRAFT
|
||||
: await readJsonFile<FlightReportRequestDraft>(argv.input || usage());
|
||||
console.log(JSON.stringify(normalizeFlightReportRequest(draft), null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "report-status") {
|
||||
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
getFlightReportStatus(normalizeFlightReportRequest(payload.request), payload),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "render-report") {
|
||||
const payload = await readJsonFile<FlightReportPayload>(argv.input || usage());
|
||||
const rendered = await renderFlightReportPdf(payload, argv.output || usage());
|
||||
console.log(rendered);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "delivery-plan") {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
buildLukeDeliveryPlan({
|
||||
recipientEmail: argv.to,
|
||||
subject: argv.subject || "",
|
||||
body: argv.body || "",
|
||||
attachmentPath: argv.attach || usage()
|
||||
}),
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
usage();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error instanceof Error ? error.message : String(error));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
25
skills/flight-finder/src/input-validation.ts
Normal file
25
skills/flight-finder/src/input-validation.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
export function normalizeMarketCountry(
|
||||
value: string | null | undefined
|
||||
): string | null | undefined {
|
||||
const cleaned = typeof value === "string" ? value.trim() : undefined;
|
||||
if (!cleaned) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = cleaned.toUpperCase();
|
||||
if (!/^[A-Z]{2}$/.test(normalized)) {
|
||||
throw new Error(
|
||||
`Invalid marketCountry "${value}". Use an ISO 3166-1 alpha-2 uppercase country code such as "TH" or "DE".`
|
||||
);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function isPlausibleEmail(value: string | null | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
|
||||
}
|
||||
36
skills/flight-finder/src/price-normalization.ts
Normal file
36
skills/flight-finder/src/price-normalization.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export type NormalizePriceInput = {
|
||||
amount: number;
|
||||
currency: string;
|
||||
exchangeRates?: Record<string, number>;
|
||||
};
|
||||
|
||||
export type NormalizedPrice = {
|
||||
currency: "USD";
|
||||
amountUsd: number;
|
||||
notes: string[];
|
||||
};
|
||||
|
||||
export function normalizePriceToUsd(
|
||||
input: NormalizePriceInput
|
||||
): NormalizedPrice {
|
||||
const currency = input.currency.trim().toUpperCase();
|
||||
if (currency === "USD") {
|
||||
return {
|
||||
currency: "USD",
|
||||
amountUsd: input.amount,
|
||||
notes: ["Price already in USD."]
|
||||
};
|
||||
}
|
||||
|
||||
const rate = input.exchangeRates?.[currency];
|
||||
if (typeof rate !== "number" || !Number.isFinite(rate) || rate <= 0) {
|
||||
throw new Error(`Missing usable exchange rate for ${currency}.`);
|
||||
}
|
||||
|
||||
const amountUsd = Math.round(input.amount * rate * 100) / 100;
|
||||
return {
|
||||
currency: "USD",
|
||||
amountUsd,
|
||||
notes: [`Converted ${currency} to USD using rate ${rate}.`]
|
||||
};
|
||||
}
|
||||
50
skills/flight-finder/src/report-delivery.ts
Normal file
50
skills/flight-finder/src/report-delivery.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { isPlausibleEmail } from "./input-validation.js";
|
||||
|
||||
type DeliveryPlanInput = {
|
||||
recipientEmail?: string | null;
|
||||
subject: string;
|
||||
body: string;
|
||||
attachmentPath: string;
|
||||
};
|
||||
|
||||
export type DeliveryPlan = {
|
||||
ready: boolean;
|
||||
needsRecipientEmail: boolean;
|
||||
command: string | null;
|
||||
sender: "luke@fiorinis.com";
|
||||
recipientEmail: string | null;
|
||||
};
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, `'\"'\"'`)}'`;
|
||||
}
|
||||
|
||||
export function buildLukeDeliveryPlan(input: DeliveryPlanInput): DeliveryPlan {
|
||||
const recipientEmail = input.recipientEmail?.trim() || null;
|
||||
if (!recipientEmail || !isPlausibleEmail(recipientEmail)) {
|
||||
return {
|
||||
ready: false,
|
||||
needsRecipientEmail: true,
|
||||
command: null,
|
||||
sender: "luke@fiorinis.com",
|
||||
recipientEmail
|
||||
};
|
||||
}
|
||||
|
||||
const validRecipientEmail: string = recipientEmail;
|
||||
const command = [
|
||||
"zsh ~/.openclaw/workspace/bin/gog-luke gmail send",
|
||||
`--to ${shellQuote(validRecipientEmail)}`,
|
||||
`--subject ${shellQuote(input.subject)}`,
|
||||
`--body ${shellQuote(input.body)}`,
|
||||
`--attach ${shellQuote(input.attachmentPath)}`
|
||||
].join(" ");
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
needsRecipientEmail: false,
|
||||
command,
|
||||
sender: "luke@fiorinis.com",
|
||||
recipientEmail: validRecipientEmail
|
||||
};
|
||||
}
|
||||
407
skills/flight-finder/src/report-pdf.ts
Normal file
407
skills/flight-finder/src/report-pdf.ts
Normal file
@@ -0,0 +1,407 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import PDFDocument from "pdfkit";
|
||||
|
||||
import { getFlightReportStatus } from "./report-status.js";
|
||||
import { normalizeFlightReportRequest } from "./request-normalizer.js";
|
||||
import type {
|
||||
FlightLegWindow,
|
||||
FlightPassengerGroup,
|
||||
FlightQuote,
|
||||
FlightRecommendationOption,
|
||||
FlightReportPayload
|
||||
} from "./types.js";
|
||||
|
||||
export class ReportValidationError extends Error {}
|
||||
|
||||
function bulletLines(value: string[] | undefined, fallback = "Not provided."): string[] {
|
||||
return value?.length ? value : [fallback];
|
||||
}
|
||||
|
||||
function asLegLabelMap(legs: FlightLegWindow[]): Map<string, string> {
|
||||
return new Map(legs.map((leg) => [leg.id, leg.label || `${leg.origin} -> ${leg.destination}`]));
|
||||
}
|
||||
|
||||
function asPassengerGroupLabelMap(groups: FlightPassengerGroup[]): Map<string, string> {
|
||||
return new Map(
|
||||
groups.map((group) => [group.id, group.label || `${group.adults} adult${group.adults === 1 ? "" : "s"}`])
|
||||
);
|
||||
}
|
||||
|
||||
function formatUsd(value: number): string {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
maximumFractionDigits: 0
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
function formatDateWindow(leg: FlightLegWindow): string {
|
||||
if (leg.earliest && leg.latest) {
|
||||
return leg.earliest === leg.latest ? leg.earliest : `${leg.earliest} to ${leg.latest}`;
|
||||
}
|
||||
if (leg.relativeToLegId) {
|
||||
const min = leg.minDaysAfter ?? "?";
|
||||
const max = leg.maxDaysAfter ?? "?";
|
||||
return `${min}-${max} days after ${leg.relativeToLegId}`;
|
||||
}
|
||||
return "Not provided";
|
||||
}
|
||||
|
||||
function ensurePageSpace(doc: PDFKit.PDFDocument, requiredHeight: number): void {
|
||||
const bottomLimit = doc.page.height - doc.page.margins.bottom;
|
||||
if (doc.y + requiredHeight > bottomLimit) {
|
||||
doc.addPage();
|
||||
}
|
||||
}
|
||||
|
||||
const WORKSPACE_REPORTS_PREFIX = "/Users/stefano/.openclaw/workspace/reports/";
|
||||
|
||||
export function formatArtifactsRoot(value: string): string {
|
||||
const trimmed = String(value || "").trim();
|
||||
if (!trimmed) return "Not provided";
|
||||
if (trimmed.startsWith(WORKSPACE_REPORTS_PREFIX)) {
|
||||
return `reports/${trimmed.slice(WORKSPACE_REPORTS_PREFIX.length)}`;
|
||||
}
|
||||
return path.basename(trimmed) || trimmed;
|
||||
}
|
||||
|
||||
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
||||
ensurePageSpace(doc, 28);
|
||||
const x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const height = 18;
|
||||
|
||||
doc.save();
|
||||
doc.roundedRect(x, y, width, height, 3).fill("#10384E");
|
||||
doc
|
||||
.fillColor("white")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text(title, x + 8, y + 4, { width: width - 16 });
|
||||
doc.restore();
|
||||
doc.moveDown(1.2);
|
||||
}
|
||||
|
||||
function drawBulletList(doc: PDFKit.PDFDocument, items: string[] | undefined, fallback = "Not provided."): void {
|
||||
const lines = bulletLines(items, fallback);
|
||||
for (const line of lines) {
|
||||
const lineHeight = doc.heightOfString(line, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2
|
||||
});
|
||||
ensurePageSpace(doc, lineHeight + 12);
|
||||
const startY = doc.y;
|
||||
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#10384E");
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica")
|
||||
.fontSize(10.5)
|
||||
.text(line, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2
|
||||
});
|
||||
doc.moveDown(0.35);
|
||||
}
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
type QuoteBookingLink = {
|
||||
label: string;
|
||||
text: string;
|
||||
url: string | null;
|
||||
};
|
||||
|
||||
export function describeQuoteBookingLinks(quote: FlightQuote): QuoteBookingLink[] {
|
||||
return [
|
||||
{
|
||||
label: "Search / book this fare",
|
||||
text: `Search / book this fare: ${quote.displayPriceUsd} via ${quote.source}`,
|
||||
url: quote.bookingLink
|
||||
},
|
||||
{
|
||||
label: "Direct airline booking",
|
||||
text: quote.directBookingUrl
|
||||
? `Direct airline booking: ${quote.airlineName || "Airline site"}`
|
||||
: "Direct airline booking: not captured in this run",
|
||||
url: quote.directBookingUrl || null
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
function drawQuoteBookingLinks(doc: PDFKit.PDFDocument, links: QuoteBookingLink[], left: number, width: number): void {
|
||||
for (const linkItem of links) {
|
||||
const lineHeight = Math.max(
|
||||
doc.heightOfString(`${linkItem.label}:`, { width: 130 }),
|
||||
doc.heightOfString(linkItem.text.replace(`${linkItem.label}: `, ""), {
|
||||
width: width - 132,
|
||||
lineGap: 1
|
||||
})
|
||||
);
|
||||
ensurePageSpace(doc, lineHeight + 8);
|
||||
const startY = doc.y;
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(9.5)
|
||||
.text(`${linkItem.label}:`, left, startY, { width: 130 });
|
||||
doc
|
||||
.fillColor(linkItem.url ? "#0B5EA8" : "#4F5B66")
|
||||
.font("Helvetica")
|
||||
.fontSize(9.5)
|
||||
.text(linkItem.text.replace(`${linkItem.label}: `, ""), left + 132, startY, {
|
||||
width: width - 132,
|
||||
lineGap: 1,
|
||||
...(linkItem.url ? { link: linkItem.url, underline: true } : {})
|
||||
});
|
||||
doc.moveDown(0.25);
|
||||
}
|
||||
}
|
||||
|
||||
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
|
||||
const left = doc.page.margins.left;
|
||||
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const keyWidth = 170;
|
||||
const valueWidth = totalWidth - keyWidth;
|
||||
|
||||
for (const [key, value] of rows) {
|
||||
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
|
||||
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
|
||||
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
|
||||
ensurePageSpace(doc, rowHeight + 8);
|
||||
const startY = doc.y;
|
||||
|
||||
doc.save();
|
||||
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
|
||||
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
|
||||
doc
|
||||
.lineWidth(0.5)
|
||||
.strokeColor("#C7D0D9")
|
||||
.rect(left, startY, totalWidth, rowHeight)
|
||||
.stroke();
|
||||
doc.moveTo(left + keyWidth, startY).lineTo(left + keyWidth, startY + rowHeight).stroke();
|
||||
doc.restore();
|
||||
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(9.5)
|
||||
.text(key, left + 6, startY + 6, { width: keyWidth - 12 });
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica")
|
||||
.fontSize(9.5)
|
||||
.text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
|
||||
doc.y = startY + rowHeight;
|
||||
}
|
||||
doc.moveDown(0.8);
|
||||
}
|
||||
|
||||
function ensureFreshSearch(payload: FlightReportPayload): void {
|
||||
const searchExecution = payload.searchExecution;
|
||||
if (!searchExecution?.freshSearch) {
|
||||
throw new ReportValidationError(
|
||||
"The flight report payload must come from a fresh search run. Cached or previously captured results are not allowed."
|
||||
);
|
||||
}
|
||||
|
||||
const startedAt = Date.parse(searchExecution.startedAt);
|
||||
const completedAt = Date.parse(searchExecution.completedAt);
|
||||
if (!Number.isFinite(startedAt) || !Number.isFinite(completedAt) || completedAt < startedAt) {
|
||||
throw new ReportValidationError(
|
||||
"The flight report payload must include a valid fresh-search time window."
|
||||
);
|
||||
}
|
||||
|
||||
if (!String(searchExecution.artifactsRoot || "").trim()) {
|
||||
throw new ReportValidationError(
|
||||
"The flight report payload must include the artifacts root for the fresh search run."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function drawItineraryCard(
|
||||
doc: PDFKit.PDFDocument,
|
||||
option: FlightRecommendationOption,
|
||||
quotesById: Map<string, FlightQuote>,
|
||||
legLabels: Map<string, string>,
|
||||
passengerLabels: Map<string, string>
|
||||
): void {
|
||||
const left = doc.page.margins.left;
|
||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const badge = option.backup ? "Backup option" : "Primary recommendation";
|
||||
const quoteBlocks = option.quoteIds.flatMap((quoteId) => {
|
||||
const quote = quotesById.get(quoteId);
|
||||
if (!quote) {
|
||||
return [
|
||||
{
|
||||
heading: `Missing quote reference: ${quoteId}`,
|
||||
details: [],
|
||||
links: []
|
||||
}
|
||||
];
|
||||
}
|
||||
const passengerLine = quote.passengerGroupIds
|
||||
.map((groupId) => passengerLabels.get(groupId) || groupId)
|
||||
.join(", ");
|
||||
const legLine = legLabels.get(quote.legId) || quote.legId;
|
||||
return [
|
||||
{
|
||||
heading: `${legLine}: ${quote.itinerarySummary} (${quote.displayPriceUsd})`,
|
||||
details: [
|
||||
`Passengers: ${passengerLine}`,
|
||||
`Timing: ${quote.departureTimeLocal || "?"} -> ${quote.arrivalTimeLocal || "?"}`,
|
||||
`Routing: ${quote.stopsText || "stops?"}; ${quote.layoverText || "layover?"}; ${quote.totalDurationText || "duration?"}`,
|
||||
...(quote.notes || []).map((note) => `Note: ${note}`)
|
||||
],
|
||||
links: describeQuoteBookingLinks(quote)
|
||||
}
|
||||
];
|
||||
});
|
||||
ensurePageSpace(doc, 28);
|
||||
const top = doc.y;
|
||||
doc.save();
|
||||
doc.roundedRect(left, top, width, 22, 4).fill(option.backup ? "#586E75" : "#1E6B52");
|
||||
doc
|
||||
.fillColor("white")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(10.5)
|
||||
.text(`${badge}: ${option.title}`, left + 10, top + 6.5, { width: width - 20 });
|
||||
doc.restore();
|
||||
doc.y = top + 28;
|
||||
|
||||
drawKeyValueTable(doc, [
|
||||
["Recommendation total", formatUsd(option.totalPriceUsd)],
|
||||
["Why it ranked here", option.rationale]
|
||||
]);
|
||||
|
||||
for (const block of quoteBlocks) {
|
||||
ensurePageSpace(doc, 22);
|
||||
doc
|
||||
.fillColor("#10384E")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(10)
|
||||
.text(block.heading, left, doc.y, {
|
||||
width,
|
||||
lineGap: 2
|
||||
});
|
||||
doc.moveDown(0.2);
|
||||
drawBulletList(doc, block.details);
|
||||
drawQuoteBookingLinks(doc, block.links, left, width);
|
||||
doc.moveDown(0.5);
|
||||
}
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
export async function renderFlightReportPdf(
|
||||
payload: FlightReportPayload,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
const normalizedRequest = normalizeFlightReportRequest(payload.request);
|
||||
const status = getFlightReportStatus(normalizedRequest, payload);
|
||||
|
||||
if (!status.pdfReady) {
|
||||
throw new ReportValidationError(
|
||||
"The flight report payload is still incomplete. Finish the report before generating the PDF."
|
||||
);
|
||||
}
|
||||
|
||||
ensureFreshSearch(payload);
|
||||
|
||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
margin: 50,
|
||||
info: {
|
||||
Title: payload.request.tripName || "Flight report",
|
||||
Author: "OpenClaw flight-finder"
|
||||
}
|
||||
});
|
||||
|
||||
const stream = fs.createWriteStream(outputPath);
|
||||
doc.pipe(stream);
|
||||
|
||||
const request = normalizedRequest.request;
|
||||
const legLabels = asLegLabelMap(request.legs);
|
||||
const passengerLabels = asPassengerGroupLabelMap(request.passengerGroups);
|
||||
const quotesById = new Map(payload.quotes.map((quote) => [quote.id, quote]));
|
||||
|
||||
try {
|
||||
doc.fillColor("#10384E").font("Helvetica-Bold").fontSize(22).text(request.tripName || "Flight report");
|
||||
doc.moveDown(0.2);
|
||||
doc
|
||||
.fillColor("#4F5B66")
|
||||
.font("Helvetica")
|
||||
.fontSize(10)
|
||||
.text(`Generated: ${payload.generatedAt}`);
|
||||
doc
|
||||
.fillColor("#4F5B66")
|
||||
.font("Helvetica")
|
||||
.fontSize(10)
|
||||
.text(
|
||||
`Market: ${payload.marketCountryUsed || "Default market"} | Fresh search completed: ${payload.searchExecution.completedAt}`
|
||||
);
|
||||
doc.moveDown();
|
||||
|
||||
drawSectionHeader(doc, "Trip Snapshot");
|
||||
drawKeyValueTable(doc, [
|
||||
["Recipient", request.recipientEmail || "Missing"],
|
||||
["Passenger groups", request.passengerGroups.map((group) => group.label || `${group.adults} adults`).join(" | ")],
|
||||
["Preferences", [
|
||||
request.preferences.flexibleDates ? "Flexible dates" : "Fixed dates",
|
||||
request.preferences.maxStops != null ? `Max stops ${request.preferences.maxStops}` : "Stops not capped",
|
||||
request.preferences.maxLayoverHours != null ? `Layover <= ${request.preferences.maxLayoverHours}h` : "Layover open"
|
||||
].join(" | ")],
|
||||
["Market-country mode", request.preferences.marketCountry || "Off"],
|
||||
["Search artifacts", formatArtifactsRoot(payload.searchExecution.artifactsRoot)]
|
||||
]);
|
||||
|
||||
drawSectionHeader(doc, "Legs And Travelers");
|
||||
for (const leg of request.legs) {
|
||||
const assignedGroups = request.legAssignments
|
||||
.find((assignment) => assignment.legId === leg.id)
|
||||
?.passengerGroupIds.map((groupId) => passengerLabels.get(groupId) || groupId)
|
||||
.join(", ") || "Not assigned";
|
||||
drawKeyValueTable(doc, [
|
||||
["Leg", leg.label || leg.id],
|
||||
["Route", `${leg.origin} -> ${leg.destination}`],
|
||||
["Window", formatDateWindow(leg)],
|
||||
["Travelers", assignedGroups]
|
||||
]);
|
||||
}
|
||||
|
||||
drawSectionHeader(doc, "Executive Summary");
|
||||
drawBulletList(doc, payload.executiveSummary);
|
||||
|
||||
drawSectionHeader(doc, "Recommended Options");
|
||||
payload.rankedOptions.forEach((option) => drawItineraryCard(doc, option, quotesById, legLabels, passengerLabels));
|
||||
|
||||
drawSectionHeader(doc, "Source Findings");
|
||||
payload.sourceFindings.forEach((finding) => {
|
||||
drawKeyValueTable(doc, [
|
||||
["Source", finding.source],
|
||||
["Status", finding.status],
|
||||
["Checked", finding.checkedAt]
|
||||
]);
|
||||
drawBulletList(doc, finding.notes, "No source notes.");
|
||||
});
|
||||
|
||||
drawSectionHeader(doc, "Warnings And Degraded Conditions");
|
||||
drawBulletList(doc, [...payload.reportWarnings, ...payload.degradedReasons], "No additional warnings.");
|
||||
|
||||
doc.end();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("finish", () => resolve());
|
||||
stream.on("error", reject);
|
||||
});
|
||||
} catch (error) {
|
||||
stream.destroy();
|
||||
await fs.promises.unlink(outputPath).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
83
skills/flight-finder/src/report-status.ts
Normal file
83
skills/flight-finder/src/report-status.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import type {
|
||||
FlightReportPayload,
|
||||
FlightReportStatusResult,
|
||||
NormalizedFlightReportRequest
|
||||
} from "./types.js";
|
||||
import { isPlausibleEmail } from "./input-validation.js";
|
||||
|
||||
export function getFlightReportStatus(
|
||||
normalizedRequest: NormalizedFlightReportRequest,
|
||||
payload?: FlightReportPayload | null
|
||||
): FlightReportStatusResult {
|
||||
if (normalizedRequest.missingSearchInputs.length) {
|
||||
return {
|
||||
needsMissingInputs: normalizedRequest.missingSearchInputs,
|
||||
readyToSearch: false,
|
||||
pdfReady: false,
|
||||
emailReady: false,
|
||||
chatSummaryReady: false,
|
||||
terminalOutcome: "missing-inputs",
|
||||
degraded: false,
|
||||
degradedReasons: [],
|
||||
blockingReason: "Missing search-critical trip inputs."
|
||||
};
|
||||
}
|
||||
|
||||
if (!payload) {
|
||||
return {
|
||||
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
|
||||
readyToSearch: true,
|
||||
pdfReady: false,
|
||||
emailReady: false,
|
||||
chatSummaryReady: false,
|
||||
terminalOutcome: "report-incomplete",
|
||||
degraded: false,
|
||||
degradedReasons: [],
|
||||
lastCompletedPhase: "intake",
|
||||
blockingReason: "Search and report assembly have not completed yet."
|
||||
};
|
||||
}
|
||||
|
||||
const allSourcesFailed =
|
||||
payload.sourceFindings.length > 0 &&
|
||||
payload.sourceFindings.every((finding) => finding.status === "blocked") &&
|
||||
payload.quotes.length === 0;
|
||||
|
||||
if (allSourcesFailed) {
|
||||
return {
|
||||
needsMissingInputs: normalizedRequest.missingDeliveryInputs,
|
||||
readyToSearch: true,
|
||||
pdfReady: false,
|
||||
emailReady: false,
|
||||
chatSummaryReady: true,
|
||||
terminalOutcome: "all-sources-failed",
|
||||
degraded: true,
|
||||
degradedReasons: payload.degradedReasons,
|
||||
lastCompletedPhase: payload.lastCompletedPhase || "search",
|
||||
blockingReason: "All configured travel sources failed or were blocked."
|
||||
};
|
||||
}
|
||||
|
||||
const reportComplete = Boolean(
|
||||
payload.quotes.length &&
|
||||
payload.rankedOptions.length &&
|
||||
payload.executiveSummary.length
|
||||
);
|
||||
const validRecipient = isPlausibleEmail(normalizedRequest.request.recipientEmail);
|
||||
|
||||
return {
|
||||
needsMissingInputs: validRecipient ? [] : normalizedRequest.missingDeliveryInputs,
|
||||
readyToSearch: true,
|
||||
pdfReady: reportComplete,
|
||||
emailReady: reportComplete && validRecipient,
|
||||
chatSummaryReady:
|
||||
payload.quotes.length > 0 ||
|
||||
payload.rankedOptions.length > 0 ||
|
||||
payload.executiveSummary.length > 0,
|
||||
terminalOutcome: reportComplete ? "ready" : "report-incomplete",
|
||||
degraded: payload.degradedReasons.length > 0,
|
||||
degradedReasons: payload.degradedReasons,
|
||||
lastCompletedPhase: payload.lastCompletedPhase,
|
||||
blockingReason: reportComplete ? undefined : "The report payload is still incomplete."
|
||||
};
|
||||
}
|
||||
309
skills/flight-finder/src/request-normalizer.ts
Normal file
309
skills/flight-finder/src/request-normalizer.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
import type {
|
||||
FlightLegWindow,
|
||||
FlightPassengerGroup,
|
||||
FlightReportRequest,
|
||||
FlightReportRequestDraft,
|
||||
FlightSearchPreferences,
|
||||
NormalizedFlightReportRequest
|
||||
} from "./types.js";
|
||||
import { isPlausibleEmail, normalizeMarketCountry } from "./input-validation.js";
|
||||
|
||||
const DEFAULT_PREFERENCES: FlightSearchPreferences = {
|
||||
preferOneStop: true,
|
||||
maxLayoverHours: 6,
|
||||
normalizeCurrencyTo: "USD"
|
||||
};
|
||||
|
||||
function cleanString(value: string | null | undefined): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function uniqueStrings(values: Array<string | null | undefined> | undefined): string[] | undefined {
|
||||
if (!values?.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const cleaned = values
|
||||
.map((value) => cleanString(value))
|
||||
.filter((value): value is string => Boolean(value));
|
||||
|
||||
return cleaned.length ? Array.from(new Set(cleaned)) : undefined;
|
||||
}
|
||||
|
||||
function normalizePassengerGroups(
|
||||
groups: FlightReportRequestDraft["passengerGroups"]
|
||||
): FlightPassengerGroup[] {
|
||||
return (groups || [])
|
||||
.filter((group): group is NonNullable<typeof group> => Boolean(group))
|
||||
.map((group, index) => ({
|
||||
id: cleanString(group.id) || `group-${index + 1}`,
|
||||
adults: Number(group.adults || 0),
|
||||
children: Number(group.children || 0) || undefined,
|
||||
infants: Number(group.infants || 0) || undefined,
|
||||
label: cleanString(group.label)
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeLegs(legs: FlightReportRequestDraft["legs"]): FlightLegWindow[] {
|
||||
return (legs || [])
|
||||
.filter((leg): leg is NonNullable<typeof leg> => Boolean(leg))
|
||||
.map((leg, index) => ({
|
||||
id: cleanString(leg.id) || `leg-${index + 1}`,
|
||||
origin: cleanString(leg.origin) || "",
|
||||
destination: cleanString(leg.destination) || "",
|
||||
earliest: cleanString(leg.earliest),
|
||||
latest: cleanString(leg.latest),
|
||||
relativeToLegId: cleanString(leg.relativeToLegId),
|
||||
minDaysAfter:
|
||||
typeof leg.minDaysAfter === "number" && Number.isFinite(leg.minDaysAfter)
|
||||
? leg.minDaysAfter
|
||||
: undefined,
|
||||
maxDaysAfter:
|
||||
typeof leg.maxDaysAfter === "number" && Number.isFinite(leg.maxDaysAfter)
|
||||
? leg.maxDaysAfter
|
||||
: undefined,
|
||||
label: cleanString(leg.label)
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeAssignments(
|
||||
draft: FlightReportRequestDraft["legAssignments"]
|
||||
): FlightReportRequest["legAssignments"] {
|
||||
return (draft || [])
|
||||
.filter((assignment): assignment is NonNullable<typeof assignment> => Boolean(assignment))
|
||||
.map((assignment) => ({
|
||||
legId: cleanString(assignment.legId) || "",
|
||||
passengerGroupIds:
|
||||
uniqueStrings(
|
||||
(assignment.passengerGroupIds || []).map((value) =>
|
||||
typeof value === "string" ? value : undefined
|
||||
)
|
||||
) || []
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizePreferences(
|
||||
preferences: FlightReportRequestDraft["preferences"]
|
||||
): FlightSearchPreferences {
|
||||
const draft = preferences || {};
|
||||
return {
|
||||
...DEFAULT_PREFERENCES,
|
||||
cabin: draft.cabin,
|
||||
maxStops: typeof draft.maxStops === "number" ? draft.maxStops : undefined,
|
||||
preferOneStop:
|
||||
typeof draft.preferOneStop === "boolean"
|
||||
? draft.preferOneStop
|
||||
: DEFAULT_PREFERENCES.preferOneStop,
|
||||
maxLayoverHours:
|
||||
typeof draft.maxLayoverHours === "number"
|
||||
? draft.maxLayoverHours
|
||||
: DEFAULT_PREFERENCES.maxLayoverHours,
|
||||
excludeAirlines: uniqueStrings(draft.excludeAirlines),
|
||||
excludeCountries: uniqueStrings(draft.excludeCountries),
|
||||
excludeAirports: uniqueStrings(draft.excludeAirports),
|
||||
flexibleDates:
|
||||
typeof draft.flexibleDates === "boolean" ? draft.flexibleDates : undefined,
|
||||
requireAirlineDirectCrossCheck:
|
||||
typeof draft.requireAirlineDirectCrossCheck === "boolean"
|
||||
? draft.requireAirlineDirectCrossCheck
|
||||
: undefined,
|
||||
specialConstraints: uniqueStrings(draft.specialConstraints),
|
||||
geoPricingMarket: cleanString(draft.geoPricingMarket),
|
||||
marketCountry: normalizeMarketCountry(draft.marketCountry),
|
||||
normalizeCurrencyTo: "USD"
|
||||
};
|
||||
}
|
||||
|
||||
function collectMissingSearchInputs(request: FlightReportRequest): string[] {
|
||||
const missing = new Set<string>();
|
||||
|
||||
if (!request.legs.length) {
|
||||
missing.add("trip legs");
|
||||
}
|
||||
|
||||
if (!request.passengerGroups.length) {
|
||||
missing.add("passenger groups");
|
||||
}
|
||||
|
||||
for (const group of request.passengerGroups) {
|
||||
const total = group.adults + (group.children || 0) + (group.infants || 0);
|
||||
if (total <= 0) {
|
||||
missing.add(`traveler count for passenger group ${group.label || group.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
const legIds = new Set(request.legs.map((leg) => leg.id));
|
||||
const groupIds = new Set(request.passengerGroups.map((group) => group.id));
|
||||
|
||||
for (const leg of request.legs) {
|
||||
const label = leg.label || leg.id;
|
||||
if (!leg.origin || !leg.destination) {
|
||||
missing.add(`origin and destination for ${label}`);
|
||||
}
|
||||
|
||||
const hasAbsoluteWindow = Boolean(leg.earliest || leg.latest);
|
||||
const hasRelativeWindow = Boolean(
|
||||
leg.relativeToLegId &&
|
||||
(typeof leg.minDaysAfter === "number" || typeof leg.maxDaysAfter === "number")
|
||||
);
|
||||
|
||||
if (!hasAbsoluteWindow && !hasRelativeWindow) {
|
||||
missing.add(`date window for ${label}`);
|
||||
}
|
||||
|
||||
if (leg.relativeToLegId && !legIds.has(leg.relativeToLegId)) {
|
||||
missing.add(`valid reference leg for ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!request.legAssignments.length) {
|
||||
missing.add("passenger assignments for every leg");
|
||||
}
|
||||
|
||||
for (const leg of request.legs) {
|
||||
const label = leg.label || leg.id;
|
||||
const assignment = request.legAssignments.find((entry) => entry.legId === leg.id);
|
||||
if (!assignment) {
|
||||
missing.add(`passenger assignments for ${label}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!assignment.passengerGroupIds.length) {
|
||||
missing.add(`assigned travelers for ${label}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assignment.passengerGroupIds.some((groupId) => !groupIds.has(groupId))) {
|
||||
missing.add(`valid passenger-group references for ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(missing);
|
||||
}
|
||||
|
||||
export function normalizeFlightReportRequest(
|
||||
draft: FlightReportRequestDraft
|
||||
): NormalizedFlightReportRequest {
|
||||
const normalizedRecipientEmail = cleanString(draft.recipientEmail) || null;
|
||||
const request: FlightReportRequest = {
|
||||
tripName: cleanString(draft.tripName),
|
||||
legs: normalizeLegs(draft.legs),
|
||||
passengerGroups: normalizePassengerGroups(draft.passengerGroups),
|
||||
legAssignments: normalizeAssignments(draft.legAssignments),
|
||||
recipientEmail: normalizedRecipientEmail,
|
||||
preferences: normalizePreferences(draft.preferences)
|
||||
};
|
||||
|
||||
const missingSearchInputs = collectMissingSearchInputs(request);
|
||||
const missingDeliveryInputs = !request.recipientEmail
|
||||
? ["recipient email"]
|
||||
: isPlausibleEmail(request.recipientEmail)
|
||||
? []
|
||||
: ["valid recipient email"];
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (!request.recipientEmail) {
|
||||
warnings.push(
|
||||
"Recipient email is still missing. Ask for it before rendering or sending the PDF report."
|
||||
);
|
||||
} else if (!isPlausibleEmail(request.recipientEmail)) {
|
||||
warnings.push(
|
||||
"Recipient email looks malformed. Ask for a corrected email address before rendering or sending the PDF report."
|
||||
);
|
||||
}
|
||||
|
||||
if (request.preferences.marketCountry && !request.preferences.geoPricingMarket) {
|
||||
warnings.push(
|
||||
`Market-localized search is explicit for this run. Connect VPN to ${request.preferences.marketCountry} only for the bounded search phase, then disconnect before ranking/render/delivery.`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
readyToSearch: missingSearchInputs.length === 0,
|
||||
missingInputs: [...missingSearchInputs, ...missingDeliveryInputs],
|
||||
missingSearchInputs,
|
||||
missingDeliveryInputs,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
export const DFW_BLQ_2026_PROMPT_DRAFT: FlightReportRequestDraft = {
|
||||
tripName: "DFW ↔ BLQ flight report",
|
||||
legs: [
|
||||
{
|
||||
id: "outbound",
|
||||
origin: "DFW",
|
||||
destination: "BLQ",
|
||||
earliest: "2026-05-30",
|
||||
latest: "2026-06-07",
|
||||
label: "Outbound"
|
||||
},
|
||||
{
|
||||
id: "return-pair",
|
||||
origin: "BLQ",
|
||||
destination: "DFW",
|
||||
relativeToLegId: "outbound",
|
||||
minDaysAfter: 6,
|
||||
maxDaysAfter: 10,
|
||||
label: "Return for 2 adults"
|
||||
},
|
||||
{
|
||||
id: "return-solo",
|
||||
origin: "BLQ",
|
||||
destination: "DFW",
|
||||
earliest: "2026-06-28",
|
||||
latest: "2026-07-05",
|
||||
label: "Return for 1 adult"
|
||||
}
|
||||
],
|
||||
passengerGroups: [
|
||||
{
|
||||
id: "pair",
|
||||
adults: 2,
|
||||
label: "2 adults traveling together"
|
||||
},
|
||||
{
|
||||
id: "solo",
|
||||
adults: 1,
|
||||
label: "1 adult returning separately"
|
||||
}
|
||||
],
|
||||
legAssignments: [
|
||||
{
|
||||
legId: "outbound",
|
||||
passengerGroupIds: ["pair", "solo"]
|
||||
},
|
||||
{
|
||||
legId: "return-pair",
|
||||
passengerGroupIds: ["pair"]
|
||||
},
|
||||
{
|
||||
legId: "return-solo",
|
||||
passengerGroupIds: ["solo"]
|
||||
}
|
||||
],
|
||||
recipientEmail: "stefano@fiorinis.com",
|
||||
preferences: {
|
||||
preferOneStop: true,
|
||||
maxStops: 1,
|
||||
maxLayoverHours: 6,
|
||||
flexibleDates: true,
|
||||
excludeAirlines: ["Turkish Airlines"],
|
||||
excludeCountries: ["TR"],
|
||||
requireAirlineDirectCrossCheck: true,
|
||||
specialConstraints: [
|
||||
"Exclude itineraries operated by airlines based in Arab or Middle Eastern countries.",
|
||||
"Exclude itineraries routing through airports in Arab or Middle Eastern countries.",
|
||||
"Start from a fresh browser/session profile for this search."
|
||||
],
|
||||
geoPricingMarket: "Thailand",
|
||||
marketCountry: "TH",
|
||||
normalizeCurrencyTo: "USD"
|
||||
}
|
||||
};
|
||||
59
skills/flight-finder/src/run-state.ts
Normal file
59
skills/flight-finder/src/run-state.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import type { FlightRunState } from "./types.js";
|
||||
|
||||
const DEFAULT_STATE_DIR = path.join(
|
||||
os.homedir(),
|
||||
".openclaw",
|
||||
"workspace",
|
||||
"state",
|
||||
"flight-finder"
|
||||
);
|
||||
|
||||
export function getFlightFinderStatePath(
|
||||
baseDir = DEFAULT_STATE_DIR
|
||||
): string {
|
||||
return path.join(baseDir, "flight-finder-run.json");
|
||||
}
|
||||
|
||||
export async function saveFlightFinderRunState(
|
||||
state: FlightRunState,
|
||||
baseDir = DEFAULT_STATE_DIR
|
||||
): Promise<string> {
|
||||
await fs.mkdir(baseDir, { recursive: true });
|
||||
const statePath = getFlightFinderStatePath(baseDir);
|
||||
await fs.writeFile(statePath, JSON.stringify(state, null, 2));
|
||||
return statePath;
|
||||
}
|
||||
|
||||
export async function loadFlightFinderRunState(
|
||||
baseDir = DEFAULT_STATE_DIR
|
||||
): Promise<FlightRunState | null> {
|
||||
const statePath = getFlightFinderStatePath(baseDir);
|
||||
try {
|
||||
const raw = await fs.readFile(statePath, "utf8");
|
||||
return JSON.parse(raw) as FlightRunState;
|
||||
} catch (error) {
|
||||
const maybeNodeError = error as NodeJS.ErrnoException;
|
||||
if (maybeNodeError.code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function clearFlightFinderRunState(
|
||||
baseDir = DEFAULT_STATE_DIR
|
||||
): Promise<void> {
|
||||
const statePath = getFlightFinderStatePath(baseDir);
|
||||
try {
|
||||
await fs.unlink(statePath);
|
||||
} catch (error) {
|
||||
const maybeNodeError = error as NodeJS.ErrnoException;
|
||||
if (maybeNodeError.code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
81
skills/flight-finder/src/search-orchestration.ts
Normal file
81
skills/flight-finder/src/search-orchestration.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
FlightReportRequest,
|
||||
FlightSearchPlan,
|
||||
FlightSearchSourceFinding,
|
||||
FlightSearchSourceName,
|
||||
FlightSearchSourceViability
|
||||
} from "./types.js";
|
||||
|
||||
export const SOURCE_SILENT_TIMEOUT_MS = 45_000;
|
||||
export const SOURCE_TOTAL_TIMEOUT_MS = 180_000;
|
||||
export const RUN_TOTAL_TIMEOUT_MS = 900_000;
|
||||
export const VPN_CONNECT_TIMEOUT_MS = 30_000;
|
||||
export const VPN_DISCONNECT_TIMEOUT_MS = 15_000;
|
||||
|
||||
const DEFAULT_SOURCE_ORDER: FlightSearchSourceName[] = [
|
||||
"kayak",
|
||||
"skyscanner",
|
||||
"expedia",
|
||||
"airline-direct"
|
||||
];
|
||||
|
||||
function findingFor(
|
||||
source: FlightSearchSourceName,
|
||||
findings: FlightSearchSourceFinding[]
|
||||
): FlightSearchSourceFinding | undefined {
|
||||
return findings.find((entry) => entry.source === source);
|
||||
}
|
||||
|
||||
function sourceStatus(
|
||||
source: FlightSearchSourceName,
|
||||
findings: FlightSearchSourceFinding[]
|
||||
): FlightSearchSourceViability {
|
||||
return findingFor(source, findings)?.status || "viable";
|
||||
}
|
||||
|
||||
export function buildFlightSearchPlan(
|
||||
request: FlightReportRequest,
|
||||
findings: FlightSearchSourceFinding[] = []
|
||||
): FlightSearchPlan {
|
||||
const sourceOrder = DEFAULT_SOURCE_ORDER.map((source) => {
|
||||
const status = sourceStatus(source, findings);
|
||||
const finding = findingFor(source, findings);
|
||||
const required = source !== "airline-direct";
|
||||
const enabled = status !== "blocked";
|
||||
const reason =
|
||||
finding?.notes[0] ||
|
||||
(source === "airline-direct"
|
||||
? "Best-effort airline direct cross-check."
|
||||
: "Primary bounded search source.");
|
||||
|
||||
return {
|
||||
source,
|
||||
enabled,
|
||||
required,
|
||||
status,
|
||||
silentTimeoutMs: SOURCE_SILENT_TIMEOUT_MS,
|
||||
totalTimeoutMs: SOURCE_TOTAL_TIMEOUT_MS,
|
||||
reason
|
||||
};
|
||||
});
|
||||
|
||||
const degradedReasons = sourceOrder
|
||||
.filter((source) => source.status !== "viable")
|
||||
.map((source) =>
|
||||
source.enabled
|
||||
? `${source.source} is degraded for this run: ${source.reason}`
|
||||
: `${source.source} is blocked for this run: ${source.reason}`
|
||||
);
|
||||
|
||||
return {
|
||||
sourceOrder,
|
||||
vpn: {
|
||||
enabled: Boolean(request.preferences.marketCountry),
|
||||
marketCountry: request.preferences.marketCountry || null,
|
||||
connectTimeoutMs: VPN_CONNECT_TIMEOUT_MS,
|
||||
disconnectTimeoutMs: VPN_DISCONNECT_TIMEOUT_MS,
|
||||
fallbackMode: "default-market"
|
||||
},
|
||||
degradedReasons
|
||||
};
|
||||
}
|
||||
194
skills/flight-finder/src/types.ts
Normal file
194
skills/flight-finder/src/types.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
export type FlightPassengerGroup = {
|
||||
id: string;
|
||||
adults: number;
|
||||
children?: number;
|
||||
infants?: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type FlightLegWindow = {
|
||||
id: string;
|
||||
origin: string;
|
||||
destination: string;
|
||||
earliest?: string;
|
||||
latest?: string;
|
||||
relativeToLegId?: string;
|
||||
minDaysAfter?: number;
|
||||
maxDaysAfter?: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type FlightSearchPreferences = {
|
||||
cabin?: "economy" | "premium-economy" | "business" | "first";
|
||||
maxStops?: number;
|
||||
preferOneStop?: boolean;
|
||||
maxLayoverHours?: number;
|
||||
excludeAirlines?: string[];
|
||||
excludeCountries?: string[];
|
||||
excludeAirports?: string[];
|
||||
flexibleDates?: boolean;
|
||||
requireAirlineDirectCrossCheck?: boolean;
|
||||
specialConstraints?: string[];
|
||||
geoPricingMarket?: string | null;
|
||||
marketCountry?: string | null;
|
||||
normalizeCurrencyTo?: "USD";
|
||||
};
|
||||
|
||||
export type FlightReportRequest = {
|
||||
tripName?: string;
|
||||
legs: FlightLegWindow[];
|
||||
passengerGroups: FlightPassengerGroup[];
|
||||
legAssignments: Array<{ legId: string; passengerGroupIds: string[] }>;
|
||||
recipientEmail?: string | null;
|
||||
preferences: FlightSearchPreferences;
|
||||
};
|
||||
|
||||
export type FlightReportStatus = {
|
||||
needsMissingInputs: string[];
|
||||
readyToSearch: boolean;
|
||||
pdfReady: boolean;
|
||||
emailReady: boolean;
|
||||
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||
};
|
||||
|
||||
export type FlightQuote = {
|
||||
id: string;
|
||||
source: FlightSearchSourceName;
|
||||
legId: string;
|
||||
passengerGroupIds: string[];
|
||||
bookingLink: string;
|
||||
itinerarySummary: string;
|
||||
airlineName?: string;
|
||||
departureTimeLocal?: string;
|
||||
arrivalTimeLocal?: string;
|
||||
stopsText?: string;
|
||||
layoverText?: string;
|
||||
totalDurationText?: string;
|
||||
totalPriceUsd: number;
|
||||
displayPriceUsd: string;
|
||||
originalCurrency?: string;
|
||||
originalTotalPrice?: number;
|
||||
directBookingUrl?: string | null;
|
||||
crossCheckStatus?: "verified" | "not-available" | "failed";
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export type FlightRecommendationOption = {
|
||||
id: string;
|
||||
title: string;
|
||||
quoteIds: string[];
|
||||
totalPriceUsd: number;
|
||||
rationale: string;
|
||||
backup?: boolean;
|
||||
};
|
||||
|
||||
export type FlightSearchSourceName =
|
||||
| "kayak"
|
||||
| "skyscanner"
|
||||
| "expedia"
|
||||
| "airline-direct";
|
||||
|
||||
export type FlightSearchSourceViability = "viable" | "degraded" | "blocked";
|
||||
|
||||
export type FlightSearchSourceFinding = {
|
||||
source: FlightSearchSourceName;
|
||||
status: FlightSearchSourceViability;
|
||||
checkedAt: string;
|
||||
notes: string[];
|
||||
evidence?: string[];
|
||||
};
|
||||
|
||||
export type FlightSearchPlanSource = {
|
||||
source: FlightSearchSourceName;
|
||||
enabled: boolean;
|
||||
required: boolean;
|
||||
status: FlightSearchSourceViability;
|
||||
silentTimeoutMs: number;
|
||||
totalTimeoutMs: number;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export type FlightSearchPlan = {
|
||||
sourceOrder: FlightSearchPlanSource[];
|
||||
vpn: {
|
||||
enabled: boolean;
|
||||
marketCountry: string | null;
|
||||
connectTimeoutMs: number;
|
||||
disconnectTimeoutMs: number;
|
||||
fallbackMode: "default-market";
|
||||
};
|
||||
degradedReasons: string[];
|
||||
};
|
||||
|
||||
export type FlightReportRequestDraft = {
|
||||
tripName?: string | null;
|
||||
legs?: Array<Partial<FlightLegWindow> | null | undefined> | null;
|
||||
passengerGroups?: Array<Partial<FlightPassengerGroup> | null | undefined> | null;
|
||||
legAssignments?:
|
||||
| Array<
|
||||
| {
|
||||
legId?: string | null;
|
||||
passengerGroupIds?: Array<string | null | undefined> | null;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
>
|
||||
| null;
|
||||
recipientEmail?: string | null;
|
||||
preferences?: Partial<FlightSearchPreferences> | null;
|
||||
};
|
||||
|
||||
export type NormalizedFlightReportRequest = {
|
||||
request: FlightReportRequest;
|
||||
readyToSearch: boolean;
|
||||
missingInputs: string[];
|
||||
missingSearchInputs: string[];
|
||||
missingDeliveryInputs: string[];
|
||||
warnings: string[];
|
||||
};
|
||||
|
||||
export type FlightReportPayload = {
|
||||
request: FlightReportRequest;
|
||||
searchExecution: {
|
||||
freshSearch: boolean;
|
||||
startedAt: string;
|
||||
completedAt: string;
|
||||
artifactsRoot: string;
|
||||
notes?: string[];
|
||||
};
|
||||
sourceFindings: FlightSearchSourceFinding[];
|
||||
quotes: FlightQuote[];
|
||||
rankedOptions: FlightRecommendationOption[];
|
||||
executiveSummary: string[];
|
||||
reportWarnings: string[];
|
||||
degradedReasons: string[];
|
||||
comparisonCurrency: "USD";
|
||||
marketCountryUsed?: string | null;
|
||||
lastCompletedPhase?: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||
renderedPdfPath?: string | null;
|
||||
deliveredTo?: string | null;
|
||||
emailAuthorized?: boolean;
|
||||
generatedAt: string;
|
||||
};
|
||||
|
||||
export type FlightReportStatusResult = FlightReportStatus & {
|
||||
chatSummaryReady: boolean;
|
||||
terminalOutcome:
|
||||
| "ready"
|
||||
| "missing-inputs"
|
||||
| "all-sources-failed"
|
||||
| "report-incomplete";
|
||||
degraded: boolean;
|
||||
degradedReasons: string[];
|
||||
blockingReason?: string;
|
||||
};
|
||||
|
||||
export type FlightRunState = {
|
||||
request: FlightReportRequest;
|
||||
lastCompletedPhase: "intake" | "search" | "ranking" | "render" | "delivery";
|
||||
updatedAt: string;
|
||||
reportWarnings: string[];
|
||||
degradedReasons: string[];
|
||||
sourceFindings: FlightSearchSourceFinding[];
|
||||
comparisonCurrency: "USD";
|
||||
};
|
||||
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
38
skills/flight-finder/tests/price-normalization.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { normalizePriceToUsd } from "../src/price-normalization.js";
|
||||
|
||||
test("normalizePriceToUsd passes through USD values", () => {
|
||||
const result = normalizePriceToUsd({
|
||||
amount: 2847,
|
||||
currency: "USD"
|
||||
});
|
||||
|
||||
assert.equal(result.amountUsd, 2847);
|
||||
assert.equal(result.currency, "USD");
|
||||
});
|
||||
|
||||
test("normalizePriceToUsd converts foreign currencies when a rate is supplied", () => {
|
||||
const result = normalizePriceToUsd({
|
||||
amount: 100,
|
||||
currency: "EUR",
|
||||
exchangeRates: {
|
||||
EUR: 1.08
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.amountUsd, 108);
|
||||
assert.match(result.notes.join(" "), /EUR/i);
|
||||
});
|
||||
|
||||
test("normalizePriceToUsd rejects missing exchange rates", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizePriceToUsd({
|
||||
amount: 100,
|
||||
currency: "EUR"
|
||||
}),
|
||||
/exchange rate/i
|
||||
);
|
||||
});
|
||||
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
44
skills/flight-finder/tests/report-delivery.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildLukeDeliveryPlan } from "../src/report-delivery.js";
|
||||
|
||||
test("buildLukeDeliveryPlan requires a plausible recipient email", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "invalid-email",
|
||||
subject: "DFW ↔ BLQ flight report",
|
||||
body: "Summary",
|
||||
attachmentPath: "/tmp/report.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, false);
|
||||
assert.equal(plan.needsRecipientEmail, true);
|
||||
assert.equal(plan.command, null);
|
||||
});
|
||||
|
||||
test("buildLukeDeliveryPlan uses Luke's wrapper path when recipient is valid", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "stefano@fiorinis.com",
|
||||
subject: "DFW ↔ BLQ flight report",
|
||||
body: "Summary",
|
||||
attachmentPath: "/tmp/report.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, true);
|
||||
assert.match(String(plan.command), /gog-luke gmail send/);
|
||||
assert.match(String(plan.command), /stefano@fiorinis.com/);
|
||||
assert.match(String(plan.command), /report\.pdf/);
|
||||
});
|
||||
|
||||
test("buildLukeDeliveryPlan safely quotes shell-sensitive content", () => {
|
||||
const plan = buildLukeDeliveryPlan({
|
||||
recipientEmail: "stefano@fiorinis.com",
|
||||
subject: "Luke's flight report; rm -rf /",
|
||||
body: "It's ready.",
|
||||
attachmentPath: "/tmp/report's-final.pdf"
|
||||
});
|
||||
|
||||
assert.equal(plan.ready, true);
|
||||
assert.match(String(plan.command), /--subject 'Luke'"'"'s flight report; rm -rf \/'/);
|
||||
assert.match(String(plan.command), /--attach '\/tmp\/report'"'"'s-final\.pdf'/);
|
||||
});
|
||||
273
skills/flight-finder/tests/report-pdf.test.ts
Normal file
273
skills/flight-finder/tests/report-pdf.test.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
import {
|
||||
describeQuoteBookingLinks,
|
||||
formatArtifactsRoot,
|
||||
renderFlightReportPdf,
|
||||
ReportValidationError
|
||||
} from "../src/report-pdf.js";
|
||||
import type { FlightReportPayload } from "../src/types.js";
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
|
||||
function samplePayload(): FlightReportPayload {
|
||||
return {
|
||||
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
||||
searchExecution: {
|
||||
freshSearch: true,
|
||||
startedAt: "2026-03-30T20:45:00Z",
|
||||
completedAt: "2026-03-30T21:00:00Z",
|
||||
artifactsRoot: "/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-exec",
|
||||
notes: ["Fresh bounded search executed for this report."]
|
||||
},
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "viable",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Returned usable options."]
|
||||
}
|
||||
],
|
||||
quotes: [
|
||||
{
|
||||
id: "quote-1",
|
||||
source: "kayak",
|
||||
legId: "outbound",
|
||||
passengerGroupIds: ["pair", "solo"],
|
||||
bookingLink: "https://example.com/quote-1",
|
||||
itinerarySummary: "DFW -> BLQ via LHR",
|
||||
totalPriceUsd: 2847,
|
||||
displayPriceUsd: "$2,847"
|
||||
}
|
||||
],
|
||||
rankedOptions: [
|
||||
{
|
||||
id: "primary",
|
||||
title: "Best overall itinerary",
|
||||
quoteIds: ["quote-1"],
|
||||
totalPriceUsd: 2847,
|
||||
rationale: "Best price-to-convenience tradeoff."
|
||||
}
|
||||
],
|
||||
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
comparisonCurrency: "USD",
|
||||
generatedAt: "2026-03-30T21:00:00Z",
|
||||
lastCompletedPhase: "ranking"
|
||||
};
|
||||
}
|
||||
|
||||
function countPdfPages(outputPath: string): number {
|
||||
return fs.readFileSync(outputPath, "latin1").split("/Type /Page").length - 1;
|
||||
}
|
||||
|
||||
test("renderFlightReportPdf writes a non-empty PDF", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-${Date.now()}.pdf`);
|
||||
await renderFlightReportPdf(samplePayload(), outputPath);
|
||||
assert.ok(fs.existsSync(outputPath));
|
||||
assert.ok(fs.statSync(outputPath).size > 0);
|
||||
});
|
||||
|
||||
test("renderFlightReportPdf embeds booking links and only includes direct-airline URLs when captured", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-links-${Date.now()}.pdf`);
|
||||
const payload = samplePayload();
|
||||
payload.quotes = [
|
||||
{
|
||||
...payload.quotes[0],
|
||||
bookingLink: "https://example.com/quote-1",
|
||||
directBookingUrl: null
|
||||
},
|
||||
{
|
||||
id: "quote-2",
|
||||
source: "airline-direct",
|
||||
legId: "return-pair",
|
||||
passengerGroupIds: ["pair"],
|
||||
bookingLink: "https://example.com/quote-2",
|
||||
directBookingUrl: "https://www.britishairways.com/",
|
||||
airlineName: "British Airways",
|
||||
itinerarySummary: "BLQ -> DFW via LHR",
|
||||
totalPriceUsd: 1100,
|
||||
displayPriceUsd: "$1,100"
|
||||
}
|
||||
];
|
||||
payload.rankedOptions = [
|
||||
{
|
||||
id: "primary",
|
||||
title: "Best overall itinerary",
|
||||
quoteIds: ["quote-1", "quote-2"],
|
||||
totalPriceUsd: 3947,
|
||||
rationale: "Best price-to-convenience tradeoff."
|
||||
}
|
||||
];
|
||||
|
||||
await renderFlightReportPdf(payload, outputPath);
|
||||
|
||||
const pdfContents = fs.readFileSync(outputPath, "latin1");
|
||||
assert.match(pdfContents, /https:\/\/example\.com\/quote-1/);
|
||||
assert.match(pdfContents, /https:\/\/example\.com\/quote-2/);
|
||||
assert.match(pdfContents, /https:\/\/www\.britishairways\.com\//);
|
||||
assert.doesNotMatch(pdfContents, /Direct airline booking: not captured in this run[\s\S]*https:\/\/example\.com\/quote-1/);
|
||||
});
|
||||
|
||||
test("renderFlightReportPdf avoids raw absolute artifact paths and keeps page count bounded for a long report", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-long-${Date.now()}.pdf`);
|
||||
const payload = samplePayload();
|
||||
payload.searchExecution.artifactsRoot =
|
||||
"/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742";
|
||||
payload.quotes = [
|
||||
{
|
||||
...payload.quotes[0],
|
||||
id: "quote-1",
|
||||
notes: [
|
||||
"Observed on fresh KAYAK sweep at $794 per traveler for 3 adults.",
|
||||
"Skyscanner TH/USD corroborated the same Madrid pattern.",
|
||||
"Expedia also found a nearby fare band."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "quote-2",
|
||||
source: "kayak",
|
||||
legId: "return-pair",
|
||||
passengerGroupIds: ["pair"],
|
||||
bookingLink: "https://example.com/quote-2",
|
||||
itinerarySummary: "BLQ -> DFW via LHR",
|
||||
totalPriceUsd: 1316,
|
||||
displayPriceUsd: "$1,316 total ($658 pp x 2)",
|
||||
notes: [
|
||||
"Cheapest valid paired return captured in the fresh KAYAK sweep.",
|
||||
"Skyscanner did not reproduce the exact low Jun 8 return cleanly.",
|
||||
"Expedia hit a bot challenge on the Jun 8 paired-return check."
|
||||
]
|
||||
},
|
||||
{
|
||||
id: "quote-3",
|
||||
source: "kayak",
|
||||
legId: "return-solo",
|
||||
passengerGroupIds: ["solo"],
|
||||
bookingLink: "https://example.com/quote-3",
|
||||
itinerarySummary: "BLQ -> DFW via MAD",
|
||||
totalPriceUsd: 722,
|
||||
displayPriceUsd: "$722 total",
|
||||
notes: [
|
||||
"Lowest valid solo-return fare in the fresh Jul window capture.",
|
||||
"Skyscanner TH/USD showed the same Madrid connection at about $742.",
|
||||
"Expedia surfaced a cheaper Turkish option, but it was excluded."
|
||||
]
|
||||
}
|
||||
];
|
||||
payload.rankedOptions = [
|
||||
{
|
||||
id: "primary",
|
||||
title: "Best overall itinerary",
|
||||
quoteIds: ["quote-1", "quote-2", "quote-3"],
|
||||
totalPriceUsd: 4420,
|
||||
rationale: "Best price-to-convenience tradeoff."
|
||||
},
|
||||
{
|
||||
id: "backup",
|
||||
title: "Backup itinerary",
|
||||
quoteIds: ["quote-1", "quote-2", "quote-3"],
|
||||
totalPriceUsd: 4515,
|
||||
rationale: "Slightly higher price but still viable."
|
||||
}
|
||||
];
|
||||
payload.executiveSummary = [
|
||||
"Fresh Thailand-market sweeps shifted the outbound slightly earlier.",
|
||||
"Lowest valid total observed was $4,420.",
|
||||
"Cross-source corroboration is strongest for the outbound and solo return."
|
||||
];
|
||||
|
||||
await renderFlightReportPdf(payload, outputPath);
|
||||
|
||||
const pdfContents = fs.readFileSync(outputPath, "latin1");
|
||||
assert.doesNotMatch(pdfContents, /\/Users\/stefano\/\.openclaw\/workspace\/reports\//);
|
||||
assert.ok(countPdfPages(outputPath) <= 4);
|
||||
});
|
||||
|
||||
test("renderFlightReportPdf rejects incomplete report payloads", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-incomplete-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
renderFlightReportPdf(
|
||||
{
|
||||
...samplePayload(),
|
||||
rankedOptions: [],
|
||||
executiveSummary: []
|
||||
},
|
||||
outputPath
|
||||
),
|
||||
ReportValidationError
|
||||
);
|
||||
});
|
||||
|
||||
test("renderFlightReportPdf rejects payloads that are not marked as fresh-search output", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `flight-finder-stale-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
renderFlightReportPdf(
|
||||
{
|
||||
...samplePayload(),
|
||||
searchExecution: {
|
||||
...samplePayload().searchExecution,
|
||||
freshSearch: false
|
||||
}
|
||||
},
|
||||
outputPath
|
||||
),
|
||||
ReportValidationError
|
||||
);
|
||||
});
|
||||
|
||||
test("describeQuoteBookingLinks includes both aggregator and airline-direct links when captured", () => {
|
||||
const payload = samplePayload();
|
||||
const quote = {
|
||||
...payload.quotes[0],
|
||||
airlineName: "British Airways",
|
||||
directBookingUrl: "https://www.britishairways.com/"
|
||||
};
|
||||
|
||||
assert.deepEqual(describeQuoteBookingLinks(quote), [
|
||||
{
|
||||
label: "Search / book this fare",
|
||||
text: "Search / book this fare: $2,847 via kayak",
|
||||
url: "https://example.com/quote-1"
|
||||
},
|
||||
{
|
||||
label: "Direct airline booking",
|
||||
text: "Direct airline booking: British Airways",
|
||||
url: "https://www.britishairways.com/"
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("describeQuoteBookingLinks calls out missing direct-airline links explicitly", () => {
|
||||
const payload = samplePayload();
|
||||
|
||||
assert.deepEqual(describeQuoteBookingLinks(payload.quotes[0]), [
|
||||
{
|
||||
label: "Search / book this fare",
|
||||
text: "Search / book this fare: $2,847 via kayak",
|
||||
url: "https://example.com/quote-1"
|
||||
},
|
||||
{
|
||||
label: "Direct airline booking",
|
||||
text: "Direct airline booking: not captured in this run",
|
||||
url: null
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
test("formatArtifactsRoot only shortens known workspace report paths", () => {
|
||||
assert.equal(
|
||||
formatArtifactsRoot("/Users/stefano/.openclaw/workspace/reports/dfw-blq-flight-report-2026-03-30-fresh-185742"),
|
||||
"reports/dfw-blq-flight-report-2026-03-30-fresh-185742"
|
||||
);
|
||||
assert.equal(formatArtifactsRoot("/tmp/run-123"), "run-123");
|
||||
});
|
||||
106
skills/flight-finder/tests/report-status.test.ts
Normal file
106
skills/flight-finder/tests/report-status.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
import { getFlightReportStatus } from "../src/report-status.js";
|
||||
import type { FlightReportPayload } from "../src/types.js";
|
||||
|
||||
function buildPayload(): FlightReportPayload {
|
||||
return {
|
||||
request: normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request,
|
||||
searchExecution: {
|
||||
freshSearch: true,
|
||||
startedAt: "2026-03-30T20:45:00Z",
|
||||
completedAt: "2026-03-30T21:00:00Z",
|
||||
artifactsRoot: "/tmp/flight-finder-report-status",
|
||||
notes: ["Fresh bounded search executed for this report."]
|
||||
},
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "viable",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Returned usable options."]
|
||||
}
|
||||
],
|
||||
quotes: [
|
||||
{
|
||||
id: "quote-1",
|
||||
source: "kayak",
|
||||
legId: "outbound",
|
||||
passengerGroupIds: ["pair", "solo"],
|
||||
bookingLink: "https://example.com/quote-1",
|
||||
itinerarySummary: "DFW -> BLQ via LHR",
|
||||
totalPriceUsd: 2847,
|
||||
displayPriceUsd: "$2,847",
|
||||
crossCheckStatus: "not-available"
|
||||
}
|
||||
],
|
||||
rankedOptions: [
|
||||
{
|
||||
id: "primary",
|
||||
title: "Best overall itinerary",
|
||||
quoteIds: ["quote-1"],
|
||||
totalPriceUsd: 2847,
|
||||
rationale: "Best price-to-convenience tradeoff."
|
||||
}
|
||||
],
|
||||
executiveSummary: ["Best fare currently found is $2,847 total for 3 adults."],
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
comparisonCurrency: "USD",
|
||||
generatedAt: "2026-03-30T21:00:00Z",
|
||||
lastCompletedPhase: "ranking"
|
||||
};
|
||||
}
|
||||
|
||||
test("getFlightReportStatus treats missing recipient email as delivery-only blocker", () => {
|
||||
const normalized = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
recipientEmail: null
|
||||
});
|
||||
const payload = {
|
||||
...buildPayload(),
|
||||
request: normalized.request
|
||||
};
|
||||
|
||||
const status = getFlightReportStatus(normalized, payload);
|
||||
|
||||
assert.equal(status.readyToSearch, true);
|
||||
assert.equal(status.pdfReady, true);
|
||||
assert.equal(status.emailReady, false);
|
||||
assert.deepEqual(status.needsMissingInputs, ["recipient email"]);
|
||||
});
|
||||
|
||||
test("getFlightReportStatus marks all-sources-failed as a blocked but chat-summarizable outcome", () => {
|
||||
const normalized = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
|
||||
const status = getFlightReportStatus(normalized, {
|
||||
...buildPayload(),
|
||||
sourceFindings: [
|
||||
{
|
||||
source: "kayak",
|
||||
status: "blocked",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Timed out."]
|
||||
},
|
||||
{
|
||||
source: "skyscanner",
|
||||
status: "blocked",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Timed out."]
|
||||
}
|
||||
],
|
||||
quotes: [],
|
||||
rankedOptions: [],
|
||||
executiveSummary: [],
|
||||
degradedReasons: ["All configured travel sources failed."]
|
||||
});
|
||||
|
||||
assert.equal(status.terminalOutcome, "all-sources-failed");
|
||||
assert.equal(status.chatSummaryReady, true);
|
||||
assert.equal(status.pdfReady, false);
|
||||
assert.equal(status.degraded, true);
|
||||
});
|
||||
98
skills/flight-finder/tests/request-normalizer.test.ts
Normal file
98
skills/flight-finder/tests/request-normalizer.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
|
||||
test("normalizeFlightReportRequest preserves the legacy DFW-BLQ split-return structure", () => {
|
||||
const result = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT);
|
||||
|
||||
assert.equal(result.readyToSearch, true);
|
||||
assert.deepEqual(result.missingSearchInputs, []);
|
||||
assert.deepEqual(result.missingDeliveryInputs, []);
|
||||
assert.equal(result.request.preferences.marketCountry, "TH");
|
||||
assert.equal(result.request.preferences.normalizeCurrencyTo, "USD");
|
||||
assert.equal(result.request.legs.length, 3);
|
||||
assert.equal(result.request.passengerGroups.length, 2);
|
||||
assert.deepEqual(
|
||||
result.request.legAssignments.find((entry) => entry.legId === "outbound")?.passengerGroupIds,
|
||||
["pair", "solo"]
|
||||
);
|
||||
assert.equal(
|
||||
result.request.preferences.requireAirlineDirectCrossCheck,
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest keeps email missing as a delivery gate, not a search blocker", () => {
|
||||
const result = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
recipientEmail: null
|
||||
});
|
||||
|
||||
assert.equal(result.readyToSearch, true);
|
||||
assert.deepEqual(result.missingSearchInputs, []);
|
||||
assert.deepEqual(result.missingDeliveryInputs, ["recipient email"]);
|
||||
assert.match(result.warnings.join(" "), /recipient email/i);
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest reports missing search-critical inputs explicitly", () => {
|
||||
const result = normalizeFlightReportRequest({
|
||||
tripName: "Incomplete flight request",
|
||||
legs: [
|
||||
{
|
||||
id: "outbound",
|
||||
origin: "DFW",
|
||||
destination: "",
|
||||
label: "Outbound"
|
||||
}
|
||||
],
|
||||
passengerGroups: [],
|
||||
legAssignments: []
|
||||
});
|
||||
|
||||
assert.equal(result.readyToSearch, false);
|
||||
assert.match(result.missingSearchInputs.join(" | "), /origin and destination/i);
|
||||
assert.match(result.missingSearchInputs.join(" | "), /date window/i);
|
||||
assert.match(result.missingSearchInputs.join(" | "), /passenger groups/i);
|
||||
assert.match(result.missingSearchInputs.join(" | "), /assignments/i);
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest validates marketCountry as an ISO alpha-2 code", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
preferences: {
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||
marketCountry: "Germany"
|
||||
}
|
||||
}),
|
||||
/ISO 3166-1 alpha-2/i
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest uppercases a lowercase marketCountry code", () => {
|
||||
const result = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
preferences: {
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||
marketCountry: "th"
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.request.preferences.marketCountry, "TH");
|
||||
});
|
||||
|
||||
test("normalizeFlightReportRequest flags a malformed recipient email without blocking search readiness", () => {
|
||||
const result = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
recipientEmail: "stefano-at-example"
|
||||
});
|
||||
|
||||
assert.equal(result.readyToSearch, true);
|
||||
assert.deepEqual(result.missingDeliveryInputs, ["valid recipient email"]);
|
||||
assert.match(result.warnings.join(" "), /malformed/i);
|
||||
});
|
||||
42
skills/flight-finder/tests/run-state.test.ts
Normal file
42
skills/flight-finder/tests/run-state.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import {
|
||||
clearFlightFinderRunState,
|
||||
loadFlightFinderRunState,
|
||||
saveFlightFinderRunState
|
||||
} from "../src/run-state.js";
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
|
||||
test("save/load/clearFlightFinderRunState persists the resumable phase context", async () => {
|
||||
const baseDir = await fs.mkdtemp(
|
||||
path.join(os.tmpdir(), "flight-finder-run-state-")
|
||||
);
|
||||
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
|
||||
|
||||
await saveFlightFinderRunState(
|
||||
{
|
||||
request,
|
||||
lastCompletedPhase: "search",
|
||||
updatedAt: "2026-03-30T21:00:00Z",
|
||||
reportWarnings: [],
|
||||
degradedReasons: [],
|
||||
sourceFindings: [],
|
||||
comparisonCurrency: "USD"
|
||||
},
|
||||
baseDir
|
||||
);
|
||||
|
||||
const loaded = await loadFlightFinderRunState(baseDir);
|
||||
assert.equal(loaded?.lastCompletedPhase, "search");
|
||||
assert.equal(loaded?.request.tripName, "DFW ↔ BLQ flight report");
|
||||
|
||||
await clearFlightFinderRunState(baseDir);
|
||||
assert.equal(await loadFlightFinderRunState(baseDir), null);
|
||||
});
|
||||
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
46
skills/flight-finder/tests/search-orchestration.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
normalizeFlightReportRequest
|
||||
} from "../src/request-normalizer.js";
|
||||
import {
|
||||
VPN_CONNECT_TIMEOUT_MS,
|
||||
VPN_DISCONNECT_TIMEOUT_MS,
|
||||
buildFlightSearchPlan
|
||||
} from "../src/search-orchestration.js";
|
||||
|
||||
test("buildFlightSearchPlan activates VPN only when marketCountry is explicit", () => {
|
||||
const request = normalizeFlightReportRequest(DFW_BLQ_2026_PROMPT_DRAFT).request;
|
||||
const plan = buildFlightSearchPlan(request);
|
||||
|
||||
assert.equal(plan.vpn.enabled, true);
|
||||
assert.equal(plan.vpn.marketCountry, "TH");
|
||||
assert.equal(plan.vpn.connectTimeoutMs, VPN_CONNECT_TIMEOUT_MS);
|
||||
assert.equal(plan.vpn.disconnectTimeoutMs, VPN_DISCONNECT_TIMEOUT_MS);
|
||||
});
|
||||
|
||||
test("buildFlightSearchPlan keeps airline-direct best-effort when degraded", () => {
|
||||
const request = normalizeFlightReportRequest({
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT,
|
||||
preferences: {
|
||||
...DFW_BLQ_2026_PROMPT_DRAFT.preferences,
|
||||
marketCountry: null
|
||||
}
|
||||
}).request;
|
||||
const plan = buildFlightSearchPlan(request, [
|
||||
{
|
||||
source: "airline-direct",
|
||||
status: "degraded",
|
||||
checkedAt: "2026-03-30T21:00:00Z",
|
||||
notes: ["Direct booking shell loads but search completion is unreliable."]
|
||||
}
|
||||
]);
|
||||
|
||||
assert.equal(plan.vpn.enabled, false);
|
||||
const directSource = plan.sourceOrder.find((entry) => entry.source === "airline-direct");
|
||||
assert.equal(directSource?.enabled, true);
|
||||
assert.equal(directSource?.required, false);
|
||||
assert.match(plan.degradedReasons.join(" "), /airline-direct/i);
|
||||
});
|
||||
17
skills/flight-finder/tsconfig.json
Normal file
17
skills/flight-finder/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -41,7 +41,13 @@ node scripts/nordvpn-client.js status --debug
|
||||
- use NordLynx/WireGuard through `wireguard-go` and `wireguard-tools`
|
||||
- `install` bootstraps them with Homebrew
|
||||
- `login` validates the token for the WireGuard backend
|
||||
- the generated WireGuard config stays free of `DNS = ...`
|
||||
- `connect` now requires a bounded persistence gate plus a verified exit before success is declared
|
||||
- the skill snapshots and applies NordVPN DNS only to eligible physical services while connected
|
||||
- NordVPN DNS is applied only after the tunnel remains up, the final liveness check still shows the requested exit, and system hostname resolution still works afterward
|
||||
- `disconnect` restores the saved DNS/search-domain state even if the tunnel state is stale
|
||||
- Tailscale is suspended before connect and resumed after disconnect or failed connect
|
||||
- the skill writes a short-lived Tailscale suppression marker during VPN connect so host watchdogs do not immediately re-run `tailscale up`
|
||||
- `NordVPN.app` may remain installed but is only the manual fallback
|
||||
|
||||
## Credentials
|
||||
@@ -75,6 +81,10 @@ Exact `visudo` rule for the installed OpenClaw skill:
|
||||
stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh probe, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh up, /Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh down
|
||||
```
|
||||
|
||||
Operational note:
|
||||
|
||||
- the persistence gate reuses the already-allowed `probe` action to confirm the live `utun*` WireGuard runtime and does not require extra sudoers actions beyond `probe`, `up`, and `down`
|
||||
|
||||
## Agent Guidance
|
||||
|
||||
- run `status` first when the machine state is unclear
|
||||
@@ -83,10 +93,11 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
|
||||
- use `connect` before location-sensitive skills such as `web-automation`
|
||||
- use `verify` after connect when you need an explicit location check
|
||||
- use `disconnect` after the follow-up task
|
||||
- if `connect` fails its persistence or final verification gate, treat that as a safe rollback, not a partial success
|
||||
|
||||
## Output Rules
|
||||
|
||||
- normal JSON output redacts local path metadata
|
||||
- normal JSON output redacts local path metadata and helper-hardening diagnostics
|
||||
- use `--debug` only when deeper troubleshooting requires internal local paths and helper/config metadata
|
||||
|
||||
## Troubleshooting Cues
|
||||
@@ -98,6 +109,7 @@ stefano ALL=(root) NOPASSWD: /Users/stefano/.openclaw/workspace/skills/nordvpn-c
|
||||
- connect succeeds but final state looks inconsistent:
|
||||
- rely on the verified public IP/location first
|
||||
- then inspect `status --debug`
|
||||
- `verified: true` but `persistence.stable: false` should not happen anymore; if it does, the skill should roll back instead of pinning DNS
|
||||
- disconnect should leave:
|
||||
- normal public IP restored
|
||||
- no active WireGuard state
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,28 +11,58 @@ function loadInternals() {
|
||||
module.exports = {
|
||||
buildMacTailscaleState:
|
||||
typeof buildMacTailscaleState === "function" ? buildMacTailscaleState : undefined,
|
||||
markMacTailscaleRecoverySuppressed:
|
||||
typeof markMacTailscaleRecoverySuppressed === "function" ? markMacTailscaleRecoverySuppressed : undefined,
|
||||
clearMacTailscaleRecoverySuppressed:
|
||||
typeof clearMacTailscaleRecoverySuppressed === "function" ? clearMacTailscaleRecoverySuppressed : undefined,
|
||||
buildMacDnsState:
|
||||
typeof buildMacDnsState === "function" ? buildMacDnsState : undefined,
|
||||
buildWireguardConfig:
|
||||
typeof buildWireguardConfig === "function" ? buildWireguardConfig : undefined,
|
||||
buildLookupResult:
|
||||
typeof buildLookupResult === "function" ? buildLookupResult : undefined,
|
||||
cleanupMacWireguardState:
|
||||
typeof cleanupMacWireguardState === "function" ? cleanupMacWireguardState : undefined,
|
||||
cleanupMacWireguardAndDnsState:
|
||||
typeof cleanupMacWireguardAndDnsState === "function" ? cleanupMacWireguardAndDnsState : undefined,
|
||||
collectMacWireguardDiagnostics:
|
||||
typeof collectMacWireguardDiagnostics === "function" ? collectMacWireguardDiagnostics : undefined,
|
||||
acquireOperationLock:
|
||||
typeof acquireOperationLock === "function" ? acquireOperationLock : undefined,
|
||||
inspectMacWireguardHelperSecurity:
|
||||
typeof inspectMacWireguardHelperSecurity === "function" ? inspectMacWireguardHelperSecurity : undefined,
|
||||
getMacTailscalePath:
|
||||
typeof getMacTailscalePath === "function" ? getMacTailscalePath : undefined,
|
||||
isBenignMacWireguardAbsentError:
|
||||
typeof isBenignMacWireguardAbsentError === "function" ? isBenignMacWireguardAbsentError : undefined,
|
||||
isMacTailscaleActive:
|
||||
typeof isMacTailscaleActive === "function" ? isMacTailscaleActive : undefined,
|
||||
checkMacWireguardPersistence:
|
||||
typeof checkMacWireguardPersistence === "function" ? checkMacWireguardPersistence : undefined,
|
||||
normalizeSuccessfulConnectState:
|
||||
typeof normalizeSuccessfulConnectState === "function" ? normalizeSuccessfulConnectState : undefined,
|
||||
normalizeStatusState:
|
||||
typeof normalizeStatusState === "function" ? normalizeStatusState : undefined,
|
||||
parseMacWireguardHelperStatus:
|
||||
typeof parseMacWireguardHelperStatus === "function" ? parseMacWireguardHelperStatus : undefined,
|
||||
shouldRejectMacDnsBaseline:
|
||||
typeof shouldRejectMacDnsBaseline === "function" ? shouldRejectMacDnsBaseline : undefined,
|
||||
shouldManageMacDnsService:
|
||||
typeof shouldManageMacDnsService === "function" ? shouldManageMacDnsService : undefined,
|
||||
sanitizeOutputPayload:
|
||||
typeof sanitizeOutputPayload === "function" ? sanitizeOutputPayload : undefined,
|
||||
shouldFinalizeMacWireguardConnect:
|
||||
typeof shouldFinalizeMacWireguardConnect === "function" ? shouldFinalizeMacWireguardConnect : undefined,
|
||||
shouldResumeMacTailscale:
|
||||
typeof shouldResumeMacTailscale === "function" ? shouldResumeMacTailscale : undefined,
|
||||
shouldAttemptMacWireguardDisconnect:
|
||||
typeof shouldAttemptMacWireguardDisconnect === "function" ? shouldAttemptMacWireguardDisconnect : undefined,
|
||||
detectMacWireguardActiveFromIfconfig:
|
||||
typeof detectMacWireguardActiveFromIfconfig === "function" ? detectMacWireguardActiveFromIfconfig : undefined,
|
||||
resolveHostnameWithFallback:
|
||||
typeof resolveHostnameWithFallback === "function" ? resolveHostnameWithFallback : undefined,
|
||||
verifySystemHostnameResolution:
|
||||
typeof verifySystemHostnameResolution === "function" ? verifySystemHostnameResolution : undefined,
|
||||
verifyConnectionWithRetry:
|
||||
typeof verifyConnectionWithRetry === "function" ? verifyConnectionWithRetry : undefined,
|
||||
};`;
|
||||
@@ -78,7 +108,7 @@ test("buildLookupResult supports lookup all=true mode", () => {
|
||||
assert.equal(JSON.stringify(buildLookupResult("104.26.9.44", { all: false })), JSON.stringify(["104.26.9.44", 4]));
|
||||
});
|
||||
|
||||
test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config path", () => {
|
||||
test("buildWireguardConfig omits DNS and relies on post-connect networksetup on macOS", () => {
|
||||
const { buildWireguardConfig } = loadInternals();
|
||||
assert.equal(typeof buildWireguardConfig, "function");
|
||||
|
||||
@@ -91,10 +121,71 @@ test("buildWireguardConfig includes NordVPN DNS for the vanilla macOS config pat
|
||||
"PRIVATEKEY"
|
||||
);
|
||||
|
||||
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), true);
|
||||
assert.equal(config.includes("DNS = 103.86.96.100, 103.86.99.100"), false);
|
||||
assert.equal(config.includes("AllowedIPs = 0.0.0.0/0"), true);
|
||||
});
|
||||
|
||||
test("shouldManageMacDnsService keeps active physical services and excludes virtual ones", () => {
|
||||
const { shouldManageMacDnsService } = loadInternals();
|
||||
assert.equal(typeof shouldManageMacDnsService, "function");
|
||||
|
||||
assert.equal(shouldManageMacDnsService("Wi-Fi"), true);
|
||||
assert.equal(shouldManageMacDnsService("USB 10/100/1000 LAN"), true);
|
||||
assert.equal(shouldManageMacDnsService("Tailscale"), false);
|
||||
assert.equal(shouldManageMacDnsService("Thunderbolt Bridge"), false);
|
||||
assert.equal(shouldManageMacDnsService("Acme VPN"), false);
|
||||
});
|
||||
|
||||
test("buildMacDnsState records DNS and search domains per service", () => {
|
||||
const { buildMacDnsState } = loadInternals();
|
||||
assert.equal(typeof buildMacDnsState, "function");
|
||||
|
||||
assert.equal(
|
||||
JSON.stringify(
|
||||
buildMacDnsState([
|
||||
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
|
||||
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
|
||||
])
|
||||
),
|
||||
JSON.stringify({
|
||||
services: [
|
||||
{ name: "Wi-Fi", dnsServers: ["1.1.1.1"], searchDomains: [] },
|
||||
{ name: "USB 10/100/1000 LAN", dnsServers: [], searchDomains: ["lan"] },
|
||||
],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldRejectMacDnsBaseline flags a NordVPN-pinned restore snapshot", () => {
|
||||
const { shouldRejectMacDnsBaseline } = loadInternals();
|
||||
assert.equal(typeof shouldRejectMacDnsBaseline, "function");
|
||||
|
||||
assert.equal(
|
||||
shouldRejectMacDnsBaseline({
|
||||
services: [
|
||||
{
|
||||
name: "Wi-Fi",
|
||||
dnsServers: ["103.86.96.100", "103.86.99.100"],
|
||||
searchDomains: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
shouldRejectMacDnsBaseline({
|
||||
services: [
|
||||
{
|
||||
name: "Wi-Fi",
|
||||
dnsServers: ["1.1.1.1"],
|
||||
searchDomains: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("getMacTailscalePath falls back to /opt/homebrew/bin/tailscale when PATH lookup is missing", () => {
|
||||
const { getMacTailscalePath } = loadInternals();
|
||||
assert.equal(typeof getMacTailscalePath, "function");
|
||||
@@ -118,6 +209,29 @@ test("buildMacTailscaleState records whether tailscale was active", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("tailscale recovery suppression marker can be created and cleared", () => {
|
||||
const { markMacTailscaleRecoverySuppressed, clearMacTailscaleRecoverySuppressed } = loadInternals();
|
||||
assert.equal(typeof markMacTailscaleRecoverySuppressed, "function");
|
||||
assert.equal(typeof clearMacTailscaleRecoverySuppressed, "function");
|
||||
|
||||
const markerPath = path.join(process.env.HOME || "", ".nordvpn-client", "tailscale-suppressed");
|
||||
clearMacTailscaleRecoverySuppressed();
|
||||
markMacTailscaleRecoverySuppressed();
|
||||
assert.equal(fs.existsSync(markerPath), true);
|
||||
clearMacTailscaleRecoverySuppressed();
|
||||
assert.equal(fs.existsSync(markerPath), false);
|
||||
});
|
||||
|
||||
test("shouldResumeMacTailscale only resumes when previously active and not already running", () => {
|
||||
const { shouldResumeMacTailscale } = loadInternals();
|
||||
assert.equal(typeof shouldResumeMacTailscale, "function");
|
||||
|
||||
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, false), true);
|
||||
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: true }, true), false);
|
||||
assert.equal(shouldResumeMacTailscale({ tailscaleWasActive: false }, false), false);
|
||||
assert.equal(shouldResumeMacTailscale(null, false), false);
|
||||
});
|
||||
|
||||
test("cleanupMacWireguardState removes stale config and last-connection files", () => {
|
||||
const { cleanupMacWireguardState } = loadInternals();
|
||||
assert.equal(typeof cleanupMacWireguardState, "function");
|
||||
@@ -138,6 +252,190 @@ test("cleanupMacWireguardState removes stale config and last-connection files",
|
||||
assert.equal(fs.existsSync(lastConnectionPath), false);
|
||||
});
|
||||
|
||||
test("cleanupMacWireguardAndDnsState removes stale config, DNS snapshot, and last-connection files", () => {
|
||||
const { cleanupMacWireguardAndDnsState } = loadInternals();
|
||||
assert.equal(typeof cleanupMacWireguardAndDnsState, "function");
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "state-"));
|
||||
const configPath = path.join(tmpDir, "nordvpnctl.conf");
|
||||
const lastConnectionPath = path.join(tmpDir, "last-connection.json");
|
||||
const dnsStatePath = path.join(tmpDir, "dns.json");
|
||||
fs.writeFileSync(configPath, "wireguard-config");
|
||||
fs.writeFileSync(lastConnectionPath, "{\"country\":\"Germany\"}");
|
||||
fs.writeFileSync(dnsStatePath, "{\"services\":[]}");
|
||||
|
||||
const result = cleanupMacWireguardAndDnsState({
|
||||
configPath,
|
||||
lastConnectionPath,
|
||||
dnsStatePath,
|
||||
});
|
||||
|
||||
assert.equal(result.cleaned, true);
|
||||
assert.equal(fs.existsSync(configPath), false);
|
||||
assert.equal(fs.existsSync(lastConnectionPath), false);
|
||||
assert.equal(fs.existsSync(dnsStatePath), false);
|
||||
});
|
||||
|
||||
test("inspectMacWireguardHelperSecurity rejects a user-owned helper path", () => {
|
||||
const { inspectMacWireguardHelperSecurity } = loadInternals();
|
||||
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
|
||||
|
||||
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
|
||||
fileExists: () => true,
|
||||
statSync: () => ({
|
||||
uid: 501,
|
||||
gid: 20,
|
||||
mode: 0o100755,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result.exists, true);
|
||||
assert.equal(result.hardened, false);
|
||||
assert.match(result.reason, /root-owned/i);
|
||||
});
|
||||
|
||||
test("inspectMacWireguardHelperSecurity accepts a root-owned non-writable helper path", () => {
|
||||
const { inspectMacWireguardHelperSecurity } = loadInternals();
|
||||
assert.equal(typeof inspectMacWireguardHelperSecurity, "function");
|
||||
|
||||
const result = inspectMacWireguardHelperSecurity("/tmp/nordvpn-wireguard-helper.sh", {
|
||||
fileExists: () => true,
|
||||
statSync: () => ({
|
||||
uid: 0,
|
||||
gid: 0,
|
||||
mode: 0o100755,
|
||||
}),
|
||||
});
|
||||
|
||||
assert.equal(result.exists, true);
|
||||
assert.equal(result.hardened, true);
|
||||
assert.equal(result.reason, "");
|
||||
});
|
||||
|
||||
test("collectMacWireguardDiagnostics captures bounded wg/ifconfig/route/process output", async () => {
|
||||
const { collectMacWireguardDiagnostics } = loadInternals();
|
||||
assert.equal(typeof collectMacWireguardDiagnostics, "function");
|
||||
|
||||
const seen = [];
|
||||
const result = await collectMacWireguardDiagnostics({
|
||||
interfaceName: "nordvpnctl",
|
||||
runExec: async (command, args) => {
|
||||
seen.push(`${command} ${args.join(" ")}`);
|
||||
if (command === "/opt/homebrew/bin/wg") {
|
||||
return { ok: true, stdout: "interface: nordvpnctl\npeer: abc123", stderr: "", error: "" };
|
||||
}
|
||||
if (command === "ifconfig") {
|
||||
return { ok: true, stdout: "utun8: flags=8051\n\tinet 10.5.0.2 --> 10.5.0.2", stderr: "", error: "" };
|
||||
}
|
||||
if (command === "route") {
|
||||
return { ok: true, stdout: "default 10.5.0.2 UGSc", stderr: "", error: "" };
|
||||
}
|
||||
if (command === "pgrep") {
|
||||
return { ok: true, stdout: "1234 wireguard-go utun\n5678 wg-quick up nordvpnctl", stderr: "", error: "" };
|
||||
}
|
||||
throw new Error(`unexpected command: ${command}`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(seen, [
|
||||
"/opt/homebrew/bin/wg show nordvpnctl",
|
||||
"ifconfig nordvpnctl",
|
||||
"route -n get default",
|
||||
"pgrep -fl wireguard-go|wg-quick|nordvpnctl",
|
||||
]);
|
||||
assert.equal(result.interfaceName, "nordvpnctl");
|
||||
assert.equal(result.wgShow.includes("peer: abc123"), true);
|
||||
assert.equal(result.ifconfig.includes("10.5.0.2"), true);
|
||||
assert.equal(result.routes.includes("default 10.5.0.2"), true);
|
||||
assert.equal(result.processes.includes("wireguard-go"), true);
|
||||
});
|
||||
|
||||
test("parseMacWireguardHelperStatus reads active helper key-value output", () => {
|
||||
const { parseMacWireguardHelperStatus } = loadInternals();
|
||||
assert.equal(typeof parseMacWireguardHelperStatus, "function");
|
||||
|
||||
const result = parseMacWireguardHelperStatus("active=1\ninterfaceName=nordvpnctl\n");
|
||||
|
||||
assert.equal(result.active, true);
|
||||
assert.equal(result.interfaceName, "nordvpnctl");
|
||||
});
|
||||
|
||||
test("checkMacWireguardPersistence waits for both helper-active and verified exit", async () => {
|
||||
const { checkMacWireguardPersistence } = loadInternals();
|
||||
assert.equal(typeof checkMacWireguardPersistence, "function");
|
||||
|
||||
const helperStatuses = [
|
||||
{ active: false, interfaceName: "nordvpnctl" },
|
||||
{ active: true, interfaceName: "nordvpnctl" },
|
||||
];
|
||||
const verifications = [
|
||||
{ ok: false, ipInfo: { ok: false, error: "timeout" } },
|
||||
{ ok: true, ipInfo: { ok: true, country: "Germany", city: "Frankfurt" } },
|
||||
];
|
||||
|
||||
const result = await checkMacWireguardPersistence(
|
||||
{ country: "Germany", city: "" },
|
||||
{
|
||||
attempts: 2,
|
||||
delayMs: 1,
|
||||
getHelperStatus: async () => helperStatuses.shift(),
|
||||
verifyConnection: async () => verifications.shift(),
|
||||
sleep: async () => {},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.stable, true);
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(result.helperStatus.active, true);
|
||||
assert.equal(result.verified.ok, true);
|
||||
});
|
||||
|
||||
test("checkMacWireguardPersistence returns the last failed status when stability is not reached", async () => {
|
||||
const { checkMacWireguardPersistence } = loadInternals();
|
||||
assert.equal(typeof checkMacWireguardPersistence, "function");
|
||||
|
||||
const result = await checkMacWireguardPersistence(
|
||||
{ country: "Germany", city: "" },
|
||||
{
|
||||
attempts: 2,
|
||||
delayMs: 1,
|
||||
getHelperStatus: async () => ({ active: false, interfaceName: "nordvpnctl" }),
|
||||
verifyConnection: async () => ({ ok: false, ipInfo: { ok: false, error: "timeout" } }),
|
||||
sleep: async () => {},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.stable, false);
|
||||
assert.equal(result.attempts, 2);
|
||||
assert.equal(result.helperStatus.active, false);
|
||||
assert.equal(result.verified.ok, false);
|
||||
});
|
||||
|
||||
test("acquireOperationLock cleans a stale dead-pid lock before taking ownership", () => {
|
||||
const { acquireOperationLock } = loadInternals();
|
||||
assert.equal(typeof acquireOperationLock, "function");
|
||||
|
||||
const tmpDir = fs.mkdtempSync(path.join(fs.mkdtempSync("/tmp/nordvpn-client-test-"), "lock-"));
|
||||
const lockPath = path.join(tmpDir, "operation.lock");
|
||||
fs.writeFileSync(
|
||||
lockPath,
|
||||
JSON.stringify({
|
||||
action: "connect",
|
||||
pid: 0,
|
||||
startedAt: new Date(0).toISOString(),
|
||||
startedAtMs: 0,
|
||||
})
|
||||
);
|
||||
|
||||
const lock = acquireOperationLock("disconnect", { lockPath });
|
||||
const lockFile = JSON.parse(fs.readFileSync(lockPath, "utf8"));
|
||||
|
||||
assert.equal(lockFile.action, "disconnect");
|
||||
assert.equal(lockFile.pid, process.pid);
|
||||
lock.release();
|
||||
assert.equal(fs.existsSync(lockPath), false);
|
||||
});
|
||||
|
||||
test("shouldAttemptMacWireguardDisconnect does not trust active=false when residual state exists", () => {
|
||||
const { shouldAttemptMacWireguardDisconnect } = loadInternals();
|
||||
assert.equal(typeof shouldAttemptMacWireguardDisconnect, "function");
|
||||
@@ -173,6 +471,15 @@ test("shouldAttemptMacWireguardDisconnect does not trust active=false when resid
|
||||
);
|
||||
});
|
||||
|
||||
test("isBenignMacWireguardAbsentError recognizes stale-interface teardown errors", () => {
|
||||
const { isBenignMacWireguardAbsentError } = loadInternals();
|
||||
assert.equal(typeof isBenignMacWireguardAbsentError, "function");
|
||||
|
||||
assert.equal(isBenignMacWireguardAbsentError("wg-quick: `nordvpnctl' is not a WireGuard interface"), true);
|
||||
assert.equal(isBenignMacWireguardAbsentError("Unable to access interface: No such file or directory"), true);
|
||||
assert.equal(isBenignMacWireguardAbsentError("permission denied"), false);
|
||||
});
|
||||
|
||||
test("normalizeSuccessfulConnectState marks the connect snapshot active after verified macOS wireguard connect", () => {
|
||||
const { normalizeSuccessfulConnectState } = loadInternals();
|
||||
assert.equal(typeof normalizeSuccessfulConnectState, "function");
|
||||
@@ -206,6 +513,17 @@ test("normalizeSuccessfulConnectState marks the connect snapshot active after ve
|
||||
assert.equal(state.wireguard.endpoint, "de1227.nordvpn.com:51820");
|
||||
});
|
||||
|
||||
test("shouldFinalizeMacWireguardConnect requires a verified and stable wireguard connect", () => {
|
||||
const { shouldFinalizeMacWireguardConnect } = loadInternals();
|
||||
assert.equal(typeof shouldFinalizeMacWireguardConnect, "function");
|
||||
|
||||
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: true }), true);
|
||||
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: true }, { stable: false }), false);
|
||||
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "wireguard" }, { ok: false }, { stable: true }), false);
|
||||
assert.equal(shouldFinalizeMacWireguardConnect({ backend: "cli" }, { ok: true }, { stable: true }), false);
|
||||
assert.equal(shouldFinalizeMacWireguardConnect(null, { ok: true }, { stable: true }), false);
|
||||
});
|
||||
|
||||
test("normalizeStatusState marks macOS wireguard connected when public IP matches the last successful target", () => {
|
||||
const { normalizeStatusState } = loadInternals();
|
||||
assert.equal(typeof normalizeStatusState, "function");
|
||||
@@ -243,6 +561,10 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
|
||||
wireguard: {
|
||||
configPath: "/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf",
|
||||
helperPath: "/Users/stefano/.openclaw/workspace/skills/nordvpn-client/scripts/nordvpn-wireguard-helper.sh",
|
||||
helperSecurity: {
|
||||
hardened: false,
|
||||
reason: "Helper must be root-owned before privileged actions are trusted.",
|
||||
},
|
||||
authCache: {
|
||||
tokenSource: "default:/Users/stefano/.openclaw/workspace/.clawdbot/credentials/nordvpn/token.txt",
|
||||
},
|
||||
@@ -254,6 +576,7 @@ test("sanitizeOutputPayload redacts local path metadata from normal JSON output"
|
||||
assert.equal(sanitized.appPath, null);
|
||||
assert.equal(sanitized.wireguard.configPath, null);
|
||||
assert.equal(sanitized.wireguard.helperPath, null);
|
||||
assert.equal(sanitized.wireguard.helperSecurity, null);
|
||||
assert.equal(sanitized.wireguard.authCache.tokenSource, null);
|
||||
assert.equal(sanitized.wireguard.endpoint, "jp454.nordvpn.com:51820");
|
||||
});
|
||||
@@ -307,3 +630,42 @@ test("resolveHostnameWithFallback uses fallback resolvers when system lookup fai
|
||||
assert.equal(address, "104.26.9.44");
|
||||
assert.deepEqual(calls, ["1.1.1.1:ipapi.co", "8.8.8.8:ipapi.co"]);
|
||||
});
|
||||
|
||||
test("verifySystemHostnameResolution succeeds when any system lookup resolves", async () => {
|
||||
const { verifySystemHostnameResolution } = loadInternals();
|
||||
assert.equal(typeof verifySystemHostnameResolution, "function");
|
||||
|
||||
const calls = [];
|
||||
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
|
||||
timeoutMs: 5,
|
||||
lookup: async (hostname) => {
|
||||
calls.push(hostname);
|
||||
if (hostname === "www.google.com") {
|
||||
throw new Error("ENOTFOUND");
|
||||
}
|
||||
return { address: "104.18.33.45", family: 4 };
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.hostname, "api.openai.com");
|
||||
assert.equal(result.address, "104.18.33.45");
|
||||
assert.deepEqual(calls, ["www.google.com", "api.openai.com"]);
|
||||
});
|
||||
|
||||
test("verifySystemHostnameResolution fails when all hostnames fail system lookup", async () => {
|
||||
const { verifySystemHostnameResolution } = loadInternals();
|
||||
assert.equal(typeof verifySystemHostnameResolution, "function");
|
||||
|
||||
const result = await verifySystemHostnameResolution(["www.google.com", "api.openai.com"], {
|
||||
timeoutMs: 5,
|
||||
lookup: async (hostname) => {
|
||||
throw new Error(`${hostname}: timeout`);
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.ok, false);
|
||||
assert.equal(result.hostname, "");
|
||||
assert.match(result.error, /www\.google\.com/);
|
||||
assert.match(result.error, /api\.openai\.com/);
|
||||
});
|
||||
|
||||
@@ -3,21 +3,53 @@ set -eu
|
||||
|
||||
ACTION="${1:-}"
|
||||
case "$ACTION" in
|
||||
probe|up|down)
|
||||
probe|up|down|status|cleanup)
|
||||
;;
|
||||
*)
|
||||
echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down]" >&2
|
||||
echo "Usage: nordvpn-wireguard-helper.sh [probe|up|down|status|cleanup]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
WG_QUICK="/opt/homebrew/bin/wg-quick"
|
||||
WG="/opt/homebrew/bin/wg"
|
||||
WG_CONFIG="/Users/stefano/.nordvpn-client/wireguard/nordvpnctl.conf"
|
||||
WG_INTERFACE="nordvpnctl"
|
||||
PATH="/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin"
|
||||
export PATH
|
||||
|
||||
if [ "$ACTION" = "probe" ]; then
|
||||
if [ "$ACTION" = "probe" ] || [ "$ACTION" = "status" ]; then
|
||||
test -x "$WG_QUICK"
|
||||
ACTIVE=0
|
||||
RUNTIME_INTERFACE=""
|
||||
if [ -x "$WG" ]; then
|
||||
RUNTIME_INTERFACE=$("$WG" show interfaces 2>/dev/null | awk 'NF { print $1; exit }')
|
||||
fi
|
||||
if [ -n "$RUNTIME_INTERFACE" ]; then
|
||||
ACTIVE=1
|
||||
elif [ -x "$WG" ] && "$WG" show "$WG_INTERFACE" >/dev/null 2>&1; then
|
||||
ACTIVE=1
|
||||
elif /sbin/ifconfig "$WG_INTERFACE" >/dev/null 2>&1; then
|
||||
ACTIVE=1
|
||||
elif pgrep -f "wg-quick up $WG_CONFIG" >/dev/null 2>&1; then
|
||||
ACTIVE=1
|
||||
elif pgrep -f "wireguard-go utun" >/dev/null 2>&1; then
|
||||
ACTIVE=1
|
||||
fi
|
||||
|
||||
echo "active=$ACTIVE"
|
||||
echo "interfaceName=$WG_INTERFACE"
|
||||
if [ -n "$RUNTIME_INTERFACE" ]; then
|
||||
echo "wireguardInterface=$RUNTIME_INTERFACE"
|
||||
fi
|
||||
if [ -f "$WG_CONFIG" ]; then
|
||||
echo "configPath=$WG_CONFIG"
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$ACTION" = "cleanup" ]; then
|
||||
"$WG_QUICK" down "$WG_CONFIG" >/dev/null 2>&1 || true
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
3
skills/property-assessor/.gitignore
vendored
Normal file
3
skills/property-assessor/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.venv/
|
||||
496
skills/property-assessor/SKILL.md
Normal file
496
skills/property-assessor/SKILL.md
Normal file
@@ -0,0 +1,496 @@
|
||||
---
|
||||
name: property-assessor
|
||||
description: Assess a real property from an address or listing URL and produce a decision-grade summary. Use when a user wants fair value, comps, rental or STR viability, carry-cost review, HOA or insurance risk analysis, or offer guidance for a condo, house, townhouse, or similar residential property. Prefer when the task should discover and reconcile multiple listing sources from a street address first, then give a buy/pass/only-below-X verdict.
|
||||
---
|
||||
|
||||
# Property Assessor
|
||||
|
||||
Start from the property address when possible. Treat listing URLs as supporting evidence, not the only source of truth.
|
||||
|
||||
## Inputs
|
||||
|
||||
Accept any of:
|
||||
- a street address
|
||||
- one or more listing URLs
|
||||
- an address plus the reason for the assessment, such as investment property, vacation home, owner-occupant, long-term rental, STR, or housing for a child in college
|
||||
|
||||
The assessment purpose is required for a decision-grade result.
|
||||
If the user does not say why they want the property assessed, stop and ask before finalizing the analysis.
|
||||
Do not silently infer or reuse the assessment purpose from earlier turns just because the property address is the same.
|
||||
Only reuse a prior purpose when the user explicitly says it is the same purpose as before.
|
||||
If the current request includes only the property/address plus email or PDF instructions, treat the assessment purpose as missing and ask again.
|
||||
If the current request explicitly says to email or send the PDF to a stated target address, treat that as delivery authorization for that target once the report is ready. Do not ask for a second "send it" confirmation unless the user expressed uncertainty or changed the destination.
|
||||
|
||||
If the property has a unit / apartment / suite number, include it.
|
||||
Do not drop the unit when discovering listing sources. Unit-qualified condo/townhome addresses must be matched as the exact unit, while single-family addresses with no unit should still work normally.
|
||||
|
||||
## Core workflow
|
||||
|
||||
1. Normalize the address and property type.
|
||||
2. Discover accessible listing or public-record sources for the same property.
|
||||
3. Establish a baseline fact set from the best available source.
|
||||
4. Cross-check the same property on other sites.
|
||||
5. Pull same-building comps for condos or nearby comps for houses/townhomes.
|
||||
6. Underwrite carrying cost with taxes, HOA, insurance, and realistic friction.
|
||||
7. Flag risk drivers before giving a verdict.
|
||||
8. End with a specific recommendation: `buy`, `pass`, or `only below X`.
|
||||
|
||||
Completion rule:
|
||||
- A preliminary helper payload is not completion.
|
||||
- If `assess` returns a preliminary payload, continue the remaining analysis yourself in the same run.
|
||||
- Use the preliminary payload as scaffolding for the missing work: listing facts, comp analysis, valuation range, condition interpretation from photos, and final verdict.
|
||||
- Do not stop and ask the user whether to continue just because the helper stopped at a preliminary checkpoint.
|
||||
- Only interrupt the user when there is a genuine blocker: missing assessment purpose, missing recipient email at final render/send time, unavailable listing/public-record sources with no reasonable fallback, or missing facts that cannot be recovered.
|
||||
|
||||
## Approval-averse / chat-safe behavior
|
||||
|
||||
When operating from chat surfaces such as WhatsApp, Telegram, Signal, or other messaging channels, prefer workflows that do **not** trigger host exec approval prompts.
|
||||
|
||||
Use this priority order:
|
||||
1. `web-automation` for Zillow/HAR listing discovery, rendered listing facts, all-photos flows, and other anti-bot-heavy property pages
|
||||
2. `web_fetch` when an official CAD/assessor page or simple listing/public page is directly fetchable
|
||||
3. `web_search` only as a narrow fallback to discover an alternate URL after direct `web-automation` / `web_fetch` attempts miss
|
||||
4. interactive browser/gallery automation only as a last resort
|
||||
|
||||
Rules:
|
||||
- Prefer accessible listing text, public mirrors, and scrollable all-photos pages over custom gallery traversal.
|
||||
- Avoid fragile interactive gallery flows if they are likely to require approval or bounce the user into Control UI.
|
||||
- If a richer photo pass would require approval, do not silently force that path first. Continue with the best approval-free workflow available and clearly lower confidence if needed.
|
||||
- Only escalate to approval-heavy browser interaction when there is no reasonable alternative and the extra fidelity materially changes the assessment.
|
||||
- In WhatsApp or similar messaging runs, keep the core assessment on `web-automation` plus `web_fetch`. Treat `web_search` as a fallback discovery aid, not the primary property-analysis path.
|
||||
- For Zillow/HAR property discovery, photo extraction, and rendered listing review, do **not** start with Brave-backed `web_search` when `web-automation` can open the candidate source directly.
|
||||
- For CAD/public-record enrichment, prefer official assessor/CAD pages via `web_fetch` first. Use `web-automation` when the official site needs rendered interaction. Do **not** start CAD lookup from generic web-search snippets when the official site is already known or derivable from the address.
|
||||
- In Texas runs, do **not** use `https://www.texas.gov/propertytaxes/search/` as the CAD lookup path. Use the address-first CAD/helper path or the discovered county CAD pages directly.
|
||||
- In those messaging runs, do **not** make `scripts/property-assessor assess`, `scripts/property-assessor locate-public-records`, `node zillow-discover.js`, `node har-discover.js`, `node zillow-photos.js`, `node har-photos.js`, `curl`, or `wget` the default core-analysis path.
|
||||
- From messaging runs, the only subprocess-style step you should attempt by default is the final `scripts/property-assessor render-report` call after the verdict, fair-value range, and report body are complete.
|
||||
- Do **not** inspect Gmail/email-send skills, mail tooling, or delivery integrations until the assessment is complete and the PDF is either already rendered or ready to render immediately.
|
||||
- Property-assessor delivery emails should be sent as Luke from Luke's Google Workspace account, while still delivering to the user-specified destination.
|
||||
- Use `zsh ~/.openclaw/workspace/bin/gog-luke gmail send --to "<target>" --subject "<subject>" --body "<body>" --attach "<pdf-path>"` for the final PDF delivery step.
|
||||
- Do **not** use generic `gog` or the Stefano helper `node ~/.openclaw/workspace/integrations/google-workspace/gw.js` for property-assessor report delivery.
|
||||
- If you need to confirm Luke auth before sending, use `zsh ~/.openclaw/workspace/bin/gog-luke auth list --check --plain`.
|
||||
- A silent helper is a failed helper in messaging runs. If a background helper produces no useful stdout/stderr and no result within a short bound, stop polling it, treat that path as failed, and continue on the chat-native assessment path instead of narrating that it is still chewing.
|
||||
- If Zillow photo extraction fails, immediately continue with HAR photo fallback or the next available rendered listing/photo source. Do **not** stop the assessment because one Zillow photo subprocess failed.
|
||||
- After a Zillow/HAR photo miss, continue the comp and CAD/public-record work in the same run. A photo-source miss is a fallback event, not a terminal state.
|
||||
- Do **not** leave the user parked behind background helper polling. If a helper has not produced a result quickly, give a concise status update and continue the assessment with the next available non-helper path.
|
||||
- If the user already instructed you to email/send the finished PDF to a specific target, do **not** ask for a second send confirmation after rendering. Render, send, and report the result.
|
||||
- If the final PDF render fails, return the complete decision-grade report in chat and say the render/send step failed. Do not restart the whole assessment.
|
||||
- Do **not** run `npm install`, `npm ci`, or other dependency-setup commands during a normal chat assessment flow when the local skill already has `node_modules` present.
|
||||
- On this machine, treat `property-assessor` dependencies as already installed. Use the helper entrypoints directly unless the wrapper itself explicitly reports missing local dependencies.
|
||||
- Do **not** use ad hoc shell snippets, heredocs, or inline interpreter eval for public-record or CAD lookup from chat. Avoid forms like `python3 - <<'PY'`, `python -c`, `node -e`, `node --input-type=module -e`, or raw `bash -lc '...'` probes.
|
||||
- For public-record enrichment in chat, prefer `web_fetch` plus official assessor/CAD pages. If a one-off helper is truly needed, add a file-based helper under the skill tree first and use it from a non-messaging surface instead of inline code.
|
||||
|
||||
## Source order
|
||||
|
||||
Prefer this order unless the user says otherwise:
|
||||
1. Zillow
|
||||
2. Redfin
|
||||
3. Realtor.com
|
||||
4. HAR / Homes.com / brokerage mirror pages
|
||||
5. county or appraisal pages
|
||||
|
||||
Use the `web-automation` skill for rendered pages and anti-bot-heavy sites.
|
||||
Use `web_search` sparingly to discover alternate URLs, then return to `web-automation` for extraction.
|
||||
|
||||
## Helper runtime
|
||||
|
||||
These helper commands are primarily for local/manual runs, non-chat surfaces, and the final PDF render step. They are **not** the default WhatsApp core-analysis path.
|
||||
|
||||
`property-assessor` now includes TypeScript helper commands for:
|
||||
- address-first preliminary assessment assembly
|
||||
- public-record jurisdiction lookup
|
||||
- fixed-template PDF rendering
|
||||
|
||||
Before using those helper commands:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
test -x node_modules/.bin/tsx
|
||||
```
|
||||
|
||||
Quick command summary:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor assess --address "<street-address>"
|
||||
scripts/property-assessor assess --address "<street-address>" --assessment-purpose "<purpose>"
|
||||
scripts/property-assessor assess --address "<street-address>" --assessment-purpose "<purpose>" --recipient-email "<target@example.com>"
|
||||
scripts/property-assessor locate-public-records --address "<street-address>"
|
||||
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
|
||||
```
|
||||
|
||||
`assess` is the address-first entrypoint for helper-driven runs. It should:
|
||||
- require the assessment purpose
|
||||
- treat the assessment purpose as missing unless it is present in the current request or explicitly confirmed as unchanged from earlier context
|
||||
- resolve official public-record jurisdiction automatically from the address
|
||||
- keep CAD discovery jurisdiction-specific from the address; do not hardcode one county CAD for every property
|
||||
- try to discover Zillow and HAR listing URLs from the address when no listing URL is provided
|
||||
- start Zillow and HAR discovery in parallel, while still preferring Zillow first for the photo-review path
|
||||
- run the approval-safe Zillow/HAR photo extractor chain automatically
|
||||
- allow slower exact-unit Zillow pages a longer source-specific discovery/photo window before giving up and falling back
|
||||
- build a purpose-aware report payload
|
||||
- complete the analysis without requiring recipient email(s)
|
||||
- only stop and ask for recipient email(s) when the user is explicitly rendering or sending the PDF
|
||||
- render the PDF only after recipient email(s) are known
|
||||
- do **not** render or send a PDF from the helper's preliminary payload while verdict is still `pending` or fair value is not established
|
||||
- do **not** render or send a decision-grade PDF while `photoReview.status` is anything other than `completed`
|
||||
- if comps, valuation, or decision-grade condition interpretation are still incomplete, return the preliminary payload and say that the PDF/send step must wait
|
||||
|
||||
Agent follow-through rule:
|
||||
- When the user asked for a full property assessment or asked for the PDF/email result, do not stop at the helper output.
|
||||
- After `assess` returns a preliminary payload, continue with the remaining manual/model-driven steps needed to reach a decision-grade report.
|
||||
- Only after the verdict and fair-value range are established should you render/send the PDF.
|
||||
- A verdict and fair-value range are still not enough by themselves; the subject-unit photo review must also be completed before the PDF/send step.
|
||||
- If the analysis still cannot be completed, explain the first real blocker, not just that the helper was preliminary.
|
||||
- If the user sends `update?`, `and?`, or similar mid-run, answer with status and keep the original assessment going. Do not treat that message as a reset or a cue to stop at the last helper checkpoint.
|
||||
- In WhatsApp or similar messaging runs, do **not** start a background `assess` helper and then wait on repeated zero-output polls. That counts as a failed path; abandon it and continue with native search/fetch/browser work.
|
||||
- In WhatsApp or similar messaging runs, do **not** start mail-sending or mail-skill discovery before the report content, verdict, and delivery artifact are ready.
|
||||
- Once the report is ready, treat an explicit prior instruction like "Email pdf version to me at <address>" as sufficient authorization to send.
|
||||
|
||||
## Public-record enrichment
|
||||
|
||||
Public-record / assessor data should be used when available and linked in the final result.
|
||||
|
||||
Default approach:
|
||||
1. start from the street address
|
||||
2. resolve the address to county/state/geography
|
||||
3. identify the related appraisal district / assessor jurisdiction
|
||||
4. use the official public-record site as a primary-source check against listing data
|
||||
5. link the official jurisdiction page and any direct property page used in the final result
|
||||
|
||||
Approval-safe rule:
|
||||
- do not perform CAD/public-record discovery with inline shell or interpreter snippets
|
||||
- in chat/messaging runs, prefer `web_fetch` plus official CAD/assessor pages first
|
||||
- use the built-in TypeScript helper path on local/manual surfaces or for the final PDF render step
|
||||
- if rendered interaction is unavoidable, use bounded `web-automation` behavior rather than ad hoc shell text
|
||||
|
||||
Use the helper CLI first on local/manual surfaces:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor locate-public-records --address "<street-address>"
|
||||
```
|
||||
|
||||
When you want the helper to assemble the preliminary assessment payload in one step from a non-messaging surface, use:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
scripts/property-assessor assess --address "<street-address>"
|
||||
```
|
||||
|
||||
This command should automatically include the public-record jurisdiction result in the returned assessment payload.
|
||||
When `--assessment-purpose` is present, it should also:
|
||||
- frame the analysis around that stated purpose
|
||||
- try Zillow discovery from the address
|
||||
- try HAR discovery from the address
|
||||
- run Zillow photo extraction first when available, then HAR as fallback
|
||||
- include the extracted `imageUrls` in `photoReview` when successful
|
||||
|
||||
This command currently:
|
||||
- resolves the address through the official Census geocoder
|
||||
- when Census address matching misses, falls back to an external address geocoder and then resolves official Census geographies from coordinates
|
||||
- retries the fallback geocoder without the unit suffix when a condo/unit-qualified address is too specific for the fallback provider
|
||||
- returns county/state/FIPS/GEOID context
|
||||
- for Texas, resolves the official Texas Comptroller county directory page
|
||||
- returns the county appraisal district and tax assessor/collector links when available
|
||||
- when a supported official CAD detail host is found, retrieves subject-property facts from that county CAD and includes them in the assessment payload
|
||||
|
||||
Important rules:
|
||||
- listing-site geo IDs are hints only; do **not** treat them as assessor record keys
|
||||
- parcel/APN/account identifiers from Zillow/HAR/Redfin are much stronger keys than listing geo IDs
|
||||
- if Zillow exposes a parcel/APN/account number on the listing, capture it and feed that identifier into CAD lookup before relying on address-only matching
|
||||
- if a direct public-record property page is available, use its data in the assessment and link it explicitly
|
||||
- when the helper exposes official CAD owner, legal-description, property-ID/account, value, or exemption data, treat those as primary-source facts in the model's assessment
|
||||
- if the jurisdiction can be identified but the property detail page is not directly retrievable, still link the official jurisdiction page and say what could not be confirmed
|
||||
- a host approval prompt triggered by an ad hoc shell snippet is workflow drift; return to `locate-public-records`, `assess`, `web_fetch`, or a file-based helper instead of approving the inline probe by default
|
||||
|
||||
### Texas rule
|
||||
|
||||
For Texas properties, public-record enrichment is required when feasible.
|
||||
|
||||
Process:
|
||||
1. run `locate-public-records` from the subject address
|
||||
2. use the returned Texas Comptroller county directory page as the official jurisdiction reference
|
||||
3. use the returned CAD website for address / account / parcel lookup
|
||||
- when Zillow exposes the parcel/APN/account number, prefer that over address-only search
|
||||
4. when accessible, capture:
|
||||
- account number
|
||||
- owner name
|
||||
- land value
|
||||
- improvement value
|
||||
- assessed total
|
||||
- exemptions
|
||||
- tax office links
|
||||
|
||||
In the final assessment, explicitly label official public-record facts as such.
|
||||
|
||||
Nueces-specific note:
|
||||
- when searching Nueces CAD by parcel / Geographic ID, format the identifier with a dash after the first 4 digits and after the first 8 digits, for example `123456789012` -> `1234-5678-9012`
|
||||
|
||||
## Minimum data to capture
|
||||
|
||||
For the target property, capture when available:
|
||||
- address
|
||||
- ask price or last known list price
|
||||
- property type
|
||||
- beds / baths
|
||||
- sqft
|
||||
- lot size if relevant
|
||||
- year built
|
||||
- HOA fee and included services
|
||||
- taxes
|
||||
- days on market
|
||||
- price history
|
||||
- parking
|
||||
- waterfront / flood clues
|
||||
- subdivision / building name when applicable
|
||||
- same-building or nearby active inventory
|
||||
- listing photos and visible condition cues
|
||||
- included appliances and obvious missing appliances
|
||||
- flooring mix, especially whether carpet is present
|
||||
- public-record jurisdiction and linked official source
|
||||
- account / parcel / tax ID if confirmed
|
||||
- official assessed values and exemptions if confirmed
|
||||
|
||||
## Photo and condition review
|
||||
|
||||
Always look at the listing photos when they are available. Do not rate a property only from structured text.
|
||||
|
||||
### Required photo-access workflow
|
||||
|
||||
When the source site exposes listing photos, prefer the **most accessible all-photos view** first. This can be:
|
||||
- a scrollable all-photos page
|
||||
- a photo grid
|
||||
- an expanded photo list
|
||||
- or, if necessary, a modal gallery/lightbox
|
||||
|
||||
Use `web-automation` for this. Preferred process:
|
||||
1. Open the listing page.
|
||||
2. Click the photo entry point such as `See all photos`, `See all 29 photos`, `Show all photos`, or the main hero image.
|
||||
3. If that opens a scrollable all-photos view, grid, or photo page that clearly exposes the listing images, use that directly for photo review.
|
||||
4. Only use next-arrow / slideshow traversal when the site does not provide an accessible all-photos view.
|
||||
5. If you must use a modal/lightbox, verify that you are seeing distinct images, not just a gallery preview tile or repeated screenshot of the first image.
|
||||
6. Review enough images to cover the key rooms and exterior, and for smaller listings aim to review all photos when practical.
|
||||
7. If photo access fails or is incomplete, say so explicitly and do not claim that you reviewed all photos.
|
||||
|
||||
Minimum honesty rule: never say you "looked at all photos" unless the site actually exposed the full set and you successfully reviewed them.
|
||||
A gallery landing page, collage preview, repeated first image, or a single screenshot of the listing page does **not** count as full photo review.
|
||||
|
||||
### What to inspect in the photos
|
||||
|
||||
At minimum, note:
|
||||
- overall finish level: dated, average, lightly updated, fully updated
|
||||
- kitchen condition: cabinets, counters, backsplash, appliance quality
|
||||
- bathroom condition: vanity, tile, surrounds, fixtures
|
||||
- flooring: tile, vinyl, laminate, hardwood, carpet
|
||||
- whether carpet appears in bedrooms, stairs, or living areas
|
||||
- obvious make-ready issues: paint, damaged trim, old fixtures, mismatched finishes, worn surfaces
|
||||
- visible missing items: refrigerator, washer/dryer, range hood, dishwasher, etc.
|
||||
- any signs of deferred maintenance or water intrusion visible in photos
|
||||
- exterior/common-area condition when visible
|
||||
- balconies, decks, sliders, windows, and waterfront-facing elements for condos/townhomes near water
|
||||
|
||||
### If photo review is incomplete
|
||||
|
||||
If photos are weak, incomplete, blocked, or the gallery automation fails:
|
||||
- say so explicitly
|
||||
- lower confidence
|
||||
- avoid strong condition claims
|
||||
- do not infer turnkey condition from marketing text alone
|
||||
|
||||
### Mandatory photo-review rule
|
||||
|
||||
If an accessible all-photos view, photo grid, photo page, or fallback source exists, photo review is **required** before making condition claims.
|
||||
Do not silently skip photos just because pricing, comps, or carrying-cost analysis can proceed without them.
|
||||
|
||||
Before outputting `Photo review: not completed`, you must attempt a reasonable photo-access chain when sources are available.
|
||||
Preferred order:
|
||||
1. primary listing source all-photos page (for example Zillow `See all photos` / `See all X photos`)
|
||||
2. HAR photo page
|
||||
3. Realtor.com photo page
|
||||
4. brokerage mirror or other accessible listing mirror
|
||||
|
||||
Use the first source that exposes the listing photos reliably. A scrollable photo page, photo grid, or expanded all-photos view counts.
|
||||
Do not stop at the first failure if another accessible source is available.
|
||||
|
||||
### Zillow-specific rule
|
||||
|
||||
For Zillow listings, do **not** treat the listing shell text or hero gallery preview as a photo review attempt.
|
||||
You must use **only `web-automation`** to:
|
||||
1. open the Zillow listing page
|
||||
2. click `See all photos` / `See all X photos`
|
||||
3. access the resulting all-photos page or scrollable photo view
|
||||
4. review the exposed photo set from that page
|
||||
|
||||
If Zillow exposes a page with a scroller that shows the listing photos, that page counts as the Zillow photo source and should be used directly.
|
||||
If that scroller page exposes direct image links such as `https://photos.zillowstatic.com/...`, treat those URLs as successful photo access and use that image set for review.
|
||||
This is preferred over fragile modal next/previous navigation.
|
||||
If the rendered Zillow listing shell itself already exposes the full direct Zillow image set and the extracted image count matches the announced photo count, that also counts as successful photo access even if the `See all photos` click path is flaky.
|
||||
|
||||
For this normal Zillow all-photos workflow, stay inside **`web-automation` only**.
|
||||
Use the dedicated file-based extractor first:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node zillow-photos.js "<zillow-listing-url>"
|
||||
```
|
||||
|
||||
Do **not** escalate to coding-agent, ad hoc Python helpers, or extra dependency-heavy tooling just to open `See all photos`, inspect the scroller page, or extract Zillow image URLs.
|
||||
Only escalate beyond `web-automation` if `web-automation` itself truly cannot access the all-photos/scroller page or the exposed image set.
|
||||
|
||||
Do not rely on generic page text, photo counts, or non-photo shells as a Zillow attempt.
|
||||
Only fall back to HAR/Realtor/broker mirrors if the Zillow all-photos path was actually attempted with `web-automation` and did not expose the photos reliably.
|
||||
A source only counts as an attempted photo source if you actually did one of these:
|
||||
- opened the all-photos page / photo grid / photo page successfully, or
|
||||
- explicitly tried to open it and observed a concrete failure
|
||||
|
||||
The following do **not** count as a photo-source attempt by themselves:
|
||||
- seeing a `See all photos` button
|
||||
- seeing a photo count
|
||||
- reading listing text that mentions photos
|
||||
- capturing only the listing shell, hero image, or collage preview
|
||||
|
||||
### HAR fallback rule
|
||||
|
||||
If Zillow photo extraction does not expose usable direct image URLs, try HAR next for the same property.
|
||||
|
||||
For HAR listings, use **only `web-automation`** to:
|
||||
1. open the HAR listing page
|
||||
2. click `Show all photos` / `View all photos`
|
||||
3. access the resulting all-photos page or photo view
|
||||
4. extract the direct image URLs from that page
|
||||
|
||||
Use the dedicated HAR extractor first:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node har-photos.js "<har-listing-url>"
|
||||
```
|
||||
|
||||
If HAR exposes the direct photo URLs from the all-photos page, treat that as successful photo access and use that image set for review.
|
||||
Do not stop after a failed Zillow attempt if HAR is available and exposes the listing photos more reliably.
|
||||
|
||||
When a dedicated extractor returns `imageUrls`, inspect the images in that returned set before making condition claims.
|
||||
For smaller listings, review the full extracted set when practical; for a 20-30 photo listing, that usually means all photos.
|
||||
|
||||
### Approval-safe command shape
|
||||
|
||||
When running `web-automation` from chat-driven property assessment, prefer file-based commands under `~/.openclaw/workspace/skills/web-automation/scripts`.
|
||||
|
||||
Good:
|
||||
- `node check-install.js`
|
||||
- `node zillow-photos.js "<url>"`
|
||||
- `node har-photos.js "<url>"`
|
||||
|
||||
Avoid approval-sensitive inline interpreter eval where possible:
|
||||
- `node -e "..."`
|
||||
- `node --input-type=module -e "..."`
|
||||
|
||||
The final assessment must explicitly include these lines in the output:
|
||||
- `Photo source attempts: <action-based summary>`
|
||||
- `Photo review: completed via <source>`
|
||||
or `Photo review: not completed`
|
||||
|
||||
If completed, briefly summarize the condition read from the photos.
|
||||
If not completed, mark condition confidence as limited and say why.
|
||||
|
||||
## PDF report requirement
|
||||
|
||||
The deliverable is not just chat text. A fixed-template PDF report must be generated for completed assessments.
|
||||
|
||||
Use the property-assessor helper CLI:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/property-assessor
|
||||
npm install
|
||||
scripts/property-assessor assess --address "<street-address>" --recipient-email "<target@example.com>"
|
||||
scripts/property-assessor render-report --input "<report-payload-json>" --output "<output-pdf>"
|
||||
```
|
||||
|
||||
The renderer uses a fixed template and must keep the same look across runs.
|
||||
Template rules are documented in `references/report-template.md`.
|
||||
|
||||
The PDF report should include:
|
||||
1. report header
|
||||
2. verdict panel
|
||||
3. subject-property summary table
|
||||
4. Snapshot
|
||||
5. What I like
|
||||
6. What I do not like
|
||||
7. Comp view
|
||||
8. Underwriting / carry view
|
||||
9. Risks and diligence items
|
||||
10. Photo review
|
||||
11. Public records
|
||||
12. Source links
|
||||
|
||||
### Recipient-email gate
|
||||
|
||||
Before rendering or sending the PDF, the skill must know the target recipient email address(es).
|
||||
|
||||
This gate applies to the PDF step, not the analysis step.
|
||||
Do not interrupt a normal assessment run just because recipient email is missing if the user has not yet asked to render or send the PDF.
|
||||
|
||||
If the prompt does **not** include target email(s):
|
||||
- stop
|
||||
- ask the user for the target email address(es)
|
||||
- do **not** render or send the final PDF yet
|
||||
|
||||
If target email(s) are present:
|
||||
- include them in the report payload
|
||||
- render the PDF with the fixed template
|
||||
- if a delivery workflow is available, use those same target email(s) for sending
|
||||
|
||||
The renderer enforces this gate and will fail if the payload has no recipient email list.
|
||||
|
||||
## Normalization / make-ready adjustment
|
||||
|
||||
Estimate a rough make-ready budget when condition is not turnkey. The goal is not contractor precision; the goal is apples-to-apples comparison.
|
||||
|
||||
Use simple buckets and state them as rough ranges:
|
||||
- light make-ready: paint, fixtures, minor hardware, patching
|
||||
- medium make-ready: flooring replacement in some rooms, appliance replacement, bathroom refresh
|
||||
- heavy make-ready: major kitchen/bath work, widespread flooring, obvious deferred maintenance
|
||||
|
||||
Call out carpet separately. If carpet is present, estimate replacement or removal cost as part of the make-ready note.
|
||||
|
||||
## Underwriting rules
|
||||
|
||||
Always show a simple carrying-cost view with at least:
|
||||
- principal and interest if available from the listing
|
||||
- taxes per month
|
||||
- HOA per month if applicable
|
||||
- insurance estimate or note uncertainty
|
||||
- realistic effective carry range after maintenance, vacancy, and property-specific risk
|
||||
|
||||
Treat these as strong caution flags:
|
||||
- high HOA relative to price or expected rent
|
||||
- older waterfront or coastal exposure
|
||||
- unknown reserve / assessment history for condos
|
||||
- many active units in the same building or micro-area
|
||||
- stale days on market with weak price action
|
||||
- no clear rent support
|
||||
|
||||
## Output format
|
||||
|
||||
Keep the answer concise but decision-grade:
|
||||
|
||||
1. Snapshot
|
||||
2. What I like
|
||||
3. What I do not like
|
||||
4. Comp view
|
||||
5. Underwriting / carry view
|
||||
6. Risks and diligence items
|
||||
7. Verdict with fair value range and offer guidance
|
||||
|
||||
Also include:
|
||||
- public-record / CAD evidence and links when available
|
||||
- the path to the rendered PDF after generation
|
||||
|
||||
If the user did not provide recipient email(s), ask for them instead of finalizing the PDF workflow.
|
||||
|
||||
## Reuse notes
|
||||
|
||||
When condos are involved, same-building comps and HOA economics usually matter more than neighborhood averages.
|
||||
For detailed heuristics and the reusable memo template, read:
|
||||
- `references/underwriting-rules.md`
|
||||
- `references/report-template.md`
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"recipientEmails": [
|
||||
"buyer@example.com"
|
||||
],
|
||||
"assessmentPurpose": "investment property",
|
||||
"reportTitle": "Property Assessment Report",
|
||||
"subtitle": "Sample property assessment payload",
|
||||
"subjectProperty": {
|
||||
"address": "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
"listingPrice": 149900,
|
||||
"propertyType": "Townhouse",
|
||||
"beds": 2,
|
||||
"baths": 2,
|
||||
"squareFeet": 900,
|
||||
"yearBuilt": 1978
|
||||
},
|
||||
"verdict": {
|
||||
"decision": "only below x",
|
||||
"fairValueRange": "$132,000 - $138,000",
|
||||
"offerGuidance": "Only attractive below the current ask once HOA, insurance, and make-ready are priced in."
|
||||
},
|
||||
"snapshot": [
|
||||
"Small coastal townhouse with a tight margin at the current ask.",
|
||||
"Needs CAD/public-record reconciliation before a high-confidence offer."
|
||||
],
|
||||
"whatILike": [
|
||||
"Usable 2 bed / 2 bath layout.",
|
||||
"Straightforward official public-record jurisdiction in Nueces County."
|
||||
],
|
||||
"whatIDontLike": [
|
||||
"Ask looks full for the visible finish level.",
|
||||
"Coastal exposure increases long-run carry and maintenance risk."
|
||||
],
|
||||
"compView": [
|
||||
"Need same-building or very local townhome comps before treating ask as fair value."
|
||||
],
|
||||
"carryView": [
|
||||
"Underwrite taxes, HOA, wind/flood insurance, and maintenance together."
|
||||
],
|
||||
"risksAndDiligence": [
|
||||
"Confirm reserve strength and special assessment history.",
|
||||
"Confirm insurance obligations and any storm-related repair history."
|
||||
],
|
||||
"photoReview": {
|
||||
"status": "completed",
|
||||
"source": "Zillow",
|
||||
"attempts": [
|
||||
"Zillow extractor returned the full direct photo set."
|
||||
],
|
||||
"summary": "Interior reads dated-to-average rather than turnkey."
|
||||
},
|
||||
"publicRecords": {
|
||||
"jurisdiction": "Nueces Appraisal District",
|
||||
"accountNumber": "sample-account",
|
||||
"landValue": 42000,
|
||||
"improvementValue": 99000,
|
||||
"assessedTotalValue": 141000,
|
||||
"exemptions": "Not confirmed in sample payload",
|
||||
"links": [
|
||||
{
|
||||
"label": "Texas Comptroller County Directory",
|
||||
"url": "https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php"
|
||||
},
|
||||
{
|
||||
"label": "Nueces CAD",
|
||||
"url": "http://www.ncadistrict.com/"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sourceLinks": [
|
||||
{
|
||||
"label": "Zillow Listing",
|
||||
"url": "https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/"
|
||||
},
|
||||
{
|
||||
"label": "HAR Listing",
|
||||
"url": "https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
|
||||
}
|
||||
]
|
||||
}
|
||||
791
skills/property-assessor/package-lock.json
generated
Normal file
791
skills/property-assessor/package-lock.json
generated
Normal file
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"name": "property-assessor-scripts",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "property-assessor-scripts",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz",
|
||||
"integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz",
|
||||
"integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz",
|
||||
"integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz",
|
||||
"integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz",
|
||||
"integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz",
|
||||
"integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz",
|
||||
"integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz",
|
||||
"integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz",
|
||||
"integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/helpers": {
|
||||
"version": "0.5.20",
|
||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.20.tgz",
|
||||
"integrity": "sha512-2egEBHUMasdypIzrprsu8g+OEVd7Vp2MM3a2eVlM/cyFYto0nGz5BX5BTgh/ShZZI9ed+ozEq+Ngt+rgmUs8tw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/minimist": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz",
|
||||
"integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz",
|
||||
"integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pdfkit": {
|
||||
"version": "0.17.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/pdfkit/-/pdfkit-0.17.5.tgz",
|
||||
"integrity": "sha512-T3ZHnvF91HsEco5ClhBCOuBwobZfPcI2jaiSHybkkKYq4KhVIIurod94JVKvDIG0JXT6o3KiERC0X0//m8dyrg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brotli": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/brotli/-/brotli-1.3.3.tgz",
|
||||
"integrity": "sha512-oTKjJdShmDuGW94SyyaoQvAjf30dZaHnjJ8uAF+u2/vGJkJbJPJAT1gDiOJP5v1Zb6f9KEyW/1HpuaWIXtGHPg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/clone": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/crypto-js": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
|
||||
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dfa": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/dfa/-/dfa-1.2.0.tgz",
|
||||
"integrity": "sha512-ED3jP8saaweFTjeGX8HQPjeC1YYyZs98jGNZx6IiBvxW7JG5v492kamAQB3m2wop07CvU/RQmzcKr6bgcC5D/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.27.4",
|
||||
"@esbuild/android-arm": "0.27.4",
|
||||
"@esbuild/android-arm64": "0.27.4",
|
||||
"@esbuild/android-x64": "0.27.4",
|
||||
"@esbuild/darwin-arm64": "0.27.4",
|
||||
"@esbuild/darwin-x64": "0.27.4",
|
||||
"@esbuild/freebsd-arm64": "0.27.4",
|
||||
"@esbuild/freebsd-x64": "0.27.4",
|
||||
"@esbuild/linux-arm": "0.27.4",
|
||||
"@esbuild/linux-arm64": "0.27.4",
|
||||
"@esbuild/linux-ia32": "0.27.4",
|
||||
"@esbuild/linux-loong64": "0.27.4",
|
||||
"@esbuild/linux-mips64el": "0.27.4",
|
||||
"@esbuild/linux-ppc64": "0.27.4",
|
||||
"@esbuild/linux-riscv64": "0.27.4",
|
||||
"@esbuild/linux-s390x": "0.27.4",
|
||||
"@esbuild/linux-x64": "0.27.4",
|
||||
"@esbuild/netbsd-arm64": "0.27.4",
|
||||
"@esbuild/netbsd-x64": "0.27.4",
|
||||
"@esbuild/openbsd-arm64": "0.27.4",
|
||||
"@esbuild/openbsd-x64": "0.27.4",
|
||||
"@esbuild/openharmony-arm64": "0.27.4",
|
||||
"@esbuild/sunos-x64": "0.27.4",
|
||||
"@esbuild/win32-arm64": "0.27.4",
|
||||
"@esbuild/win32-ia32": "0.27.4",
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fontkit": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fontkit/-/fontkit-2.0.4.tgz",
|
||||
"integrity": "sha512-syetQadaUEDNdxdugga9CpEYVaQIxOwk7GlwZWWZ19//qW4zE5bknOKeMBDYAASwnpaSHKJITRLMF9m1fp3s6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@swc/helpers": "^0.5.12",
|
||||
"brotli": "^1.3.2",
|
||||
"clone": "^2.1.2",
|
||||
"dfa": "^1.2.0",
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"restructure": "^3.0.0",
|
||||
"tiny-inflate": "^1.0.3",
|
||||
"unicode-properties": "^1.4.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/jpeg-exif": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/jpeg-exif/-/jpeg-exif-1.1.4.tgz",
|
||||
"integrity": "sha512-a+bKEcCjtuW5WTdgeXFzswSrdqi0jk4XlEtZlx5A94wCoBpFjfFTbo/Tra5SpNCl/YFZPvcV1dJc+TAYeg6ROQ==",
|
||||
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/linebreak": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/linebreak/-/linebreak-1.1.0.tgz",
|
||||
"integrity": "sha512-MHp03UImeVhB7XZtjd0E4n6+3xr5Dq/9xI/5FptGk5FrbDR3zagPa2DS6U8ks/3HjbKWG9Q1M2ufOzxV2qLYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "0.0.8",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linebreak/node_modules/base64-js": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-0.0.8.tgz",
|
||||
"integrity": "sha512-3XSA2cR/h/73EzlXXdU6YNycmYI7+kicTxks4eJg2g39biHR84slg2+des+p7iHYhbRg/udIS4TD53WabcOUkw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "0.2.9",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-0.2.9.tgz",
|
||||
"integrity": "sha512-NUcwaKxUxWrZLpDG+z/xZaCgQITkA/Dv4V/T6bw7VON6l1Xz/VnrBqrYjZQ12TamKHzITTfOEIYUj48y2KXImA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pdfkit": {
|
||||
"version": "0.17.2",
|
||||
"resolved": "https://registry.npmjs.org/pdfkit/-/pdfkit-0.17.2.tgz",
|
||||
"integrity": "sha512-UnwF5fXy08f0dnp4jchFYAROKMNTaPqb/xgR8GtCzIcqoTnbOqtp3bwKvO4688oHI6vzEEs8Q6vqqEnC5IUELw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"crypto-js": "^4.2.0",
|
||||
"fontkit": "^2.0.4",
|
||||
"jpeg-exif": "^1.1.4",
|
||||
"linebreak": "^1.1.0",
|
||||
"png-js": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/png-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/png-js/-/png-js-1.0.0.tgz",
|
||||
"integrity": "sha512-k+YsbhpA9e+EFfKjTCH3VW6aoKlyNYI6NYdTfDL4CIvFnvsuO84ttonmZE7rc+v23SLTH8XX+5w/Ak9v0xGY4g=="
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/restructure": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/restructure/-/restructure-3.0.2.tgz",
|
||||
"integrity": "sha512-gSfoiOEA0VPE6Tukkrr7I0RBdE0s7H1eFCDBk05l1KIQT1UIKNc5JZy6jdyW6eYH3aR3g5b3PuL77rq0hvwtAw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tiny-inflate": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-inflate/-/tiny-inflate-1.0.3.tgz",
|
||||
"integrity": "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/unicode-properties": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/unicode-properties/-/unicode-properties-1.4.1.tgz",
|
||||
"integrity": "sha512-CLjCCLQ6UuMxWnbIylkisbRj31qxHPAurvena/0iwSVbQ2G1VY5/HjV0IRabOEbDHlzZlRdCrD4NhB0JtU40Pg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.0",
|
||||
"unicode-trie": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/unicode-trie": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unicode-trie/-/unicode-trie-2.0.0.tgz",
|
||||
"integrity": "sha512-x7bc76x0bm4prf1VLg79uhAzKw8DVboClSN5VxJuQ+LKDOVEW9CdH+VY7SP+vX7xCYQqzzgQpFqz15zeLvAtZQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pako": "^0.2.5",
|
||||
"tiny-inflate": "^1.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
skills/property-assessor/package.json
Normal file
22
skills/property-assessor/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "property-assessor-scripts",
|
||||
"version": "1.0.0",
|
||||
"description": "Property assessor helpers for public-record lookup and fixed-template PDF rendering",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"assess": "tsx src/cli.ts assess",
|
||||
"locate-public-records": "tsx src/cli.ts locate-public-records",
|
||||
"render-report": "tsx src/cli.ts render-report",
|
||||
"test": "node --import tsx --test tests/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.8",
|
||||
"pdfkit": "^0.17.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/minimist": "^1.2.5",
|
||||
"@types/pdfkit": "^0.17.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "^5.9.2"
|
||||
}
|
||||
}
|
||||
56
skills/property-assessor/references/report-template.md
Normal file
56
skills/property-assessor/references/report-template.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Property Assessment PDF Template
|
||||
|
||||
The `property-assessor` PDF output must use the same visual template every time.
|
||||
|
||||
## Template sections
|
||||
|
||||
1. Report header
|
||||
- title: `Property Assessment Report`
|
||||
- subtitle / run context
|
||||
- prepared-for recipient email list
|
||||
- generated timestamp
|
||||
|
||||
2. Verdict panel
|
||||
- decision badge: `BUY`, `PASS`, or `ONLY BELOW X`
|
||||
- offer guidance sentence
|
||||
|
||||
3. Summary table
|
||||
- address
|
||||
- ask / last price
|
||||
- property type
|
||||
- beds / baths
|
||||
- square feet
|
||||
- year built
|
||||
- verdict
|
||||
- fair value range
|
||||
- public-record jurisdiction
|
||||
- assessed total
|
||||
|
||||
4. Body sections
|
||||
- Snapshot
|
||||
- What I Like
|
||||
- What I Do Not Like
|
||||
- Comp View
|
||||
- Underwriting / Carry View
|
||||
- Risks and Diligence Items
|
||||
- Photo Review
|
||||
- Public Records
|
||||
- Source Links
|
||||
|
||||
5. Notes page
|
||||
- report policy note
|
||||
- reminder that listing data should be reconciled against official public records when available
|
||||
|
||||
## Visual rules
|
||||
|
||||
- fixed blue section headers
|
||||
- verdict badge color depends on the decision
|
||||
- same margins, typography, and section ordering for every report
|
||||
- links should be shown explicitly in the PDF
|
||||
- no ad hoc rearranging of sections per run
|
||||
|
||||
## Recipient gate
|
||||
|
||||
The report must not be rendered or sent unless target recipient email address(es) are known.
|
||||
|
||||
If the prompt does not include recipient email(s), stop and ask for them before rendering the PDF.
|
||||
110
skills/property-assessor/references/underwriting-rules.md
Normal file
110
skills/property-assessor/references/underwriting-rules.md
Normal file
@@ -0,0 +1,110 @@
|
||||
# Property Assessor Underwriting Rules
|
||||
|
||||
## Goal
|
||||
|
||||
Turn messy property data into a fast investor or buyer decision without pretending precision where the data is weak.
|
||||
|
||||
## Practical heuristics
|
||||
|
||||
### 1. Use the most comparable comp set first
|
||||
- For condos: same-building active and sold comps first.
|
||||
- For houses or townhomes: same subdivision or immediate micro-area first.
|
||||
- Expand outward only when the close comp set is thin.
|
||||
|
||||
### 2. Fixed carrying costs can kill a deal
|
||||
High HOA, insurance, taxes, or unusual maintenance burden can make a property unattractive even when price-per-sqft looks cheap.
|
||||
|
||||
### 3. Waterfront, coastal, older, or unusual properties deserve extra skepticism
|
||||
Assume elevated insurance, maintenance, and assessment risk until proven otherwise.
|
||||
|
||||
### 4. DOM and price cuts are negotiation signals, not automatic value
|
||||
Long market time helps the buyer, but a stale listing can still be a mediocre deal if the economics are weak.
|
||||
|
||||
### 5. Unknowns must stay visible
|
||||
If reserve studies, bylaws, STR rules, assessment history, lease restrictions, or condition details are missing, list them explicitly as unresolved diligence items.
|
||||
|
||||
## Minimum comp stack
|
||||
|
||||
For each property, aim to collect:
|
||||
- target listing facts
|
||||
- same-building or same-micro-area active listings
|
||||
- sold listings if available
|
||||
- nearby similar properties
|
||||
- rent or lease signals when investment analysis matters
|
||||
|
||||
## Condition normalization
|
||||
|
||||
Listings with similar asking prices can have very different real basis once make-ready work is included.
|
||||
|
||||
Assess photos for:
|
||||
- flooring condition and carpet presence
|
||||
- appliance package completeness and age
|
||||
- kitchen and bath refresh level
|
||||
- paint and trim condition
|
||||
- lighting, fans, doors, and visible wear
|
||||
- any obvious repair or moisture concerns
|
||||
|
||||
Use a rough make-ready range such as:
|
||||
- Light: cosmetic cleanup / paint / fixtures
|
||||
- Medium: flooring plus partial appliance or bath refresh
|
||||
- Heavy: major interior updates or visible deferred maintenance
|
||||
|
||||
When carpet is present, note it explicitly and include an estimated removal or replacement adjustment in the make-ready range.
|
||||
|
||||
When major appliances are missing, note the likely replacement burden rather than pretending the asking price is fully comparable to turnkey units.
|
||||
|
||||
## Carry framework
|
||||
|
||||
At minimum, estimate:
|
||||
- P&I
|
||||
- taxes monthly
|
||||
- HOA monthly if applicable
|
||||
- insurance assumption or uncertainty note
|
||||
- effective carry range after maintenance / vacancy / property friction
|
||||
- make-ready burden when condition is not turnkey
|
||||
|
||||
If the listing already provides an estimated payment, use it as the starting point, then explain what it leaves out.
|
||||
|
||||
## STR / rental notes
|
||||
|
||||
Never assume STR viability from location alone. Confirm or explicitly mark as unknown:
|
||||
- HOA restrictions
|
||||
- minimum stay rules
|
||||
- city or building constraints
|
||||
- whether the micro-location is truly tourist-driven or just adjacent to something attractive
|
||||
|
||||
## Verdict language
|
||||
|
||||
Use one of these:
|
||||
- `Buy` — pricing and risk support action now
|
||||
- `Pass` — weak economics or too much unresolved risk
|
||||
- `Only below X` — decent candidate only if bought at a materially lower basis
|
||||
|
||||
## Suggested memo template
|
||||
|
||||
### Snapshot
|
||||
- Address
|
||||
- Source links checked
|
||||
- Price / type / beds / baths / sqft / HOA / taxes / DOM
|
||||
|
||||
### Market read
|
||||
- Same-building or same-area active inventory
|
||||
- Nearby active comps
|
||||
- Any sold signals
|
||||
|
||||
### Economics
|
||||
- Base carry
|
||||
- Effective carry range
|
||||
- Rent / STR comments when relevant
|
||||
|
||||
### Risk flags
|
||||
- HOA or fixed-cost burden
|
||||
- insurance / waterfront / age
|
||||
- reserves / assessments / restrictions if relevant
|
||||
- liquidity / DOM
|
||||
|
||||
### Recommendation
|
||||
- Fair value range
|
||||
- Opening offer
|
||||
- Ceiling offer
|
||||
- Final verdict
|
||||
13
skills/property-assessor/scripts/property-assessor
Executable file
13
skills/property-assessor/scripts/property-assessor
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
TSX_BIN="${SKILL_DIR}/node_modules/.bin/tsx"
|
||||
|
||||
if [[ ! -e "${TSX_BIN}" ]]; then
|
||||
echo "Missing local Node dependencies for property-assessor. Run 'cd ${SKILL_DIR} && npm install' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec node "${TSX_BIN}" "${SKILL_DIR}/src/cli.ts" "$@"
|
||||
550
skills/property-assessor/src/assessment.ts
Normal file
550
skills/property-assessor/src/assessment.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { extractZillowIdentifierHints } from "../../web-automation/scripts/zillow-identifiers.js";
|
||||
import { discoverListingSources, type ListingDiscoveryResult } from "./listing-discovery.js";
|
||||
import { extractPhotoData, type PhotoExtractionResult, type PhotoSource } from "./photo-review.js";
|
||||
import { resolvePublicRecords, type PublicRecordsResolution } from "./public-records.js";
|
||||
import {
|
||||
isDecisionGradeReportPayload,
|
||||
renderReportPdf,
|
||||
type ReportPayload
|
||||
} from "./report-pdf.js";
|
||||
|
||||
export interface AssessPropertyOptions {
|
||||
address: string;
|
||||
assessmentPurpose?: string;
|
||||
recipientEmails?: string[] | string;
|
||||
output?: string;
|
||||
parcelId?: string;
|
||||
listingGeoId?: string;
|
||||
listingSourceUrl?: string;
|
||||
}
|
||||
|
||||
export interface AssessPropertyResult {
|
||||
ok: true;
|
||||
needsAssessmentPurpose: boolean;
|
||||
needsRecipientEmails: boolean;
|
||||
pdfReady: boolean;
|
||||
message: string;
|
||||
outputPath: string | null;
|
||||
reportPayload: ReportPayload | null;
|
||||
publicRecords: PublicRecordsResolution | null;
|
||||
}
|
||||
|
||||
interface AssessPropertyDeps {
|
||||
resolvePublicRecordsFn?: typeof resolvePublicRecords;
|
||||
renderReportPdfFn?: typeof renderReportPdf;
|
||||
discoverListingSourcesFn?: typeof discoverListingSources;
|
||||
extractPhotoDataFn?: typeof extractPhotoData;
|
||||
extractZillowIdentifierHintsFn?: typeof extractZillowIdentifierHints;
|
||||
}
|
||||
|
||||
interface PurposeGuidance {
|
||||
label: string;
|
||||
snapshot: string;
|
||||
like: string;
|
||||
caution: string;
|
||||
comp: string;
|
||||
carry: string;
|
||||
diligence: string;
|
||||
verdict: string;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
if (typeof value === "string") {
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.flatMap((item) => asStringArray(item));
|
||||
}
|
||||
return [String(value).trim()].filter(Boolean);
|
||||
}
|
||||
|
||||
function shouldRenderPdf(
|
||||
options: AssessPropertyOptions,
|
||||
recipientEmails: string[]
|
||||
): boolean {
|
||||
return Boolean(options.output || recipientEmails.length);
|
||||
}
|
||||
|
||||
function slugify(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80) || "property";
|
||||
}
|
||||
|
||||
function pushLink(
|
||||
target: Array<{ label: string; url: string }>,
|
||||
label: string,
|
||||
url: unknown
|
||||
): void {
|
||||
if (typeof url !== "string" || !url.trim()) return;
|
||||
const normalized = url.trim();
|
||||
if (!target.some((item) => item.url === normalized)) {
|
||||
target.push({ label, url: normalized });
|
||||
}
|
||||
}
|
||||
|
||||
function buildPublicRecordLinks(
|
||||
publicRecords: PublicRecordsResolution
|
||||
): Array<{ label: string; url: string }> {
|
||||
const links: Array<{ label: string; url: string }> = [];
|
||||
pushLink(links, "Census Geocoder", publicRecords.officialLinks.censusGeocoder);
|
||||
pushLink(
|
||||
links,
|
||||
"Texas Comptroller County Directory",
|
||||
publicRecords.officialLinks.texasCountyDirectory
|
||||
);
|
||||
pushLink(
|
||||
links,
|
||||
"Texas Property Tax Portal",
|
||||
publicRecords.officialLinks.texasPropertyTaxPortal
|
||||
);
|
||||
pushLink(links, "Appraisal District Website", publicRecords.appraisalDistrict?.Website);
|
||||
pushLink(
|
||||
links,
|
||||
"Appraisal District Directory Page",
|
||||
publicRecords.appraisalDistrict?.directoryPage
|
||||
);
|
||||
pushLink(
|
||||
links,
|
||||
"Tax Assessor / Collector Website",
|
||||
publicRecords.taxAssessorCollector?.Website
|
||||
);
|
||||
pushLink(
|
||||
links,
|
||||
"Tax Assessor / Collector Directory Page",
|
||||
publicRecords.taxAssessorCollector?.directoryPage
|
||||
);
|
||||
pushLink(links, "CAD Property Detail", publicRecords.propertyDetails?.sourceUrl);
|
||||
return links;
|
||||
}
|
||||
|
||||
function normalizePurpose(value: string): string {
|
||||
return value.trim().replace(/\s+/g, " ");
|
||||
}
|
||||
|
||||
function getPurposeGuidance(purpose: string): PurposeGuidance {
|
||||
const normalized = purpose.toLowerCase();
|
||||
|
||||
if (/(daughter|son|college|student|school|campus)/i.test(normalized)) {
|
||||
return {
|
||||
label: purpose,
|
||||
snapshot: "Purpose fit: evaluate this as practical housing for a student, with safety, commute, durability, and oversight burden in mind.",
|
||||
like: "This purpose is best served by simple logistics, durable finishes, and a layout that is easy to live in and maintain.",
|
||||
caution: "Student-use housing can become a bad fit when commute friction, fragile finishes, or management burden are underestimated.",
|
||||
comp: "Comp work should emphasize campus proximity, day-to-day practicality, and whether comparable student-friendly options exist at lower all-in cost.",
|
||||
carry: "Carry view should account for parent-risk factors, furnishing/setup costs, upkeep burden, and whether renting may still be the lower-risk option.",
|
||||
diligence: "Confirm commute, safety, parking, roommate practicality if relevant, and whether the property is easy to maintain from a distance.",
|
||||
verdict: `Assessment purpose: ${purpose}. The final recommendation should prioritize practicality, safety, and management burden over generic resale talking points.`
|
||||
};
|
||||
}
|
||||
|
||||
if (/(vacation|second home|weekend|personal use|beach|getaway)/i.test(normalized)) {
|
||||
return {
|
||||
label: purpose,
|
||||
snapshot: "Purpose fit: evaluate this as a vacation home with personal-use fit and carrying-cost tolerance in mind.",
|
||||
like: "A vacation-home decision can justify paying for lifestyle fit, but only if ongoing friction is acceptable.",
|
||||
caution: "Vacation home ownership can hide real recurring cost drag when insurance, HOA, storm exposure, and deferred maintenance are under-modeled.",
|
||||
comp: "Comp work should focus on lifestyle alternatives, micro-location quality, and whether the premium over substitutes is defensible for a vacation home.",
|
||||
carry: "Carry view should stress-test second-home costs, especially insurance, HOA, special assessments, and low-utilization months.",
|
||||
diligence: "Confirm rules, reserves, storm or flood exposure, and whether the property still makes sense if usage ends up lower than expected.",
|
||||
verdict: `Assessment purpose: ${purpose}. The final call should weigh personal-use fit against ongoing friction, not just headline list price.`
|
||||
};
|
||||
}
|
||||
|
||||
if (/(invest|rental|cash[\s-]?flow|income|flip|str|airbnb)/i.test(normalized)) {
|
||||
return {
|
||||
label: purpose,
|
||||
snapshot: `Purpose fit: evaluate this as an income property first, not a generic owner-occupant home.`,
|
||||
like: "Purpose-aligned upside will depend on durable rent support, manageable make-ready, and exit liquidity.",
|
||||
caution: "An investment property is not attractive just because the address clears basic public-record checks; rent support and margin still decide the deal.",
|
||||
comp: "Comp work should focus on resale liquidity and true rent support for an income property, not only nearby asking prices.",
|
||||
carry: "Carry view should underwrite this as an income property with taxes, insurance, HOA, maintenance, vacancy, and management friction.",
|
||||
diligence: "Confirm realistic rent, reserve for turns and repairs, and whether the building/submarket has enough liquidity for an investment property exit.",
|
||||
verdict: `Assessment purpose: ${purpose}. Do not issue a buy/pass conclusion until the property clears rent support, carry, and make-ready standards for an investment property.`
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: purpose,
|
||||
snapshot: `Purpose fit: ${purpose}. The final recommendation should be explicitly tested against that goal.`,
|
||||
like: "The assessment should stay anchored to the stated purpose rather than defaulting to generic market commentary.",
|
||||
caution: "Even a clean public-record and photo intake is not enough if the property does not fit the stated purpose.",
|
||||
comp: "Comp work should compare against alternatives that solve the same purpose, not just nearby listings.",
|
||||
carry: "Carry view should reflect the stated purpose and the real friction it implies.",
|
||||
diligence: "Purpose-specific diligence should be listed explicitly before a final buy/pass/offer recommendation.",
|
||||
verdict: `Assessment purpose: ${purpose}. The final conclusion must be explained in terms of that stated objective.`
|
||||
};
|
||||
}
|
||||
|
||||
function inferSourceFromUrl(rawUrl: string): PhotoSource | null {
|
||||
try {
|
||||
const url = new URL(rawUrl);
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (host.includes("zillow.com")) return "zillow";
|
||||
if (host.includes("har.com")) return "har";
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
interface ResolvedListingCandidates {
|
||||
attempts: string[];
|
||||
listingUrls: Array<{ label: string; url: string }>;
|
||||
zillowUrl: string | null;
|
||||
harUrl: string | null;
|
||||
}
|
||||
|
||||
async function resolveListingCandidates(
|
||||
options: AssessPropertyOptions,
|
||||
discoverListingSourcesFn: typeof discoverListingSources
|
||||
): Promise<ResolvedListingCandidates> {
|
||||
const attempts: string[] = [];
|
||||
const listingUrls: Array<{ label: string; url: string }> = [];
|
||||
|
||||
const addListingUrl = (label: string, url: string | null | undefined): void => {
|
||||
if (!url) return;
|
||||
if (!listingUrls.some((item) => item.url === url)) {
|
||||
listingUrls.push({ label, url });
|
||||
}
|
||||
};
|
||||
|
||||
let zillowUrl: string | null = null;
|
||||
let harUrl: string | null = null;
|
||||
|
||||
if (options.listingSourceUrl) {
|
||||
const explicitSource = inferSourceFromUrl(options.listingSourceUrl);
|
||||
addListingUrl("Explicit Listing Source", options.listingSourceUrl);
|
||||
if (explicitSource === "zillow") {
|
||||
zillowUrl = options.listingSourceUrl;
|
||||
attempts.push(`Using explicit Zillow listing URL: ${options.listingSourceUrl}`);
|
||||
} else if (explicitSource === "har") {
|
||||
harUrl = options.listingSourceUrl;
|
||||
attempts.push(`Using explicit HAR listing URL: ${options.listingSourceUrl}`);
|
||||
} else {
|
||||
attempts.push(
|
||||
`Explicit listing URL was provided but is not a supported Zillow/HAR photo source: ${options.listingSourceUrl}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!zillowUrl && !harUrl) {
|
||||
const discovered = await discoverListingSourcesFn(options.address);
|
||||
attempts.push(...discovered.attempts);
|
||||
zillowUrl = discovered.zillowUrl;
|
||||
harUrl = discovered.harUrl;
|
||||
addListingUrl("Discovered Zillow Listing", zillowUrl);
|
||||
addListingUrl("Discovered HAR Listing", harUrl);
|
||||
}
|
||||
|
||||
return {
|
||||
attempts,
|
||||
listingUrls,
|
||||
zillowUrl,
|
||||
harUrl,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolvePhotoReview(
|
||||
listingCandidates: ResolvedListingCandidates,
|
||||
extractPhotoDataFn: typeof extractPhotoData,
|
||||
additionalAttempts: string[] = []
|
||||
): Promise<{
|
||||
listingUrls: Array<{ label: string; url: string }>;
|
||||
photoReview: Record<string, unknown>;
|
||||
}> {
|
||||
const attempts: string[] = [...listingCandidates.attempts, ...additionalAttempts];
|
||||
const listingUrls = [...listingCandidates.listingUrls];
|
||||
const candidates: Array<{ source: PhotoSource; url: string }> = [];
|
||||
if (listingCandidates.zillowUrl) candidates.push({ source: "zillow", url: listingCandidates.zillowUrl });
|
||||
if (listingCandidates.harUrl) candidates.push({ source: "har", url: listingCandidates.harUrl });
|
||||
|
||||
let extracted: PhotoExtractionResult | null = null;
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const result = await extractPhotoDataFn(candidate.source, candidate.url);
|
||||
extracted = result;
|
||||
attempts.push(
|
||||
`${candidate.source} photo extraction succeeded with ${result.photoCount} photos.`
|
||||
);
|
||||
if (result.notes.length) {
|
||||
attempts.push(...result.notes);
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
attempts.push(
|
||||
`${candidate.source} photo extraction failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (extracted) {
|
||||
return {
|
||||
listingUrls,
|
||||
photoReview: {
|
||||
status: "completed",
|
||||
source: extracted.source,
|
||||
photoCount: extracted.photoCount,
|
||||
expectedPhotoCount: extracted.expectedPhotoCount ?? null,
|
||||
imageUrls: extracted.imageUrls,
|
||||
attempts,
|
||||
summary:
|
||||
"Photo URLs were collected successfully. A decision-grade condition read still requires reviewing the extracted image set.",
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!candidates.length) {
|
||||
attempts.push(
|
||||
"No supported Zillow or HAR listing URL was available for photo extraction."
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
listingUrls,
|
||||
photoReview: {
|
||||
status: "not completed",
|
||||
source: candidates.length ? "listing source attempted" : "no supported listing source",
|
||||
attempts,
|
||||
summary:
|
||||
"Condition review is incomplete until Zillow or HAR photos are extracted and inspected."
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function buildAssessmentReportPayload(
|
||||
options: AssessPropertyOptions,
|
||||
publicRecords: PublicRecordsResolution,
|
||||
listingUrls: Array<{ label: string; url: string }>,
|
||||
photoReview: Record<string, unknown>
|
||||
): ReportPayload {
|
||||
const recipientEmails = asStringArray(options.recipientEmails);
|
||||
const matchedAddress = publicRecords.matchedAddress || options.address;
|
||||
const publicRecordLinks = buildPublicRecordLinks(publicRecords);
|
||||
const sourceLinks = [...publicRecordLinks];
|
||||
const purpose = normalizePurpose(options.assessmentPurpose || "");
|
||||
const purposeGuidance = getPurposeGuidance(purpose);
|
||||
|
||||
for (const item of listingUrls) {
|
||||
pushLink(sourceLinks, item.label, item.url);
|
||||
}
|
||||
|
||||
const jurisdiction =
|
||||
publicRecords.county.name && publicRecords.appraisalDistrict
|
||||
? `${publicRecords.county.name} Appraisal District`
|
||||
: publicRecords.county.name || publicRecords.state.name || "Official public-record jurisdiction";
|
||||
const assessedTotalValue = publicRecords.propertyDetails?.assessedTotalValue ?? null;
|
||||
const ownerName = publicRecords.propertyDetails?.ownerName ?? undefined;
|
||||
const landValue = publicRecords.propertyDetails?.landValue ?? undefined;
|
||||
const improvementValue = publicRecords.propertyDetails?.improvementValue ?? undefined;
|
||||
const exemptions = publicRecords.propertyDetails?.exemptions?.length
|
||||
? publicRecords.propertyDetails.exemptions
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
recipientEmails,
|
||||
assessmentPurpose: purposeGuidance.label,
|
||||
reportTitle: "Property Assessment Report",
|
||||
subtitle: "Address-first intake with public-record enrichment and approval-safe photo-source orchestration.",
|
||||
subjectProperty: {
|
||||
address: matchedAddress,
|
||||
county: publicRecords.county.name || "N/A",
|
||||
state: publicRecords.state.code || publicRecords.state.name || "N/A",
|
||||
geoid: publicRecords.geoid || "N/A"
|
||||
},
|
||||
verdict: {
|
||||
decision: "pending",
|
||||
fairValueRange: "Not established",
|
||||
offerGuidance: purposeGuidance.verdict
|
||||
},
|
||||
snapshot: [
|
||||
`Matched address: ${matchedAddress}`,
|
||||
`Jurisdiction anchor: ${publicRecords.county.name || "Unknown county"}, ${publicRecords.state.code || publicRecords.state.name || "Unknown state"}.`,
|
||||
publicRecords.geoid ? `Census block GEOID: ${publicRecords.geoid}.` : "Census block GEOID not returned.",
|
||||
assessedTotalValue != null
|
||||
? `Official CAD assessed value: $${assessedTotalValue.toLocaleString("en-US")}.`
|
||||
: "Official CAD assessed value was not retrieved.",
|
||||
purposeGuidance.snapshot
|
||||
],
|
||||
whatILike: [
|
||||
"Address-first flow avoids treating listing-site geo IDs as assessor record keys.",
|
||||
publicRecords.appraisalDistrict
|
||||
? "Official appraisal-district contact and website were identified from public records."
|
||||
: "Official public-record geography was identified.",
|
||||
purposeGuidance.like
|
||||
],
|
||||
whatIDontLike: [
|
||||
"This assess helper still needs listing facts, comp analysis, and a human/model review of the extracted photo set before any final valuation claim.",
|
||||
purposeGuidance.caution
|
||||
],
|
||||
compView: [purposeGuidance.comp],
|
||||
carryView: [purposeGuidance.carry],
|
||||
risksAndDiligence: [
|
||||
...publicRecords.lookupRecommendations,
|
||||
...(publicRecords.propertyDetails?.notes || []),
|
||||
purposeGuidance.diligence
|
||||
],
|
||||
photoReview,
|
||||
publicRecords: {
|
||||
jurisdiction,
|
||||
accountNumber:
|
||||
publicRecords.propertyDetails?.propertyId ||
|
||||
options.parcelId ||
|
||||
publicRecords.sourceIdentifierHints.parcelId,
|
||||
ownerName,
|
||||
landValue,
|
||||
improvementValue,
|
||||
assessedTotalValue,
|
||||
exemptions,
|
||||
links: publicRecordLinks
|
||||
},
|
||||
sourceLinks
|
||||
};
|
||||
}
|
||||
|
||||
export async function assessProperty(
|
||||
options: AssessPropertyOptions,
|
||||
deps: AssessPropertyDeps = {}
|
||||
): Promise<AssessPropertyResult> {
|
||||
const purpose = normalizePurpose(options.assessmentPurpose || "");
|
||||
if (!purpose) {
|
||||
return {
|
||||
ok: true,
|
||||
needsAssessmentPurpose: true,
|
||||
needsRecipientEmails: false,
|
||||
pdfReady: false,
|
||||
message:
|
||||
"Missing assessment purpose. Stop and ask the user why they want this property assessed before producing a decision-grade analysis.",
|
||||
outputPath: null,
|
||||
reportPayload: null,
|
||||
publicRecords: null
|
||||
};
|
||||
}
|
||||
|
||||
const resolvePublicRecordsFn = deps.resolvePublicRecordsFn || resolvePublicRecords;
|
||||
const renderReportPdfFn = deps.renderReportPdfFn || renderReportPdf;
|
||||
const discoverListingSourcesFn = deps.discoverListingSourcesFn || discoverListingSources;
|
||||
const extractPhotoDataFn = deps.extractPhotoDataFn || extractPhotoData;
|
||||
const extractZillowIdentifierHintsFn =
|
||||
deps.extractZillowIdentifierHintsFn || extractZillowIdentifierHints;
|
||||
|
||||
const listingCandidates = await resolveListingCandidates(
|
||||
{ ...options, assessmentPurpose: purpose },
|
||||
discoverListingSourcesFn
|
||||
);
|
||||
|
||||
const identifierAttempts: string[] = [];
|
||||
let effectiveParcelId = options.parcelId;
|
||||
if (!effectiveParcelId && listingCandidates.zillowUrl) {
|
||||
try {
|
||||
const hints = await extractZillowIdentifierHintsFn(listingCandidates.zillowUrl);
|
||||
effectiveParcelId = hints.parcelId || hints.apn || effectiveParcelId;
|
||||
if (Array.isArray(hints.notes) && hints.notes.length) {
|
||||
identifierAttempts.push(...hints.notes);
|
||||
}
|
||||
} catch (error) {
|
||||
identifierAttempts.push(
|
||||
`Zillow parcel/APN extraction failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const effectiveListingSourceUrl =
|
||||
options.listingSourceUrl || listingCandidates.zillowUrl || listingCandidates.harUrl || undefined;
|
||||
|
||||
const publicRecords = await resolvePublicRecordsFn(options.address, {
|
||||
parcelId: effectiveParcelId,
|
||||
listingGeoId: options.listingGeoId,
|
||||
listingSourceUrl: effectiveListingSourceUrl
|
||||
});
|
||||
|
||||
const photoResolution = await resolvePhotoReview(
|
||||
listingCandidates,
|
||||
extractPhotoDataFn,
|
||||
identifierAttempts
|
||||
);
|
||||
|
||||
const reportPayload = buildAssessmentReportPayload(
|
||||
{
|
||||
...options,
|
||||
assessmentPurpose: purpose,
|
||||
parcelId: effectiveParcelId,
|
||||
listingSourceUrl: effectiveListingSourceUrl
|
||||
},
|
||||
publicRecords,
|
||||
photoResolution.listingUrls,
|
||||
photoResolution.photoReview
|
||||
);
|
||||
const recipientEmails = asStringArray(options.recipientEmails);
|
||||
const renderPdf = shouldRenderPdf(options, recipientEmails);
|
||||
|
||||
if (renderPdf && !recipientEmails.length) {
|
||||
return {
|
||||
ok: true,
|
||||
needsAssessmentPurpose: false,
|
||||
needsRecipientEmails: true,
|
||||
pdfReady: false,
|
||||
message:
|
||||
"Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF.",
|
||||
outputPath: null,
|
||||
reportPayload,
|
||||
publicRecords
|
||||
};
|
||||
}
|
||||
|
||||
if (!renderPdf) {
|
||||
return {
|
||||
ok: true,
|
||||
needsAssessmentPurpose: false,
|
||||
needsRecipientEmails: false,
|
||||
pdfReady: true,
|
||||
message:
|
||||
"Assessment payload is ready to render later. Review the analysis now; recipient email is only needed when you want the PDF.",
|
||||
outputPath: null,
|
||||
reportPayload,
|
||||
publicRecords
|
||||
};
|
||||
}
|
||||
|
||||
if (!isDecisionGradeReportPayload(reportPayload)) {
|
||||
return {
|
||||
ok: true,
|
||||
needsAssessmentPurpose: false,
|
||||
needsRecipientEmails: false,
|
||||
pdfReady: false,
|
||||
message:
|
||||
"The report payload is still preliminary. Do not render or send the PDF until comps, valuation, and a decision-grade verdict are completed.",
|
||||
outputPath: null,
|
||||
reportPayload,
|
||||
publicRecords
|
||||
};
|
||||
}
|
||||
|
||||
const outputPath =
|
||||
options.output ||
|
||||
path.join(
|
||||
os.tmpdir(),
|
||||
`property-assessment-${slugify(publicRecords.matchedAddress || options.address)}-${Date.now()}.pdf`
|
||||
);
|
||||
|
||||
const renderedPath = await renderReportPdfFn(reportPayload, outputPath);
|
||||
return {
|
||||
ok: true,
|
||||
needsAssessmentPurpose: false,
|
||||
needsRecipientEmails: false,
|
||||
pdfReady: true,
|
||||
message: `Property assessment PDF rendered: ${renderedPath}`,
|
||||
outputPath: renderedPath,
|
||||
reportPayload,
|
||||
publicRecords
|
||||
};
|
||||
}
|
||||
37
skills/property-assessor/src/async-timeout.ts
Normal file
37
skills/property-assessor/src/async-timeout.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export class TimeoutError extends Error {
|
||||
readonly timeoutMs: number;
|
||||
|
||||
constructor(operationName: string, timeoutMs: number) {
|
||||
super(`${operationName} timed out after ${timeoutMs}ms`);
|
||||
this.name = "TimeoutError";
|
||||
this.timeoutMs = timeoutMs;
|
||||
}
|
||||
}
|
||||
|
||||
export async function withTimeout<T>(
|
||||
operation: () => Promise<T>,
|
||||
{
|
||||
operationName,
|
||||
timeoutMs
|
||||
}: {
|
||||
operationName: string;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<T> {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation(),
|
||||
new Promise<T>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new TimeoutError(operationName, timeoutMs));
|
||||
}, timeoutMs);
|
||||
})
|
||||
]);
|
||||
} finally {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
91
skills/property-assessor/src/cli.ts
Normal file
91
skills/property-assessor/src/cli.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import minimist from "minimist";
|
||||
|
||||
import { assessProperty } from "./assessment.js";
|
||||
import { resolvePublicRecords } from "./public-records.js";
|
||||
import { ReportValidationError, loadReportPayload, renderReportPdf } from "./report-pdf.js";
|
||||
|
||||
function usage(): void {
|
||||
process.stdout.write(`property-assessor\n
|
||||
Commands:
|
||||
assess --address "<address>" --assessment-purpose "<purpose>" [--recipient-email "<email>"] [--output "<report.pdf>"] [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
|
||||
locate-public-records --address "<address>" [--parcel-id "<id>"] [--listing-geo-id "<id>"] [--listing-source-url "<url>"]
|
||||
render-report --input "<payload.json>" --output "<report.pdf>"
|
||||
`);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const argv = minimist(process.argv.slice(2), {
|
||||
string: [
|
||||
"address",
|
||||
"assessment-purpose",
|
||||
"recipient-email",
|
||||
"parcel-id",
|
||||
"listing-geo-id",
|
||||
"listing-source-url",
|
||||
"input",
|
||||
"output"
|
||||
],
|
||||
alias: {
|
||||
h: "help"
|
||||
}
|
||||
});
|
||||
const [command] = argv._;
|
||||
|
||||
if (!command || argv.help) {
|
||||
usage();
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (command === "assess") {
|
||||
if (!argv.address) {
|
||||
throw new Error("Missing required option: --address");
|
||||
}
|
||||
const payload = await assessProperty({
|
||||
address: argv.address,
|
||||
assessmentPurpose: argv["assessment-purpose"],
|
||||
recipientEmails: argv["recipient-email"],
|
||||
output: argv.output,
|
||||
parcelId: argv["parcel-id"],
|
||||
listingGeoId: argv["listing-geo-id"],
|
||||
listingSourceUrl: argv["listing-source-url"]
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "locate-public-records") {
|
||||
if (!argv.address) {
|
||||
throw new Error("Missing required option: --address");
|
||||
}
|
||||
const payload = await resolvePublicRecords(argv.address, {
|
||||
parcelId: argv["parcel-id"],
|
||||
listingGeoId: argv["listing-geo-id"],
|
||||
listingSourceUrl: argv["listing-source-url"]
|
||||
});
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (command === "render-report") {
|
||||
if (!argv.input || !argv.output) {
|
||||
throw new Error("Missing required options: --input and --output");
|
||||
}
|
||||
const payload = await loadReportPayload(argv.input);
|
||||
const outputPath = await renderReportPdf(payload, argv.output);
|
||||
process.stdout.write(`${JSON.stringify({ ok: true, outputPath }, null, 2)}\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(`Unknown command: ${command}`);
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
const message =
|
||||
error instanceof ReportValidationError || error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
process.stderr.write(`${message}\n`);
|
||||
process.exit(1);
|
||||
});
|
||||
100
skills/property-assessor/src/listing-discovery.ts
Normal file
100
skills/property-assessor/src/listing-discovery.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { discoverHarListing } from "../../web-automation/scripts/har-discover.js";
|
||||
import { discoverZillowListing } from "../../web-automation/scripts/zillow-discover.js";
|
||||
import { TimeoutError, withTimeout } from "./async-timeout.js";
|
||||
|
||||
export interface ListingDiscoveryResult {
|
||||
attempts: string[];
|
||||
zillowUrl: string | null;
|
||||
harUrl: string | null;
|
||||
}
|
||||
|
||||
interface ListingDiscoveryDeps {
|
||||
timeoutMs?: number;
|
||||
zillowTimeoutMs?: number;
|
||||
harTimeoutMs?: number;
|
||||
discoverZillowListingFn?: typeof discoverZillowListing;
|
||||
discoverHarListingFn?: typeof discoverHarListing;
|
||||
}
|
||||
|
||||
const DEFAULT_DISCOVERY_TIMEOUT_MS = Number(
|
||||
process.env.PROPERTY_ASSESSOR_DISCOVERY_TIMEOUT_MS || 20_000
|
||||
);
|
||||
const DEFAULT_ZILLOW_DISCOVERY_TIMEOUT_MS = Number(
|
||||
process.env.PROPERTY_ASSESSOR_ZILLOW_DISCOVERY_TIMEOUT_MS || 60_000
|
||||
);
|
||||
const DEFAULT_HAR_DISCOVERY_TIMEOUT_MS = Number(
|
||||
process.env.PROPERTY_ASSESSOR_HAR_DISCOVERY_TIMEOUT_MS || DEFAULT_DISCOVERY_TIMEOUT_MS
|
||||
);
|
||||
|
||||
interface SourceDiscoveryOutcome {
|
||||
source: "zillow" | "har";
|
||||
url: string | null;
|
||||
attempts: string[];
|
||||
}
|
||||
|
||||
export async function discoverListingSources(
|
||||
address: string,
|
||||
deps: ListingDiscoveryDeps = {}
|
||||
): Promise<ListingDiscoveryResult> {
|
||||
const timeoutMs = deps.timeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
|
||||
const zillowTimeoutMs =
|
||||
deps.zillowTimeoutMs ??
|
||||
(deps.timeoutMs != null ? timeoutMs : DEFAULT_ZILLOW_DISCOVERY_TIMEOUT_MS);
|
||||
const harTimeoutMs =
|
||||
deps.harTimeoutMs ??
|
||||
(deps.timeoutMs != null ? timeoutMs : DEFAULT_HAR_DISCOVERY_TIMEOUT_MS);
|
||||
const discoverZillowListingFn = deps.discoverZillowListingFn || discoverZillowListing;
|
||||
const discoverHarListingFn = deps.discoverHarListingFn || discoverHarListing;
|
||||
|
||||
const runSource = async (
|
||||
source: "zillow" | "har",
|
||||
timeoutForSourceMs: number,
|
||||
operation: () => Promise<{ listingUrl: string | null; attempts: string[] }>
|
||||
): Promise<SourceDiscoveryOutcome> => {
|
||||
try {
|
||||
const result = await withTimeout(operation, {
|
||||
operationName: `${source === "zillow" ? "Zillow" : "HAR"} discovery`,
|
||||
timeoutMs: timeoutForSourceMs
|
||||
});
|
||||
return {
|
||||
source,
|
||||
url: result.listingUrl,
|
||||
attempts: result.attempts
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof TimeoutError) {
|
||||
return {
|
||||
source,
|
||||
url: null,
|
||||
attempts: [
|
||||
`${source === "zillow" ? "Zillow" : "HAR"} discovery timed out after ${timeoutForSourceMs}ms.`
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source,
|
||||
url: null,
|
||||
attempts: [
|
||||
`${source === "zillow" ? "Zillow" : "HAR"} discovery failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
]
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const zillowPromise = runSource("zillow", zillowTimeoutMs, () =>
|
||||
discoverZillowListingFn(address, { timeoutMs: zillowTimeoutMs })
|
||||
);
|
||||
const harPromise = runSource("har", harTimeoutMs, () =>
|
||||
discoverHarListingFn(address, { timeoutMs: harTimeoutMs })
|
||||
);
|
||||
|
||||
const [zillowResult, harResult] = await Promise.all([zillowPromise, harPromise]);
|
||||
const attempts = [...zillowResult.attempts, ...harResult.attempts];
|
||||
|
||||
return {
|
||||
attempts,
|
||||
zillowUrl: zillowResult.url,
|
||||
harUrl: harResult.url
|
||||
};
|
||||
}
|
||||
93
skills/property-assessor/src/photo-review.ts
Normal file
93
skills/property-assessor/src/photo-review.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { extractHarPhotos } from "../../web-automation/scripts/har-photos.js";
|
||||
import { extractZillowPhotos } from "../../web-automation/scripts/zillow-photos.js";
|
||||
import { withTimeout } from "./async-timeout.js";
|
||||
|
||||
export type PhotoSource = "zillow" | "har";
|
||||
|
||||
export interface PhotoExtractionResult {
|
||||
source: PhotoSource;
|
||||
requestedUrl: string;
|
||||
finalUrl?: string;
|
||||
expectedPhotoCount?: number | null;
|
||||
complete?: boolean;
|
||||
photoCount: number;
|
||||
imageUrls: string[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface PhotoReviewResolution {
|
||||
review: Record<string, unknown>;
|
||||
discoveredListingUrls: Array<{ label: string; url: string }>;
|
||||
}
|
||||
|
||||
interface PhotoReviewDeps {
|
||||
timeoutMs?: number;
|
||||
zillowTimeoutMs?: number;
|
||||
harTimeoutMs?: number;
|
||||
extractZillowPhotosFn?: typeof extractZillowPhotos;
|
||||
extractHarPhotosFn?: typeof extractHarPhotos;
|
||||
}
|
||||
|
||||
const DEFAULT_PHOTO_EXTRACTION_TIMEOUT_MS = Number(
|
||||
process.env.PROPERTY_ASSESSOR_PHOTO_TIMEOUT_MS || 25_000
|
||||
);
|
||||
const DEFAULT_ZILLOW_PHOTO_EXTRACTION_TIMEOUT_MS = Number(
|
||||
process.env.PROPERTY_ASSESSOR_ZILLOW_PHOTO_TIMEOUT_MS || 60_000
|
||||
);
|
||||
const DEFAULT_HAR_PHOTO_EXTRACTION_TIMEOUT_MS = Number(
|
||||
process.env.PROPERTY_ASSESSOR_HAR_PHOTO_TIMEOUT_MS || DEFAULT_PHOTO_EXTRACTION_TIMEOUT_MS
|
||||
);
|
||||
|
||||
export async function extractPhotoData(
|
||||
source: PhotoSource,
|
||||
url: string,
|
||||
deps: PhotoReviewDeps = {}
|
||||
): Promise<PhotoExtractionResult> {
|
||||
const timeoutMs = deps.timeoutMs ?? DEFAULT_PHOTO_EXTRACTION_TIMEOUT_MS;
|
||||
const zillowTimeoutMs =
|
||||
deps.zillowTimeoutMs ??
|
||||
(deps.timeoutMs != null ? timeoutMs : DEFAULT_ZILLOW_PHOTO_EXTRACTION_TIMEOUT_MS);
|
||||
const harTimeoutMs =
|
||||
deps.harTimeoutMs ??
|
||||
(deps.timeoutMs != null ? timeoutMs : DEFAULT_HAR_PHOTO_EXTRACTION_TIMEOUT_MS);
|
||||
const extractZillowPhotosFn = deps.extractZillowPhotosFn || extractZillowPhotos;
|
||||
const extractHarPhotosFn = deps.extractHarPhotosFn || extractHarPhotos;
|
||||
|
||||
if (source === "zillow") {
|
||||
const payload = await withTimeout(
|
||||
() => extractZillowPhotosFn(url, { timeoutMs: zillowTimeoutMs }),
|
||||
{
|
||||
operationName: "Zillow photo extraction",
|
||||
timeoutMs: zillowTimeoutMs
|
||||
}
|
||||
);
|
||||
return {
|
||||
source,
|
||||
requestedUrl: String(payload.requestedUrl || url),
|
||||
finalUrl: typeof payload.finalUrl === "string" ? payload.finalUrl : undefined,
|
||||
expectedPhotoCount: typeof payload.expectedPhotoCount === "number" ? payload.expectedPhotoCount : null,
|
||||
complete: Boolean(payload.complete),
|
||||
photoCount: Number(payload.photoCount || 0),
|
||||
imageUrls: Array.isArray(payload.imageUrls) ? payload.imageUrls.map(String) : [],
|
||||
notes: Array.isArray(payload.notes) ? payload.notes.map(String) : []
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await withTimeout(
|
||||
() => extractHarPhotosFn(url, { timeoutMs: harTimeoutMs }),
|
||||
{
|
||||
operationName: "HAR photo extraction",
|
||||
timeoutMs: harTimeoutMs
|
||||
}
|
||||
);
|
||||
return {
|
||||
source,
|
||||
requestedUrl: String(payload.requestedUrl || url),
|
||||
finalUrl: typeof payload.finalUrl === "string" ? payload.finalUrl : undefined,
|
||||
expectedPhotoCount: typeof payload.expectedPhotoCount === "number" ? payload.expectedPhotoCount : null,
|
||||
complete: Boolean(payload.complete),
|
||||
photoCount: Number(payload.photoCount || 0),
|
||||
imageUrls: Array.isArray(payload.imageUrls) ? payload.imageUrls.map(String) : [],
|
||||
notes: Array.isArray(payload.notes) ? payload.notes.map(String) : []
|
||||
};
|
||||
}
|
||||
803
skills/property-assessor/src/public-records.ts
Normal file
803
skills/property-assessor/src/public-records.ts
Normal file
@@ -0,0 +1,803 @@
|
||||
export const CENSUS_GEOCODER_URL =
|
||||
"https://geocoding.geo.census.gov/geocoder/geographies/onelineaddress";
|
||||
export const CENSUS_COORDINATES_URL =
|
||||
"https://geocoding.geo.census.gov/geocoder/geographies/coordinates";
|
||||
export const NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search";
|
||||
export const TEXAS_COUNTY_DIRECTORY_URL =
|
||||
"https://comptroller.texas.gov/taxes/property-tax/county-directory/";
|
||||
export const TEXAS_PROPERTY_TAX_PORTAL = "https://texas.gov/PropertyTaxes";
|
||||
|
||||
export class PublicRecordsLookupError extends Error {}
|
||||
|
||||
export interface PropertyDetailsResolution {
|
||||
source: string;
|
||||
sourceUrl: string;
|
||||
propertyId: string | null;
|
||||
ownerName: string | null;
|
||||
situsAddress: string | null;
|
||||
legalDescription: string | null;
|
||||
landValue: number | null;
|
||||
improvementValue: number | null;
|
||||
marketValue: number | null;
|
||||
assessedTotalValue: number | null;
|
||||
exemptions: string[];
|
||||
notes: string[];
|
||||
}
|
||||
|
||||
export interface PublicRecordsResolution {
|
||||
requestedAddress: string;
|
||||
matchedAddress: string;
|
||||
latitude: number | null;
|
||||
longitude: number | null;
|
||||
geoid: string | null;
|
||||
state: {
|
||||
name: string | null;
|
||||
code: string | null;
|
||||
fips: string | null;
|
||||
};
|
||||
county: {
|
||||
name: string | null;
|
||||
fips: string | null;
|
||||
geoid: string | null;
|
||||
};
|
||||
officialLinks: {
|
||||
censusGeocoder: string;
|
||||
texasCountyDirectory: string | null;
|
||||
texasPropertyTaxPortal: string | null;
|
||||
};
|
||||
appraisalDistrict: Record<string, unknown> | null;
|
||||
taxAssessorCollector: Record<string, unknown> | null;
|
||||
lookupRecommendations: string[];
|
||||
sourceIdentifierHints: Record<string, string>;
|
||||
propertyDetails: PropertyDetailsResolution | null;
|
||||
}
|
||||
|
||||
interface FetchTextInit {
|
||||
body?: string;
|
||||
headers?: Record<string, string>;
|
||||
method?: string;
|
||||
}
|
||||
|
||||
interface FetchLike {
|
||||
(url: string, init?: FetchTextInit): Promise<string>;
|
||||
}
|
||||
|
||||
const defaultFetchText: FetchLike = async (url, init = {}) => {
|
||||
const response = await fetch(url, {
|
||||
body: init.body,
|
||||
headers: {
|
||||
"user-agent": "property-assessor/1.0",
|
||||
...(init.headers || {})
|
||||
},
|
||||
method: init.method || "GET"
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new PublicRecordsLookupError(`Request failed for ${url}: ${response.status}`);
|
||||
}
|
||||
return await response.text();
|
||||
};
|
||||
|
||||
function collapseWhitespace(value: string | null | undefined): string {
|
||||
return (value || "").replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function normalizeCountyName(value: string): string {
|
||||
return collapseWhitespace(value)
|
||||
.toLowerCase()
|
||||
.replace(/ county\b/, "")
|
||||
.replace(/[^a-z0-9]+/g, "");
|
||||
}
|
||||
|
||||
function stripHtml(value: string): string {
|
||||
let output = value
|
||||
.replace(/<br\s*\/?>/gi, "\n")
|
||||
.replace(/<[^>]+>/g, "");
|
||||
output = output
|
||||
.replace(/ /gi, " ")
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number.parseInt(number, 10)))
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">");
|
||||
output = collapseWhitespace(output.replace(/\n/g, ", "));
|
||||
output = output.replace(/\s*,\s*/g, ", ").replace(/(,\s*){2,}/g, ", ");
|
||||
return output.replace(/^,\s*|\s*,\s*$/g, "");
|
||||
}
|
||||
|
||||
function buildFallbackAddressCandidates(address: string): string[] {
|
||||
const normalized = collapseWhitespace(address);
|
||||
if (!normalized) return [];
|
||||
|
||||
const candidates = [normalized];
|
||||
const [streetPartRaw, ...restParts] = normalized.split(",");
|
||||
const streetPart = collapseWhitespace(streetPartRaw);
|
||||
const locality = restParts.map((part) => collapseWhitespace(part)).filter(Boolean).join(", ");
|
||||
const strippedStreet = collapseWhitespace(
|
||||
streetPart.replace(
|
||||
/\s+(?:apt|apartment|unit|suite|ste)\s*[a-z0-9-]+$/i,
|
||||
""
|
||||
).replace(/\s+#\s*[a-z0-9-]+$/i, "")
|
||||
);
|
||||
|
||||
if (strippedStreet && strippedStreet !== streetPart) {
|
||||
candidates.push(locality ? `${strippedStreet}, ${locality}` : strippedStreet);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function extractAnchorHref(fragment: string): string | null {
|
||||
const match = fragment.match(/<a[^>]+href="([^"]+)"/i);
|
||||
if (!match) return null;
|
||||
const href = match[1].trim();
|
||||
if (href.startsWith("//")) return `https:${href}`;
|
||||
return href;
|
||||
}
|
||||
|
||||
function normalizeUrl(rawUrl: string): string {
|
||||
const value = collapseWhitespace(rawUrl);
|
||||
if (!value) return value;
|
||||
if (/^https?:\/\//i.test(value)) return value;
|
||||
return `https://${value.replace(/^\/+/, "")}`;
|
||||
}
|
||||
|
||||
function decodeHtmlEntities(value: string): string {
|
||||
return value
|
||||
.replace(/&#x([0-9a-f]+);/gi, (_, hex) => String.fromCodePoint(Number.parseInt(hex, 16)))
|
||||
.replace(/&#(\d+);/g, (_, number) => String.fromCodePoint(Number.parseInt(number, 10)))
|
||||
.replace(/&/gi, "&")
|
||||
.replace(/"/gi, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</gi, "<")
|
||||
.replace(/>/gi, ">");
|
||||
}
|
||||
|
||||
function parseCurrencyValue(value: string | null | undefined): number | null {
|
||||
const normalized = collapseWhitespace(value);
|
||||
if (!normalized) return null;
|
||||
const numeric = normalized.replace(/[^0-9.-]/g, "");
|
||||
if (!numeric) return null;
|
||||
const parsed = Number(numeric);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseCurrentYearFromSearchHome(searchHomeHtml: string): number {
|
||||
const configuredYear = searchHomeHtml.match(/"DefaultYear"\s*:\s*(\d{4})/i);
|
||||
if (configuredYear) {
|
||||
return Number(configuredYear[1]);
|
||||
}
|
||||
return new Date().getFullYear();
|
||||
}
|
||||
|
||||
function buildCadSearchKeywords(address: string, year: number): string {
|
||||
return `${collapseWhitespace(address)} Year:${year}`.trim();
|
||||
}
|
||||
|
||||
function formatNuecesGeographicId(parcelId: string | null | undefined): string | null {
|
||||
const normalized = collapseWhitespace(parcelId).replace(/[^0-9]/g, "");
|
||||
if (!normalized) return null;
|
||||
if (normalized.length <= 4) return normalized;
|
||||
if (normalized.length <= 8) {
|
||||
return `${normalized.slice(0, 4)}-${normalized.slice(4)}`;
|
||||
}
|
||||
return `${normalized.slice(0, 4)}-${normalized.slice(4, 8)}-${normalized.slice(8)}`;
|
||||
}
|
||||
|
||||
function parseAddressForCadSearch(address: string): {
|
||||
rawAddress: string;
|
||||
streetNumber: string | null;
|
||||
streetName: string | null;
|
||||
unit: string | null;
|
||||
} {
|
||||
const rawAddress = collapseWhitespace(address);
|
||||
const streetPart = collapseWhitespace(rawAddress.split(",")[0] || rawAddress);
|
||||
const unitMatch = streetPart.match(/\b(?:apt|apartment|unit|suite|ste|#)\s*([a-z0-9-]+)/i);
|
||||
const unit = unitMatch ? unitMatch[1].toUpperCase() : null;
|
||||
|
||||
const withoutUnit = collapseWhitespace(
|
||||
streetPart
|
||||
.replace(/\b(?:apt|apartment|unit|suite|ste)\s*[a-z0-9-]+/gi, "")
|
||||
.replace(/#\s*[a-z0-9-]+/gi, "")
|
||||
);
|
||||
const numberMatch = withoutUnit.match(/^(\d+[a-z]?)/i);
|
||||
const streetNumber = numberMatch ? numberMatch[1] : null;
|
||||
const suffixes = new Set([
|
||||
"rd",
|
||||
"road",
|
||||
"dr",
|
||||
"drive",
|
||||
"st",
|
||||
"street",
|
||||
"ave",
|
||||
"avenue",
|
||||
"blvd",
|
||||
"boulevard",
|
||||
"ct",
|
||||
"court",
|
||||
"cir",
|
||||
"circle",
|
||||
"ln",
|
||||
"lane",
|
||||
"trl",
|
||||
"trail",
|
||||
"way",
|
||||
"pkwy",
|
||||
"parkway",
|
||||
"pl",
|
||||
"place",
|
||||
"ter",
|
||||
"terrace",
|
||||
"loop",
|
||||
"hwy",
|
||||
"highway"
|
||||
]);
|
||||
|
||||
const streetTokens = withoutUnit
|
||||
.replace(/^(\d+[a-z]?)\s*/i, "")
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
while (streetTokens.length && suffixes.has(streetTokens[streetTokens.length - 1].toLowerCase())) {
|
||||
streetTokens.pop();
|
||||
}
|
||||
|
||||
return {
|
||||
rawAddress,
|
||||
streetNumber,
|
||||
streetName: streetTokens.length ? streetTokens.join(" ") : null,
|
||||
unit
|
||||
};
|
||||
}
|
||||
|
||||
function extractSearchToken(searchHomeHtml: string): string | null {
|
||||
const match = searchHomeHtml.match(/meta name="search-token" content="([^"]+)"/i);
|
||||
return match ? decodeHtmlEntities(match[1]) : null;
|
||||
}
|
||||
|
||||
function extractPropertySearchUrl(homepageHtml: string): string | null {
|
||||
const preferred = homepageHtml.match(/href="(https:\/\/[^"]*esearch[^"]*)"/i);
|
||||
if (preferred) {
|
||||
return preferred[1];
|
||||
}
|
||||
const generic = homepageHtml.match(/href="([^"]+)"[^>]*>\s*(?:SEARCH NOW|Property Search)\s*</i);
|
||||
return generic ? generic[1] : null;
|
||||
}
|
||||
|
||||
function extractDetailField(detailHtml: string, label: string): string | null {
|
||||
const escaped = label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const patterns = [
|
||||
new RegExp(`<div[^>]*>\\s*${escaped}\\s*<\\/div>\\s*<div[^>]*>(.*?)<\\/div>`, "is"),
|
||||
new RegExp(`<strong>\\s*${escaped}\\s*:?\\s*<\\/strong>\\s*(.*?)(?:<br\\s*\\/?>|<\\/p>|<\\/div>)`, "is"),
|
||||
new RegExp(`${escaped}\\s*:?\\s*<\\/[^>]+>\\s*<[^>]+>(.*?)<\\/[^>]+>`, "is")
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = detailHtml.match(pattern);
|
||||
if (match) {
|
||||
return stripHtml(match[1]);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractExemptions(detailHtml: string): string[] {
|
||||
const raw = extractDetailField(detailHtml, "Exemptions");
|
||||
if (!raw) return [];
|
||||
return raw
|
||||
.split(/[,;|]/)
|
||||
.map((item) => collapseWhitespace(item))
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function scoreAddressMatch(needle: string, haystack: string): number {
|
||||
const normalizedNeedle = collapseWhitespace(needle).toLowerCase();
|
||||
const normalizedHaystack = collapseWhitespace(haystack).toLowerCase();
|
||||
if (!normalizedNeedle || !normalizedHaystack) return 0;
|
||||
|
||||
let score = 0;
|
||||
const tokens = normalizedNeedle.split(/[\s,]+/).filter(Boolean);
|
||||
for (const token of tokens) {
|
||||
if (normalizedHaystack.includes(token)) {
|
||||
score += token.length > 3 ? 2 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
const unitMatch = normalizedNeedle.match(/\b(?:apt|apartment|unit|suite|ste|#)\s*([a-z0-9-]+)/i);
|
||||
if (unitMatch) {
|
||||
score += normalizedHaystack.includes(unitMatch[1].toLowerCase()) ? 4 : -4;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function pickBestCadResult(
|
||||
address: string,
|
||||
results: Array<Record<string, unknown>>
|
||||
): Record<string, unknown> | null {
|
||||
const scored = results
|
||||
.map((result) => {
|
||||
const candidateText = [
|
||||
result.address,
|
||||
result.legalDescription,
|
||||
result.ownerName,
|
||||
result.condo,
|
||||
result.geoId,
|
||||
result.propertyId
|
||||
]
|
||||
.map((item) => collapseWhitespace(String(item || "")))
|
||||
.join(" ");
|
||||
return { result, score: scoreAddressMatch(address, candidateText) };
|
||||
})
|
||||
.sort((a, b) => b.score - a.score);
|
||||
|
||||
return scored[0]?.score > 0 ? scored[0].result : null;
|
||||
}
|
||||
|
||||
async function enrichNuecesCadPropertyDetails(
|
||||
address: string,
|
||||
appraisalDistrictWebsite: string,
|
||||
parcelId: string | null | undefined,
|
||||
fetchText: FetchLike
|
||||
): Promise<PropertyDetailsResolution | null> {
|
||||
const parsedAddress = parseAddressForCadSearch(address);
|
||||
const homepageUrl = normalizeUrl(appraisalDistrictWebsite);
|
||||
const homepageHtml = await fetchText(homepageUrl);
|
||||
const propertySearchUrl = extractPropertySearchUrl(homepageHtml);
|
||||
if (!propertySearchUrl) return null;
|
||||
|
||||
const normalizedPropertySearchUrl = normalizeUrl(propertySearchUrl).replace(/\/+$/, "");
|
||||
const searchHomeHtml = await fetchText(`${normalizedPropertySearchUrl}/`);
|
||||
const searchToken = extractSearchToken(searchHomeHtml);
|
||||
if (!searchToken) return null;
|
||||
|
||||
const searchYear = parseCurrentYearFromSearchHome(searchHomeHtml);
|
||||
const formattedGeographicId = formatNuecesGeographicId(parcelId);
|
||||
const searchKeywords =
|
||||
formattedGeographicId ||
|
||||
(parsedAddress.streetNumber && parsedAddress.streetName
|
||||
? `StreetNumber:${parsedAddress.streetNumber} StreetName:"${parsedAddress.streetName}"`
|
||||
: buildCadSearchKeywords(address, searchYear));
|
||||
|
||||
const fetchSearchPage = async (page: number): Promise<any> => {
|
||||
const searchResultsUrl = `${normalizedPropertySearchUrl}/search/SearchResults?keywords=${encodeURIComponent(searchKeywords)}`;
|
||||
if (fetchText === defaultFetchText) {
|
||||
const sessionTokenResponse = await fetch(
|
||||
`${normalizedPropertySearchUrl}/search/requestSessionToken`,
|
||||
{
|
||||
headers: {
|
||||
"user-agent": "property-assessor/1.0"
|
||||
}
|
||||
}
|
||||
);
|
||||
const sessionTokenPayload = await sessionTokenResponse.json();
|
||||
const searchSessionToken = sessionTokenPayload?.searchSessionToken;
|
||||
const resultUrl = `${normalizedPropertySearchUrl}/search/result?keywords=${encodeURIComponent(searchKeywords)}&searchSessionToken=${encodeURIComponent(String(searchSessionToken || ""))}`;
|
||||
const resultResponse = await fetch(resultUrl, {
|
||||
headers: {
|
||||
"user-agent": "property-assessor/1.0"
|
||||
}
|
||||
});
|
||||
const cookieHeader = (resultResponse.headers.getSetCookie?.() || [])
|
||||
.map((item) => item.split(";", 1)[0])
|
||||
.join("; ");
|
||||
const resultPageHtml = await resultResponse.text();
|
||||
const liveSearchToken = extractSearchToken(resultPageHtml) || searchToken;
|
||||
const jsonResponse = await fetch(searchResultsUrl, {
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
pageSize: 25,
|
||||
isArb: false,
|
||||
recaptchaToken: "",
|
||||
searchToken: liveSearchToken
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
cookie: cookieHeader,
|
||||
referer: resultUrl,
|
||||
"user-agent": "property-assessor/1.0"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
return await jsonResponse.json();
|
||||
}
|
||||
|
||||
const searchResultsRaw = await fetchText(searchResultsUrl, {
|
||||
body: JSON.stringify({
|
||||
page,
|
||||
pageSize: 25,
|
||||
isArb: false,
|
||||
recaptchaToken: "",
|
||||
searchToken
|
||||
}),
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
method: "POST"
|
||||
});
|
||||
return JSON.parse(searchResultsRaw);
|
||||
};
|
||||
|
||||
const firstPage = await fetchSearchPage(1);
|
||||
const totalPages = Math.min(Number(firstPage?.totalPages || 1), 8);
|
||||
const collectedResults: Array<Record<string, unknown>> = Array.isArray(firstPage?.resultsList)
|
||||
? [...firstPage.resultsList]
|
||||
: [];
|
||||
let bestResult = pickBestCadResult(address, collectedResults);
|
||||
|
||||
if (parsedAddress.unit && !String(bestResult?.legalDescription || "").toUpperCase().includes(`UNIT ${parsedAddress.unit}`)) {
|
||||
bestResult = null;
|
||||
}
|
||||
|
||||
for (let page = 2; !bestResult && page <= totalPages; page += 1) {
|
||||
const nextPage = await fetchSearchPage(page);
|
||||
if (Array.isArray(nextPage?.resultsList)) {
|
||||
collectedResults.push(...nextPage.resultsList);
|
||||
bestResult = pickBestCadResult(address, collectedResults);
|
||||
if (parsedAddress.unit && !String(bestResult?.legalDescription || "").toUpperCase().includes(`UNIT ${parsedAddress.unit}`)) {
|
||||
bestResult = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestResult) return null;
|
||||
|
||||
const detailPath = collapseWhitespace(String(bestResult.detailUrl || ""));
|
||||
const canUseDetailPath = Boolean(detailPath) && !/[?&]Id=/i.test(detailPath);
|
||||
const detailUrl = canUseDetailPath
|
||||
? new URL(detailPath, `${normalizedPropertySearchUrl}/`).toString()
|
||||
: new URL(
|
||||
`/property/view/${encodeURIComponent(String(bestResult.propertyId || ""))}?year=${encodeURIComponent(String(bestResult.year || searchYear))}&ownerId=${encodeURIComponent(String(bestResult.ownerId || ""))}`,
|
||||
`${normalizedPropertySearchUrl}/`
|
||||
).toString();
|
||||
|
||||
const detailHtml = await fetchText(detailUrl);
|
||||
return {
|
||||
source: "nueces-esearch",
|
||||
sourceUrl: detailUrl,
|
||||
propertyId: collapseWhitespace(String(bestResult.propertyId || "")) || null,
|
||||
ownerName:
|
||||
extractDetailField(detailHtml, "Owner Name") ||
|
||||
collapseWhitespace(String(bestResult.ownerName || "")) ||
|
||||
null,
|
||||
situsAddress:
|
||||
extractDetailField(detailHtml, "Situs Address") ||
|
||||
extractDetailField(detailHtml, "Address") ||
|
||||
collapseWhitespace(String(bestResult.address || "")) ||
|
||||
null,
|
||||
legalDescription:
|
||||
extractDetailField(detailHtml, "Legal Description") ||
|
||||
collapseWhitespace(String(bestResult.legalDescription || "")) ||
|
||||
null,
|
||||
landValue: parseCurrencyValue(extractDetailField(detailHtml, "Land Value")),
|
||||
improvementValue: parseCurrencyValue(extractDetailField(detailHtml, "Improvement Value")),
|
||||
marketValue:
|
||||
parseCurrencyValue(extractDetailField(detailHtml, "Market Value")) ||
|
||||
(Number.isFinite(Number(bestResult.appraisedValue))
|
||||
? Number(bestResult.appraisedValue)
|
||||
: parseCurrencyValue(String(bestResult.appraisedValueDisplay || ""))),
|
||||
assessedTotalValue:
|
||||
parseCurrencyValue(extractDetailField(detailHtml, "Assessed Value")) ||
|
||||
parseCurrencyValue(extractDetailField(detailHtml, "Appraised Value")) ||
|
||||
(Number.isFinite(Number(bestResult.appraisedValue))
|
||||
? Number(bestResult.appraisedValue)
|
||||
: parseCurrencyValue(String(bestResult.appraisedValueDisplay || ""))),
|
||||
exemptions: extractExemptions(detailHtml),
|
||||
notes: [
|
||||
"Official CAD property detail page exposed owner, value, and exemption data."
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
async function tryEnrichPropertyDetails(
|
||||
address: string,
|
||||
parcelId: string | null | undefined,
|
||||
appraisalDistrictWebsite: string | null,
|
||||
fetchText: FetchLike
|
||||
): Promise<PropertyDetailsResolution | null> {
|
||||
const website = collapseWhitespace(appraisalDistrictWebsite);
|
||||
if (!website) return null;
|
||||
|
||||
const normalizedWebsite = normalizeUrl(website).toLowerCase();
|
||||
try {
|
||||
if (normalizedWebsite.includes("nuecescad.net") || normalizedWebsite.includes("ncadistrict.com")) {
|
||||
return await enrichNuecesCadPropertyDetails(address, website, parcelId, fetchText);
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function geocodeAddress(address: string, fetchText: FetchLike): Promise<{
|
||||
match: any;
|
||||
censusGeocoderUrl: string;
|
||||
usedFallbackGeocoder: boolean;
|
||||
}> {
|
||||
const query = new URLSearchParams({
|
||||
address,
|
||||
benchmark: "Public_AR_Current",
|
||||
vintage: "Current_Current",
|
||||
format: "json"
|
||||
});
|
||||
const url = `${CENSUS_GEOCODER_URL}?${query.toString()}`;
|
||||
const payload = JSON.parse(await fetchText(url));
|
||||
const matches = payload?.result?.addressMatches || [];
|
||||
if (matches.length) {
|
||||
return {
|
||||
match: matches[0],
|
||||
censusGeocoderUrl: url,
|
||||
usedFallbackGeocoder: false
|
||||
};
|
||||
}
|
||||
|
||||
let fallbackMatch: any = null;
|
||||
for (const candidateAddress of buildFallbackAddressCandidates(address)) {
|
||||
const fallbackQuery = new URLSearchParams({
|
||||
q: candidateAddress,
|
||||
format: "jsonv2",
|
||||
limit: "1",
|
||||
countrycodes: "us",
|
||||
addressdetails: "1"
|
||||
});
|
||||
const fallbackUrl = `${NOMINATIM_SEARCH_URL}?${fallbackQuery.toString()}`;
|
||||
const fallbackPayload = JSON.parse(await fetchText(fallbackUrl));
|
||||
fallbackMatch = Array.isArray(fallbackPayload) ? fallbackPayload[0] : null;
|
||||
if (fallbackMatch) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!fallbackMatch) {
|
||||
throw new PublicRecordsLookupError(`No Census geocoder match found for address: ${address}`);
|
||||
}
|
||||
|
||||
const latitude = Number(fallbackMatch.lat);
|
||||
const longitude = Number(fallbackMatch.lon);
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
throw new PublicRecordsLookupError(
|
||||
`Fallback geocoder returned invalid coordinates for address: ${address}`
|
||||
);
|
||||
}
|
||||
|
||||
const coordinateQuery = new URLSearchParams({
|
||||
x: String(longitude),
|
||||
y: String(latitude),
|
||||
benchmark: "Public_AR_Current",
|
||||
vintage: "Current_Current",
|
||||
format: "json"
|
||||
});
|
||||
const coordinateUrl = `${CENSUS_COORDINATES_URL}?${coordinateQuery.toString()}`;
|
||||
const coordinatePayload = JSON.parse(await fetchText(coordinateUrl));
|
||||
const geographies = coordinatePayload?.result?.geographies;
|
||||
if (!geographies) {
|
||||
throw new PublicRecordsLookupError(
|
||||
`Census coordinate geographies lookup failed for address: ${address}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
match: {
|
||||
matchedAddress: collapseWhitespace(fallbackMatch.display_name || address),
|
||||
coordinates: {
|
||||
x: longitude,
|
||||
y: latitude
|
||||
},
|
||||
geographies
|
||||
},
|
||||
censusGeocoderUrl: coordinateUrl,
|
||||
usedFallbackGeocoder: true
|
||||
};
|
||||
}
|
||||
|
||||
async function findTexasCountyHref(countyName: string, fetchText: FetchLike): Promise<string> {
|
||||
const html = await fetchText(TEXAS_COUNTY_DIRECTORY_URL);
|
||||
const countyNorm = normalizeCountyName(countyName);
|
||||
const matches = html.matchAll(/<a href="([^"]+\.php)">\s*\d+\s+([^<]+)\s*<\/a>/gi);
|
||||
for (const match of matches) {
|
||||
const href = match[1];
|
||||
const label = match[2];
|
||||
if (normalizeCountyName(label) === countyNorm) {
|
||||
return href.startsWith("http://") || href.startsWith("https://")
|
||||
? href
|
||||
: `${TEXAS_COUNTY_DIRECTORY_URL}${href.replace(/^\/+/, "")}`;
|
||||
}
|
||||
}
|
||||
throw new PublicRecordsLookupError(
|
||||
`Could not find Texas county directory page for county: ${countyName}`
|
||||
);
|
||||
}
|
||||
|
||||
function parseTexasSection(sectionHtml: string): Record<string, unknown> {
|
||||
const result: Record<string, unknown> = {};
|
||||
|
||||
const lastUpdated = sectionHtml.match(
|
||||
/<p class="file-info">\s*Last Updated:\s*([^<]+)<\/p>/i
|
||||
);
|
||||
if (lastUpdated) {
|
||||
result.lastUpdated = collapseWhitespace(lastUpdated[1]);
|
||||
}
|
||||
|
||||
const lead = sectionHtml.match(/<h4>\s*([^:<]+):\s*([^<]+)<\/h4>/i);
|
||||
if (lead) {
|
||||
result[lead[1].trim()] = collapseWhitespace(lead[2]);
|
||||
}
|
||||
|
||||
const infoBlock = sectionHtml.match(/<h4>\s*[^<]+<\/h4>\s*<p>(.*?)<\/p>/is);
|
||||
if (infoBlock) {
|
||||
for (const match of infoBlock[1].matchAll(
|
||||
/<strong>\s*([^:<]+):\s*<\/strong>\s*(.*?)(?:<br\s*\/?>|$)/gis
|
||||
)) {
|
||||
const key = collapseWhitespace(match[1]);
|
||||
const rawValue = match[2];
|
||||
const hrefValue = extractAnchorHref(rawValue);
|
||||
if (key.toLowerCase() === "website" && hrefValue) {
|
||||
result[key] = hrefValue;
|
||||
} else if (
|
||||
key.toLowerCase() === "email" &&
|
||||
hrefValue &&
|
||||
hrefValue.startsWith("mailto:")
|
||||
) {
|
||||
result[key] = hrefValue.replace(/^mailto:/i, "");
|
||||
} else {
|
||||
result[key] = stripHtml(rawValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const headings: Array<[string, string]> = [
|
||||
["Mailing Address", "mailingAddress"],
|
||||
["Street Address", "streetAddress"],
|
||||
["Collecting Unit", "collectingUnit"]
|
||||
];
|
||||
|
||||
for (const [heading, key] of headings) {
|
||||
const match = sectionHtml.match(
|
||||
new RegExp(`<h4>\\s*${heading}\\s*<\\/h4>\\s*<p>(.*?)<\\/p>`, "is")
|
||||
);
|
||||
if (match) {
|
||||
result[key] = stripHtml(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetchTexasCountyOffices(
|
||||
countyName: string,
|
||||
fetchText: FetchLike
|
||||
): Promise<{
|
||||
directoryPage: string;
|
||||
appraisalDistrict: Record<string, unknown>;
|
||||
taxAssessorCollector: Record<string, unknown> | null;
|
||||
}> {
|
||||
const pageUrl = await findTexasCountyHref(countyName, fetchText);
|
||||
const html = await fetchText(pageUrl);
|
||||
const appraisalMatch = html.match(
|
||||
/<h3>\s*Appraisal District\s*<\/h3>(.*?)(?=<h3>\s*Tax Assessor\/Collector\s*<\/h3>)/is
|
||||
);
|
||||
const taxMatch = html.match(/<h3>\s*Tax Assessor\/Collector\s*<\/h3>(.*)$/is);
|
||||
if (!appraisalMatch) {
|
||||
throw new PublicRecordsLookupError(
|
||||
`Could not parse Appraisal District section for county: ${countyName}`
|
||||
);
|
||||
}
|
||||
|
||||
const appraisalDistrict = parseTexasSection(appraisalMatch[1]);
|
||||
appraisalDistrict.directoryPage = pageUrl;
|
||||
|
||||
const taxAssessorCollector = taxMatch ? parseTexasSection(taxMatch[1]) : null;
|
||||
if (taxAssessorCollector) {
|
||||
taxAssessorCollector.directoryPage = pageUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
directoryPage: pageUrl,
|
||||
appraisalDistrict,
|
||||
taxAssessorCollector
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolvePublicRecords(
|
||||
address: string,
|
||||
options: {
|
||||
parcelId?: string;
|
||||
listingGeoId?: string;
|
||||
listingSourceUrl?: string;
|
||||
fetchText?: FetchLike;
|
||||
} = {}
|
||||
): Promise<PublicRecordsResolution> {
|
||||
const fetchText = options.fetchText || defaultFetchText;
|
||||
const { match, censusGeocoderUrl, usedFallbackGeocoder } = await geocodeAddress(
|
||||
address,
|
||||
fetchText
|
||||
);
|
||||
const geographies = match.geographies || {};
|
||||
const state = (geographies.States || [{}])[0];
|
||||
const county = (geographies.Counties || [{}])[0];
|
||||
const block = (geographies["2020 Census Blocks"] || [{}])[0];
|
||||
const coordinates = match.coordinates || {};
|
||||
|
||||
let texasCountyDirectory: string | null = null;
|
||||
let texasPropertyTaxPortal: string | null = null;
|
||||
let appraisalDistrict: Record<string, unknown> | null = null;
|
||||
let taxAssessorCollector: Record<string, unknown> | null = null;
|
||||
let propertyDetails: PropertyDetailsResolution | null = null;
|
||||
|
||||
const lookupRecommendations = [
|
||||
"Start from the official public-record jurisdiction instead of a listing-site geo ID.",
|
||||
"Try official address search first on the appraisal district site.",
|
||||
"If the listing exposes parcel/APN/account identifiers, use them as stronger search keys than ZPID or listing geo IDs."
|
||||
];
|
||||
if (usedFallbackGeocoder) {
|
||||
lookupRecommendations.push(
|
||||
"The Census address lookup missed this address, so a fallback geocoder was used to obtain coordinates before resolving official Census geographies."
|
||||
);
|
||||
}
|
||||
|
||||
if (state.STUSAB === "TX" && county.NAME) {
|
||||
const offices = await fetchTexasCountyOffices(county.NAME, fetchText);
|
||||
texasCountyDirectory = offices.directoryPage;
|
||||
texasPropertyTaxPortal = TEXAS_PROPERTY_TAX_PORTAL;
|
||||
appraisalDistrict = offices.appraisalDistrict;
|
||||
taxAssessorCollector = offices.taxAssessorCollector;
|
||||
propertyDetails = await tryEnrichPropertyDetails(
|
||||
address,
|
||||
options.parcelId,
|
||||
typeof offices.appraisalDistrict?.Website === "string"
|
||||
? offices.appraisalDistrict.Website
|
||||
: null,
|
||||
fetchText
|
||||
);
|
||||
lookupRecommendations.push(
|
||||
"Use the Texas Comptroller county directory page as the official jurisdiction link in the final report.",
|
||||
"Attempt to retrieve assessed value, land value, improvement value, exemptions, and account number from the CAD website when a direct property page is publicly accessible."
|
||||
);
|
||||
if (propertyDetails) {
|
||||
lookupRecommendations.push(
|
||||
...propertyDetails.notes,
|
||||
"Use the official CAD property-detail values in the final assessment instead of relying only on listing-site value hints."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const sourceIdentifierHints: Record<string, string> = {};
|
||||
if (options.parcelId) sourceIdentifierHints.parcelId = options.parcelId;
|
||||
if (options.listingGeoId) {
|
||||
sourceIdentifierHints.listingGeoId = options.listingGeoId;
|
||||
lookupRecommendations.push(
|
||||
"Treat listing geo IDs as regional hints only; do not use them as assessor record keys."
|
||||
);
|
||||
}
|
||||
if (options.listingSourceUrl) {
|
||||
sourceIdentifierHints.listingSourceUrl = options.listingSourceUrl;
|
||||
}
|
||||
|
||||
return {
|
||||
requestedAddress: address,
|
||||
matchedAddress: match.matchedAddress || address,
|
||||
latitude: coordinates.y ?? null,
|
||||
longitude: coordinates.x ?? null,
|
||||
geoid: block.GEOID || null,
|
||||
state: {
|
||||
name: state.NAME || null,
|
||||
code: state.STUSAB || null,
|
||||
fips: state.STATE || null
|
||||
},
|
||||
county: {
|
||||
name: county.NAME || null,
|
||||
fips: county.COUNTY || null,
|
||||
geoid: county.GEOID || null
|
||||
},
|
||||
officialLinks: {
|
||||
censusGeocoder: censusGeocoderUrl,
|
||||
texasCountyDirectory,
|
||||
texasPropertyTaxPortal
|
||||
},
|
||||
appraisalDistrict,
|
||||
taxAssessorCollector,
|
||||
lookupRecommendations,
|
||||
sourceIdentifierHints,
|
||||
propertyDetails
|
||||
};
|
||||
}
|
||||
360
skills/property-assessor/src/report-pdf.ts
Normal file
360
skills/property-assessor/src/report-pdf.ts
Normal file
@@ -0,0 +1,360 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import PDFDocument from "pdfkit";
|
||||
|
||||
export class ReportValidationError extends Error {}
|
||||
|
||||
export interface ReportPayload {
|
||||
recipientEmails?: string[] | string;
|
||||
assessmentPurpose?: string;
|
||||
reportTitle?: string;
|
||||
subtitle?: string;
|
||||
generatedAt?: string;
|
||||
preparedBy?: string;
|
||||
reportNotes?: string;
|
||||
subjectProperty?: Record<string, unknown>;
|
||||
verdict?: Record<string, unknown>;
|
||||
snapshot?: unknown;
|
||||
whatILike?: unknown;
|
||||
whatIDontLike?: unknown;
|
||||
compView?: unknown;
|
||||
carryView?: unknown;
|
||||
risksAndDiligence?: unknown;
|
||||
photoReview?: Record<string, unknown>;
|
||||
publicRecords?: Record<string, unknown>;
|
||||
sourceLinks?: unknown;
|
||||
}
|
||||
|
||||
export function isDecisionGradeReportPayload(payload: ReportPayload): boolean {
|
||||
const decision = String(payload.verdict?.decision || "").trim().toLowerCase();
|
||||
const fairValueRange = String(payload.verdict?.fairValueRange || "").trim().toLowerCase();
|
||||
const photoReviewStatus = String(payload.photoReview?.status || "").trim().toLowerCase();
|
||||
if (!decision || decision === "pending") return false;
|
||||
if (!fairValueRange || fairValueRange === "not established") return false;
|
||||
if (photoReviewStatus !== "completed") return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function asStringArray(value: unknown): string[] {
|
||||
if (value == null) return [];
|
||||
if (typeof value === "string") {
|
||||
const text = value.trim();
|
||||
return text ? [text] : [];
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return [String(value)];
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const out: string[] = [];
|
||||
for (const item of value) {
|
||||
out.push(...asStringArray(item));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
if (typeof value === "object") {
|
||||
return Object.entries(value as Record<string, unknown>)
|
||||
.filter(([, item]) => item != null && item !== "")
|
||||
.map(([key, item]) => `${key}: ${item}`);
|
||||
}
|
||||
return [String(value)];
|
||||
}
|
||||
|
||||
function currency(value: unknown): string {
|
||||
if (value == null || value === "") return "N/A";
|
||||
const num = Number(value);
|
||||
if (Number.isFinite(num)) return `$${num.toLocaleString("en-US", { maximumFractionDigits: 0 })}`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function validateReportPayload(payload: ReportPayload): string[] {
|
||||
const recipients = asStringArray(payload.recipientEmails).map((item) => item.trim()).filter(Boolean);
|
||||
if (!recipients.length) {
|
||||
throw new ReportValidationError(
|
||||
"Missing target email. Stop and ask the user for target email address(es) before generating or sending the property assessment PDF."
|
||||
);
|
||||
}
|
||||
const address = payload.subjectProperty && typeof payload.subjectProperty.address === "string"
|
||||
? payload.subjectProperty.address.trim()
|
||||
: "";
|
||||
if (!address) {
|
||||
throw new ReportValidationError("The report payload must include subjectProperty.address.");
|
||||
}
|
||||
if (!isDecisionGradeReportPayload(payload)) {
|
||||
throw new ReportValidationError(
|
||||
"The report payload is still preliminary. Stop and complete the decision-grade analysis, including subject-unit photo review, before generating or sending the property assessment PDF."
|
||||
);
|
||||
}
|
||||
return recipients;
|
||||
}
|
||||
|
||||
function drawSectionHeader(doc: PDFKit.PDFDocument, title: string): void {
|
||||
const x = doc.page.margins.left;
|
||||
const y = doc.y;
|
||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const height = 18;
|
||||
doc.save();
|
||||
doc.roundedRect(x, y, width, height, 3).fill("#123B5D");
|
||||
doc
|
||||
.fillColor("white")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text(title, x + 8, y + 4, { width: width - 16 });
|
||||
doc.restore();
|
||||
doc.moveDown(1.2);
|
||||
}
|
||||
|
||||
function drawBulletList(doc: PDFKit.PDFDocument, value: unknown, fallback = "Not provided."): void {
|
||||
const items = asStringArray(value);
|
||||
if (!items.length) {
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(fallback);
|
||||
doc.moveDown(0.6);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const startY = doc.y;
|
||||
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D");
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica")
|
||||
.fontSize(10.5)
|
||||
.text(item, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2
|
||||
});
|
||||
doc.moveDown(0.35);
|
||||
}
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
function drawKeyValueTable(doc: PDFKit.PDFDocument, rows: Array<[string, string]>): void {
|
||||
const left = doc.page.margins.left;
|
||||
const totalWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const keyWidth = 150;
|
||||
const valueWidth = totalWidth - keyWidth;
|
||||
for (const [key, value] of rows) {
|
||||
const startY = doc.y;
|
||||
const keyHeight = doc.heightOfString(key, { width: keyWidth - 12 });
|
||||
const valueHeight = doc.heightOfString(value, { width: valueWidth - 12 });
|
||||
const rowHeight = Math.max(keyHeight, valueHeight) + 12;
|
||||
|
||||
doc.save();
|
||||
doc.rect(left, startY, keyWidth, rowHeight).fill("#EEF3F7");
|
||||
doc.rect(left + keyWidth, startY, valueWidth, rowHeight).fill("#FFFFFF");
|
||||
doc
|
||||
.lineWidth(0.5)
|
||||
.strokeColor("#C7D0D9")
|
||||
.rect(left, startY, totalWidth, rowHeight)
|
||||
.stroke();
|
||||
doc
|
||||
.moveTo(left + keyWidth, startY)
|
||||
.lineTo(left + keyWidth, startY + rowHeight)
|
||||
.stroke();
|
||||
doc.restore();
|
||||
|
||||
doc.fillColor("#1E2329").font("Helvetica-Bold").fontSize(9.5).text(key, left + 6, startY + 6, { width: keyWidth - 12 });
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(9.5).text(value, left + keyWidth + 6, startY + 6, { width: valueWidth - 12 });
|
||||
doc.y = startY + rowHeight;
|
||||
}
|
||||
doc.moveDown(0.8);
|
||||
}
|
||||
|
||||
function drawVerdictPanel(doc: PDFKit.PDFDocument, verdict: Record<string, unknown> | undefined): void {
|
||||
const decision = String(verdict?.decision || "pending").trim().toLowerCase();
|
||||
const badgeColor =
|
||||
decision === "buy" ? "#1E6B52" : decision === "pass" ? "#8B2E2E" : "#7A5D12";
|
||||
const left = doc.page.margins.left;
|
||||
const width = doc.page.width - doc.page.margins.left - doc.page.margins.right;
|
||||
const top = doc.y;
|
||||
const bodyText = String(
|
||||
verdict?.offerGuidance || "Offer guidance not provided."
|
||||
);
|
||||
const bodyHeight = doc.heightOfString(bodyText, { width: width - 20 }) + 16;
|
||||
|
||||
doc.save();
|
||||
doc.roundedRect(left, top, width, 26, 4).fill(badgeColor);
|
||||
doc
|
||||
.fillColor("white")
|
||||
.font("Helvetica-Bold")
|
||||
.fontSize(12)
|
||||
.text(String(verdict?.decision || "N/A").toUpperCase(), left + 10, top + 7, {
|
||||
width: width - 20
|
||||
});
|
||||
|
||||
doc
|
||||
.roundedRect(left, top + 26, width, bodyHeight, 4)
|
||||
.fillAndStroke("#F4F6F8", "#C7D0D9");
|
||||
doc
|
||||
.fillColor("#1E2329")
|
||||
.font("Helvetica")
|
||||
.fontSize(10.5)
|
||||
.text(bodyText, left + 10, top + 36, {
|
||||
width: width - 20,
|
||||
lineGap: 2
|
||||
});
|
||||
doc.restore();
|
||||
doc.y = top + 26 + bodyHeight + 10;
|
||||
}
|
||||
|
||||
function drawLinks(doc: PDFKit.PDFDocument, value: unknown): void {
|
||||
const items = Array.isArray(value) ? value : [];
|
||||
if (!items.length) {
|
||||
drawBulletList(doc, [], "Not provided.");
|
||||
return;
|
||||
}
|
||||
for (const item of items as Array<Record<string, unknown>>) {
|
||||
const label = typeof item.label === "string" ? item.label : "Link";
|
||||
const url = typeof item.url === "string" ? item.url : "";
|
||||
const line = url ? `${label}: ${url}` : label;
|
||||
const startY = doc.y;
|
||||
doc.circle(doc.page.margins.left + 4, startY + 6, 1.5).fill("#123B5D");
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5);
|
||||
if (url) {
|
||||
doc.text(line, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2,
|
||||
link: url,
|
||||
underline: true
|
||||
});
|
||||
} else {
|
||||
doc.text(line, doc.page.margins.left + 14, startY, {
|
||||
width: doc.page.width - doc.page.margins.left - doc.page.margins.right - 14,
|
||||
lineGap: 2
|
||||
});
|
||||
}
|
||||
doc.moveDown(0.35);
|
||||
}
|
||||
doc.moveDown(0.2);
|
||||
}
|
||||
|
||||
export async function renderReportPdf(
|
||||
payload: ReportPayload,
|
||||
outputPath: string
|
||||
): Promise<string> {
|
||||
const recipients = validateReportPayload(payload);
|
||||
await fs.promises.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
|
||||
const doc = new PDFDocument({
|
||||
size: "LETTER",
|
||||
margin: 50,
|
||||
info: {
|
||||
Title: payload.reportTitle || "Property Assessment Report",
|
||||
Author: String(payload.preparedBy || "OpenClaw property-assessor")
|
||||
}
|
||||
});
|
||||
|
||||
const stream = fs.createWriteStream(outputPath);
|
||||
doc.pipe(stream);
|
||||
|
||||
const generatedAt =
|
||||
payload.generatedAt ||
|
||||
new Date().toLocaleString("en-US", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short"
|
||||
});
|
||||
const subject = payload.subjectProperty || {};
|
||||
const verdict = payload.verdict || {};
|
||||
const publicRecords = payload.publicRecords || {};
|
||||
|
||||
doc.fillColor("#123B5D").font("Helvetica-Bold").fontSize(22).text(payload.reportTitle || "Property Assessment Report");
|
||||
doc.moveDown(0.2);
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(11).text(
|
||||
String(
|
||||
payload.subtitle ||
|
||||
"Decision-grade acquisition review with listing, public-record, comp, and risk analysis."
|
||||
)
|
||||
);
|
||||
doc.moveDown(0.4);
|
||||
doc.fillColor("#5A6570").font("Helvetica").fontSize(9);
|
||||
doc.text(`Prepared for: ${recipients.join(", ")}`);
|
||||
doc.text(`Generated: ${generatedAt}`);
|
||||
doc.moveDown(0.8);
|
||||
|
||||
drawVerdictPanel(doc, verdict);
|
||||
|
||||
drawKeyValueTable(doc, [
|
||||
["Address", String(subject.address || "N/A")],
|
||||
["Assessment Purpose", String(payload.assessmentPurpose || "N/A")],
|
||||
["Ask / Last Price", currency(subject.listingPrice)],
|
||||
["Type", String(subject.propertyType || "N/A")],
|
||||
["Beds / Baths", `${subject.beds ?? "N/A"} / ${subject.baths ?? "N/A"}`],
|
||||
["Sqft", String(subject.squareFeet ?? "N/A")],
|
||||
["Year Built", String(subject.yearBuilt ?? "N/A")],
|
||||
["Verdict", String(verdict.decision || "N/A")],
|
||||
["Fair Value Range", String(verdict.fairValueRange || "N/A")],
|
||||
["Public-Record Jurisdiction", String(publicRecords.jurisdiction || "N/A")],
|
||||
["Assessed Total", currency(publicRecords.assessedTotalValue)]
|
||||
]);
|
||||
|
||||
const sections: Array<[string, unknown, "list" | "links"]> = [
|
||||
["Snapshot", payload.snapshot, "list"],
|
||||
["What I Like", payload.whatILike, "list"],
|
||||
["What I Do Not Like", payload.whatIDontLike, "list"],
|
||||
["Comp View", payload.compView, "list"],
|
||||
["Underwriting / Carry View", payload.carryView, "list"],
|
||||
["Risks and Diligence Items", payload.risksAndDiligence, "list"],
|
||||
[
|
||||
"Photo Review",
|
||||
[
|
||||
...(asStringArray((payload.photoReview || {}).status ? [`Photo review: ${String((payload.photoReview || {}).status)}${(payload.photoReview || {}).source ? ` via ${String((payload.photoReview || {}).source)}` : ""}`] : [])),
|
||||
...asStringArray((payload.photoReview || {}).attempts),
|
||||
...asStringArray((payload.photoReview || {}).summary ? [`Condition read: ${String((payload.photoReview || {}).summary)}`] : [])
|
||||
],
|
||||
"list"
|
||||
],
|
||||
[
|
||||
"Public Records",
|
||||
[
|
||||
...asStringArray({
|
||||
Jurisdiction: publicRecords.jurisdiction,
|
||||
"Account Number": publicRecords.accountNumber,
|
||||
"Owner Name": publicRecords.ownerName,
|
||||
"Land Value": publicRecords.landValue != null ? currency(publicRecords.landValue) : undefined,
|
||||
"Improvement Value":
|
||||
publicRecords.improvementValue != null ? currency(publicRecords.improvementValue) : undefined,
|
||||
"Assessed Total":
|
||||
publicRecords.assessedTotalValue != null ? currency(publicRecords.assessedTotalValue) : undefined,
|
||||
Exemptions: publicRecords.exemptions
|
||||
}),
|
||||
...asStringArray((publicRecords.links || []).map((item: any) => `${item.label}: ${item.url}`))
|
||||
],
|
||||
"list"
|
||||
],
|
||||
["Source Links", payload.sourceLinks, "links"]
|
||||
];
|
||||
|
||||
for (const [title, content, kind] of sections) {
|
||||
if (doc.y > 660) doc.addPage();
|
||||
drawSectionHeader(doc, title);
|
||||
if (kind === "links") {
|
||||
drawLinks(doc, content);
|
||||
} else {
|
||||
drawBulletList(doc, content);
|
||||
}
|
||||
}
|
||||
|
||||
doc.addPage();
|
||||
drawSectionHeader(doc, "Report Notes");
|
||||
doc.fillColor("#1E2329").font("Helvetica").fontSize(10.5).text(
|
||||
String(
|
||||
payload.reportNotes ||
|
||||
"This report uses the property-assessor fixed PDF template. Listing data should be reconciled against official public records when available, and public-record links should be included in any delivered report."
|
||||
),
|
||||
{
|
||||
lineGap: 3
|
||||
}
|
||||
);
|
||||
|
||||
doc.end();
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
stream.on("finish", () => resolve());
|
||||
stream.on("error", reject);
|
||||
});
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
export async function loadReportPayload(inputPath: string): Promise<ReportPayload> {
|
||||
return JSON.parse(await fs.promises.readFile(inputPath, "utf8")) as ReportPayload;
|
||||
}
|
||||
343
skills/property-assessor/tests/assessment.test.ts
Normal file
343
skills/property-assessor/tests/assessment.test.ts
Normal file
@@ -0,0 +1,343 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { assessProperty } from "../src/assessment.js";
|
||||
import type { PublicRecordsResolution } from "../src/public-records.js";
|
||||
|
||||
const samplePublicRecords: PublicRecordsResolution = {
|
||||
requestedAddress: "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
matchedAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418",
|
||||
latitude: 27.6138,
|
||||
longitude: -97.3024,
|
||||
geoid: "483550031013005",
|
||||
state: {
|
||||
name: "Texas",
|
||||
code: "TX",
|
||||
fips: "48"
|
||||
},
|
||||
county: {
|
||||
name: "Nueces County",
|
||||
fips: "355",
|
||||
geoid: "48355"
|
||||
},
|
||||
officialLinks: {
|
||||
censusGeocoder: "https://geocoding.geo.census.gov/example",
|
||||
texasCountyDirectory:
|
||||
"https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php",
|
||||
texasPropertyTaxPortal: "https://texas.gov/PropertyTaxes"
|
||||
},
|
||||
appraisalDistrict: {
|
||||
"Chief Appraiser": "Debra Morin, Interim",
|
||||
Website: "http://www.ncadistrict.com/",
|
||||
directoryPage:
|
||||
"https://comptroller.texas.gov/taxes/property-tax/county-directory/nueces.php"
|
||||
},
|
||||
taxAssessorCollector: {
|
||||
"Tax Assessor-Collector": "Kevin Kieschnick",
|
||||
Website: "http://www.nuecesco.com"
|
||||
},
|
||||
lookupRecommendations: [
|
||||
"Start from the official public-record jurisdiction instead of a listing-site geo ID."
|
||||
],
|
||||
sourceIdentifierHints: {
|
||||
parcelId: "14069438"
|
||||
},
|
||||
propertyDetails: {
|
||||
source: "nueces-esearch",
|
||||
sourceUrl: "https://esearch.nuecescad.net/property/view/14069438?year=2026",
|
||||
propertyId: "14069438",
|
||||
ownerName: "Fiorini Family Trust",
|
||||
situsAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418",
|
||||
legalDescription: "LOT 4 BLOCK 3 EXAMPLE SUBDIVISION",
|
||||
landValue: 42000,
|
||||
improvementValue: 99000,
|
||||
assessedTotalValue: 141000,
|
||||
exemptions: ["Homestead"],
|
||||
notes: [
|
||||
"Official CAD property detail page exposed owner, value, and exemption data."
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
test("assessProperty asks for assessment purpose before building a decision-grade assessment", async () => {
|
||||
const result = await assessProperty(
|
||||
{
|
||||
address: "4141 Whiteley Dr, Corpus Christi, TX 78418"
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.needsAssessmentPurpose, true);
|
||||
assert.equal(result.needsRecipientEmails, false);
|
||||
assert.equal(result.outputPath, null);
|
||||
assert.match(result.message, /assessment purpose/i);
|
||||
assert.equal(result.reportPayload, null);
|
||||
});
|
||||
|
||||
test("assessProperty auto-discovers listing sources, runs Zillow photos first, and does not ask for email during analysis-only runs", async () => {
|
||||
const result = await assessProperty(
|
||||
{
|
||||
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
assessmentPurpose: "investment property",
|
||||
listingGeoId: "233290",
|
||||
parcelId: "14069438"
|
||||
},
|
||||
{
|
||||
resolvePublicRecordsFn: async () => samplePublicRecords,
|
||||
discoverListingSourcesFn: async () => ({
|
||||
attempts: [
|
||||
"Zillow discovery located a property page from the address.",
|
||||
"HAR discovery located a property page from the address."
|
||||
],
|
||||
zillowUrl:
|
||||
"https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/",
|
||||
harUrl:
|
||||
"https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
|
||||
}),
|
||||
extractPhotoDataFn: async (source, url) => ({
|
||||
source,
|
||||
requestedUrl: url,
|
||||
finalUrl: url,
|
||||
expectedPhotoCount: 29,
|
||||
complete: true,
|
||||
photoCount: 29,
|
||||
imageUrls: ["https://photos.example/1.jpg", "https://photos.example/2.jpg"],
|
||||
notes: [`${source} extractor succeeded.`]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.needsAssessmentPurpose, false);
|
||||
assert.equal(result.needsRecipientEmails, false);
|
||||
assert.equal(result.outputPath, null);
|
||||
assert.doesNotMatch(result.message, /target email/i);
|
||||
assert.match(result.message, /ready to render|recipient email is only needed when you want the pdf/i);
|
||||
assert.equal(result.reportPayload?.subjectProperty?.address, samplePublicRecords.matchedAddress);
|
||||
assert.equal(result.reportPayload?.publicRecords?.jurisdiction, "Nueces County Appraisal District");
|
||||
assert.equal(result.reportPayload?.publicRecords?.accountNumber, "14069438");
|
||||
assert.equal(result.reportPayload?.publicRecords?.ownerName, "Fiorini Family Trust");
|
||||
assert.equal(result.reportPayload?.publicRecords?.landValue, 42000);
|
||||
assert.equal(result.reportPayload?.publicRecords?.improvementValue, 99000);
|
||||
assert.equal(result.reportPayload?.publicRecords?.assessedTotalValue, 141000);
|
||||
assert.deepEqual(result.reportPayload?.publicRecords?.exemptions, ["Homestead"]);
|
||||
assert.match(
|
||||
String(result.reportPayload?.snapshot?.join(" ")),
|
||||
/141,000|141000|assessed/i
|
||||
);
|
||||
assert.match(
|
||||
String(result.reportPayload?.risksAndDiligence?.join(" ")),
|
||||
/official cad property detail page exposed owner, value, and exemption data/i
|
||||
);
|
||||
assert.equal(result.reportPayload?.photoReview?.status, "completed");
|
||||
assert.equal(result.reportPayload?.photoReview?.source, "zillow");
|
||||
assert.equal(result.reportPayload?.photoReview?.photoCount, 29);
|
||||
assert.match(
|
||||
String(result.reportPayload?.verdict?.offerGuidance),
|
||||
/investment property/i
|
||||
);
|
||||
assert.match(
|
||||
String(result.reportPayload?.carryView?.[0]),
|
||||
/income property/i
|
||||
);
|
||||
assert.deepEqual(result.reportPayload?.recipientEmails, []);
|
||||
});
|
||||
|
||||
test("assessProperty uses parcel/APN hints extracted from Zillow before CAD lookup when parcel ID was not provided", async () => {
|
||||
const seenPublicRecordOptions: Array<Record<string, unknown>> = [];
|
||||
|
||||
const result = await assessProperty(
|
||||
{
|
||||
address: "6702 Everhart Rd APT T106, Corpus Christi, TX 78413",
|
||||
assessmentPurpose: "college housing for daughter attending TAMU-CC"
|
||||
},
|
||||
{
|
||||
resolvePublicRecordsFn: async (_address, options) => {
|
||||
seenPublicRecordOptions.push({ ...options });
|
||||
return samplePublicRecords;
|
||||
},
|
||||
discoverListingSourcesFn: async () => ({
|
||||
attempts: ["Zillow discovery located a property page from the address."],
|
||||
zillowUrl:
|
||||
"https://www.zillow.com/homedetails/6702-Everhart-Rd-APT-T106-Corpus-Christi-TX-78413/2067445642_zpid/",
|
||||
harUrl: null
|
||||
}),
|
||||
extractZillowIdentifierHintsFn: async () => ({
|
||||
parcelId: "1234567890",
|
||||
notes: ["Zillow listing exposed parcel/APN number 1234567890."]
|
||||
}),
|
||||
extractPhotoDataFn: async (source, url) => ({
|
||||
source,
|
||||
requestedUrl: url,
|
||||
finalUrl: url,
|
||||
expectedPhotoCount: 29,
|
||||
complete: true,
|
||||
photoCount: 29,
|
||||
imageUrls: ["https://photos.example/1.jpg"],
|
||||
notes: [`${source} extractor succeeded.`]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(seenPublicRecordOptions.length, 1);
|
||||
assert.equal(seenPublicRecordOptions[0]?.parcelId, "1234567890");
|
||||
});
|
||||
|
||||
test("assessProperty asks for recipient email only when PDF render is explicitly requested", async () => {
|
||||
const result = await assessProperty(
|
||||
{
|
||||
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
assessmentPurpose: "investment property",
|
||||
output: path.join(os.tmpdir(), `property-assess-missing-email-${Date.now()}.pdf`)
|
||||
},
|
||||
{
|
||||
resolvePublicRecordsFn: async () => samplePublicRecords,
|
||||
discoverListingSourcesFn: async () => ({
|
||||
attempts: ["Zillow discovery located a property page from the address."],
|
||||
zillowUrl:
|
||||
"https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/",
|
||||
harUrl: null
|
||||
}),
|
||||
extractPhotoDataFn: async (source, url) => ({
|
||||
source,
|
||||
requestedUrl: url,
|
||||
finalUrl: url,
|
||||
expectedPhotoCount: 29,
|
||||
complete: true,
|
||||
photoCount: 29,
|
||||
imageUrls: ["https://photos.example/1.jpg"],
|
||||
notes: [`${source} extractor succeeded.`]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.needsRecipientEmails, true);
|
||||
assert.equal(result.outputPath, null);
|
||||
assert.match(result.message, /target email/i);
|
||||
});
|
||||
|
||||
test("assessProperty falls back to HAR when Zillow photo extraction fails", async () => {
|
||||
const result = await assessProperty(
|
||||
{
|
||||
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
assessmentPurpose: "vacation home"
|
||||
},
|
||||
{
|
||||
resolvePublicRecordsFn: async () => samplePublicRecords,
|
||||
discoverListingSourcesFn: async () => ({
|
||||
attempts: ["Address-based discovery found Zillow and HAR candidates."],
|
||||
zillowUrl:
|
||||
"https://www.zillow.com/homedetails/4141-Whiteley-Dr-Corpus-Christi-TX-78418/2103723704_zpid/",
|
||||
harUrl:
|
||||
"https://www.har.com/homedetail/4141-whiteley-dr-corpus-christi-tx-78418/14069438"
|
||||
}),
|
||||
extractPhotoDataFn: async (source, url) => {
|
||||
if (source === "zillow") {
|
||||
throw new Error(`zillow failed for ${url}`);
|
||||
}
|
||||
return {
|
||||
source,
|
||||
requestedUrl: url,
|
||||
finalUrl: url,
|
||||
expectedPhotoCount: 29,
|
||||
complete: true,
|
||||
photoCount: 29,
|
||||
imageUrls: ["https://photos.har.example/1.jpg"],
|
||||
notes: ["HAR extractor succeeded after Zillow failed."]
|
||||
};
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.reportPayload?.photoReview?.status, "completed");
|
||||
assert.equal(result.reportPayload?.photoReview?.source, "har");
|
||||
assert.match(
|
||||
String(result.reportPayload?.photoReview?.attempts?.join(" ")),
|
||||
/zillow/i
|
||||
);
|
||||
assert.match(
|
||||
String(result.reportPayload?.verdict?.offerGuidance),
|
||||
/vacation home/i
|
||||
);
|
||||
});
|
||||
|
||||
test("assessProperty does not render a PDF from a preliminary helper payload even when recipient email is present", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `property-assess-command-${Date.now()}.pdf`);
|
||||
let renderCalls = 0;
|
||||
const result = await assessProperty(
|
||||
{
|
||||
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
assessmentPurpose: "rental for my daughter in college",
|
||||
recipientEmails: ["buyer@example.com"],
|
||||
output: outputPath
|
||||
},
|
||||
{
|
||||
resolvePublicRecordsFn: async () => samplePublicRecords,
|
||||
discoverListingSourcesFn: async () => ({
|
||||
attempts: ["No listing sources discovered from the address."],
|
||||
zillowUrl: null,
|
||||
harUrl: null
|
||||
}),
|
||||
renderReportPdfFn: async () => {
|
||||
renderCalls += 1;
|
||||
return outputPath;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.equal(result.needsAssessmentPurpose, false);
|
||||
assert.equal(result.needsRecipientEmails, false);
|
||||
assert.equal(renderCalls, 0);
|
||||
assert.equal(result.pdfReady, false);
|
||||
assert.equal(result.outputPath, null);
|
||||
assert.match(result.message, /preliminary|decision-grade|cannot render/i);
|
||||
});
|
||||
|
||||
test("assessProperty prioritizes student housing guidance over investment fallback keywords", async () => {
|
||||
const result = await assessProperty(
|
||||
{
|
||||
address: "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
|
||||
assessmentPurpose:
|
||||
"college housing for daughter attending TAMU-CC; prioritize proximity, safety/livability, and resale/rental fallback"
|
||||
},
|
||||
{
|
||||
resolvePublicRecordsFn: async () => samplePublicRecords,
|
||||
discoverListingSourcesFn: async () => ({
|
||||
attempts: ["Zillow discovery located a property page from the address."],
|
||||
zillowUrl:
|
||||
"https://www.zillow.com/homedetails/1011-Ennis-Joslin-Rd-APT-235-Corpus-Christi-TX-78412/28848927_zpid/",
|
||||
harUrl: null
|
||||
}),
|
||||
extractPhotoDataFn: async (source, url) => ({
|
||||
source,
|
||||
requestedUrl: url,
|
||||
finalUrl: url,
|
||||
expectedPhotoCount: 20,
|
||||
complete: true,
|
||||
photoCount: 20,
|
||||
imageUrls: ["https://photos.example/1.jpg"],
|
||||
notes: [`${source} extractor succeeded.`]
|
||||
})
|
||||
}
|
||||
);
|
||||
|
||||
assert.match(
|
||||
String(result.reportPayload?.verdict?.offerGuidance),
|
||||
/daughter|student|practicality|safety/i
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
String(result.reportPayload?.verdict?.offerGuidance),
|
||||
/income property|investment property/i
|
||||
);
|
||||
assert.match(
|
||||
String(result.reportPayload?.carryView?.[0]),
|
||||
/parent-risk|upkeep burden|renting/i
|
||||
);
|
||||
});
|
||||
380
skills/property-assessor/tests/public-records.test.ts
Normal file
380
skills/property-assessor/tests/public-records.test.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolvePublicRecords } from "../src/public-records.js";
|
||||
|
||||
const geocoderPayload = {
|
||||
result: {
|
||||
addressMatches: [
|
||||
{
|
||||
matchedAddress: "4141 WHITELEY DR, CORPUS CHRISTI, TX, 78418",
|
||||
coordinates: { x: -97.30174, y: 27.613668 },
|
||||
geographies: {
|
||||
States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }],
|
||||
Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }],
|
||||
"2020 Census Blocks": [{ GEOID: "483550031013005" }]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
const countyIndexHtml = `
|
||||
<ul>
|
||||
<li><a href="nueces.php">178 Nueces</a></li>
|
||||
</ul>
|
||||
`;
|
||||
|
||||
const countyPageHtml = `
|
||||
<div class="medium-6 small-12 columns">
|
||||
<h3>Appraisal District</h3>
|
||||
<p class="file-info">Last Updated: 08/13/2025</p>
|
||||
<h4>Chief Appraiser: Debra Morin, Interim</h4>
|
||||
<p>
|
||||
<strong>Phone:</strong> <a href="tel:361-881-9978">361-881-9978</a><br />
|
||||
<strong>Email:</strong> <a href="mailto:info@nuecescad.net">info@nuecescad.net</a><br />
|
||||
<strong>Website:</strong> <a href="http://www.ncadistrict.com/">www.ncadistrict.com</a>
|
||||
</p>
|
||||
<h4>Mailing Address</h4>
|
||||
<p>201 N. Chaparral St.<br />Corpus Christi, TX 78401-2503</p>
|
||||
</div>
|
||||
<div class="medium-6 small-12 columns">
|
||||
<h3>Tax Assessor/Collector</h3>
|
||||
<p class="file-info">Last Updated: 02/18/2025</p>
|
||||
<h4>Tax Assessor-Collector: Kevin Kieschnick</h4>
|
||||
<p>
|
||||
<strong>Phone:</strong> <a href="tel:361-888-0307">361-888-0307</a><br />
|
||||
<strong>Email:</strong> <a href="mailto:nueces.tax@nuecesco.com">nueces.tax@nuecesco.com</a><br />
|
||||
<strong>Website:</strong> <a href="http://www.nuecesco.com">www.nuecesco.com</a>
|
||||
</p>
|
||||
<h4>Street Address</h4>
|
||||
<p>901 Leopard St., Room 301<br />Corpus Christi, Texas 78401-3602</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const fakeFetchText = async (url: string): Promise<string> => {
|
||||
if (url.includes("geocoding.geo.census.gov")) {
|
||||
return JSON.stringify(geocoderPayload);
|
||||
}
|
||||
if (url.endsWith("/county-directory/")) {
|
||||
return countyIndexHtml;
|
||||
}
|
||||
if (url.endsWith("/county-directory/nueces.php")) {
|
||||
return countyPageHtml;
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
test("resolvePublicRecords uses Census and Texas county directory", async () => {
|
||||
const payload = await resolvePublicRecords("4141 Whiteley Dr, Corpus Christi, TX 78418", {
|
||||
parcelId: "14069438",
|
||||
listingGeoId: "233290",
|
||||
listingSourceUrl: "https://www.zillow.com/homedetails/example",
|
||||
fetchText: fakeFetchText
|
||||
});
|
||||
|
||||
assert.equal(payload.county.name, "Nueces County");
|
||||
assert.equal(payload.state.code, "TX");
|
||||
assert.equal(payload.appraisalDistrict?.Website, "http://www.ncadistrict.com/");
|
||||
assert.equal(payload.taxAssessorCollector?.Email, "nueces.tax@nuecesco.com");
|
||||
assert.equal(payload.sourceIdentifierHints.parcelId, "14069438");
|
||||
assert.match(payload.lookupRecommendations.join(" "), /listing geo IDs as regional hints only/i);
|
||||
});
|
||||
|
||||
test("resolvePublicRecords falls back to coordinate geocoding when Census address lookup misses", async () => {
|
||||
const coordinatePayload = {
|
||||
result: {
|
||||
geographies: {
|
||||
States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }],
|
||||
Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }],
|
||||
"2020 Census Blocks": [{ GEOID: "483550031013005" }]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fallbackFetchText = async (url: string): Promise<string> => {
|
||||
if (url.includes("geocoding.geo.census.gov") && url.includes("onelineaddress")) {
|
||||
return JSON.stringify({ result: { addressMatches: [] } });
|
||||
}
|
||||
if (url.includes("nominatim.openstreetmap.org/search")) {
|
||||
return JSON.stringify([
|
||||
{
|
||||
lat: "27.708000",
|
||||
lon: "-97.360000",
|
||||
display_name: "1011 Ennis Joslin Rd Apt 235, Corpus Christi, TX 78412"
|
||||
}
|
||||
]);
|
||||
}
|
||||
if (url.includes("geocoding.geo.census.gov") && url.includes("geographies/coordinates")) {
|
||||
return JSON.stringify(coordinatePayload);
|
||||
}
|
||||
if (url.endsWith("/county-directory/")) {
|
||||
return countyIndexHtml;
|
||||
}
|
||||
if (url.endsWith("/county-directory/nueces.php")) {
|
||||
return countyPageHtml;
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const payload = await resolvePublicRecords(
|
||||
"1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
|
||||
{
|
||||
fetchText: fallbackFetchText
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
payload.matchedAddress,
|
||||
"1011 Ennis Joslin Rd Apt 235, Corpus Christi, TX 78412"
|
||||
);
|
||||
assert.equal(payload.county.name, "Nueces County");
|
||||
assert.equal(payload.state.code, "TX");
|
||||
assert.equal(payload.latitude, 27.708);
|
||||
assert.equal(payload.longitude, -97.36);
|
||||
assert.match(
|
||||
payload.lookupRecommendations.join(" "),
|
||||
/fallback geocoder/i
|
||||
);
|
||||
});
|
||||
|
||||
test("resolvePublicRecords retries fallback geocoding without the unit suffix", async () => {
|
||||
const seenFallbackQueries: string[] = [];
|
||||
const coordinatePayload = {
|
||||
result: {
|
||||
geographies: {
|
||||
States: [{ NAME: "Texas", STUSAB: "TX", STATE: "48" }],
|
||||
Counties: [{ NAME: "Nueces County", COUNTY: "355", GEOID: "48355" }],
|
||||
"2020 Census Blocks": [{ GEOID: "483550031013005" }]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const retryingFetchText = async (url: string): Promise<string> => {
|
||||
if (url.includes("geocoding.geo.census.gov") && url.includes("onelineaddress")) {
|
||||
return JSON.stringify({ result: { addressMatches: [] } });
|
||||
}
|
||||
if (url.includes("nominatim.openstreetmap.org/search")) {
|
||||
const query = new URL(url).searchParams.get("q") || "";
|
||||
seenFallbackQueries.push(query);
|
||||
if (query.includes("APT 235")) {
|
||||
return "[]";
|
||||
}
|
||||
if (query === "1011 Ennis Joslin Rd, Corpus Christi, TX 78412") {
|
||||
return JSON.stringify([
|
||||
{
|
||||
lat: "27.6999080",
|
||||
lon: "-97.3338107",
|
||||
display_name: "Ennis Joslin Road, Corpus Christi, Nueces County, Texas, 78412, United States"
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
if (url.includes("geocoding.geo.census.gov") && url.includes("geographies/coordinates")) {
|
||||
return JSON.stringify(coordinatePayload);
|
||||
}
|
||||
if (url.endsWith("/county-directory/")) {
|
||||
return countyIndexHtml;
|
||||
}
|
||||
if (url.endsWith("/county-directory/nueces.php")) {
|
||||
return countyPageHtml;
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const payload = await resolvePublicRecords(
|
||||
"1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
|
||||
{
|
||||
fetchText: retryingFetchText
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(seenFallbackQueries, [
|
||||
"1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
|
||||
"1011 Ennis Joslin Rd, Corpus Christi, TX 78412"
|
||||
]);
|
||||
assert.equal(payload.county.name, "Nueces County");
|
||||
assert.equal(payload.state.code, "TX");
|
||||
});
|
||||
|
||||
test("resolvePublicRecords enriches official CAD property facts when a supported CAD detail source is available", async () => {
|
||||
const fetchedUrls: string[] = [];
|
||||
const enrichedFetchText = async (url: string): Promise<string> => {
|
||||
fetchedUrls.push(url);
|
||||
|
||||
if (url.includes("geocoding.geo.census.gov")) {
|
||||
return JSON.stringify(geocoderPayload);
|
||||
}
|
||||
if (url.endsWith("/county-directory/")) {
|
||||
return countyIndexHtml;
|
||||
}
|
||||
if (url.endsWith("/county-directory/nueces.php")) {
|
||||
return countyPageHtml.replace(
|
||||
"http://www.ncadistrict.com/",
|
||||
"https://nuecescad.net/"
|
||||
);
|
||||
}
|
||||
if (url === "https://nuecescad.net/") {
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<a href="https://esearch.nuecescad.net/">Property Search</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
if (url === "https://esearch.nuecescad.net/") {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<meta name="search-token" content="token-value|2026-03-28T00:00:00Z" />
|
||||
</head>
|
||||
<body>
|
||||
Property Search
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
if (url.includes("/search/SearchResults?")) {
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
resultsList: [
|
||||
{
|
||||
propertyId: "14069438",
|
||||
ownerName: "Fiorini Family Trust",
|
||||
ownerId: "998877",
|
||||
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
legalDescription: "LOT 4 BLOCK 3 EXAMPLE SUBDIVISION",
|
||||
appraisedValueDisplay: "$141,000",
|
||||
detailUrl: "/property/view/14069438?year=2026"
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
if (url === "https://esearch.nuecescad.net/property/view/14069438?year=2026") {
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<div class="property-summary">
|
||||
<div>Owner Name</div><div>Fiorini Family Trust</div>
|
||||
<div>Account Number</div><div>14069438</div>
|
||||
<div>Situs Address</div><div>4141 Whiteley Dr, Corpus Christi, TX 78418</div>
|
||||
<div>Legal Description</div><div>LOT 4 BLOCK 3 EXAMPLE SUBDIVISION</div>
|
||||
<div>Land Value</div><div>$42,000</div>
|
||||
<div>Improvement Value</div><div>$99,000</div>
|
||||
<div>Market Value</div><div>$141,000</div>
|
||||
<div>Assessed Value</div><div>$141,000</div>
|
||||
<div>Exemptions</div><div>Homestead</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const payload = await resolvePublicRecords("4141 Whiteley Dr, Corpus Christi, TX 78418", {
|
||||
fetchText: enrichedFetchText
|
||||
});
|
||||
|
||||
assert.equal(payload.propertyDetails?.propertyId, "14069438");
|
||||
assert.equal(payload.propertyDetails?.ownerName, "Fiorini Family Trust");
|
||||
assert.equal(payload.propertyDetails?.landValue, 42000);
|
||||
assert.equal(payload.propertyDetails?.improvementValue, 99000);
|
||||
assert.equal(payload.propertyDetails?.assessedTotalValue, 141000);
|
||||
assert.deepEqual(payload.propertyDetails?.exemptions, ["Homestead"]);
|
||||
assert.match(
|
||||
payload.lookupRecommendations.join(" "),
|
||||
/official cad property detail/i
|
||||
);
|
||||
assert.ok(
|
||||
fetchedUrls.some((url) => url.includes("esearch.nuecescad.net/property/view/14069438"))
|
||||
);
|
||||
});
|
||||
|
||||
test("resolvePublicRecords uses formatted Nueces Geographic ID search when a parcel ID is available", async () => {
|
||||
const fetchedUrls: string[] = [];
|
||||
const enrichedFetchText = async (url: string): Promise<string> => {
|
||||
fetchedUrls.push(url);
|
||||
|
||||
if (url.includes("geocoding.geo.census.gov")) {
|
||||
return JSON.stringify(geocoderPayload);
|
||||
}
|
||||
if (url.endsWith("/county-directory/")) {
|
||||
return countyIndexHtml;
|
||||
}
|
||||
if (url.endsWith("/county-directory/nueces.php")) {
|
||||
return countyPageHtml.replace(
|
||||
"http://www.ncadistrict.com/",
|
||||
"https://nuecescad.net/"
|
||||
);
|
||||
}
|
||||
if (url === "https://nuecescad.net/") {
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<a href="https://esearch.nuecescad.net/">Property Search</a>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
if (url === "https://esearch.nuecescad.net/") {
|
||||
return `
|
||||
<html>
|
||||
<head>
|
||||
<meta name="search-token" content="token-value|2026-03-28T00:00:00Z" />
|
||||
</head>
|
||||
<body>
|
||||
Property Search
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
if (url.includes("/search/SearchResults?")) {
|
||||
assert.match(url, /keywords=1234-5678-9012/);
|
||||
return JSON.stringify({
|
||||
success: true,
|
||||
resultsList: [
|
||||
{
|
||||
propertyId: "200016970",
|
||||
ownerName: "NGUYEN TRANG THUY",
|
||||
ownerId: "677681",
|
||||
address: "6702 Everhart Rd Apt T106, Corpus Christi, TX 78413",
|
||||
legalDescription: "UNIT T106 EXAMPLE CONDO",
|
||||
appraisedValueDisplay: "$128,876",
|
||||
detailUrl: "/property/view/200016970?year=2026"
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
if (url === "https://esearch.nuecescad.net/property/view/200016970?year=2026") {
|
||||
return `
|
||||
<html>
|
||||
<body>
|
||||
<div class="property-summary">
|
||||
<div>Owner Name</div><div>NGUYEN TRANG THUY</div>
|
||||
<div>Account Number</div><div>200016970</div>
|
||||
<div>Situs Address</div><div>6702 Everhart Rd Apt T106, Corpus Christi, TX 78413</div>
|
||||
<div>Legal Description</div><div>UNIT T106 EXAMPLE CONDO</div>
|
||||
<div>Land Value</div><div>$20,000</div>
|
||||
<div>Improvement Value</div><div>$108,876</div>
|
||||
<div>Market Value</div><div>$128,876</div>
|
||||
<div>Assessed Value</div><div>$128,876</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
}
|
||||
throw new Error(`Unexpected URL: ${url}`);
|
||||
};
|
||||
|
||||
const payload = await resolvePublicRecords("6702 Everhart Rd APT T106, Corpus Christi, TX 78413", {
|
||||
parcelId: "123456789012",
|
||||
fetchText: enrichedFetchText
|
||||
});
|
||||
|
||||
assert.equal(payload.propertyDetails?.propertyId, "200016970");
|
||||
assert.ok(
|
||||
fetchedUrls.some((url) => url.includes("keywords=1234-5678-9012"))
|
||||
);
|
||||
});
|
||||
109
skills/property-assessor/tests/report-pdf.test.ts
Normal file
109
skills/property-assessor/tests/report-pdf.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { ReportValidationError, renderReportPdf } from "../src/report-pdf.js";
|
||||
|
||||
const samplePayload = {
|
||||
recipientEmails: ["buyer@example.com"],
|
||||
assessmentPurpose: "investment property",
|
||||
reportTitle: "Property Assessment Report",
|
||||
subjectProperty: {
|
||||
address: "4141 Whiteley Dr, Corpus Christi, TX 78418",
|
||||
listingPrice: 149900,
|
||||
propertyType: "Townhouse",
|
||||
beds: 2,
|
||||
baths: 2,
|
||||
squareFeet: 900,
|
||||
yearBuilt: 1978
|
||||
},
|
||||
verdict: {
|
||||
decision: "only below x",
|
||||
fairValueRange: "$132,000 - $138,000",
|
||||
offerGuidance:
|
||||
"Only attractive below ask after HOA, insurance, and medium make-ready assumptions are priced in."
|
||||
},
|
||||
snapshot: ["2 bed / 2 bath coastal townhouse in Flour Bluff."],
|
||||
whatILike: ["Compact layout with usable bedroom count for the size."],
|
||||
whatIDontLike: ["Thin margin at the current ask."],
|
||||
compView: ["Need same-building or local comp confirmation."],
|
||||
carryView: ["Underwrite taxes, HOA, wind/flood exposure, and maintenance together."],
|
||||
risksAndDiligence: ["Confirm reserve strength and special assessment history."],
|
||||
photoReview: {
|
||||
status: "completed",
|
||||
source: "Zillow",
|
||||
attempts: ["Zillow all-photo extractor returned the full 29-photo set."],
|
||||
summary: "Interior reads dated-to-average rather than turnkey."
|
||||
},
|
||||
publicRecords: {
|
||||
jurisdiction: "Nueces Appraisal District",
|
||||
assessedTotalValue: 141000,
|
||||
links: [{ label: "Nueces CAD", url: "http://www.ncadistrict.com/" }]
|
||||
},
|
||||
sourceLinks: [
|
||||
{ label: "Zillow Listing", url: "https://www.zillow.com/homedetails/example" }
|
||||
]
|
||||
};
|
||||
|
||||
test("renderReportPdf writes a non-empty PDF", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `property-assessor-${Date.now()}.pdf`);
|
||||
await renderReportPdf(samplePayload, outputPath);
|
||||
const stat = await fs.promises.stat(outputPath);
|
||||
assert.ok(stat.size > 1000);
|
||||
});
|
||||
|
||||
test("renderReportPdf requires recipient email", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `property-assessor-missing-email-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
renderReportPdf(
|
||||
{
|
||||
...samplePayload,
|
||||
recipientEmails: []
|
||||
},
|
||||
outputPath
|
||||
),
|
||||
ReportValidationError
|
||||
);
|
||||
});
|
||||
|
||||
test("renderReportPdf rejects a preliminary report with pending verdict", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `property-assessor-preliminary-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
renderReportPdf(
|
||||
{
|
||||
...samplePayload,
|
||||
verdict: {
|
||||
decision: "pending",
|
||||
fairValueRange: "Not established",
|
||||
offerGuidance: "Still needs comps and decision-grade analysis."
|
||||
}
|
||||
},
|
||||
outputPath
|
||||
),
|
||||
/decision-grade|preliminary|pending/i
|
||||
);
|
||||
});
|
||||
|
||||
test("renderReportPdf rejects a report when subject-unit photo review is not completed", async () => {
|
||||
const outputPath = path.join(os.tmpdir(), `property-assessor-missing-photos-${Date.now()}.pdf`);
|
||||
await assert.rejects(
|
||||
() =>
|
||||
renderReportPdf(
|
||||
{
|
||||
...samplePayload,
|
||||
photoReview: {
|
||||
status: "not completed",
|
||||
source: "accessible listing-photo source not reliably exposed for unit 235",
|
||||
attempts: ["Zillow and HAR photo review did not complete."],
|
||||
summary: "Condition review is incomplete."
|
||||
}
|
||||
},
|
||||
outputPath
|
||||
),
|
||||
/photo review|decision-grade|incomplete/i
|
||||
);
|
||||
});
|
||||
97
skills/property-assessor/tests/timeout-guards.test.ts
Normal file
97
skills/property-assessor/tests/timeout-guards.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { discoverListingSources } from "../src/listing-discovery.js";
|
||||
import { extractPhotoData } from "../src/photo-review.js";
|
||||
|
||||
test("discoverListingSources times out stalled Zillow and HAR discovery calls", async () => {
|
||||
const result = await discoverListingSources(
|
||||
"1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
|
||||
{
|
||||
timeoutMs: 20,
|
||||
discoverZillowListingFn: async () => await new Promise(() => {}),
|
||||
discoverHarListingFn: async () => await new Promise(() => {})
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.zillowUrl, null);
|
||||
assert.equal(result.harUrl, null);
|
||||
assert.match(result.attempts.join(" "), /zillow discovery timed out/i);
|
||||
assert.match(result.attempts.join(" "), /har discovery timed out/i);
|
||||
});
|
||||
|
||||
test("discoverListingSources starts Zillow and HAR discovery in parallel", async () => {
|
||||
let zillowStarted = false;
|
||||
let harStarted = false;
|
||||
|
||||
const discoveryPromise = discoverListingSources("1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412", {
|
||||
timeoutMs: 100,
|
||||
discoverZillowListingFn: async () => {
|
||||
zillowStarted = true;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
return {
|
||||
source: "zillow",
|
||||
address: "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
|
||||
searchUrl: "https://www.zillow.com/example-search",
|
||||
finalUrl: "https://www.zillow.com/example-search",
|
||||
title: "Example Zillow Search",
|
||||
listingUrl: null,
|
||||
attempts: ["Zillow did not find a confident match."]
|
||||
};
|
||||
},
|
||||
discoverHarListingFn: async () => {
|
||||
harStarted = true;
|
||||
return {
|
||||
source: "har",
|
||||
address: "1011 Ennis Joslin Rd APT 235, Corpus Christi, TX 78412",
|
||||
searchUrl: "https://www.har.com/example-search",
|
||||
finalUrl: "https://www.har.com/example-search",
|
||||
title: "Example HAR Search",
|
||||
listingUrl: "https://www.har.com/homedetail/example/123",
|
||||
attempts: ["HAR found a matching listing quickly."]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
assert.equal(zillowStarted, true);
|
||||
assert.equal(harStarted, true);
|
||||
|
||||
const result = await discoveryPromise;
|
||||
assert.equal(result.harUrl, "https://www.har.com/homedetail/example/123");
|
||||
});
|
||||
|
||||
test("extractPhotoData honors a longer Zillow timeout override", async () => {
|
||||
const result = await extractPhotoData("zillow", "https://www.zillow.com/example", {
|
||||
timeoutMs: 20,
|
||||
zillowTimeoutMs: 80,
|
||||
extractZillowPhotosFn: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
return {
|
||||
source: "zillow",
|
||||
requestedUrl: "https://www.zillow.com/example",
|
||||
finalUrl: "https://www.zillow.com/example",
|
||||
expectedPhotoCount: 1,
|
||||
complete: true,
|
||||
photoCount: 1,
|
||||
imageUrls: ["https://photos.example/1.jpg"],
|
||||
notes: ["Zillow extractor succeeded after a slow page load."]
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(result.source, "zillow");
|
||||
assert.equal(result.photoCount, 1);
|
||||
});
|
||||
|
||||
test("extractPhotoData times out a stalled photo extraction instead of hanging forever", async () => {
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
extractPhotoData("zillow", "https://www.zillow.com/example", {
|
||||
timeoutMs: 20,
|
||||
extractZillowPhotosFn: async () => await new Promise(() => {})
|
||||
}),
|
||||
/timed out/i
|
||||
);
|
||||
});
|
||||
17
skills/property-assessor/tsconfig.json
Normal file
17
skills/property-assessor/tsconfig.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"types": ["node"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
86
skills/us-cpa/README.md
Normal file
86
skills/us-cpa/README.md
Normal file
@@ -0,0 +1,86 @@
|
||||
# us-cpa package
|
||||
|
||||
Standalone Python CLI package for the `us-cpa` skill.
|
||||
|
||||
## Install
|
||||
|
||||
From `skills/us-cpa/`:
|
||||
|
||||
```bash
|
||||
python3 -m ensurepip --upgrade
|
||||
python3 -m pip install --upgrade pip setuptools wheel
|
||||
python3 -m pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
## OpenClaw installation
|
||||
|
||||
Install the skill into the OpenClaw workspace copy, not only in the repo checkout.
|
||||
|
||||
1. Sync the skill into the workspace:
|
||||
|
||||
```bash
|
||||
rsync -a --delete --exclude '.venv' \
|
||||
~/.openclaw/workspace/projects/stef-openclaw-skills/skills/us-cpa/ \
|
||||
~/.openclaw/workspace/skills/us-cpa/
|
||||
```
|
||||
|
||||
2. Create a skill-local virtualenv in the workspace copy:
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/us-cpa
|
||||
python3 -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python3 -m ensurepip --upgrade
|
||||
python3 -m pip install --upgrade pip setuptools wheel
|
||||
python3 -m pip install -e '.[dev]'
|
||||
```
|
||||
|
||||
3. Run the workspace wrapper:
|
||||
|
||||
```bash
|
||||
~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa --help
|
||||
```
|
||||
|
||||
The wrapper now prefers `~/.openclaw/workspace/skills/us-cpa/.venv/bin/python` when present and falls back to `python3` otherwise.
|
||||
|
||||
Keep the `--exclude '.venv'` flag on future syncs, otherwise the workspace virtualenv will be deleted by `rsync --delete`.
|
||||
|
||||
## Run
|
||||
|
||||
Installed entry point:
|
||||
|
||||
```bash
|
||||
us-cpa --help
|
||||
```
|
||||
|
||||
Repo-local wrapper without installation:
|
||||
|
||||
```bash
|
||||
scripts/us-cpa --help
|
||||
```
|
||||
|
||||
OpenClaw workspace wrapper:
|
||||
|
||||
```bash
|
||||
~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa --help
|
||||
```
|
||||
|
||||
Module execution:
|
||||
|
||||
```bash
|
||||
python3 -m us_cpa.cli --help
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
From `skills/us-cpa/`:
|
||||
|
||||
```bash
|
||||
PYTHONPATH=src python3 -m unittest
|
||||
```
|
||||
|
||||
Or with the dev extra installed:
|
||||
|
||||
```bash
|
||||
python -m unittest
|
||||
```
|
||||
77
skills/us-cpa/SKILL.md
Normal file
77
skills/us-cpa/SKILL.md
Normal file
@@ -0,0 +1,77 @@
|
||||
---
|
||||
name: us-cpa
|
||||
description: Use when answering U.S. federal individual tax questions, preparing a federal Form 1040 return package, or reviewing a draft/completed federal individual return.
|
||||
---
|
||||
|
||||
# US CPA
|
||||
|
||||
`us-cpa` is a Python-first federal individual tax workflow skill. The CLI is the canonical engine. Use the skill to classify the request, gather missing inputs, and invoke the CLI.
|
||||
|
||||
## Modes
|
||||
|
||||
- `question`
|
||||
- one-off federal tax question
|
||||
- case folder optional
|
||||
- `prepare`
|
||||
- new or existing return-preparation case
|
||||
- case folder required
|
||||
- `review`
|
||||
- new or existing return-review case
|
||||
- case folder required
|
||||
|
||||
## Agent Workflow
|
||||
|
||||
1. Determine whether the request is:
|
||||
- question-only
|
||||
- a new preparation/review case
|
||||
- work on an existing case
|
||||
2. If the request is `prepare` or `review` and no case folder is supplied:
|
||||
- ask whether to create a new case
|
||||
- ask where to store it
|
||||
3. Use the bundled CLI:
|
||||
|
||||
```bash
|
||||
skills/us-cpa/scripts/us-cpa question --question "What is the standard deduction?" --tax-year 2025
|
||||
skills/us-cpa/scripts/us-cpa question --question "What is the standard deduction?" --tax-year 2025 --style memo --format markdown
|
||||
skills/us-cpa/scripts/us-cpa prepare --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
|
||||
skills/us-cpa/scripts/us-cpa export-efile-ready --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
|
||||
skills/us-cpa/scripts/us-cpa review --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe
|
||||
skills/us-cpa/scripts/us-cpa review --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe --style memo --format markdown
|
||||
skills/us-cpa/scripts/us-cpa extract-docs --tax-year 2025 --case-dir ~/tax-cases/2025-jane-doe --create-case --case-label "Jane Doe" --facts-json ./facts.json
|
||||
```
|
||||
|
||||
4. For `question` mode, do not mechanically repeat the CLI fallback text.
|
||||
- If the CLI returns `analysis.excerpts` with `primaryLawRequired: false`, answer the user directly from those IRS excerpts in your own words.
|
||||
- Cite the specific IRS authorities returned by the CLI.
|
||||
- Only tell the user the question needs deeper legal research when the CLI returns `primaryLawRequired: true` and no relevant IRS excerpts were found.
|
||||
|
||||
When OpenClaw is using the installed workspace copy, the entrypoint is:
|
||||
|
||||
```bash
|
||||
~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa --help
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- federal individual returns only in v1
|
||||
- IRS materials first; escalate to primary law only when needed
|
||||
- stop on conflicting facts and ask the user to resolve the issue before continuing
|
||||
- official IRS PDFs are the target compiled-form artifacts
|
||||
- deterministic field-fill is the preferred render path when the official PDF exposes usable fields
|
||||
- overlay-rendered forms are the fallback and must be flagged for human review
|
||||
|
||||
## Output
|
||||
|
||||
- JSON by default
|
||||
- markdown output available with `--format markdown`
|
||||
- `question` supports `--style conversation|memo`
|
||||
- `fetch-year` downloads the bootstrap IRS form/instruction corpus into `~/.cache/us-cpa` by default
|
||||
- override the cache root with `US_CPA_CACHE_DIR` when you need an isolated run or fixture generation
|
||||
- `extract-docs` creates or opens a case, registers documents, stores facts, extracts machine-usable facts from JSON/text/PDF sources where possible, and stops with a structured issue if facts conflict
|
||||
- `question` now searches the downloaded IRS corpus for relevant authorities and excerpts before escalating to primary-law research
|
||||
- rendered form artifacts prefer fillable-field output when possible and otherwise fall back to overlay output
|
||||
- `prepare` computes the current supported federal 1040 package, preserves fact provenance in the normalized return, and writes normalized return/artifact/report files into the case directory
|
||||
- `export-efile-ready` writes a draft transmission-ready payload without transmitting anything
|
||||
- `review` recomputes the return from case facts, checks artifacts, flags source-fact mismatches and likely omissions, and returns findings-first output in conversation or memo style
|
||||
|
||||
For operator details, limitations, and the planned case structure, see `docs/us-cpa.md`.
|
||||
27
skills/us-cpa/pyproject.toml
Normal file
27
skills/us-cpa/pyproject.toml
Normal file
@@ -0,0 +1,27 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "us-cpa"
|
||||
version = "0.1.0"
|
||||
description = "US federal individual tax workflow CLI for questions, preparation, and review."
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"pypdf>=5.0.0",
|
||||
"reportlab>=4.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
us-cpa = "us_cpa.cli:main"
|
||||
|
||||
[tool.setuptools]
|
||||
package-dir = {"" = "src"}
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
13
skills/us-cpa/scripts/us-cpa
Executable file
13
skills/us-cpa/scripts/us-cpa
Executable file
@@ -0,0 +1,13 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
SKILL_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
PYTHON_BIN="${SKILL_DIR}/.venv/bin/python"
|
||||
export PYTHONPATH="${SKILL_DIR}/src${PYTHONPATH:+:${PYTHONPATH}}"
|
||||
|
||||
if [[ ! -x "${PYTHON_BIN}" ]]; then
|
||||
PYTHON_BIN="python3"
|
||||
fi
|
||||
|
||||
exec "${PYTHON_BIN}" -m us_cpa.cli "$@"
|
||||
2
skills/us-cpa/src/us_cpa/__init__.py
Normal file
2
skills/us-cpa/src/us_cpa/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
"""us-cpa package."""
|
||||
|
||||
202
skills/us-cpa/src/us_cpa/cases.py
Normal file
202
skills/us-cpa/src/us_cpa/cases.py
Normal file
@@ -0,0 +1,202 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from us_cpa.document_extractors import extract_document_facts
|
||||
|
||||
|
||||
CASE_SUBDIRECTORIES = (
|
||||
"input",
|
||||
"extracted",
|
||||
"return",
|
||||
"output",
|
||||
"reports",
|
||||
"issues",
|
||||
"sources",
|
||||
)
|
||||
|
||||
|
||||
def _timestamp() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _sha256_path(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(65536), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
class CaseConflictError(Exception):
|
||||
def __init__(self, issue: dict[str, Any]) -> None:
|
||||
super().__init__(issue["message"])
|
||||
self.issue = issue
|
||||
|
||||
|
||||
@dataclass
|
||||
class CaseManager:
|
||||
case_dir: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.case_dir = self.case_dir.expanduser().resolve()
|
||||
|
||||
@property
|
||||
def manifest_path(self) -> Path:
|
||||
return self.case_dir / "case-manifest.json"
|
||||
|
||||
@property
|
||||
def facts_path(self) -> Path:
|
||||
return self.case_dir / "extracted" / "facts.json"
|
||||
|
||||
@property
|
||||
def issues_path(self) -> Path:
|
||||
return self.case_dir / "issues" / "open-issues.json"
|
||||
|
||||
def create_case(self, *, case_label: str, tax_year: int) -> dict[str, Any]:
|
||||
self.case_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name in CASE_SUBDIRECTORIES:
|
||||
(self.case_dir / name).mkdir(exist_ok=True)
|
||||
|
||||
manifest = {
|
||||
"caseLabel": case_label,
|
||||
"taxYear": tax_year,
|
||||
"createdAt": _timestamp(),
|
||||
"updatedAt": _timestamp(),
|
||||
"status": "open",
|
||||
"documents": [],
|
||||
}
|
||||
self.manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||
if not self.facts_path.exists():
|
||||
self.facts_path.write_text(json.dumps({"facts": {}}, indent=2))
|
||||
if not self.issues_path.exists():
|
||||
self.issues_path.write_text(json.dumps({"issues": []}, indent=2))
|
||||
return manifest
|
||||
|
||||
def load_manifest(self) -> dict[str, Any]:
|
||||
return json.loads(self.manifest_path.read_text())
|
||||
|
||||
def _load_facts(self) -> dict[str, Any]:
|
||||
return json.loads(self.facts_path.read_text())
|
||||
|
||||
def _write_manifest(self, manifest: dict[str, Any]) -> None:
|
||||
manifest["updatedAt"] = _timestamp()
|
||||
self.manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||
|
||||
def _write_facts(self, facts: dict[str, Any]) -> None:
|
||||
self.facts_path.write_text(json.dumps(facts, indent=2))
|
||||
|
||||
def _write_issue(self, issue: dict[str, Any]) -> None:
|
||||
current = json.loads(self.issues_path.read_text())
|
||||
current["issues"].append(issue)
|
||||
self.issues_path.write_text(json.dumps(current, indent=2))
|
||||
|
||||
def _record_fact(
|
||||
self,
|
||||
facts_payload: dict[str, Any],
|
||||
*,
|
||||
field: str,
|
||||
value: Any,
|
||||
source_type: str,
|
||||
source_name: str,
|
||||
tax_year: int,
|
||||
) -> None:
|
||||
existing = facts_payload["facts"].get(field)
|
||||
if existing and existing["value"] != value:
|
||||
issue = {
|
||||
"status": "needs_resolution",
|
||||
"issueType": "fact_conflict",
|
||||
"field": field,
|
||||
"existingValue": existing["value"],
|
||||
"newValue": value,
|
||||
"message": f"Conflicting values for {field}. Resolve before continuing.",
|
||||
"createdAt": _timestamp(),
|
||||
"taxYear": tax_year,
|
||||
}
|
||||
self._write_issue(issue)
|
||||
raise CaseConflictError(issue)
|
||||
|
||||
captured_at = _timestamp()
|
||||
source_entry = {
|
||||
"sourceType": source_type,
|
||||
"sourceName": source_name,
|
||||
"capturedAt": captured_at,
|
||||
}
|
||||
if existing:
|
||||
existing["sources"].append(source_entry)
|
||||
return
|
||||
|
||||
facts_payload["facts"][field] = {
|
||||
"value": value,
|
||||
"sourceType": source_type,
|
||||
"capturedAt": captured_at,
|
||||
"sources": [source_entry],
|
||||
}
|
||||
|
||||
def intake(
|
||||
self,
|
||||
*,
|
||||
tax_year: int,
|
||||
user_facts: dict[str, Any],
|
||||
document_paths: list[Path],
|
||||
) -> dict[str, Any]:
|
||||
manifest = self.load_manifest()
|
||||
if manifest["taxYear"] != tax_year:
|
||||
raise ValueError(
|
||||
f"Case tax year {manifest['taxYear']} does not match requested tax year {tax_year}."
|
||||
)
|
||||
|
||||
registered_documents = []
|
||||
for source_path in document_paths:
|
||||
source_path = source_path.expanduser().resolve()
|
||||
destination = self.case_dir / "input" / source_path.name
|
||||
shutil.copy2(source_path, destination)
|
||||
document_entry = {
|
||||
"name": source_path.name,
|
||||
"sourcePath": str(source_path),
|
||||
"storedPath": str(destination),
|
||||
"sha256": _sha256_path(destination),
|
||||
"registeredAt": _timestamp(),
|
||||
}
|
||||
manifest["documents"].append(document_entry)
|
||||
registered_documents.append(document_entry)
|
||||
|
||||
facts_payload = self._load_facts()
|
||||
for document_entry in registered_documents:
|
||||
extracted = extract_document_facts(Path(document_entry["storedPath"]))
|
||||
document_entry["extractedFacts"] = extracted
|
||||
for field, value in extracted.items():
|
||||
self._record_fact(
|
||||
facts_payload,
|
||||
field=field,
|
||||
value=value,
|
||||
source_type="document_extract",
|
||||
source_name=document_entry["name"],
|
||||
tax_year=tax_year,
|
||||
)
|
||||
|
||||
for field, value in user_facts.items():
|
||||
self._record_fact(
|
||||
facts_payload,
|
||||
field=field,
|
||||
value=value,
|
||||
source_type="user_statement",
|
||||
source_name="interactive-intake",
|
||||
tax_year=tax_year,
|
||||
)
|
||||
|
||||
self._write_manifest(manifest)
|
||||
self._write_facts(facts_payload)
|
||||
return {
|
||||
"status": "accepted",
|
||||
"caseDir": str(self.case_dir),
|
||||
"taxYear": tax_year,
|
||||
"registeredDocuments": registered_documents,
|
||||
"factCount": len(facts_payload["facts"]),
|
||||
}
|
||||
257
skills/us-cpa/src/us_cpa/cli.py
Normal file
257
skills/us-cpa/src/us_cpa/cli.py
Normal file
@@ -0,0 +1,257 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from us_cpa.cases import CaseConflictError, CaseManager
|
||||
from us_cpa.prepare import EfileExporter, PrepareEngine, render_case_forms
|
||||
from us_cpa.questions import QuestionEngine, render_analysis, render_memo
|
||||
from us_cpa.review import ReviewEngine, render_review_memo, render_review_summary
|
||||
from us_cpa.sources import TaxYearCorpus, bootstrap_irs_catalog
|
||||
|
||||
COMMANDS = (
|
||||
"question",
|
||||
"prepare",
|
||||
"review",
|
||||
"fetch-year",
|
||||
"extract-docs",
|
||||
"render-forms",
|
||||
"export-efile-ready",
|
||||
)
|
||||
|
||||
|
||||
def _add_common_arguments(
|
||||
parser: argparse.ArgumentParser, *, include_tax_year: bool = True
|
||||
) -> None:
|
||||
if include_tax_year:
|
||||
parser.add_argument("--tax-year", type=int, default=None)
|
||||
parser.add_argument("--case-dir", default=None)
|
||||
parser.add_argument("--format", choices=("json", "markdown"), default="json")
|
||||
|
||||
|
||||
def _emit(payload: dict[str, Any], output_format: str) -> int:
|
||||
if output_format == "markdown":
|
||||
lines = [f"# {payload['command']}"]
|
||||
for key, value in payload.items():
|
||||
if key == "command":
|
||||
continue
|
||||
lines.append(f"- **{key}**: {value}")
|
||||
print("\n".join(lines))
|
||||
else:
|
||||
print(json.dumps(payload, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
def _require_case_dir(args: argparse.Namespace) -> Path:
|
||||
if not args.case_dir:
|
||||
raise SystemExit("A case directory is required for this command.")
|
||||
return Path(args.case_dir).expanduser().resolve()
|
||||
|
||||
|
||||
def _load_json_file(path_value: str | None) -> dict[str, Any]:
|
||||
if not path_value:
|
||||
return {}
|
||||
return json.loads(Path(path_value).expanduser().resolve().read_text())
|
||||
|
||||
|
||||
def _ensure_question_corpus(corpus: TaxYearCorpus, tax_year: int) -> None:
|
||||
paths = corpus.paths_for_year(tax_year)
|
||||
required_slugs = {item.slug for item in bootstrap_irs_catalog(tax_year)}
|
||||
if not paths.manifest_path.exists():
|
||||
corpus.download_catalog(tax_year, bootstrap_irs_catalog(tax_year))
|
||||
return
|
||||
|
||||
manifest = json.loads(paths.manifest_path.read_text())
|
||||
existing_slugs = {item["slug"] for item in manifest.get("sources", [])}
|
||||
if not required_slugs.issubset(existing_slugs):
|
||||
corpus.download_catalog(tax_year, bootstrap_irs_catalog(tax_year))
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="us-cpa",
|
||||
description="US federal individual tax workflow CLI.",
|
||||
)
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
question = subparsers.add_parser("question", help="Answer a tax question.")
|
||||
_add_common_arguments(question)
|
||||
question.add_argument("--question", required=True)
|
||||
question.add_argument("--style", choices=("conversation", "memo"), default="conversation")
|
||||
|
||||
prepare = subparsers.add_parser("prepare", help="Prepare a return case.")
|
||||
_add_common_arguments(prepare)
|
||||
|
||||
review = subparsers.add_parser("review", help="Review a return case.")
|
||||
_add_common_arguments(review)
|
||||
review.add_argument("--style", choices=("conversation", "memo"), default="conversation")
|
||||
|
||||
fetch_year = subparsers.add_parser(
|
||||
"fetch-year", help="Fetch tax-year forms and instructions."
|
||||
)
|
||||
_add_common_arguments(fetch_year, include_tax_year=False)
|
||||
fetch_year.add_argument("--tax-year", type=int, required=True)
|
||||
|
||||
extract_docs = subparsers.add_parser(
|
||||
"extract-docs", help="Extract facts from case documents."
|
||||
)
|
||||
_add_common_arguments(extract_docs)
|
||||
extract_docs.add_argument("--create-case", action="store_true")
|
||||
extract_docs.add_argument("--case-label")
|
||||
extract_docs.add_argument("--facts-json")
|
||||
extract_docs.add_argument("--input-file", action="append", default=[])
|
||||
|
||||
render_forms = subparsers.add_parser(
|
||||
"render-forms", help="Render compiled IRS forms."
|
||||
)
|
||||
_add_common_arguments(render_forms)
|
||||
|
||||
export_efile = subparsers.add_parser(
|
||||
"export-efile-ready", help="Export an e-file-ready payload."
|
||||
)
|
||||
_add_common_arguments(export_efile)
|
||||
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.command == "question":
|
||||
corpus = TaxYearCorpus()
|
||||
_ensure_question_corpus(corpus, args.tax_year)
|
||||
engine = QuestionEngine(corpus=corpus)
|
||||
case_facts: dict[str, Any] = {}
|
||||
if args.case_dir:
|
||||
manager = CaseManager(Path(args.case_dir))
|
||||
if manager.facts_path.exists():
|
||||
case_facts = {
|
||||
key: value["value"]
|
||||
for key, value in json.loads(manager.facts_path.read_text())["facts"].items()
|
||||
}
|
||||
analysis = engine.answer(
|
||||
question=args.question,
|
||||
tax_year=args.tax_year,
|
||||
case_facts=case_facts,
|
||||
)
|
||||
payload = {
|
||||
"command": "question",
|
||||
"format": args.format,
|
||||
"style": args.style,
|
||||
"taxYear": args.tax_year,
|
||||
"caseDir": args.case_dir,
|
||||
"question": args.question,
|
||||
"status": "answered",
|
||||
"analysis": analysis,
|
||||
}
|
||||
payload["rendered"] = (
|
||||
render_memo(analysis) if args.style == "memo" else render_analysis(analysis)
|
||||
)
|
||||
if args.format == "markdown":
|
||||
print(payload["rendered"])
|
||||
return 0
|
||||
return _emit(payload, args.format)
|
||||
|
||||
if args.command == "extract-docs":
|
||||
case_dir = _require_case_dir(args)
|
||||
manager = CaseManager(case_dir)
|
||||
if args.create_case:
|
||||
if not args.case_label:
|
||||
raise SystemExit("--case-label is required when --create-case is used.")
|
||||
manager.create_case(case_label=args.case_label, tax_year=args.tax_year)
|
||||
elif not manager.manifest_path.exists():
|
||||
raise SystemExit("Case manifest not found. Use --create-case for a new case.")
|
||||
|
||||
try:
|
||||
result = manager.intake(
|
||||
tax_year=args.tax_year,
|
||||
user_facts=_load_json_file(args.facts_json),
|
||||
document_paths=[
|
||||
Path(path_value).expanduser().resolve() for path_value in args.input_file
|
||||
],
|
||||
)
|
||||
except CaseConflictError as exc:
|
||||
print(json.dumps(exc.issue, indent=2))
|
||||
return 1
|
||||
payload = {
|
||||
"command": args.command,
|
||||
"format": args.format,
|
||||
**result,
|
||||
}
|
||||
return _emit(payload, args.format)
|
||||
|
||||
if args.command == "prepare":
|
||||
case_dir = _require_case_dir(args)
|
||||
payload = {
|
||||
"command": args.command,
|
||||
"format": args.format,
|
||||
**PrepareEngine().prepare_case(case_dir),
|
||||
}
|
||||
return _emit(payload, args.format)
|
||||
|
||||
if args.command == "render-forms":
|
||||
case_dir = _require_case_dir(args)
|
||||
manager = CaseManager(case_dir)
|
||||
manifest = manager.load_manifest()
|
||||
normalized = json.loads((case_dir / "return" / "normalized-return.json").read_text())
|
||||
artifacts = render_case_forms(case_dir, TaxYearCorpus(), normalized)
|
||||
payload = {
|
||||
"command": "render-forms",
|
||||
"format": args.format,
|
||||
"taxYear": manifest["taxYear"],
|
||||
"status": "rendered",
|
||||
**artifacts,
|
||||
}
|
||||
return _emit(payload, args.format)
|
||||
|
||||
if args.command == "export-efile-ready":
|
||||
case_dir = _require_case_dir(args)
|
||||
payload = {
|
||||
"command": "export-efile-ready",
|
||||
"format": args.format,
|
||||
**EfileExporter().export_case(case_dir),
|
||||
}
|
||||
return _emit(payload, args.format)
|
||||
|
||||
if args.command == "review":
|
||||
case_dir = _require_case_dir(args)
|
||||
review_payload = ReviewEngine().review_case(case_dir)
|
||||
payload = {
|
||||
"command": "review",
|
||||
"format": args.format,
|
||||
"style": args.style,
|
||||
**review_payload,
|
||||
}
|
||||
payload["rendered"] = (
|
||||
render_review_memo(review_payload)
|
||||
if args.style == "memo"
|
||||
else render_review_summary(review_payload)
|
||||
)
|
||||
if args.format == "markdown":
|
||||
print(payload["rendered"])
|
||||
return 0
|
||||
return _emit(payload, args.format)
|
||||
|
||||
if args.command == "fetch-year":
|
||||
corpus = TaxYearCorpus()
|
||||
manifest = corpus.download_catalog(args.tax_year, bootstrap_irs_catalog(args.tax_year))
|
||||
payload = {
|
||||
"command": "fetch-year",
|
||||
"format": args.format,
|
||||
"taxYear": args.tax_year,
|
||||
"status": "downloaded",
|
||||
"sourceCount": manifest["sourceCount"],
|
||||
"manifestPath": corpus.paths_for_year(args.tax_year).manifest_path.as_posix(),
|
||||
}
|
||||
return _emit(payload, args.format)
|
||||
|
||||
parser.error(f"Unsupported command: {args.command}")
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
74
skills/us-cpa/src/us_cpa/document_extractors.py
Normal file
74
skills/us-cpa/src/us_cpa/document_extractors.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
|
||||
_NUMBER = r"(-?\d+(?:,\d{3})*(?:\.\d+)?)"
|
||||
|
||||
|
||||
def _parse_number(raw: str) -> float:
|
||||
return float(raw.replace(",", ""))
|
||||
|
||||
|
||||
def _extract_text(path: Path) -> str:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix in {".txt", ".md"}:
|
||||
return path.read_text()
|
||||
if suffix == ".pdf":
|
||||
reader = PdfReader(str(path))
|
||||
return "\n".join((page.extract_text() or "") for page in reader.pages)
|
||||
return ""
|
||||
|
||||
|
||||
def _facts_from_text(text: str) -> dict[str, Any]:
|
||||
extracted: dict[str, Any] = {}
|
||||
|
||||
if match := re.search(r"Employee:\s*(.+)", text):
|
||||
extracted["taxpayer.fullName"] = match.group(1).strip()
|
||||
if match := re.search(r"Recipient:\s*(.+)", text):
|
||||
extracted.setdefault("taxpayer.fullName", match.group(1).strip())
|
||||
if match := re.search(r"Box 1 Wages, tips, other compensation\s+" + _NUMBER, text, re.I):
|
||||
extracted["wages"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 2 Federal income tax withheld\s+" + _NUMBER, text, re.I):
|
||||
extracted["federalWithholding"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 16 State wages, tips, etc\.\s+" + _NUMBER, text, re.I):
|
||||
extracted["stateWages"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 17 State income tax\s+" + _NUMBER, text, re.I):
|
||||
extracted["stateWithholding"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 3 Social security wages\s+" + _NUMBER, text, re.I):
|
||||
extracted["socialSecurityWages"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 5 Medicare wages and tips\s+" + _NUMBER, text, re.I):
|
||||
extracted["medicareWages"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 1 Interest Income\s+" + _NUMBER, text, re.I):
|
||||
extracted["taxableInterest"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 1a Total ordinary dividends\s+" + _NUMBER, text, re.I):
|
||||
extracted["ordinaryDividends"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 1 Gross distribution\s+" + _NUMBER, text, re.I):
|
||||
extracted["retirementDistribution"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Box 3 Other income\s+" + _NUMBER, text, re.I):
|
||||
extracted["otherIncome"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Net profit(?: or loss)?\s+" + _NUMBER, text, re.I):
|
||||
extracted["businessIncome"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Adjusted gross income\s+" + _NUMBER, text, re.I):
|
||||
extracted["priorYear.adjustedGrossIncome"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Taxable income\s+" + _NUMBER, text, re.I):
|
||||
extracted["priorYear.taxableIncome"] = _parse_number(match.group(1))
|
||||
if match := re.search(r"Refund\s+" + _NUMBER, text, re.I):
|
||||
extracted["priorYear.refund"] = _parse_number(match.group(1))
|
||||
|
||||
return extracted
|
||||
|
||||
|
||||
def extract_document_facts(path: Path) -> dict[str, Any]:
|
||||
suffix = path.suffix.lower()
|
||||
if suffix == ".json":
|
||||
payload = json.loads(path.read_text())
|
||||
if isinstance(payload, dict):
|
||||
return payload
|
||||
return {}
|
||||
return _facts_from_text(_extract_text(path))
|
||||
79
skills/us-cpa/src/us_cpa/prepare.py
Normal file
79
skills/us-cpa/src/us_cpa/prepare.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from us_cpa.cases import CaseManager
|
||||
from us_cpa.renderers import render_case_forms
|
||||
from us_cpa.returns import normalize_case_facts
|
||||
from us_cpa.sources import TaxYearCorpus
|
||||
|
||||
|
||||
def _load_case_facts(case_dir: Path) -> dict[str, Any]:
|
||||
facts_path = case_dir / "extracted" / "facts.json"
|
||||
payload = json.loads(facts_path.read_text())
|
||||
facts = {key: value["value"] for key, value in payload["facts"].items()}
|
||||
facts["_factMetadata"] = {
|
||||
key: {"sources": value.get("sources", [])} for key, value in payload["facts"].items()
|
||||
}
|
||||
return facts
|
||||
|
||||
|
||||
|
||||
class PrepareEngine:
|
||||
def __init__(self, *, corpus: TaxYearCorpus | None = None) -> None:
|
||||
self.corpus = corpus or TaxYearCorpus()
|
||||
|
||||
def prepare_case(self, case_dir: Path) -> dict[str, Any]:
|
||||
manager = CaseManager(case_dir)
|
||||
manifest = manager.load_manifest()
|
||||
facts = _load_case_facts(manager.case_dir)
|
||||
normalized = normalize_case_facts(facts, manifest["taxYear"])
|
||||
normalized_path = manager.case_dir / "return" / "normalized-return.json"
|
||||
normalized_path.write_text(json.dumps(normalized, indent=2))
|
||||
|
||||
artifacts = render_case_forms(manager.case_dir, self.corpus, normalized)
|
||||
unresolved_issues = json.loads(manager.issues_path.read_text())["issues"]
|
||||
|
||||
summary = {
|
||||
"requiredForms": normalized["requiredForms"],
|
||||
"reviewRequiredArtifacts": [
|
||||
artifact["formCode"] for artifact in artifacts["artifacts"] if artifact["reviewRequired"]
|
||||
],
|
||||
"refund": normalized["totals"]["refund"],
|
||||
"balanceDue": normalized["totals"]["balanceDue"],
|
||||
"unresolvedIssueCount": len(unresolved_issues),
|
||||
}
|
||||
result = {
|
||||
"status": "prepared",
|
||||
"caseDir": str(manager.case_dir),
|
||||
"taxYear": manifest["taxYear"],
|
||||
"normalizedReturnPath": str(normalized_path),
|
||||
"artifactManifestPath": str(manager.case_dir / "output" / "artifacts.json"),
|
||||
"summary": summary,
|
||||
}
|
||||
(manager.case_dir / "reports" / "prepare-summary.json").write_text(json.dumps(result, indent=2))
|
||||
return result
|
||||
|
||||
|
||||
class EfileExporter:
|
||||
def export_case(self, case_dir: Path) -> dict[str, Any]:
|
||||
case_dir = Path(case_dir).expanduser().resolve()
|
||||
normalized = json.loads((case_dir / "return" / "normalized-return.json").read_text())
|
||||
artifacts = json.loads((case_dir / "output" / "artifacts.json").read_text())
|
||||
issues = json.loads((case_dir / "issues" / "open-issues.json").read_text())["issues"]
|
||||
payload = {
|
||||
"status": "draft" if issues or any(a["reviewRequired"] for a in artifacts["artifacts"]) else "ready",
|
||||
"taxYear": normalized["taxYear"],
|
||||
"returnSummary": {
|
||||
"requiredForms": normalized["requiredForms"],
|
||||
"refund": normalized["totals"]["refund"],
|
||||
"balanceDue": normalized["totals"]["balanceDue"],
|
||||
},
|
||||
"attachments": artifacts["artifacts"],
|
||||
"unresolvedIssues": issues,
|
||||
}
|
||||
output_path = case_dir / "output" / "efile-ready.json"
|
||||
output_path.write_text(json.dumps(payload, indent=2))
|
||||
return payload
|
||||
448
skills/us-cpa/src/us_cpa/questions.py
Normal file
448
skills/us-cpa/src/us_cpa/questions.py
Normal file
@@ -0,0 +1,448 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pypdf import PdfReader
|
||||
|
||||
from us_cpa.sources import TaxYearCorpus, build_primary_law_authorities
|
||||
|
||||
|
||||
TOPIC_RULES = [
|
||||
{
|
||||
"issue": "standard_deduction",
|
||||
"keywords": ("standard deduction",),
|
||||
"authority_slugs": ("i1040gi",),
|
||||
"answer_by_status": {
|
||||
"single": "$15,750",
|
||||
"married_filing_jointly": "$31,500",
|
||||
"qualifying_surviving_spouse": "$31,500",
|
||||
"head_of_household": "$23,625",
|
||||
},
|
||||
"summary_template": "{filing_status_label} filers use a {answer} standard deduction for tax year {tax_year}.",
|
||||
"confidence": "high",
|
||||
},
|
||||
{
|
||||
"issue": "schedule_c_required",
|
||||
"keywords": ("schedule c", "sole proprietor", "self-employment"),
|
||||
"authority_slugs": ("f1040sc", "i1040sc"),
|
||||
"answer": "Schedule C is generally required when a taxpayer reports sole proprietorship business income or expenses.",
|
||||
"summary": "Business income and expenses from a sole proprietorship generally belong on Schedule C.",
|
||||
"confidence": "medium",
|
||||
},
|
||||
{
|
||||
"issue": "schedule_d_required",
|
||||
"keywords": ("schedule d", "capital gains"),
|
||||
"authority_slugs": ("f1040sd", "i1040sd", "f8949", "i8949"),
|
||||
"answer": "Schedule D is generally required when a taxpayer reports capital gains or losses, often alongside Form 8949.",
|
||||
"summary": "Capital gains and losses generally flow through Schedule D, with Form 8949 supporting detail when required.",
|
||||
"confidence": "medium",
|
||||
},
|
||||
{
|
||||
"issue": "schedule_e_required",
|
||||
"keywords": ("schedule e", "rental income"),
|
||||
"authority_slugs": ("f1040se", "i1040se"),
|
||||
"answer": "Schedule E is generally required when a taxpayer reports rental real-estate income or expenses.",
|
||||
"summary": "Rental income and expenses generally belong on Schedule E.",
|
||||
"confidence": "medium",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
RISK_BY_CONFIDENCE = {
|
||||
"high": "low",
|
||||
"medium": "medium",
|
||||
"low": "high",
|
||||
}
|
||||
|
||||
|
||||
QUESTION_STOPWORDS = {
|
||||
"a",
|
||||
"also",
|
||||
"am",
|
||||
"an",
|
||||
"and",
|
||||
"are",
|
||||
"as",
|
||||
"at",
|
||||
"be",
|
||||
"before",
|
||||
"but",
|
||||
"by",
|
||||
"can",
|
||||
"considered",
|
||||
"did",
|
||||
"do",
|
||||
"does",
|
||||
"for",
|
||||
"from",
|
||||
"had",
|
||||
"has",
|
||||
"have",
|
||||
"her",
|
||||
"hers",
|
||||
"his",
|
||||
"i",
|
||||
"if",
|
||||
"in",
|
||||
"is",
|
||||
"it",
|
||||
"its",
|
||||
"my",
|
||||
"of",
|
||||
"or",
|
||||
"our",
|
||||
"she",
|
||||
"should",
|
||||
"that",
|
||||
"the",
|
||||
"their",
|
||||
"them",
|
||||
"they",
|
||||
"this",
|
||||
"to",
|
||||
"was",
|
||||
"we",
|
||||
"went",
|
||||
"what",
|
||||
"worked",
|
||||
"would",
|
||||
"year",
|
||||
"you",
|
||||
"your",
|
||||
}
|
||||
|
||||
|
||||
SEARCH_SOURCE_BONUS = {
|
||||
"irs_publication": 30,
|
||||
"irs_instructions": 20,
|
||||
"irs_faq": 10,
|
||||
"irs_form": 0,
|
||||
}
|
||||
|
||||
|
||||
def _normalize_question(question: str) -> str:
|
||||
return question.strip().lower()
|
||||
|
||||
|
||||
def _filing_status_label(status: str) -> str:
|
||||
return status.replace("_", " ").title()
|
||||
|
||||
|
||||
def _question_terms(normalized_question: str) -> list[str]:
|
||||
terms = []
|
||||
for token in re.findall(r"[a-z0-9]+", normalized_question):
|
||||
if len(token) < 3 or token in QUESTION_STOPWORDS or token.isdigit():
|
||||
continue
|
||||
terms.append(token)
|
||||
|
||||
expanded = set(terms)
|
||||
if any(token in expanded for token in {"dependent", "dependents", "daughter", "son", "child", "children"}):
|
||||
expanded.update({"dependent", "qualifying", "child", "support", "residency"})
|
||||
if any(token in expanded for token in {"college", "school", "student", "tuition"}):
|
||||
expanded.update({"student", "school", "education", "temporary", "absence"})
|
||||
|
||||
return sorted(expanded)
|
||||
|
||||
|
||||
def _load_searchable_pages(path: Path) -> list[str]:
|
||||
payload = path.read_bytes()
|
||||
if payload.startswith(b"%PDF"):
|
||||
try:
|
||||
reader = PdfReader(path)
|
||||
pages = []
|
||||
for page in reader.pages:
|
||||
text = page.extract_text() or ""
|
||||
if text.strip():
|
||||
pages.append(text)
|
||||
if pages:
|
||||
return pages
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
decoded = payload.decode("utf-8", errors="ignore")
|
||||
except Exception:
|
||||
return []
|
||||
return [decoded] if decoded.strip() else []
|
||||
|
||||
|
||||
def _build_excerpt(text: str, terms: list[str], *, width: int = 420) -> str:
|
||||
lowered = text.lower()
|
||||
first_index = None
|
||||
for term in terms:
|
||||
idx = lowered.find(term)
|
||||
if idx >= 0 and (first_index is None or idx < first_index):
|
||||
first_index = idx
|
||||
if first_index is None:
|
||||
cleaned = " ".join(text.split())
|
||||
return cleaned[:width]
|
||||
|
||||
start = max(0, first_index - 120)
|
||||
end = min(len(text), first_index + width)
|
||||
cleaned = " ".join(text[start:end].split())
|
||||
return cleaned
|
||||
|
||||
|
||||
def _rank_research_hits(manifest: dict[str, Any], normalized_question: str) -> list[dict[str, Any]]:
|
||||
terms = _question_terms(normalized_question)
|
||||
if not terms:
|
||||
return []
|
||||
|
||||
hits: list[dict[str, Any]] = []
|
||||
for source in manifest["sources"]:
|
||||
path = Path(source["localPath"])
|
||||
if not path.exists():
|
||||
continue
|
||||
pages = _load_searchable_pages(path)
|
||||
for page_number, text in enumerate(pages, start=1):
|
||||
lowered = text.lower()
|
||||
matched_terms = [term for term in terms if term in lowered]
|
||||
if not matched_terms:
|
||||
continue
|
||||
score = (
|
||||
len(matched_terms) * 10
|
||||
+ SEARCH_SOURCE_BONUS.get(source["sourceClass"], 0)
|
||||
- int(source["authorityRank"])
|
||||
)
|
||||
hits.append(
|
||||
{
|
||||
"slug": source["slug"],
|
||||
"title": source["title"],
|
||||
"sourceClass": source["sourceClass"],
|
||||
"url": source["url"],
|
||||
"localPath": source["localPath"],
|
||||
"authorityRank": source["authorityRank"],
|
||||
"page": page_number,
|
||||
"score": score,
|
||||
"matchedTerms": matched_terms,
|
||||
"excerpt": _build_excerpt(text, matched_terms),
|
||||
}
|
||||
)
|
||||
|
||||
hits.sort(key=lambda item: (-item["score"], item["authorityRank"], item["slug"], item["page"]))
|
||||
return hits[:5]
|
||||
|
||||
|
||||
FILING_STATUS_PATTERNS = (
|
||||
(("qualifying surviving spouse",), "qualifying_surviving_spouse"),
|
||||
(("qualifying widow",), "qualifying_surviving_spouse"),
|
||||
(("qualifying widower",), "qualifying_surviving_spouse"),
|
||||
(("surviving spouse",), "qualifying_surviving_spouse"),
|
||||
(("married filing jointly",), "married_filing_jointly"),
|
||||
(("mfj",), "married_filing_jointly"),
|
||||
(("head of household",), "head_of_household"),
|
||||
(("hoh",), "head_of_household"),
|
||||
(("married filing separately",), "married_filing_separately"),
|
||||
(("mfs",), "married_filing_separately"),
|
||||
(("single",), "single"),
|
||||
)
|
||||
|
||||
|
||||
def _infer_filing_status(normalized_question: str, case_facts: dict[str, Any]) -> str:
|
||||
if "filingStatus" in case_facts:
|
||||
return case_facts["filingStatus"]
|
||||
|
||||
for patterns, filing_status in FILING_STATUS_PATTERNS:
|
||||
if all(pattern in normalized_question for pattern in patterns):
|
||||
return filing_status
|
||||
|
||||
return "single"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestionEngine:
|
||||
corpus: TaxYearCorpus
|
||||
|
||||
def _manifest(self, tax_year: int) -> dict[str, Any]:
|
||||
path = self.corpus.paths_for_year(tax_year).manifest_path
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Tax year {tax_year} corpus not found at {path}. Run fetch-year first."
|
||||
)
|
||||
return json.loads(path.read_text())
|
||||
|
||||
def _authorities_for(self, manifest: dict[str, Any], slugs: tuple[str, ...]) -> list[dict[str, Any]]:
|
||||
found = []
|
||||
sources = {item["slug"]: item for item in manifest["sources"]}
|
||||
for slug in slugs:
|
||||
if slug in sources:
|
||||
source = sources[slug]
|
||||
found.append(
|
||||
{
|
||||
"slug": source["slug"],
|
||||
"title": source["title"],
|
||||
"sourceClass": source["sourceClass"],
|
||||
"url": source["url"],
|
||||
"localPath": source["localPath"],
|
||||
"authorityRank": source["authorityRank"],
|
||||
}
|
||||
)
|
||||
return found
|
||||
|
||||
def answer(self, *, question: str, tax_year: int, case_facts: dict[str, Any]) -> dict[str, Any]:
|
||||
manifest = self._manifest(tax_year)
|
||||
normalized = _normalize_question(question)
|
||||
facts_used = [{"field": key, "value": value} for key, value in sorted(case_facts.items())]
|
||||
|
||||
for rule in TOPIC_RULES:
|
||||
if all(keyword in normalized for keyword in rule["keywords"]):
|
||||
authorities = self._authorities_for(manifest, rule["authority_slugs"])
|
||||
if rule["issue"] == "standard_deduction":
|
||||
filing_status = _infer_filing_status(normalized, case_facts)
|
||||
answer = rule["answer_by_status"].get(filing_status, rule["answer_by_status"]["single"])
|
||||
summary = rule["summary_template"].format(
|
||||
filing_status_label=_filing_status_label(filing_status),
|
||||
answer=answer,
|
||||
tax_year=tax_year,
|
||||
)
|
||||
else:
|
||||
answer = rule["answer"]
|
||||
summary = rule["summary"]
|
||||
|
||||
return {
|
||||
"issue": rule["issue"],
|
||||
"taxYear": tax_year,
|
||||
"factsUsed": facts_used,
|
||||
"missingFacts": [],
|
||||
"authorities": authorities,
|
||||
"conclusion": {"answer": answer, "summary": summary},
|
||||
"confidence": rule["confidence"],
|
||||
"riskLevel": RISK_BY_CONFIDENCE[rule["confidence"]],
|
||||
"followUpQuestions": [],
|
||||
"primaryLawRequired": False,
|
||||
"excerpts": [],
|
||||
}
|
||||
|
||||
research_hits = _rank_research_hits(manifest, normalized)
|
||||
if research_hits:
|
||||
authorities = []
|
||||
seen = set()
|
||||
for hit in research_hits:
|
||||
if hit["slug"] in seen:
|
||||
continue
|
||||
authorities.append(
|
||||
{
|
||||
"slug": hit["slug"],
|
||||
"title": hit["title"],
|
||||
"sourceClass": hit["sourceClass"],
|
||||
"url": hit["url"],
|
||||
"localPath": hit["localPath"],
|
||||
"authorityRank": hit["authorityRank"],
|
||||
}
|
||||
)
|
||||
seen.add(hit["slug"])
|
||||
|
||||
return {
|
||||
"issue": "irs_corpus_research",
|
||||
"taxYear": tax_year,
|
||||
"factsUsed": facts_used,
|
||||
"missingFacts": [],
|
||||
"authorities": authorities,
|
||||
"excerpts": [
|
||||
{
|
||||
"slug": hit["slug"],
|
||||
"title": hit["title"],
|
||||
"page": hit["page"],
|
||||
"matchedTerms": hit["matchedTerms"],
|
||||
"excerpt": hit["excerpt"],
|
||||
}
|
||||
for hit in research_hits
|
||||
],
|
||||
"conclusion": {
|
||||
"answer": "Relevant IRS authorities were found in the downloaded tax-year corpus. Answer from those authorities directly, and only escalate further if the cited passages are still insufficient.",
|
||||
"summary": "Relevant IRS materials in the cached tax-year corpus address this question. Use the cited passages below to answer it directly.",
|
||||
},
|
||||
"confidence": "medium",
|
||||
"riskLevel": "medium",
|
||||
"followUpQuestions": [],
|
||||
"primaryLawRequired": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"issue": "requires_primary_law_escalation",
|
||||
"taxYear": tax_year,
|
||||
"factsUsed": facts_used,
|
||||
"missingFacts": [
|
||||
"Internal Revenue Code or Treasury regulation analysis is required before answering this question confidently."
|
||||
],
|
||||
"authorities": build_primary_law_authorities(question),
|
||||
"conclusion": {
|
||||
"answer": "Insufficient IRS-form and instruction support for a confident answer.",
|
||||
"summary": "This question needs primary-law analysis before a reliable answer can be given.",
|
||||
},
|
||||
"confidence": "low",
|
||||
"riskLevel": "high",
|
||||
"followUpQuestions": [
|
||||
"What facts drive the section-level issue?",
|
||||
"Is there an existing return position or drafted treatment to review?",
|
||||
],
|
||||
"primaryLawRequired": True,
|
||||
"excerpts": [],
|
||||
}
|
||||
|
||||
|
||||
def render_analysis(analysis: dict[str, Any]) -> str:
|
||||
lines = [analysis["conclusion"]["summary"]]
|
||||
lines.append(
|
||||
f"Confidence: {analysis['confidence']}. Risk: {analysis['riskLevel']}."
|
||||
)
|
||||
if analysis["factsUsed"]:
|
||||
facts = ", ".join(f"{item['field']}={item['value']}" for item in analysis["factsUsed"])
|
||||
lines.append(f"Facts used: {facts}.")
|
||||
if analysis["authorities"]:
|
||||
titles = "; ".join(item["title"] for item in analysis["authorities"])
|
||||
lines.append(f"Authorities: {titles}.")
|
||||
if analysis.get("excerpts"):
|
||||
excerpt_lines = []
|
||||
for item in analysis["excerpts"][:3]:
|
||||
excerpt_lines.append(f"{item['title']} p.{item['page']}: {item['excerpt']}")
|
||||
lines.append(f"Excerpts: {' | '.join(excerpt_lines)}")
|
||||
if analysis["missingFacts"]:
|
||||
lines.append(f"Open items: {' '.join(analysis['missingFacts'])}")
|
||||
return " ".join(lines)
|
||||
|
||||
|
||||
def render_memo(analysis: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
"# Tax Memo",
|
||||
"",
|
||||
f"## Issue\n{analysis['issue']}",
|
||||
"",
|
||||
"## Facts",
|
||||
]
|
||||
if analysis["factsUsed"]:
|
||||
for item in analysis["factsUsed"]:
|
||||
lines.append(f"- {item['field']}: {item['value']}")
|
||||
else:
|
||||
lines.append("- No case-specific facts supplied.")
|
||||
lines.extend(["", "## Authorities"])
|
||||
if analysis["authorities"]:
|
||||
for authority in analysis["authorities"]:
|
||||
lines.append(f"- {authority['title']}")
|
||||
else:
|
||||
lines.append("- Primary-law escalation required.")
|
||||
if analysis.get("excerpts"):
|
||||
lines.extend(["", "## IRS Excerpts"])
|
||||
for item in analysis["excerpts"]:
|
||||
lines.append(f"- {item['title']} (page {item['page']}): {item['excerpt']}")
|
||||
lines.extend(
|
||||
[
|
||||
"",
|
||||
"## Analysis",
|
||||
analysis["conclusion"]["summary"],
|
||||
f"Confidence: {analysis['confidence']}",
|
||||
f"Risk level: {analysis['riskLevel']}",
|
||||
"",
|
||||
"## Conclusion",
|
||||
analysis["conclusion"]["answer"],
|
||||
]
|
||||
)
|
||||
if analysis["missingFacts"]:
|
||||
lines.extend(["", "## Open Items"])
|
||||
for item in analysis["missingFacts"]:
|
||||
lines.append(f"- {item}")
|
||||
return "\n".join(lines)
|
||||
120
skills/us-cpa/src/us_cpa/renderers.py
Normal file
120
skills/us-cpa/src/us_cpa/renderers.py
Normal file
@@ -0,0 +1,120 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from us_cpa.sources import TaxYearCorpus
|
||||
|
||||
|
||||
FORM_TEMPLATES = {
|
||||
"f1040": "f1040",
|
||||
"f1040sb": "f1040sb",
|
||||
"f1040sc": "f1040sc",
|
||||
"f1040se": "f1040se",
|
||||
"f1040s1": "f1040s1",
|
||||
}
|
||||
|
||||
|
||||
OVERLAY_FIELDS = {
|
||||
"f1040": [
|
||||
(72, 725, lambda data: f"Taxpayer: {data['taxpayer']['fullName']}"),
|
||||
(72, 705, lambda data: f"Filing status: {data['filingStatus']}"),
|
||||
(72, 685, lambda data: f"Wages: {data['income']['wages']:.2f}"),
|
||||
(72, 665, lambda data: f"Taxable interest: {data['income']['taxableInterest']:.2f}"),
|
||||
(72, 645, lambda data: f"AGI: {data['totals']['adjustedGrossIncome']:.2f}"),
|
||||
(72, 625, lambda data: f"Standard deduction: {data['deductions']['standardDeduction']:.2f}"),
|
||||
(72, 605, lambda data: f"Taxable income: {data['totals']['taxableIncome']:.2f}"),
|
||||
(72, 585, lambda data: f"Total tax: {data['taxes']['totalTax']:.2f}"),
|
||||
(72, 565, lambda data: f"Withholding: {data['payments']['federalWithholding']:.2f}"),
|
||||
(72, 545, lambda data: f"Refund: {data['totals']['refund']:.2f}"),
|
||||
(72, 525, lambda data: f"Balance due: {data['totals']['balanceDue']:.2f}"),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
FIELD_FILL_VALUES = {
|
||||
"f1040": lambda data: {
|
||||
"taxpayer_full_name": data["taxpayer"]["fullName"],
|
||||
"filing_status": data["filingStatus"],
|
||||
"wages": f"{data['income']['wages']:.2f}",
|
||||
"taxable_interest": f"{data['income']['taxableInterest']:.2f}",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _field_fill_page(template_path: Path, output_path: Path, form_code: str, normalized: dict[str, Any]) -> bool:
|
||||
reader = PdfReader(str(template_path))
|
||||
fields = reader.get_fields() or {}
|
||||
values = FIELD_FILL_VALUES.get(form_code, lambda _: {})(normalized)
|
||||
matched = {key: value for key, value in values.items() if key in fields}
|
||||
if not matched:
|
||||
return False
|
||||
|
||||
writer = PdfWriter(clone_from=str(template_path))
|
||||
writer.update_page_form_field_values(writer.pages[0], matched, auto_regenerate=False)
|
||||
writer.set_need_appearances_writer()
|
||||
with output_path.open("wb") as handle:
|
||||
writer.write(handle)
|
||||
return True
|
||||
|
||||
|
||||
def _overlay_page(template_path: Path, output_path: Path, form_code: str, normalized: dict[str, Any]) -> None:
|
||||
reader = PdfReader(str(template_path))
|
||||
writer = PdfWriter(clone_from=str(template_path))
|
||||
|
||||
page = writer.pages[0]
|
||||
width = float(page.mediabox.width)
|
||||
height = float(page.mediabox.height)
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer, pagesize=(width, height))
|
||||
for x, y, getter in OVERLAY_FIELDS.get(form_code, []):
|
||||
pdf.drawString(x, y, getter(normalized))
|
||||
pdf.save()
|
||||
buffer.seek(0)
|
||||
overlay = PdfReader(buffer)
|
||||
page.merge_page(overlay.pages[0])
|
||||
with output_path.open("wb") as handle:
|
||||
writer.write(handle)
|
||||
|
||||
|
||||
def render_case_forms(case_dir: Path, corpus: TaxYearCorpus, normalized: dict[str, Any]) -> dict[str, Any]:
|
||||
output_dir = case_dir / "output" / "forms"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
irs_dir = corpus.paths_for_year(normalized["taxYear"]).irs_dir
|
||||
|
||||
artifacts = []
|
||||
for form_code in normalized["requiredForms"]:
|
||||
template_slug = FORM_TEMPLATES.get(form_code)
|
||||
if template_slug is None:
|
||||
continue
|
||||
template_path = irs_dir / f"{template_slug}.pdf"
|
||||
output_path = output_dir / f"{form_code}.pdf"
|
||||
render_method = "overlay"
|
||||
review_required = True
|
||||
if _field_fill_page(template_path, output_path, form_code, normalized):
|
||||
render_method = "field_fill"
|
||||
review_required = False
|
||||
else:
|
||||
_overlay_page(template_path, output_path, form_code, normalized)
|
||||
artifacts.append(
|
||||
{
|
||||
"formCode": form_code,
|
||||
"templatePath": str(template_path),
|
||||
"outputPath": str(output_path),
|
||||
"renderMethod": render_method,
|
||||
"reviewRequired": review_required,
|
||||
}
|
||||
)
|
||||
|
||||
artifact_manifest = {
|
||||
"taxYear": normalized["taxYear"],
|
||||
"artifactCount": len(artifacts),
|
||||
"artifacts": artifacts,
|
||||
}
|
||||
(case_dir / "output" / "artifacts.json").write_text(json.dumps(artifact_manifest, indent=2))
|
||||
return artifact_manifest
|
||||
194
skills/us-cpa/src/us_cpa/returns.py
Normal file
194
skills/us-cpa/src/us_cpa/returns.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from us_cpa.tax_years import tax_year_rules
|
||||
|
||||
|
||||
def _as_float(value: Any) -> float:
|
||||
if value in (None, ""):
|
||||
return 0.0
|
||||
return float(value)
|
||||
|
||||
|
||||
def _fact_metadata(facts: dict[str, Any]) -> dict[str, Any]:
|
||||
return facts.get("_factMetadata", {})
|
||||
|
||||
|
||||
def _provenance_for(field: str, metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
entry = metadata.get(field, {})
|
||||
return {"sources": list(entry.get("sources", []))}
|
||||
|
||||
|
||||
def tax_on_ordinary_income(amount: float, filing_status: str, tax_year: int) -> float:
|
||||
taxable = max(0.0, amount)
|
||||
brackets = tax_year_rules(tax_year)["ordinaryIncomeBrackets"][filing_status]
|
||||
lower = 0.0
|
||||
tax = 0.0
|
||||
for upper, rate in brackets:
|
||||
if taxable <= lower:
|
||||
break
|
||||
portion = min(taxable, upper) - lower
|
||||
tax += portion * rate
|
||||
lower = upper
|
||||
return round(tax, 2)
|
||||
|
||||
|
||||
def resolve_required_forms(normalized: dict[str, Any]) -> list[str]:
|
||||
forms = ["f1040"]
|
||||
if normalized["income"]["taxableInterest"] > 1500:
|
||||
forms.append("f1040sb")
|
||||
if normalized["income"]["businessIncome"] != 0:
|
||||
forms.extend(["f1040sc", "f1040sse", "f1040s1", "f8995"])
|
||||
if normalized["income"]["capitalGainLoss"] != 0:
|
||||
forms.extend(["f1040sd", "f8949"])
|
||||
if normalized["income"]["rentalIncome"] != 0:
|
||||
forms.extend(["f1040se", "f1040s1"])
|
||||
if normalized["deductions"]["deductionType"] == "itemized":
|
||||
forms.append("f1040sa")
|
||||
if normalized["adjustments"]["hsaContribution"] != 0:
|
||||
forms.append("f8889")
|
||||
if normalized["credits"]["educationCredit"] != 0:
|
||||
forms.append("f8863")
|
||||
if normalized["credits"]["foreignTaxCredit"] != 0:
|
||||
forms.append("f1116")
|
||||
if normalized["business"]["qualifiedBusinessIncome"] != 0 and "f8995" not in forms:
|
||||
forms.append("f8995")
|
||||
if normalized["basis"]["traditionalIraBasis"] != 0:
|
||||
forms.append("f8606")
|
||||
if normalized["taxes"]["additionalMedicareTax"] != 0:
|
||||
forms.append("f8959")
|
||||
if normalized["taxes"]["netInvestmentIncomeTax"] != 0:
|
||||
forms.append("f8960")
|
||||
if normalized["taxes"]["alternativeMinimumTax"] != 0:
|
||||
forms.append("f6251")
|
||||
if normalized["taxes"]["additionalTaxPenalty"] != 0:
|
||||
forms.append("f5329")
|
||||
if normalized["credits"]["energyCredit"] != 0:
|
||||
forms.append("f5695")
|
||||
if normalized["depreciation"]["depreciationExpense"] != 0:
|
||||
forms.append("f4562")
|
||||
if normalized["assetSales"]["section1231GainLoss"] != 0:
|
||||
forms.append("f4797")
|
||||
return list(dict.fromkeys(forms))
|
||||
|
||||
|
||||
def normalize_case_facts(facts: dict[str, Any], tax_year: int) -> dict[str, Any]:
|
||||
rules = tax_year_rules(tax_year)
|
||||
metadata = _fact_metadata(facts)
|
||||
filing_status = facts.get("filingStatus", "single")
|
||||
wages = _as_float(facts.get("wages"))
|
||||
interest = _as_float(facts.get("taxableInterest"))
|
||||
business_income = _as_float(facts.get("businessIncome"))
|
||||
capital_gain_loss = _as_float(facts.get("capitalGainLoss"))
|
||||
rental_income = _as_float(facts.get("rentalIncome"))
|
||||
withholding = _as_float(facts.get("federalWithholding"))
|
||||
itemized_deductions = _as_float(facts.get("itemizedDeductions"))
|
||||
hsa_contribution = _as_float(facts.get("hsaContribution"))
|
||||
education_credit = _as_float(facts.get("educationCredit"))
|
||||
foreign_tax_credit = _as_float(facts.get("foreignTaxCredit"))
|
||||
qualified_business_income = _as_float(facts.get("qualifiedBusinessIncome"))
|
||||
traditional_ira_basis = _as_float(facts.get("traditionalIraBasis"))
|
||||
additional_medicare_tax = _as_float(facts.get("additionalMedicareTax"))
|
||||
net_investment_income_tax = _as_float(facts.get("netInvestmentIncomeTax"))
|
||||
alternative_minimum_tax = _as_float(facts.get("alternativeMinimumTax"))
|
||||
additional_tax_penalty = _as_float(facts.get("additionalTaxPenalty"))
|
||||
energy_credit = _as_float(facts.get("energyCredit"))
|
||||
depreciation_expense = _as_float(facts.get("depreciationExpense"))
|
||||
section1231_gain_loss = _as_float(facts.get("section1231GainLoss"))
|
||||
|
||||
adjusted_gross_income = wages + interest + business_income + capital_gain_loss + rental_income
|
||||
standard_deduction = rules["standardDeduction"][filing_status]
|
||||
deduction_type = "itemized" if itemized_deductions > standard_deduction else "standard"
|
||||
deduction_amount = itemized_deductions if deduction_type == "itemized" else standard_deduction
|
||||
taxable_income = max(0.0, adjusted_gross_income - deduction_amount)
|
||||
income_tax = tax_on_ordinary_income(taxable_income, filing_status, tax_year)
|
||||
self_employment_tax = round(max(0.0, business_income) * 0.9235 * 0.153, 2)
|
||||
total_tax = round(
|
||||
income_tax
|
||||
+ self_employment_tax
|
||||
+ additional_medicare_tax
|
||||
+ net_investment_income_tax
|
||||
+ alternative_minimum_tax
|
||||
+ additional_tax_penalty,
|
||||
2,
|
||||
)
|
||||
total_payments = withholding
|
||||
total_credits = round(education_credit + foreign_tax_credit + energy_credit, 2)
|
||||
refund = round(max(0.0, total_payments + total_credits - total_tax), 2)
|
||||
balance_due = round(max(0.0, total_tax - total_payments - total_credits), 2)
|
||||
|
||||
normalized = {
|
||||
"taxYear": tax_year,
|
||||
"taxpayer": {
|
||||
"fullName": facts.get("taxpayer.fullName", "Unknown Taxpayer"),
|
||||
},
|
||||
"spouse": {
|
||||
"fullName": facts.get("spouse.fullName", ""),
|
||||
},
|
||||
"dependents": list(facts.get("dependents", [])),
|
||||
"filingStatus": filing_status,
|
||||
"income": {
|
||||
"wages": wages,
|
||||
"taxableInterest": interest,
|
||||
"businessIncome": business_income,
|
||||
"capitalGainLoss": capital_gain_loss,
|
||||
"rentalIncome": rental_income,
|
||||
},
|
||||
"adjustments": {
|
||||
"hsaContribution": hsa_contribution,
|
||||
},
|
||||
"payments": {
|
||||
"federalWithholding": withholding,
|
||||
},
|
||||
"deductions": {
|
||||
"standardDeduction": standard_deduction,
|
||||
"itemizedDeductions": itemized_deductions,
|
||||
"deductionType": deduction_type,
|
||||
"deductionAmount": deduction_amount,
|
||||
},
|
||||
"credits": {
|
||||
"educationCredit": education_credit,
|
||||
"foreignTaxCredit": foreign_tax_credit,
|
||||
"energyCredit": energy_credit,
|
||||
},
|
||||
"taxes": {
|
||||
"incomeTax": income_tax,
|
||||
"selfEmploymentTax": self_employment_tax,
|
||||
"additionalMedicareTax": additional_medicare_tax,
|
||||
"netInvestmentIncomeTax": net_investment_income_tax,
|
||||
"alternativeMinimumTax": alternative_minimum_tax,
|
||||
"additionalTaxPenalty": additional_tax_penalty,
|
||||
"totalTax": total_tax,
|
||||
},
|
||||
"business": {
|
||||
"qualifiedBusinessIncome": qualified_business_income,
|
||||
},
|
||||
"basis": {
|
||||
"traditionalIraBasis": traditional_ira_basis,
|
||||
},
|
||||
"depreciation": {
|
||||
"depreciationExpense": depreciation_expense,
|
||||
},
|
||||
"assetSales": {
|
||||
"section1231GainLoss": section1231_gain_loss,
|
||||
},
|
||||
"totals": {
|
||||
"adjustedGrossIncome": round(adjusted_gross_income, 2),
|
||||
"taxableIncome": round(taxable_income, 2),
|
||||
"totalPayments": round(total_payments, 2),
|
||||
"totalCredits": total_credits,
|
||||
"refund": refund,
|
||||
"balanceDue": balance_due,
|
||||
},
|
||||
"provenance": {
|
||||
"income.wages": _provenance_for("wages", metadata),
|
||||
"income.taxableInterest": _provenance_for("taxableInterest", metadata),
|
||||
"income.businessIncome": _provenance_for("businessIncome", metadata),
|
||||
"income.capitalGainLoss": _provenance_for("capitalGainLoss", metadata),
|
||||
"income.rentalIncome": _provenance_for("rentalIncome", metadata),
|
||||
"payments.federalWithholding": _provenance_for("federalWithholding", metadata),
|
||||
},
|
||||
}
|
||||
normalized["requiredForms"] = resolve_required_forms(normalized)
|
||||
return normalized
|
||||
162
skills/us-cpa/src/us_cpa/review.py
Normal file
162
skills/us-cpa/src/us_cpa/review.py
Normal file
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from us_cpa.returns import normalize_case_facts
|
||||
from us_cpa.sources import TaxYearCorpus
|
||||
|
||||
|
||||
def _severity_rank(severity: str) -> int:
|
||||
return {"high": 0, "medium": 1, "low": 2}[severity]
|
||||
|
||||
|
||||
class ReviewEngine:
|
||||
def __init__(self, *, corpus: TaxYearCorpus | None = None) -> None:
|
||||
self.corpus = corpus or TaxYearCorpus()
|
||||
|
||||
def review_case(self, case_dir: Path) -> dict[str, Any]:
|
||||
case_dir = Path(case_dir).expanduser().resolve()
|
||||
manifest = json.loads((case_dir / "case-manifest.json").read_text())
|
||||
stored_return = json.loads((case_dir / "return" / "normalized-return.json").read_text())
|
||||
facts_payload = json.loads((case_dir / "extracted" / "facts.json").read_text())
|
||||
facts = {key: value["value"] for key, value in facts_payload["facts"].items()}
|
||||
facts["_factMetadata"] = {
|
||||
key: {"sources": value.get("sources", [])} for key, value in facts_payload["facts"].items()
|
||||
}
|
||||
recomputed = normalize_case_facts(facts, manifest["taxYear"])
|
||||
artifacts_payload = json.loads((case_dir / "output" / "artifacts.json").read_text())
|
||||
|
||||
findings: list[dict[str, Any]] = []
|
||||
if stored_return["totals"]["adjustedGrossIncome"] != recomputed["totals"]["adjustedGrossIncome"]:
|
||||
findings.append(
|
||||
{
|
||||
"severity": "high",
|
||||
"title": "Adjusted gross income mismatch",
|
||||
"explanation": "Stored adjusted gross income does not match the recomputed return from case facts.",
|
||||
"suggestedAction": f"Update AGI to {recomputed['totals']['adjustedGrossIncome']:.2f} on Form 1040 line 11.",
|
||||
"authorities": [
|
||||
{"title": "Instructions for Form 1040 and Schedules 1-3", "sourceClass": "irs_instructions"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
for field, label in (
|
||||
("wages", "wages"),
|
||||
("taxableInterest", "taxable interest"),
|
||||
("businessIncome", "business income"),
|
||||
("capitalGainLoss", "capital gains or losses"),
|
||||
("rentalIncome", "rental income"),
|
||||
):
|
||||
stored_value = stored_return["income"].get(field, 0.0)
|
||||
recomputed_value = recomputed["income"].get(field, 0.0)
|
||||
sources = recomputed.get("provenance", {}).get(f"income.{field}", {}).get("sources", [])
|
||||
has_document_source = any(item.get("sourceType") == "document_extract" for item in sources)
|
||||
if stored_value != recomputed_value:
|
||||
findings.append(
|
||||
{
|
||||
"severity": "high" if has_document_source else "medium",
|
||||
"title": f"Source fact mismatch for {label}",
|
||||
"explanation": f"Stored return reports {stored_value:.2f} for {label}, but case facts support {recomputed_value:.2f}.",
|
||||
"suggestedAction": f"Reconcile {label} to {recomputed_value:.2f} before treating the return as final.",
|
||||
"authorities": [
|
||||
{"title": "Case fact registry", "sourceClass": "irs_form"}
|
||||
],
|
||||
}
|
||||
)
|
||||
if stored_value == 0 and recomputed_value > 0 and has_document_source:
|
||||
findings.append(
|
||||
{
|
||||
"severity": "high",
|
||||
"title": f"Likely omitted {label}",
|
||||
"explanation": f"Document-extracted facts support {recomputed_value:.2f} of {label}, but the stored return reports none.",
|
||||
"suggestedAction": f"Add {label} to the return and regenerate the required forms.",
|
||||
"authorities": [
|
||||
{"title": "Case document extraction", "sourceClass": "irs_form"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
rendered_forms = {artifact["formCode"] for artifact in artifacts_payload["artifacts"]}
|
||||
for required_form in recomputed["requiredForms"]:
|
||||
if required_form not in rendered_forms:
|
||||
findings.append(
|
||||
{
|
||||
"severity": "high",
|
||||
"title": f"Missing rendered artifact for {required_form}",
|
||||
"explanation": "The return requires this form, but no rendered artifact is present in the artifact manifest.",
|
||||
"suggestedAction": f"Render and review {required_form} before treating the package as complete.",
|
||||
"authorities": [{"title": "Supported form manifest", "sourceClass": "irs_form"}],
|
||||
}
|
||||
)
|
||||
|
||||
for artifact in artifacts_payload["artifacts"]:
|
||||
if artifact.get("reviewRequired"):
|
||||
findings.append(
|
||||
{
|
||||
"severity": "medium",
|
||||
"title": f"Human review required for {artifact['formCode']}",
|
||||
"explanation": "The form was overlay-rendered on the official IRS PDF and must be reviewed before filing.",
|
||||
"suggestedAction": f"Review the rendered {artifact['formCode']} artifact visually before any filing/export handoff.",
|
||||
"authorities": [{"title": "Artifact render policy", "sourceClass": "irs_form"}],
|
||||
}
|
||||
)
|
||||
|
||||
required_forms_union = set(recomputed["requiredForms"]) | set(stored_return.get("requiredForms", []))
|
||||
if any(form in required_forms_union for form in ("f6251", "f8960", "f8959", "f1116")):
|
||||
findings.append(
|
||||
{
|
||||
"severity": "medium",
|
||||
"title": "High-complexity tax position requires specialist follow-up",
|
||||
"explanation": "The return includes forms or computations that usually require deeper technical support and careful authority review.",
|
||||
"suggestedAction": "Review the supporting authority and computations for the high-complexity forms before treating the return as filing-ready.",
|
||||
"authorities": [{"title": "Required form analysis", "sourceClass": "irs_instructions"}],
|
||||
}
|
||||
)
|
||||
|
||||
findings.sort(key=lambda item: (_severity_rank(item["severity"]), item["title"]))
|
||||
review = {
|
||||
"status": "reviewed",
|
||||
"taxYear": manifest["taxYear"],
|
||||
"caseDir": str(case_dir),
|
||||
"findingCount": len(findings),
|
||||
"findings": findings,
|
||||
}
|
||||
(case_dir / "reports" / "review-report.json").write_text(json.dumps(review, indent=2))
|
||||
return review
|
||||
|
||||
|
||||
def render_review_summary(review: dict[str, Any]) -> str:
|
||||
if not review["findings"]:
|
||||
return "No findings detected in the reviewed return package."
|
||||
lines = ["Review findings:"]
|
||||
for finding in review["findings"]:
|
||||
lines.append(f"- [{finding['severity'].upper()}] {finding['title']}: {finding['explanation']}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def render_review_memo(review: dict[str, Any]) -> str:
|
||||
lines = ["# Review Memo", ""]
|
||||
if not review["findings"]:
|
||||
lines.append("No findings detected.")
|
||||
return "\n".join(lines)
|
||||
for index, finding in enumerate(review["findings"], start=1):
|
||||
lines.extend(
|
||||
[
|
||||
f"## Finding {index}: {finding['title']}",
|
||||
f"Severity: {finding['severity']}",
|
||||
"",
|
||||
"### Explanation",
|
||||
finding["explanation"],
|
||||
"",
|
||||
"### Suggested correction",
|
||||
finding["suggestedAction"],
|
||||
"",
|
||||
"### Authorities",
|
||||
]
|
||||
)
|
||||
for authority in finding["authorities"]:
|
||||
lines.append(f"- {authority['title']}")
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip()
|
||||
239
skills/us-cpa/src/us_cpa/sources.py
Normal file
239
skills/us-cpa/src/us_cpa/sources.py
Normal file
@@ -0,0 +1,239 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import IntEnum
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
from urllib.request import urlopen
|
||||
|
||||
|
||||
class AuthorityRank(IntEnum):
|
||||
IRS_FORM = 10
|
||||
IRS_INSTRUCTIONS = 20
|
||||
IRS_PUBLICATION = 30
|
||||
IRS_FAQ = 40
|
||||
INTERNAL_REVENUE_CODE = 100
|
||||
TREASURY_REGULATION = 110
|
||||
OTHER_PRIMARY_AUTHORITY = 120
|
||||
|
||||
|
||||
AUTHORITY_RANKS: dict[str, AuthorityRank] = {
|
||||
"irs_form": AuthorityRank.IRS_FORM,
|
||||
"irs_instructions": AuthorityRank.IRS_INSTRUCTIONS,
|
||||
"irs_publication": AuthorityRank.IRS_PUBLICATION,
|
||||
"irs_faq": AuthorityRank.IRS_FAQ,
|
||||
"internal_revenue_code": AuthorityRank.INTERNAL_REVENUE_CODE,
|
||||
"treasury_regulation": AuthorityRank.TREASURY_REGULATION,
|
||||
"other_primary_authority": AuthorityRank.OTHER_PRIMARY_AUTHORITY,
|
||||
}
|
||||
|
||||
|
||||
def authority_rank_for(source_class: str) -> AuthorityRank:
|
||||
return AUTHORITY_RANKS[source_class]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceDescriptor:
|
||||
slug: str
|
||||
title: str
|
||||
source_class: str
|
||||
media_type: str
|
||||
url: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaxYearPaths:
|
||||
year_dir: Path
|
||||
irs_dir: Path
|
||||
manifest_path: Path
|
||||
|
||||
|
||||
def default_cache_root() -> Path:
|
||||
override = os.getenv("US_CPA_CACHE_DIR")
|
||||
if override:
|
||||
return Path(override).expanduser().resolve()
|
||||
return (Path.home() / ".cache" / "us-cpa").resolve()
|
||||
|
||||
|
||||
def build_irs_prior_pdf_url(slug: str, tax_year: int) -> str:
|
||||
return f"https://www.irs.gov/pub/irs-prior/{slug}--{tax_year}.pdf"
|
||||
|
||||
|
||||
def build_primary_law_authorities(question: str) -> list[dict[str, str | int]]:
|
||||
authorities: list[dict[str, str | int]] = []
|
||||
normalized = question.lower()
|
||||
|
||||
for match in re.finditer(r"(?:section|sec\.)\s+(\d+[a-z0-9-]*)", normalized):
|
||||
section = match.group(1)
|
||||
authorities.append(
|
||||
{
|
||||
"slug": f"irc-{section}",
|
||||
"title": f"Internal Revenue Code section {section}",
|
||||
"sourceClass": "internal_revenue_code",
|
||||
"url": f"https://uscode.house.gov/view.xhtml?req=granuleid:USC-prelim-title26-section{section}&num=0&edition=prelim",
|
||||
"authorityRank": int(AuthorityRank.INTERNAL_REVENUE_CODE),
|
||||
}
|
||||
)
|
||||
|
||||
for match in re.finditer(r"(?:treas(?:ury)?\.?\s+reg(?:ulation)?\.?\s*)([\d.]+-\d+)", normalized):
|
||||
section = match.group(1)
|
||||
authorities.append(
|
||||
{
|
||||
"slug": f"reg-{section}",
|
||||
"title": f"Treasury Regulation {section}",
|
||||
"sourceClass": "treasury_regulation",
|
||||
"url": f"https://www.ecfr.gov/current/title-26/section-{section}",
|
||||
"authorityRank": int(AuthorityRank.TREASURY_REGULATION),
|
||||
}
|
||||
)
|
||||
|
||||
return authorities
|
||||
|
||||
|
||||
def bootstrap_irs_catalog(tax_year: int) -> list[SourceDescriptor]:
|
||||
entries = [
|
||||
("f1040", "Form 1040", "irs_form"),
|
||||
("f1040s1", "Schedule 1 (Form 1040)", "irs_form"),
|
||||
("f1040s2", "Schedule 2 (Form 1040)", "irs_form"),
|
||||
("f1040s3", "Schedule 3 (Form 1040)", "irs_form"),
|
||||
("f1040sa", "Schedule A (Form 1040)", "irs_form"),
|
||||
("f1040sb", "Schedule B (Form 1040)", "irs_form"),
|
||||
("f1040sc", "Schedule C (Form 1040)", "irs_form"),
|
||||
("f1040sd", "Schedule D (Form 1040)", "irs_form"),
|
||||
("f1040se", "Schedule E (Form 1040)", "irs_form"),
|
||||
("f1040sse", "Schedule SE (Form 1040)", "irs_form"),
|
||||
("f1040s8", "Schedule 8812 (Form 1040)", "irs_form"),
|
||||
("f8949", "Form 8949", "irs_form"),
|
||||
("f4562", "Form 4562", "irs_form"),
|
||||
("f4797", "Form 4797", "irs_form"),
|
||||
("f6251", "Form 6251", "irs_form"),
|
||||
("f8606", "Form 8606", "irs_form"),
|
||||
("f8863", "Form 8863", "irs_form"),
|
||||
("f8889", "Form 8889", "irs_form"),
|
||||
("f8959", "Form 8959", "irs_form"),
|
||||
("f8960", "Form 8960", "irs_form"),
|
||||
("f8995", "Form 8995", "irs_form"),
|
||||
("f8995a", "Form 8995-A", "irs_form"),
|
||||
("f5329", "Form 5329", "irs_form"),
|
||||
("f5695", "Form 5695", "irs_form"),
|
||||
("f1116", "Form 1116", "irs_form"),
|
||||
("i1040gi", "Instructions for Form 1040 and Schedules 1-3", "irs_instructions"),
|
||||
("i1040sca", "Instructions for Schedule A", "irs_instructions"),
|
||||
("i1040sc", "Instructions for Schedule C", "irs_instructions"),
|
||||
("i1040sd", "Instructions for Schedule D", "irs_instructions"),
|
||||
("i1040se", "Instructions for Schedule E (Form 1040)", "irs_instructions"),
|
||||
("i1040sse", "Instructions for Schedule SE", "irs_instructions"),
|
||||
("i1040s8", "Instructions for Schedule 8812 (Form 1040)", "irs_instructions"),
|
||||
("i8949", "Instructions for Form 8949", "irs_instructions"),
|
||||
("i4562", "Instructions for Form 4562", "irs_instructions"),
|
||||
("i4797", "Instructions for Form 4797", "irs_instructions"),
|
||||
("i6251", "Instructions for Form 6251", "irs_instructions"),
|
||||
("i8606", "Instructions for Form 8606", "irs_instructions"),
|
||||
("i8863", "Instructions for Form 8863", "irs_instructions"),
|
||||
("i8889", "Instructions for Form 8889", "irs_instructions"),
|
||||
("i8959", "Instructions for Form 8959", "irs_instructions"),
|
||||
("i8960", "Instructions for Form 8960", "irs_instructions"),
|
||||
("i8995", "Instructions for Form 8995", "irs_instructions"),
|
||||
("i8995a", "Instructions for Form 8995-A", "irs_instructions"),
|
||||
("i5329", "Instructions for Form 5329", "irs_instructions"),
|
||||
("i5695", "Instructions for Form 5695", "irs_instructions"),
|
||||
("i1116", "Instructions for Form 1116", "irs_instructions"),
|
||||
("p501", "Publication 501, Dependents, Standard Deduction, and Filing Information", "irs_publication"),
|
||||
]
|
||||
return [
|
||||
SourceDescriptor(
|
||||
slug=slug,
|
||||
title=title,
|
||||
source_class=source_class,
|
||||
media_type="application/pdf",
|
||||
url=build_irs_prior_pdf_url(slug, tax_year),
|
||||
)
|
||||
for slug, title, source_class in entries
|
||||
]
|
||||
|
||||
|
||||
def _sha256_bytes(payload: bytes) -> str:
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _http_fetch(url: str) -> bytes:
|
||||
with urlopen(url) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
class TaxYearCorpus:
|
||||
def __init__(self, cache_root: Path | None = None) -> None:
|
||||
self.cache_root = cache_root or default_cache_root()
|
||||
|
||||
def paths_for_year(self, tax_year: int) -> TaxYearPaths:
|
||||
year_dir = self.cache_root / "tax-years" / str(tax_year)
|
||||
return TaxYearPaths(
|
||||
year_dir=year_dir,
|
||||
irs_dir=year_dir / "irs",
|
||||
manifest_path=year_dir / "manifest.json",
|
||||
)
|
||||
|
||||
def download_catalog(
|
||||
self,
|
||||
tax_year: int,
|
||||
catalog: list[SourceDescriptor],
|
||||
*,
|
||||
fetcher: Callable[[str], bytes] = _http_fetch,
|
||||
) -> dict:
|
||||
paths = self.paths_for_year(tax_year)
|
||||
paths.irs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
fetched_at = datetime.now(timezone.utc).isoformat()
|
||||
sources: list[dict] = []
|
||||
for descriptor in catalog:
|
||||
payload = fetcher(descriptor.url)
|
||||
destination = paths.irs_dir / f"{descriptor.slug}.pdf"
|
||||
destination.write_bytes(payload)
|
||||
sources.append(
|
||||
{
|
||||
"slug": descriptor.slug,
|
||||
"title": descriptor.title,
|
||||
"sourceClass": descriptor.source_class,
|
||||
"mediaType": descriptor.media_type,
|
||||
"url": descriptor.url,
|
||||
"localPath": str(destination),
|
||||
"sha256": _sha256_bytes(payload),
|
||||
"fetchedAt": fetched_at,
|
||||
"authorityRank": int(authority_rank_for(descriptor.source_class)),
|
||||
}
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"taxYear": tax_year,
|
||||
"fetchedAt": fetched_at,
|
||||
"cacheRoot": str(self.cache_root),
|
||||
"sourceCount": len(sources),
|
||||
"sources": sources,
|
||||
"indexes": self.index_manifest(sources),
|
||||
"primaryLawHooks": [
|
||||
{
|
||||
"sourceClass": "internal_revenue_code",
|
||||
"authorityRank": int(AuthorityRank.INTERNAL_REVENUE_CODE),
|
||||
},
|
||||
{
|
||||
"sourceClass": "treasury_regulation",
|
||||
"authorityRank": int(AuthorityRank.TREASURY_REGULATION),
|
||||
},
|
||||
],
|
||||
}
|
||||
paths.manifest_path.write_text(json.dumps(manifest, indent=2))
|
||||
return manifest
|
||||
|
||||
@staticmethod
|
||||
def index_manifest(sources: list[dict]) -> dict[str, dict[str, list[str]]]:
|
||||
by_class: dict[str, list[str]] = {}
|
||||
by_slug: dict[str, list[str]] = {}
|
||||
for source in sources:
|
||||
by_class.setdefault(source["sourceClass"], []).append(source["slug"])
|
||||
by_slug.setdefault(source["slug"], []).append(source["localPath"])
|
||||
return {"bySourceClass": by_class, "bySlug": by_slug}
|
||||
101
skills/us-cpa/src/us_cpa/tax_years.py
Normal file
101
skills/us-cpa/src/us_cpa/tax_years.py
Normal file
@@ -0,0 +1,101 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
TAX_YEAR_DATA: dict[int, dict[str, Any]] = {
|
||||
2024: {
|
||||
"standardDeduction": {
|
||||
"single": 14600.0,
|
||||
"married_filing_jointly": 29200.0,
|
||||
"head_of_household": 21900.0,
|
||||
},
|
||||
"ordinaryIncomeBrackets": {
|
||||
"single": [
|
||||
(11600.0, 0.10),
|
||||
(47150.0, 0.12),
|
||||
(100525.0, 0.22),
|
||||
(191950.0, 0.24),
|
||||
(243725.0, 0.32),
|
||||
(609350.0, 0.35),
|
||||
(float("inf"), 0.37),
|
||||
],
|
||||
"married_filing_jointly": [
|
||||
(23200.0, 0.10),
|
||||
(94300.0, 0.12),
|
||||
(201050.0, 0.22),
|
||||
(383900.0, 0.24),
|
||||
(487450.0, 0.32),
|
||||
(731200.0, 0.35),
|
||||
(float("inf"), 0.37),
|
||||
],
|
||||
"head_of_household": [
|
||||
(16550.0, 0.10),
|
||||
(63100.0, 0.12),
|
||||
(100500.0, 0.22),
|
||||
(191950.0, 0.24),
|
||||
(243700.0, 0.32),
|
||||
(609350.0, 0.35),
|
||||
(float("inf"), 0.37),
|
||||
],
|
||||
},
|
||||
"sourceCitations": {
|
||||
"standardDeduction": "IRS Rev. Proc. 2023-34, section 3.01; 2024 Form 1040 instructions.",
|
||||
"ordinaryIncomeBrackets": "IRS Rev. Proc. 2023-34, section 3.01; 2024 Form 1040 instructions.",
|
||||
},
|
||||
},
|
||||
2025: {
|
||||
"standardDeduction": {
|
||||
"single": 15750.0,
|
||||
"married_filing_jointly": 31500.0,
|
||||
"head_of_household": 23625.0,
|
||||
},
|
||||
"ordinaryIncomeBrackets": {
|
||||
"single": [
|
||||
(11925.0, 0.10),
|
||||
(48475.0, 0.12),
|
||||
(103350.0, 0.22),
|
||||
(197300.0, 0.24),
|
||||
(250525.0, 0.32),
|
||||
(626350.0, 0.35),
|
||||
(float("inf"), 0.37),
|
||||
],
|
||||
"married_filing_jointly": [
|
||||
(23850.0, 0.10),
|
||||
(96950.0, 0.12),
|
||||
(206700.0, 0.22),
|
||||
(394600.0, 0.24),
|
||||
(501050.0, 0.32),
|
||||
(751600.0, 0.35),
|
||||
(float("inf"), 0.37),
|
||||
],
|
||||
"head_of_household": [
|
||||
(17000.0, 0.10),
|
||||
(64850.0, 0.12),
|
||||
(103350.0, 0.22),
|
||||
(197300.0, 0.24),
|
||||
(250500.0, 0.32),
|
||||
(626350.0, 0.35),
|
||||
(float("inf"), 0.37),
|
||||
],
|
||||
},
|
||||
"sourceCitations": {
|
||||
"standardDeduction": "IRS Rev. Proc. 2024-40, section 3.01; 2025 Form 1040 instructions.",
|
||||
"ordinaryIncomeBrackets": "IRS Rev. Proc. 2024-40, section 3.01; 2025 Form 1040 instructions.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def supported_tax_years() -> list[int]:
|
||||
return sorted(TAX_YEAR_DATA)
|
||||
|
||||
|
||||
def tax_year_rules(tax_year: int) -> dict[str, Any]:
|
||||
try:
|
||||
return TAX_YEAR_DATA[tax_year]
|
||||
except KeyError as exc:
|
||||
years = ", ".join(str(year) for year in supported_tax_years())
|
||||
raise ValueError(
|
||||
f"Unsupported tax year {tax_year}. Supported tax years: {years}."
|
||||
) from exc
|
||||
1
skills/us-cpa/tests/fixtures/documents/.gitkeep
vendored
Normal file
1
skills/us-cpa/tests/fixtures/documents/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
3
skills/us-cpa/tests/fixtures/documents/interest-1099.txt
vendored
Normal file
3
skills/us-cpa/tests/fixtures/documents/interest-1099.txt
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
Form 1099-INT
|
||||
Recipient: Jane Doe
|
||||
Box 1 Interest Income 1750
|
||||
4
skills/us-cpa/tests/fixtures/documents/simple-w2.txt
vendored
Normal file
4
skills/us-cpa/tests/fixtures/documents/simple-w2.txt
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
Form W-2 Wage and Tax Statement
|
||||
Employee: Jane Doe
|
||||
Box 1 Wages, tips, other compensation 50000
|
||||
Box 2 Federal income tax withheld 6000
|
||||
1
skills/us-cpa/tests/fixtures/facts/.gitkeep
vendored
Normal file
1
skills/us-cpa/tests/fixtures/facts/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
6
skills/us-cpa/tests/fixtures/facts/overlay-case-2025.json
vendored
Normal file
6
skills/us-cpa/tests/fixtures/facts/overlay-case-2025.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"taxpayer.fullName": "Olivia Overlay",
|
||||
"filingStatus": "single",
|
||||
"wages": 42000,
|
||||
"federalWithholding": 5000
|
||||
}
|
||||
8
skills/us-cpa/tests/fixtures/facts/review-mismatch-2025.json
vendored
Normal file
8
skills/us-cpa/tests/fixtures/facts/review-mismatch-2025.json
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"taxpayer.fullName": "Jane Doe",
|
||||
"filingStatus": "single",
|
||||
"wages": 50000,
|
||||
"taxableInterest": 100,
|
||||
"federalWithholding": 6000,
|
||||
"expectedIssue": "agi_mismatch"
|
||||
}
|
||||
6
skills/us-cpa/tests/fixtures/facts/schedule-c-2025.json
vendored
Normal file
6
skills/us-cpa/tests/fixtures/facts/schedule-c-2025.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"taxpayer.fullName": "Jamie Owner",
|
||||
"filingStatus": "single",
|
||||
"businessIncome": 12000,
|
||||
"federalWithholding": 0
|
||||
}
|
||||
7
skills/us-cpa/tests/fixtures/facts/simple-w2-interest-2025.json
vendored
Normal file
7
skills/us-cpa/tests/fixtures/facts/simple-w2-interest-2025.json
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"taxpayer.fullName": "Jane Doe",
|
||||
"filingStatus": "single",
|
||||
"wages": 50000,
|
||||
"taxableInterest": 100,
|
||||
"federalWithholding": 6000
|
||||
}
|
||||
1
skills/us-cpa/tests/fixtures/irs/.gitkeep
vendored
Normal file
1
skills/us-cpa/tests/fixtures/irs/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
skills/us-cpa/tests/fixtures/returns/.gitkeep
vendored
Normal file
1
skills/us-cpa/tests/fixtures/returns/.gitkeep
vendored
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
16
skills/us-cpa/tests/fixtures/returns/simple-w2-interest-2025-normalized.json
vendored
Normal file
16
skills/us-cpa/tests/fixtures/returns/simple-w2-interest-2025-normalized.json
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"taxYear": 2025,
|
||||
"filingStatus": "single",
|
||||
"requiredForms": ["f1040", "f1040sb"],
|
||||
"income": {
|
||||
"wages": 50000.0,
|
||||
"taxableInterest": 1750.0,
|
||||
"businessIncome": 0.0,
|
||||
"capitalGainLoss": 0.0,
|
||||
"rentalIncome": 0.0
|
||||
},
|
||||
"totals": {
|
||||
"adjustedGrossIncome": 51750.0,
|
||||
"taxableIncome": 36000.0
|
||||
}
|
||||
}
|
||||
113
skills/us-cpa/tests/test_cases.py
Normal file
113
skills/us-cpa/tests/test_cases.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from us_cpa.cases import CaseConflictError, CaseManager
|
||||
|
||||
|
||||
class CaseManagerTests(unittest.TestCase):
|
||||
def test_create_case_builds_expected_layout(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir = Path(temp_dir) / "2025-jane-doe"
|
||||
manager = CaseManager(case_dir)
|
||||
|
||||
manifest = manager.create_case(case_label="Jane Doe", tax_year=2025)
|
||||
|
||||
self.assertEqual(manifest["caseLabel"], "Jane Doe")
|
||||
self.assertEqual(manifest["taxYear"], 2025)
|
||||
for name in (
|
||||
"input",
|
||||
"extracted",
|
||||
"return",
|
||||
"output",
|
||||
"reports",
|
||||
"issues",
|
||||
"sources",
|
||||
):
|
||||
self.assertTrue((case_dir / name).is_dir())
|
||||
self.assertTrue((case_dir / "case-manifest.json").exists())
|
||||
|
||||
def test_intake_registers_documents_and_user_facts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
case_dir = root / "2025-jane-doe"
|
||||
document = root / "w2.txt"
|
||||
document.write_text("sample w2")
|
||||
manager = CaseManager(case_dir)
|
||||
manager.create_case(case_label="Jane Doe", tax_year=2025)
|
||||
|
||||
result = manager.intake(
|
||||
tax_year=2025,
|
||||
user_facts={"filingStatus": "single", "taxpayer.ssnLast4": "1234"},
|
||||
document_paths=[document],
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "accepted")
|
||||
self.assertEqual(len(result["registeredDocuments"]), 1)
|
||||
self.assertTrue((case_dir / "input" / "w2.txt").exists())
|
||||
facts = json.loads((case_dir / "extracted" / "facts.json").read_text())
|
||||
self.assertEqual(facts["facts"]["filingStatus"]["value"], "single")
|
||||
|
||||
def test_intake_extracts_machine_usable_facts_from_text_documents(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir)
|
||||
case_dir = root / "2025-jane-doe"
|
||||
w2 = root / "w2.txt"
|
||||
w2.write_text(
|
||||
"Form W-2 Wage and Tax Statement\n"
|
||||
"Employee: Jane Doe\n"
|
||||
"Box 1 Wages, tips, other compensation 50000\n"
|
||||
"Box 2 Federal income tax withheld 6000\n"
|
||||
)
|
||||
interest = root / "1099-int.txt"
|
||||
interest.write_text(
|
||||
"Form 1099-INT\n"
|
||||
"Recipient: Jane Doe\n"
|
||||
"Box 1 Interest Income 1750\n"
|
||||
)
|
||||
manager = CaseManager(case_dir)
|
||||
manager.create_case(case_label="Jane Doe", tax_year=2025)
|
||||
|
||||
result = manager.intake(
|
||||
tax_year=2025,
|
||||
user_facts={"filingStatus": "single"},
|
||||
document_paths=[w2, interest],
|
||||
)
|
||||
|
||||
self.assertEqual(result["status"], "accepted")
|
||||
facts = json.loads((case_dir / "extracted" / "facts.json").read_text())
|
||||
self.assertEqual(facts["facts"]["wages"]["value"], 50000.0)
|
||||
self.assertEqual(facts["facts"]["federalWithholding"]["value"], 6000.0)
|
||||
self.assertEqual(facts["facts"]["taxableInterest"]["value"], 1750.0)
|
||||
self.assertEqual(facts["facts"]["wages"]["sources"][0]["sourceType"], "document_extract")
|
||||
|
||||
def test_conflicting_facts_raise_structured_issue(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir = Path(temp_dir) / "2025-jane-doe"
|
||||
manager = CaseManager(case_dir)
|
||||
manager.create_case(case_label="Jane Doe", tax_year=2025)
|
||||
manager.intake(
|
||||
tax_year=2025,
|
||||
user_facts={"filingStatus": "single"},
|
||||
document_paths=[],
|
||||
)
|
||||
|
||||
with self.assertRaises(CaseConflictError) as context:
|
||||
manager.intake(
|
||||
tax_year=2025,
|
||||
user_facts={"filingStatus": "married_filing_jointly"},
|
||||
document_paths=[],
|
||||
)
|
||||
|
||||
issue = context.exception.issue
|
||||
self.assertEqual(issue["status"], "needs_resolution")
|
||||
self.assertEqual(issue["issueType"], "fact_conflict")
|
||||
self.assertEqual(issue["field"], "filingStatus")
|
||||
self.assertTrue((case_dir / "issues" / "open-issues.json").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
400
skills/us-cpa/tests/test_cli.py
Normal file
400
skills/us-cpa/tests/test_cli.py
Normal file
@@ -0,0 +1,400 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
SKILL_DIR = Path(__file__).resolve().parents[1]
|
||||
SRC_DIR = SKILL_DIR / "src"
|
||||
|
||||
|
||||
def _pyproject_text() -> str:
|
||||
return (SKILL_DIR / "pyproject.toml").read_text()
|
||||
|
||||
|
||||
class UsCpaCliSmokeTests(unittest.TestCase):
|
||||
def test_skill_scaffold_files_exist(self) -> None:
|
||||
self.assertTrue((SKILL_DIR / "SKILL.md").exists())
|
||||
self.assertTrue((SKILL_DIR / "pyproject.toml").exists())
|
||||
self.assertTrue((SKILL_DIR / "README.md").exists())
|
||||
self.assertTrue((SKILL_DIR / "scripts" / "us-cpa").exists())
|
||||
self.assertTrue(
|
||||
(SKILL_DIR.parent.parent / "docs" / "us-cpa.md").exists()
|
||||
)
|
||||
|
||||
def test_pyproject_declares_runtime_and_dev_dependencies(self) -> None:
|
||||
pyproject = _pyproject_text()
|
||||
self.assertIn('"pypdf>=', pyproject)
|
||||
self.assertIn('"reportlab>=', pyproject)
|
||||
self.assertIn("[project.optional-dependencies]", pyproject)
|
||||
self.assertIn('"pytest>=', pyproject)
|
||||
|
||||
def test_readme_documents_install_and_script_usage(self) -> None:
|
||||
readme = (SKILL_DIR / "README.md").read_text()
|
||||
self.assertIn("pip install -e '.[dev]'", readme)
|
||||
self.assertIn("python3 -m ensurepip --upgrade", readme)
|
||||
self.assertIn("python3 -m pip install --upgrade pip setuptools wheel", readme)
|
||||
self.assertIn("scripts/us-cpa", readme)
|
||||
self.assertIn("python -m unittest", readme)
|
||||
|
||||
def test_docs_explain_openclaw_installation_flow(self) -> None:
|
||||
readme = (SKILL_DIR / "README.md").read_text()
|
||||
operator_doc = (SKILL_DIR.parent.parent / "docs" / "us-cpa.md").read_text()
|
||||
skill_doc = (SKILL_DIR / "SKILL.md").read_text()
|
||||
|
||||
self.assertIn("OpenClaw installation", readme)
|
||||
self.assertIn("~/.openclaw/workspace/skills/us-cpa", readme)
|
||||
self.assertIn(".venv/bin/python", readme)
|
||||
self.assertIn("python3 -m ensurepip --upgrade", readme)
|
||||
self.assertIn("python3 -m pip install --upgrade pip setuptools wheel", readme)
|
||||
self.assertIn("--exclude '.venv'", readme)
|
||||
self.assertNotIn("/Users/stefano/", readme)
|
||||
self.assertIn("OpenClaw installation", operator_doc)
|
||||
self.assertIn("rsync -a --delete", operator_doc)
|
||||
self.assertIn("python3 -m ensurepip --upgrade", operator_doc)
|
||||
self.assertIn("python3 -m pip install --upgrade pip setuptools wheel", operator_doc)
|
||||
self.assertIn("--exclude '.venv'", operator_doc)
|
||||
self.assertIn("~/", operator_doc)
|
||||
self.assertNotIn("/Users/stefano/", operator_doc)
|
||||
self.assertIn("~/.openclaw/workspace/skills/us-cpa/scripts/us-cpa", skill_doc)
|
||||
|
||||
def test_wrapper_prefers_local_virtualenv_python(self) -> None:
|
||||
wrapper = (SKILL_DIR / "scripts" / "us-cpa").read_text()
|
||||
self.assertIn('.venv/bin/python', wrapper)
|
||||
self.assertIn('PYTHON_BIN', wrapper)
|
||||
|
||||
def test_fixture_directories_exist(self) -> None:
|
||||
fixtures_dir = SKILL_DIR / "tests" / "fixtures"
|
||||
for name in ("irs", "facts", "documents", "returns"):
|
||||
self.assertTrue((fixtures_dir / name).exists())
|
||||
|
||||
def run_cli(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(SRC_DIR)
|
||||
return subprocess.run(
|
||||
[sys.executable, "-m", "us_cpa.cli", *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
def test_help_lists_all_commands(self) -> None:
|
||||
result = self.run_cli("--help")
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
for command in (
|
||||
"question",
|
||||
"prepare",
|
||||
"review",
|
||||
"fetch-year",
|
||||
"extract-docs",
|
||||
"render-forms",
|
||||
"export-efile-ready",
|
||||
):
|
||||
self.assertIn(command, result.stdout)
|
||||
|
||||
def test_question_command_emits_json_by_default(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(SRC_DIR)
|
||||
env["US_CPA_CACHE_DIR"] = temp_dir
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "us_cpa.cli", "fetch-year", "--tax-year", "2025"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"us_cpa.cli",
|
||||
"question",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--question",
|
||||
"What is the standard deduction?",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["command"], "question")
|
||||
self.assertEqual(payload["format"], "json")
|
||||
self.assertEqual(payload["question"], "What is the standard deduction?")
|
||||
self.assertEqual(payload["status"], "answered")
|
||||
self.assertIn("analysis", payload)
|
||||
|
||||
def test_prepare_requires_case_dir(self) -> None:
|
||||
result = self.run_cli("prepare", "--tax-year", "2025")
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("case directory", result.stderr.lower())
|
||||
|
||||
def test_extract_docs_can_create_case_and_register_facts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir = Path(temp_dir) / "2025-jane-doe"
|
||||
facts_path = Path(temp_dir) / "facts.json"
|
||||
facts_path.write_text(json.dumps({"filingStatus": "single"}))
|
||||
|
||||
result = self.run_cli(
|
||||
"extract-docs",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--case-dir",
|
||||
str(case_dir),
|
||||
"--create-case",
|
||||
"--case-label",
|
||||
"Jane Doe",
|
||||
"--facts-json",
|
||||
str(facts_path),
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["status"], "accepted")
|
||||
self.assertEqual(payload["factCount"], 1)
|
||||
self.assertTrue((case_dir / "case-manifest.json").exists())
|
||||
|
||||
def test_extract_docs_stops_on_conflicts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir = Path(temp_dir) / "2025-jane-doe"
|
||||
first_facts = Path(temp_dir) / "facts-1.json"
|
||||
second_facts = Path(temp_dir) / "facts-2.json"
|
||||
first_facts.write_text(json.dumps({"filingStatus": "single"}))
|
||||
second_facts.write_text(json.dumps({"filingStatus": "married_filing_jointly"}))
|
||||
|
||||
first = self.run_cli(
|
||||
"extract-docs",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--case-dir",
|
||||
str(case_dir),
|
||||
"--create-case",
|
||||
"--case-label",
|
||||
"Jane Doe",
|
||||
"--facts-json",
|
||||
str(first_facts),
|
||||
)
|
||||
self.assertEqual(first.returncode, 0, first.stderr)
|
||||
|
||||
second = self.run_cli(
|
||||
"extract-docs",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--case-dir",
|
||||
str(case_dir),
|
||||
"--facts-json",
|
||||
str(second_facts),
|
||||
)
|
||||
self.assertNotEqual(second.returncode, 0)
|
||||
payload = json.loads(second.stdout)
|
||||
self.assertEqual(payload["status"], "needs_resolution")
|
||||
self.assertEqual(payload["issueType"], "fact_conflict")
|
||||
|
||||
def test_question_markdown_memo_mode_renders_tax_memo(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(SRC_DIR)
|
||||
env["US_CPA_CACHE_DIR"] = temp_dir
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "us_cpa.cli", "fetch-year", "--tax-year", "2025"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"us_cpa.cli",
|
||||
"question",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--format",
|
||||
"markdown",
|
||||
"--style",
|
||||
"memo",
|
||||
"--question",
|
||||
"What is the standard deduction?",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("# Tax Memo", result.stdout)
|
||||
self.assertIn("## Conclusion", result.stdout)
|
||||
|
||||
def test_prepare_command_generates_return_package(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(SRC_DIR)
|
||||
env["US_CPA_CACHE_DIR"] = str(Path(temp_dir) / "cache")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "us_cpa.cli", "fetch-year", "--tax-year", "2025"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
|
||||
case_dir = Path(temp_dir) / "2025-jane-doe"
|
||||
facts_path = Path(temp_dir) / "facts.json"
|
||||
facts_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"taxpayer.fullName": "Jane Doe",
|
||||
"filingStatus": "single",
|
||||
"wages": 50000,
|
||||
"taxableInterest": 100,
|
||||
"federalWithholding": 6000,
|
||||
}
|
||||
)
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"us_cpa.cli",
|
||||
"extract-docs",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--case-dir",
|
||||
str(case_dir),
|
||||
"--create-case",
|
||||
"--case-label",
|
||||
"Jane Doe",
|
||||
"--facts-json",
|
||||
str(facts_path),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"us_cpa.cli",
|
||||
"prepare",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--case-dir",
|
||||
str(case_dir),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["status"], "prepared")
|
||||
self.assertEqual(payload["summary"]["requiredForms"], ["f1040"])
|
||||
self.assertTrue((case_dir / "output" / "artifacts.json").exists())
|
||||
|
||||
def test_review_command_returns_findings(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(SRC_DIR)
|
||||
env["US_CPA_CACHE_DIR"] = str(Path(temp_dir) / "cache")
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "us_cpa.cli", "fetch-year", "--tax-year", "2025"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
case_dir = Path(temp_dir) / "2025-jane-doe"
|
||||
facts_path = Path(temp_dir) / "facts.json"
|
||||
facts_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"taxpayer.fullName": "Jane Doe",
|
||||
"filingStatus": "single",
|
||||
"wages": 50000,
|
||||
"taxableInterest": 100,
|
||||
"federalWithholding": 6000,
|
||||
}
|
||||
)
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"us_cpa.cli",
|
||||
"extract-docs",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--case-dir",
|
||||
str(case_dir),
|
||||
"--create-case",
|
||||
"--case-label",
|
||||
"Jane Doe",
|
||||
"--facts-json",
|
||||
str(facts_path),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"us_cpa.cli",
|
||||
"prepare",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--case-dir",
|
||||
str(case_dir),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
check=True,
|
||||
)
|
||||
normalized_path = case_dir / "return" / "normalized-return.json"
|
||||
normalized = json.loads(normalized_path.read_text())
|
||||
normalized["totals"]["adjustedGrossIncome"] = 99999.0
|
||||
normalized_path.write_text(json.dumps(normalized, indent=2))
|
||||
|
||||
result = subprocess.run(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"us_cpa.cli",
|
||||
"review",
|
||||
"--tax-year",
|
||||
"2025",
|
||||
"--case-dir",
|
||||
str(case_dir),
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
env=env,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
payload = json.loads(result.stdout)
|
||||
self.assertEqual(payload["status"], "reviewed")
|
||||
self.assertEqual(payload["findingCount"], 2)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
66
skills/us-cpa/tests/test_document_extractors.py
Normal file
66
skills/us-cpa/tests/test_document_extractors.py
Normal file
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from us_cpa.document_extractors import extract_document_facts
|
||||
|
||||
|
||||
class DocumentExtractorTests(unittest.TestCase):
|
||||
def test_extracts_common_w2_fields(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "w2.txt"
|
||||
path.write_text(
|
||||
"Form W-2 Wage and Tax Statement\n"
|
||||
"Employee: Jane Doe\n"
|
||||
"Box 1 Wages, tips, other compensation 50000\n"
|
||||
"Box 2 Federal income tax withheld 6000\n"
|
||||
"Box 16 State wages, tips, etc. 50000\n"
|
||||
"Box 17 State income tax 1200\n"
|
||||
"Box 3 Social security wages 50000\n"
|
||||
"Box 5 Medicare wages and tips 50000\n"
|
||||
)
|
||||
|
||||
extracted = extract_document_facts(path)
|
||||
|
||||
self.assertEqual(extracted["taxpayer.fullName"], "Jane Doe")
|
||||
self.assertEqual(extracted["wages"], 50000.0)
|
||||
self.assertEqual(extracted["federalWithholding"], 6000.0)
|
||||
self.assertEqual(extracted["stateWages"], 50000.0)
|
||||
self.assertEqual(extracted["stateWithholding"], 1200.0)
|
||||
self.assertEqual(extracted["socialSecurityWages"], 50000.0)
|
||||
self.assertEqual(extracted["medicareWages"], 50000.0)
|
||||
|
||||
def test_extracts_common_1099_patterns(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
div_path = Path(temp_dir) / "1099-div.txt"
|
||||
div_path.write_text("Form 1099-DIV\nRecipient: Jane Doe\nBox 1a Total ordinary dividends 250\n")
|
||||
ret_path = Path(temp_dir) / "1099-r.txt"
|
||||
ret_path.write_text("Form 1099-R\nRecipient: Jane Doe\nBox 1 Gross distribution 10000\n")
|
||||
misc_path = Path(temp_dir) / "1099-misc.txt"
|
||||
misc_path.write_text("Form 1099-MISC\nRecipient: Jane Doe\nBox 3 Other income 900\n")
|
||||
|
||||
self.assertEqual(extract_document_facts(div_path)["ordinaryDividends"], 250.0)
|
||||
self.assertEqual(extract_document_facts(ret_path)["retirementDistribution"], 10000.0)
|
||||
self.assertEqual(extract_document_facts(misc_path)["otherIncome"], 900.0)
|
||||
|
||||
def test_extracts_prior_year_return_summary_values(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
path = Path(temp_dir) / "prior-return.txt"
|
||||
path.write_text(
|
||||
"2024 Form 1040 Summary\n"
|
||||
"Adjusted gross income 72100\n"
|
||||
"Taxable income 49800\n"
|
||||
"Refund 2100\n"
|
||||
)
|
||||
|
||||
extracted = extract_document_facts(path)
|
||||
|
||||
self.assertEqual(extracted["priorYear.adjustedGrossIncome"], 72100.0)
|
||||
self.assertEqual(extracted["priorYear.taxableIncome"], 49800.0)
|
||||
self.assertEqual(extracted["priorYear.refund"], 2100.0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
75
skills/us-cpa/tests/test_prepare.py
Normal file
75
skills/us-cpa/tests/test_prepare.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from us_cpa.cases import CaseManager
|
||||
from us_cpa.prepare import EfileExporter, PrepareEngine
|
||||
from us_cpa.sources import TaxYearCorpus, bootstrap_irs_catalog
|
||||
|
||||
|
||||
class PrepareEngineTests(unittest.TestCase):
|
||||
def build_case(self, temp_dir: str) -> tuple[CaseManager, TaxYearCorpus]:
|
||||
case_dir = Path(temp_dir) / "2025-jane-doe"
|
||||
manager = CaseManager(case_dir)
|
||||
manager.create_case(case_label="Jane Doe", tax_year=2025)
|
||||
manager.intake(
|
||||
tax_year=2025,
|
||||
user_facts={
|
||||
"taxpayer.fullName": "Jane Doe",
|
||||
"filingStatus": "single",
|
||||
"wages": 50000,
|
||||
"taxableInterest": 100,
|
||||
"federalWithholding": 6000,
|
||||
},
|
||||
document_paths=[],
|
||||
)
|
||||
|
||||
corpus = TaxYearCorpus(cache_root=Path(temp_dir) / "cache")
|
||||
|
||||
def fake_fetch(url: str) -> bytes:
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer)
|
||||
pdf.drawString(72, 720, f"Template for {url}")
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
corpus.download_catalog(2025, bootstrap_irs_catalog(2025), fetcher=fake_fetch)
|
||||
return manager, corpus
|
||||
|
||||
def test_prepare_creates_normalized_return_and_artifacts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
manager, corpus = self.build_case(temp_dir)
|
||||
engine = PrepareEngine(corpus=corpus)
|
||||
|
||||
result = engine.prepare_case(manager.case_dir)
|
||||
|
||||
self.assertEqual(result["status"], "prepared")
|
||||
self.assertEqual(result["summary"]["requiredForms"], ["f1040"])
|
||||
self.assertEqual(result["summary"]["reviewRequiredArtifacts"], ["f1040"])
|
||||
self.assertTrue((manager.case_dir / "return" / "normalized-return.json").exists())
|
||||
self.assertTrue((manager.case_dir / "output" / "artifacts.json").exists())
|
||||
normalized = json.loads((manager.case_dir / "return" / "normalized-return.json").read_text())
|
||||
self.assertEqual(normalized["totals"]["adjustedGrossIncome"], 50100.0)
|
||||
self.assertEqual(normalized["totals"]["taxableIncome"], 34350.0)
|
||||
|
||||
def test_exporter_writes_efile_ready_payload(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
manager, corpus = self.build_case(temp_dir)
|
||||
engine = PrepareEngine(corpus=corpus)
|
||||
engine.prepare_case(manager.case_dir)
|
||||
|
||||
export = EfileExporter().export_case(manager.case_dir)
|
||||
|
||||
self.assertEqual(export["status"], "draft")
|
||||
self.assertTrue((manager.case_dir / "output" / "efile-ready.json").exists())
|
||||
self.assertEqual(export["returnSummary"]["requiredForms"], ["f1040"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
189
skills/us-cpa/tests/test_questions.py
Normal file
189
skills/us-cpa/tests/test_questions.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from us_cpa.questions import QuestionEngine, render_analysis, render_memo
|
||||
from us_cpa.sources import TaxYearCorpus, bootstrap_irs_catalog
|
||||
|
||||
|
||||
class QuestionEngineTests(unittest.TestCase):
|
||||
def build_engine(self, temp_dir: str) -> QuestionEngine:
|
||||
corpus = TaxYearCorpus(cache_root=Path(temp_dir))
|
||||
|
||||
def fake_fetch(url: str) -> bytes:
|
||||
if "p501" in url:
|
||||
return (
|
||||
"A qualifying child may be your dependent if the relationship, age, residency, support, and joint return tests are met. "
|
||||
"Temporary absences due to education count as time lived with you. "
|
||||
"To meet the support test, the child must not have provided more than half of their own support for the year."
|
||||
).encode()
|
||||
return f"source for {url}".encode()
|
||||
|
||||
corpus.download_catalog(2025, bootstrap_irs_catalog(2025), fetcher=fake_fetch)
|
||||
return QuestionEngine(corpus=corpus)
|
||||
|
||||
def test_standard_deduction_question_returns_structured_analysis(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question="What is the standard deduction for single filers?",
|
||||
tax_year=2025,
|
||||
case_facts={"filingStatus": "single"},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["issue"], "standard_deduction")
|
||||
self.assertEqual(analysis["taxYear"], 2025)
|
||||
self.assertEqual(analysis["conclusion"]["answer"], "$15,750")
|
||||
self.assertEqual(analysis["confidence"], "high")
|
||||
self.assertEqual(analysis["riskLevel"], "low")
|
||||
self.assertTrue(analysis["authorities"])
|
||||
self.assertEqual(analysis["authorities"][0]["sourceClass"], "irs_instructions")
|
||||
|
||||
def test_standard_deduction_infers_married_filing_jointly_from_question(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question="What is the standard deduction for married filing jointly?",
|
||||
tax_year=2025,
|
||||
case_facts={},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["issue"], "standard_deduction")
|
||||
self.assertEqual(analysis["conclusion"]["answer"], "$31,500")
|
||||
self.assertIn("Married Filing Jointly", analysis["conclusion"]["summary"])
|
||||
|
||||
def test_standard_deduction_infers_head_of_household_from_question(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question="What is the standard deduction for a head of household filer?",
|
||||
tax_year=2025,
|
||||
case_facts={},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["issue"], "standard_deduction")
|
||||
self.assertEqual(analysis["conclusion"]["answer"], "$23,625")
|
||||
self.assertIn("Head Of Household", analysis["conclusion"]["summary"])
|
||||
|
||||
def test_standard_deduction_infers_qualifying_surviving_spouse_from_question(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question="What is the standard deduction for a qualifying surviving spouse?",
|
||||
tax_year=2025,
|
||||
case_facts={},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["issue"], "standard_deduction")
|
||||
self.assertEqual(analysis["conclusion"]["answer"], "$31,500")
|
||||
self.assertIn("Qualifying Surviving Spouse", analysis["conclusion"]["summary"])
|
||||
|
||||
def test_standard_deduction_infers_qualifying_widow_from_question(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question="What is the standard deduction for a qualifying widow?",
|
||||
tax_year=2025,
|
||||
case_facts={},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["issue"], "standard_deduction")
|
||||
self.assertEqual(analysis["conclusion"]["answer"], "$31,500")
|
||||
self.assertIn("Qualifying Surviving Spouse", analysis["conclusion"]["summary"])
|
||||
|
||||
def test_dependency_question_uses_irs_corpus_research_before_primary_law(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question=(
|
||||
"If my daughter went to college in 2025 starting in August, but also worked before that, "
|
||||
"should she be considered as a dependent?"
|
||||
),
|
||||
tax_year=2025,
|
||||
case_facts={},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["issue"], "irs_corpus_research")
|
||||
self.assertFalse(analysis["primaryLawRequired"])
|
||||
self.assertEqual(analysis["authorities"][0]["slug"], "p501")
|
||||
self.assertTrue(any(item["slug"] == "p501" for item in analysis["authorities"]))
|
||||
self.assertTrue(analysis["excerpts"])
|
||||
|
||||
def test_complex_question_flags_primary_law_escalation(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question="Does section 469 passive activity loss limitation apply here?",
|
||||
tax_year=2025,
|
||||
case_facts={},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["confidence"], "low")
|
||||
self.assertEqual(analysis["riskLevel"], "high")
|
||||
self.assertTrue(analysis["primaryLawRequired"])
|
||||
self.assertIn("Internal Revenue Code", analysis["missingFacts"][0])
|
||||
self.assertTrue(any(item["sourceClass"] == "internal_revenue_code" for item in analysis["authorities"]))
|
||||
|
||||
def test_capital_gains_question_returns_schedule_d_guidance(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question="Do I need Schedule D for capital gains?",
|
||||
tax_year=2025,
|
||||
case_facts={"capitalGainLoss": 400},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["issue"], "schedule_d_required")
|
||||
self.assertEqual(analysis["confidence"], "medium")
|
||||
self.assertFalse(analysis["primaryLawRequired"])
|
||||
self.assertTrue(any(item["slug"] == "f1040sd" for item in analysis["authorities"]))
|
||||
|
||||
def test_schedule_e_question_returns_rental_guidance(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
engine = self.build_engine(temp_dir)
|
||||
|
||||
analysis = engine.answer(
|
||||
question="Do I need Schedule E for rental income?",
|
||||
tax_year=2025,
|
||||
case_facts={"rentalIncome": 1200},
|
||||
)
|
||||
|
||||
self.assertEqual(analysis["issue"], "schedule_e_required")
|
||||
self.assertFalse(analysis["primaryLawRequired"])
|
||||
self.assertTrue(any(item["slug"] == "f1040se" for item in analysis["authorities"]))
|
||||
|
||||
def test_renderers_produce_conversation_and_memo(self) -> None:
|
||||
analysis = {
|
||||
"issue": "standard_deduction",
|
||||
"taxYear": 2025,
|
||||
"factsUsed": [{"field": "filingStatus", "value": "single"}],
|
||||
"missingFacts": [],
|
||||
"authorities": [{"title": "Instructions for Form 1040 and Schedules 1-3"}],
|
||||
"conclusion": {"answer": "$15,750", "summary": "Single filers use a $15,750 standard deduction for tax year 2025."},
|
||||
"confidence": "high",
|
||||
"riskLevel": "low",
|
||||
"followUpQuestions": [],
|
||||
"primaryLawRequired": False,
|
||||
}
|
||||
|
||||
conversation = render_analysis(analysis)
|
||||
memo = render_memo(analysis)
|
||||
|
||||
self.assertIn("$15,750", conversation)
|
||||
self.assertIn("Issue", memo)
|
||||
self.assertIn("Authorities", memo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
100
skills/us-cpa/tests/test_renderers.py
Normal file
100
skills/us-cpa/tests/test_renderers.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from us_cpa.renderers import render_case_forms
|
||||
from us_cpa.sources import TaxYearCorpus
|
||||
|
||||
|
||||
class RendererTests(unittest.TestCase):
|
||||
def test_render_case_forms_prefers_fillable_pdf_fields_when_available(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir = Path(temp_dir) / "case"
|
||||
(case_dir / "output").mkdir(parents=True)
|
||||
corpus = TaxYearCorpus(cache_root=Path(temp_dir) / "cache")
|
||||
irs_dir = corpus.paths_for_year(2025).irs_dir
|
||||
irs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer)
|
||||
form = pdf.acroForm
|
||||
pdf.drawString(72, 720, "Name")
|
||||
form.textfield(name="taxpayer_full_name", x=120, y=710, width=200, height=20)
|
||||
pdf.drawString(72, 680, "Wages")
|
||||
form.textfield(name="wages", x=120, y=670, width=200, height=20)
|
||||
pdf.save()
|
||||
(irs_dir / "f1040.pdf").write_bytes(buffer.getvalue())
|
||||
|
||||
normalized = {
|
||||
"taxYear": 2025,
|
||||
"requiredForms": ["f1040"],
|
||||
"taxpayer": {"fullName": "Jane Doe"},
|
||||
"filingStatus": "single",
|
||||
"income": {"wages": 50000.0, "taxableInterest": 100.0, "businessIncome": 0.0, "capitalGainLoss": 0.0, "rentalIncome": 0.0},
|
||||
"deductions": {"standardDeduction": 15750.0, "deductionType": "standard", "deductionAmount": 15750.0},
|
||||
"adjustments": {"hsaContribution": 0.0},
|
||||
"credits": {"educationCredit": 0.0, "foreignTaxCredit": 0.0, "energyCredit": 0.0},
|
||||
"taxes": {"totalTax": 3883.5, "additionalMedicareTax": 0.0, "netInvestmentIncomeTax": 0.0, "alternativeMinimumTax": 0.0, "additionalTaxPenalty": 0.0},
|
||||
"payments": {"federalWithholding": 6000.0},
|
||||
"business": {"qualifiedBusinessIncome": 0.0},
|
||||
"basis": {"traditionalIraBasis": 0.0},
|
||||
"depreciation": {"depreciationExpense": 0.0},
|
||||
"assetSales": {"section1231GainLoss": 0.0},
|
||||
"totals": {"adjustedGrossIncome": 50100.0, "taxableIncome": 34350.0, "refund": 2116.5, "balanceDue": 0.0},
|
||||
}
|
||||
|
||||
artifacts = render_case_forms(case_dir, corpus, normalized)
|
||||
|
||||
self.assertEqual(artifacts["artifacts"][0]["renderMethod"], "field_fill")
|
||||
self.assertFalse(artifacts["artifacts"][0]["reviewRequired"])
|
||||
|
||||
def test_render_case_forms_writes_overlay_artifacts_and_flags_review(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir = Path(temp_dir) / "case"
|
||||
(case_dir / "output").mkdir(parents=True)
|
||||
corpus = TaxYearCorpus(cache_root=Path(temp_dir) / "cache")
|
||||
irs_dir = corpus.paths_for_year(2025).irs_dir
|
||||
irs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer)
|
||||
pdf.drawString(72, 720, "Template")
|
||||
pdf.save()
|
||||
(irs_dir / "f1040.pdf").write_bytes(buffer.getvalue())
|
||||
|
||||
normalized = {
|
||||
"taxYear": 2025,
|
||||
"requiredForms": ["f1040"],
|
||||
"taxpayer": {"fullName": "Jane Doe"},
|
||||
"filingStatus": "single",
|
||||
"income": {"wages": 50000.0, "taxableInterest": 100.0, "businessIncome": 0.0, "capitalGainLoss": 0.0, "rentalIncome": 0.0},
|
||||
"deductions": {"standardDeduction": 15750.0, "deductionType": "standard", "deductionAmount": 15750.0},
|
||||
"adjustments": {"hsaContribution": 0.0},
|
||||
"credits": {"educationCredit": 0.0, "foreignTaxCredit": 0.0, "energyCredit": 0.0},
|
||||
"taxes": {"totalTax": 3883.5, "additionalMedicareTax": 0.0, "netInvestmentIncomeTax": 0.0, "alternativeMinimumTax": 0.0, "additionalTaxPenalty": 0.0},
|
||||
"payments": {"federalWithholding": 6000.0},
|
||||
"business": {"qualifiedBusinessIncome": 0.0},
|
||||
"basis": {"traditionalIraBasis": 0.0},
|
||||
"depreciation": {"depreciationExpense": 0.0},
|
||||
"assetSales": {"section1231GainLoss": 0.0},
|
||||
"totals": {"adjustedGrossIncome": 50100.0, "taxableIncome": 34350.0, "refund": 2116.5, "balanceDue": 0.0},
|
||||
}
|
||||
|
||||
artifacts = render_case_forms(case_dir, corpus, normalized)
|
||||
|
||||
self.assertEqual(artifacts["artifactCount"], 1)
|
||||
self.assertEqual(artifacts["artifacts"][0]["renderMethod"], "overlay")
|
||||
self.assertTrue(artifacts["artifacts"][0]["reviewRequired"])
|
||||
self.assertTrue((case_dir / "output" / "forms" / "f1040.pdf").exists())
|
||||
manifest = json.loads((case_dir / "output" / "artifacts.json").read_text())
|
||||
self.assertEqual(manifest["artifacts"][0]["formCode"], "f1040")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
102
skills/us-cpa/tests/test_returns.py
Normal file
102
skills/us-cpa/tests/test_returns.py
Normal file
@@ -0,0 +1,102 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from us_cpa.returns import normalize_case_facts, resolve_required_forms, tax_on_ordinary_income
|
||||
|
||||
|
||||
class ReturnModelTests(unittest.TestCase):
|
||||
def test_normalize_case_facts_computes_basic_1040_totals(self) -> None:
|
||||
normalized = normalize_case_facts(
|
||||
{
|
||||
"taxpayer.fullName": "Jane Doe",
|
||||
"filingStatus": "single",
|
||||
"wages": 50000,
|
||||
"taxableInterest": 100,
|
||||
"federalWithholding": 6000,
|
||||
},
|
||||
2025,
|
||||
)
|
||||
|
||||
self.assertEqual(normalized["requiredForms"], ["f1040"])
|
||||
self.assertEqual(normalized["deductions"]["standardDeduction"], 15750.0)
|
||||
self.assertEqual(normalized["totals"]["adjustedGrossIncome"], 50100.0)
|
||||
self.assertEqual(normalized["totals"]["taxableIncome"], 34350.0)
|
||||
self.assertEqual(normalized["totals"]["refund"], 2116.5)
|
||||
|
||||
def test_resolve_required_forms_adds_business_and_interest_forms(self) -> None:
|
||||
normalized = normalize_case_facts(
|
||||
{
|
||||
"filingStatus": "single",
|
||||
"wages": 0,
|
||||
"taxableInterest": 2000,
|
||||
"businessIncome": 12000,
|
||||
},
|
||||
2025,
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
resolve_required_forms(normalized),
|
||||
["f1040", "f1040sb", "f1040sc", "f1040sse", "f1040s1", "f8995"],
|
||||
)
|
||||
|
||||
def test_tax_bracket_calculation_uses_2025_single_rates(self) -> None:
|
||||
self.assertEqual(tax_on_ordinary_income(34350.0, "single", 2025), 3883.5)
|
||||
|
||||
def test_tax_bracket_calculation_uses_selected_tax_year(self) -> None:
|
||||
self.assertEqual(tax_on_ordinary_income(33650.0, "single", 2024), 3806.0)
|
||||
|
||||
def test_normalize_case_facts_rejects_unsupported_tax_year(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported tax year"):
|
||||
normalize_case_facts({"filingStatus": "single"}, 2023)
|
||||
|
||||
def test_normalize_case_facts_preserves_provenance_and_expands_form_resolution(self) -> None:
|
||||
normalized = normalize_case_facts(
|
||||
{
|
||||
"taxpayer.fullName": "Jane Doe",
|
||||
"spouse.fullName": "John Doe",
|
||||
"dependents": [{"fullName": "Kid Doe", "ssnLast4": "4321"}],
|
||||
"filingStatus": "married_filing_jointly",
|
||||
"wages": 50000,
|
||||
"taxableInterest": 2001,
|
||||
"capitalGainLoss": 400,
|
||||
"rentalIncome": 1200,
|
||||
"itemizedDeductions": 40000,
|
||||
"hsaContribution": 1000,
|
||||
"educationCredit": 500,
|
||||
"foreignTaxCredit": 250,
|
||||
"qualifiedBusinessIncome": 12000,
|
||||
"traditionalIraBasis": 6000,
|
||||
"additionalMedicareTax": 100,
|
||||
"netInvestmentIncomeTax": 200,
|
||||
"alternativeMinimumTax": 300,
|
||||
"additionalTaxPenalty": 50,
|
||||
"energyCredit": 600,
|
||||
"_factMetadata": {
|
||||
"wages": {"sources": [{"sourceType": "document_extract", "documentName": "w2.txt"}]},
|
||||
},
|
||||
},
|
||||
2025,
|
||||
)
|
||||
|
||||
self.assertEqual(normalized["spouse"]["fullName"], "John Doe")
|
||||
self.assertEqual(normalized["dependents"][0]["fullName"], "Kid Doe")
|
||||
self.assertEqual(normalized["provenance"]["income.wages"]["sources"][0]["documentName"], "w2.txt")
|
||||
self.assertIn("f1040sa", normalized["requiredForms"])
|
||||
self.assertIn("f1040sd", normalized["requiredForms"])
|
||||
self.assertIn("f8949", normalized["requiredForms"])
|
||||
self.assertIn("f1040se", normalized["requiredForms"])
|
||||
self.assertIn("f8889", normalized["requiredForms"])
|
||||
self.assertIn("f8863", normalized["requiredForms"])
|
||||
self.assertIn("f1116", normalized["requiredForms"])
|
||||
self.assertIn("f8995", normalized["requiredForms"])
|
||||
self.assertIn("f8606", normalized["requiredForms"])
|
||||
self.assertIn("f8959", normalized["requiredForms"])
|
||||
self.assertIn("f8960", normalized["requiredForms"])
|
||||
self.assertIn("f6251", normalized["requiredForms"])
|
||||
self.assertIn("f5329", normalized["requiredForms"])
|
||||
self.assertIn("f5695", normalized["requiredForms"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
128
skills/us-cpa/tests/test_review.py
Normal file
128
skills/us-cpa/tests/test_review.py
Normal file
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
from reportlab.pdfgen import canvas
|
||||
|
||||
from us_cpa.cases import CaseManager
|
||||
from us_cpa.prepare import PrepareEngine
|
||||
from us_cpa.review import ReviewEngine, render_review_memo, render_review_summary
|
||||
from us_cpa.sources import TaxYearCorpus, bootstrap_irs_catalog
|
||||
|
||||
|
||||
class ReviewEngineTests(unittest.TestCase):
|
||||
def build_prepared_case(self, temp_dir: str) -> tuple[Path, TaxYearCorpus]:
|
||||
case_dir = Path(temp_dir) / "2025-jane-doe"
|
||||
manager = CaseManager(case_dir)
|
||||
manager.create_case(case_label="Jane Doe", tax_year=2025)
|
||||
manager.intake(
|
||||
tax_year=2025,
|
||||
user_facts={
|
||||
"taxpayer.fullName": "Jane Doe",
|
||||
"filingStatus": "single",
|
||||
"wages": 50000,
|
||||
"taxableInterest": 100,
|
||||
"federalWithholding": 6000,
|
||||
},
|
||||
document_paths=[],
|
||||
)
|
||||
corpus = TaxYearCorpus(cache_root=Path(temp_dir) / "cache")
|
||||
|
||||
def fake_fetch(url: str) -> bytes:
|
||||
buffer = BytesIO()
|
||||
pdf = canvas.Canvas(buffer)
|
||||
pdf.drawString(72, 720, f"Template for {url}")
|
||||
pdf.save()
|
||||
return buffer.getvalue()
|
||||
|
||||
corpus.download_catalog(2025, bootstrap_irs_catalog(2025), fetcher=fake_fetch)
|
||||
PrepareEngine(corpus=corpus).prepare_case(case_dir)
|
||||
return case_dir, corpus
|
||||
|
||||
def test_review_detects_mismatched_return_and_missing_artifacts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir, corpus = self.build_prepared_case(temp_dir)
|
||||
normalized_path = case_dir / "return" / "normalized-return.json"
|
||||
normalized = json.loads(normalized_path.read_text())
|
||||
normalized["totals"]["adjustedGrossIncome"] = 99999.0
|
||||
normalized_path.write_text(json.dumps(normalized, indent=2))
|
||||
|
||||
artifacts_path = case_dir / "output" / "artifacts.json"
|
||||
artifacts = json.loads(artifacts_path.read_text())
|
||||
artifacts["artifacts"] = []
|
||||
artifacts["artifactCount"] = 0
|
||||
artifacts_path.write_text(json.dumps(artifacts, indent=2))
|
||||
|
||||
review = ReviewEngine(corpus=corpus).review_case(case_dir)
|
||||
|
||||
self.assertEqual(review["status"], "reviewed")
|
||||
self.assertEqual(review["findings"][0]["severity"], "high")
|
||||
self.assertIn("adjusted gross income", review["findings"][0]["title"].lower())
|
||||
self.assertTrue(any("missing rendered artifact" in item["title"].lower() for item in review["findings"]))
|
||||
|
||||
def test_review_detects_reporting_omissions_from_source_facts(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir, corpus = self.build_prepared_case(temp_dir)
|
||||
normalized_path = case_dir / "return" / "normalized-return.json"
|
||||
normalized = json.loads(normalized_path.read_text())
|
||||
normalized["income"]["taxableInterest"] = 0.0
|
||||
normalized["totals"]["adjustedGrossIncome"] = 50000.0
|
||||
normalized_path.write_text(json.dumps(normalized, indent=2))
|
||||
|
||||
facts_path = case_dir / "extracted" / "facts.json"
|
||||
facts_payload = json.loads(facts_path.read_text())
|
||||
facts_payload["facts"]["taxableInterest"] = {
|
||||
"value": 1750.0,
|
||||
"sources": [{"sourceType": "document_extract", "sourceName": "1099-int.txt"}],
|
||||
}
|
||||
facts_path.write_text(json.dumps(facts_payload, indent=2))
|
||||
|
||||
review = ReviewEngine(corpus=corpus).review_case(case_dir)
|
||||
|
||||
self.assertTrue(
|
||||
any("likely omitted taxable interest" in item["title"].lower() for item in review["findings"])
|
||||
)
|
||||
|
||||
def test_review_flags_high_complexity_positions_for_specialist_follow_up(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
case_dir, corpus = self.build_prepared_case(temp_dir)
|
||||
normalized_path = case_dir / "return" / "normalized-return.json"
|
||||
normalized = json.loads(normalized_path.read_text())
|
||||
normalized["requiredForms"].append("f6251")
|
||||
normalized["taxes"]["alternativeMinimumTax"] = 300.0
|
||||
normalized_path.write_text(json.dumps(normalized, indent=2))
|
||||
|
||||
review = ReviewEngine(corpus=corpus).review_case(case_dir)
|
||||
|
||||
self.assertTrue(
|
||||
any("high-complexity tax position" in item["title"].lower() for item in review["findings"])
|
||||
)
|
||||
|
||||
def test_review_renderers_produce_summary_and_memo(self) -> None:
|
||||
review = {
|
||||
"status": "reviewed",
|
||||
"findings": [
|
||||
{
|
||||
"severity": "high",
|
||||
"title": "Adjusted gross income mismatch",
|
||||
"explanation": "Stored AGI does not match recomputed AGI.",
|
||||
"suggestedAction": "Update Form 1040 line 11.",
|
||||
"authorities": [{"title": "Instructions for Form 1040 and Schedules 1-3"}],
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
summary = render_review_summary(review)
|
||||
memo = render_review_memo(review)
|
||||
|
||||
self.assertIn("Adjusted gross income mismatch", summary)
|
||||
self.assertIn("# Review Memo", memo)
|
||||
self.assertIn("Suggested correction", memo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
109
skills/us-cpa/tests/test_sources.py
Normal file
109
skills/us-cpa/tests/test_sources.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from us_cpa.sources import (
|
||||
AuthorityRank,
|
||||
SourceDescriptor,
|
||||
TaxYearCorpus,
|
||||
authority_rank_for,
|
||||
bootstrap_irs_catalog,
|
||||
build_irs_prior_pdf_url,
|
||||
build_primary_law_authorities,
|
||||
)
|
||||
|
||||
|
||||
class SourceCatalogTests(unittest.TestCase):
|
||||
def test_build_irs_prior_pdf_url_uses_expected_pattern(self) -> None:
|
||||
self.assertEqual(
|
||||
build_irs_prior_pdf_url("f1040", 2025),
|
||||
"https://www.irs.gov/pub/irs-prior/f1040--2025.pdf",
|
||||
)
|
||||
self.assertEqual(
|
||||
build_irs_prior_pdf_url("i1040gi", 2025),
|
||||
"https://www.irs.gov/pub/irs-prior/i1040gi--2025.pdf",
|
||||
)
|
||||
|
||||
def test_authority_ranking_orders_irs_before_primary_law(self) -> None:
|
||||
self.assertEqual(authority_rank_for("irs_form"), AuthorityRank.IRS_FORM)
|
||||
self.assertEqual(
|
||||
authority_rank_for("treasury_regulation"),
|
||||
AuthorityRank.TREASURY_REGULATION,
|
||||
)
|
||||
self.assertLess(
|
||||
authority_rank_for("irs_form"), authority_rank_for("internal_revenue_code")
|
||||
)
|
||||
|
||||
def test_bootstrap_catalog_builds_tax_year_specific_urls(self) -> None:
|
||||
catalog = bootstrap_irs_catalog(2025)
|
||||
|
||||
self.assertGreaterEqual(len(catalog), 5)
|
||||
self.assertEqual(catalog[0].url, "https://www.irs.gov/pub/irs-prior/f1040--2025.pdf")
|
||||
self.assertTrue(any(item.slug == "i1040gi" for item in catalog))
|
||||
self.assertTrue(any(item.slug == "f1040sse" for item in catalog))
|
||||
|
||||
def test_primary_law_authorities_build_official_urls(self) -> None:
|
||||
authorities = build_primary_law_authorities(
|
||||
"Does section 469 apply and what does Treas. Reg. 1.469-1 say?"
|
||||
)
|
||||
|
||||
self.assertTrue(any(item["sourceClass"] == "internal_revenue_code" for item in authorities))
|
||||
self.assertTrue(any(item["sourceClass"] == "treasury_regulation" for item in authorities))
|
||||
self.assertTrue(any("uscode.house.gov" in item["url"] for item in authorities))
|
||||
self.assertTrue(any("ecfr.gov" in item["url"] for item in authorities))
|
||||
|
||||
|
||||
class TaxYearCorpusTests(unittest.TestCase):
|
||||
def test_tax_year_layout_is_deterministic(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
corpus = TaxYearCorpus(cache_root=Path(temp_dir))
|
||||
paths = corpus.paths_for_year(2025)
|
||||
|
||||
self.assertEqual(paths.year_dir, Path(temp_dir) / "tax-years" / "2025")
|
||||
self.assertEqual(paths.irs_dir, paths.year_dir / "irs")
|
||||
self.assertEqual(paths.manifest_path, paths.year_dir / "manifest.json")
|
||||
|
||||
def test_download_catalog_writes_files_and_manifest(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
corpus = TaxYearCorpus(cache_root=Path(temp_dir))
|
||||
catalog = [
|
||||
SourceDescriptor(
|
||||
slug="f1040",
|
||||
title="Form 1040",
|
||||
source_class="irs_form",
|
||||
media_type="application/pdf",
|
||||
url=build_irs_prior_pdf_url("f1040", 2025),
|
||||
),
|
||||
SourceDescriptor(
|
||||
slug="i1040gi",
|
||||
title="Instructions for Form 1040",
|
||||
source_class="irs_instructions",
|
||||
media_type="application/pdf",
|
||||
url=build_irs_prior_pdf_url("i1040gi", 2025),
|
||||
),
|
||||
]
|
||||
|
||||
def fake_fetch(url: str) -> bytes:
|
||||
return f"downloaded:{url}".encode()
|
||||
|
||||
manifest = corpus.download_catalog(2025, catalog, fetcher=fake_fetch)
|
||||
|
||||
self.assertEqual(manifest["taxYear"], 2025)
|
||||
self.assertEqual(manifest["sourceCount"], 2)
|
||||
self.assertTrue(corpus.paths_for_year(2025).manifest_path.exists())
|
||||
|
||||
first = manifest["sources"][0]
|
||||
self.assertEqual(first["slug"], "f1040")
|
||||
self.assertEqual(first["authorityRank"], int(AuthorityRank.IRS_FORM))
|
||||
self.assertTrue(Path(first["localPath"]).exists())
|
||||
|
||||
saved = json.loads(corpus.paths_for_year(2025).manifest_path.read_text())
|
||||
self.assertEqual(saved["sourceCount"], 2)
|
||||
self.assertEqual(saved["sources"][1]["slug"], "i1040gi")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
25
skills/us-cpa/tests/test_tax_years.py
Normal file
25
skills/us-cpa/tests/test_tax_years.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from us_cpa.tax_years import supported_tax_years, tax_year_rules
|
||||
|
||||
|
||||
class TaxYearRuleTests(unittest.TestCase):
|
||||
def test_supported_years_are_listed(self) -> None:
|
||||
self.assertEqual(supported_tax_years(), [2024, 2025])
|
||||
|
||||
def test_tax_year_rules_include_source_citations(self) -> None:
|
||||
rules = tax_year_rules(2025)
|
||||
|
||||
self.assertIn("sourceCitations", rules)
|
||||
self.assertIn("standardDeduction", rules["sourceCitations"])
|
||||
self.assertIn("ordinaryIncomeBrackets", rules["sourceCitations"])
|
||||
|
||||
def test_unsupported_tax_year_raises_clear_error(self) -> None:
|
||||
with self.assertRaisesRegex(ValueError, "Unsupported tax year 2023"):
|
||||
tax_year_rules(2023)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
4
skills/web-automation/.gitignore
vendored
4
skills/web-automation/.gitignore
vendored
@@ -1,3 +1,7 @@
|
||||
node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
scripts/page-*.html
|
||||
scripts/tmp_*.mjs
|
||||
scripts/expedia_dump.mjs
|
||||
scripts/pnpm-workspace.yaml
|
||||
|
||||
@@ -48,8 +48,7 @@ Before running any automation, verify CloakBrowser and Playwright Core dependenc
|
||||
|
||||
```bash
|
||||
cd ~/.openclaw/workspace/skills/web-automation/scripts
|
||||
node --input-type=module -e "await import('cloakbrowser');import 'playwright-core';console.log('OK: cloakbrowser + playwright-core installed')"
|
||||
node -e "const fs=require('fs');const t=fs.readFileSync('browse.ts','utf8');if(!/import\s*\{[^}]*launchPersistentContext[^}]*\}\s*from\s*['\"]cloakbrowser['\"]/.test(t)){throw new Error('browse.ts is not configured for CloakBrowser')}console.log('OK: CloakBrowser integration detected in browse.ts')"
|
||||
node check-install.js
|
||||
```
|
||||
|
||||
If any check fails, stop and return:
|
||||
@@ -66,12 +65,45 @@ pnpm rebuild better-sqlite3 esbuild
|
||||
|
||||
## Quick Reference
|
||||
|
||||
- Install check: `node check-install.js`
|
||||
- Zillow listing discovery from address: `node scripts/zillow-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"`
|
||||
- HAR listing discovery from address: `node scripts/har-discover.js "4141 Whiteley Dr, Corpus Christi, TX 78418"`
|
||||
- One-shot JSON extract: `node scripts/extract.js "https://example.com"`
|
||||
- Zillow photo URLs: `node scripts/zillow-photos.js "https://www.zillow.com/homedetails/..."`
|
||||
- HAR photo URLs: `node scripts/har-photos.js "https://www.har.com/homedetail/..."`
|
||||
- Browse page: `npx tsx browse.ts --url "https://example.com"`
|
||||
- Scrape markdown: `npx tsx scrape.ts --url "https://example.com" --mode main --output page.md`
|
||||
- Authenticate: `npx tsx auth.ts --url "https://example.com/login"`
|
||||
- Natural-language flow: `npx tsx flow.ts --instruction 'go to https://example.com then click on "Login" then type "user@example.com" in #email then press enter'`
|
||||
|
||||
Messaging rule:
|
||||
- For WhatsApp or similar chat-driven runs, prefer native `web_search`, `web_fetch`, and bounded browser actions over shelling out to these helper scripts for every core step.
|
||||
- Treat the dedicated Zillow/HAR scripts as local/manual helpers, regression checks, or non-chat fallbacks.
|
||||
- If a messaging workflow needs a subprocess at all, reserve it for a single final delivery step rather than the whole assessment.
|
||||
|
||||
## OpenClaw Exec Approvals / Allowlist
|
||||
|
||||
If OpenClaw prompts for exec approval every time this skill runs, add a local approvals allowlist for the main agent before retrying. This is especially helpful for repeated `extract.js`, `browse.ts`, and other CloakBrowser-backed scrapes.
|
||||
|
||||
```bash
|
||||
openclaw approvals allowlist add --agent main "/opt/homebrew/bin/node"
|
||||
openclaw approvals allowlist add --agent main "/usr/bin/env"
|
||||
openclaw approvals allowlist add --agent main "~/.openclaw/workspace/skills/web-automation/scripts/*.js"
|
||||
openclaw approvals allowlist add --agent main "~/.openclaw/workspace/skills/web-automation/scripts/node_modules/.bin/*"
|
||||
```
|
||||
|
||||
Then verify:
|
||||
|
||||
```bash
|
||||
openclaw approvals get
|
||||
```
|
||||
|
||||
Notes:
|
||||
- If `node` lives somewhere else on the host, replace `/opt/homebrew/bin/node` with the output of `which node`.
|
||||
- If matching problems persist, replace `~/.openclaw/...` with the full absolute path such as `/Users/<user>/.openclaw/...`.
|
||||
- Keep the allowlist scoped to the main agent unless there is a real reason to broaden it.
|
||||
- Prefer file-based commands like `node check-install.js` or `node scripts/zillow-photos.js ...` over inline interpreter eval (`node -e`, `node --input-type=module -e`). OpenClaw exec approvals treat inline eval as a higher-friction path.
|
||||
|
||||
## One-shot extraction
|
||||
|
||||
Use `extract.js` when you need a single page fetch with JavaScript rendering and lightweight anti-bot shaping, but not a full automation session.
|
||||
@@ -105,6 +137,67 @@ Example:
|
||||
npx tsx flow.ts --instruction 'go to https://search.fiorinis.com then type "pippo" then press enter then wait 2s'
|
||||
```
|
||||
|
||||
## Real-estate photo extraction
|
||||
|
||||
Use the dedicated extractors before trying a free-form gallery flow.
|
||||
|
||||
- Zillow discovery: `node scripts/zillow-discover.js "<street-address>"`
|
||||
- HAR discovery: `node scripts/har-discover.js "<street-address>"`
|
||||
- Zillow: `node scripts/zillow-photos.js "<listing-url>"`
|
||||
- Zillow identifiers: `node scripts/zillow-identifiers.js "<listing-url>"`
|
||||
- HAR: `node scripts/har-photos.js "<listing-url>"`
|
||||
|
||||
The discovery scripts are purpose-built for the common address-to-listing workflow:
|
||||
- open the site search or address URL
|
||||
- keep apartment / unit identifiers when the address includes them
|
||||
- resolve or identify a matching listing page when possible
|
||||
- reject a mismatched unit when the requested address includes one
|
||||
- still work normally for single-family / no-unit addresses
|
||||
- return the direct listing URL as JSON
|
||||
- support longer source-specific timeouts when a caller such as `property-assessor` imports them for slower exact-unit Zillow pages
|
||||
|
||||
The photo scripts are purpose-built for the common `See all photos` / `Show all photos` workflow:
|
||||
- open the listing page
|
||||
- on Zillow, first inspect the rendered listing shell for a complete structured `__NEXT_DATA__` photo set
|
||||
- if the visible page count is missing, trust the structured Zillow photo set when page metadata confirms the count or when the embedded set is already clearly substantial
|
||||
- only force the all-photos click path when the initial Zillow page data is incomplete
|
||||
- wait for the resulting photo page or scroller view when the click path is actually needed
|
||||
- extract direct image URLs from the rendered page
|
||||
- fail fast with a timeout instead of hanging indefinitely when the browser-backed extraction stalls
|
||||
- support longer source-specific timeouts when a caller such as `property-assessor` imports them for slower exact-unit Zillow renders
|
||||
|
||||
Output is JSON with:
|
||||
- `requestedUrl`
|
||||
- `finalUrl`
|
||||
- `clickedLabel`
|
||||
- `photoCount`
|
||||
- `imageUrls`
|
||||
- `notes`
|
||||
|
||||
`zillow-identifiers.js` is a lighter helper for CAD/public-record workflows:
|
||||
- open the Zillow listing shell
|
||||
- inspect embedded `__NEXT_DATA__` plus visible listing text
|
||||
- capture parcel/APN-style identifiers when Zillow exposes them
|
||||
- return those hints so `property-assessor` can use them as stronger CAD lookup keys than listing geo IDs
|
||||
|
||||
For property-assessor style workflows, prefer these dedicated commands over generic natural-language gallery automation.
|
||||
|
||||
### Gallery/lightbox and all-photos workflows
|
||||
|
||||
For real-estate listings and other image-heavy pages, prefer the most accessible all-photos view first.
|
||||
|
||||
Practical rules:
|
||||
- A scrollable all-photos page, expanded photo grid, or photo list is an acceptable source for condition review if it clearly exposes the listing images.
|
||||
- Do not treat a listing page hero image, gallery collage preview, or modal landing view alone as full photo review.
|
||||
- Only rely on next-arrow / slideshow traversal when the site does not provide an accessible all-photos view.
|
||||
- If using a gallery, confirm the image changed before counting the next screenshot as reviewed.
|
||||
- If a generic `Next` control exits the gallery or returns to the listing shell, stop and adjust the selector/interaction; do not claim the photos were reviewed.
|
||||
- Blind `ArrowRight` presses are not reliable enough unless you have already verified that they advance the gallery on that site.
|
||||
- For smaller listings, review all photos when practical; otherwise review enough distinct photos to cover kitchen, baths, living areas, bedrooms, exterior, and any waterfront/balcony/deck elements.
|
||||
- If automation cannot reliably access enough photos, say so explicitly in the final answer.
|
||||
|
||||
Where possible, prefer a site’s explicit `See all photos` / `Show all photos` path over fragile modal navigation.
|
||||
|
||||
## Compatibility Aliases
|
||||
|
||||
- `CAMOUFOX_PROFILE_PATH` still works as a legacy alias for `CLOAKBROWSER_PROFILE_PATH`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user