mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 16:42:18 +00:00
Three findings from the automated review passes, all confirmed against the code before changing anything. The download URL was dead on arrival for the caller it was built for. Sure serves stored files through Active Storage controllers that config/initializers/active_storage_authorization.rb gates on `viewable_by?(Current.user)` — a signed-in browser session. An MCP client has a bearer token and no session, so following the URL would have redirected to sign-in. Removed it rather than leaving a link that cannot work, and the description now points at search_family_files or the vault UI. Coverage called a month `covered` when a document merely existed. An unreconciled statement is not mismatched, so it took the `covered` branch, and the payload carried nothing to correct the reading — the same "advertised verification that never happened" bug fixed last round in get_account_statement, in a second place. Months now carry their own reconciliation_status, and the description says covered means presence, not agreement. Listing filtered visibility after limiting. Beyond underfilling a page, with no cursor and a 100-row cap an accessible statement behind enough newer invisible ones was unreachable. Visibility now lives in the query, mirroring viewable_by? for a statement manager. Also: rescue unexpected upload failures into a tool error instead of a raw exception string, derive the documented size limit from MAX_FILE_SIZE, list every coverage status in mcp.md, and cover the failed-reconciliation and base64-normalisation branches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn
148 lines
5.5 KiB
Ruby
148 lines
5.5 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?
|
|
|
|
# Visibility is filtered in SQL, not after the fact. Post-filtering a page
|
|
# would both underfill it and — because there is no cursor — make a statement
|
|
# permanently unreachable whenever enough newer rows the caller cannot see sit
|
|
# in front of it. Mirrors AccountStatement#viewable_by? for a statement
|
|
# manager: unlinked statements are visible, linked ones follow the account.
|
|
scope = family.account_statements
|
|
.where(account_id: nil)
|
|
.or(family.account_statements.where(account_id: user.accessible_accounts.select(:id)))
|
|
.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)
|
|
# Over-fetch by one to report has_more without a second count query. The rows
|
|
# are already visibility-scoped, so the page is never underfilled and the
|
|
# count discloses nothing the caller cannot see.
|
|
rows = scope.limit(limit + 1).to_a
|
|
statements = rows.first(limit)
|
|
|
|
{
|
|
success: true,
|
|
returned: statements.size,
|
|
has_more: rows.size > limit,
|
|
statements: statements.map { |statement| statement_payload(statement) }
|
|
}
|
|
end
|
|
end
|