mirror of
https://github.com/we-promise/sure.git
synced 2026-08-05 08:32:15 +00:00
A user wants to manage patrimonial history — a document-backed record of a family's wealth where every figure traces back to the statement it came from — by pointing an external agent harness at Sure. That model belongs in the harness, not in Sure: it needs numbered build deltas, golden tests and closed periods that a mutable Postgres row cannot provide. What Sure was missing was the seam. The Statement Vault already does most of the work — original bytes retained, SHA-256 dedup, period detection, account matching with a confidence score, reconciliation against ledger balances, and a month-by-month coverage map — but it is reachable only from the web UI. An agent could not archive a document, cite one, or check for gaps. Adds five preview MCP tools over what already exists, plus a citation grammar for values the agent writes: - upload_account_statement, list_account_statements, get_account_statement, get_statement_coverage - record_valuation, whose source citation is parsed rather than trusted: ["estimated: "] citation [" (grade: A|B|C)"]. An uncited or free-styled value is rejected at the write boundary instead of landing in the ledger looking authoritative. link and reject are deliberately not exposed. Attaching a statement to an account is the human's decision, and the vault UI is where it is made; the agent reports the suggested match and stops there. Assistant.function_classes now takes a user so preview tools stay out of the default surface. They are hidden from tools/list and not callable by name without the preference enabled, and the vault tools re-check the manager role and per-account permissions, since MCP calls never pass through a controller. Docs: the blueprint this implements, and a guide covering which side owns which layer, the vocabulary map between the two, the monthly runbook, and the gaps (non-user holders, non-statement documents, one value per date). No migrations, no API endpoints, no UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JFDp9HhXDeswadu4cxFojn
184 lines
6.4 KiB
Ruby
184 lines
6.4 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Assistant::Function::UploadAccountStatement < Assistant::Function
|
|
include Assistant::Function::StatementVaultSupport
|
|
|
|
class << self
|
|
def name
|
|
"upload_account_statement"
|
|
end
|
|
|
|
def description
|
|
<<~INSTRUCTIONS
|
|
Store a statement document (PDF, CSV or XLSX) in the family's Statement Vault,
|
|
the canonical archive of primary-source financial documents.
|
|
|
|
The vault keeps the original bytes and indexes them by SHA-256, so uploading
|
|
the same file twice is safe: the existing statement is returned with
|
|
`duplicate: true` and nothing new is created.
|
|
|
|
On upload the vault reads the document's period, institution and account
|
|
hints, and proposes a matching account with a confidence score. It does NOT
|
|
link the statement to that account — linking is a human decision made in
|
|
Settings -> Statement Vault. Report the suggestion; don't claim the link.
|
|
|
|
Provide the file as base64 in `content_base64`. Maximum size is 25 MB.
|
|
|
|
Example:
|
|
|
|
```
|
|
upload_account_statement({
|
|
filename: "private-bank_2026-03-31_monthly-statement.pdf",
|
|
content_base64: "JVBERi0xLjcK..."
|
|
})
|
|
```
|
|
INSTRUCTIONS
|
|
end
|
|
end
|
|
|
|
def strict_mode?
|
|
false
|
|
end
|
|
|
|
def params_schema
|
|
build_schema(
|
|
required: %w[filename content_base64],
|
|
properties: {
|
|
filename: {
|
|
type: "string",
|
|
description: "Filename including extension. Must be one of: #{AccountStatement::ACCEPTED_FILE_EXTENSIONS.join(", ")}."
|
|
},
|
|
content_base64: {
|
|
type: "string",
|
|
description: "The document's raw bytes, base64-encoded."
|
|
},
|
|
account_id: {
|
|
type: "string",
|
|
description: "Optional account UUID to link the statement to on upload. Only pass this when the user has told you which account the document belongs to — otherwise omit it and let the vault suggest a match for the user to confirm."
|
|
}
|
|
}
|
|
)
|
|
end
|
|
|
|
def call(params = {})
|
|
return not_a_statement_manager unless statement_manager?
|
|
|
|
filename = params["filename"].to_s.strip
|
|
return error("filename_required", "Please provide a filename with an extension.") if filename.blank?
|
|
|
|
unless AccountStatement::ACCEPTED_FILE_EXTENSIONS.include?(File.extname(filename).downcase)
|
|
return error(
|
|
"unsupported_file_type",
|
|
"The Statement Vault accepts #{AccountStatement::ACCEPTED_FILE_EXTENSIONS.join(", ")} files only.",
|
|
accepted_extensions: AccountStatement::ACCEPTED_FILE_EXTENSIONS
|
|
)
|
|
end
|
|
|
|
content = decode_content(params["content_base64"])
|
|
return error("invalid_content", "content_base64 could not be decoded as base64.") if content.nil?
|
|
return error("empty_file", "The decoded file is empty.") if content.empty?
|
|
|
|
if content.bytesize > AccountStatement::MAX_FILE_SIZE
|
|
return error(
|
|
"file_too_large",
|
|
"The file is #{content.bytesize} bytes; the maximum is #{AccountStatement::MAX_FILE_SIZE} bytes."
|
|
)
|
|
end
|
|
|
|
account = nil
|
|
if params["account_id"].present?
|
|
account = family.accounts.writable_by(user).find_by(id: params["account_id"]) if valid_uuid?(params["account_id"])
|
|
|
|
unless account
|
|
return error(
|
|
"account_not_found",
|
|
"No writable account matched that account_id. Omit account_id to upload the statement unlinked and let the vault suggest a match."
|
|
)
|
|
end
|
|
end
|
|
|
|
prepared = AccountStatement.prepare_upload!(upload_for(content, filename))
|
|
statement = AccountStatement.create_from_prepared_upload!(family: family, account: account, prepared_upload: prepared)
|
|
|
|
{
|
|
success: true,
|
|
duplicate: false,
|
|
statement: statement_payload(statement),
|
|
message: "Stored #{statement.filename} in the Statement Vault."
|
|
}
|
|
rescue AccountStatement::DuplicateUploadError => e
|
|
duplicate_response(e.statement)
|
|
rescue AccountStatement::InvalidUploadError
|
|
error(
|
|
"invalid_file",
|
|
"The file failed validation: its contents don't match its extension, or it isn't a readable #{AccountStatement::ACCEPTED_FILE_EXTENSIONS.join("/")} document."
|
|
)
|
|
rescue ActiveRecord::RecordInvalid => e
|
|
error("validation_failed", e.record.errors.full_messages.join("; "))
|
|
end
|
|
|
|
private
|
|
# The existing copy may be filed against an account this user cannot see, so
|
|
# the dedup result is reported without the details that would disclose it.
|
|
def duplicate_response(statement)
|
|
if statement.viewable_by?(user)
|
|
{
|
|
success: true,
|
|
duplicate: true,
|
|
statement: statement_payload(statement),
|
|
message: "This document is already in the vault (same SHA-256). Returning the existing statement; nothing was created."
|
|
}
|
|
else
|
|
{
|
|
success: true,
|
|
duplicate: true,
|
|
statement: { content_sha256: statement.content_sha256 },
|
|
message: "This document is already in the vault, filed against an account this user cannot see. Nothing was created."
|
|
}
|
|
end
|
|
end
|
|
|
|
# Whitespace is stripped first because agents routinely wrap long base64
|
|
# across lines, but decoding stays strict after that: Base64.decode64 quietly
|
|
# discards characters it doesn't understand, which would archive corrupted
|
|
# bytes under a hash that looks perfectly legitimate.
|
|
def decode_content(value)
|
|
return nil if value.blank?
|
|
|
|
Base64.strict_decode64(value.to_s.gsub(/\s+/, ""))
|
|
rescue ArgumentError
|
|
nil
|
|
end
|
|
|
|
# AccountStatement.prepare_upload! expects an uploaded-file-like object so it
|
|
# can stream, size-check and sniff the content type. Reusing it (rather than
|
|
# building a PreparedUpload by hand) keeps the MCP path under exactly the same
|
|
# validations as the web upload form. Content type is left nil on purpose so
|
|
# the vault sniffs it from the bytes rather than trusting the caller.
|
|
def upload_for(content, filename)
|
|
DecodedUpload.new(StringIO.new(content), filename)
|
|
end
|
|
|
|
class DecodedUpload
|
|
attr_reader :original_filename, :content_type
|
|
|
|
def initialize(io, filename, content_type = nil)
|
|
@io = io
|
|
@original_filename = filename
|
|
@content_type = content_type
|
|
end
|
|
|
|
def read(*args)
|
|
@io.read(*args)
|
|
end
|
|
|
|
def rewind
|
|
@io.rewind
|
|
end
|
|
|
|
def size
|
|
@io.size
|
|
end
|
|
end
|
|
end
|