feat: implement Milestone 6 - Financial Data Extractor with hybrid PD… - #8
Conversation
…F targeting and EBITDA/EBIT validation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthroughThis PR introduces a complete financial extraction pipeline that processes documents to extract structured financial metrics using Gemini, applies semantic chunking and table parsing, validates results, derives missing metrics, and persists data to the database using preference-based conflict resolution. ChangesFinancial Extraction Pipeline
Sequence DiagramsequenceDiagram
participant PDF as PDF Document
participant Extractor as financial_extractor
participant Chunker as Semantic Chunker
participant Plumber as pdfplumber
participant Gemini as Gemini API
participant Validator as Validator
participant DB as FinancialRecord DB
Extractor->>PDF: Load document pages
Extractor->>Chunker: Identify financial statement pages
Chunker-->>Extractor: Primary & trend page sets
Extractor->>Plumber: Extract tables from pages
Plumber-->>Extractor: Formatted table text
Extractor->>Gemini: Send page text + tables + schema
Gemini-->>Extractor: JSON (metrics, periods, multiplier)
Extractor->>Validator: Normalize extracted values
Validator-->>Extractor: Validate against source text
Extractor->>Extractor: Derive missing EBITDA/EBIT
Extractor->>DB: Upsert by (ticker, year, period, quarter)
DB-->>Extractor: Updated/inserted record count
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (3)
backend/test_milestone6.py (2)
32-36: ⚡ Quick winConsider adding error handling to improve test robustness.
If one document fails to process, the entire test stops. Adding try/catch around the processing call would allow the test to continue with remaining documents and report all failures at the end, making it easier to identify which documents succeeded and which failed.
🛡️ Proposed error handling pattern
# 3. Run the extraction pipeline on each document # We run them one by one + failed_docs = [] for doc in documents: print(f"\nProcessing Document ID {doc.id} ({doc.document_type})...") - async with AsyncSessionLocal() as db: - upserted = await process_document_financials(doc.id, db) - print(f"Processed Document ID {doc.id}. Upserted {upserted} periods.") + try: + async with AsyncSessionLocal() as db: + upserted = await process_document_financials(doc.id, db) + print(f"Processed Document ID {doc.id}. Upserted {upserted} periods.") + except Exception as e: + print(f"ERROR processing Document ID {doc.id}: {e}") + failed_docs.append((doc.id, str(e))) + + if failed_docs: + print(f"\n⚠️ {len(failed_docs)} document(s) failed to process:") + for doc_id, error in failed_docs: + print(f" - Document ID {doc_id}: {error}")🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/test_milestone6.py` around lines 32 - 36, Wrap the call to await process_document_financials(doc.id, db) inside a try/except in the documents loop so a failure for one document doesn't stop the test: catch exceptions around process_document_financials (and any DB scope with AsyncSessionLocal) record the doc.id and exception in a failures list, continue processing remaining documents, and after the loop assert or fail the test if failures is non-empty (or print a summary) to report all failed document IDs and errors; ensure you reference process_document_financials, AsyncSessionLocal and the documents iteration when implementing this.
66-66: ⚡ Quick winConsider adding EBIT to field validation.
The PR description mentions "validation for EBITDA and EBIT values," and the extraction engine includes EBIT derivation logic. Adding
"ebit"to the validated fields list would verify that EBIT values are also properly stored as NULL rather than 0 when missing.📊 Proposed addition
- for field in ["revenue", "ebitda", "pat", "cfo", "capex"]: + for field in ["revenue", "ebitda", "ebit", "pat", "cfo", "capex"]: val = getattr(r, field) assert val != 0, f"Error: stored 0 instead of NULL for {field} in FY {r.fiscal_year} {r.fiscal_quarter or ''}"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/test_milestone6.py` at line 66, The test that validates numeric fields iterates over ["revenue", "ebitda", "pat", "cfo", "capex"] and therefore omits EBIT; update that field list in backend/test_milestone6.py to include "ebit" so the validation loop (the for field in [...] block) also checks that EBIT is stored as NULL when missing (same expectation used for ebitda), ensuring the extraction/derivation logic is covered.backend/models/financials.py (1)
3-11: ⚡ Quick winDrop the process-wide
sys.pathmutation here.Appending to
sys.pathat import time changes module resolution for the whole process and can shadow similarly named packages. Since this module already lives underbackend.models, prefer a stable package import path forBaseinstead of mutating interpreter state.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@backend/models/financials.py` around lines 3 - 11, Remove the process-wide sys.path mutation and use a stable package import for Base: delete the sys.path.append and the try/except that mutates sys.path, then import Base using a package import (e.g. replace with "from backend.core.database import Base") or a clear relative import ("from ..core.database import Base") depending on how the package is installed; ensure you only keep one explicit import statement for Base and remove the ImportError fallback that modifies sys.path.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@backend/engines/financial_extractor.py`:
- Around line 295-330: periods_data entries are being unconditionally
overwritten, causing a race between primary and trend extraction; change the
assignment at periods_data[p_key][metric_key] = parsed_num to be deterministic
by only writing when the metric is missing (e.g., if metric_key not in
periods_data[p_key] or periods_data[p_key][metric_key] is None) or by adding an
explicit precedence check (e.g., a source flag is_primary/trend and only allow
trend to set values if primary hasn't set them), and apply the same
non-overwriting/precedence logic to the other similar block that writes into
periods_data (the later 332-336 block).
- Around line 263-265: The current early return when extracted_tables is falsy
causes skipping the Gemini/combined_text pass; instead, remove the return and
call extract_financials_from_doc_data(document_id, label, combined_text,
tables_formatted=[]) (or its existing signature) with an empty tables_formatted
fallback so the model-based extraction still runs; keep the logger.info line to
record no tables found but ensure extract_financials_from_doc_data is invoked
even when extracted_tables is empty so scanned/hybrid PDFs are processed.
- Around line 378-400: The current SELECT-then-INSERT TOCTOU around
FinancialRecord (existing_rec -> insert(FinancialRecord).values(...)) can lead
to duplicates because fiscal_quarter is nullable; replace this with a DB-native
upsert or safe retry: perform a single insert using SQLAlchemy’s
on_conflict_do_update targeting the unique constraint name
(uq_financials_period) or the index key columns (ticker, fiscal_year,
period_type, fiscal_quarter) and set metric columns from METRIC_KEYS on
conflict, so concurrent workers atomically update instead of double-inserting;
alternatively catch IntegrityError from db_session.execute, re-query the record
and retry the update/merge once; also consider making fiscal_quarter non-null by
storing a sentinel (e.g., "ANNUAL") or creating a partial unique index so annual
rows are covered by the uniqueness constraint.
In `@backend/models/financials.py`:
- Line 20: The UniqueConstraint "uq_financials_period" is bypassed when
fiscal_quarter is NULL; update the model so annual rows can't duplicate: either
make Column fiscal_quarter non-null and store a sentinel (e.g., "ANNUAL") for
period_type == 'annual' when creating/updating rows, or replace the single
UniqueConstraint("ticker","fiscal_year","period_type","fiscal_quarter",
name="uq_financials_period") with two DB-level unique constraints/indexes (one
unique on ("ticker","fiscal_year","period_type") filtered/partial for
period_type='annual', and another unique on
("ticker","fiscal_year","period_type","fiscal_quarter") for non-annual periods).
Ensure code that inserts/updates uses the sentinel or that migrations add the
filtered indexes to enforce uniqueness.
---
Nitpick comments:
In `@backend/models/financials.py`:
- Around line 3-11: Remove the process-wide sys.path mutation and use a stable
package import for Base: delete the sys.path.append and the try/except that
mutates sys.path, then import Base using a package import (e.g. replace with
"from backend.core.database import Base") or a clear relative import ("from
..core.database import Base") depending on how the package is installed; ensure
you only keep one explicit import statement for Base and remove the ImportError
fallback that modifies sys.path.
In `@backend/test_milestone6.py`:
- Around line 32-36: Wrap the call to await process_document_financials(doc.id,
db) inside a try/except in the documents loop so a failure for one document
doesn't stop the test: catch exceptions around process_document_financials (and
any DB scope with AsyncSessionLocal) record the doc.id and exception in a
failures list, continue processing remaining documents, and after the loop
assert or fail the test if failures is non-empty (or print a summary) to report
all failed document IDs and errors; ensure you reference
process_document_financials, AsyncSessionLocal and the documents iteration when
implementing this.
- Line 66: The test that validates numeric fields iterates over ["revenue",
"ebitda", "pat", "cfo", "capex"] and therefore omits EBIT; update that field
list in backend/test_milestone6.py to include "ebit" so the validation loop (the
for field in [...] block) also checks that EBIT is stored as NULL when missing
(same expectation used for ebitda), ensuring the extraction/derivation logic is
covered.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: be5c3ec9-e4f0-4873-b1ba-6af8680b89c7
📒 Files selected for processing (4)
backend/engines/financial_extractor.pybackend/models/__init__.pybackend/models/financials.pybackend/test_milestone6.py
| if not extracted_tables: | ||
| logger.info(f"No financial tables found for {label} pages of document {document_id}") | ||
| return |
There was a problem hiding this comment.
Don't skip extraction just because table detection failed.
This returns before the Gemini pass even when combined_text contains usable statement text. For scanned/hybrid PDFs where pdfplumber misses structure, the page set is silently dropped and you lose coverage entirely. Call extract_financials_from_doc_data(...) with an empty tables_formatted fallback instead of bailing out here.
Suggested change
- if not extracted_tables:
- logger.info(f"No financial tables found for {label} pages of document {document_id}")
- return
+ if not extracted_tables:
+ logger.info(
+ f"No financial tables found for {label} pages of document {document_id}; "
+ "falling back to raw page text."
+ )🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/engines/financial_extractor.py` around lines 263 - 265, The current
early return when extracted_tables is falsy causes skipping the
Gemini/combined_text pass; instead, remove the return and call
extract_financials_from_doc_data(document_id, label, combined_text,
tables_formatted=[]) (or its existing signature) with an empty tables_formatted
fallback so the model-based extraction still runs; keep the logger.info line to
record no tables found but ensure extract_financials_from_doc_data is invoked
even when extracted_tables is empty so scanned/hybrid PDFs are processed.
| for metric_key, val_list in metrics.items(): | ||
| if metric_key not in METRIC_KEYS or val_list is None: | ||
| continue | ||
| if not isinstance(val_list, list): | ||
| val_list = [val_list] | ||
|
|
||
| for p_idx, val in enumerate(val_list): | ||
| if p_idx >= len(periods): | ||
| break | ||
| if val is None or val == "-" or val == "": | ||
| continue | ||
|
|
||
| # Strict validation to avoid hallucinations | ||
| norm_val = normalize_text_for_search(val) | ||
| if norm_val not in normalized_doc_text and norm_val not in normalized_combined_tables: | ||
| if metric_key == "ebitda": | ||
| # Allow derived EBITDA values calculated by Gemini | ||
| pass | ||
| else: | ||
| logger.warning(f"Hallucination alert: value '{val}' for metric '{metric_key}' not found in source text. Skipping.") | ||
| continue | ||
|
|
||
| parsed_num = clean_and_parse_number(val, multiplier) | ||
| if parsed_num is None: | ||
| continue | ||
|
|
||
| # Validation: Revenue must be > 0 | ||
| if metric_key == "revenue" and parsed_num <= 0: | ||
| logger.warning(f"Validation alert: revenue value {parsed_num} <= 0. Skipping.") | ||
| continue | ||
|
|
||
| p = periods[p_idx] | ||
| p_key = (doc_rec.ticker, p["fiscal_year"], p["period_type"], p.get("fiscal_quarter")) | ||
| if p_key not in periods_data: | ||
| periods_data[p_key] = {} | ||
| periods_data[p_key][metric_key] = parsed_num |
There was a problem hiding this comment.
Primary and trend extraction currently race on the same period keys.
Both tasks write into periods_data in parallel, and periods_data[p_key][metric_key] = parsed_num always overwrites the previous value. If the same period shows up in both page sets, the final value depends on which Gemini call finishes last rather than a defined precedence. Prefer a deterministic merge order here, e.g. process primary first or only let trend fill missing metrics.
Also applies to: 332-336
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/engines/financial_extractor.py` around lines 295 - 330, periods_data
entries are being unconditionally overwritten, causing a race between primary
and trend extraction; change the assignment at periods_data[p_key][metric_key] =
parsed_num to be deterministic by only writing when the metric is missing (e.g.,
if metric_key not in periods_data[p_key] or periods_data[p_key][metric_key] is
None) or by adding an explicit precedence check (e.g., a source flag
is_primary/trend and only allow trend to set values if primary hasn't set them),
and apply the same non-overwriting/precedence logic to the other similar block
that writes into periods_data (the later 332-336 block).
| q = select(FinancialRecord).where( | ||
| FinancialRecord.ticker == ticker, | ||
| FinancialRecord.fiscal_year == year, | ||
| FinancialRecord.period_type == period_type, | ||
| FinancialRecord.fiscal_quarter == quarter | ||
| ) | ||
| res = await db_session.execute(q) | ||
| existing_rec = res.scalar_one_or_none() | ||
|
|
||
| if not existing_rec: | ||
| # Create new record | ||
| insert_data = { | ||
| "ticker": ticker, | ||
| "fiscal_year": year, | ||
| "period_type": period_type, | ||
| "fiscal_quarter": quarter, | ||
| "data_source": str(document_id) | ||
| } | ||
| for k in METRIC_KEYS: | ||
| insert_data[k] = metrics_dict.get(k) | ||
|
|
||
| stmt = insert(FinancialRecord).values(**insert_data) | ||
| await db_session.execute(stmt) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Inspect the target code region
sed -n '330,450p' backend/engines/financial_extractor.py | nl -ba | sed -n '1,140p'
# 2) Find the FinancialRecord model definition
rg -n "class FinancialRecord\b|FinancialRecord\s*=" -S backend | head -n 50
# 3) Locate unique constraints / indexes for FinancialRecord (model + migrations if any)
rg -n "FinancialRecord.*UniqueConstraint|UniqueConstraint\(|unique=True|unique_index|Index\(|on_conflict|ON CONFLICT|postgresql\.insert" -S backend | head -n 200
# 4) Find any existing upsert usage patterns in the codebase
rg -n "on_conflict_do_update|on_conflict_do_nothing|ON CONFLICT" -S backend | head -n 200
# 5) Identify the SQLAlchemy dialect / insert helper used (if present)
rg -n "from sqlalchemy\.dialects\.postgresql import insert|dialects\.postgresql import insert|postgresql\.insert" -S backend | head -n 50Repository: Harsh-Codes-77/stock_Iq
Length of output: 110
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Inspect the target code region with line numbers (avoid nl)
if command -v cat >/dev/null 2>&1; then
cat -n backend/engines/financial_extractor.py | sed -n '330,450p'
else
echo "cat not available"
fi
# 2) Find the FinancialRecord model definition
rg -n "class FinancialRecord\b|FinancialRecord\s*=\s*|__tablename__\s*=\s*['\"]financial_record['\"]" -S backend | head -n 50
# 3) Locate unique constraints / indexes for FinancialRecord (model + migrations if any)
rg -n "FinancialRecord.*UniqueConstraint|UniqueConstraint\(|unique=True|unique_index|Index\(|on_conflict|ON CONFLICT|postgresql\.insert|insert\(" -S backend | head -n 200
# 4) Find any existing upsert usage patterns in the codebase
rg -n "on_conflict_do_update|on_conflict_do_nothing|ON CONFLICT" -S backend | head -n 200
# 5) Identify whether insert() is coming from SQLAlchemy Postgres dialect
rg -n "from sqlalchemy\.dialects\.postgresql import insert|dialects\.postgresql import insert|postgresql\.insert" -S backend | head -n 80Repository: Harsh-Codes-77/stock_Iq
Length of output: 7349
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect FinancialRecord model around the unique constraint
sed -n '1,120p' backend/models/financials.py | cat -n
# Inspect imports in financial_extractor to see which insert() is used
sed -n '1,80p' backend/engines/financial_extractor.py | cat -n
# Check whether financial_extractor uses any ON CONFLICT or upsert for FinancialRecord
rg -n "on_conflict|DO UPDATE|DO NOTHING|upsert" backend/engines/financial_extractor.pyRepository: Harsh-Codes-77/stock_Iq
Length of output: 6232
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Check DB engine/dialect configuration (postgres vs sqlite etc.)
rg -n "postgresql|asyncpg|psycopg|DATABASE_URL|create_async_engine|dialect" -S backend | head -n 200
# Also check core.database or similar
fd -t f "database.py" backend | head -n 20
fd -t f "core.database*|database.*" . | head -n 50
# 2) Find where fiscal_quarter/quarter is derived for periods and whether annual sets it to None
rg -n "fiscal_quarter|quarter\s*=" backend/engines/financial_extractor.py backend -S | head -n 200
# Narrow to period parsing logic around where p_key is constructed
rg -n "periods_data|p_key\s*=|p_key\s*\(" backend/engines/financial_extractor.py -SRepository: Harsh-Codes-77/stock_Iq
Length of output: 4234
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# 1) Inspect DB engine config for dialect
sed -n '1,120p' backend/core/database.py | cat -n
# 2) Inspect initial migration for financials table (nullability + unique constraint)
sed -n '1,120p' backend/db/migrations/001_initial.sql | cat -n
# 3) Inspect how classifier sets fiscal_quarter for annual vs quarterly
sed -n '60,140p' backend/engines/document_classifier.py | cat -n
# 4) Inspect how financial_extractor builds p_key and where period_type/fiscal_quarter are sourced
sed -n '250,360p' backend/engines/financial_extractor.py | cat -nRepository: Harsh-Codes-77/stock_Iq
Length of output: 12144
Fix non-atomic FinancialRecord insert (add true upsert/constraint-safe handling).
- The code uses a SELECT-then-INSERT flow (TOCTOU) when
existing_recis missing, so two concurrent workers can both insert the same (ticker, fiscal_year, period_type, fiscal_quarter). - The uniqueness constraint includes
fiscal_quarter, butFinancialRecord.fiscal_quarteris nullable; in Postgres this allows multiple rows with the same keys whenfiscal_quarterisNULL, so annual period duplicates can occur without an IntegrityError. - Use a DB-native upsert (
ON CONFLICT ... DO UPDATE/on_conflict_do_updateagainstuq_financials_period) or handleIntegrityErrorby retrying after re-reading; consider storing a non-null sentinel for annual (or a partial unique index) so “annual” is constraint-safe.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/engines/financial_extractor.py` around lines 378 - 400, The current
SELECT-then-INSERT TOCTOU around FinancialRecord (existing_rec ->
insert(FinancialRecord).values(...)) can lead to duplicates because
fiscal_quarter is nullable; replace this with a DB-native upsert or safe retry:
perform a single insert using SQLAlchemy’s on_conflict_do_update targeting the
unique constraint name (uq_financials_period) or the index key columns (ticker,
fiscal_year, period_type, fiscal_quarter) and set metric columns from
METRIC_KEYS on conflict, so concurrent workers atomically update instead of
double-inserting; alternatively catch IntegrityError from db_session.execute,
re-query the record and retry the update/merge once; also consider making
fiscal_quarter non-null by storing a sentinel (e.g., "ANNUAL") or creating a
partial unique index so annual rows are covered by the uniqueness constraint.
| ticker = Column(String(20), nullable=False, index=True) | ||
| fiscal_year = Column(Integer, nullable=False) | ||
| period_type = Column(String(20), default="annual") | ||
| fiscal_quarter = Column(String(10), nullable=True) |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
ls -la
fd -t f "financials.py" -a .
# Show relevant section around the cited lines and any UNIQUE constraints near the model
python3 - <<'PY'
import subprocess, re, os, textwrap, sys, json, pathlib
path="backend/models/financials.py"
if not os.path.exists(path):
# try locate
out=subprocess.check_output(["bash","-lc", "fd -t f 'financials.py' . -a | head -n 20"], text=True)
print(out)
raise SystemExit(1)
print("=== file:", path)
import itertools
with open(path,'r',encoding='utf-8') as f:
lines=f.readlines()
for i in range(1, len(lines)+1):
if 15 <= i <= 80:
pass
# print 1-120 with line numbers but cap output
start=1; end=min(160, len(lines))
for i in range(start, end+1):
print(f"{i:4d}: {lines[i-1].rstrip()}")
PY
# Locate UNIQUE / Index definitions
rg -n "UNIQUE|unique|Index\(|UniqueConstraint|postgresql_where|sqlite_where|deferrable|nulls" backend/models/financials.py || true
# Find the exact model class name containing fiscal_quarter and constraints
rg -n "fiscal_quarter" backend/models/financials.py || trueRepository: Harsh-Codes-77/stock_Iq
Length of output: 4174
Fix uq_financials_period so annual rows can’t bypass uniqueness via NULL fiscal_quarter.
backend/models/financials.py makes fiscal_quarter nullable (line 20) and includes it in UniqueConstraint("ticker", "fiscal_year", "period_type", "fiscal_quarter", name="uq_financials_period") (lines 47-48). On common SQL backends, UNIQUE allows multiple rows with the same key when one of the key columns is NULL, so (ticker, fiscal_year, period_type='annual', NULL) can still duplicate and break the one-record-per-period contract. Use a non-null sentinel quarter for annual rows or split into DB-level unique constraints/indexes (e.g., unique on (ticker, fiscal_year, period_type) for period_type='annual').
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@backend/models/financials.py` at line 20, The UniqueConstraint
"uq_financials_period" is bypassed when fiscal_quarter is NULL; update the model
so annual rows can't duplicate: either make Column fiscal_quarter non-null and
store a sentinel (e.g., "ANNUAL") for period_type == 'annual' when
creating/updating rows, or replace the single
UniqueConstraint("ticker","fiscal_year","period_type","fiscal_quarter",
name="uq_financials_period") with two DB-level unique constraints/indexes (one
unique on ("ticker","fiscal_year","period_type") filtered/partial for
period_type='annual', and another unique on
("ticker","fiscal_year","period_type","fiscal_quarter") for non-annual periods).
Ensure code that inserts/updates uses the sentinel or that migrations add the
filtered indexes to enforce uniqueness.
targeting and EBITDA/EBIT validation
Summary by CodeRabbit