Compare commits
4
Commits
8ec7ca0881
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
56b4a5404e | ||
|
|
18305b545c | ||
|
|
1be3e53584 | ||
|
|
d706f226de |
@@ -111,6 +111,8 @@ A couple of things worth knowing:
|
|||||||
|
|
||||||
## 8. Post-boot: configure provider API keys via the Settings UI, not `.env`
|
## 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:
|
`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.
|
- **Settings → Your API keys**: Anthropic, Brave Search, NinjaPear, USPTO — stored encrypted per-user (`user_api_key_service.py`), fall back to any global `.env` value if you ever set one, but there's no need to.
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ This file is updated as each phase lands. It exists so nothing is silently claim
|
|||||||
|
|
||||||
- Access/refresh tokens are stored in `localStorage` on the frontend for simplicity (see `apps/web/lib/api-client.ts`). This is a reasonable tradeoff for a local-first MVP but is XSS-exposed compared to httpOnly cookies; a production multi-user deployment should move to cookie-based storage with CSRF protection before going live publicly.
|
- 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.
|
- 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)
|
## 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.
|
- **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`.
|
- **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).
|
- **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)
|
## 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.
|
- **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.
|
- **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.
|
- **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.
|
- **`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)
|
## 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 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.
|
- **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.
|
- **`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
|
## Account activity logging expansion: local-dev sign-ins, secret/key updates, known IPs
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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)
|
## Quick start (Docker)
|
||||||
|
|
||||||
@@ -57,7 +59,7 @@ cd apps/api && alembic upgrade head
|
|||||||
cd apps/api && alembic revision --autogenerate -m "description"
|
cd apps/api && alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
# Run a Celery worker + beat (only needed outside Docker)
|
# 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
|
cd apps/api && celery -A app.tasks.celery_app beat --loglevel=INFO
|
||||||
|
|
||||||
# Backend tests / lint / format
|
# Backend tests / lint / format
|
||||||
@@ -78,15 +80,29 @@ cd apps/web && npm run e2e
|
|||||||
|
|
||||||
## Configuration
|
## 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.
|
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.
|
||||||
- `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. 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
|
## Known limitations
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request, status
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.errors import NotFoundError
|
||||||
from app.core.rate_limit import limiter
|
from app.core.rate_limit import limiter
|
||||||
from app.db.session import get_db
|
from app.db.session import get_db
|
||||||
|
from app.enrichment.factory import get_enrichment_provider
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.company import (
|
from app.schemas.company import (
|
||||||
CompanyCreate,
|
CompanyCreate,
|
||||||
@@ -23,7 +25,14 @@ from app.schemas.company import (
|
|||||||
)
|
)
|
||||||
from app.schemas.discovery import DiscoverCompanyRequest, DiscoveredCompanyProfile
|
from app.schemas.discovery import DiscoverCompanyRequest, DiscoveredCompanyProfile
|
||||||
from app.search.factory import get_search_provider
|
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"])
|
router = APIRouter(prefix="/companies", tags=["companies"])
|
||||||
|
|
||||||
@@ -129,6 +138,28 @@ async def resume_company(
|
|||||||
return CompanyResponse.from_company(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)
|
@router.get("/{company_id}/monitor", response_model=MonitorConfigurationResponse)
|
||||||
async def get_monitor_configuration(
|
async def get_monitor_configuration(
|
||||||
company_id: uuid.UUID,
|
company_id: uuid.UUID,
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ ready to serve traffic".
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import redis.asyncio as redis_asyncio
|
import redis.asyncio as redis_asyncio
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy import text
|
from sqlalchemy import text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.db.session import get_db
|
||||||
from app.models.enums import SystemSecretKey
|
from app.models.enums import SystemSecretKey
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.schemas.provisioning import PendingProvisioningCreate, PendingProvisioningResponse
|
||||||
from app.schemas.system_secret import SetSystemSecretRequest, SystemSecretStatus
|
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
|
from app.services.enrichment_service import estimate_max_credits_per_company
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
@@ -194,3 +196,39 @@ async def system_logs(
|
|||||||
"""Most-recent-first view into the application's live log stream (capped
|
"""Most-recent-first view into the application's live log stream (capped
|
||||||
at the last 500 entries app-wide, see `core/logging.py`)."""
|
at the last 500 entries app-wide, see `core/logging.py`)."""
|
||||||
return [LogEntryResponse(**entry) for entry in await get_recent_logs(settings, limit=100)]
|
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)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ from app.models.notification_destination import ( # noqa: F401
|
|||||||
NotificationDestinationCompany,
|
NotificationDestinationCompany,
|
||||||
)
|
)
|
||||||
from app.models.password_history import PasswordHistoryEntry # noqa: F401
|
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.refresh_token import RefreshToken # noqa: F401
|
||||||
from app.models.report import Report # noqa: F401
|
from app.models.report import Report # noqa: F401
|
||||||
from app.models.snapshot import Snapshot # 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)
|
||||||
@@ -21,17 +21,66 @@ SYSTEM_PROMPT = (
|
|||||||
"third-party data provider at onboarding, also real evidence, not your own knowledge), "
|
"third-party data provider at onboarding, also real evidence, not your own knowledge), "
|
||||||
"stored source documents, and previously detected changes. Never introduce "
|
"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 "
|
"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 "
|
"evidence block, it doesn't go in the report.\n\n"
|
||||||
"fields ARE real evidence and should ground company_overview/market_positioning/"
|
"company_profile and company_enrichment are real evidence and must ground every "
|
||||||
"financial_signals/leadership_changes/products_and_services/competitor_comparison/"
|
"applicable section even when source_documents and detected_changes are sparse or "
|
||||||
"customer_sentiment/etc even when source_documents and detected_changes are sparse or "
|
|
||||||
"empty - do not say 'insufficient evidence' for a field company_profile or "
|
"empty - do not say 'insufficient evidence' for a field company_profile or "
|
||||||
"company_enrichment already answers. Every finding must be "
|
"company_enrichment already answers. In particular, the list-of-Finding sections are "
|
||||||
"traceable to the evidence given and must carry an honest confidence label: confirmed "
|
"NOT limited to newly detected changes - they must also represent the company's "
|
||||||
"(the source states it directly), strongly_indicated, likely, possible, unconfirmed, or "
|
"current, confirmed state whenever company_enrichment/company_profile answers them "
|
||||||
"insufficient_evidence. When evidence is thin or missing for a section, say so explicitly "
|
"directly, the same way you would use that evidence for a prose field:\n"
|
||||||
"in that section rather than inventing content. Distinguish clearly between what a source "
|
"- products_and_services: if company_enrichment.products is present, emit ONE Finding "
|
||||||
"states and what you are inferring."
|
"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()
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
@@ -15,6 +15,12 @@ class UserRepository:
|
|||||||
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
async def get_by_id(self, user_id: uuid.UUID) -> User | None:
|
||||||
return await self.db.get(User, user_id)
|
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:
|
async def get_by_email(self, email: str) -> User | None:
|
||||||
result = await self.db.execute(select(User).where(User.email == email.lower()))
|
result = await self.db.execute(select(User).where(User.email == email.lower()))
|
||||||
return result.scalar_one_or_none()
|
return result.scalar_one_or_none()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -52,7 +52,7 @@ from app.schemas.auth import (
|
|||||||
TokenResponse,
|
TokenResponse,
|
||||||
VerifyEmailRequest,
|
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
|
EMAIL_CODE_VALID_HOURS = 36
|
||||||
|
|
||||||
@@ -125,11 +125,25 @@ async def register(
|
|||||||
if existing is not None:
|
if existing is not None:
|
||||||
raise ConflictError("An account with this email already exists")
|
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(
|
user = await repo.create(
|
||||||
email=payload.email,
|
email=payload.email,
|
||||||
password_hash=hash_password(payload.password),
|
password_hash=hash_password(payload.password),
|
||||||
display_name=payload.display_name,
|
display_name=payload.display_name,
|
||||||
timezone=payload.timezone,
|
timezone=payload.timezone,
|
||||||
|
is_admin=is_first_admin,
|
||||||
# Test suite has no inbox to read a real code from - same
|
# Test suite has no inbox to read a real code from - same
|
||||||
# app_env == "test" precedent already used to disable rate limiting
|
# app_env == "test" precedent already used to disable rate limiting
|
||||||
# (app/core/rate_limit.py). The code-generation/sending/throttle
|
# (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)
|
await code_repo.mark_used(record)
|
||||||
user.email_verified = True
|
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 ip_throttle_service.reset_on_success(db, client_ip, ThrottleAction.VERIFY_EMAIL_CODE)
|
||||||
await UserSecurityEventRepository(db).create(
|
await UserSecurityEventRepository(db).create(
|
||||||
user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFIED, ip_address=client_ip
|
user_id=user.id, event_type=SecurityEventType.EMAIL_VERIFIED, ip_address=client_ip
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from datetime import UTC, datetime
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
|
from app.core.errors import NotFoundError
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.enrichment.base import EnrichmentProvider
|
from app.enrichment.base import EnrichmentProvider
|
||||||
from app.models.company import Company
|
from app.models.company import Company
|
||||||
@@ -174,3 +175,104 @@ async def enrich_company(
|
|||||||
failed_sections=list(errors.keys()),
|
failed_sections=list(errors.keys()),
|
||||||
)
|
)
|
||||||
return enrichment
|
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
|
||||||
@@ -23,7 +23,8 @@ from app.models.enums import EnrichmentStatus, ReportType, SourceStatus
|
|||||||
from app.models.report import Report
|
from app.models.report import Report
|
||||||
from app.models.source import Source
|
from app.models.source import Source
|
||||||
from app.models.source_document import SourceDocument
|
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.company_enrichment_repository import CompanyEnrichmentRepository
|
||||||
from app.repositories.report_repository import ReportRepository
|
from app.repositories.report_repository import ReportRepository
|
||||||
from app.services import company_service
|
from app.services import company_service
|
||||||
@@ -31,6 +32,210 @@ from app.services.report_markdown import render_report_markdown
|
|||||||
|
|
||||||
_MAX_DOCUMENTS = 40
|
_MAX_DOCUMENTS = 40
|
||||||
_MAX_CHANGES = 20
|
_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]:
|
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,
|
public_identifiers=company.public_identifiers,
|
||||||
enrichment=enrichment,
|
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)
|
generated_at = datetime.now(UTC)
|
||||||
model_name = _model_name(settings, llm)
|
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')
|
||||||
@@ -48,6 +48,31 @@ def _setup_database():
|
|||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
await conn.run_sync(Base.metadata.create_all)
|
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())
|
asyncio.run(_create())
|
||||||
yield
|
yield
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,40 @@ def test_register_rejects_weak_password(client):
|
|||||||
assert resp.status_code == 422
|
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):
|
def test_login_wrong_password_rejected(client):
|
||||||
email = _unique_email()
|
email = _unique_email()
|
||||||
client.post(
|
client.post(
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
def _register_and_login(client) -> dict[str, str]:
|
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
|
assert resp.status_code == 201
|
||||||
mock_delay.assert_not_called()
|
mock_delay.assert_not_called()
|
||||||
assert resp.json()["enrichment"] is None
|
assert resp.json()["enrichment"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_enrichment_section_requires_authentication(client):
|
||||||
|
resp = client.post(f"/api/v1/companies/{uuid.uuid4()}/enrichment/sections/products/refresh")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_enrichment_section_rejects_an_unknown_section(client):
|
||||||
|
headers = _register_and_login(client)
|
||||||
|
company = _create_company(client, headers).json()
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/companies/{company['id']}/enrichment/sections/not_a_section/refresh",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_enrichment_section_404s_without_an_enrichment_record(client):
|
||||||
|
headers = _register_and_login(client)
|
||||||
|
company = _create_company(client, headers).json() # no NINJAPEAR_API_KEY -> no record
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/companies/{company['id']}/enrichment/sections/products/refresh",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_enrichment_section_is_scoped_to_owner(client):
|
||||||
|
owner_headers = _register_and_login(client)
|
||||||
|
other_headers = _register_and_login(client)
|
||||||
|
company = _create_company(client, owner_headers).json()
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/companies/{company['id']}/enrichment/sections/products/refresh",
|
||||||
|
headers=other_headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_enrichment_section_updates_just_that_section(client, db_session, settings):
|
||||||
|
from app.enrichment.base import Product
|
||||||
|
from app.enrichment.mock import MockEnrichmentProvider
|
||||||
|
from app.repositories.company_repository import CompanyRepository
|
||||||
|
from app.services.enrichment_service import enrich_company
|
||||||
|
|
||||||
|
headers = _register_and_login(client)
|
||||||
|
company_id = _create_company(client, headers).json()["id"]
|
||||||
|
|
||||||
|
company = await CompanyRepository(db_session).get_by_id(uuid.UUID(company_id))
|
||||||
|
await enrich_company(db_session, settings, MockEnrichmentProvider(), company)
|
||||||
|
|
||||||
|
class _ProductsProvider(MockEnrichmentProvider):
|
||||||
|
async def get_products(self, name, website):
|
||||||
|
return [Product(name="Acme Pay", description="Payments product")]
|
||||||
|
|
||||||
|
with patch("app.api.v1.companies.get_enrichment_provider", return_value=_ProductsProvider()):
|
||||||
|
resp = client.post(
|
||||||
|
f"/api/v1/companies/{company_id}/enrichment/sections/products/refresh",
|
||||||
|
headers=headers,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp.status_code == 200
|
||||||
|
products = resp.json()["enrichment"]["data"]["products"]
|
||||||
|
assert products == [{"name": "Acme Pay", "description": "Payments product", "category": None}]
|
||||||
|
|||||||
@@ -7,24 +7,31 @@ import uuid
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from app.core.errors import NotFoundError
|
||||||
from app.enrichment.base import (
|
from app.enrichment.base import (
|
||||||
CompanyDetails,
|
CompanyDetails,
|
||||||
CompanyFunding,
|
CompanyFunding,
|
||||||
LeadershipMember,
|
LeadershipMember,
|
||||||
|
Product,
|
||||||
)
|
)
|
||||||
from app.models.company import Company
|
from app.models.company import Company
|
||||||
from app.models.enums import EnrichmentStatus
|
from app.models.enums import EnrichmentStatus
|
||||||
from app.repositories.company_enrichment_repository import CompanyEnrichmentRepository
|
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:
|
class _FakeProvider:
|
||||||
provider_name = "fake"
|
provider_name = "fake"
|
||||||
|
|
||||||
def __init__(
|
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._leadership = leadership or []
|
||||||
|
self._products = products if products is not None else []
|
||||||
self._fail = fail or set()
|
self._fail = fail or set()
|
||||||
|
|
||||||
async def get_company_details(self, name, website):
|
async def get_company_details(self, name, website):
|
||||||
@@ -50,7 +57,7 @@ class _FakeProvider:
|
|||||||
async def get_products(self, name, website):
|
async def get_products(self, name, website):
|
||||||
if "products" in self._fail:
|
if "products" in self._fail:
|
||||||
raise RuntimeError("products boom")
|
raise RuntimeError("products boom")
|
||||||
return []
|
return self._products
|
||||||
|
|
||||||
async def get_customers(self, name, website):
|
async def get_customers(self, name, website):
|
||||||
if "customers" in self._fail:
|
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)
|
stored = await CompanyEnrichmentRepository(db_session).get_for_company(company.id)
|
||||||
assert stored.id == first.id
|
assert stored.id == first.id
|
||||||
assert stored.status == EnrichmentStatus.PARTIAL
|
assert stored.status == EnrichmentStatus.PARTIAL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_section_replaces_only_that_section(db_session, settings):
|
||||||
|
company = await _make_company(db_session)
|
||||||
|
await enrich_company(db_session, settings, _FakeProvider(), company) # products come back []
|
||||||
|
|
||||||
|
provider = _FakeProvider(products=[Product(name="Acme Pay", description="Payments product")])
|
||||||
|
enrichment = await refresh_section(db_session, settings, provider, company, "products")
|
||||||
|
|
||||||
|
assert [p["name"] for p in enrichment.data["products"]] == ["Acme Pay"]
|
||||||
|
assert enrichment.data["description"] == "A company." # untouched by the refresh
|
||||||
|
assert enrichment.status == EnrichmentStatus.COMPLETE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_section_records_a_fresh_failure_without_losing_other_data(
|
||||||
|
db_session, settings
|
||||||
|
):
|
||||||
|
company = await _make_company(db_session)
|
||||||
|
await enrich_company(db_session, settings, _FakeProvider(), company)
|
||||||
|
|
||||||
|
failing_provider = _FakeProvider(fail={"products"})
|
||||||
|
enrichment = await refresh_section(db_session, settings, failing_provider, company, "products")
|
||||||
|
|
||||||
|
assert enrichment.errors["products"] == "products boom"
|
||||||
|
assert enrichment.data["description"] == "A company."
|
||||||
|
assert enrichment.status == EnrichmentStatus.PARTIAL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_section_clears_a_previously_recorded_error(db_session, settings):
|
||||||
|
company = await _make_company(db_session)
|
||||||
|
await enrich_company(db_session, settings, _FakeProvider(fail={"products"}), company)
|
||||||
|
|
||||||
|
provider = _FakeProvider(products=[Product(name="Acme Pay")])
|
||||||
|
enrichment = await refresh_section(db_session, settings, provider, company, "products")
|
||||||
|
|
||||||
|
assert "products" not in enrichment.errors
|
||||||
|
assert enrichment.status == EnrichmentStatus.COMPLETE
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_section_preserves_per_leader_lookups_on_a_details_refresh(
|
||||||
|
db_session, settings
|
||||||
|
):
|
||||||
|
company = await _make_company(db_session)
|
||||||
|
leadership = [LeadershipMember(name="Jane Doe")]
|
||||||
|
await enrich_company(db_session, settings, _FakeProvider(leadership=leadership), company)
|
||||||
|
|
||||||
|
stored = await CompanyEnrichmentRepository(db_session).get_for_company(company.id)
|
||||||
|
assert stored.data["leadership_team"][0]["work_email"] == "[email protected]"
|
||||||
|
|
||||||
|
# A details refresh re-fetches the roster but not the per-leader lookups -
|
||||||
|
# the previously-found work_email must survive, not silently disappear.
|
||||||
|
provider = _FakeProvider(leadership=[LeadershipMember(name="Jane Doe", title="CEO")])
|
||||||
|
enrichment = await refresh_section(db_session, settings, provider, company, "details")
|
||||||
|
|
||||||
|
member = enrichment.data["leadership_team"][0]
|
||||||
|
assert member["title"] == "CEO"
|
||||||
|
assert member["work_email"] == "[email protected]"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_section_rejects_an_unknown_section(db_session, settings):
|
||||||
|
company = await _make_company(db_session)
|
||||||
|
await enrich_company(db_session, settings, _FakeProvider(), company)
|
||||||
|
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
await refresh_section(db_session, settings, _FakeProvider(), company, "not_a_section")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_section_requires_an_existing_enrichment_record(db_session, settings):
|
||||||
|
company = await _make_company(db_session) # enrichment was never run
|
||||||
|
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
await refresh_section(db_session, settings, _FakeProvider(), company, "products")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_refresh_section_adds_its_own_credit_cost(db_session, settings):
|
||||||
|
company = await _make_company(db_session)
|
||||||
|
baseline = await enrich_company(db_session, settings, _FakeProvider(), company)
|
||||||
|
baseline_credits = baseline.credits_spent # `upsert` mutates rows in place - snapshot first
|
||||||
|
|
||||||
|
enrichment = await refresh_section(db_session, settings, _FakeProvider(), company, "products")
|
||||||
|
|
||||||
|
assert enrichment.credits_spent == baseline_credits + 3 # products' own credit cost
|
||||||
|
|||||||
@@ -0,0 +1,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']}"}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
"""_backfill_from_enrichment / _mirror_swot_into_flat_lists: company_enrichment
|
||||||
|
data (products/recent_updates/funding) and the model's own SWOT output are
|
||||||
|
direct, unambiguous sources for products_and_services/recent_developments/
|
||||||
|
financial_signals/risks/opportunities - no additional LLM judgment required.
|
||||||
|
Confirmed live that a real Anthropic call can still leave these fields empty
|
||||||
|
despite explicit prompt instructions to populate them, so this backfill
|
||||||
|
guarantees the data surfaces regardless of what the model chose to do."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.prompts.report_generation import ReportContent, SwotAnalysis
|
||||||
|
from app.prompts.schemas import Finding
|
||||||
|
from app.services.report_service import (
|
||||||
|
_backfill_from_enrichment,
|
||||||
|
_backfill_reflective_sections,
|
||||||
|
_mirror_swot_into_flat_lists,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_report() -> ReportContent:
|
||||||
|
return ReportContent(
|
||||||
|
executive_summary="",
|
||||||
|
company_overview="",
|
||||||
|
market_positioning="",
|
||||||
|
customer_sentiment="",
|
||||||
|
competitor_comparison="",
|
||||||
|
swot=SwotAnalysis(),
|
||||||
|
methodology="",
|
||||||
|
limitations="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfills_products_from_enrichment_when_model_left_it_empty():
|
||||||
|
report = _empty_report()
|
||||||
|
enrichment = {
|
||||||
|
"products": [
|
||||||
|
{"name": "Acme Pay", "category": "Payments", "description": "Accept cards online."},
|
||||||
|
{"name": "Acme Billing", "category": "Billing", "description": "Recurring invoices."},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
_backfill_from_enrichment(report, enrichment)
|
||||||
|
|
||||||
|
assert [f.headline for f in report.products_and_services] == ["Acme Pay", "Acme Billing"]
|
||||||
|
assert report.products_and_services[0].summary == "Accept cards online."
|
||||||
|
assert report.products_and_services[0].confidence == "confirmed"
|
||||||
|
assert report.products_and_services[0].evidence == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfills_recent_developments_from_enrichment_updates():
|
||||||
|
report = _empty_report()
|
||||||
|
enrichment = {
|
||||||
|
"recent_updates": [
|
||||||
|
{
|
||||||
|
"url": "https://acme.example/blog/launch",
|
||||||
|
"date": "2026-01-01",
|
||||||
|
"text": "Acme launches new dashboard",
|
||||||
|
"type": "blog",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
_backfill_from_enrichment(report, enrichment)
|
||||||
|
|
||||||
|
assert len(report.recent_developments) == 1
|
||||||
|
finding = report.recent_developments[0]
|
||||||
|
assert finding.headline == "Acme launches new dashboard"
|
||||||
|
assert finding.date == "2026-01-01"
|
||||||
|
assert finding.evidence[0].url == "https://acme.example/blog/launch"
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_overwrite_findings_the_model_already_produced():
|
||||||
|
report = _empty_report()
|
||||||
|
report.products_and_services = [
|
||||||
|
Finding(headline="Model-provided product", summary="From the model itself")
|
||||||
|
]
|
||||||
|
enrichment = {"products": [{"name": "Should not appear", "description": "..."}]}
|
||||||
|
|
||||||
|
_backfill_from_enrichment(report, enrichment)
|
||||||
|
|
||||||
|
assert len(report.products_and_services) == 1
|
||||||
|
assert report.products_and_services[0].headline == "Model-provided product"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_enrichment_data_leaves_lists_empty():
|
||||||
|
report = _empty_report()
|
||||||
|
|
||||||
|
_backfill_from_enrichment(report, None)
|
||||||
|
|
||||||
|
assert report.products_and_services == []
|
||||||
|
assert report.recent_developments == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_skips_entries_missing_the_required_key():
|
||||||
|
report = _empty_report()
|
||||||
|
enrichment = {
|
||||||
|
"products": [{"category": "No name here"}],
|
||||||
|
"recent_updates": [{"url": "https://acme.example", "date": "2026-01-01"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
_backfill_from_enrichment(report, enrichment)
|
||||||
|
|
||||||
|
assert report.products_and_services == []
|
||||||
|
assert report.recent_developments == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfills_financial_signals_from_funding_rounds_and_total():
|
||||||
|
report = _empty_report()
|
||||||
|
enrichment = {
|
||||||
|
"funding": {
|
||||||
|
"total_raised": "9810000000",
|
||||||
|
"rounds": [
|
||||||
|
{
|
||||||
|
"date": "2026-02-01",
|
||||||
|
"amount": None,
|
||||||
|
"investors": ["Thrive Capital", "Coatue Management"],
|
||||||
|
"round_name": "SECONDARY_SALE",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2023-03-01",
|
||||||
|
"amount": "6870000000",
|
||||||
|
"investors": ["Thrive Capital", "Andreessen Horowitz"],
|
||||||
|
"round_name": "SERIES_I",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_backfill_from_enrichment(report, enrichment)
|
||||||
|
|
||||||
|
assert len(report.financial_signals) == 3
|
||||||
|
assert report.financial_signals[0].headline == "Total funding raised: $9,810,000,000"
|
||||||
|
assert report.financial_signals[1].headline == "Secondary Sale"
|
||||||
|
assert "Thrive Capital" in report.financial_signals[1].summary
|
||||||
|
assert report.financial_signals[2].headline == "Series I - $6,870,000,000"
|
||||||
|
assert report.financial_signals[2].date == "2023-03-01"
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_funding_data_leaves_financial_signals_empty():
|
||||||
|
report = _empty_report()
|
||||||
|
|
||||||
|
_backfill_from_enrichment(report, {"products": []})
|
||||||
|
|
||||||
|
assert report.financial_signals == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_overwrite_financial_signals_the_model_already_produced():
|
||||||
|
report = _empty_report()
|
||||||
|
report.financial_signals = [Finding(headline="Model-provided signal", summary="From the model")]
|
||||||
|
enrichment = {"funding": {"total_raised": "1000", "rounds": []}}
|
||||||
|
|
||||||
|
_backfill_from_enrichment(report, enrichment)
|
||||||
|
|
||||||
|
assert len(report.financial_signals) == 1
|
||||||
|
assert report.financial_signals[0].headline == "Model-provided signal"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mirrors_swot_threats_into_risks_when_risks_empty():
|
||||||
|
report = _empty_report()
|
||||||
|
report.swot = SwotAnalysis(threats=["Regulatory scrutiny", "New entrants"])
|
||||||
|
|
||||||
|
_mirror_swot_into_flat_lists(report)
|
||||||
|
|
||||||
|
assert report.risks == ["Regulatory scrutiny", "New entrants"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_mirrors_swot_opportunities_into_opportunities_when_empty():
|
||||||
|
report = _empty_report()
|
||||||
|
report.swot = SwotAnalysis(opportunities=["Expand into new markets"])
|
||||||
|
|
||||||
|
_mirror_swot_into_flat_lists(report)
|
||||||
|
|
||||||
|
assert report.opportunities == ["Expand into new markets"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_does_not_overwrite_risks_or_opportunities_the_model_already_produced():
|
||||||
|
report = _empty_report()
|
||||||
|
report.risks = ["Model-provided risk"]
|
||||||
|
report.opportunities = ["Model-provided opportunity"]
|
||||||
|
report.swot = SwotAnalysis(threats=["Should not appear"], opportunities=["Should not appear"])
|
||||||
|
|
||||||
|
_mirror_swot_into_flat_lists(report)
|
||||||
|
|
||||||
|
assert report.risks == ["Model-provided risk"]
|
||||||
|
assert report.opportunities == ["Model-provided opportunity"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_swot_leaves_risks_and_opportunities_empty():
|
||||||
|
report = _empty_report()
|
||||||
|
|
||||||
|
_mirror_swot_into_flat_lists(report)
|
||||||
|
|
||||||
|
assert report.risks == []
|
||||||
|
assert report.opportunities == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_backfills_strategic_initiatives_from_specialties():
|
||||||
|
report = _empty_report()
|
||||||
|
enrichment = {"specialties": ["Payment Processing", "Billing Models"]}
|
||||||
|
|
||||||
|
_backfill_reflective_sections(
|
||||||
|
report, enrichment=enrichment, documents=[], changes=[], company_name="Acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [f.headline for f in report.strategic_initiatives] == [
|
||||||
|
"Focus area: Payment Processing",
|
||||||
|
"Focus area: Billing Models",
|
||||||
|
]
|
||||||
|
assert report.strategic_initiatives[0].confidence == "possible"
|
||||||
|
|
||||||
|
|
||||||
|
def test_regulatory_signals_get_an_insufficient_evidence_placeholder_when_empty():
|
||||||
|
report = _empty_report()
|
||||||
|
|
||||||
|
_backfill_reflective_sections(
|
||||||
|
report, enrichment=None, documents=[], changes=[], company_name="Acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(report.regulatory_and_legal_signals) == 1
|
||||||
|
assert report.regulatory_and_legal_signals[0].confidence == "insufficient_evidence"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknowns_reflect_actual_gaps_in_the_evidence_set():
|
||||||
|
report = _empty_report()
|
||||||
|
enrichment = {"funding": {}, "leadership_team": [], "customers": []}
|
||||||
|
|
||||||
|
_backfill_reflective_sections(
|
||||||
|
report, enrichment=enrichment, documents=[], changes=[], company_name="Acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert any("financial" in u.lower() for u in report.unknowns_and_missing_data)
|
||||||
|
assert any("leadership" in u.lower() for u in report.unknowns_and_missing_data)
|
||||||
|
assert any("customers" in u.lower() for u in report.unknowns_and_missing_data)
|
||||||
|
assert any("source documents" in u.lower() for u in report.unknowns_and_missing_data)
|
||||||
|
assert any("changes" in u.lower() for u in report.unknowns_and_missing_data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknowns_omit_gaps_that_are_actually_covered():
|
||||||
|
report = _empty_report()
|
||||||
|
enrichment = {
|
||||||
|
"funding": {"total_raised": "1000"},
|
||||||
|
"leadership_team": [{"name": "Jane"}],
|
||||||
|
"customers": [{"name": "Acme Corp"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
_backfill_reflective_sections(
|
||||||
|
report,
|
||||||
|
enrichment=enrichment,
|
||||||
|
documents=[{"id": "d1"}],
|
||||||
|
changes=[{"id": "c1"}],
|
||||||
|
company_name="Acme",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report.unknowns_and_missing_data == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_monitoring_recommendations_populated_when_model_left_empty():
|
||||||
|
report = _empty_report()
|
||||||
|
|
||||||
|
_backfill_reflective_sections(
|
||||||
|
report, enrichment=None, documents=[], changes=[], company_name="Acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(report.monitoring_recommendations) >= 2
|
||||||
|
assert any("Acme" in r for r in report.monitoring_recommendations)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reflective_sections_do_not_overwrite_model_output():
|
||||||
|
report = _empty_report()
|
||||||
|
report.strategic_initiatives = [Finding(headline="Model theme", summary="From the model")]
|
||||||
|
report.regulatory_and_legal_signals = [Finding(headline="Model signal", summary="From model")]
|
||||||
|
report.unknowns_and_missing_data = ["Model-noted gap"]
|
||||||
|
report.monitoring_recommendations = ["Model recommendation"]
|
||||||
|
enrichment = {"specialties": ["Should not appear"]}
|
||||||
|
|
||||||
|
_backfill_reflective_sections(
|
||||||
|
report, enrichment=enrichment, documents=[], changes=[], company_name="Acme"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert report.strategic_initiatives[0].headline == "Model theme"
|
||||||
|
assert report.regulatory_and_legal_signals[0].headline == "Model signal"
|
||||||
|
assert report.unknowns_and_missing_data == ["Model-noted gap"]
|
||||||
|
assert report.monitoring_recommendations == ["Model recommendation"]
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ChevronRight,
|
ChevronRight,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { CompanyPillLink } from "@/components/ui/company-pill";
|
import { CompanyPillLink } from "@/components/ui/company-pill";
|
||||||
import { CopyableEmail } from "@/components/ui/copyable-email";
|
import { CopyableEmail } from "@/components/ui/copyable-email";
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
useCompanies,
|
useCompanies,
|
||||||
useCompany,
|
useCompany,
|
||||||
useDeleteCompany,
|
useDeleteCompany,
|
||||||
|
useRefreshEnrichmentSection,
|
||||||
useSetCompanyPaused,
|
useSetCompanyPaused,
|
||||||
useUpdateMonitorConfiguration,
|
useUpdateMonitorConfiguration,
|
||||||
} from "@/hooks/use-companies";
|
} from "@/hooks/use-companies";
|
||||||
@@ -37,7 +39,7 @@ import {
|
|||||||
import { useCompanyRuns, useRunCompanyNow } from "@/hooks/use-monitoring-runs";
|
import { useCompanyRuns, useRunCompanyNow } from "@/hooks/use-monitoring-runs";
|
||||||
import { useCompanyReports, useGenerateReport } from "@/hooks/use-reports";
|
import { useCompanyReports, useGenerateReport } from "@/hooks/use-reports";
|
||||||
import { useSnapshots } from "@/hooks/use-snapshots";
|
import { useSnapshots } from "@/hooks/use-snapshots";
|
||||||
import type { MonitoringFrequency } from "@/lib/types";
|
import type { EnrichmentSection, MonitoringFrequency } from "@/lib/types";
|
||||||
import {
|
import {
|
||||||
FREQUENCY_LABELS,
|
FREQUENCY_LABELS,
|
||||||
MONITORING_FREQUENCIES,
|
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 }> }) {
|
export default function CompanyDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = use(params);
|
const { id } = use(params);
|
||||||
return (
|
return (
|
||||||
@@ -176,6 +202,7 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
|||||||
const deleteSource = useDeleteSource(id);
|
const deleteSource = useDeleteSource(id);
|
||||||
const testSource = useTestSource(id);
|
const testSource = useTestSource(id);
|
||||||
const updateSource = useUpdateSource(id);
|
const updateSource = useUpdateSource(id);
|
||||||
|
const refreshEnrichmentSection = useRefreshEnrichmentSection(id);
|
||||||
const { data: runs, isLoading: runsLoading } = useCompanyRuns(id);
|
const { data: runs, isLoading: runsLoading } = useCompanyRuns(id);
|
||||||
const runNow = useRunCompanyNow(id);
|
const runNow = useRunCompanyNow(id);
|
||||||
const { data: reports, isLoading: reportsLoading } = useCompanyReports(id);
|
const { data: reports, isLoading: reportsLoading } = useCompanyReports(id);
|
||||||
@@ -532,7 +559,16 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
|||||||
| { total_raised?: string; rounds?: Record<string, unknown>[] }
|
| { total_raised?: string; rounds?: Record<string, unknown>[] }
|
||||||
| undefined;
|
| undefined;
|
||||||
if (!funding || (!funding.total_raised && !funding.rounds?.length)) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -574,7 +610,14 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
|||||||
<h2 className="font-medium text-slate-700">Leadership team</h2>
|
<h2 className="font-medium text-slate-700">Leadership team</h2>
|
||||||
{!Array.isArray(company.enrichment.data.leadership_team) ||
|
{!Array.isArray(company.enrichment.data.leadership_team) ||
|
||||||
(company.enrichment.data.leadership_team as unknown[]).length === 0 ? (
|
(company.enrichment.data.leadership_team as unknown[]).length === 0 ? (
|
||||||
<p className="mt-2 text-slate-500">No leadership team data found.</p>
|
<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">
|
<ul className="mt-2">
|
||||||
{(
|
{(
|
||||||
@@ -613,37 +656,18 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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">
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||||
<h2 className="font-medium text-slate-700">Products</h2>
|
<h2 className="font-medium text-slate-700">Products</h2>
|
||||||
{!Array.isArray(company.enrichment.data.products) ||
|
{!Array.isArray(company.enrichment.data.products) ||
|
||||||
(company.enrichment.data.products as unknown[]).length === 0 ? (
|
(company.enrichment.data.products as unknown[]).length === 0 ? (
|
||||||
<p className="mt-2 text-slate-500">None found.</p>
|
<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">
|
<ul className="mt-2 space-y-1.5">
|
||||||
{(company.enrichment.data.products as Record<string, unknown>[]).map(
|
{(company.enrichment.data.products as Record<string, unknown>[]).map(
|
||||||
@@ -662,14 +686,19 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||||
<h2 className="font-medium text-slate-700">Recent updates</h2>
|
<h2 className="font-medium text-slate-700">Recent updates</h2>
|
||||||
{!Array.isArray(company.enrichment.data.recent_updates) ||
|
{!Array.isArray(company.enrichment.data.recent_updates) ||
|
||||||
(company.enrichment.data.recent_updates as unknown[]).length === 0 ? (
|
(company.enrichment.data.recent_updates as unknown[]).length === 0 ? (
|
||||||
<p className="mt-2 text-slate-500">None found.</p>
|
<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">
|
<ul className="mt-2">
|
||||||
{(
|
{(
|
||||||
@@ -693,6 +722,41 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
|||||||
</ul>
|
</ul>
|
||||||
)}
|
)}
|
||||||
</section>
|
</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">
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
||||||
<h2 className="font-medium text-slate-700">
|
<h2 className="font-medium text-slate-700">
|
||||||
@@ -701,7 +765,14 @@ function CompanyDetailPageInner({ id }: { id: string }) {
|
|||||||
</h2>
|
</h2>
|
||||||
{!Array.isArray(company.enrichment.data.competitors) ||
|
{!Array.isArray(company.enrichment.data.competitors) ||
|
||||||
(company.enrichment.data.competitors as unknown[]).length === 0 ? (
|
(company.enrichment.data.competitors as unknown[]).length === 0 ? (
|
||||||
<p className="mt-2 text-slate-500">None found.</p>
|
<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">
|
<ul className="mt-2 flex flex-wrap gap-2">
|
||||||
{(
|
{(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
CompanyCreatePayload,
|
CompanyCreatePayload,
|
||||||
CompanyResponse,
|
CompanyResponse,
|
||||||
CompanyUpdatePayload,
|
CompanyUpdatePayload,
|
||||||
|
EnrichmentSection,
|
||||||
MonitorConfigurationUpdatePayload,
|
MonitorConfigurationUpdatePayload,
|
||||||
} from "@/lib/types";
|
} 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) {
|
export function useUpdateMonitorConfiguration(companyId: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
DeleteAccountPayload,
|
DeleteAccountPayload,
|
||||||
DiscoverCompanyRequest,
|
DiscoverCompanyRequest,
|
||||||
DiscoveredCompanyProfile,
|
DiscoveredCompanyProfile,
|
||||||
|
EnrichmentSection,
|
||||||
IpBan,
|
IpBan,
|
||||||
LogEntry,
|
LogEntry,
|
||||||
LoginPayload,
|
LoginPayload,
|
||||||
@@ -316,6 +317,12 @@ export const api = {
|
|||||||
resumeCompany: (companyId: string) =>
|
resumeCompany: (companyId: string) =>
|
||||||
request<CompanyResponse>(`/api/v1/companies/${companyId}/resume`, { method: "POST" }),
|
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) =>
|
updateMonitorConfiguration: (companyId: string, payload: MonitorConfigurationUpdatePayload) =>
|
||||||
request<MonitorConfigurationResponse>(`/api/v1/companies/${companyId}/monitor`, {
|
request<MonitorConfigurationResponse>(`/api/v1/companies/${companyId}/monitor`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -221,6 +221,14 @@ export interface MonitorConfigurationUpdatePayload {
|
|||||||
|
|
||||||
export type EnrichmentStatus = "pending" | "partial" | "complete" | "failed";
|
export type EnrichmentStatus = "pending" | "partial" | "complete" | "failed";
|
||||||
|
|
||||||
|
export type EnrichmentSection =
|
||||||
|
| "details"
|
||||||
|
| "funding"
|
||||||
|
| "updates"
|
||||||
|
| "competitors"
|
||||||
|
| "products"
|
||||||
|
| "customers";
|
||||||
|
|
||||||
export interface CompanyEnrichmentResponse {
|
export interface CompanyEnrichmentResponse {
|
||||||
status: EnrichmentStatus;
|
status: EnrichmentStatus;
|
||||||
data: Record<string, unknown>;
|
data: Record<string, unknown>;
|
||||||
|
|||||||
@@ -59,6 +59,12 @@ http {
|
|||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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
Regular → Executable
Reference in New Issue
Block a user