Files
sure/app/models/assistant/function/list_account_statements.rb
Claude 74b3fbac8b Correct the vault tools' reconciliation claims and citation parsing
Review findings from @diegomarino, all verified against the code before
changing anything.

The reconciliation claim was the serious one. get_account_statement told
agents the checks were "the trustworthy part" and returned "the balances read
off it" — but nothing reads balances off a document. MetadataDetector never
touches them and create_from_prepared_upload! never sets them; they are
user-editable fields in the Statement Vault UI. So a statement archived over
MCP always came back with an empty check list, which an agent could easily
read as "the document agrees with the ledger" when it means "nobody has
entered the figures". The description now says so, and the payload carries a
reconciliation_note spelling it out for anything reading only the JSON. Also
noted that these checks are ledger agreement, not parse integrity: nothing
here verifies a document's parts sum to its printed total.

Provenance::Citation had two patterns disagreeing about spacing. GRADE_SUFFIX
allowed "(grade:A)" but FORMAT required exactly one space, so that citation
passed the pre-check and then parsed as ungraded with the grade swallowed into
the text — silently discarding the reliability the caller supplied, which is
the one thing this parser exists to prevent.

list_account_statements downcases content_sha256 before querying. The column
is constrained to lowercase hex, so uppercase input could never match, and an
agent would read the empty result as "not archived" and upload a duplicate.
Its period filters are renamed overlapping_from / overlapping_until, since
they match on overlap and the old names claimed otherwise to anyone reading
the schema without the descriptions. has_more now explains that there is no
cursor and the way forward is a bigger limit or narrower filters.

record_valuation no longer overwrites the entry's notes. Re-recording a date
would destroy a note a person had written there. Nothing is removed now: an
identical citation is a no-op, a changed one is appended, and the trail of
what was cited when survives. Detecting "did this tool write that line?" is
not possible — almost any prose parses as a valid ungraded citation — so the
code does not guess.

Minor: accept urlsafe base64 on upload, and explain in the code why
record_valuation checks the account ACL rather than the vault manager role, so
nobody "tightens" it into the wrong permission later.

Tests cover each: the grade-spacing cases both ways, uppercase SHA lookup,
overlap window boundaries, note preservation and no-stacking, the unavailable
reconciliation note appearing and disappearing, and — per the review — that
the download URL's signed id actually expires, rather than trusting the
description's claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn
2026-08-01 17:15:24 +00:00

140 lines
5.1 KiB
Ruby

# frozen_string_literal: true
class Assistant::Function::ListAccountStatements < Assistant::Function
include Assistant::Function::StatementVaultSupport
DEFAULT_LIMIT = 25
MAX_LIMIT = 100
class << self
def name
"list_account_statements"
end
def description
<<~INSTRUCTIONS
List documents in the family's Statement Vault with their identity and
provenance: SHA-256, filename, statement period, linked account, and
review status.
Use this to answer "which statements do we hold?", to find the document
backing a figure, to check whether a file is already archived (filter by
`content_sha256`), or to work the review queue (filter by
`review_status: "unmatched"` for documents awaiting a human's account
decision).
This returns document identity, not document contents. To search inside
uploaded documents use `search_family_files`; to fetch one statement's
reconciliation figures and a download link use `get_account_statement`.
There is no cursor or offset. `has_more: true` means the result was
truncated — raise `limit` (up to #{MAX_LIMIT}) or narrow the filters to see
the rest; paging forward is not possible.
Example:
```
list_account_statements({
review_status: "unmatched",
overlapping_from: "2026-01-01"
})
```
INSTRUCTIONS
end
end
def strict_mode?
false
end
def params_schema
build_schema(
properties: {
account_id: {
type: "string",
description: "Only statements linked to this account UUID."
},
review_status: {
type: "string",
enum: AccountStatement.review_statuses.keys,
description: "unmatched = awaiting a human account decision, linked = attached to an account, rejected = the suggested match was declined."
},
content_sha256: {
type: "string",
description: "Look up a specific document by the SHA-256 of its contents (hex; case-insensitive). Use this to check whether a file is already archived."
},
overlapping_from: {
type: "string",
description: "ISO 8601 date (YYYY-MM-DD). Only statements whose period overlaps this date or later, i.e. whose period ENDS on or after it."
},
overlapping_until: {
type: "string",
description: "ISO 8601 date (YYYY-MM-DD). Only statements whose period overlaps this date or earlier, i.e. whose period STARTS on or before it."
},
limit: {
type: "integer",
description: "Maximum statements to return (default #{DEFAULT_LIMIT}, max #{MAX_LIMIT})."
}
}
)
end
def call(params = {})
return not_a_statement_manager unless statement_manager?
scope = family.account_statements.includes(:account, :suggested_account).ordered
if params["account_id"].present?
return error("invalid_account_id", "account_id must be a UUID.") unless valid_uuid?(params["account_id"])
scope = scope.where(account_id: params["account_id"])
end
if params["review_status"].present?
status = params["review_status"].to_s
unless AccountStatement.review_statuses.key?(status)
return error("invalid_review_status", "review_status must be one of: #{AccountStatement.review_statuses.keys.join(", ")}.")
end
scope = scope.where(review_status: status)
end
# Downcased because the column is constrained to lowercase hex
# (chk_account_statements_content_sha256), so uppercase input would not merely
# be unlikely to match — it could never match, and the agent would read the
# empty result as "not archived" and upload a duplicate.
if params["content_sha256"].present?
scope = scope.where(content_sha256: params["content_sha256"].to_s.strip.downcase)
end
if params["overlapping_from"].present?
date = parse_date(params["overlapping_from"])
return error("invalid_date", "overlapping_from must be an ISO 8601 date (YYYY-MM-DD).") unless date
scope = scope.where("period_end_on >= ?", date)
end
if params["overlapping_until"].present?
date = parse_date(params["overlapping_until"])
return error("invalid_date", "overlapping_until must be an ISO 8601 date (YYYY-MM-DD).") unless date
scope = scope.where("period_start_on <= ?", date)
end
limit = (params["limit"] || DEFAULT_LIMIT).to_i.clamp(1, MAX_LIMIT)
# A statement with no account is visible to any statement manager; a linked
# one follows the account's sharing rules, so filter after the query. Counting
# before that filter would report statements this user may not know exist, so
# the page is over-fetched by one and reported as has_more instead.
rows = scope.limit(limit + 1).to_a
statements = rows.first(limit).select { |statement| statement.viewable_by?(user) }
{
success: true,
returned: statements.size,
has_more: rows.size > limit,
statements: statements.map { |statement| statement_payload(statement) }
}
end
end