Backfill sparse report sections, add enrichment section refresh, and bootstrap first-admin

Reports: the LLM reliably used company_enrichment for prose fields but
inconsistently populated the parallel Finding-list/string-list fields from
the same evidence, even with progressively more explicit prompting. Add a
code-level backfill (products, recent developments, financial signals,
strategic initiatives, regulatory signals, risks/opportunities mirrored
from SWOT, unknowns, monitoring recommendations) that only ever fills in
what the model left empty, never overwrites what it produced.

Enrichment tab: reorder sections (Products/Recent updates before
Customers/Competitors) and add a per-section "Refresh" button that
re-fetches just one of NinjaPear's six independent per-company endpoints
when it came back empty - confirmed live that a data-coverage gap (e.g.
Amazon returning no products) is real provider behavior, not a bug.

Auth: the first account registered on a deployment with zero existing
admins is now auto-promoted to admin, closing the chicken-and-egg gap
where the only path to admin access was direct DB access. Self-heals if
the last admin ever deletes their account.

Also bumps nginx's proxy_read_timeout for api.ciagent.org to cover the
enrichment refresh's synchronous funding-endpoint call (up to 5 minutes
per NinjaPear's docs).

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
2026-08-06 17:41:02 -04:00
co-authored by Claude Sonnet 5
parent 1be3e53584
commit 18305b545c
18 changed files with 1087 additions and 53 deletions
+25
View File
@@ -48,6 +48,31 @@ def _setup_database():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# auth_service.register() auto-promotes the first-ever registration
# to admin (see its docstring) so a fresh deployment always has a
# bootstrap path to admin access. Without this seed row, whichever
# test happens to register a "plain" user first in this shared
# session-wide DB would non-deterministically become an admin,
# breaking every non-admin-should-get-403 test depending on
# execution order. Seeding one admin up front keeps that dedicated
# to test_first_registered_user_becomes_admin (which starts from a
# genuinely empty users table), while every other test's "plain"
# registration behaves exactly as before.
from app.models.user import User
async with get_sessionmaker()() as session:
session.add(
User(
email="[email protected]",
password_hash=None,
display_name="Seed Admin",
timezone="UTC",
is_admin=True,
email_verified=True,
)
)
await session.commit()
asyncio.run(_create())
yield
+34
View File
@@ -63,6 +63,40 @@ def test_register_rejects_weak_password(client):
assert resp.status_code == 422
async def test_first_registration_on_an_admin_less_deployment_becomes_admin(db_session, settings):
"""Bootstraps admin access on a fresh deployment - without this, the
only way to ever get an admin account is direct DB access. Patches
count_admins() to simulate a genuinely admin-less deployment rather
than manipulating the shared session-wide test DB's real admin count
(conftest.py seeds one admin precisely so ordinary registrations in
other tests never accidentally trip this path)."""
from unittest.mock import patch
from app.repositories.user_repository import UserRepository
from app.schemas.auth import RegisterRequest
from app.services.auth_service import register
payload = RegisterRequest(
email=_unique_email(), password="correct-horse-1", display_name="First User"
)
with patch.object(UserRepository, "count_admins", return_value=0):
user = await register(db_session, settings, "127.0.0.1", payload)
assert user.is_admin is True
async def test_registration_after_an_admin_already_exists_is_not_promoted(db_session, settings):
from app.schemas.auth import RegisterRequest
from app.services.auth_service import register
payload = RegisterRequest(
email=_unique_email(), password="correct-horse-1", display_name="Second User"
)
user = await register(db_session, settings, "127.0.0.1", payload)
assert user.is_admin is False
def test_login_wrong_password_rejected(client):
email = _unique_email()
client.post(
+73
View File
@@ -3,6 +3,9 @@
from __future__ import annotations
import uuid
from unittest.mock import patch
import pytest
def _register_and_login(client) -> dict[str, str]:
@@ -252,3 +255,73 @@ def test_create_company_does_not_enqueue_enrichment_without_a_key(client):
assert resp.status_code == 201
mock_delay.assert_not_called()
assert resp.json()["enrichment"] is None
def test_refresh_enrichment_section_requires_authentication(client):
resp = client.post(f"/api/v1/companies/{uuid.uuid4()}/enrichment/sections/products/refresh")
assert resp.status_code == 401
def test_refresh_enrichment_section_rejects_an_unknown_section(client):
headers = _register_and_login(client)
company = _create_company(client, headers).json()
resp = client.post(
f"/api/v1/companies/{company['id']}/enrichment/sections/not_a_section/refresh",
headers=headers,
)
assert resp.status_code == 422
def test_refresh_enrichment_section_404s_without_an_enrichment_record(client):
headers = _register_and_login(client)
company = _create_company(client, headers).json() # no NINJAPEAR_API_KEY -> no record
resp = client.post(
f"/api/v1/companies/{company['id']}/enrichment/sections/products/refresh",
headers=headers,
)
assert resp.status_code == 404
def test_refresh_enrichment_section_is_scoped_to_owner(client):
owner_headers = _register_and_login(client)
other_headers = _register_and_login(client)
company = _create_company(client, owner_headers).json()
resp = client.post(
f"/api/v1/companies/{company['id']}/enrichment/sections/products/refresh",
headers=other_headers,
)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_refresh_enrichment_section_updates_just_that_section(client, db_session, settings):
from app.enrichment.base import Product
from app.enrichment.mock import MockEnrichmentProvider
from app.repositories.company_repository import CompanyRepository
from app.services.enrichment_service import enrich_company
headers = _register_and_login(client)
company_id = _create_company(client, headers).json()["id"]
company = await CompanyRepository(db_session).get_by_id(uuid.UUID(company_id))
await enrich_company(db_session, settings, MockEnrichmentProvider(), company)
class _ProductsProvider(MockEnrichmentProvider):
async def get_products(self, name, website):
return [Product(name="Acme Pay", description="Payments product")]
with patch("app.api.v1.companies.get_enrichment_provider", return_value=_ProductsProvider()):
resp = client.post(
f"/api/v1/companies/{company_id}/enrichment/sections/products/refresh",
headers=headers,
)
assert resp.status_code == 200
products = resp.json()["enrichment"]["data"]["products"]
assert products == [{"name": "Acme Pay", "description": "Payments product", "category": None}]
+99 -3
View File
@@ -7,24 +7,31 @@ import uuid
import pytest
from app.core.errors import NotFoundError
from app.enrichment.base import (
CompanyDetails,
CompanyFunding,
LeadershipMember,
Product,
)
from app.models.company import Company
from app.models.enums import EnrichmentStatus
from app.repositories.company_enrichment_repository import CompanyEnrichmentRepository
from app.services.enrichment_service import enrich_company
from app.services.enrichment_service import enrich_company, refresh_section
class _FakeProvider:
provider_name = "fake"
def __init__(
self, *, leadership: list[LeadershipMember] | None = None, fail: set[str] | None = None
self,
*,
leadership: list[LeadershipMember] | None = None,
products: list[Product] | None = None,
fail: set[str] | None = None,
):
self._leadership = leadership or []
self._products = products if products is not None else []
self._fail = fail or set()
async def get_company_details(self, name, website):
@@ -50,7 +57,7 @@ class _FakeProvider:
async def get_products(self, name, website):
if "products" in self._fail:
raise RuntimeError("products boom")
return []
return self._products
async def get_customers(self, name, website):
if "customers" in self._fail:
@@ -168,3 +175,92 @@ async def test_upsert_updates_the_same_row_on_a_second_call(db_session, settings
stored = await CompanyEnrichmentRepository(db_session).get_for_company(company.id)
assert stored.id == first.id
assert stored.status == EnrichmentStatus.PARTIAL
@pytest.mark.asyncio
async def test_refresh_section_replaces_only_that_section(db_session, settings):
company = await _make_company(db_session)
await enrich_company(db_session, settings, _FakeProvider(), company) # products come back []
provider = _FakeProvider(products=[Product(name="Acme Pay", description="Payments product")])
enrichment = await refresh_section(db_session, settings, provider, company, "products")
assert [p["name"] for p in enrichment.data["products"]] == ["Acme Pay"]
assert enrichment.data["description"] == "A company." # untouched by the refresh
assert enrichment.status == EnrichmentStatus.COMPLETE
@pytest.mark.asyncio
async def test_refresh_section_records_a_fresh_failure_without_losing_other_data(
db_session, settings
):
company = await _make_company(db_session)
await enrich_company(db_session, settings, _FakeProvider(), company)
failing_provider = _FakeProvider(fail={"products"})
enrichment = await refresh_section(db_session, settings, failing_provider, company, "products")
assert enrichment.errors["products"] == "products boom"
assert enrichment.data["description"] == "A company."
assert enrichment.status == EnrichmentStatus.PARTIAL
@pytest.mark.asyncio
async def test_refresh_section_clears_a_previously_recorded_error(db_session, settings):
company = await _make_company(db_session)
await enrich_company(db_session, settings, _FakeProvider(fail={"products"}), company)
provider = _FakeProvider(products=[Product(name="Acme Pay")])
enrichment = await refresh_section(db_session, settings, provider, company, "products")
assert "products" not in enrichment.errors
assert enrichment.status == EnrichmentStatus.COMPLETE
@pytest.mark.asyncio
async def test_refresh_section_preserves_per_leader_lookups_on_a_details_refresh(
db_session, settings
):
company = await _make_company(db_session)
leadership = [LeadershipMember(name="Jane Doe")]
await enrich_company(db_session, settings, _FakeProvider(leadership=leadership), company)
stored = await CompanyEnrichmentRepository(db_session).get_for_company(company.id)
assert stored.data["leadership_team"][0]["work_email"] == "[email protected]"
# A details refresh re-fetches the roster but not the per-leader lookups -
# the previously-found work_email must survive, not silently disappear.
provider = _FakeProvider(leadership=[LeadershipMember(name="Jane Doe", title="CEO")])
enrichment = await refresh_section(db_session, settings, provider, company, "details")
member = enrichment.data["leadership_team"][0]
assert member["title"] == "CEO"
assert member["work_email"] == "[email protected]"
@pytest.mark.asyncio
async def test_refresh_section_rejects_an_unknown_section(db_session, settings):
company = await _make_company(db_session)
await enrich_company(db_session, settings, _FakeProvider(), company)
with pytest.raises(ValueError):
await refresh_section(db_session, settings, _FakeProvider(), company, "not_a_section")
@pytest.mark.asyncio
async def test_refresh_section_requires_an_existing_enrichment_record(db_session, settings):
company = await _make_company(db_session) # enrichment was never run
with pytest.raises(NotFoundError):
await refresh_section(db_session, settings, _FakeProvider(), company, "products")
@pytest.mark.asyncio
async def test_refresh_section_adds_its_own_credit_cost(db_session, settings):
company = await _make_company(db_session)
baseline = await enrich_company(db_session, settings, _FakeProvider(), company)
baseline_credits = baseline.credits_spent # `upsert` mutates rows in place - snapshot first
enrichment = await refresh_section(db_session, settings, _FakeProvider(), company, "products")
assert enrichment.credits_spent == baseline_credits + 3 # products' own credit cost
+283
View File
@@ -0,0 +1,283 @@
"""_backfill_from_enrichment / _mirror_swot_into_flat_lists: company_enrichment
data (products/recent_updates/funding) and the model's own SWOT output are
direct, unambiguous sources for products_and_services/recent_developments/
financial_signals/risks/opportunities - no additional LLM judgment required.
Confirmed live that a real Anthropic call can still leave these fields empty
despite explicit prompt instructions to populate them, so this backfill
guarantees the data surfaces regardless of what the model chose to do."""
from __future__ import annotations
from app.prompts.report_generation import ReportContent, SwotAnalysis
from app.prompts.schemas import Finding
from app.services.report_service import (
_backfill_from_enrichment,
_backfill_reflective_sections,
_mirror_swot_into_flat_lists,
)
def _empty_report() -> ReportContent:
return ReportContent(
executive_summary="",
company_overview="",
market_positioning="",
customer_sentiment="",
competitor_comparison="",
swot=SwotAnalysis(),
methodology="",
limitations="",
)
def test_backfills_products_from_enrichment_when_model_left_it_empty():
report = _empty_report()
enrichment = {
"products": [
{"name": "Acme Pay", "category": "Payments", "description": "Accept cards online."},
{"name": "Acme Billing", "category": "Billing", "description": "Recurring invoices."},
]
}
_backfill_from_enrichment(report, enrichment)
assert [f.headline for f in report.products_and_services] == ["Acme Pay", "Acme Billing"]
assert report.products_and_services[0].summary == "Accept cards online."
assert report.products_and_services[0].confidence == "confirmed"
assert report.products_and_services[0].evidence == []
def test_backfills_recent_developments_from_enrichment_updates():
report = _empty_report()
enrichment = {
"recent_updates": [
{
"url": "https://acme.example/blog/launch",
"date": "2026-01-01",
"text": "Acme launches new dashboard",
"type": "blog",
}
]
}
_backfill_from_enrichment(report, enrichment)
assert len(report.recent_developments) == 1
finding = report.recent_developments[0]
assert finding.headline == "Acme launches new dashboard"
assert finding.date == "2026-01-01"
assert finding.evidence[0].url == "https://acme.example/blog/launch"
def test_does_not_overwrite_findings_the_model_already_produced():
report = _empty_report()
report.products_and_services = [
Finding(headline="Model-provided product", summary="From the model itself")
]
enrichment = {"products": [{"name": "Should not appear", "description": "..."}]}
_backfill_from_enrichment(report, enrichment)
assert len(report.products_and_services) == 1
assert report.products_and_services[0].headline == "Model-provided product"
def test_no_enrichment_data_leaves_lists_empty():
report = _empty_report()
_backfill_from_enrichment(report, None)
assert report.products_and_services == []
assert report.recent_developments == []
def test_skips_entries_missing_the_required_key():
report = _empty_report()
enrichment = {
"products": [{"category": "No name here"}],
"recent_updates": [{"url": "https://acme.example", "date": "2026-01-01"}],
}
_backfill_from_enrichment(report, enrichment)
assert report.products_and_services == []
assert report.recent_developments == []
def test_backfills_financial_signals_from_funding_rounds_and_total():
report = _empty_report()
enrichment = {
"funding": {
"total_raised": "9810000000",
"rounds": [
{
"date": "2026-02-01",
"amount": None,
"investors": ["Thrive Capital", "Coatue Management"],
"round_name": "SECONDARY_SALE",
},
{
"date": "2023-03-01",
"amount": "6870000000",
"investors": ["Thrive Capital", "Andreessen Horowitz"],
"round_name": "SERIES_I",
},
],
}
}
_backfill_from_enrichment(report, enrichment)
assert len(report.financial_signals) == 3
assert report.financial_signals[0].headline == "Total funding raised: $9,810,000,000"
assert report.financial_signals[1].headline == "Secondary Sale"
assert "Thrive Capital" in report.financial_signals[1].summary
assert report.financial_signals[2].headline == "Series I - $6,870,000,000"
assert report.financial_signals[2].date == "2023-03-01"
def test_no_funding_data_leaves_financial_signals_empty():
report = _empty_report()
_backfill_from_enrichment(report, {"products": []})
assert report.financial_signals == []
def test_does_not_overwrite_financial_signals_the_model_already_produced():
report = _empty_report()
report.financial_signals = [Finding(headline="Model-provided signal", summary="From the model")]
enrichment = {"funding": {"total_raised": "1000", "rounds": []}}
_backfill_from_enrichment(report, enrichment)
assert len(report.financial_signals) == 1
assert report.financial_signals[0].headline == "Model-provided signal"
def test_mirrors_swot_threats_into_risks_when_risks_empty():
report = _empty_report()
report.swot = SwotAnalysis(threats=["Regulatory scrutiny", "New entrants"])
_mirror_swot_into_flat_lists(report)
assert report.risks == ["Regulatory scrutiny", "New entrants"]
def test_mirrors_swot_opportunities_into_opportunities_when_empty():
report = _empty_report()
report.swot = SwotAnalysis(opportunities=["Expand into new markets"])
_mirror_swot_into_flat_lists(report)
assert report.opportunities == ["Expand into new markets"]
def test_does_not_overwrite_risks_or_opportunities_the_model_already_produced():
report = _empty_report()
report.risks = ["Model-provided risk"]
report.opportunities = ["Model-provided opportunity"]
report.swot = SwotAnalysis(threats=["Should not appear"], opportunities=["Should not appear"])
_mirror_swot_into_flat_lists(report)
assert report.risks == ["Model-provided risk"]
assert report.opportunities == ["Model-provided opportunity"]
def test_empty_swot_leaves_risks_and_opportunities_empty():
report = _empty_report()
_mirror_swot_into_flat_lists(report)
assert report.risks == []
assert report.opportunities == []
def test_backfills_strategic_initiatives_from_specialties():
report = _empty_report()
enrichment = {"specialties": ["Payment Processing", "Billing Models"]}
_backfill_reflective_sections(
report, enrichment=enrichment, documents=[], changes=[], company_name="Acme"
)
assert [f.headline for f in report.strategic_initiatives] == [
"Focus area: Payment Processing",
"Focus area: Billing Models",
]
assert report.strategic_initiatives[0].confidence == "possible"
def test_regulatory_signals_get_an_insufficient_evidence_placeholder_when_empty():
report = _empty_report()
_backfill_reflective_sections(
report, enrichment=None, documents=[], changes=[], company_name="Acme"
)
assert len(report.regulatory_and_legal_signals) == 1
assert report.regulatory_and_legal_signals[0].confidence == "insufficient_evidence"
def test_unknowns_reflect_actual_gaps_in_the_evidence_set():
report = _empty_report()
enrichment = {"funding": {}, "leadership_team": [], "customers": []}
_backfill_reflective_sections(
report, enrichment=enrichment, documents=[], changes=[], company_name="Acme"
)
assert any("financial" in u.lower() for u in report.unknowns_and_missing_data)
assert any("leadership" in u.lower() for u in report.unknowns_and_missing_data)
assert any("customers" in u.lower() for u in report.unknowns_and_missing_data)
assert any("source documents" in u.lower() for u in report.unknowns_and_missing_data)
assert any("changes" in u.lower() for u in report.unknowns_and_missing_data)
def test_unknowns_omit_gaps_that_are_actually_covered():
report = _empty_report()
enrichment = {
"funding": {"total_raised": "1000"},
"leadership_team": [{"name": "Jane"}],
"customers": [{"name": "Acme Corp"}],
}
_backfill_reflective_sections(
report,
enrichment=enrichment,
documents=[{"id": "d1"}],
changes=[{"id": "c1"}],
company_name="Acme",
)
assert report.unknowns_and_missing_data == []
def test_monitoring_recommendations_populated_when_model_left_empty():
report = _empty_report()
_backfill_reflective_sections(
report, enrichment=None, documents=[], changes=[], company_name="Acme"
)
assert len(report.monitoring_recommendations) >= 2
assert any("Acme" in r for r in report.monitoring_recommendations)
def test_reflective_sections_do_not_overwrite_model_output():
report = _empty_report()
report.strategic_initiatives = [Finding(headline="Model theme", summary="From the model")]
report.regulatory_and_legal_signals = [Finding(headline="Model signal", summary="From model")]
report.unknowns_and_missing_data = ["Model-noted gap"]
report.monitoring_recommendations = ["Model recommendation"]
enrichment = {"specialties": ["Should not appear"]}
_backfill_reflective_sections(
report, enrichment=enrichment, documents=[], changes=[], company_name="Acme"
)
assert report.strategic_initiatives[0].headline == "Model theme"
assert report.regulatory_and_legal_signals[0].headline == "Model signal"
assert report.unknowns_and_missing_data == ["Model-noted gap"]
assert report.monitoring_recommendations == ["Model recommendation"]