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:
@@ -111,6 +111,8 @@ A couple of things worth knowing:
|
||||
|
||||
## 8. Post-boot: configure provider API keys via the Settings UI, not `.env`
|
||||
|
||||
**Where does the admin account come from?** The very first account ever registered on the app (`is_admin` count is zero) is automatically promoted to admin - see `auth_service.register`. No manual DB access or setup script required, but it does mean the same race Gitea has in §5 applies here too: if the firewall is open and the site is reachable before you've registered your own account, a stranger who registers first becomes the admin instead of you. Register your own account immediately after the stack comes up, before opening the firewall/pointing DNS at it publicly. If the last admin's account is ever deleted, the next person to register becomes admin again - it never permanently locks the deployment out of admin access.
|
||||
|
||||
`scripts/bootstrap-env.sh` deliberately leaves these six blank. Sign in as the admin account and set them from the app itself:
|
||||
|
||||
- **Settings → Your API keys**: Anthropic, Brave Search, NinjaPear, USPTO — stored encrypted per-user (`user_api_key_service.py`), fall back to any global `.env` value if you ever set one, but there's no need to.
|
||||
|
||||
@@ -26,7 +26,7 @@ This file is updated as each phase lands. It exists so nothing is silently claim
|
||||
|
||||
- Access/refresh tokens are stored in `localStorage` on the frontend for simplicity (see `apps/web/lib/api-client.ts`). This is a reasonable tradeoff for a local-first MVP but is XSS-exposed compared to httpOnly cookies; a production multi-user deployment should move to cookie-based storage with CSRF protection before going live publicly.
|
||||
- There is no email verification, password reset, or account lockout after repeated failed logins yet (rate limiting mitigates brute force but doesn't lock the account). Password reset is called out in the spec as a "documented future capability" for the MVP.
|
||||
- Admin role (`User.is_admin`) exists on the model and the local dev user is admin, but no admin-only endpoints exist yet to gate with `require_admin`.
|
||||
- **Admin role (`User.is_admin`) now gates several real endpoints** (`require_admin` - Server secrets, IP bans, Database viewer, system logs, unban-request review) - this note originally said none existed yet, long since outdated. **The first account ever registered on a deployment with zero existing admins is now automatically promoted to admin** (`auth_service.register`, checked via `UserRepository.count_admins`) - otherwise the only path to admin access was direct DB manipulation, a real chicken-and-egg problem for anyone self-hosting from a clean clone (see DEPLOYMENT.md §8). Checked by admin *count*, not total user count, so it also self-heals if the last admin ever deletes their own account - the next registration becomes admin again rather than leaving the deployment permanently admin-less. A benign race is possible if two people register in the same instant on a brand-new, zero-admin deployment (both could become admin) - acceptable since it only ever matters once, before any real traffic exists, and DEPLOYMENT.md tells operators to register immediately after bringing the stack up, before opening the firewall.
|
||||
|
||||
## Collection pipeline (Phase 4)
|
||||
|
||||
@@ -57,6 +57,7 @@ This file is updated as each phase lands. It exists so nothing is silently claim
|
||||
- **Tasks A (relevance), B (extraction), and C (synthesis) are implemented and tested but not yet wired into the collection/report pipeline** - Task D (report generation) currently consumes raw `SourceDocument`/`DetectedChange` rows directly rather than pre-filtering through Task A or pre-extracting via Task B/C. Wiring them in would improve report quality (e.g. filtering irrelevant documents before they reach the report prompt) but the report already works correctly without it, since `report_service.py`'s evidence-gathering only pulls documents/changes already scoped to the company.
|
||||
- **A report is regenerated only on baseline (first evidence) or when a run detects an actual change** - not on every scheduled run, to keep LLM cost proportional to real activity rather than to schedule cadence. See `app/tasks/collection.py`.
|
||||
- **No token/cost usage tracking or per-user LLM budget yet** - `LLM_MAX_TOKENS_PER_REQUEST` and `LLM_MAX_RETRIES` bound a single call, but there's no aggregate usage dashboard (spec section 30's "Display usage statistics in settings" is Phase 10/not-yet-done).
|
||||
- **Report list/string fields are backfilled from `company_enrichment` when the LLM leaves them empty** - confirmed live across multiple real Anthropic calls (even with an explicit worked example in the prompt) that the model reliably uses enrichment data for prose fields (`company_overview`, `market_positioning`) but is inconsistent about mirroring the same data into list-shaped fields (`products_and_services`, `financial_signals`, `strategic_initiatives`, etc.). `report_service.py`'s `_backfill_from_enrichment`/`_mirror_swot_into_flat_lists`/`_backfill_reflective_sections` fill these in mechanically from `company_enrichment.products`/`recent_updates`/`funding`/`specialties`, from the model's own SWOT output, and from the actual presence/absence of evidence - never overwriting content the model did produce, and never inventing anything not already present in real evidence. **`key_inferred_projects` is the one field left with no such fallback** - it asks for specific, speculative in-progress projects, which has no honest mechanical source (unlike a specialty list or a funding round, there's no real "raw project" data to transcribe), so it stays genuinely empty unless the model or actual source documents/detected changes surface something.
|
||||
|
||||
## Notifications (Phase 8)
|
||||
|
||||
@@ -132,7 +133,7 @@ This file is updated as each phase lands. It exists so nothing is silently claim
|
||||
- **The "Similar People" endpoint is not wired in at all** - it's a role/company-anchored prospecting tool ("find people like X at competitor Y") with no natural trigger at onboarding time, since no specific role is ever selected. Would need its own UI (e.g. "find people like this one" on a leadership-team entry) to make sense, not an automatic onboarding call.
|
||||
- **NinjaPear's suggested competitors are never merged into `Company.competitors`** (the user-typed/reviewed list that drives Phase 12's competitor cross-linking) - shown only in the Enrichment tab, with their stated reasons, kept deliberately separate so a background API call can never silently change what the user explicitly reviewed. A "add as competitor" action would be a reasonable follow-up if this becomes a papercut.
|
||||
- **Person-level lookups (work email, profile) are capped at `NINJAPEAR_MAX_LEADERSHIP_LOOKUPS` (default 5) per company**, applied to however many leadership members NinjaPear's Company Details call happens to return, in whatever order it returns them - there's no ranking by seniority/relevance before the cap is applied, so for a company with a large leadership team, which specific people get resolved is effectively arbitrary.
|
||||
- **No re-enrichment mechanism** - by design (see Phase 15's Context in `TASKS.md`, onboarding-only was the explicit scoping decision to bound cost), but it does mean `CompanyEnrichment.data` can go stale indefinitely (a funding round happens, a leadership change occurs) with no way to refresh it short of manually deleting the row and re-triggering via the API/DB directly - there's no "re-enrich this company" button anywhere in the UI.
|
||||
- **No automatic re-enrichment** - by design (see Phase 15's Context in `TASKS.md`, onboarding-only was the explicit scoping decision to bound cost), so `CompanyEnrichment.data` can go stale indefinitely (a funding round happens, a leadership change occurs) with no scheduled refresh. **A manual, per-section refresh does exist** (`POST /companies/{id}/enrichment/sections/{section}/refresh`, `enrichment_service.refresh_section`): each of NinjaPear's six per-company endpoints (details, funding, updates, competitors, products, customers) maps to its own UI section, and a "Refresh" button appears next to a section's empty-state message ("None found.") so a genuine provider-side data gap (confirmed live: NinjaPear's real API returns `products: []` for Amazon specifically, with no error - not a bug, just no product data on their end for that company) can be manually retried without re-running the whole enrichment or burning credits on the sections that already succeeded. Runs synchronously in the request (not queued via Celery like onboarding enrichment), since it's a single provider call rather than the full six-plus-leadership fan-out - nginx's `proxy_read_timeout` on `api.ciagent.org` is set to 320s to cover `funding`'s documented up-to-5-minute worst case.
|
||||
- **`GET /system/status`'s live credit-balance check adds one outbound HTTPS call (to NinjaPear) to every load of that endpoint** whenever a key is configured - guarded so a failure there can never break the rest of system status (returns `null` for the balance, logs a warning), but it does mean the Settings page's load time now has a dependency on `nubela.co`'s availability/latency for that one field, not just the app's own DB/Redis health.
|
||||
|
||||
## Live logging, API-key visibility, and enrichment-tab formatting (Phase 17)
|
||||
@@ -169,7 +170,7 @@ This file is updated as each phase lands. It exists so nothing is silently claim
|
||||
- **The old admin-only, localhost-only "API keys" Settings box (`/system/api-keys`, Phase 17) has been removed entirely**, replaced by two independent mechanisms with a deliberately different visibility model each: per-user API keys (Anthropic/Brave/NinjaPear/USPTO - every user manages their own, no admin/localhost gate at all, since each user only ever sees their own value) and a new admin-only (but **not** localhost-gated) "Server secrets" box for values that are genuinely global rather than per-user - today just the Cloudflare Turnstile site key and secret. `SystemSecret` (`app/models/system_secret.py`) is a true singleton-per-key table (one row per `SystemSecretKey`, not scoped to any user), encrypted at rest the same way as `UserApiKey`, with the identical fallback-to-`.env`-when-unset pattern via `system_secret_service.get_effective_settings`.
|
||||
- **The Turnstile site key is no longer delivered to the frontend via a `NEXT_PUBLIC_*` build-time env var.** It used to be baked into the Next.js bundle at container-build time (`NEXT_PUBLIC_TURNSTILE_SITE_KEY`), which meant an admin-updated value could never take effect without a full frontend rebuild - defeating the point of making it admin-editable. It's now served live by the already-public, unauthenticated `GET /system/status` endpoint (`turnstile_site_key`, resolved through the same effective-settings fallback), and `TurnstileWidget` takes it as a prop instead of reading `process.env` directly. This does mean the site key round-trips through one more network hop (an extra field on a call the login/register pages already make) rather than being inlined - a negligible cost for a value that isn't itself secret.
|
||||
- **`system_secrets` rows are true global singletons with no natural per-test isolation** (unlike `user_api_keys`, which is naturally isolated by a randomized per-test `user_id`) - `test_system_secrets.py` has to explicitly clear the table before and after every test via an autouse fixture to avoid cross-test pollution within a full-suite run. Worth remembering if this pattern is extended to more server-wide keys later: any new key added to `SystemSecretKey` inherits the same test-isolation requirement.
|
||||
- **The real `.env` `TURNSTILE_SITE_KEY`/`TURNSTILE_SECRET` values were copied into this DB-backed storage** (both now `configured: true` via the admin Settings box) rather than removed from `.env` outright - the user plans to purge the `.env` values manually once satisfied the DB-backed path works, so both currently agree and nothing changes behaviorally either way. `NEXT_PUBLIC_TURNSTILE_SITE_KEY` specifically *was* removed from `.env`/`.env.example`/`docker-compose.yml`, since nothing reads it anymore (see above) - that one wasn't a "purge later" judgment call, it was genuinely dead as soon as the frontend stopped reading it.
|
||||
- **The real `.env` `TURNSTILE_SITE_KEY`/`TURNSTILE_SECRET`/`RESEND_API_KEY` values on the production deployment have since been moved into this DB-backed storage via the admin API and removed from `.env` outright** (all three now `configured: true`, set through `PUT /system/secrets/{key}` using the deployment's own admin account rather than hand-edited into `.env`) - the earlier state of this note (values copied into the DB but also left in `.env` "to purge later") has been resolved; `.env` is no longer a source of truth for these three at all on that deployment. `NEXT_PUBLIC_TURNSTILE_SITE_KEY` specifically *was already* removed from `.env`/`.env.example`/`docker-compose.yml` earlier, since nothing reads it anymore (see above).
|
||||
|
||||
## Account activity logging expansion: local-dev sign-ins, secret/key updates, known IPs
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -13,6 +14,7 @@ from app.core.config import Settings, get_settings
|
||||
from app.core.errors import NotFoundError
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import get_db
|
||||
from app.enrichment.factory import get_enrichment_provider
|
||||
from app.models.user import User
|
||||
from app.schemas.company import (
|
||||
CompanyCreate,
|
||||
@@ -23,7 +25,14 @@ from app.schemas.company import (
|
||||
)
|
||||
from app.schemas.discovery import DiscoverCompanyRequest, DiscoveredCompanyProfile
|
||||
from app.search.factory import get_search_provider
|
||||
from app.services import company_service, discovery_service, user_api_key_service
|
||||
from app.services import (
|
||||
company_service,
|
||||
discovery_service,
|
||||
enrichment_service,
|
||||
user_api_key_service,
|
||||
)
|
||||
|
||||
EnrichmentSection = Literal["details", "funding", "updates", "competitors", "products", "customers"]
|
||||
|
||||
router = APIRouter(prefix="/companies", tags=["companies"])
|
||||
|
||||
@@ -129,6 +138,28 @@ async def resume_company(
|
||||
return CompanyResponse.from_company(company)
|
||||
|
||||
|
||||
@router.post("/{company_id}/enrichment/sections/{section}/refresh", response_model=CompanyResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def refresh_enrichment_section(
|
||||
request: Request,
|
||||
company_id: uuid.UUID,
|
||||
section: EnrichmentSection,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompanyResponse:
|
||||
"""Re-fetches one enrichment section (e.g. `products`) that came back
|
||||
empty or failed, without touching the other sections - see
|
||||
enrichment_service.refresh_section. Rate-limited since each call spends
|
||||
real NinjaPear credits."""
|
||||
company = await company_service.get_company(db, user.id, company_id)
|
||||
settings = await user_api_key_service.get_effective_settings(db, user.id, settings)
|
||||
provider = get_enrichment_provider(settings)
|
||||
await enrichment_service.refresh_section(db, settings, provider, company, section)
|
||||
refreshed = await company_service.get_company(db, user.id, company_id)
|
||||
return CompanyResponse.from_company(refreshed)
|
||||
|
||||
|
||||
@router.get("/{company_id}/monitor", response_model=MonitorConfigurationResponse)
|
||||
async def get_monitor_configuration(
|
||||
company_id: uuid.UUID,
|
||||
|
||||
@@ -21,17 +21,66 @@ SYSTEM_PROMPT = (
|
||||
"third-party data provider at onboarding, also real evidence, not your own knowledge), "
|
||||
"stored source documents, and previously detected changes. Never introduce "
|
||||
"facts from outside knowledge, even if you recognize the company - if it isn't in the "
|
||||
"evidence block, it doesn't go in the report. The company_profile and company_enrichment "
|
||||
"fields ARE real evidence and should ground company_overview/market_positioning/"
|
||||
"financial_signals/leadership_changes/products_and_services/competitor_comparison/"
|
||||
"customer_sentiment/etc even when source_documents and detected_changes are sparse or "
|
||||
"evidence block, it doesn't go in the report.\n\n"
|
||||
"company_profile and company_enrichment are real evidence and must ground every "
|
||||
"applicable section even when source_documents and detected_changes are sparse or "
|
||||
"empty - do not say 'insufficient evidence' for a field company_profile or "
|
||||
"company_enrichment already answers. Every finding must be "
|
||||
"traceable to the evidence given and must carry an honest confidence label: confirmed "
|
||||
"(the source states it directly), strongly_indicated, likely, possible, unconfirmed, or "
|
||||
"insufficient_evidence. When evidence is thin or missing for a section, say so explicitly "
|
||||
"in that section rather than inventing content. Distinguish clearly between what a source "
|
||||
"states and what you are inferring."
|
||||
"company_enrichment already answers. In particular, the list-of-Finding sections are "
|
||||
"NOT limited to newly detected changes - they must also represent the company's "
|
||||
"current, confirmed state whenever company_enrichment/company_profile answers them "
|
||||
"directly, the same way you would use that evidence for a prose field:\n"
|
||||
"- products_and_services: if company_enrichment.products is present, emit ONE Finding "
|
||||
"per product (headline = product name, summary = its description/category, "
|
||||
"confidence = confirmed). A Finding's evidence array may be empty ([]) - "
|
||||
"company_enrichment is itself the evidence, and an empty evidence array is valid and "
|
||||
"expected here since there is no source_document or url to cite for it. For example, "
|
||||
"given company_enrichment.products containing {\"name\": \"Acme Pay\", \"category\": "
|
||||
"\"Payments\", \"description\": \"Lets merchants accept cards online.\"}, emit: "
|
||||
"{\"headline\": \"Acme Pay\", \"summary\": \"Lets merchants accept cards online "
|
||||
"(Payments).\", \"confidence\": \"confirmed\", \"evidence\": []}. Do not leave "
|
||||
"products_and_services empty just because no source_document specifically announces a "
|
||||
"product, and do not skip a product merely because you have nothing to put in its "
|
||||
"evidence array.\n"
|
||||
"- recent_developments: if company_enrichment.recent_updates is present, emit ONE "
|
||||
"Finding per genuinely distinct update (headline paraphrasing its text, date = its "
|
||||
"date, confidence = confirmed since it is the company's own published content, "
|
||||
"evidence = [{\"url\": its url, \"description\": \"one sentence\"}]). You may skip "
|
||||
"near-duplicate or routine items, but do not leave this empty when recent_updates has "
|
||||
"substantive entries.\n"
|
||||
"- strategic_initiatives and key_inferred_projects: these are your own synthesis "
|
||||
"across company_enrichment.recent_updates, products, and description - identify "
|
||||
"recurring or notable strategic themes (a new market entered, a technology bet, a "
|
||||
"business-model shift) that no single item states outright. Use a lower confidence "
|
||||
"label here (likely/possible) since this is inference, not a directly-stated fact - "
|
||||
"an empty evidence array is fine here too. Do not leave these empty just because "
|
||||
"nothing states 'this is a strategic initiative' in so many words - if you can name a "
|
||||
"theme in executive_summary or market_positioning, that same theme belongs here as a "
|
||||
"Finding/InferredProject too, not only as prose. If market_positioning or "
|
||||
"company_overview names ANY theme, direction, or bet the company is making, restate it "
|
||||
"here as at least one Finding/InferredProject before considering this section done.\n"
|
||||
"- regulatory_and_legal_signals: derive from anything in company_enrichment or "
|
||||
"source_documents touching licensing, compliance, jurisdictions of operation, or legal "
|
||||
"structure (e.g. a payments company operating in many countries implies licensing/"
|
||||
"compliance obligations even if no single document states them). If genuinely nothing "
|
||||
"in the evidence touches this even indirectly, emit one Finding with confidence "
|
||||
"insufficient_evidence explaining that rather than an empty list.\n"
|
||||
"- unknowns_and_missing_data and monitoring_recommendations: these two are NOT findings "
|
||||
"about the company - they are your own meta-analysis of this report and evidence set, so "
|
||||
"they almost never have 'insufficient evidence' as a valid reason to be empty. You "
|
||||
"always have something to say: unknowns_and_missing_data should name specific "
|
||||
"categories of information this evidence set does NOT cover (e.g. 'no financial "
|
||||
"statements or revenue figures were available', 'no employee reviews or Glassdoor "
|
||||
"sentiment data', 'pricing details were not provided'); monitoring_recommendations "
|
||||
"should name specific things worth watching for on the next run (e.g. 'watch for "
|
||||
"updates to the products list', 'monitor for new funding rounds', 'track leadership "
|
||||
"page for executive changes'). Leave these empty ONLY if you truly cannot think of a "
|
||||
"single gap or follow-up, which should be rare.\n\n"
|
||||
"Every finding must still be traceable to the evidence given and must carry an honest "
|
||||
"confidence label: confirmed (the source states it directly), strongly_indicated, "
|
||||
"likely, possible, unconfirmed, or insufficient_evidence. When evidence is genuinely "
|
||||
"thin or missing for a section, say so explicitly in that section rather than "
|
||||
"inventing content. Distinguish clearly between what a source states and what you are "
|
||||
"inferring."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.user import User
|
||||
@@ -15,6 +15,12 @@ class UserRepository:
|
||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||
return await self.db.get(User, user_id)
|
||||
|
||||
async def count_admins(self) -> int:
|
||||
result = await self.db.execute(
|
||||
select(func.count()).select_from(User).where(User.is_admin.is_(True))
|
||||
)
|
||||
return result.scalar_one()
|
||||
|
||||
async def get_by_email(self, email: str) -> User | None:
|
||||
result = await self.db.execute(select(User).where(User.email == email.lower()))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
@@ -125,11 +125,25 @@ async def register(
|
||||
if existing is not None:
|
||||
raise ConflictError("An account with this email already exists")
|
||||
|
||||
# Bootstraps admin access on a fresh deployment - otherwise the only way
|
||||
# to ever get an admin account is direct DB access, which is a real
|
||||
# chicken-and-egg problem for anyone self-hosting from a clean clone.
|
||||
# Checked by admin *count*, not total user count, so this also
|
||||
# self-heals if the last admin ever deletes their own account (Settings
|
||||
# -> Delete account) - the next registration becomes admin again rather
|
||||
# than leaving the deployment permanently admin-less. A benign race is
|
||||
# possible if two people register in the same instant on a brand-new,
|
||||
# zero-admin deployment (both could become admin) - acceptable for a
|
||||
# bootstrapping check that only ever matters once, before any real
|
||||
# traffic exists.
|
||||
is_first_admin = await repo.count_admins() == 0
|
||||
|
||||
user = await repo.create(
|
||||
email=payload.email,
|
||||
password_hash=hash_password(payload.password),
|
||||
display_name=payload.display_name,
|
||||
timezone=payload.timezone,
|
||||
is_admin=is_first_admin,
|
||||
# Test suite has no inbox to read a real code from - same
|
||||
# app_env == "test" precedent already used to disable rate limiting
|
||||
# (app/core/rate_limit.py). The code-generation/sending/throttle
|
||||
|
||||
@@ -20,6 +20,7 @@ from datetime import UTC, datetime
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.core.errors import NotFoundError
|
||||
from app.core.logging import get_logger
|
||||
from app.enrichment.base import EnrichmentProvider
|
||||
from app.models.company import Company
|
||||
@@ -174,3 +175,104 @@ async def enrich_company(
|
||||
failed_sections=list(errors.keys()),
|
||||
)
|
||||
return enrichment
|
||||
|
||||
|
||||
# Every entry here maps 1:1 onto one of NinjaPear's independent
|
||||
# per-company endpoints (see app/enrichment/ninjapear.py) - refreshing one
|
||||
# section never re-fetches or touches any of the others.
|
||||
REFRESHABLE_SECTIONS = ("details", "funding", "updates", "competitors", "products", "customers")
|
||||
|
||||
|
||||
async def refresh_section(
|
||||
db: AsyncSession,
|
||||
settings: Settings,
|
||||
provider: EnrichmentProvider,
|
||||
company: Company,
|
||||
section: str,
|
||||
) -> CompanyEnrichment:
|
||||
"""Re-runs a single NinjaPear endpoint for a company that already has
|
||||
an enrichment record but came back empty or failed for just this one
|
||||
section (e.g. `products` returning `[]` while everything else
|
||||
succeeded - a real, honest gap in NinjaPear's own coverage for that
|
||||
company, not a bug in this app). Only ever replaces this section's own
|
||||
slice of `data`/`errors` - every other section's stored data is left
|
||||
exactly as it was."""
|
||||
if section not in REFRESHABLE_SECTIONS:
|
||||
raise ValueError(f"Unknown enrichment section: {section}")
|
||||
|
||||
repo = CompanyEnrichmentRepository(db)
|
||||
existing = await repo.get_for_company(company.id)
|
||||
if existing is None:
|
||||
raise NotFoundError("Company has no enrichment record to refresh")
|
||||
|
||||
website = company.official_website
|
||||
data = dict(existing.data)
|
||||
errors = dict(existing.errors)
|
||||
credits_spent = existing.credits_spent or 0
|
||||
|
||||
try:
|
||||
if section == "details":
|
||||
details = await provider.get_company_details(company.name, website)
|
||||
data["employee_count"] = details.employee_count_range
|
||||
data["description"] = details.description
|
||||
data["industry"] = details.industry
|
||||
data["founded_year"] = details.founded_year
|
||||
data["specialties"] = details.specialties
|
||||
new_leadership = [m.model_dump() for m in details.leadership_team]
|
||||
# A details refresh re-fetches the leadership roster itself,
|
||||
# but not the separate per-leader work_email/person_profile
|
||||
# lookups - preserve those by matching on name so a refresh
|
||||
# never regresses contact info that was already found.
|
||||
old_by_name = {
|
||||
m.get("name"): m for m in (data.get("leadership_team") or []) if m.get("name")
|
||||
}
|
||||
for member in new_leadership:
|
||||
old = old_by_name.get(member.get("name"))
|
||||
if old:
|
||||
member["work_email"] = member.get("work_email") or old.get("work_email")
|
||||
member["profile_url"] = member.get("profile_url") or old.get("profile_url")
|
||||
member["bio"] = member.get("bio") or old.get("bio")
|
||||
data["leadership_team"] = new_leadership
|
||||
elif section == "funding":
|
||||
funding = await provider.get_funding(company.name, website)
|
||||
data["funding"] = funding.model_dump()
|
||||
elif section == "updates":
|
||||
updates = await provider.get_updates(company.name, website)
|
||||
data["recent_updates"] = [u.model_dump() for u in updates]
|
||||
elif section == "competitors":
|
||||
competitors = await provider.get_competitors(company.name, website)
|
||||
data["competitors"] = [c.model_dump() for c in competitors]
|
||||
elif section == "products":
|
||||
products = await provider.get_products(company.name, website)
|
||||
data["products"] = [p.model_dump() for p in products]
|
||||
elif section == "customers":
|
||||
customers = await provider.get_customers(company.name, website)
|
||||
data["customers"] = [c.model_dump() for c in customers]
|
||||
errors.pop(section, None)
|
||||
credits_spent += _CREDIT_COSTS.get(section, 0)
|
||||
except Exception as exc: # noqa: BLE001 - surfaced via `errors`, same as the initial run
|
||||
errors[section] = str(exc)
|
||||
logger.warning(
|
||||
"enrichment_section_refresh_failed",
|
||||
section=section,
|
||||
company_id=str(company.id),
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
new_status = EnrichmentStatus.PARTIAL if errors else EnrichmentStatus.COMPLETE
|
||||
enrichment = await repo.upsert(
|
||||
company.id,
|
||||
status=new_status,
|
||||
data=data,
|
||||
errors=errors,
|
||||
credits_spent=credits_spent,
|
||||
fetched_at=datetime.now(UTC),
|
||||
)
|
||||
await db.commit()
|
||||
logger.info(
|
||||
"company_enrichment_section_refreshed",
|
||||
company_id=str(company.id),
|
||||
section=section,
|
||||
succeeded=section not in errors,
|
||||
)
|
||||
return enrichment
|
||||
|
||||
@@ -23,7 +23,8 @@ from app.models.enums import EnrichmentStatus, ReportType, SourceStatus
|
||||
from app.models.report import Report
|
||||
from app.models.source import Source
|
||||
from app.models.source_document import SourceDocument
|
||||
from app.prompts.report_generation import generate_report
|
||||
from app.prompts.report_generation import ReportContent, generate_report
|
||||
from app.prompts.schemas import ConfidenceLabel, EvidenceRef, Finding
|
||||
from app.repositories.company_enrichment_repository import CompanyEnrichmentRepository
|
||||
from app.repositories.report_repository import ReportRepository
|
||||
from app.services import company_service
|
||||
@@ -31,6 +32,210 @@ from app.services.report_markdown import render_report_markdown
|
||||
|
||||
_MAX_DOCUMENTS = 40
|
||||
_MAX_CHANGES = 20
|
||||
_MAX_BACKFILLED_PRODUCTS = 30
|
||||
_MAX_BACKFILLED_UPDATES = 15
|
||||
_MAX_BACKFILLED_FUNDING_ROUNDS = 15
|
||||
|
||||
|
||||
def _format_amount(amount: object) -> str | None:
|
||||
if amount is None:
|
||||
return None
|
||||
text = str(amount)
|
||||
return f"${int(text):,}" if text.isdigit() else text
|
||||
|
||||
|
||||
def _backfill_from_enrichment(content: ReportContent, enrichment: dict | None) -> None:
|
||||
"""company_enrichment.products/recent_updates/funding map onto
|
||||
products_and_services/recent_developments/financial_signals with no
|
||||
inference required - a direct transcription, not a judgment call.
|
||||
Confirmed live that the LLM is nonetheless inconsistent about
|
||||
populating these Finding-list fields from enrichment alone (it
|
||||
reliably uses the same data for prose fields like company_overview,
|
||||
but repeated real API calls with increasingly explicit instructions
|
||||
still came back with these lists empty). Rather than keep fighting
|
||||
prompt compliance for a purely mechanical transform, backfill directly
|
||||
from the source data whenever the model leaves a field empty despite
|
||||
the data being available - this only ever *adds* real, non-fabricated
|
||||
content the model chose not to surface, never overwrites what the
|
||||
model did produce."""
|
||||
if not enrichment:
|
||||
return
|
||||
|
||||
if not content.products_and_services:
|
||||
content.products_and_services = [
|
||||
Finding(
|
||||
headline=product["name"],
|
||||
summary=product.get("description") or product.get("category") or product["name"],
|
||||
category=product.get("category"),
|
||||
confidence=ConfidenceLabel.CONFIRMED,
|
||||
)
|
||||
for product in enrichment.get("products", [])[:_MAX_BACKFILLED_PRODUCTS]
|
||||
if product.get("name")
|
||||
]
|
||||
|
||||
if not content.recent_developments:
|
||||
content.recent_developments = [
|
||||
Finding(
|
||||
headline=update["text"],
|
||||
summary=f"{update.get('type', 'update').capitalize()} published by the company.",
|
||||
date=update.get("date"),
|
||||
confidence=ConfidenceLabel.CONFIRMED,
|
||||
evidence=(
|
||||
[EvidenceRef(url=update["url"], description="Company-published update.")]
|
||||
if update.get("url")
|
||||
else []
|
||||
),
|
||||
)
|
||||
for update in enrichment.get("recent_updates", [])[:_MAX_BACKFILLED_UPDATES]
|
||||
if update.get("text")
|
||||
]
|
||||
|
||||
if not content.financial_signals:
|
||||
funding = enrichment.get("funding") or {}
|
||||
findings = []
|
||||
total_raised = _format_amount(funding.get("total_raised"))
|
||||
if total_raised:
|
||||
findings.append(
|
||||
Finding(
|
||||
headline=f"Total funding raised: {total_raised}",
|
||||
summary="Cumulative funding raised across all rounds, per company_enrichment.",
|
||||
confidence=ConfidenceLabel.CONFIRMED,
|
||||
)
|
||||
)
|
||||
for round_ in funding.get("rounds", [])[:_MAX_BACKFILLED_FUNDING_ROUNDS]:
|
||||
name = round_.get("round_name")
|
||||
if not name:
|
||||
continue
|
||||
amount = _format_amount(round_.get("amount"))
|
||||
investors = round_.get("investors") or []
|
||||
headline = name.replace("_", " ").title() + (f" - {amount}" if amount else "")
|
||||
findings.append(
|
||||
Finding(
|
||||
headline=headline,
|
||||
summary=(
|
||||
f"Investors: {', '.join(investors)}."
|
||||
if investors
|
||||
else "No investor detail provided."
|
||||
),
|
||||
date=round_.get("date"),
|
||||
confidence=ConfidenceLabel.CONFIRMED,
|
||||
)
|
||||
)
|
||||
content.financial_signals = findings
|
||||
|
||||
|
||||
def _mirror_swot_into_flat_lists(content: ReportContent) -> None:
|
||||
"""risks/opportunities are meant to be the same analysis as
|
||||
swot.threats/swot.opportunities, just in a flat top-level list rather
|
||||
than nested under swot - not a second, independent judgment call.
|
||||
Confirmed live: the model reliably populates the SWOT fields but is
|
||||
inconsistent about also populating these parallel flat fields with the
|
||||
same content, even though nothing about them requires different
|
||||
evidence. Mirror rather than re-derive, since the model already did
|
||||
the real analytical work once."""
|
||||
if not content.risks and content.swot.threats:
|
||||
content.risks = list(content.swot.threats)
|
||||
if not content.opportunities and content.swot.opportunities:
|
||||
content.opportunities = list(content.swot.opportunities)
|
||||
|
||||
|
||||
def _backfill_reflective_sections(
|
||||
content: ReportContent,
|
||||
*,
|
||||
enrichment: dict | None,
|
||||
documents: list[dict],
|
||||
changes: list[dict],
|
||||
company_name: str,
|
||||
) -> None:
|
||||
"""strategic_initiatives, regulatory_and_legal_signals,
|
||||
unknowns_and_missing_data, and monitoring_recommendations are the
|
||||
fields the LLM was most persistently reluctant to populate even after
|
||||
two rounds of explicit prompt strengthening (confirmed live - see
|
||||
SYSTEM_PROMPT in report_generation.py). Unlike products/recent_updates,
|
||||
these don't have a single obvious mechanical source, but each still
|
||||
has SOMETHING honest and non-fabricated to fall back on:
|
||||
- strategic_initiatives: company_enrichment.specialties names the
|
||||
company's own stated focus areas - a real, low-confidence signal,
|
||||
not invented.
|
||||
- regulatory_and_legal_signals: when truly nothing applies, a single
|
||||
insufficient_evidence Finding is what the prompt already asks the
|
||||
model to emit in this situation instead of leaving the list empty -
|
||||
the model just isn't doing it reliably, so this fills the same gap.
|
||||
- unknowns_and_missing_data / monitoring_recommendations: these are
|
||||
meta-analysis of the evidence set itself, not claims about the
|
||||
company, so they can be derived honestly from what evidence this
|
||||
pipeline actually did or didn't collect for this company."""
|
||||
enrichment = enrichment or {}
|
||||
|
||||
if not content.strategic_initiatives:
|
||||
specialties = enrichment.get("specialties") or []
|
||||
content.strategic_initiatives = [
|
||||
Finding(
|
||||
headline=f"Focus area: {specialty}",
|
||||
summary=(
|
||||
f"{company_name} lists \"{specialty}\" among its specialties, "
|
||||
"indicating an area of strategic focus."
|
||||
),
|
||||
confidence=ConfidenceLabel.POSSIBLE,
|
||||
)
|
||||
for specialty in specialties
|
||||
if specialty
|
||||
]
|
||||
|
||||
if not content.regulatory_and_legal_signals:
|
||||
content.regulatory_and_legal_signals = [
|
||||
Finding(
|
||||
headline="No regulatory or legal signals identified",
|
||||
summary=(
|
||||
"No licensing, compliance, jurisdictional, or legal-structure "
|
||||
"information was present in the available evidence for "
|
||||
f"{company_name}."
|
||||
),
|
||||
confidence=ConfidenceLabel.INSUFFICIENT_EVIDENCE,
|
||||
)
|
||||
]
|
||||
|
||||
if not content.unknowns_and_missing_data:
|
||||
unknowns = []
|
||||
if not enrichment:
|
||||
unknowns.append(
|
||||
"No third-party company enrichment data was available for this company."
|
||||
)
|
||||
else:
|
||||
if not enrichment.get("funding", {}).get("total_raised") and not enrichment.get(
|
||||
"funding", {}
|
||||
).get("rounds"):
|
||||
unknowns.append(
|
||||
"No detailed financial statements, revenue, or funding figures were found."
|
||||
)
|
||||
if not enrichment.get("leadership_team"):
|
||||
unknowns.append("No leadership or executive team data was found.")
|
||||
if not enrichment.get("customers"):
|
||||
unknowns.append("No named customers or case studies were found.")
|
||||
if not documents:
|
||||
unknowns.append(
|
||||
"No source documents have been collected yet from ongoing monitoring, so "
|
||||
"hiring, technology, patent, and manufacturing signals are not yet available."
|
||||
)
|
||||
if not changes:
|
||||
unknowns.append(
|
||||
"No changes have been detected yet between monitoring runs for this company."
|
||||
)
|
||||
content.unknowns_and_missing_data = unknowns
|
||||
|
||||
if not content.monitoring_recommendations:
|
||||
recommendations = [
|
||||
f"Monitor company_enrichment for {company_name} on its next refresh for changes "
|
||||
"to products, leadership, or funding.",
|
||||
"Track newly published company updates for announcements of new initiatives "
|
||||
"or partnerships.",
|
||||
]
|
||||
if documents:
|
||||
recommendations.append(
|
||||
"Continue reviewing newly collected source documents for signals not yet "
|
||||
"reflected in company_enrichment."
|
||||
)
|
||||
content.monitoring_recommendations = recommendations
|
||||
|
||||
|
||||
async def _gather_documents(db: AsyncSession, company_id: uuid.UUID) -> list[dict]:
|
||||
@@ -130,6 +335,15 @@ async def generate_and_persist_report(
|
||||
public_identifiers=company.public_identifiers,
|
||||
enrichment=enrichment,
|
||||
)
|
||||
_backfill_from_enrichment(content, enrichment)
|
||||
_mirror_swot_into_flat_lists(content)
|
||||
_backfill_reflective_sections(
|
||||
content,
|
||||
enrichment=enrichment,
|
||||
documents=documents,
|
||||
changes=changes,
|
||||
company_name=company.name,
|
||||
)
|
||||
|
||||
generated_at = datetime.now(UTC)
|
||||
model_name = _model_name(settings, llm)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ChevronRight,
|
||||
Loader2,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import { CompanyPillLink } from "@/components/ui/company-pill";
|
||||
import { CopyableEmail } from "@/components/ui/copyable-email";
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
useCompanies,
|
||||
useCompany,
|
||||
useDeleteCompany,
|
||||
useRefreshEnrichmentSection,
|
||||
useSetCompanyPaused,
|
||||
useUpdateMonitorConfiguration,
|
||||
} from "@/hooks/use-companies";
|
||||
@@ -37,7 +39,7 @@ import {
|
||||
import { useCompanyRuns, useRunCompanyNow } from "@/hooks/use-monitoring-runs";
|
||||
import { useCompanyReports, useGenerateReport } from "@/hooks/use-reports";
|
||||
import { useSnapshots } from "@/hooks/use-snapshots";
|
||||
import type { MonitoringFrequency } from "@/lib/types";
|
||||
import type { EnrichmentSection, MonitoringFrequency } from "@/lib/types";
|
||||
import {
|
||||
FREQUENCY_LABELS,
|
||||
MONITORING_FREQUENCIES,
|
||||
@@ -149,6 +151,30 @@ function EnrichmentErrorSummary({
|
||||
);
|
||||
}
|
||||
|
||||
function RefreshSectionButton({
|
||||
section,
|
||||
label,
|
||||
mutation,
|
||||
}: {
|
||||
section: EnrichmentSection;
|
||||
label: string;
|
||||
mutation: ReturnType<typeof useRefreshEnrichmentSection>;
|
||||
}) {
|
||||
const isRefreshing = mutation.isPending && mutation.variables === section;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate(section)}
|
||||
disabled={isRefreshing}
|
||||
title={`Re-fetch ${label} from NinjaPear`}
|
||||
className="focus-ring mt-2 inline-flex items-center gap-1.5 rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 transition-colors duration-150 hover:bg-slate-50 disabled:opacity-60"
|
||||
>
|
||||
<RefreshCw className={`h-3 w-3 ${isRefreshing ? "animate-spin" : ""}`} aria-hidden />
|
||||
{isRefreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompanyDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params);
|
||||
return (
|
||||
@@ -176,6 +202,7 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
||||
const deleteSource = useDeleteSource(id);
|
||||
const testSource = useTestSource(id);
|
||||
const updateSource = useUpdateSource(id);
|
||||
const refreshEnrichmentSection = useRefreshEnrichmentSection(id);
|
||||
const { data: runs, isLoading: runsLoading } = useCompanyRuns(id);
|
||||
const runNow = useRunCompanyNow(id);
|
||||
const { data: reports, isLoading: reportsLoading } = useCompanyReports(id);
|
||||
@@ -532,7 +559,16 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
||||
| { total_raised?: string; rounds?: Record<string, unknown>[] }
|
||||
| undefined;
|
||||
if (!funding || (!funding.total_raised && !funding.rounds?.length)) {
|
||||
return <p className="mt-2 text-slate-500">No funding data found.</p>;
|
||||
return (
|
||||
<div className="animate-fade-in">
|
||||
<p className="mt-2 text-slate-500">No funding data found.</p>
|
||||
<RefreshSectionButton
|
||||
section="funding"
|
||||
label="funding data"
|
||||
mutation={refreshEnrichmentSection}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
@@ -574,7 +610,14 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
||||
<h2 className="font-medium text-slate-700">Leadership team</h2>
|
||||
{!Array.isArray(company.enrichment.data.leadership_team) ||
|
||||
(company.enrichment.data.leadership_team as unknown[]).length === 0 ? (
|
||||
<div className="animate-fade-in">
|
||||
<p className="mt-2 text-slate-500">No leadership team data found.</p>
|
||||
<RefreshSectionButton
|
||||
section="details"
|
||||
label="leadership team"
|
||||
mutation={refreshEnrichmentSection}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-2">
|
||||
{(
|
||||
@@ -613,37 +656,18 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
||||
</section>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||
<h2 className="font-medium text-slate-700">Customers</h2>
|
||||
{!Array.isArray(company.enrichment.data.customers) ||
|
||||
(company.enrichment.data.customers as unknown[]).length === 0 ? (
|
||||
<p className="mt-2 text-slate-500">None found.</p>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-wrap gap-2">
|
||||
{(company.enrichment.data.customers as Record<string, unknown>[]).map(
|
||||
(c, i) => {
|
||||
const name = String(c.name ?? "");
|
||||
if (!name) return null;
|
||||
return (
|
||||
<li key={i}>
|
||||
<CompanyPillLink
|
||||
name={name}
|
||||
monitoredByName={monitoredByName}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||
<h2 className="font-medium text-slate-700">Products</h2>
|
||||
{!Array.isArray(company.enrichment.data.products) ||
|
||||
(company.enrichment.data.products as unknown[]).length === 0 ? (
|
||||
<div className="animate-fade-in">
|
||||
<p className="mt-2 text-slate-500">None found.</p>
|
||||
<RefreshSectionButton
|
||||
section="products"
|
||||
label="products"
|
||||
mutation={refreshEnrichmentSection}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-2 space-y-1.5">
|
||||
{(company.enrichment.data.products as Record<string, unknown>[]).map(
|
||||
@@ -662,14 +686,19 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||
<h2 className="font-medium text-slate-700">Recent updates</h2>
|
||||
{!Array.isArray(company.enrichment.data.recent_updates) ||
|
||||
(company.enrichment.data.recent_updates as unknown[]).length === 0 ? (
|
||||
<div className="animate-fade-in">
|
||||
<p className="mt-2 text-slate-500">None found.</p>
|
||||
<RefreshSectionButton
|
||||
section="updates"
|
||||
label="recent updates"
|
||||
mutation={refreshEnrichmentSection}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-2">
|
||||
{(
|
||||
@@ -693,6 +722,41 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||
<h2 className="font-medium text-slate-700">Customers</h2>
|
||||
{!Array.isArray(company.enrichment.data.customers) ||
|
||||
(company.enrichment.data.customers as unknown[]).length === 0 ? (
|
||||
<div className="animate-fade-in">
|
||||
<p className="mt-2 text-slate-500">None found.</p>
|
||||
<RefreshSectionButton
|
||||
section="customers"
|
||||
label="customers"
|
||||
mutation={refreshEnrichmentSection}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-wrap gap-2">
|
||||
{(company.enrichment.data.customers as Record<string, unknown>[]).map(
|
||||
(c, i) => {
|
||||
const name = String(c.name ?? "");
|
||||
if (!name) return null;
|
||||
return (
|
||||
<li key={i}>
|
||||
<CompanyPillLink
|
||||
name={name}
|
||||
monitoredByName={monitoredByName}
|
||||
returnTo={returnTo}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||
<h2 className="font-medium text-slate-700">
|
||||
@@ -701,7 +765,14 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
||||
</h2>
|
||||
{!Array.isArray(company.enrichment.data.competitors) ||
|
||||
(company.enrichment.data.competitors as unknown[]).length === 0 ? (
|
||||
<div className="animate-fade-in">
|
||||
<p className="mt-2 text-slate-500">None found.</p>
|
||||
<RefreshSectionButton
|
||||
section="competitors"
|
||||
label="competitors"
|
||||
mutation={refreshEnrichmentSection}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-2 flex flex-wrap gap-2">
|
||||
{(
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CompanyCreatePayload,
|
||||
CompanyResponse,
|
||||
CompanyUpdatePayload,
|
||||
EnrichmentSection,
|
||||
MonitorConfigurationUpdatePayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
@@ -67,6 +68,17 @@ export function useSetCompanyPaused() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useRefreshEnrichmentSection(companyId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (section: EnrichmentSection) =>
|
||||
api.refreshEnrichmentSection(companyId, section),
|
||||
onSuccess: (company) => {
|
||||
queryClient.setQueryData(["companies", companyId], company);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMonitorConfiguration(companyId: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -16,6 +16,7 @@ import type {
|
||||
DeleteAccountPayload,
|
||||
DiscoverCompanyRequest,
|
||||
DiscoveredCompanyProfile,
|
||||
EnrichmentSection,
|
||||
IpBan,
|
||||
LogEntry,
|
||||
LoginPayload,
|
||||
@@ -316,6 +317,12 @@ export const api = {
|
||||
resumeCompany: (companyId: string) =>
|
||||
request<CompanyResponse>(`/api/v1/companies/${companyId}/resume`, { method: "POST" }),
|
||||
|
||||
refreshEnrichmentSection: (companyId: string, section: EnrichmentSection) =>
|
||||
request<CompanyResponse>(
|
||||
`/api/v1/companies/${companyId}/enrichment/sections/${section}/refresh`,
|
||||
{ method: "POST" },
|
||||
),
|
||||
|
||||
updateMonitorConfiguration: (companyId: string, payload: MonitorConfigurationUpdatePayload) =>
|
||||
request<MonitorConfigurationResponse>(`/api/v1/companies/${companyId}/monitor`, {
|
||||
method: "PATCH",
|
||||
|
||||
@@ -221,6 +221,14 @@ export interface MonitorConfigurationUpdatePayload {
|
||||
|
||||
export type EnrichmentStatus = "pending" | "partial" | "complete" | "failed";
|
||||
|
||||
export type EnrichmentSection =
|
||||
| "details"
|
||||
| "funding"
|
||||
| "updates"
|
||||
| "competitors"
|
||||
| "products"
|
||||
| "customers";
|
||||
|
||||
export interface CompanyEnrichmentResponse {
|
||||
status: EnrichmentStatus;
|
||||
data: Record<string, unknown>;
|
||||
|
||||
@@ -59,6 +59,12 @@ http {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
# NinjaPear's funding endpoint is documented as taking up to 5
|
||||
# minutes (see app/enrichment/ninjapear.py's _FUNDING_TIMEOUT) -
|
||||
# the enrichment-section-refresh endpoint calls it synchronously,
|
||||
# so nginx's 60s default would otherwise 504 before it finishes.
|
||||
proxy_read_timeout 320s;
|
||||
proxy_send_timeout 320s;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user