Compare commits

..
4 Commits
Author SHA1 Message Date
sakshamandClaude Sonnet 5 56b4a5404e Add pending account provisioning for demoing to not-yet-existing accounts
An admin can now queue up an email address in advance (POST/GET/DELETE
/system/pending-provisioning) with a source user and an optional admin
flag. The moment that email actually verifies a real account - not raw
registration, which proves nothing about ownership - it gets a deep copy
of the source user's per-user API keys and every company they own
(company profile, aliases, competitors, monitor config, sources, source
documents, monitoring runs, reports, snapshots, detected changes, and
enrichment - not just the company row), plus an email notification
destination for its own address linked to the copied companies.

The clone logic (_copy_row/_clone_company in provisioning_service.py) is
generic over every table it touches via column introspection, so it
doesn't need hand-maintained field lists per model.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-06 18:47:29 -04:00
sakshamandClaude Sonnet 5 18305b545c 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]>
2026-08-06 17:41:02 -04:00
sakshamandClaude Sonnet 5 1be3e53584 Clarify README: fresh clones ship with zero API keys, by design
Expands the Configuration section to explain why (nothing paid is
ever committed, .env is gitignored) and separates the two independent
places to add real keys once you have your own: .env for deployment-
wide provider selection/defaults, vs. the Settings page's "Your API
keys" (per-user) and admin-only "Server secrets" (Turnstile, Resend)
for a running deployment with no restart needed. Also links
DEPLOYMENT.md and the live ciagent.org/git.ciagent.org reference
deployment, and fixes the Celery queue list to include "enrichment".

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-06 08:14:14 -04:00
sakshamandClaude Sonnet 5 d706f226de Fix executable bit on scripts/*.sh, tracked as non-executable since commit
auto-deploy.sh only ever worked because someone chmod +x'd it directly
on the server after cloning, outside git - a fix that lived nowhere
git could see. A `git checkout --` to that file (recovering from an
unrelated direct edit) silently restored the tracked 644 mode,
breaking the deploy timer with "Permission denied" until caught via
journalctl. bootstrap-env.sh had the identical latent bug, just never
triggered since it's only ever run manually.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
2026-08-06 07:13:42 -04:00
29 changed files with 2016 additions and 65 deletions
+2
View File
@@ -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.
+4 -3
View File
@@ -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
+25 -9
View File
@@ -2,7 +2,9 @@
CI Agent monitors companies you choose, collects publicly available information from multiple sources on a schedule, analyzes it with an LLM into an evidence-linked report, detects meaningful changes between runs, and alerts you by email (and optionally SMS) with a severity and confidence score.
See [`PLAN.md`](PLAN.md) for the build strategy, [`ARCHITECTURE.md`](ARCHITECTURE.md) for system design, [`SECURITY.md`](SECURITY.md) for the threat model, [`TASKS.md`](TASKS.md) for the live implementation checklist, [`KNOWN_LIMITATIONS.md`](KNOWN_LIMITATIONS.md) for what's stubbed vs. fully live, and [`docs/FIREBASE_MIGRATION.md`](docs/FIREBASE_MIGRATION.md) for a (skeptical) look at what moving to Firebase would take.
See [`PLAN.md`](PLAN.md) for the build strategy, [`ARCHITECTURE.md`](ARCHITECTURE.md) for system design, [`SECURITY.md`](SECURITY.md) for the threat model, [`TASKS.md`](TASKS.md) for the live implementation checklist, [`KNOWN_LIMITATIONS.md`](KNOWN_LIMITATIONS.md) for what's stubbed vs. fully live, [`DEPLOYMENT.md`](DEPLOYMENT.md) for running this in production behind Cloudflare/Nginx, and [`docs/FIREBASE_MIGRATION.md`](docs/FIREBASE_MIGRATION.md) for a (skeptical) look at what moving to Firebase would take.
A live reference deployment runs at [ciagent.org](https://ciagent.org), with its own code hosted on a self-hosted Gitea instance at [git.ciagent.org](https://git.ciagent.org) (public read, admin-only write) — click "Git Repository" on the landing page.
## Quick start (Docker)
@@ -57,7 +59,7 @@ cd apps/api && alembic upgrade head
cd apps/api && alembic revision --autogenerate -m "description"
# Run a Celery worker + beat (only needed outside Docker)
cd apps/api && celery -A app.tasks.celery_app worker --loglevel=INFO -Q default,collection,analysis,notifications,maintenance
cd apps/api && celery -A app.tasks.celery_app worker --loglevel=INFO -Q default,collection,analysis,notifications,maintenance,enrichment
cd apps/api && celery -A app.tasks.celery_app beat --loglevel=INFO
# Backend tests / lint / format
@@ -78,15 +80,29 @@ cd apps/web && npm run e2e
## Configuration
All configuration is via environment variables — see [`.env.example`](.env.example) for the full list with comments. Highlights:
**A fresh clone ships with zero API keys, on purpose.** `.env` is gitignored and never committed; `.env.example` — the only thing that *is* committed — leaves every paid provider blank. There's no "secret" hidden somewhere in the repo history either; nothing paid has ever been checked in. Clone it, `docker compose up`, and the entire demo workflow (register/login, add a company, run a monitor, get a report, get an alert) works immediately against mock LLM/search providers and console-logged notifications — no signups, no billing, no keys, ever required just to try it.
- `AUTH_MODE=local|jwt` — local single-user dev mode vs. real email/password accounts.
- `LLM_PROVIDER=mock|anthropic|ollama|gemini` — set `ANTHROPIC_API_KEY`/`ANTHROPIC_MODEL`, `OLLAMA_BASE_URL`/`OLLAMA_MODEL`, or `GEMINI_API_KEY`/`GEMINI_MODEL` to go live. Gemini has a genuine free tier — grab a key at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) — so it's the cheapest provider to actually try against a real model.
- `SEARCH_PROVIDER=mock|brave` — set `BRAVE_SEARCH_API_KEY` to go live.
- `SMTP_HOST`/`RESEND_API_KEY` — no bundled local mail sink; point SMTP at a real relay or set `RESEND_API_KEY` to actually test email delivery (alerts and security email both use this).
- `NOTIFICATION_SMS_ENABLED=false` by default — set to `true` and provide `TWILIO_*` to enable SMS.
Every paid/external provider defaults to a mock/console implementation this way. Automated tests always run against mocks too, and never call a paid API.
Every paid/external provider defaults to a mock/console implementation. Automated tests always run against mocks and never call a paid API.
### Adding real provider keys
Once you have your own API keys, there are two independent places to put them, and they serve different purposes:
1. **`.env` (deployment-wide defaults)** — see [`.env.example`](.env.example) for the full list with comments. The key ones:
- `AUTH_MODE=local|jwt` — local single-user dev mode vs. real email/password accounts.
- `LLM_PROVIDER=mock|anthropic|ollama|gemini` — set `ANTHROPIC_API_KEY`/`ANTHROPIC_MODEL`, `OLLAMA_BASE_URL`/`OLLAMA_MODEL`, or `GEMINI_API_KEY`/`GEMINI_MODEL` to go live. Gemini has a genuine free tier — grab a key at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) — so it's the cheapest provider to actually try against a real model.
- `SEARCH_PROVIDER=mock|brave` — set `BRAVE_SEARCH_API_KEY` to go live.
- `NINJAPEAR_API_KEY` / `USPTO_API_KEY` — optional company-enrichment and patent-search providers, both free-tier-friendly to start.
- `SMTP_HOST`/`RESEND_API_KEY` — no bundled local mail sink; point SMTP at a real relay or set `RESEND_API_KEY` to actually test email delivery (alerts and security email both use this).
- `NOTIFICATION_SMS_ENABLED=false` by default — set to `true` and provide `TWILIO_*` to enable SMS.
`LLM_PROVIDER`/`SEARCH_PROVIDER` are provider *selection* and only live here — after changing them, rebuild/restart `api`, `worker`, and `beat` to pick up the change.
2. **The app's own Settings page (no `.env` edit, no restart)** — once the app is running, sign in and go to Settings:
- **"Your API keys"** — every account can set its own Anthropic/Brave/NinjaPear/USPTO key, used for that account's own companies. Falls back to the `.env` value if you never set one.
- **"Server secrets"** (admin accounts only) — Cloudflare Turnstile site key/secret and the Resend API key, shared server-wide, stored encrypted in the database, and take effect immediately for every visitor. This is the intended way to configure these three on a real deployment rather than editing `.env` directly.
If `LLM_PROVIDER`/`SEARCH_PROVIDER` still show as `mock` in Settings → System configuration after you've set keys, that's the `.env`-level provider selection, not a missing key — it needs the `.env` edit + restart from option 1 above, not the Settings UI.
## Known limitations
+32 -1
View File
@@ -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,
+40 -2
View File
@@ -7,11 +7,12 @@ ready to serve traffic".
from __future__ import annotations
import uuid
from typing import Literal
import httpx
import redis.asyncio as redis_asyncio
from fastapi import APIRouter, Depends, Request
from fastapi import APIRouter, Depends, Request, status
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -23,8 +24,9 @@ from app.core.security import get_client_ip, is_localhost
from app.db.session import get_db
from app.models.enums import SystemSecretKey
from app.models.user import User
from app.schemas.provisioning import PendingProvisioningCreate, PendingProvisioningResponse
from app.schemas.system_secret import SetSystemSecretRequest, SystemSecretStatus
from app.services import system_secret_service
from app.services import provisioning_service, system_secret_service
from app.services.enrichment_service import estimate_max_credits_per_company
logger = get_logger(__name__)
@@ -194,3 +196,39 @@ async def system_logs(
"""Most-recent-first view into the application's live log stream (capped
at the last 500 entries app-wide, see `core/logging.py`)."""
return [LogEntryResponse(**entry) for entry in await get_recent_logs(settings, limit=100)]
@router.get("/system/pending-provisioning", response_model=list[PendingProvisioningResponse])
async def list_pending_provisioning(
db: AsyncSession = Depends(get_db), _admin: User = Depends(require_admin)
) -> list[PendingProvisioningResponse]:
"""Queued not-yet-existing accounts - see app.services.provisioning_service."""
return [
PendingProvisioningResponse.model_validate(p, from_attributes=True)
for p in await provisioning_service.list_pending(db)
]
@router.post(
"/system/pending-provisioning",
response_model=PendingProvisioningResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_pending_provisioning(
payload: PendingProvisioningCreate,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> PendingProvisioningResponse:
record = await provisioning_service.create_pending(
db, payload.email, payload.source_user_id, payload.make_admin
)
return PendingProvisioningResponse.model_validate(record, from_attributes=True)
@router.delete("/system/pending-provisioning/{pending_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_pending_provisioning(
pending_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_admin: User = Depends(require_admin),
) -> None:
await provisioning_service.delete_pending(db, pending_id)
+1
View File
@@ -22,6 +22,7 @@ from app.models.notification_destination import ( # noqa: F401
NotificationDestinationCompany,
)
from app.models.password_history import PasswordHistoryEntry # noqa: F401
from app.models.pending_provisioning import PendingProvisioning # noqa: F401
from app.models.refresh_token import RefreshToken # noqa: F401
from app.models.report import Report # noqa: F401
from app.models.snapshot import Snapshot # noqa: F401
@@ -0,0 +1,31 @@
"""Lets an admin pre-configure what a not-yet-existing account should get
the moment someone verifies their email with a matching address - copies
of another user's per-user API keys and companies (with their full data:
sources, enrichment, reports, snapshots, etc.), an email notification
destination for the new account's own address, and optionally an admin
promotion. Built for demoing the app to people who don't have accounts
yet without hand-entering everything for them after the fact.
Applied at email-verification time, not raw registration, since
registering an email address doesn't prove you own it - see
auth_service.verify_email / provisioning_service.apply_if_pending.
"""
from __future__ import annotations
import uuid
from sqlalchemy import Boolean, ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class PendingProvisioning(Base, UUIDPrimaryKeyMixin, TimestampMixin):
__tablename__ = "pending_provisionings"
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
source_user_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
make_admin: Mapped[bool] = mapped_column(Boolean, default=False)
+59 -10
View File
@@ -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."
)
@@ -0,0 +1,37 @@
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.pending_provisioning import PendingProvisioning
class PendingProvisioningRepository:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_all(self) -> list[PendingProvisioning]:
result = await self.db.execute(select(PendingProvisioning))
return list(result.scalars().all())
async def get_by_email(self, email: str) -> PendingProvisioning | None:
result = await self.db.execute(
select(PendingProvisioning).where(PendingProvisioning.email == email.lower())
)
return result.scalar_one_or_none()
async def create(
self, email: str, source_user_id: uuid.UUID, make_admin: bool
) -> PendingProvisioning:
record = PendingProvisioning(
email=email.lower(), source_user_id=source_user_id, make_admin=make_admin
)
self.db.add(record)
await self.db.flush()
return record
async def delete(self, record: PendingProvisioning) -> None:
await self.db.delete(record)
await self.db.flush()
+7 -1
View File
@@ -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()
+20
View File
@@ -0,0 +1,20 @@
from __future__ import annotations
import uuid
from datetime import datetime
from pydantic import BaseModel, EmailStr
class PendingProvisioningCreate(BaseModel):
email: EmailStr
source_user_id: uuid.UUID
make_admin: bool = False
class PendingProvisioningResponse(BaseModel):
id: uuid.UUID
email: str
source_user_id: uuid.UUID
make_admin: bool
created_at: datetime
+20 -1
View File
@@ -52,7 +52,7 @@ from app.schemas.auth import (
TokenResponse,
VerifyEmailRequest,
)
from app.services import ip_throttle_service, security_email_service
from app.services import ip_throttle_service, provisioning_service, security_email_service
EMAIL_CODE_VALID_HOURS = 36
@@ -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
@@ -188,6 +202,11 @@ async def verify_email(db: AsyncSession, client_ip: str, payload: VerifyEmailReq
await code_repo.mark_used(record)
user.email_verified = True
# Verifying an email is the point at which the app can actually trust
# someone owns this address - if an admin queued this address up for
# pre-provisioning (see provisioning_service), this is where it fires,
# never at raw registration (which proves nothing about ownership).
await provisioning_service.apply_if_pending(db, user)
await ip_throttle_service.reset_on_success(db, client_ip, ThrottleAction.VERIFY_EMAIL_CODE)
await UserSecurityEventRepository(db).create(
user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFIED, ip_address=client_ip
+102
View File
@@ -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
@@ -0,0 +1,295 @@
"""Pre-provisions a not-yet-existing account: the moment someone verifies
an email address an admin has queued up (see PendingProvisioning), they
get a deep copy of another user's per-user API keys and companies -
including every tab's worth of data (sources, source documents, monitoring
runs, reports, snapshots, detected changes, enrichment) - plus an email
notification destination for their own address linked to the copied
companies, and optionally an admin promotion.
Applied at email-verification time (see auth_service.verify_email), not
raw registration - registering an email doesn't prove ownership of it,
verifying does.
"""
from __future__ import annotations
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.errors import ConflictError, NotFoundError
from app.models.company import Company, CompanyAlias, Competitor
from app.models.company_enrichment import CompanyEnrichment
from app.models.detected_change import DetectedChange
from app.models.enums import NotificationType, SeverityLevel
from app.models.monitor_configuration import MonitorConfiguration
from app.models.monitoring_run import MonitoringRun
from app.models.notification_destination import (
NotificationDestination,
NotificationDestinationCompany,
)
from app.models.pending_provisioning import PendingProvisioning
from app.models.report import Report
from app.models.snapshot import Snapshot
from app.models.source import Source
from app.models.source_document import SourceDocument
from app.models.user import User
from app.models.user_api_key import UserApiKey
from app.repositories.pending_provisioning_repository import PendingProvisioningRepository
from app.repositories.user_repository import UserRepository
def _copy_row[ModelT](
model_cls: type[ModelT], source_row: Any, overrides: dict[str, Any]
) -> ModelT:
"""A new ORM instance of the same class, with every column value copied
from `source_row` except whatever `overrides` replaces (always at least
`id`, plus any foreign keys that need remapping to the new owner's
copies of their parents). Generic over every table involved so adding a
new company-related table later doesn't require touching this file."""
data = {
col.name: getattr(source_row, col.name) for col in model_cls.__table__.columns # type: ignore[attr-defined]
}
data.update(overrides)
return model_cls(**data)
async def _clone_company(
db: AsyncSession, source_company_id: uuid.UUID, target_user_id: uuid.UUID
) -> Company:
id_map: dict[uuid.UUID, uuid.UUID] = {}
def new_id(old_id: uuid.UUID) -> uuid.UUID:
if old_id not in id_map:
id_map[old_id] = uuid.uuid4()
return id_map[old_id]
company = await db.get(Company, source_company_id)
assert company is not None
new_company = _copy_row(Company, company, {"id": new_id(company.id), "user_id": target_user_id})
db.add(new_company)
await db.flush()
for alias in (
await db.execute(select(CompanyAlias).where(CompanyAlias.company_id == source_company_id))
).scalars():
db.add(_copy_row(CompanyAlias, alias, {"id": uuid.uuid4(), "company_id": new_company.id}))
for competitor in (
await db.execute(select(Competitor).where(Competitor.company_id == source_company_id))
).scalars():
db.add(
_copy_row(Competitor, competitor, {"id": uuid.uuid4(), "company_id": new_company.id})
)
config = (
await db.execute(
select(MonitorConfiguration).where(MonitorConfiguration.company_id == source_company_id)
)
).scalar_one_or_none()
if config is not None:
db.add(
_copy_row(
MonitorConfiguration, config, {"id": uuid.uuid4(), "company_id": new_company.id}
)
)
enrichment = (
await db.execute(
select(CompanyEnrichment).where(CompanyEnrichment.company_id == source_company_id)
)
).scalar_one_or_none()
if enrichment is not None:
db.add(
_copy_row(
CompanyEnrichment, enrichment, {"id": uuid.uuid4(), "company_id": new_company.id}
)
)
sources = (
(await db.execute(select(Source).where(Source.company_id == source_company_id)))
.scalars()
.all()
)
for source in sources:
db.add(_copy_row(Source, source, {"id": new_id(source.id), "company_id": new_company.id}))
await db.flush()
for doc in (
await db.execute(
select(SourceDocument).where(SourceDocument.company_id == source_company_id)
)
).scalars():
db.add(
_copy_row(
SourceDocument,
doc,
{
"id": uuid.uuid4(),
"company_id": new_company.id,
"source_id": new_id(doc.source_id),
},
)
)
runs = (
(
await db.execute(
select(MonitoringRun).where(MonitoringRun.company_id == source_company_id)
)
)
.scalars()
.all()
)
for run in runs:
db.add(_copy_row(MonitoringRun, run, {"id": new_id(run.id), "company_id": new_company.id}))
await db.flush()
for report in (
await db.execute(select(Report).where(Report.company_id == source_company_id))
).scalars():
db.add(
_copy_row(
Report,
report,
{
"id": uuid.uuid4(),
"company_id": new_company.id,
"monitoring_run_id": (
new_id(report.monitoring_run_id) if report.monitoring_run_id else None
),
},
)
)
snapshots = (
(await db.execute(select(Snapshot).where(Snapshot.company_id == source_company_id)))
.scalars()
.all()
)
for snapshot in snapshots:
db.add(
_copy_row(
Snapshot,
snapshot,
{
"id": new_id(snapshot.id),
"company_id": new_company.id,
"source_id": new_id(snapshot.source_id) if snapshot.source_id else None,
"monitoring_run_id": (
new_id(snapshot.monitoring_run_id) if snapshot.monitoring_run_id else None
),
},
)
)
await db.flush()
for change in (
await db.execute(
select(DetectedChange).where(DetectedChange.company_id == source_company_id)
)
).scalars():
db.add(
_copy_row(
DetectedChange,
change,
{
"id": uuid.uuid4(),
"company_id": new_company.id,
"source_id": new_id(change.source_id) if change.source_id else None,
"monitoring_run_id": (
new_id(change.monitoring_run_id) if change.monitoring_run_id else None
),
"previous_snapshot_id": (
new_id(change.previous_snapshot_id) if change.previous_snapshot_id else None
),
"current_snapshot_id": (
new_id(change.current_snapshot_id) if change.current_snapshot_id else None
),
},
)
)
return new_company
async def _copy_api_keys(
db: AsyncSession, source_user_id: uuid.UUID, target_user_id: uuid.UUID
) -> None:
keys = (
(await db.execute(select(UserApiKey).where(UserApiKey.user_id == source_user_id)))
.scalars()
.all()
)
for key in keys:
db.add(_copy_row(UserApiKey, key, {"id": uuid.uuid4(), "user_id": target_user_id}))
async def create_pending(
db: AsyncSession, email: str, source_user_id: uuid.UUID, make_admin: bool
) -> PendingProvisioning:
if await UserRepository(db).get_by_email(email) is not None:
raise ConflictError("An account with this email already exists")
if await PendingProvisioningRepository(db).get_by_email(email) is not None:
raise ConflictError("This email is already queued for provisioning")
record = await PendingProvisioningRepository(db).create(email, source_user_id, make_admin)
await db.commit()
return record
async def list_pending(db: AsyncSession) -> list[PendingProvisioning]:
return await PendingProvisioningRepository(db).list_all()
async def delete_pending(db: AsyncSession, pending_id: uuid.UUID) -> None:
repo = PendingProvisioningRepository(db)
match = next((p for p in await repo.list_all() if p.id == pending_id), None)
if match is None:
raise NotFoundError("Pending provisioning entry not found")
await repo.delete(match)
await db.commit()
async def apply_if_pending(db: AsyncSession, new_user: User) -> bool:
"""Called right after a fresh account's email gets verified. Returns
True if a pending provisioning entry matched and was applied (and
consumed - a pending entry only ever fires once)."""
pending = await PendingProvisioningRepository(db).get_by_email(new_user.email)
if pending is None:
return False
await _copy_api_keys(db, pending.source_user_id, new_user.id)
source_companies = (
(await db.execute(select(Company).where(Company.user_id == pending.source_user_id)))
.scalars()
.all()
)
new_company_ids = [
(await _clone_company(db, company.id, new_user.id)).id for company in source_companies
]
if new_company_ids:
destination = NotificationDestination(
user_id=new_user.id,
type=NotificationType.EMAIL,
destination_value=new_user.email,
verified=False,
enabled=True,
minimum_severity=SeverityLevel.MEDIUM,
)
db.add(destination)
await db.flush()
for company_id in new_company_ids:
db.add(
NotificationDestinationCompany(destination_id=destination.id, company_id=company_id)
)
if pending.make_admin:
new_user.is_admin = True
await PendingProvisioningRepository(db).delete(pending)
return True
+215 -1
View File
@@ -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)
@@ -0,0 +1,45 @@
"""add pending_provisionings table
Revision ID: 2c2a563a5edb
Revises: 9e6c80c11da7
Create Date: 2026-08-06 22:22:22.348339
"""
from __future__ import annotations
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = '2c2a563a5edb'
down_revision: str | None = '9e6c80c11da7'
branch_labels: Sequence[str] | str | None = None
depends_on: Sequence[str] | str | None = None
def upgrade() -> None:
# Autogenerate also proposed adding a redundant UNIQUE constraint on the
# `id` column of every other existing table (a cosmetic artifact of how
# this project's earlier migrations declared unnamed unique constraints,
# unrelated to this change) - stripped out, keeping only the new table.
op.create_table('pending_provisionings',
sa.Column('email', sa.String(length=320), nullable=False),
sa.Column('source_user_id', sa.Uuid(), nullable=False),
sa.Column('make_admin', sa.Boolean(), nullable=False),
sa.Column('id', sa.Uuid(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['source_user_id'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('id')
)
op.create_index(op.f('ix_pending_provisionings_email'), 'pending_provisionings', ['email'], unique=True)
op.create_index(op.f('ix_pending_provisionings_source_user_id'), 'pending_provisionings', ['source_user_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_pending_provisionings_source_user_id'), table_name='pending_provisionings')
op.drop_index(op.f('ix_pending_provisionings_email'), table_name='pending_provisionings')
op.drop_table('pending_provisionings')
+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
+429
View File
@@ -0,0 +1,429 @@
"""Pending account provisioning: an admin queues up an email in advance,
and the moment that email actually verifies a real account, it gets a
deep copy of another user's API keys and companies (every related table,
not just the company row itself), a notification destination for its own
address, and optionally an admin promotion. See provisioning_service.py."""
from __future__ import annotations
import asyncio
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from sqlalchemy import select
from app.core.errors import ConflictError, NotFoundError
from app.core.security import hash_email_code, hash_password
from app.db.session import get_sessionmaker
from app.models.company import Company, CompanyAlias, Competitor
from app.models.company_enrichment import CompanyEnrichment
from app.models.detected_change import DetectedChange
from app.models.email_code import EmailCode
from app.models.enums import (
ApiKeyProvider,
ChangeStatus,
ChangeType,
CompanyStatus,
EmailCodePurpose,
EnrichmentStatus,
MonitoringFrequency,
MonitoringRunStatus,
MonitoringRunTrigger,
ReportType,
SeverityLevel,
SourceStatus,
SourceType,
)
from app.models.monitor_configuration import MonitorConfiguration
from app.models.monitoring_run import MonitoringRun
from app.models.report import Report
from app.models.snapshot import Snapshot
from app.models.source import Source
from app.models.source_document import SourceDocument
from app.models.user import User
from app.models.user_api_key import UserApiKey
from app.repositories.notification_destination_repository import (
NotificationDestinationRepository,
)
from app.repositories.pending_provisioning_repository import PendingProvisioningRepository
from app.repositories.user_repository import UserRepository
from app.services import provisioning_service
async def _make_user(db_session, *, email: str | None = None, is_admin: bool = False) -> User:
user = User(
id=uuid.uuid4(),
email=(email or f"user-{uuid.uuid4().hex[:10]}@example.com").lower(),
password_hash=hash_password("correct-horse-1"),
display_name="Test User",
timezone="UTC",
is_admin=is_admin,
email_verified=True,
)
db_session.add(user)
await db_session.flush()
return user
async def _build_full_company(db_session, owner: User) -> Company:
"""One company with at least one row in every table _clone_company
touches, so the deep-clone test actually exercises every FK remap."""
company = Company(
user_id=owner.id,
name="Acme Corp",
slug=f"acme-{uuid.uuid4().hex[:6]}",
official_website="https://acme.example.com",
status=CompanyStatus.ACTIVE,
)
db_session.add(company)
await db_session.flush()
db_session.add(CompanyAlias(company_id=company.id, alias="Acme"))
db_session.add(Competitor(company_id=company.id, name="Rival Co"))
db_session.add(
MonitorConfiguration(
company_id=company.id, frequency_type=MonitoringFrequency.WEEKLY, enabled=True
)
)
db_session.add(
CompanyEnrichment(
company_id=company.id,
status=EnrichmentStatus.COMPLETE,
data={"products": [{"name": "Widget"}]},
errors={},
credits_spent=5,
)
)
source = Source(
company_id=company.id,
source_type=SourceType.WEBSITE,
name="Homepage",
status=SourceStatus.ACTIVE,
)
db_session.add(source)
await db_session.flush()
db_session.add(
SourceDocument(
source_id=source.id,
company_id=company.id,
url="https://acme.example.com/page",
canonical_url="https://acme.example.com/page",
title="Homepage",
content_text="Hello world",
content_hash="abc123",
retrieved_date=datetime.now(UTC),
extraction_method="static_html",
)
)
run = MonitoringRun(
company_id=company.id,
trigger_type=MonitoringRunTrigger.MANUAL,
status=MonitoringRunStatus.SUCCESSFUL,
)
db_session.add(run)
await db_session.flush()
db_session.add(
Report(
company_id=company.id,
monitoring_run_id=run.id,
report_type=ReportType.MANUAL,
title="Acme Report",
executive_summary="Summary",
structured_report={"executive_summary": "Summary"},
markdown_content="# Acme Report",
model_provider="mock",
model_name="mock",
)
)
snapshot = Snapshot(
company_id=company.id,
source_id=source.id,
monitoring_run_id=run.id,
snapshot_type="structured",
hash="snaphash",
)
db_session.add(snapshot)
await db_session.flush()
db_session.add(
DetectedChange(
company_id=company.id,
source_id=source.id,
monitoring_run_id=run.id,
previous_snapshot_id=snapshot.id,
current_snapshot_id=snapshot.id,
change_type=ChangeType.CONTENT_MODIFIED,
severity=SeverityLevel.LOW,
status=ChangeStatus.NEW,
summary="Something changed",
confidence_score=0.5,
significance_score=0.5,
)
)
db_session.add(
UserApiKey(
user_id=owner.id, provider=ApiKeyProvider.ANTHROPIC, encrypted_key="encrypted-blob"
)
)
await db_session.commit()
return company
@pytest.mark.asyncio
async def test_create_pending_rejects_an_email_that_already_has_an_account(db_session, settings):
owner = await _make_user(db_session)
existing = await _make_user(db_session, email="[email protected]")
with pytest.raises(ConflictError):
await provisioning_service.create_pending(db_session, existing.email, owner.id, False)
@pytest.mark.asyncio
async def test_create_pending_rejects_a_duplicate_pending_email(db_session, settings):
owner = await _make_user(db_session)
email = f"pending-{uuid.uuid4().hex[:10]}@example.com"
await provisioning_service.create_pending(db_session, email, owner.id, False)
with pytest.raises(ConflictError):
await provisioning_service.create_pending(db_session, email, owner.id, False)
@pytest.mark.asyncio
async def test_apply_if_pending_is_a_no_op_without_a_matching_entry(db_session, settings):
new_user = await _make_user(db_session, email="[email protected]")
applied = await provisioning_service.apply_if_pending(db_session, new_user)
assert applied is False
@pytest.mark.asyncio
async def test_apply_if_pending_deep_clones_everything_and_is_consumed_once(db_session, settings):
owner = await _make_user(db_session)
company = await _build_full_company(db_session, owner)
email = f"invitee-{uuid.uuid4().hex[:10]}@example.com"
await provisioning_service.create_pending(db_session, email, owner.id, make_admin=True)
new_user = await _make_user(db_session, email=email)
applied = await provisioning_service.apply_if_pending(db_session, new_user)
await db_session.commit()
assert applied is True
assert new_user.is_admin is True
# The pending entry only ever fires once.
assert await PendingProvisioningRepository(db_session).get_by_email(email) is None
applied_again = await provisioning_service.apply_if_pending(db_session, new_user)
assert applied_again is False
new_company = (
await db_session.execute(select(Company).where(Company.user_id == new_user.id))
).scalar_one()
assert new_company.id != company.id
assert new_company.name == "Acme Corp"
assert (
await db_session.execute(
select(CompanyAlias).where(CompanyAlias.company_id == new_company.id)
)
).scalar_one().alias == "Acme"
assert (
await db_session.execute(select(Competitor).where(Competitor.company_id == new_company.id))
).scalar_one().name == "Rival Co"
assert (
(
await db_session.execute(
select(MonitorConfiguration).where(
MonitorConfiguration.company_id == new_company.id
)
)
)
.scalar_one()
.enabled
)
new_source = (
await db_session.execute(select(Source).where(Source.company_id == new_company.id))
).scalar_one()
assert (
new_source.id
!= (await db_session.execute(select(Source).where(Source.company_id == company.id)))
.scalar_one()
.id
)
new_doc = (
await db_session.execute(
select(SourceDocument).where(SourceDocument.company_id == new_company.id)
)
).scalar_one()
assert new_doc.source_id == new_source.id # remapped to the CLONED source, not the original
new_run = (
await db_session.execute(
select(MonitoringRun).where(MonitoringRun.company_id == new_company.id)
)
).scalar_one()
new_report = (
await db_session.execute(select(Report).where(Report.company_id == new_company.id))
).scalar_one()
assert new_report.monitoring_run_id == new_run.id
new_snapshot = (
await db_session.execute(select(Snapshot).where(Snapshot.company_id == new_company.id))
).scalar_one()
assert new_snapshot.source_id == new_source.id
assert new_snapshot.monitoring_run_id == new_run.id
new_change = (
await db_session.execute(
select(DetectedChange).where(DetectedChange.company_id == new_company.id)
)
).scalar_one()
assert new_change.previous_snapshot_id == new_snapshot.id
assert new_change.current_snapshot_id == new_snapshot.id
assert new_change.source_id == new_source.id
assert new_change.monitoring_run_id == new_run.id
new_enrichment = (
await db_session.execute(
select(CompanyEnrichment).where(CompanyEnrichment.company_id == new_company.id)
)
).scalar_one()
assert new_enrichment.data == {"products": [{"name": "Widget"}]}
new_key = (
await db_session.execute(select(UserApiKey).where(UserApiKey.user_id == new_user.id))
).scalar_one()
assert new_key.provider == ApiKeyProvider.ANTHROPIC
assert new_key.encrypted_key == "encrypted-blob" # copied as-is, not re-encrypted
# The original owner's data must be completely untouched.
original_source = (
await db_session.execute(select(Source).where(Source.company_id == company.id))
).scalar_one()
assert original_source.id != new_source.id
destination = (await NotificationDestinationRepository(db_session).list_for_user(new_user.id))[
0
]
assert destination.destination_value == email
assert destination.company_links[0].company_id == new_company.id
@pytest.mark.asyncio
async def test_apply_if_pending_without_make_admin_leaves_user_a_regular_account(
db_session, settings
):
owner = await _make_user(db_session)
await _build_full_company(db_session, owner)
email = f"invitee-{uuid.uuid4().hex[:10]}@example.com"
await provisioning_service.create_pending(db_session, email, owner.id, make_admin=False)
new_user = await _make_user(db_session, email=email)
await provisioning_service.apply_if_pending(db_session, new_user)
assert new_user.is_admin is False
@pytest.mark.asyncio
async def test_delete_pending_removes_an_unconsumed_entry(db_session, settings):
owner = await _make_user(db_session)
email = f"pending-{uuid.uuid4().hex[:10]}@example.com"
record = await provisioning_service.create_pending(db_session, email, owner.id, False)
await provisioning_service.delete_pending(db_session, record.id)
assert await PendingProvisioningRepository(db_session).get_by_email(email) is None
@pytest.mark.asyncio
async def test_delete_pending_404s_for_an_unknown_id(db_session, settings):
with pytest.raises(NotFoundError):
await provisioning_service.delete_pending(db_session, uuid.uuid4())
def test_verify_email_applies_a_matching_pending_entry_end_to_end(client, settings):
"""The verify-email endpoint, not registration, is what actually
triggers provisioning - see auth_service.verify_email's docstring."""
owner_email = f"owner-{uuid.uuid4().hex[:10]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": owner_email, "password": "correct-horse-1", "display_name": "Owner"},
)
invitee_email = f"invitee-{uuid.uuid4().hex[:10]}@example.com"
async def _setup() -> None:
async with get_sessionmaker()() as db:
owner = await UserRepository(db).get_by_email(owner_email)
await _build_full_company(db, owner)
await provisioning_service.create_pending(db, invitee_email, owner.id, make_admin=True)
asyncio.run(_setup())
register_resp = client.post(
"/api/v1/auth/register",
json={"email": invitee_email, "password": "correct-horse-1", "display_name": "Invitee"},
)
assert register_resp.status_code == 201
assert register_resp.json()["is_admin"] is False # not yet - only verifying triggers it
code = "424242"
async def _issue_code() -> None:
async with get_sessionmaker()() as db:
db.add(
EmailCode(
user_id=uuid.UUID(register_resp.json()["id"]),
purpose=EmailCodePurpose.VERIFY_EMAIL,
code_hash=hash_email_code(code),
expires_at=datetime.now(UTC) + timedelta(hours=1),
)
)
await db.commit()
asyncio.run(_issue_code())
verify_resp = client.post(
"/api/v1/auth/verify-email", json={"email": invitee_email, "code": code}
)
assert verify_resp.status_code == 204
login = client.post(
"/api/v1/auth/login", json={"email": invitee_email, "password": "correct-horse-1"}
).json()
headers = {"Authorization": f"Bearer {login['access_token']}"}
me = client.get("/api/v1/auth/me", headers=headers).json()
assert me["is_admin"] is True
companies = client.get("/api/v1/companies", headers=headers).json()
assert len(companies) == 1
assert companies[0]["name"] == "Acme Corp"
def test_pending_provisioning_admin_endpoints_require_admin(client):
headers = _register_and_login_plain(client)
resp = client.get("/api/v1/system/pending-provisioning", headers=headers)
assert resp.status_code == 403
def _register_and_login_plain(client) -> dict[str, str]:
email = f"plain-{uuid.uuid4().hex[:10]}@example.com"
client.post(
"/api/v1/auth/register",
json={"email": email, "password": "correct-horse-1", "display_name": "Plain"},
)
tokens = client.post(
"/api/v1/auth/login", json={"email": email, "password": "correct-horse-1"}
).json()
return {"Authorization": f"Bearer {tokens['access_token']}"}
+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"]
+101 -30
View File
@@ -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">
{(
+12
View File
@@ -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({
+7
View File
@@ -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",
+8
View File
@@ -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>;
+6
View File
@@ -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;
}
}
Regular → Executable
View File
Regular → Executable
View File