Aissugur Kurganbekov Claude Opus 4.8 commited on
Commit
40c5ba0
Β·
1 Parent(s): f22b512

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 CHANGED
@@ -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
- answer = _focused_answer(question, [s.chunk for s in relevant])
 
 
 
 
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",
backend/app/audit.py CHANGED
@@ -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. It reuses the
5
- SAME retrieval + abstention gate as ``/ask`` (``answer_question``) to FIND
6
- candidate evidence, then applies a field-specific discriminator before calling a
7
- check "supported": the cited excerpt must actually contain one of the field's
8
- keywords (e.g. a "tax"/"vat" token for the tax check, a "due date"/"payment due"
9
- phrase for the due-date check). Without that second gate, the generic /ask
10
- coverage rule (any two query words overlapping anywhere) would mark a field
11
- "present" off incidental words like "Sales"/"Charged" β€” a false positive that
12
- would wrongly lower the audit's risk level. So a check passes only when the
13
- document genuinely supports the field, and the audit never invents a value.
14
-
15
- This is intentionally simple and deterministic: no new model, no LLM required.
 
16
  """
17
  import logging
18
- from dataclasses import dataclass
19
  from typing import List, Optional, Tuple
20
 
21
- from .answering import _focused_span, answer_question
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 # question used to retrieve candidate evidence via answer_question
34
  severity: str # severity to assign WHEN THE FIELD IS MISSING (low/medium/high)
35
- keywords: Tuple[str, ...] # β‰₯1 must appear in the cited excerpt to count as present
 
36
 
37
 
38
- # Checklists per audit type. `keywords` are the field's discriminators: distinctive
39
- # (often multi-word) phrases that genuinely indicate the field is present, so an
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", "What is the total amount due?", "high",
45
- ("total", "amount due", "balance due", "grand total", "amount payable")),
46
- _Check("due_date", "due date", "What is the payment due date?", "high",
47
- ("due date", "payment due", "due by", "due on", "pay by", "payable by")),
48
- _Check("payment_terms", "payment terms", "What are the payment terms, e.g. net 30?", "medium",
49
- ("net ", "payment term", "terms net", "due within", "payable within")),
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", "What are the payment terms?", "high",
59
- ("net ", "payment term", "payable within", "due within", "paid within")),
60
- _Check("termination", "termination clause", "What is the termination clause or notice period?", "medium",
61
- ("terminat", "notice period", "days' notice", "days notice", "may cancel", "for cause")),
62
- _Check("liability", "liability clause", "What is the liability, penalty, or indemnification clause?", "medium",
63
- ("liabilit", "indemnif", "indemnit", "damages", "limitation of")),
64
- _Check("renewal", "renewal terms", "Is there an automatic renewal or renewal term?", "medium",
65
- ("renew", "auto-renew", "automatic renewal", "renewal term")),
66
- _Check("governing_law", "governing law", "What is the governing law or jurisdiction?", "low",
67
- ("governing law", "jurisdiction", "governed by", "laws of", "venue")),
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", "payment amount", "What is the payment amount or outstanding balance?", "high",
73
- ("amount", "balance", "outstanding", "total due", "owing")),
74
- _Check("due_date", "due date", "What is the payment due date?", "high",
75
- ("due date", "payment due", "due by", "due on", "pay by")),
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", "What is the total amount or balance?", "medium",
83
- ("total", "amount", "balance", "subtotal")),
84
- _Check("date", "key date", "What is the due date or other key date?", "medium",
85
- ("date", "deadline", "due")),
86
- _Check("payment_terms", "payment terms", "What are the payment terms?", "low",
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 _supporting_citation(
99
- keywords: Tuple[str, ...], citations: List[Citation]
100
- ) -> Optional[Citation]:
101
- """The first cited excerpt that actually contains one of the field's
102
- discriminators, or None β€” i.e. the field really appears in the evidence."""
103
- for c in citations:
104
- low = c.excerpt.lower()
105
- if any(kw.strip() and kw in low for kw in keywords):
106
- return c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  return None
108
 
109
 
110
- def _claim_span(keywords: Tuple[str, ...], excerpt: str, fallback: str) -> str:
111
- """A verbatim window of the excerpt centered on the field's discriminator, so
112
- the shown value is the field's region (not a dense overlap of generic words).
113
- Nothing is generated."""
114
- low = excerpt.lower()
115
- hit = next((kw for kw in keywords if kw.strip() and kw in low), None)
116
- span = _focused_span(set(tokenize(hit)), excerpt, window_words=14) if hit else None
117
- if span and span[1].strip():
118
- return span[1].strip()
119
- return (fallback or excerpt).strip()
 
 
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
- answer, abstained, _mode, citations, _meta = answer_question(chk.query, chunks, "fast")
134
- cite = None if abstained else _supporting_citation(chk.keywords, citations)
135
- if cite is not None:
136
- findings.append(
137
- Finding(
138
- type=chk.type,
139
- severity="low", # present and cited -> verified, lowest concern
140
- claim=_claim_span(chk.keywords, cite.excerpt, answer),
141
- evidence=cite.excerpt,
142
- citation=cite,
143
- )
144
- )
145
  else:
146
- findings.append(
147
- Finding(
148
- type=chk.type,
149
- severity=chk.severity, # missing field -> risk by importance
150
- claim=f"No {chk.label} found in the document.",
151
- evidence="",
152
- citation=None,
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]
backend/app/extractors.py ADDED
@@ -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
backend/tests/test_audit.py CHANGED
@@ -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 5000 dollars. Charged to account 12 on file. "
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.
frontend/index.html CHANGED
@@ -6,9 +6,9 @@
6
  <meta name="theme-color" content="#fbfbfd" />
7
  <meta
8
  name="description"
9
- content="FinanceFlow AI β€” source-backed AI assistant for business documents."
10
  />
11
- <title>FinanceFlow AI β€” Source-backed document intelligence</title>
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
frontend/src/components/views/Ask.jsx CHANGED
@@ -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.
frontend/src/components/views/AuthScreen.jsx CHANGED
@@ -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
- Source-grounded document Q&amp;A β€” every answer cites the exact line, and
136
- it abstains when the evidence isn’t there.
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">Source-backed document intelligence.</p>
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 ---- */}
frontend/src/components/views/Overview.jsx CHANGED
@@ -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} /> Source-grounded 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 reads your invoices, contracts, and payment notes and answers
108
- questions <strong>only</strong> from what's in the document β€” with citations
109
- you 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,7 +325,7 @@ export default function Overview({ documents, loading, reload, onTryDemo, seedin
325
  </span>
326
  FinanceFlow AI
327
  </span>
328
- <span>Source-backed document intelligence.</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>
frontend/src/styles.css CHANGED
@@ -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 ==================================== */