Spaces:
Sleeping
Fix live gaps: auditor copy, direct Ask answers, Ask metadata UI, audit tax detection
Browse files- Landing/Auth/Overview copy -> evidence-backed finance auditor positioning
(index.html title+meta, AuthScreen, Overview hero eyebrow/body/footer).
- New extractors.py: deterministic regex extraction of common finance fields
(total, due date, terms, tax, invoice no, late fee, overdue, uptime). Ask now
returns a crisp grounded answer ("The total amount due is $2,413.98.") and falls
back to focused-span extraction; values are verbatim, citations unchanged.
- Ask metadata panel (question type, evidence, confidence, retrieved/cited,
latency) rendered below each answer.
- Audit: detect structured fields via the same extractors and fuzzy clauses via
keyword scan instead of the retrieval gate -- fixes "No tax / VAT found" on
"Tax (8.25%) $183.98" and a parallel false-negative on the contract liability
clause. Audit is now fully deterministic.
Backwards compatible (Ask, audit, evaluate, auth, scoping unchanged in shape).
Verified: 59 backend tests pass, frontend builds, evaluate 7/7.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- backend/app/answering.py +6 -2
- backend/app/audit.py +114 -109
- backend/app/extractors.py +168 -0
- backend/tests/test_audit.py +50 -1
- frontend/index.html +2 -2
- frontend/src/components/views/Ask.jsx +41 -0
- frontend/src/components/views/AuthScreen.jsx +3 -3
- frontend/src/components/views/Overview.jsx +5 -5
- frontend/src/styles.css +29 -0
|
@@ -13,7 +13,7 @@ import logging
|
|
| 13 |
import time
|
| 14 |
from typing import List, Optional, Set, Tuple
|
| 15 |
|
| 16 |
-
from . import config, embeddings, llm, reranking
|
| 17 |
from .models import Chunk
|
| 18 |
from .retrieval import ScoredChunk, hybrid_rank, rank_chunks, tokenize
|
| 19 |
from .schemas import Citation
|
|
@@ -286,7 +286,11 @@ def answer_question(question: str, chunks: List[Chunk], mode: str = "fast"):
|
|
| 286 |
# No key / call failed -> transparently fall back to the fast engine.
|
| 287 |
logger.info("Thinking unavailable; using fast (extractive) for: %s", question)
|
| 288 |
|
| 289 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
metadata = _build_metadata(
|
| 291 |
question_type=question_type,
|
| 292 |
evidence_status="supported",
|
|
|
|
| 13 |
import time
|
| 14 |
from typing import List, Optional, Set, Tuple
|
| 15 |
|
| 16 |
+
from . import config, embeddings, extractors, llm, reranking
|
| 17 |
from .models import Chunk
|
| 18 |
from .retrieval import ScoredChunk, hybrid_rank, rank_chunks, tokenize
|
| 19 |
from .schemas import Citation
|
|
|
|
| 286 |
# No key / call failed -> transparently fall back to the fast engine.
|
| 287 |
logger.info("Thinking unavailable; using fast (extractive) for: %s", question)
|
| 288 |
|
| 289 |
+
# Prefer a crisp, grounded direct answer for common finance questions
|
| 290 |
+
# ("The total amount due is $2,413.98."), extracted verbatim from the retrieved
|
| 291 |
+
# chunks; fall back to the focused span when no pattern matches.
|
| 292 |
+
direct = extractors.extract_direct_answer(question, [s.chunk.text for s in relevant])
|
| 293 |
+
answer = direct or _focused_answer(question, [s.chunk for s in relevant])
|
| 294 |
metadata = _build_metadata(
|
| 295 |
question_type=question_type,
|
| 296 |
evidence_status="supported",
|
|
@@ -1,26 +1,26 @@
|
|
| 1 |
"""Evidence-backed document audit.
|
| 2 |
|
| 3 |
Runs a finance-specific checklist over a document and reports, per check, either
|
| 4 |
-
the cited value the document supports or that the field is missing.
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
| 16 |
"""
|
| 17 |
import logging
|
| 18 |
-
from dataclasses import dataclass
|
| 19 |
from typing import List, Optional, Tuple
|
| 20 |
|
| 21 |
-
from .
|
| 22 |
from .models import Chunk
|
| 23 |
-
from .retrieval import tokenize
|
| 24 |
from .schemas import AuditMetrics, AuditResponse, Citation, Finding
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
|
@@ -30,63 +30,47 @@ logger = logging.getLogger(__name__)
|
|
| 30 |
class _Check:
|
| 31 |
type: str # finding type key (stable id for the UI)
|
| 32 |
label: str # human-readable field name (used in claims/summary)
|
| 33 |
-
query: str #
|
| 34 |
severity: str # severity to assign WHEN THE FIELD IS MISSING (low/medium/high)
|
| 35 |
-
|
|
|
|
| 36 |
|
| 37 |
|
| 38 |
-
# Checklists per audit type.
|
| 39 |
-
# (
|
| 40 |
-
# incidental overlap of generic query words can't pass the check. Matching is
|
| 41 |
-
# case-insensitive substring on the cited excerpt.
|
| 42 |
_CHECKLISTS = {
|
| 43 |
"invoice": [
|
| 44 |
-
_Check("total_amount", "total amount due", "
|
| 45 |
-
|
| 46 |
-
_Check("
|
| 47 |
-
|
| 48 |
-
_Check("
|
| 49 |
-
|
| 50 |
-
_Check("invoice_number", "invoice number", "What is the invoice number?", "low",
|
| 51 |
-
("invoice number", "invoice no", "invoice #", "inv-", "invoice id")),
|
| 52 |
-
_Check("late_fee", "late fee", "What is the late fee or penalty for overdue payment?", "low",
|
| 53 |
-
("late fee", "late payment", "late charge", "overdue", "penalty", "per month", "per annum")),
|
| 54 |
-
_Check("tax", "tax / VAT", "What sales tax or VAT percentage is charged?", "low",
|
| 55 |
-
("tax", "vat", "gst")),
|
| 56 |
],
|
| 57 |
"contract": [
|
| 58 |
-
_Check("payment_terms", "payment terms", "
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
_Check("late_fee", "late fee", "What interest or late fee applies to overdue amounts?", "low",
|
| 69 |
-
("late fee", "late charge", "overdue", "per annum", "per month", "interest on")),
|
| 70 |
],
|
| 71 |
"payment_note": [
|
| 72 |
-
_Check("amount", "
|
| 73 |
-
|
| 74 |
-
_Check("
|
| 75 |
-
|
| 76 |
-
_Check("overdue", "overdue status", "Is the payment overdue, and by how many days?", "medium",
|
| 77 |
-
("overdue", "past due", "days late", "days overdue", "arrears")),
|
| 78 |
-
_Check("reference", "reference number", "What is the invoice or reference number?", "low",
|
| 79 |
-
("reference", "ref ", "ref:", "ref.", "invoice no", "invoice number", "inv-")),
|
| 80 |
],
|
| 81 |
"general": [
|
| 82 |
-
_Check("total_amount", "amount", "
|
| 83 |
-
|
| 84 |
-
_Check("
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
("net ", "payment term", "terms")),
|
| 88 |
-
_Check("parties", "parties", "Who are the parties, vendor, or customer involved?", "low",
|
| 89 |
-
("vendor", "customer", "client", "bill to", "sold to", "supplier", "between")),
|
| 90 |
],
|
| 91 |
}
|
| 92 |
|
|
@@ -95,63 +79,84 @@ def checklist_for(audit_type: str) -> List[_Check]:
|
|
| 95 |
return _CHECKLISTS.get(audit_type) or _CHECKLISTS["general"]
|
| 96 |
|
| 97 |
|
| 98 |
-
def
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
"""
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
return None
|
| 108 |
|
| 109 |
|
| 110 |
-
def
|
| 111 |
-
""
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
def run_audit(audit_type: str, document_name: str, chunks: List[Chunk]) -> AuditResponse:
|
| 123 |
-
"""Run the checklist for ``audit_type`` over ``chunks`` and return the audit.
|
| 124 |
-
|
| 125 |
-
For each check we retrieve candidate evidence via ``answer_question`` and then
|
| 126 |
-
require the cited excerpt to contain the field's discriminator. Only then is
|
| 127 |
-
the finding SUPPORTED (with the cited value + citation); otherwise the field
|
| 128 |
-
is flagged unsupported (no citation) and takes the check's severity β a missing
|
| 129 |
-
critical field drives the risk level.
|
| 130 |
-
"""
|
| 131 |
findings: List[Finding] = []
|
|
|
|
|
|
|
| 132 |
for chk in checklist_for(audit_type):
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
citation=cite,
|
| 143 |
-
)
|
| 144 |
-
)
|
| 145 |
else:
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
)
|
| 154 |
-
|
|
|
|
| 155 |
|
| 156 |
supported = [f for f in findings if f.citation is not None]
|
| 157 |
unsupported = [f for f in findings if f.citation is None]
|
|
|
|
| 1 |
"""Evidence-backed document audit.
|
| 2 |
|
| 3 |
Runs a finance-specific checklist over a document and reports, per check, either
|
| 4 |
+
the cited value the document supports or that the field is missing. Two grounded
|
| 5 |
+
detection strategies (neither invents a value):
|
| 6 |
+
|
| 7 |
+
* Structured fields (totals, due dates, terms, tax, invoice numbers, late fees,
|
| 8 |
+
overdue days) are detected by deterministic regex extractors over the document
|
| 9 |
+
chunks β precise, and finds a value even where the generic /ask retrieval gate
|
| 10 |
+
would abstain (e.g. a lone "Tax (8.25%) $183.98" line, the live bug this fixes).
|
| 11 |
+
* Fuzzy clauses (termination, liability, renewal, governing law, parties) have no
|
| 12 |
+
clean regex, so they are detected by scanning the chunks for the clause's
|
| 13 |
+
discriminator keywords and citing the surrounding text.
|
| 14 |
+
|
| 15 |
+
Both paths are deterministic (no model, no retrieval gate). A check that finds
|
| 16 |
+
nothing is an *unsupported* finding; a missing critical field drives the risk.
|
| 17 |
"""
|
| 18 |
import logging
|
| 19 |
+
from dataclasses import dataclass, field
|
| 20 |
from typing import List, Optional, Tuple
|
| 21 |
|
| 22 |
+
from . import extractors
|
| 23 |
from .models import Chunk
|
|
|
|
| 24 |
from .schemas import AuditMetrics, AuditResponse, Citation, Finding
|
| 25 |
|
| 26 |
logger = logging.getLogger(__name__)
|
|
|
|
| 30 |
class _Check:
|
| 31 |
type: str # finding type key (stable id for the UI)
|
| 32 |
label: str # human-readable field name (used in claims/summary)
|
| 33 |
+
query: str # short description of what the check looks for
|
| 34 |
severity: str # severity to assign WHEN THE FIELD IS MISSING (low/medium/high)
|
| 35 |
+
extractor: Optional[str] = None # regex extractor key (structured fields)
|
| 36 |
+
keywords: Tuple[str, ...] = field(default_factory=tuple) # discriminators (fuzzy clauses)
|
| 37 |
|
| 38 |
|
| 39 |
+
# Checklists per audit type. Structured fields set `extractor`; fuzzy clauses set
|
| 40 |
+
# `keywords` (distinctive phrases whose presence indicates the clause).
|
|
|
|
|
|
|
| 41 |
_CHECKLISTS = {
|
| 42 |
"invoice": [
|
| 43 |
+
_Check("total_amount", "total amount due", "total amount due", "high", extractor="total_amount"),
|
| 44 |
+
_Check("due_date", "due date", "payment due date", "high", extractor="due_date"),
|
| 45 |
+
_Check("payment_terms", "payment terms", "payment terms", "medium", extractor="payment_terms"),
|
| 46 |
+
_Check("invoice_number", "invoice number", "invoice number", "low", extractor="invoice_number"),
|
| 47 |
+
_Check("late_fee", "late fee", "late fee for overdue payment", "low", extractor="late_fee"),
|
| 48 |
+
_Check("tax", "tax / VAT", "sales tax or VAT", "low", extractor="tax"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
],
|
| 50 |
"contract": [
|
| 51 |
+
_Check("payment_terms", "payment terms", "payment terms", "high", extractor="payment_terms"),
|
| 52 |
+
_Check("termination", "termination clause", "termination clause or notice period", "medium",
|
| 53 |
+
keywords=("terminat", "notice period", "days' notice", "days notice", "may cancel", "for cause")),
|
| 54 |
+
_Check("liability", "liability clause", "liability or indemnification clause", "medium",
|
| 55 |
+
keywords=("liabilit", "indemnif", "indemnit", "limitation of")),
|
| 56 |
+
_Check("renewal", "renewal terms", "automatic renewal or renewal term", "medium",
|
| 57 |
+
keywords=("renew", "auto-renew", "automatic renewal", "renewal term")),
|
| 58 |
+
_Check("governing_law", "governing law", "governing law or jurisdiction", "low",
|
| 59 |
+
keywords=("governing law", "jurisdiction", "governed by", "laws of", "venue")),
|
| 60 |
+
_Check("late_fee", "late fee", "interest or late fee on overdue amounts", "low", extractor="late_fee"),
|
|
|
|
|
|
|
| 61 |
],
|
| 62 |
"payment_note": [
|
| 63 |
+
_Check("amount", "outstanding amount", "outstanding balance", "high", extractor="amount"),
|
| 64 |
+
_Check("due_date", "due date", "payment due date", "high", extractor="due_date"),
|
| 65 |
+
_Check("overdue", "overdue status", "days overdue", "medium", extractor="overdue"),
|
| 66 |
+
_Check("reference", "reference number", "invoice or reference number", "low", extractor="reference"),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
],
|
| 68 |
"general": [
|
| 69 |
+
_Check("total_amount", "amount", "total amount or balance", "medium", extractor="total_amount"),
|
| 70 |
+
_Check("date", "key date", "due date or key date", "medium", extractor="date"),
|
| 71 |
+
_Check("payment_terms", "payment terms", "payment terms", "low", extractor="payment_terms"),
|
| 72 |
+
_Check("parties", "parties", "parties, vendor, or customer", "low",
|
| 73 |
+
keywords=("vendor", "customer", "client", "bill to", "sold to", "supplier", "between")),
|
|
|
|
|
|
|
|
|
|
| 74 |
],
|
| 75 |
}
|
| 76 |
|
|
|
|
| 79 |
return _CHECKLISTS.get(audit_type) or _CHECKLISTS["general"]
|
| 80 |
|
| 81 |
|
| 82 |
+
def _excerpt_around(text: str, pos: int, length: int, pad: Tuple[int, int] = (55, 95)) -> str:
|
| 83 |
+
"""A readable, ellipsised window of `text` around [pos, pos+length)."""
|
| 84 |
+
s, e = max(0, pos - pad[0]), min(len(text), pos + length + pad[1])
|
| 85 |
+
return ("β¦" if s > 0 else "") + text[s:e].strip() + ("β¦" if e < len(text) else "")
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _citation(chunk: Chunk, excerpt: str, match_text: str) -> Citation:
|
| 89 |
+
return Citation(
|
| 90 |
+
document_id=chunk.document_id,
|
| 91 |
+
document_name=chunk.document.filename if chunk.document else "unknown",
|
| 92 |
+
chunk_index=chunk.chunk_index,
|
| 93 |
+
page=chunk.page,
|
| 94 |
+
excerpt=excerpt,
|
| 95 |
+
score=1.0, # exact match -> full confidence
|
| 96 |
+
match_text=match_text,
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _citation_for_span(chunk: Chunk, span: str) -> Citation:
|
| 101 |
+
"""Citation around a verbatim regex-matched span within a chunk."""
|
| 102 |
+
pos = chunk.text.lower().find(span.lower())
|
| 103 |
+
excerpt = span if pos < 0 else _excerpt_around(chunk.text, pos, len(span), pad=(50, 50))
|
| 104 |
+
return _citation(chunk, excerpt, span)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _find_clause(keywords: Tuple[str, ...], chunks: List[Chunk]):
|
| 108 |
+
"""First (chunk, position, keyword_len) where a discriminator keyword appears."""
|
| 109 |
+
for c in chunks:
|
| 110 |
+
low = c.text.lower()
|
| 111 |
+
for kw in keywords:
|
| 112 |
+
k = kw.strip().lower()
|
| 113 |
+
if k:
|
| 114 |
+
pos = low.find(k)
|
| 115 |
+
if pos != -1:
|
| 116 |
+
return c, pos, len(k)
|
| 117 |
return None
|
| 118 |
|
| 119 |
|
| 120 |
+
def _supported(type_: str, claim: str, evidence: str, citation: Citation) -> Finding:
|
| 121 |
+
return Finding(type=type_, severity="low", claim=claim, evidence=evidence, citation=citation)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _missing(chk: _Check) -> Finding:
|
| 125 |
+
return Finding(
|
| 126 |
+
type=chk.type,
|
| 127 |
+
severity=chk.severity,
|
| 128 |
+
claim=f"No {chk.label} found in the document.",
|
| 129 |
+
evidence="",
|
| 130 |
+
citation=None,
|
| 131 |
+
)
|
| 132 |
|
| 133 |
|
| 134 |
def run_audit(audit_type: str, document_name: str, chunks: List[Chunk]) -> AuditResponse:
|
| 135 |
+
"""Run the checklist for ``audit_type`` over ``chunks`` and return the audit."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
findings: List[Finding] = []
|
| 137 |
+
chunk_texts = [c.text for c in chunks]
|
| 138 |
+
|
| 139 |
for chk in checklist_for(audit_type):
|
| 140 |
+
if chk.extractor:
|
| 141 |
+
# Structured field: deterministic regex over the document chunks.
|
| 142 |
+
hit = extractors.find_field(chk.extractor, chunk_texts)
|
| 143 |
+
if hit is not None:
|
| 144 |
+
idx, span, _answer = hit
|
| 145 |
+
cite = _citation_for_span(chunks[idx], span)
|
| 146 |
+
findings.append(_supported(chk.type, span, cite.excerpt, cite))
|
| 147 |
+
else:
|
| 148 |
+
findings.append(_missing(chk))
|
|
|
|
|
|
|
|
|
|
| 149 |
else:
|
| 150 |
+
# Fuzzy clause: scan the chunks for a discriminator keyword and cite
|
| 151 |
+
# the surrounding text (no retrieval gate, so no false-negative abstain).
|
| 152 |
+
found = _find_clause(chk.keywords, chunks)
|
| 153 |
+
if found is not None:
|
| 154 |
+
chunk, pos, klen = found
|
| 155 |
+
excerpt = _excerpt_around(chunk.text, pos, klen)
|
| 156 |
+
cite = _citation(chunk, excerpt, chunk.text[pos : pos + klen])
|
| 157 |
+
findings.append(_supported(chk.type, excerpt, excerpt, cite))
|
| 158 |
+
else:
|
| 159 |
+
findings.append(_missing(chk))
|
| 160 |
|
| 161 |
supported = [f for f in findings if f.citation is not None]
|
| 162 |
unsupported = [f for f in findings if f.citation is None]
|
|
@@ -0,0 +1,168 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic, regex-based extraction of common finance fields.
|
| 2 |
+
|
| 3 |
+
Used in two grounded places (never invents β every value is copied verbatim from
|
| 4 |
+
the supplied text, we only wrap it in a sentence):
|
| 5 |
+
|
| 6 |
+
* /ask β turn a long grounded span into a crisp direct answer
|
| 7 |
+
("The total amount due is $2,413.98.") for common finance questions,
|
| 8 |
+
falling back to the existing focused-span answer when nothing matches.
|
| 9 |
+
* /audit β detect a field and its verbatim value even when the generic
|
| 10 |
+
retrieval gate would abstain (e.g. a lone "Tax (8.25%) $183.98"
|
| 11 |
+
line that shares too few query words to clear the /ask gate).
|
| 12 |
+
|
| 13 |
+
Each extractor returns (verbatim_span, formatted_answer) or None.
|
| 14 |
+
"""
|
| 15 |
+
import re
|
| 16 |
+
from dataclasses import dataclass
|
| 17 |
+
from typing import Callable, List, Optional, Tuple
|
| 18 |
+
|
| 19 |
+
Extracted = Tuple[str, str] # (verbatim matched span, formatted direct answer)
|
| 20 |
+
|
| 21 |
+
# A currency amount. Requires a leading "$" so a bare percentage or count can't be
|
| 22 |
+
# mistaken for money (e.g. "Tax 8.25%" must NOT read as "$8.25"). Cents optional.
|
| 23 |
+
_MONEY = r"\$\s?[\d,]+(?:\.\d{2})?"
|
| 24 |
+
# A date like "April 2, 2025" / "April 2 2025" or "04/02/2025".
|
| 25 |
+
_DATE = r"[A-Za-z]{3,9}\.?\s+\d{1,2},?\s+\d{2,4}|\d{1,2}[/\-.]\d{1,2}[/\-.]\d{2,4}"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _money(s: str) -> str:
|
| 29 |
+
s = s.strip()
|
| 30 |
+
return s if s.startswith("$") else "$" + s.lstrip("$ ")
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def _search(text: str, pattern: str):
|
| 34 |
+
return re.search(pattern, text, re.IGNORECASE)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _ex_total(text: str) -> Optional[Extracted]:
|
| 38 |
+
m = _search(text, rf"(?:total\s+amount\s+(?:due|payable)|balance\s+due|grand\s+total|total\s+(?:due|payable)|amount\s+due)\s*[:\-]?\s*({_MONEY})")
|
| 39 |
+
if not m:
|
| 40 |
+
return None
|
| 41 |
+
return m.group(0).strip(), f"The total amount due is {_money(m.group(1))}."
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _ex_amount(text: str) -> Optional[Extracted]:
|
| 45 |
+
m = _search(text, rf"(?:amount\s+outstanding|outstanding\s+balance|balance\s+outstanding|amount\s+payable|outstanding)\s*[:\-]?\s*({_MONEY})")
|
| 46 |
+
if m:
|
| 47 |
+
return m.group(0).strip(), f"The amount outstanding is {_money(m.group(1))}."
|
| 48 |
+
return _ex_total(text) # fall back to a stated total / amount due
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _ex_tax(text: str) -> Optional[Extracted]:
|
| 52 |
+
# "Tax (8.25%) $183.98", "Sales Tax $183.98", "VAT $183.98". The % is optional
|
| 53 |
+
# and the dollar amount is required, so "exclusive of applicable taxes" (no $)
|
| 54 |
+
# does not register as a tax line.
|
| 55 |
+
m = _search(text, rf"(?:sales\s+tax|tax|vat|gst)\b\s*(\(\s*[\d.]+\s*%\s*\))?\s*[:\-]?\s*({_MONEY})")
|
| 56 |
+
if not m:
|
| 57 |
+
return None
|
| 58 |
+
pct = (m.group(1) or "").strip()
|
| 59 |
+
answer = f"The tax is {_money(m.group(2))}" + (f" {pct}." if pct else ".")
|
| 60 |
+
return m.group(0).strip(), answer
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def _ex_due_date(text: str) -> Optional[Extracted]:
|
| 64 |
+
m = _search(text, rf"(?:due\s+date|payment\s+due|due\s+by|pay\s+by|payable\s+by|due\s+on)\s*[:\-]?\s*({_DATE})")
|
| 65 |
+
if not m:
|
| 66 |
+
return None
|
| 67 |
+
return m.group(0).strip(), f"The invoice due date is {m.group(1).strip()}."
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _ex_terms(text: str) -> Optional[Extracted]:
|
| 71 |
+
m = _search(text, r"\bnet\s+\d{1,3}\b")
|
| 72 |
+
if m:
|
| 73 |
+
terms = re.sub(r"net", "Net", m.group(0), flags=re.IGNORECASE)
|
| 74 |
+
return m.group(0).strip(), f"The payment terms are {terms}."
|
| 75 |
+
m = _search(text, r"(?:payable|due)\s+within\s+[\w()]+\s+days")
|
| 76 |
+
if m:
|
| 77 |
+
return m.group(0).strip(), f"The payment terms are {m.group(0).strip()}."
|
| 78 |
+
return None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _ex_invoice_no(text: str) -> Optional[Extracted]:
|
| 82 |
+
m = _search(text, r"(?:invoice\s+(?:number|no\.?|#|id)|reference)\s*[:\-]?\s*([A-Z]{2,}[-\d][A-Z0-9\-]*)")
|
| 83 |
+
if not m:
|
| 84 |
+
return None
|
| 85 |
+
return m.group(0).strip(), f"The invoice number is {m.group(1).strip()}."
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _ex_late_fee(text: str) -> Optional[Extracted]:
|
| 89 |
+
m = _search(text, r"(?:late\s+(?:fee|payment|charge)|finance\s+charge|past\s+due)[^.]{0,45}?(\d+(?:\.\d+)?\s*%(?:\s*(?:per\s+month|per\s+annum|monthly|annually))?|\$[\d,]+(?:\.\d{2})?)")
|
| 90 |
+
if not m:
|
| 91 |
+
return None
|
| 92 |
+
return m.group(0).strip(), f"The late fee is {m.group(1).strip()}."
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _ex_overdue(text: str) -> Optional[Extracted]:
|
| 96 |
+
m = _search(text, r"(\d{1,4})\s+days?\s+(?:overdue|past\s+due|late)")
|
| 97 |
+
if not m:
|
| 98 |
+
m = _search(text, r"days?\s+overdue\s*[:\-]?\s*(\d{1,4})")
|
| 99 |
+
if not m:
|
| 100 |
+
return None
|
| 101 |
+
return m.group(0).strip(), f"The payment is {m.group(1)} days overdue."
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _ex_uptime(text: str) -> Optional[Extracted]:
|
| 105 |
+
m = _search(text, r"uptime\s+(?:of\s+|commitment\s+(?:of\s+)?|guarantee\s+(?:of\s+)?|is\s+)?(\d{1,3}(?:\.\d+)?\s*%)")
|
| 106 |
+
if not m:
|
| 107 |
+
m = _search(text, r"(\d{1,3}(?:\.\d+)?\s*%)\s+(?:monthly\s+)?uptime")
|
| 108 |
+
if not m:
|
| 109 |
+
return None
|
| 110 |
+
return m.group(0).strip(), f"The uptime commitment is {m.group(1).strip()}."
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@dataclass(frozen=True)
|
| 114 |
+
class _Field:
|
| 115 |
+
key: str
|
| 116 |
+
triggers: Tuple[str, ...] # question substrings that select this extractor
|
| 117 |
+
fn: Callable[[str], Optional[Extracted]]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# Order matters: the first field whose trigger appears in the question wins, so
|
| 121 |
+
# specific intents (total / invoice number / due date) precede broader ones.
|
| 122 |
+
_FIELDS: List[_Field] = [
|
| 123 |
+
_Field("total_amount", ("total amount", "amount due", "balance due", "grand total", "total due", "how much do", "how much is"), _ex_total),
|
| 124 |
+
_Field("invoice_number", ("invoice number", "invoice no", "invoice #", "invoice id", "reference number"), _ex_invoice_no),
|
| 125 |
+
_Field("tax", ("tax", "vat", "gst"), _ex_tax),
|
| 126 |
+
_Field("due_date", ("due date", "due by", "when is", "when due", "payment due", "what date"), _ex_due_date),
|
| 127 |
+
_Field("payment_terms", ("payment term", "net terms", "credit terms"), _ex_terms),
|
| 128 |
+
_Field("late_fee", ("late fee", "late payment", "late charge", "finance charge", "penalty"), _ex_late_fee),
|
| 129 |
+
_Field("overdue", ("overdue", "days late", "past due", "how late"), _ex_overdue),
|
| 130 |
+
_Field("uptime", ("uptime", "availability", "service level", "sla"), _ex_uptime),
|
| 131 |
+
]
|
| 132 |
+
|
| 133 |
+
# Extractor lookup by field key (used by the audit, which knows the field up front).
|
| 134 |
+
_BY_KEY: dict = {
|
| 135 |
+
"total_amount": _ex_total, "amount": _ex_amount, "due_date": _ex_due_date,
|
| 136 |
+
"date": _ex_due_date, "payment_terms": _ex_terms, "invoice_number": _ex_invoice_no,
|
| 137 |
+
"reference": _ex_invoice_no, "late_fee": _ex_late_fee, "tax": _ex_tax,
|
| 138 |
+
"overdue": _ex_overdue, "uptime": _ex_uptime,
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def extract_direct_answer(question: str, texts: List[str]) -> Optional[str]:
|
| 143 |
+
"""A crisp grounded answer for a common finance question, or None to let the
|
| 144 |
+
caller fall back to span extraction. Only the FIRST field whose trigger words
|
| 145 |
+
appear in the question is tried (across the retrieved texts, in order)."""
|
| 146 |
+
q = question.lower()
|
| 147 |
+
for fld in _FIELDS:
|
| 148 |
+
if any(t in q for t in fld.triggers):
|
| 149 |
+
for text in texts:
|
| 150 |
+
res = fld.fn(text)
|
| 151 |
+
if res:
|
| 152 |
+
return res[1]
|
| 153 |
+
return None # intent recognized but no value found -> fall back to span
|
| 154 |
+
return None
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def find_field(field_key: str, texts: List[str]) -> Optional[Tuple[int, str, str]]:
|
| 158 |
+
"""For the audit: return (text_index, verbatim_span, formatted_answer) for the
|
| 159 |
+
first text whose extractor matches the field, or None if the field isn't found
|
| 160 |
+
(or has no extractor)."""
|
| 161 |
+
fn = _BY_KEY.get(field_key)
|
| 162 |
+
if fn is None:
|
| 163 |
+
return None
|
| 164 |
+
for i, text in enumerate(texts):
|
| 165 |
+
res = fn(text)
|
| 166 |
+
if res:
|
| 167 |
+
return i, res[0], res[1]
|
| 168 |
+
return None
|
|
@@ -19,7 +19,7 @@ _INVOICE = (
|
|
| 19 |
# date. The audit must NOT mark those fields supported off incidental overlaps.
|
| 20 |
_TRAP_INVOICE = (
|
| 21 |
b"Invoice INV-99. Sales representative John handled this order. "
|
| 22 |
-
b"Total Amount Due
|
| 23 |
b"Percentage discount none."
|
| 24 |
)
|
| 25 |
|
|
@@ -121,6 +121,55 @@ def test_audit_on_missing_document_returns_404(client):
|
|
| 121 |
assert res.status_code == 404
|
| 122 |
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
def test_audit_cannot_access_another_users_document(client, second_client):
|
| 125 |
# Per-user scoping: a different user must not be able to audit (or even learn
|
| 126 |
# of) someone else's private document β it 404s, never 403/leaks.
|
|
|
|
| 19 |
# date. The audit must NOT mark those fields supported off incidental overlaps.
|
| 20 |
_TRAP_INVOICE = (
|
| 21 |
b"Invoice INV-99. Sales representative John handled this order. "
|
| 22 |
+
b"Total Amount Due $5,000.00. Charged to account 12 on file. "
|
| 23 |
b"Percentage discount none."
|
| 24 |
)
|
| 25 |
|
|
|
|
| 121 |
assert res.status_code == 404
|
| 122 |
|
| 123 |
|
| 124 |
+
def test_audit_detects_tax_in_sample_invoice(client):
|
| 125 |
+
# Regression for the live bug: sample_invoice.txt contains "Tax (8.25%) $183.98"
|
| 126 |
+
# but the audit reported "No tax found". The regex extractor must detect it.
|
| 127 |
+
client.post("/demo/seed")
|
| 128 |
+
inv = next(
|
| 129 |
+
d for d in client.get("/documents").json() if d["filename"] == "sample_invoice.txt"
|
| 130 |
+
)
|
| 131 |
+
body = client.post(
|
| 132 |
+
"/audit", json={"document_id": inv["id"], "audit_type": "invoice"}
|
| 133 |
+
).json()
|
| 134 |
+
findings = _by_type(body["findings"])
|
| 135 |
+
tax = findings["tax"]
|
| 136 |
+
assert tax["citation"] is not None, tax
|
| 137 |
+
assert "183.98" in tax["claim"]
|
| 138 |
+
assert "8.25%" in (tax["claim"] + tax["evidence"])
|
| 139 |
+
# The real invoice also has a total, due date, terms, number, and late fee.
|
| 140 |
+
for key in ("total_amount", "due_date", "payment_terms", "invoice_number", "late_fee"):
|
| 141 |
+
assert findings[key]["citation"] is not None, findings[key]
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def test_ask_returns_direct_total_amount_answer(client):
|
| 145 |
+
# /ask must give a crisp grounded answer, not a long raw chunk.
|
| 146 |
+
client.post("/demo/seed")
|
| 147 |
+
inv = next(
|
| 148 |
+
d for d in client.get("/documents").json() if d["filename"] == "sample_invoice.txt"
|
| 149 |
+
)
|
| 150 |
+
res = client.post(
|
| 151 |
+
"/ask", json={"document_id": inv["id"], "question": "What is the total amount due?"}
|
| 152 |
+
).json()
|
| 153 |
+
assert res["answer"] == "The total amount due is $2,413.98."
|
| 154 |
+
assert res["abstained"] is False
|
| 155 |
+
assert len(res["citations"]) >= 1 # citations unchanged
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def test_contract_audit_detects_fuzzy_clauses(client):
|
| 159 |
+
# The fuzzy (retrieval + discriminator) path still finds clause fields.
|
| 160 |
+
client.post("/demo/seed")
|
| 161 |
+
contract = next(
|
| 162 |
+
d for d in client.get("/documents").json()
|
| 163 |
+
if d["filename"] == "sample_vendor_contract.txt"
|
| 164 |
+
)
|
| 165 |
+
body = client.post(
|
| 166 |
+
"/audit", json={"document_id": contract["id"], "audit_type": "contract"}
|
| 167 |
+
).json()
|
| 168 |
+
findings = _by_type(body["findings"])
|
| 169 |
+
for key in ("payment_terms", "termination", "liability", "governing_law"):
|
| 170 |
+
assert findings[key]["citation"] is not None, findings[key]
|
| 171 |
+
|
| 172 |
+
|
| 173 |
def test_audit_cannot_access_another_users_document(client, second_client):
|
| 174 |
# Per-user scoping: a different user must not be able to audit (or even learn
|
| 175 |
# of) someone else's private document β it 404s, never 403/leaks.
|
|
@@ -6,9 +6,9 @@
|
|
| 6 |
<meta name="theme-color" content="#fbfbfd" />
|
| 7 |
<meta
|
| 8 |
name="description"
|
| 9 |
-
content="FinanceFlow AI β
|
| 10 |
/>
|
| 11 |
-
<title>FinanceFlow AI β
|
| 12 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 13 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 14 |
<link
|
|
|
|
| 6 |
<meta name="theme-color" content="#fbfbfd" />
|
| 7 |
<meta
|
| 8 |
name="description"
|
| 9 |
+
content="FinanceFlow AI β evidence-backed assistant for auditing invoices, contracts, and payment notes."
|
| 10 |
/>
|
| 11 |
+
<title>FinanceFlow AI β Evidence-backed finance document auditor</title>
|
| 12 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 13 |
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
| 14 |
<link
|
|
@@ -268,6 +268,47 @@ export default function Ask({ documents, demoSignal, llmAvailable, onDemoConsume
|
|
| 268 |
<p className={`answer-body ${result.abstained ? "dim" : ""}`}>
|
| 269 |
{result.answer}
|
| 270 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
{result.requestedMode === "thinking" && result.mode === "fast" && (
|
| 272 |
<p className="seg-note" style={{ margin: "10px 0 0" }}>
|
| 273 |
Thinking was unavailable for this request β answered with Fast.
|
|
|
|
| 268 |
<p className={`answer-body ${result.abstained ? "dim" : ""}`}>
|
| 269 |
{result.answer}
|
| 270 |
</p>
|
| 271 |
+
{result.metadata && (
|
| 272 |
+
<dl className="ask-meta">
|
| 273 |
+
<div className="ask-meta-row">
|
| 274 |
+
<dt className="ask-meta-k">Question type</dt>
|
| 275 |
+
<dd className="ask-meta-v">
|
| 276 |
+
{typeof result.metadata.question_type === "string"
|
| 277 |
+
? result.metadata.question_type.replace(/_/g, " ")
|
| 278 |
+
: "β"}
|
| 279 |
+
</dd>
|
| 280 |
+
</div>
|
| 281 |
+
<div className="ask-meta-row">
|
| 282 |
+
<dt className="ask-meta-k">Evidence</dt>
|
| 283 |
+
<dd className="ask-meta-v">
|
| 284 |
+
{result.metadata.evidence_status ?? "β"}
|
| 285 |
+
</dd>
|
| 286 |
+
</div>
|
| 287 |
+
<div className="ask-meta-row">
|
| 288 |
+
<dt className="ask-meta-k">Confidence</dt>
|
| 289 |
+
<dd className="ask-meta-v">
|
| 290 |
+
{typeof result.metadata.confidence === "number"
|
| 291 |
+
? `${Math.round(result.metadata.confidence * 100)}%`
|
| 292 |
+
: "β"}
|
| 293 |
+
</dd>
|
| 294 |
+
</div>
|
| 295 |
+
<div className="ask-meta-row">
|
| 296 |
+
<dt className="ask-meta-k">Retrieved / cited</dt>
|
| 297 |
+
<dd className="ask-meta-v">
|
| 298 |
+
{result.metadata.retrieved_chunks ?? "β"} /{" "}
|
| 299 |
+
{result.metadata.cited_chunks ?? "β"}
|
| 300 |
+
</dd>
|
| 301 |
+
</div>
|
| 302 |
+
<div className="ask-meta-row">
|
| 303 |
+
<dt className="ask-meta-k">Latency</dt>
|
| 304 |
+
<dd className="ask-meta-v">
|
| 305 |
+
{typeof result.metadata.latency_ms === "number"
|
| 306 |
+
? `${result.metadata.latency_ms} ms`
|
| 307 |
+
: "β"}
|
| 308 |
+
</dd>
|
| 309 |
+
</div>
|
| 310 |
+
</dl>
|
| 311 |
+
)}
|
| 312 |
{result.requestedMode === "thinking" && result.mode === "fast" && (
|
| 313 |
<p className="seg-note" style={{ margin: "10px 0 0" }}>
|
| 314 |
Thinking was unavailable for this request β answered with Fast.
|
|
@@ -132,8 +132,8 @@ export default function AuthScreen({
|
|
| 132 |
Answers you can <span className="clip-light">prove.</span>
|
| 133 |
</h1>
|
| 134 |
<p className="auth-brand-sub">
|
| 135 |
-
|
| 136 |
-
it abstains when
|
| 137 |
</p>
|
| 138 |
<ul className="auth-feat">
|
| 139 |
<li>
|
|
@@ -159,7 +159,7 @@ export default function AuthScreen({
|
|
| 159 |
</li>
|
| 160 |
</ul>
|
| 161 |
</div>
|
| 162 |
-
<p className="auth-brand-foot">
|
| 163 |
</aside>
|
| 164 |
|
| 165 |
{/* ---- Form panel ---- */}
|
|
|
|
| 132 |
Answers you can <span className="clip-light">prove.</span>
|
| 133 |
</h1>
|
| 134 |
<p className="auth-brand-sub">
|
| 135 |
+
Audit invoices, contracts, and payment notes β every answer cites the
|
| 136 |
+
exact source, and it abstains when evidence is missing.
|
| 137 |
</p>
|
| 138 |
<ul className="auth-feat">
|
| 139 |
<li>
|
|
|
|
| 159 |
</li>
|
| 160 |
</ul>
|
| 161 |
</div>
|
| 162 |
+
<p className="auth-brand-foot">Evidence-backed finance document intelligence.</p>
|
| 163 |
</aside>
|
| 164 |
|
| 165 |
{/* ---- Form panel ---- */}
|
|
@@ -98,15 +98,15 @@ export default function Overview({ documents, loading, reload, onTryDemo, seedin
|
|
| 98 |
<section className="hero-stage">
|
| 99 |
<div>
|
| 100 |
<span className="hero-eyebrow">
|
| 101 |
-
<IconSpark size={14} />
|
| 102 |
</span>
|
| 103 |
<h1 className="hero-h1">
|
| 104 |
Answers you can <span className="clip">prove.</span>
|
| 105 |
</h1>
|
| 106 |
<p className="hero-sub">
|
| 107 |
-
FinanceFlow
|
| 108 |
-
|
| 109 |
-
|
| 110 |
</p>
|
| 111 |
<div className="hero-cta">
|
| 112 |
<Button variant="primary" size="lg" loading={seeding} onClick={onTryDemo}>
|
|
@@ -325,7 +325,7 @@ export default function Overview({ documents, loading, reload, onTryDemo, seedin
|
|
| 325 |
</span>
|
| 326 |
FinanceFlow AI
|
| 327 |
</span>
|
| 328 |
-
<span>
|
| 329 |
<a href="https://github.com/aissugur/financeflow-ai" target="_blank" rel="noreferrer">
|
| 330 |
GitHub β
|
| 331 |
</a>
|
|
|
|
| 98 |
<section className="hero-stage">
|
| 99 |
<div>
|
| 100 |
<span className="hero-eyebrow">
|
| 101 |
+
<IconSpark size={14} /> Audit-ready finance answers
|
| 102 |
</span>
|
| 103 |
<h1 className="hero-h1">
|
| 104 |
Answers you can <span className="clip">prove.</span>
|
| 105 |
</h1>
|
| 106 |
<p className="hero-sub">
|
| 107 |
+
FinanceFlow audits invoices, contracts, and payment notes for totals, due
|
| 108 |
+
dates, payment terms, late fees, and unsupported claims β with citations you
|
| 109 |
+
can verify. When the evidence isn't there, it says so instead of guessing.
|
| 110 |
</p>
|
| 111 |
<div className="hero-cta">
|
| 112 |
<Button variant="primary" size="lg" loading={seeding} onClick={onTryDemo}>
|
|
|
|
| 325 |
</span>
|
| 326 |
FinanceFlow AI
|
| 327 |
</span>
|
| 328 |
+
<span>Evidence-backed finance document intelligence.</span>
|
| 329 |
<a href="https://github.com/aissugur/financeflow-ai" target="_blank" rel="noreferrer">
|
| 330 |
GitHub β
|
| 331 |
</a>
|
|
@@ -670,6 +670,35 @@ code {
|
|
| 670 |
.answer-body { font-size: 16px; line-height: 1.65; color: var(--text); max-width: 72ch; }
|
| 671 |
.answer-body.dim { color: var(--text-dim); font-size: 15px; }
|
| 672 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 673 |
@keyframes reveal { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
| 674 |
|
| 675 |
/* ===================== Evidence panel ==================================== */
|
|
|
|
| 670 |
.answer-body { font-size: 16px; line-height: 1.65; color: var(--text); max-width: 72ch; }
|
| 671 |
.answer-body.dim { color: var(--text-dim); font-size: 15px; }
|
| 672 |
|
| 673 |
+
/* Compact AI-evaluation / audit metadata under the answer body. Quiet by design. */
|
| 674 |
+
.ask-meta {
|
| 675 |
+
margin: 16px 0 0;
|
| 676 |
+
padding-top: 14px;
|
| 677 |
+
border-top: 1px solid var(--border);
|
| 678 |
+
display: grid;
|
| 679 |
+
gap: 6px;
|
| 680 |
+
max-width: 72ch;
|
| 681 |
+
}
|
| 682 |
+
.ask-meta-row {
|
| 683 |
+
display: grid;
|
| 684 |
+
grid-template-columns: minmax(0, 9.5em) 1fr;
|
| 685 |
+
gap: 12px;
|
| 686 |
+
align-items: baseline;
|
| 687 |
+
}
|
| 688 |
+
.ask-meta-k {
|
| 689 |
+
font-size: var(--text-xs);
|
| 690 |
+
text-transform: uppercase;
|
| 691 |
+
letter-spacing: 0.06em;
|
| 692 |
+
color: var(--text-faint);
|
| 693 |
+
margin: 0;
|
| 694 |
+
}
|
| 695 |
+
.ask-meta-v {
|
| 696 |
+
font-size: var(--text-sm);
|
| 697 |
+
color: var(--text-dim);
|
| 698 |
+
margin: 0;
|
| 699 |
+
font-variant-numeric: tabular-nums;
|
| 700 |
+
}
|
| 701 |
+
|
| 702 |
@keyframes reveal { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } }
|
| 703 |
|
| 704 |
/* ===================== Evidence panel ==================================== */
|