Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring, source collection, LLM-based change analysis, enrichment, and account security (Turnstile, escalating lockout, email verification).
This commit is contained in:
+152
@@ -0,0 +1,152 @@
|
|||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
## CI Agent environment configuration
|
||||||
|
## Copy to .env and adjust. Nothing here is a real secret.
|
||||||
|
## ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# --- App -----------------------------------------------------------------
|
||||||
|
APP_ENV=development
|
||||||
|
APP_NAME=CI Agent
|
||||||
|
# Comma-separated list of allowed CORS origins. Add your LAN address here
|
||||||
|
# (e.g. http://localhost:3000,http://192.168.1.190:3000) to reach the app
|
||||||
|
# from another device on your network - find your LAN IP with `ipconfig`
|
||||||
|
# (Windows) or `ifconfig`/`ip a` (Mac/Linux).
|
||||||
|
FRONTEND_URL=http://localhost:3000
|
||||||
|
BACKEND_URL=http://localhost:8000
|
||||||
|
# What the *browser* uses to reach the API - baked into the frontend build,
|
||||||
|
# so for LAN access this must be the host's LAN IP, not localhost (e.g.
|
||||||
|
# http://192.168.1.190:8000). Leave unset for localhost-only access.
|
||||||
|
NEXT_PUBLIC_API_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# --- Reverse proxy (only relevant once deployed behind Cloudflare/Nginx) -----
|
||||||
|
# Empty = trust the direct connection for client-IP resolution (correct for
|
||||||
|
# this Docker/local setup - no proxy exists). Set to CF-Connecting-IP once
|
||||||
|
# behind Cloudflare's proxy, or the IP throttle/ban system and is_localhost
|
||||||
|
# will treat every visitor as sharing the proxy's own address.
|
||||||
|
TRUSTED_PROXY_IP_HEADER=
|
||||||
|
|
||||||
|
# --- Local-dev convenience --------------------------------------------------
|
||||||
|
# Comma-separated extra IPs that is_localhost treats as loopback-equivalent.
|
||||||
|
# Needed under Docker Desktop, where even host-originated traffic arrives at
|
||||||
|
# the containers via the bridge gateway rather than real loopback - find
|
||||||
|
# yours from a recent ip_throttle_state/user_security_events row, or leave
|
||||||
|
# blank to keep the strict default (only real 127.0.0.1/::1 count as local).
|
||||||
|
# Never set this in a real deployment.
|
||||||
|
ADDITIONAL_TRUSTED_LOCAL_IPS=
|
||||||
|
|
||||||
|
# --- Auth ------------------------------------------------------------------
|
||||||
|
# local = single fixed dev user, no login screen required
|
||||||
|
# jwt = real email/password accounts
|
||||||
|
AUTH_MODE=local
|
||||||
|
JWT_SECRET=dev-only-change-me-32-characters-minimum
|
||||||
|
JWT_ACCESS_TOKEN_MINUTES=15
|
||||||
|
JWT_REFRESH_TOKEN_DAYS=7
|
||||||
|
|
||||||
|
# --- Database ----------------------------------------------------------------
|
||||||
|
# Postgres (Docker Compose default):
|
||||||
|
DATABASE_URL=postgresql+psycopg://ciagent:ciagent@postgres:5432/ciagent
|
||||||
|
# SQLite fallback for running the API without Docker:
|
||||||
|
# DATABASE_URL=sqlite+aiosqlite:///./ciagent_dev.db
|
||||||
|
|
||||||
|
# --- Redis / Celery --------------------------------------------------------
|
||||||
|
REDIS_URL=redis://redis:6379/0
|
||||||
|
CELERY_TASK_ALWAYS_EAGER=false
|
||||||
|
|
||||||
|
# --- LLM ---------------------------------------------------------------------
|
||||||
|
# mock | anthropic | ollama | gemini
|
||||||
|
LLM_PROVIDER=mock
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
ANTHROPIC_MODEL=claude-sonnet-5
|
||||||
|
OLLAMA_BASE_URL=http://host.docker.internal:11434
|
||||||
|
OLLAMA_MODEL=llama3.1
|
||||||
|
# Free-tier option: create a key at https://aistudio.google.com/apikey
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
GEMINI_MODEL=gemini-2.0-flash
|
||||||
|
LLM_MAX_TOKENS_PER_REQUEST=4000
|
||||||
|
LLM_MAX_RETRIES=2
|
||||||
|
|
||||||
|
# --- Search / company discovery --------------------------------------------
|
||||||
|
# mock | brave
|
||||||
|
SEARCH_PROVIDER=mock
|
||||||
|
BRAVE_SEARCH_API_KEY=
|
||||||
|
SERPAPI_API_KEY=
|
||||||
|
BING_SEARCH_API_KEY=
|
||||||
|
|
||||||
|
# --- Patents -----------------------------------------------------------------
|
||||||
|
# Free key via account registration at data.uspto.gov/apis/getting-started
|
||||||
|
# (now requires ID.me identity verification). PatentSourceCollector falls
|
||||||
|
# back to its honest disabled/fixture behavior when this is empty.
|
||||||
|
USPTO_API_KEY=
|
||||||
|
|
||||||
|
# --- Company enrichment (NinjaPear / nubela.co) -------------------------------
|
||||||
|
# Paid, per-credit API - get a key from nubela.co/dashboard after
|
||||||
|
# registering. Only ever called once per company, at creation time (never
|
||||||
|
# on a recurring schedule) - see app/services/enrichment_service.py.
|
||||||
|
# Leave empty to skip this feature entirely; nothing else in the app
|
||||||
|
# depends on it.
|
||||||
|
NINJAPEAR_API_KEY=
|
||||||
|
NINJAPEAR_MAX_LEADERSHIP_LOOKUPS=5
|
||||||
|
|
||||||
|
# --- Email (SMTP) ------------------------------------------------------------
|
||||||
|
# Fallback transport when RESEND_API_KEY (below) isn't set - alert emails
|
||||||
|
# and, if Resend is unconfigured, security emails go through this. Point it
|
||||||
|
# at any real SMTP relay (e.g. your own mail server, or Resend's own SMTP
|
||||||
|
# endpoint at smtp.resend.com). Nothing in this stack runs a local catch-all
|
||||||
|
# mail sink - a real relay (or a real Resend account) is required to
|
||||||
|
# actually test email delivery locally.
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USERNAME=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM_EMAIL=[email protected]
|
||||||
|
SMTP_USE_TLS=true
|
||||||
|
|
||||||
|
# --- Resend (transactional security email: verify/reset/lockout) ------------
|
||||||
|
# Unset by default - falls back to the SMTP block above. Get a key from
|
||||||
|
# resend.com after verifying your sending domain.
|
||||||
|
RESEND_API_KEY=
|
||||||
|
RESEND_SECURITY_FROM_EMAIL=[email protected]
|
||||||
|
|
||||||
|
# --- Cloudflare Turnstile (CAPTCHA on register/login/password-reset) --------
|
||||||
|
# Unset by default - skipped when the caller is on loopback, or when this
|
||||||
|
# isn't configured at all (neither here nor via the Settings page's admin
|
||||||
|
# "Server secrets" box, which takes priority over these when set - see
|
||||||
|
# app/services/system_secret_service.py). The site key is safe to expose
|
||||||
|
# publicly; /system/status serves it live to the frontend, so there's no
|
||||||
|
# separate NEXT_PUBLIC_* build-time variable for it.
|
||||||
|
TURNSTILE_SITE_KEY=
|
||||||
|
TURNSTILE_SECRET=
|
||||||
|
|
||||||
|
# --- SMS (optional) -----------------------------------------------------------
|
||||||
|
NOTIFICATION_SMS_ENABLED=false
|
||||||
|
# twilio | telnyx
|
||||||
|
SMS_PROVIDER=twilio
|
||||||
|
TWILIO_ACCOUNT_SID=
|
||||||
|
TWILIO_AUTH_TOKEN=
|
||||||
|
TWILIO_FROM_NUMBER=
|
||||||
|
# Telnyx: portal.telnyx.com -> API Keys for TELNYX_API_KEY; the number must
|
||||||
|
# be assigned to a Messaging Profile (portal.telnyx.com -> Messaging).
|
||||||
|
TELNYX_API_KEY=
|
||||||
|
TELNYX_FROM_NUMBER=
|
||||||
|
SMS_MONTHLY_CAP=50
|
||||||
|
|
||||||
|
# --- GitHub collector (optional, raises rate limit) --------------------------
|
||||||
|
GITHUB_TOKEN=
|
||||||
|
|
||||||
|
# --- Scheduling defaults -----------------------------------------------------
|
||||||
|
DEFAULT_TIMEZONE=America/New_York
|
||||||
|
DEFAULT_MONITORING_FREQUENCY=weekly
|
||||||
|
MINIMUM_MONITORING_INTERVAL_MINUTES=60
|
||||||
|
|
||||||
|
# --- Scraper behavior ---------------------------------------------------------
|
||||||
|
SCRAPER_USER_AGENT=CIAgentBot/1.0 (+https://ci-agent.local/bot)
|
||||||
|
MAX_PAGES_PER_DOMAIN=25
|
||||||
|
SCRAPER_REQUEST_TIMEOUT_SECONDS=30
|
||||||
|
SCRAPER_DOMAIN_DELAY_SECONDS=2
|
||||||
|
|
||||||
|
# --- Cost / abuse controls -----------------------------------------------------
|
||||||
|
MAX_COMPANIES_PER_USER=25
|
||||||
|
MAX_MANUAL_RUNS_PER_DAY=10
|
||||||
|
|
||||||
|
# --- Retention & logging -------------------------------------------------------
|
||||||
|
DATA_RETENTION_DAYS=365
|
||||||
|
LOG_LEVEL=INFO
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
# --- env / secrets ---
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
*.pem
|
||||||
|
*.key
|
||||||
|
|
||||||
|
# --- Python ---
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.egg-info/
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
.mypy_cache/
|
||||||
|
htmlcov/
|
||||||
|
.coverage
|
||||||
|
*.db
|
||||||
|
*.sqlite3
|
||||||
|
.fixture_state
|
||||||
|
|
||||||
|
# --- Node / Next.js ---
|
||||||
|
node_modules/
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
.turbo/
|
||||||
|
coverage/
|
||||||
|
*.tsbuildinfo
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# --- Playwright ---
|
||||||
|
test-results/
|
||||||
|
playwright-report/
|
||||||
|
playwright/.cache/
|
||||||
|
|
||||||
|
# --- Docker / OS ---
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
# Stray file Git Bash on Windows sometimes creates from a `> nul` redirect
|
||||||
|
# that doesn't map to the real NUL device the way cmd.exe's does.
|
||||||
|
nul
|
||||||
|
|
||||||
|
# --- IDE ---
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# --- Logs ---
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# --- Local dev data ---
|
||||||
|
apps/api/ciagent_dev.db
|
||||||
|
mailpit-data/
|
||||||
|
|
||||||
|
# --- Local agent tooling state (not app source) ---
|
||||||
|
.claude/
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
```
|
||||||
|
apps/web (Next.js) <---REST/JSON---> apps/api (FastAPI) <---> PostgreSQL
|
||||||
|
| \
|
||||||
|
| ---> Redis (Celery broker + cache)
|
||||||
|
|
|
||||||
|
Celery worker(s) + Celery Beat
|
||||||
|
|
|
||||||
|
collectors -> LLM analysis -> change detection -> notifications
|
||||||
|
```
|
||||||
|
|
||||||
|
Everything runs via `docker-compose.yml`: `postgres`, `redis`, `api`, `worker`, `beat`, `web`. Each backend concern (`apps/api/app/*`) is organized by responsibility, not by HTTP route, so services can be reused from Celery tasks and from route handlers alike.
|
||||||
|
|
||||||
|
## Provider abstraction pattern
|
||||||
|
|
||||||
|
Every external dependency that costs money, requires a credential, or might later move to a different vendor (including a future Firebase/GCP migration) sits behind a small `Protocol` interface with a `Mock`/`Console` implementation that requires no configuration and is what the app uses by default:
|
||||||
|
|
||||||
|
| Concern | Interface | Implementations |
|
||||||
|
|---|---|---|
|
||||||
|
| Auth | `AuthProvider` (`app/auth/base.py`) | `LocalAuthProvider`, `JWTAuthProvider` |
|
||||||
|
| LLM | `LLMProvider` (`app/analysis/llm/base.py`) | `MockLLMProvider`, `AnthropicLLMProvider`, `OllamaLLMProvider`, `GeminiLLMProvider` |
|
||||||
|
| Search | `SearchProvider` (`app/search/base.py`) | `MockSearchProvider`, `BraveSearchProvider` |
|
||||||
|
| Notifications | `NotificationProvider` (`app/notifications/base.py`) | `ConsoleProvider`, `SmtpEmailProvider`, `TwilioSmsProvider` |
|
||||||
|
| Source collection | `SourceCollector` (`app/collectors/base.py`) | `WebsiteCollector`, `RssCollector` (incl. Google News auto-discovery), `CustomUrlCollector`, `SecEdgarCollector`, `GithubCollector`, `JobPostingCollector`, `GovContractCollector` (USASpending.gov, free/keyless), `PatentSourceCollector` (real USPTO ODP call when `USPTO_API_KEY` is set, fixture fallback otherwise), `ReviewSourceCollector` (fixture) |
|
||||||
|
| Company enrichment | `EnrichmentProvider` (`app/enrichment/base.py`) | `MockEnrichmentProvider`, `NinjaPearProvider` (nubela.co, paid/per-credit, real call when `NINJAPEAR_API_KEY` is set) |
|
||||||
|
|
||||||
|
Selection is via environment variables (`LLM_PROVIDER`, `SEARCH_PROVIDER`, `AUTH_MODE`, etc.), read once in `app/core/config.py` (a pydantic-settings `Settings` singleton) and resolved through small factory functions — never imported directly by call sites. This is what makes the Firebase/GCP migration path (see `docs/FIREBASE_MIGRATION.md`) tractable: swapping `AuthProvider` for a Firebase-backed one, or the DB session for a Firestore client, doesn't ripple through route handlers or Celery tasks.
|
||||||
|
|
||||||
|
## Data flow: monitoring run
|
||||||
|
|
||||||
|
1. `MonitorConfiguration.next_run` is reached, or an individual `Source` has its own faster `frequency_type` override that's independently due (Beat), or a user hits "Run Now" (API) → a `MonitoringRun` row is created (`status=queued`) and a `collection.run_monitoring` Celery task is enqueued with the run ID.
|
||||||
|
2. The task loads active `Source` rows for the company. For a `SCHEDULED` trigger, only sources actually due right now are collected (a source with no override rides the company's own cadence; one with an override uses its own `next_check`, via `SourceRepository.list_due_for_company`) - a `MANUAL` "Run now" always collects every active source regardless of individual cadence. For each, the task calls the matching `SourceCollector.collect()`, going through `app/core/http.safe_fetch()` (SSRF-guarded) for anything network-bound.
|
||||||
|
3. Each successful collection produces a `SourceDocument` (raw extracted text + metadata) and updates the run's `sources_successful` / `sources_failed` counters. A `Snapshot` (structured summary + hash) is derived per source.
|
||||||
|
4. `change_detection` compares the new `Snapshot` against the most recent prior one for the same source, layer by layer (hash → structured fields → bounded text diff → optional LLM semantic check), producing a `DetectedChange` with a `significance_score`, `confidence`, and `severity`.
|
||||||
|
5. `analysis` (LLM) generates/updates the `Report` for the company from the accumulated evidence (`SourceDocument`s + `DetectedChange`s), never inventing facts outside that evidence.
|
||||||
|
6. For each `DetectedChange` above the user's severity threshold, an `Alert` is created (after dedup/cooldown checks) and `notifications` delivers it to each enabled `NotificationDestination` *linked to that company* (via the `notification_destination_companies` join table - a destination can be shared across several companies, e.g. one email registered once but linked to every company the user monitors), recording a `NotificationDelivery`. A destination with zero remaining company links is garbage-collected when the last linking company is deleted.
|
||||||
|
7. The frontend polls `MonitoringRun` status and then renders the `Report`/`Alert` once available.
|
||||||
|
|
||||||
|
## Data flow: company discovery (onboarding)
|
||||||
|
|
||||||
|
`POST /api/v1/companies/discover` (`app/services/discovery_service.py`) turns a bare company name into a pre-filled, user-editable profile, and is deliberately kept separate from the two pipelines above:
|
||||||
|
|
||||||
|
- **`SearchProvider`** answers "where should I look" — it returns URLs/snippets, never facts about the company itself.
|
||||||
|
- **`SourceCollector`** (the same collectors the monitoring pipeline uses) answers "what's actually there" by fetching real pages.
|
||||||
|
- **`LLMProvider`** only ever *analyzes* evidence already fetched by the two above — it is never prompted with "tell me about company X" from its own training data, matching the evidence-grounded pattern every other analysis task in this app follows (`app/prompts/*`).
|
||||||
|
|
||||||
|
Steps: (1) resolve `official_website` from the user's hint or `search.search(f"{name} official website")`; (2) fetch that homepage via the existing `fetch_with_retries`/`extract_readable_text` helpers; (3) run a few targeted searches (headquarters, competitors, aliases) for more evidence snippets; (4) call `app/prompts/company_profile.py::extract_company_profile` once over all of it; (5) merge — any user-supplied hint wins over the discovered value; (6) call the `website`/`github`/`sec_edgar`/`job_posting` collectors' existing `.discover()` methods against a synthetic, unsaved `CompanyContext` to preview likely sources. **Nothing is persisted by this endpoint** — the wizard's Review step shows the result for editing, and only the final `POST /companies` call (unchanged) writes a `Company` row. Source rows themselves still come from the pre-existing lazy discovery gate in `tasks/collection.py` (first monitoring run, not onboarding) — discovery preview and source persistence intentionally stay two different code paths so a user can preview/abandon without any DB writes.
|
||||||
|
|
||||||
|
## Data flow: company enrichment (paid, onboarding-only)
|
||||||
|
|
||||||
|
`app/services/enrichment_service.py` is a third, deliberately separate data-gathering path from the two above, for a paid third-party vendor (NinjaPear/nubela.co) that bills per-field per-request:
|
||||||
|
|
||||||
|
- Only ever runs **once**, right after a `Company` row is actually committed in `company_service.create_company` — never on a recurring schedule, and never during the discovery *preview* step (which would spend real credits on companies a user might abandon before creating). The enqueue itself (`enrich_company.delay(...)`, routed to its own `enrichment` Celery queue) is skipped entirely unless `NINJAPEAR_API_KEY` is configured, so a user who never opts in gets zero extra background-task volume — same "gate the enqueue on the key, not just the provider" pattern as `PatentSourceCollector.discover()`.
|
||||||
|
- A `CompanyEnrichment` row (1:1 with `Company`, same shape as `MonitorConfiguration`) is created with `status=pending` synchronously, in the same transaction as company creation, *before* the Celery task even starts — this gives the frontend something real to poll on (`useCompany`'s `refetchInterval`), since without it "not yet enriched" and "never configured" would both look like `enrichment: null`.
|
||||||
|
- The task (`app/tasks/enrichment.py`) calls several independent NinjaPear endpoints (company details, funding, competitors, products, customer/updates listings, plus capped per-leadership-member work-email/profile lookups) through `EnrichmentProvider`; one failed call is recorded in `errors` and never sinks the others, same principle as `tasks/collection.py`'s per-source loop. Overall `status` becomes `complete`/`partial`/`failed` depending on how many of those independent calls actually succeeded.
|
||||||
|
- Once present, `CompanyEnrichment.data` feeds into report generation as a `company_enrichment` evidence block (`report_service.py`, `prompts/report_generation.py`) exactly like `company_profile` does — real fetched data, never invented, and skipped entirely (falls back to an honest empty block) if enrichment never ran, is still pending, or failed outright.
|
||||||
|
|
||||||
|
## Change significance scoring (documented, testable formula)
|
||||||
|
|
||||||
|
Implemented in `app/change_detection/scoring.py`. Each detected difference starts with a base weight from its category (e.g. `leadership_change=0.9`, `pricing_change=0.6`, `wording_change=0.15`), then is adjusted by:
|
||||||
|
|
||||||
|
```
|
||||||
|
significance = base_weight
|
||||||
|
* source_trust_score (0.3 - 1.0)
|
||||||
|
* min(1.0, independent_sources / 2) # corroboration bonus, caps at 2 sources
|
||||||
|
* focus_match_multiplier (1.3 if matches user's stated focus, else 1.0)
|
||||||
|
* recency_multiplier (1.0 if new, 0.5 if a repeat of a prior alert)
|
||||||
|
confidence = f(extraction_confidence, source_trust_score, corroboration)
|
||||||
|
```
|
||||||
|
|
||||||
|
`severity` is then a deterministic bucket over `significance * confidence` (see `SEVERITY_THRESHOLDS` in the same module), with a hard rule that `critical` requires `confidence >= 0.7` regardless of score — an uncorroborated single-source signal cannot be labeled Critical. Full unit tests live in `apps/api/tests/unit/test_scoring.py`.
|
||||||
|
|
||||||
|
## Directory layout
|
||||||
|
|
||||||
|
See `PLAN.md` for the top-level file tree. Within `apps/api/app`, packages are: `api/v1` (routers only — thin, no business logic), `auth`, `core` (config, logging, http/SSRF, security), `db` (session, base model), `models` (SQLAlchemy ORM), `schemas` (Pydantic request/response), `repositories` (query layer, one per aggregate), `services` (business logic orchestration), `collectors`, `analysis` (LLM provider + prompt tasks), `change_detection`, `notifications`, `tasks` (Celery task definitions, thin wrappers around `services`), `prompts` (prompt templates + response schemas, one module per analysis task).
|
||||||
|
|
||||||
|
## Why FastAPI routes stay thin
|
||||||
|
|
||||||
|
Route handlers only: parse/validate input (Pydantic), call one service method, map the result/exception to an HTTP response. All ownership checks, business rules, and orchestration live in `services/`, which is what both the API and Celery tasks call — this avoids duplicating authorization or business logic between the two entry points.
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
# Known Limitations
|
||||||
|
|
||||||
|
This file is updated as each phase lands. It exists so nothing is silently claimed to work when it doesn't.
|
||||||
|
|
||||||
|
## Scope decisions (see PLAN.md)
|
||||||
|
|
||||||
|
- **Customer review source**: implemented as a real `SourceCollector` interface with a documented fixture/mock adapter, not a live scraper - there's no reliable free public API, and the spec explicitly instructs against fabricating results for sources without one. Swapping in a real provider later means implementing one class against the existing `SourceCollector` protocol.
|
||||||
|
- **Patent source**: as of Phase 14, `PatentSourceCollector` attempts a real call to USPTO's Open Data Portal when `USPTO_API_KEY` is configured (a free key via account registration), falling back to the same honest fixture/disabled behavior as before when it isn't. See the Phase 14 section below.
|
||||||
|
- **Nubela/NinjaPear company-enrichment API**: considered and dropped in Phase 14 (believed enterprise-only), then wired up in Phase 15 once the user found and paid for an individual tier that Phase 14's research missed - see the Phase 15 section below.
|
||||||
|
- **Job posting collector**: generic HTML extraction from a company's own careers page is real. Board-specific APIs (LinkedIn, Indeed, etc.) are not implemented — most require paid access or prohibit automated collection in their terms.
|
||||||
|
- **JS-rendered page collection (Playwright)**: the spec calls for Playwright for JS-heavy pages. The website/custom-URL collectors in this build use static HTML fetching (httpx + trafilatura/BeautifulSoup) only. A `PlaywrightRenderCollector` would slot in behind the same `SourceCollector` interface; not implemented in this pass to keep the collector container lightweight. Sites that require JS rendering will collect an empty/thin document and the source will be flagged rather than silently failing.
|
||||||
|
- **PDF report export**: Markdown and JSON export are implemented; PDF export is documented as a later enhancement per the spec.
|
||||||
|
- **OAuth social login**: not implemented; the spec marks this as an optional later enhancement. `AUTH_MODE=jwt` (email/password) and `AUTH_MODE=local` (dev) cover the MVP.
|
||||||
|
|
||||||
|
## Frontend dependency notes
|
||||||
|
|
||||||
|
- `npm audit` reports a moderate esbuild advisory (dev-server-only, requires a malicious site to reach your local Vite dev/test server while it's running — not exploitable in production or CI) and two high advisories inside Next.js's own vendored `postcss`/`sharp` (used only by the Image Optimization pipeline, which this app does not currently use via `next/image`). Tracked upstream; will clear on Next's next patch release. Re-run `npm audit` after `npm install` to check current status.
|
||||||
|
|
||||||
|
## Infrastructure
|
||||||
|
|
||||||
|
- Docker Desktop must be running before `docker compose up`; this was verified during Phase 1 (see TASKS.md).
|
||||||
|
- On Windows + Docker Desktop, the frontend dev container's file watcher does not always pick up newly *created* route files through the bind mount (edits to existing files hot-reload fine). If a new page 404s right after adding it, `docker compose restart web` picks it up. This is a bind-mount/watcher quirk, not an app bug.
|
||||||
|
- **`web`'s `node_modules` *and* `.next` are both anonymous Docker volumes** (`docker-compose.yml`'s `- /app/node_modules` and `- /app/.next` lines, needed so the container's Linux-built native modules and build cache don't get clobbered by the host's bind-mounted `apps/web`). This means adding a new npm dependency and running `docker compose build web` is **not enough** - the anonymous volumes from the container's first-ever `up` persist across rebuilds *and* plain `docker compose restart`, so a new package still 404s as `Module not found`, and (observed live during Phase 11) an **edited existing file can silently keep serving its pre-edit output** even after a restart, because Next dev's on-disk `.next` cache survives the restart untouched. `docker compose restart web` alone is not a reliable way to pick up source changes made while the container was already running under Docker Desktop on Windows - the fix in both cases is the same: `docker compose rm -f -s -v web` (removes the container *and* its anonymous volumes) then `docker compose up -d web` to recreate it clean. The mirror-image gotcha to the Python one below - same root cause (a persistent layer masking a rebuilt image/changed source), different mechanism (anonymous volume vs. stale image tag).
|
||||||
|
|
||||||
|
## Auth (Phase 2)
|
||||||
|
|
||||||
|
- Access/refresh tokens are stored in `localStorage` on the frontend for simplicity (see `apps/web/lib/api-client.ts`). This is a reasonable tradeoff for a local-first MVP but is XSS-exposed compared to httpOnly cookies; a production multi-user deployment should move to cookie-based storage with CSRF protection before going live publicly.
|
||||||
|
- There is no email verification, password reset, or account lockout after repeated failed logins yet (rate limiting mitigates brute force but doesn't lock the account). Password reset is called out in the spec as a "documented future capability" for the MVP.
|
||||||
|
- Admin role (`User.is_admin`) exists on the model and the local dev user is admin, but no admin-only endpoints exist yet to gate with `require_admin`.
|
||||||
|
|
||||||
|
## Collection pipeline (Phase 4)
|
||||||
|
|
||||||
|
- Auto-discovery (`collection_service.discover_sources_for_company`) is **not** wired into `POST /companies` yet — it makes several live outbound HTTP calls (robots.txt, sitemap, GitHub search, SEC EDGAR search) which don't belong on the synchronous request path. It's called directly today (see integration tests) and will be triggered as a background job once Celery exists (Phase 5), matching the spec's "System discovers company sources" step in the async pipeline diagram.
|
||||||
|
- The `SCRAPER_DOMAIN_DELAY_SECONDS` per-domain rate limit and the robots.txt cache are in-process dictionaries. Fine for a single API/worker process; a multi-worker Celery deployment would need a shared store (Redis) for these to be true global limits rather than per-worker ones.
|
||||||
|
- SEC EDGAR collector stores filing *metadata* (form type, date, accession number, link) rather than parsing full filing document bodies — full-text filing parsing is a meaningfully larger scope (SEC filings are large, structurally inconsistent HTML/XBRL documents) left for a future pass.
|
||||||
|
- No collector renders JavaScript (no Playwright integration in this pass — see the Non-goals note in PLAN.md). A JS-heavy page will collect whatever static HTML is served, which may be thin or empty; the source will still report success with fewer/emptier documents rather than silently failing.
|
||||||
|
|
||||||
|
## Background processing (Phase 5)
|
||||||
|
|
||||||
|
- **Docker image drift on new Python deps**: `api`, `worker`, and `beat` all build from the same `infrastructure/docker/api.Dockerfile`, but each is a separate image. Adding a dependency to `apps/api/pyproject.toml` and only running `docker compose restart api` leaves `worker`/`beat` on stale images (they'll crash with `ModuleNotFoundError`). Run `docker compose build api worker beat` (or `docker compose up --build`) after any dependency change, not just a restart.
|
||||||
|
- **`@celery_app.task` vs `@shared_task`**: this codebase defines exactly one Celery app (`app.tasks.celery_app.celery_app`) and every task is bound to it explicitly with `@celery_app.task(...)`. Using `@shared_task` here silently binds the task to Celery's implicit default app instead — which doesn't have our Redis broker or `task_always_eager` config — and `.delay()` will try to connect to a default RabbitMQ broker and fail. Discovered and fixed during this phase; don't reintroduce `@shared_task` in `app/tasks/*`.
|
||||||
|
- **Nested event loop under eager mode**: Celery's synchronous task functions bridge into the app's async service layer via `asyncio.run(...)`. Under `CELERY_TASK_ALWAYS_EAGER=true` (tests, or any `.delay()` call made from inside an async FastAPI route handler), the task body executes synchronously *inside* the caller's already-running event loop, and a plain `asyncio.run()` raises `RuntimeError`. `app/tasks/base.py::run_async_task` detects this and falls back to running the coroutine in a dedicated thread with its own loop. This only matters for eager/in-process execution; a real worker process never has this problem.
|
||||||
|
- The per-domain rate limiter and robots.txt cache noted in the Phase 4 section are still in-process only — a multi-worker Celery deployment (`--concurrency` > 1, or multiple worker containers) would need a shared store (Redis) for these to be true global limits.
|
||||||
|
- No task deduplication beyond the single-active-run-per-company check (`MonitoringRunRepository.get_active_for_company`). There's no distributed lock, so a very tight race (two beat ticks within milliseconds) could theoretically enqueue two runs; not observed in testing and low-impact if it happened (the second run just collects again).
|
||||||
|
|
||||||
|
## Change detection (Phase 6)
|
||||||
|
|
||||||
|
- **No semantic (Layer 4) comparison yet.** The spec describes an optional LLM/embedding-based layer for cases where the text diff is ambiguous. That needs the LLM provider interface, which is Phase 7. Layers 1-3 (hash/structured/text) plus deterministic scoring already produce real, correctly-classified detections; Layer 4 would improve recall on subtle wording-only changes, not correctness of what's already detected.
|
||||||
|
- **Single-source only.** `independent_source_count` is hardcoded to `1` in `change_detection_service.py` - cross-source corroboration (e.g. a press release *and* a job posting both pointing at the same expansion) is the spec's "cross-source synthesis" task, which is Phase 7 (LLM analysis) scope. Every current detection is scored as uncorroborated, which the formula already discounts significantly by design.
|
||||||
|
- **Leadership/price extraction are regex heuristics**, not NLP - see the docstring in `app/change_detection/extractors.py`. They catch common phrasings ("named CEO", "$49/month") and will miss unusual ones or produce occasional false positives on lookalike phrasing. Phase 7's LLM-based extraction task is the higher-fidelity replacement/supplement.
|
||||||
|
- **Structured diff only tracks the URL set**, not deeper structured fields (specific job titles changing wording, individual price line-items on a page with several). A URL/title appearing or disappearing is caught; a price changing on a page that lists ten prices, where the page's overall URL set doesn't change, is caught by the text-diff price extractor instead (which does work across the whole page, not per-item).
|
||||||
|
|
||||||
|
## LLM analysis (Phase 7)
|
||||||
|
|
||||||
|
- **`AnthropicLLMProvider` is now verified live end-to-end** - the user supplied a real `ANTHROPIC_API_KEY` (`claude-sonnet-5`) and, combined with a real `BRAVE_SEARCH_API_KEY`, the full pipeline was driven for real: company discovery (Discover -> Review, real search + real structured extraction), company creation, the pre-existing lazy source-discovery-on-first-run crawl of `stripe.com` (23 items collected across 3 sources, 0 failures), and baseline report generation via a real `api.anthropic.com/v1/messages` call. The resulting report was coherent and correctly epistemically humble - it explicitly named what it could and couldn't substantiate from the evidence actually collected (no fabricated financials/leadership claims). This is the strongest live-verification signal in the whole app: real evidence in, real grounded analysis out, exactly as the architecture intends. **`Ollama` remains structurally complete but unverified** - no local Ollama instance is available in this environment; unit-tested with the client mocked only. Set `LLM_PROVIDER=ollama` + a running Ollama instance to exercise it for real.
|
||||||
|
- **MockLLMProvider is deliberately non-generic** - each of the six analysis tasks has a purpose-built builder function that reads the evidence block and produces genuinely evidence-derived output (real counts, titles, severities), rather than a reflection-based generic filler. This is intentional (see `app/analysis/llm/mock.py` docstring) but means adding a *new* analysis task requires writing its own mock builder, not just a new Pydantic schema.
|
||||||
|
- **Tasks A (relevance), B (extraction), and C (synthesis) are implemented and tested but not yet wired into the collection/report pipeline** - Task D (report generation) currently consumes raw `SourceDocument`/`DetectedChange` rows directly rather than pre-filtering through Task A or pre-extracting via Task B/C. Wiring them in would improve report quality (e.g. filtering irrelevant documents before they reach the report prompt) but the report already works correctly without it, since `report_service.py`'s evidence-gathering only pulls documents/changes already scoped to the company.
|
||||||
|
- **A report is regenerated only on baseline (first evidence) or when a run detects an actual change** - not on every scheduled run, to keep LLM cost proportional to real activity rather than to schedule cadence. See `app/tasks/collection.py`.
|
||||||
|
- **No token/cost usage tracking or per-user LLM budget yet** - `LLM_MAX_TOKENS_PER_REQUEST` and `LLM_MAX_RETRIES` bound a single call, but there's no aggregate usage dashboard (spec section 30's "Display usage statistics in settings" is Phase 10/not-yet-done).
|
||||||
|
|
||||||
|
## Notifications (Phase 8)
|
||||||
|
|
||||||
|
- **`TwilioSmsProvider` was verified live against a real Twilio account, and the auth/transport layer works correctly** - the user supplied real `TWILIO_ACCOUNT_SID`/`TWILIO_AUTH_TOKEN`/`TWILIO_FROM_NUMBER`, added a real SMS notification destination through the Settings UI, and triggered a real "Send test notification." The request reached `api.twilio.com` with correct auth and was rejected with a real Twilio error (`400`, code `572006`, `"Invalid template name. Trial accounts can only use predefined SMS templates."`), which the app surfaced cleanly inline on the destination row rather than crashing or swallowing it - `TwilioSmsProvider.send()`'s error-passthrough path (previously only respx-mocked) is now confirmed correct end-to-end.
|
||||||
|
**This exposed a real product-level blocker, not a code bug**: Twilio trial accounts no longer support arbitrary custom SMS body text at all - they restrict outbound SMS to Twilio's own predefined templates (order confirmations, appointment reminders, etc.), confirmed via [Twilio's trial-restrictions docs](https://www.twilio.com/docs/usage/trials). Since every alert message this app sends is dynamically generated (company name, severity, summary - see `app/notifications/message_builder.py`), **custom-body SMS cannot work on a Twilio trial account at all, for any app**, not just this one. There is no code-side workaround. The account needs to be upgraded off trial (a payment method added, converting it to a standard pay-as-you-go account) before real SMS alerts can be verified end-to-end. Once upgraded, the provider itself is expected to work as-is - the failure observed was purely Twilio's trial content policy, not a malformed request.
|
||||||
|
- **`TelnyxSmsProvider` added as a second SMS vendor, selected via `SMS_PROVIDER=twilio|telnyx`** (`app/notifications/telnyx_sms.py`, `app/notifications/factory.py`) - a plain Bearer-authenticated REST POST to `https://api.telnyx.com/v2/messages`, same no-SDK shape as `TwilioSmsProvider`. Unit-tested with the HTTP call respx-mocked (success, API-error with Telnyx's `errors[]` shape, and not-configured paths). The user then supplied real `TELNYX_API_KEY`/`TELNYX_FROM_NUMBER` credentials and drove a real "Send test notification" through the Settings UI. First attempt correctly surfaced a real `403` (`"Only pre-verified destinations are allowed at this account level"` - Telnyx trial accounts cap outbound SMS to a single [verified destination number](https://support.telnyx.com/en/articles/6988813-verified-numbers)). After the user verified the destination number in the Telnyx portal, a retry got a real `200 OK` from `api.telnyx.com` and the UI showed "Test sent" - **but the text never actually arrived on the phone**.
|
||||||
|
- **This exposed a real gap: a `200 OK` from `POST /v2/messages` only means "Telnyx accepted the send request," not "the carrier delivered it."** Polling `GET /v2/messages/{id}` afterward (not something the app does automatically - no webhook receiver exists in this dev stack) showed the true outcome: `status: "delivery_failed"`, error `40010` - `"The sending number is not 10DLC-registered but is required to be by the carrier."` US carriers (T-Mobile/AT&T/Verizon) require standard 10-digit long-code numbers sending A2P SMS to go through [10DLC campaign registration](https://developers.telnyx.com/docs/account-setup/levels-and-capabilities/trial) - this is an industry-wide carrier requirement, not a Telnyx or app-specific issue, and Twilio has the identical requirement. There is no code-side workaround; the sending number's 10DLC campaign (or a toll-free number's Toll-Free Verification, a separate and often-faster alternative) must complete registration in the Telnyx portal before delivery will actually succeed. **The app's `send()` methods for both SMS providers report success based only on the initial API response, not eventual carrier delivery status** - a known simplification, not fixed here, since confirming real delivery would require either polling every send (adds latency/cost per message) or a publicly-reachable webhook endpoint (not available in this local dev stack). Worth revisiting if/when this deploys somewhere with a public URL.
|
||||||
|
- **Per the user's direction, SMS is switched back off (`NOTIFICATION_SMS_ENABLED=false`) while 10DLC registration is pending**, and this now applies consistently everywhere SMS could be triggered - a real gap was found and fixed in the same pass: `alert_service.send_test_notification` (the "Test" button) previously ignored `NOTIFICATION_SMS_ENABLED` entirely and would call a configured SMS provider for real regardless of the kill switch; only the real-alert-dispatch path (`create_alert_for_change`) checked it. Fixed so both paths short-circuit identically - verified live (the "Test" button now returns "SMS delivery is currently disabled..." with zero calls to `api.telnyx.com`, confirmed via API logs before/after). The Settings page's "Add destination" form also now shows a specific inline notice when SMS is selected while disabled, explaining *why* (10DLC registration pending) rather than just that it won't work - `apps/web/app/(app)/settings/page.tsx`. 211 backend tests (up from 210), 18 frontend tests unchanged.
|
||||||
|
- **No delivery retry.** `NotificationDelivery.attempt_count` exists in the schema for future use, but `alert_service.create_alert_for_change` currently makes exactly one send attempt per destination and records whatever result comes back - a transient SMTP/Twilio failure is recorded as `FAILED` and not retried. A retry task (e.g. exponential backoff via the existing `notifications` Celery queue) would be the natural follow-up.
|
||||||
|
- **No alert-level dedup/digest across runs.** Change-level dedup/cooldown already exists (Phase 6 - exact-repeat suppression within 24h), but there's no "don't re-alert on the same underlying issue" or "batch several changes from one run into a single digest email" logic; every alert-worthy `DetectedChange` produces its own Alert and its own set of notification sends.
|
||||||
|
- **Email/SMS body text is not localized or user-customizable** - `app/notifications/message_builder.py` produces one fixed English format per channel. Per-user notification templates are not implemented.
|
||||||
|
- **`NotificationDestination.verified` is tracked on the model but never set to `true` anywhere** - there's no verification-code/confirmation-link flow yet (a destination is usable for real sends as soon as it's created, gated only by `enabled` + `minimum_severity`). Verification is a natural Phase 10+ hardening item, especially for SMS where sending to an unverified number has cost/abuse implications.
|
||||||
|
|
||||||
|
## Fixture demo + tests (Phase 9)
|
||||||
|
|
||||||
|
- **The switchable live-demo mechanism built in this phase (the dev-only `app/dev/fixtures.py` HTTP-serving/version-switch endpoints, the Settings page's "Acme Mobility demo" panel, `scripts/seed_acme_demo.py`, `scripts/switch_fixture.py`, the SSRF allowlist setting that existed only to support it, and the Playwright E2E spec that drove it through the UI) has since been removed entirely** - it added a live, always-mounted-outside-production HTTP surface and a dedicated SSRF-guard carve-out for a one-off manual demo workflow that wasn't worth the ongoing maintenance/security surface. The underlying fixture HTML files (`apps/api/tests/fixtures/acme_mobility/v1|v2/*.html`) and the backend regression test that reads them straight off disk (`apps/api/tests/integration/test_acme_fixture_demo.py`) are unaffected and remain the automated way this pipeline's baseline→change→alert behavior gets locked in (leadership/price/content-modified/no-change, all four outcomes).
|
||||||
|
|
||||||
|
## Hardening & docs (Phase 10)
|
||||||
|
|
||||||
|
- **Rate limits are per-IP (`slowapi`'s default `get_remote_address` key func), not per-user.** Multiple users behind the same NAT/corporate proxy share a limit bucket. Fine for a local-first MVP; a real multi-tenant deployment behind a load balancer would want to key on the authenticated user id instead (falling back to IP for unauthenticated routes like register/login).
|
||||||
|
- **The daily manual-run cap (`MAX_MANUAL_RUNS_PER_DAY`) counts per company, not per user.** A user with several companies gets the cap on each independently rather than a combined daily budget. This matches how the setting reads ("manual runs per day") but is worth confirming against actual intent if usage patterns suggest otherwise.
|
||||||
|
- **Correlation-id propagation into Celery's threaded eager-mode fallback required a real fix, not just a wrapper.** `ThreadPoolExecutor` doesn't copy `contextvars` into the new thread by default, which would have silently dropped `request_id`/`run_id`/`task_id` from log lines whenever a task ran via the threaded fallback path in `app/tasks/base.py::run_async_task` (used under `CELERY_TASK_ALWAYS_EAGER=true` or when `.delay()` is called from inside an already-running event loop). Fixed by explicitly capturing `contextvars.copy_context()` and running the executor call through it - see the comment in `run_async_task`. Worth knowing about if a *new* thread-hopping code path is added elsewhere in the task layer; the same gap would reappear unless it goes through the same helper.
|
||||||
|
- **Data retention only covers `SourceDocument`.** `Snapshot`, `DetectedChange`, `Report`, `Alert`, and `NotificationDelivery` all persist indefinitely regardless of `DATA_RETENTION_DAYS` - a deliberate scope decision (see `SourceDocumentRepository.delete_older_than`'s docstring and the FK-cascade risk it documents), not an oversight, but it does mean actual storage growth from those tables is unbounded in a long-running deployment. Extending retention to them safely would need cascade-aware deletion (e.g. only delete a `DetectedChange` if no `Alert` references it) rather than a blind age-based purge.
|
||||||
|
- **No admin/ops endpoint to trigger the retention purge on demand** - it only runs on the Celery Beat schedule (3am UTC daily). Verified live by invoking `app.tasks.maintenance.purge_expired_data.delay()` directly against the Docker stack rather than through an HTTP route.
|
||||||
|
|
||||||
|
## Company discovery & provider completion (Phase 11)
|
||||||
|
|
||||||
|
- **`BraveSearchProvider` is now verified live** - the user supplied a real `BRAVE_SEARCH_API_KEY` and the discover flow was driven through the browser against the live Docker stack; all real `api.search.brave.com` queries (official website, headquarters, competitors, "formerly known as") returned `200 OK` with real result data. This is the one external provider in the whole app that has actually been confirmed working against its real API, not just unit-tested with the HTTP layer mocked.
|
||||||
|
- **`GeminiLLMProvider`'s structured-output schema had a real bug, found and fixed via this live verification**: `CompanyProfileExtraction.public_identifiers` was originally `dict[str, str]`, which Pydantic turns into an open-ended (`additionalProperties`) JSON schema - the Gemini *Developer* API (free-tier key auth, as opposed to Vertex AI Enterprise mode) rejects that shape outright with `ValueError: additionalProperties is only supported in Gemini Enterprise Agent Platform mode`. This is exactly the class of bug a mocked-SDK unit test cannot catch, since the mock never runs the real schema-conversion code path. Fixed by changing the field to `list[PublicIdentifier]` (a fixed `{key, value}` object shape) in `app/prompts/company_profile.py`, with `discovery_service.py` converting the list back to a `dict[str, str]` for the (unaffected) public API response shape - see `ARCHITECTURE.md`. **Any future Gemini-targeted structured-output schema must avoid free-form `dict`/`Mapping` fields** for this same reason.
|
||||||
|
- **After that fix, a real `GEMINI_API_KEY` still could not be verified end-to-end** - Google returned `429 RESOURCE_EXHAUSTED` with `limit: 0` for both `generate_content_free_tier_requests` and `generate_content_free_tier_input_token_count` on `gemini-2.0-flash`. A `limit: 0` (not "quota exceeded from usage") typically means the free tier isn't actually active for that API key's Google Cloud project - e.g. the Generative Language API needs to be explicitly enabled in Cloud Console, or the AI Studio account needs to complete a one-time terms/region check - rather than anything this app's code can work around. If you hit this: check [ai.dev/rate-limit](https://ai.dev/rate-limit) for that project, confirm the key was generated from an AI Studio project with the free tier actually enabled, and retry - the request/response plumbing itself (schema, retries, error surfacing) is confirmed correct up to the point Google's API rejects the call. The endpoint fails gracefully either way: `POST /companies/discover` returns a clean error response and the wizard shows "Something went wrong. Please try again." rather than crashing.
|
||||||
|
- **`MockSearchProvider`'s domain guess is a plausibility heuristic, not a real lookup** (`app/search/mock.py::_guess_domain` - lowercases the name and strips all non-alphanumeric characters, then appends `.com`). For a real company name this often happens to resolve to that company's actual site; for a fictional/test name it can resolve to an unrelated real domain (observed live: `"Acme Mobility"` → `acmemobility.com`, a real parked/for-sale domain). This is expected mock behavior, not a bug - the mock is explicitly documented as synthetic/dev-only, and the monitoring pipeline correctly handles the resulting real-world 0-evidence case by finishing the run with `status=failed` rather than crashing.
|
||||||
|
- **The discovery endpoint's LLM extraction quality is entirely a function of `MockLLMProvider`'s regex heuristics under the sandbox default** (`app/analysis/llm/mock.py::_build_company_profile`, e.g. `_HQ_RE`/`_FORMERLY_RE`) - it only fills in `headquarters`/`aliases` when the fetched evidence text happens to contain a matching phrasing ("headquartered in...", "formerly known as..."). Real company websites frequently don't phrase things this way, so most discovered profiles will legitimately come back with several blank fields under mock providers - this is why the Review step's blank-field placeholders ("Not found - fill in") are load-bearing UI, not just cosmetic. A real `LLM_PROVIDER` (anthropic/gemini/ollama) with real search evidence would fill these in far more often.
|
||||||
|
- **No confidence indicator per discovered field.** The wizard shows *that* a field came back blank (via placeholder text) but doesn't distinguish "the LLM was highly confident" from "the LLM guessed from a weak snippet" for fields that were filled in - deliberately deferred (see the plan's out-of-scope section) to avoid scope creep; `sources_consulted` gives basic transparency into what evidence existed without a full confidence UI.
|
||||||
|
- **Discovery is a real, metered API call** (one `SearchProvider.search()` per targeted query plus one `LLMProvider` call) **and has no per-user quota beyond the flat `5/minute` rate limit** on `POST /companies/discover` - a user re-running discovery repeatedly (e.g. tweaking hints and re-submitting) re-pays the full search+LLM cost each time rather than there being any caching/memoization of prior discovery calls for the same name.
|
||||||
|
- **`_resolve_official_website` skips a fixed list of reference/social hosts (Wikipedia, LinkedIn, Crunchbase, etc.) when picking from real search results, but only when a better-ranked alternative exists in the same result set** (`app/services/discovery_service.py::_NON_CORPORATE_HOSTS`). Found live: Brave's top result for `"Stripe official website"` was Stripe's Wikipedia article, not `stripe.com`; taking it at face value fed the wrong base domain into every downstream source-preview collector (observed: a broken `en.wikipedia.org/careers` URL in the "sources to monitor" preview). Fixed by preferring the first non-reference-host result when one is present in the top 3, falling back to the top result otherwise (a genuinely obscure company with only a Wikipedia page as a real hit should still resolve to it, not `None`). This is a heuristic over a fixed hostname list, not a general solution - a well-known company whose actual corporate site is itself hosted on one of these domains, or whose only real presence is a listed host not in `_NON_CORPORATE_HOSTS`, wouldn't be caught by this fix.
|
||||||
|
|
||||||
|
## UI polish & bug-fix round (Phase 12)
|
||||||
|
|
||||||
|
- **Competitor cross-linking matches by exact (case-insensitive) company name only** (`apps/web/app/(app)/companies/[id]/page.tsx::monitoredByName`) - it does not check the target company's aliases, nor does it do fuzzy/partial matching. A competitor listed as `"PayPal, Inc."` would not match an already-monitored company named plain `"PayPal"`, and would incorrectly route to the wizard's "add new" path instead of the existing company. Since competitor names come from LLM extraction (or user-typed hints), this can happen whenever the discovered/typed competitor name doesn't exactly match the monitored company's `name` field. Worth revisiting if this becomes a frequent papercut - matching against `aliases` too, or a normalized/fuzzy comparison, would close most of the gap.
|
||||||
|
- **The "generate report before any evidence exists" case is now flagged with a warning, not prevented.** The Latest Report tab shows an inline notice when `runs.length === 0`, but "Generate report now" is never actually disabled - a user can still click through and get a thin, evidence-free (but honestly-labeled) report. This is intentional: there's no strong reason to hard-block report generation, just to make sure the user understands what they're about to get before they ask for it.
|
||||||
|
- **The new Snapshots tab has no pagination** (`GET /companies/{id}/snapshots` caps at 50, newest-first, same pattern as monitoring-run history) - for a company with a long monitoring history across many sources, older snapshots beyond the cap are simply not shown. No UI affordance to page past the cap yet; would need one if 50 turns out to be too few for real usage patterns.
|
||||||
|
- **Snapshot text/structured summaries render as plain text/JSON, not a diff view.** The Snapshots tab shows what a snapshot *contained*, not what *changed* between it and the previous snapshot for the same source - that comparison already exists internally (`DetectedChange` rows, shown via Alerts/Monitoring history) but isn't cross-linked from a snapshot row to the `DetectedChange` it produced (if any). A "view what changed from the prior snapshot" link would be a natural follow-up.
|
||||||
|
|
||||||
|
## Second UI polish & bug-fix round (Phase 13)
|
||||||
|
|
||||||
|
- **Duplicate-name detection is a client-side heuristic, not authoritative.** `findPossibleDuplicate` (`apps/web/app/(app)/companies/new/page.tsx`) strips punctuation and common legal suffixes (Inc/LLC/Corp/etc.) then checks exact or substring match against the user's *currently-loaded* company list - it won't catch a genuinely different-looking name for the same company (e.g. "Facebook" vs "Meta"), and a slow/failed `useCompanies()` fetch means the check silently finds nothing rather than blocking. The backend's `_unique_display_name` is the actual hard guarantee (uniqueness per user, filesystem-style auto-suffix) - the frontend warning is a courtesy to catch obvious cases *before* paying for a real search+LLM discovery call, not a substitute for it.
|
||||||
|
- **Notification-destination company-linking requires at least one company to exist before a destination can be created** (`company_ids` has `min_length=1` in `NotificationDestinationCreate`) - a brand-new user with zero companies can't pre-register a notification destination; the Settings page's Add form correctly reflects this ("Add a company first…") but it's a real ordering constraint, not just a UI nicety.
|
||||||
|
- **The one-time migration's dedup step is lossy for `NotificationDelivery` history**: when two destination rows shared the same (user, type, value), the newer duplicate(s) were deleted and their delivery history cascaded away with them (see `TASKS.md` Phase 13 and the migration's own docstring, `60a25ddfc6a3`). Acceptable for this app's current scale/stage - delivery history isn't relied on for anything beyond the Alert detail view - but worth knowing if delivery audit history ever becomes load-bearing.
|
||||||
|
- **`Company.notification_links` and `NotificationDestination.company_links` both need explicit ORM `cascade="all, delete-orphan"` (not just the DB-level `ON DELETE CASCADE` in the migration) because SQLite - used for local dev and the entire test suite - doesn't enforce foreign-key constraints without an explicit `PRAGMA foreign_keys=ON` this app doesn't set.** This was caught before it became a real bug (tests exercise the SQLite path and would have caught silent cleanup failures), but it's a sharp edge worth remembering for any *future* cascade-delete relationship added to this codebase: an `ondelete="CASCADE"` in the migration alone is not sufficient, the ORM relationship needs its own cascade declaration and the parent object needs the child eager-loaded before deletion (see `CompanyRepository._with_relations`'s comment).
|
||||||
|
- **Report generation's new `company_profile` evidence block has no per-field confidence/staleness signal.** If a company's discovered profile turns out to be wrong (e.g. a bad search result at onboarding time) or goes stale (the company rebrands, moves HQ), the report will confidently repeat that stale/wrong fact indefinitely, since there's no re-verification step and no way for the report prompt to know the profile data's age or original confidence. Editing the company's profile fields (Configuration tab) is the only way to correct this today.
|
||||||
|
|
||||||
|
## First UI polish & bug-fix round (Phase 12)
|
||||||
|
|
||||||
|
- **Competitor cross-linking matches by exact (case-insensitive) company name only** (`apps/web/app/(app)/companies/[id]/page.tsx::monitoredByName`) - it does not check the target company's aliases, nor does it do fuzzy/partial matching. A competitor listed as `"PayPal, Inc."` would not match an already-monitored company named plain `"PayPal"`, and would incorrectly route to the wizard's "add new" path instead of the existing company. Since competitor names come from LLM extraction (or user-typed hints), this can happen whenever the discovered/typed competitor name doesn't exactly match the monitored company's `name` field. Worth revisiting if this becomes a frequent papercut - matching against `aliases` too, or a normalized/fuzzy comparison, would close most of the gap.
|
||||||
|
- **The "generate report before any evidence exists" case is now flagged with a warning, not prevented.** The Latest Report tab shows an inline notice when `runs.length === 0`, but "Generate report now" is never actually disabled - a user can still click through and get a thin, evidence-free (but honestly-labeled) report. This is intentional: there's no strong reason to hard-block report generation, just to make sure the user understands what they're about to get before they ask for it.
|
||||||
|
- **The new Snapshots tab has no pagination** (`GET /companies/{id}/snapshots` caps at 50, newest-first, same pattern as monitoring-run history) - for a company with a long monitoring history across many sources, older snapshots beyond the cap are simply not shown. No UI affordance to page past the cap yet; would need one if 50 turns out to be too few for real usage patterns.
|
||||||
|
- **Snapshot text/structured summaries render as plain text/JSON, not a diff view.** The Snapshots tab shows what a snapshot *contained*, not what *changed* between it and the previous snapshot for the same source - that comparison already exists internally (`DetectedChange` rows, shown via Alerts/Monitoring history) but isn't cross-linked from a snapshot row to the `DetectedChange` it produced (if any). A "view what changed from the prior snapshot" link would be a natural follow-up.
|
||||||
|
|
||||||
|
## New intelligence sources & per-source scheduling (Phase 14)
|
||||||
|
|
||||||
|
- **`PatentSourceCollector`'s real USPTO branch is now live-verified (Phase 15), and the live pass found real schema bugs that mocked tests couldn't catch** - fixed: the sort field needed to be `applicationMetaData.filingDate`, not `filingDate` (was causing a hard `500`); `inventionTitle`/`filingDate`/`abstractText` all live under `applicationMetaData`, not at the entry's top level (was producing "Untitled patent filing" with no dates for every real result); USPTO returns `404` for "no matching records" rather than `200` with an empty array, now treated as an honest empty `ACTIVE` result instead of a `FAILED` one (same non-error empty-result precedent as `GovContractCollector`).
|
||||||
|
- **Querying USPTO by assignee/company name is not supported by this endpoint at all** - confirmed by inspecting a real response's full field list (for a query that *did* return 110k+ real results by inventor name), which contains no assignee/organization field anywhere. USPTO's Patent Application Search reliably supports inventor-name and application-number lookups only. **Worked around, not fixed**: `PatentSourceCollector.collect()` now searches USPTO by each of the company's known leadership names instead (from NinjaPear enrichment, `CompanyContext.leadership_names`, threaded through in `collection_service.to_company_context`) - live-verified against Stripe, returning 19 real patent documents by searching its executives' names. This is a real, working signal, but a heuristic one: **there is no way to confirm a patent found this way actually belongs to the monitored company** (vs. a same-named person, or work done at a prior employer) - every such document is trust-scored at 0.5 (vs. what a verified-assignee match would warrant) and its content explicitly states which leadership name it matched on, so the report LLM's confidence labeling reflects the uncertainty rather than treating it as confirmed fact. Two real constraints this implies: (1) a company gets zero patent results until NinjaPear enrichment has actually completed and found a leadership team (no `NINJAPEAR_API_KEY` configured means patents stays empty too, even with a valid USPTO key) - self-heals on the next scheduled collection once enrichment finishes, since leadership names are re-read from the DB on every run, not cached at discovery time; (2) capped at the first 5 leadership names per company (`_MAX_INVENTOR_SEARCHES` in `app/collectors/patents.py`) to bound the number of USPTO calls per run - USPTO itself is free/keyless, so this cap is about politeness and run time, not cost.
|
||||||
|
- **`GovContractCollector` always reports a source as discoverable for every company**, including obviously-private ones - matching `SecEdgarCollector`'s existing precedent for non-public companies. A private company simply gets zero contract results (not an error); there's no attempt to guess whether a company is likely to have federal contracts before offering the source.
|
||||||
|
- **Google News RSS's query is just the company's `name` field, url-encoded** - no disambiguation for a company name that collides with something unrelated (e.g. a common word, or a same-named but different company). A company with a highly generic name will get noisy/irrelevant results; the existing evidence-grounded report generation still won't fabricate claims from noise, but the raw collected documents themselves aren't filtered for relevance at the collector layer.
|
||||||
|
- **Per-source scheduling has no cron/custom-interval UI** - the Sources tab's "Check frequency" `Select` only offers the fixed cadences (hourly through monthly), matching `MONITORING_FREQUENCIES` minus `custom`; the backend (`SourceUpdate` schema, `validate_and_compute_next_run`) fully supports a per-source `custom` override with `interval_minutes`/`cron_expression`, but there's no frontend control to set one - would need the same extra interval/cron inputs the company-level Configuration tab doesn't have either (it also has no dedicated custom-schedule UI beyond raw form fields).
|
||||||
|
- **A newly-set or newly-cleared per-source override resets `next_check` to `None`, meaning "due on the very next scheduler tick"** rather than computing a real future `next_check` immediately (unlike the company-level `MonitorConfiguration`, which does recompute `next_run` immediately on a schedule change). This is a deliberate choice to match how a brand-new source already behaves (checked ASAP), but it does mean setting a slow cadence (e.g. monthly) on a source still triggers one immediate check before the monthly cadence actually takes effect - there's no way to say "start counting from now, don't check immediately."
|
||||||
|
- **The scheduler's due-ness check queries every enabled `MonitorConfiguration` and then does a Python-side per-company source scan** (`SourceRepository.company_has_due_work`, called once per enabled company per Beat tick) rather than a single SQL query joining across companies and sources. Fine at this app's scale (a personal/small-team tool with a handful to dozens of companies); would need to move the OR-with-NULL-fallback due-ness logic into SQL if the company count grew large enough for this to become a real per-tick cost.
|
||||||
|
|
||||||
|
## NinjaPear company enrichment (Phase 15)
|
||||||
|
|
||||||
|
- **Live-verified against a real NinjaPear account and a real company (Stripe)** - and the initial implementation, written from a scraped/summarized reading of the JS-rendered `nubela.co/docs`, had several real schema bugs the live pass caught and fixed: identity is `website`-only (there is no name-based company lookup at all - a company with no `official_website` now fails fast with a clear error instead of attempting doomed calls, see `enrichment_service.enrich_company`'s upfront guard); `employee_count` and `industry` come back as raw numbers, not strings; funding's `investors` are objects (`{name, type, website}`), not plain strings; response field names throughout differ from the initial guesses (`executives` not `leadership_team`, `total_funds_raised`/`funding_rounds`/`round_type` not `total_raised`/`rounds`/`round_name`, `competitors[].website`/`competition_reason` not `name`/`reason`, `x_profile_url` not `profile_url`, work-email/profile lookups take `first_name`/`last_name`/`domain` not a single name string); and the credit-balance endpoint is `/api/v1/meta/credit-balance` returning `credit_balance`, not `/company/credit-balance` returning `balance`. All fixed and re-verified live - a real run against Stripe returned real leadership bios, work emails, X profiles, funding history, competitors-with-reasons, live blog updates, and inferred customers, landing on `status: partial` (one funding-parsing bug, since fixed, and one legitimate 404 for a board member not in NinjaPear's database) for 34 real credits.
|
||||||
|
- **Credit-cost tracking (`CompanyEnrichment.credits_spent`, the Settings-page balance display) is an estimate, not billing-accurate.** Per-call costs in `enrichment_service._CREDIT_COSTS` are transcribed from NinjaPear's published pricing page at a point in time and don't account for per-item add-on charges NinjaPear may apply (e.g. funding's "+1 per investor," customer listing's "+2 per company returned") - these are approximated as flat per-call costs. Useful for a rough sense of spend, not a source of truth; NinjaPear's own dashboard/credit-balance endpoint is the actual authority (which is why `/system/status` also surfaces the real balance separately, not just the estimated spend).
|
||||||
|
- **The "Similar People" endpoint is not wired in at all** - it's a role/company-anchored prospecting tool ("find people like X at competitor Y") with no natural trigger at onboarding time, since no specific role is ever selected. Would need its own UI (e.g. "find people like this one" on a leadership-team entry) to make sense, not an automatic onboarding call.
|
||||||
|
- **NinjaPear's suggested competitors are never merged into `Company.competitors`** (the user-typed/reviewed list that drives Phase 12's competitor cross-linking) - shown only in the Enrichment tab, with their stated reasons, kept deliberately separate so a background API call can never silently change what the user explicitly reviewed. A "add as competitor" action would be a reasonable follow-up if this becomes a papercut.
|
||||||
|
- **Person-level lookups (work email, profile) are capped at `NINJAPEAR_MAX_LEADERSHIP_LOOKUPS` (default 5) per company**, applied to however many leadership members NinjaPear's Company Details call happens to return, in whatever order it returns them - there's no ranking by seniority/relevance before the cap is applied, so for a company with a large leadership team, which specific people get resolved is effectively arbitrary.
|
||||||
|
- **No re-enrichment mechanism** - by design (see Phase 15's Context in `TASKS.md`, onboarding-only was the explicit scoping decision to bound cost), but it does mean `CompanyEnrichment.data` can go stale indefinitely (a funding round happens, a leadership change occurs) with no way to refresh it short of manually deleting the row and re-triggering via the API/DB directly - there's no "re-enrich this company" button anywhere in the UI.
|
||||||
|
- **`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)
|
||||||
|
|
||||||
|
- **Localhost detection (`_is_localhost` in `apps/api/app/api/v1/system.py`) does not work as intended under Docker Desktop for Windows/Mac.** It checks `request.client.host in ("127.0.0.1", "::1")` - correct and sufficient for a bare-metal/non-Dockerized deployment, or Docker on native Linux with `network_mode: host`. But under Docker Desktop's default networking (this project's actual `docker compose up` setup on Windows), every published-port connection - whether it originates from the host machine itself (`curl localhost:8000`) or from another machine on the LAN - gets NATed through `docker-proxy` and arrives at the API container sourced from the bridge network's gateway address (observed live: `172.18.0.1`), never from a literal loopback address. Live-verified: a `curl http://localhost:8000/api/v1/system/status` run directly on the Windows host still reports `is_localhost: false`. This was a deliberate fail-closed choice once discovered mid-implementation, not an oversight left unfixed: the alternative (treating the bridge gateway IP as "trusted local") would be actively insecure, since a genuine LAN neighbor hitting the same published port is indistinguishable from the host machine at that same gateway IP - Docker's NAT erases the distinction this feature exists to draw. (The Settings page's old admin-only API Keys box, gated on this check alone, has since been replaced by per-user API keys plus an admin-only-but-not-localhost-gated "Server secrets" box - see the addendum on that below - so this finding's practical impact today is narrower: `ADDITIONAL_TRUSTED_LOCAL_IPS`, see the Phase 19 addendum, is the intended opt-in fix for genuinely-local dev traffic that needs to look local.)
|
||||||
|
- **NinjaPear competitor/customer `name` fields are provider-supplied and inconsistent in shape** - sometimes a bare URL (`https://paypal.com`), sometimes already a clean name. `companyNameFromUrl` (`apps/web/lib/format.ts`) does a best-effort strip-and-title-case on anything URL-shaped; there's no verification the derived label (e.g. "Staxpayments" from `staxpayments.com`) matches the company's actual public branding (e.g. "Stax Payments") - it's a formatting heuristic, not a lookup against real company names.
|
||||||
|
- **The Redis-backed log capture (`apps/api/app/core/logging.py`, `app:logs` list, capped at 500) is best-effort and ungated by user/tenant** - any authenticated user of this single-tenant app can see every log line the whole application produced (across all companies, all users, if this app were ever extended to multi-tenant), including other users' company names and error details in the `context` field (secrets are redacted by `_redact_secrets`, but business data isn't). Fine for this app's current single-operator scope; would need per-tenant log scoping before this pattern could ship in a real multi-user product. It's also a plain capped list with no admin-facing way to clear/filter it beyond what's newest, and a Redis flush (`FLUSHDB`/restart) silently empties the whole feed with no persistence.
|
||||||
|
- **The four API-key-status categories (`internal_error`/`api_error`/`important`/`normal`) are derived purely from structlog level** (`error`/`critical` → red, `warning` → yellow, `info` with an explicit `important=True` kwarg → blue, else white) - this is a coarse mapping, not a semantic classification. A `logger.warning(...)` call that isn't actually about an external API failure (there are a few in this codebase that aren't) still shows up labeled "API error"; nothing currently opts into `important=True`, so the blue category has no real entries yet until a call site is deliberately updated to pass it.
|
||||||
|
|
||||||
|
## Per-connection auth: loopback stays free, everyone else logs in (Phase 18)
|
||||||
|
|
||||||
|
- **Under Docker Desktop's networking, the developer also sees the login screen.** `get_current_user` (`apps/api/app/auth/dependencies.py`) now only hands out the fixed "Local Developer" account when both `AUTH_MODE=local` *and* the request's actual TCP peer is literal loopback (`app.core.security.is_localhost`, `127.0.0.1`/`::1`). Per the Phase 17 finding this same check was built for (API-key visibility), Docker Desktop's port-publishing NATs every request - including the host machine's own browser hitting `localhost:3000` via the Docker-mapped port - through the bridge gateway IP, never literal loopback. That means **running this app via `docker compose` (the documented setup) requires a real registered account for everyone, including the person running the stack**, not just LAN/WAN visitors. This is the correct, secure outcome (the alternative - trusting the Docker bridge IP as "local" - would let a LAN neighbor bypass login too, since they'd be indistinguishable from the host at that IP) but is worth knowing going in: the fastest way to get the zero-login convenience back for local development is running the API directly (`uvicorn app.main:app`, bypassing Docker's NAT hop entirely) rather than through `docker compose`.
|
||||||
|
- **Accounts are fully data-isolated per user** (existing behavior, unchanged by this phase - every `Company`, `NotificationDestination`, etc. has always belonged to a `user_id`) - a new WAN/LAN visitor who registers starts with zero companies, not a copy of the "Local Developer" account's existing demo data (Stripe, Nvidia, the Acme Mobility fixture, etc.). There's no account-linking or data-migration path from the local-dev user to a real account.
|
||||||
|
- **The password-strength meter on the register page is a purely client-side visual affordance**, not a second source of truth - it scores length/case/symbol variety heuristically to give live feedback, but the actual enforced policy is still just the existing Zod schema (10+ characters, at least one letter and one digit) mirrored server-side by `RegisterRequest`'s validators. A password can show as "Fair" or "Good" on the meter and still be the minimum-viable accepted password; the meter never blocks or requires more than the schema does.
|
||||||
|
|
||||||
|
## Email verification, escalating lockout/ban, Turnstile, split logging (Phase 19)
|
||||||
|
|
||||||
|
- **`get_client_ip`'s `trusted_proxy_ip_header` must be configured correctly before this app ever sits behind Cloudflare (or any reverse proxy), or IP-based throttling/banning breaks in one of two ways.** Today (`TRUSTED_PROXY_IP_HEADER=` unset), every consumer - `ip_throttle_service`, `is_localhost`, the unban-request cooldown - reads `request.client.host` directly, which is correct only when nothing sits between the client and this app. Once deployed behind Cloudflare's proxy, every request's `request.client.host` becomes Cloudflare's edge IP, not the real visitor's - unset, this either bans/throttles *everyone* behind Cloudflare together as if they were one IP (one abusive visitor locks out every legitimate one), or - if Cloudflare's IP itself gets banned - locks out the entire app. Setting `TRUSTED_PROXY_IP_HEADER=CF-Connecting-IP` fixes this by reading the real visitor IP Cloudflare forwards - but this header must **only** be trusted once it's actually known the proxy is in front (never trust a client-suppliable header blindly); this is a deploy-time config change, not something the code can safely auto-detect.
|
||||||
|
- **A real production bug was found and fixed during this phase, not just a test artifact**: the original implementation called `ip_throttle_service.record_attempt(..., RESEND_VERIFICATION, ...)` unconditionally on every `POST /auth/register`, intending to start the "first resend allowed in 30s" cooldown immediately. Since IP-level throttling is deliberately IP-scoped (not account-scoped, so one abusive account can't be worked around by re-registering), this meant **every registration from a shared IP counted against the same ladder** - an office, a NAT'd household, or (as caught by the backend test suite sharing one fake IP across ~200 tests) any high-volume signup source would eventually exhaust the resend ladder, escalate through all 6 timeouts, and permanently ban that shared IP, taking down registration/login/reset for everyone behind it. Fixed by removing that call entirely: the escalation ladder for `resend_verification` now only starts from the first *manual* resend click (`POST /auth/resend-verification`), and "first resend allowed in 30s" is enforced client-side only (a UI cooldown) for the very first send, not backend-enforced. This is a real, intentional deviation from the original plan's literal wording ("first resend should be allowed in 30 seconds" was originally read as governing the very first send; it now governs resend #2 onward).
|
||||||
|
- **IP-level throttling/banning is IP-scoped by design, which has the flip side of the bug above**: a genuinely malicious actor sharing a NAT/VPN egress IP with legitimate users can still get that whole shared IP banned through repeated failed logins or resend/reset spam against real accounts - `record_attempt` on `failed_login`/`resend_reset` is still called for every attempt, since those actions can't skip IP-level protection without losing the abuse-prevention this feature exists for. `TRUSTED_PROXY_IP_HEADER` (see above) narrows the blast radius once real per-visitor IPs are available; a corporate/university NAT with no such header will always share fate at this layer, and the manual `/unban-request` flow is the intended (if annoying) escape hatch.
|
||||||
|
- **Account lockout and email verification are both keyed by account (`User.email_verified`/`User.locked_at`), not IP** - a locked or unverified account stays locked/unverified from *every* IP until the user resets their password or verifies their email, regardless of which IP most recently triggered the lockout. Only the throttle/ban layer (`ip_throttle_state`/`ip_bans`) is IP-scoped; these are two independent dimensions that happen to escalate in lockstep for the specific case of `LOGIN_BACKOFF_SECONDS`/`LOGIN_LOCKOUT_THRESHOLD` (both derived from the same 11-stage array, see `auth_service.py`), but a determined attacker rotating IPs against one specific account is still stopped by the account-level lock, not just the (bypassable-by-IP-rotation) IP throttle.
|
||||||
|
- **Security transactional email (verification codes, reset codes, lockout notices) picks the Resend HTTP API when `RESEND_API_KEY` is set, else falls back to the SMTP provider** (`security_email_service.py`) - a misconfigured/expired `RESEND_API_KEY` in production silently degrades to attempting SMTP instead (which will itself fail loudly if `SMTP_HOST` isn't configured either) rather than surfacing a specific "Resend is broken" signal anywhere in the UI - failures are logged (`get_logger`) but not otherwise surfaced to the registering/resetting user beyond the generic success message the enumeration-safe endpoints always return.
|
||||||
|
- **The bundled local Mailpit container was removed** (originally added in Phase 8/9 as a zero-config local mail sink for alert email, and reused by Phase 19's security email as the no-Resend-account fallback) - the project's real `.env` had already been pointed at Resend's own SMTP relay for both alerts and security email, making Mailpit dead weight in practice (never actually reached by the running app; the ~500 stray messages found in it during Phase 19 testing turned out to be an unrelated side effect of running the local `pytest` suite outside Docker, which defaults `SMTP_HOST` to `localhost` and happened to reach Mailpit's host-published port). Net effect: there is no bundled way to test real email delivery locally anymore without a real SMTP relay or a real Resend account - `.env.example`'s SMTP block now ships blank rather than pointing at a fake `mailpit` hostname. The unban-request admin notification (`unban_service.py`) also moved off a fixed `admin_notification_email` setting onto **every account with `is_admin=True`** (`UserRepository.list_admin_emails`) - multiple admins now all get notified, and the previous fake `[email protected]` default (which only ever worked because Mailpit intercepted it) is gone.
|
||||||
|
- **Turnstile enforcement could not be live-verified by this agent** - Cloudflare Turnstile is explicitly designed to be unsolvable by automation, and this dev environment (Docker) means even the agent's own browser-tool traffic isn't loopback (see the Phase 17/18 Docker-NAT finding above), so the widget is always presented once a `turnstile_secret` is configured (via `.env` or the admin-set override, see the system-secrets addendum below). Full live verification (register/login/forgot-password through the actual widget, plus a real Resend-delivered code if desired) requires a human to solve the challenge - tracked as the final Phase 19 verification step.
|
||||||
|
- **The public `POST /unban-requests` endpoint has no Turnstile/CAPTCHA of its own** - only a flat `3/minute` rate limit (`slowapi`) and a 24-hour per-IP cooldown enforced at the DB level (`UnbanRequestRepository.within_cooldown`). An IP that is *not yet* banned could still be used to spam the admin's inbox up to the rate limit before the cooldown kicks in on the second request; low-impact (each request is a single email + one log line, and the cooldown caps sustained abuse to one message per IP per day) but a deliberate scope decision, not an oversight - adding Turnstile here would require serving the challenge to a possibly-already-banned visitor, which is awkward given Turnstile itself is skipped for banned/localhost callers elsewhere in this app's model.
|
||||||
|
- **`GET /auth/security-events` is capped at the 100 most recent events per user** (`UserSecurityEventRepository.list_for_user`, hardcoded `limit=100`) with no pagination - a long-lived, frequently-logged-in account will eventually have older events silently fall off the visible list. No admin-facing purge/retention policy exists for this table either (unlike `SourceDocument`'s `DATA_RETENTION_DAYS`, see the Phase 10 section above) - it grows unboundedly in the DB even though only the newest 100 rows are ever shown.
|
||||||
|
- **`IpBan.reason` is a short fixed string naming which action type triggered the ban** (`resend_verification`, `resend_reset`, `failed_login`, or `manual_admin_ban` for an admin-initiated ban - see below) rather than a detailed forensic record (no timestamp history of the individual attempts that led to it, no association with which account(s) were being targeted) - sufficient for the admin Settings-page IP Bans panel to show *why*, not a full audit trail. The `user_security_events` table is the closer thing to an audit trail, but it's scoped per-account, not per-IP, so correlating "this IP got banned" with "these were the accounts it was hammering" requires manually cross-referencing `ip_address` across both tables today.
|
||||||
|
- **Admins can now ban an IP directly** (`POST /admin/ip-bans`, the Settings page's "Ban an IP manually" field) - bypasses the offense-count escalation ladder entirely (`ip_throttle_service`), a deliberate manual override rather than something the automated abuse-detection path produces. Rejects an already-banned IP with 409 rather than silently no-oping, and validates the address is a real IPv4/IPv6 literal (422 otherwise) - it does not accept CIDR ranges or hostnames.
|
||||||
|
- **Unban requests now have real Accept/Reject actions** (`POST /admin/unban-requests/{id}/accept`, `DELETE /admin/unban-requests/{id}`) instead of being a read-only queue an admin had to separately go find the IP in the bans list to act on. Accept unbans the IP (full pardon - clears throttle state too, not just the ban row) and removes the request; Reject removes the request without touching the ban, so the IP stays blocked. Either way the request is gone from the pending list afterward - there's no "resolved but kept for history" state, so once acted on, a request leaves no trace of the decision beyond whatever happened to the ban/throttle rows themselves.
|
||||||
|
- **`ADDITIONAL_TRUSTED_LOCAL_IPS` (`app.core.security.is_localhost`) is a narrow, opt-in escape hatch for the Docker-NAT findings above** - a comma-separated list of extra IPs treated as loopback-equivalent, on top of real `127.0.0.1`/`::1`. Added because Cloudflare Turnstile crashes both the Browser pane and the Claude Code process itself when it renders (observed live; the widget is designed to be unsolvable by automation, and this failure mode is worse than a stuck challenge), so an agent's own browser-tool traffic against `localhost:3000`/`8000` needs `is_localhost` to actually resolve true to avoid ever rendering it - matching how a real developer running the stack via `docker compose` would want Turnstile skipped too. Left empty by default (strict). This project's `.env` sets it to the observed Docker bridge gateway IP (`172.18.0.1`) for local dev. **This must never be set in a real deployment** - unlike `TRUSTED_PROXY_IP_HEADER` (which reads a header a trusted proxy controls), this is a flat IP allowlist; if that IP is ever reachable by anyone other than the actual host machine (e.g. a misconfigured network, or a bridge subnet shared with untrusted containers), it would incorrectly grant them the same loopback-only privileges (Turnstile bypass, and - if `AUTH_MODE=local` - the fixed local-dev-user auto-login too, see the Phase 18 section above).
|
||||||
|
|
||||||
|
## Admin-managed server secrets: Turnstile site key/secret move off .env
|
||||||
|
|
||||||
|
- **The old admin-only, localhost-only "API keys" Settings box (`/system/api-keys`, Phase 17) has been removed entirely**, replaced by two independent mechanisms with a deliberately different visibility model each: per-user API keys (Anthropic/Brave/NinjaPear/USPTO - every user manages their own, no admin/localhost gate at all, since each user only ever sees their own value) and a new admin-only (but **not** localhost-gated) "Server secrets" box for values that are genuinely global rather than per-user - today just the Cloudflare Turnstile site key and secret. `SystemSecret` (`app/models/system_secret.py`) is a true singleton-per-key table (one row per `SystemSecretKey`, not scoped to any user), encrypted at rest the same way as `UserApiKey`, with the identical fallback-to-`.env`-when-unset pattern via `system_secret_service.get_effective_settings`.
|
||||||
|
- **The Turnstile site key is no longer delivered to the frontend via a `NEXT_PUBLIC_*` build-time env var.** It used to be baked into the Next.js bundle at container-build time (`NEXT_PUBLIC_TURNSTILE_SITE_KEY`), which meant an admin-updated value could never take effect without a full frontend rebuild - defeating the point of making it admin-editable. It's now served live by the already-public, unauthenticated `GET /system/status` endpoint (`turnstile_site_key`, resolved through the same effective-settings fallback), and `TurnstileWidget` takes it as a prop instead of reading `process.env` directly. This does mean the site key round-trips through one more network hop (an extra field on a call the login/register pages already make) rather than being inlined - a negligible cost for a value that isn't itself secret.
|
||||||
|
- **`system_secrets` rows are true global singletons with no natural per-test isolation** (unlike `user_api_keys`, which is naturally isolated by a randomized per-test `user_id`) - `test_system_secrets.py` has to explicitly clear the table before and after every test via an autouse fixture to avoid cross-test pollution within a full-suite run. Worth remembering if this pattern is extended to more server-wide keys later: any new key added to `SystemSecretKey` inherits the same test-isolation requirement.
|
||||||
|
- **The real `.env` `TURNSTILE_SITE_KEY`/`TURNSTILE_SECRET` values were copied into this DB-backed storage** (both now `configured: true` via the admin Settings box) rather than removed from `.env` outright - the user plans to purge the `.env` values manually once satisfied the DB-backed path works, so both currently agree and nothing changes behaviorally either way. `NEXT_PUBLIC_TURNSTILE_SITE_KEY` specifically *was* removed from `.env`/`.env.example`/`docker-compose.yml`, since nothing reads it anymore (see above) - that one wasn't a "purge later" judgment call, it was genuinely dead as soon as the frontend stopped reading it.
|
||||||
|
|
||||||
|
## Account activity logging expansion: local-dev sign-ins, secret/key updates, known IPs
|
||||||
|
|
||||||
|
- **The local-dev bypass (`AUTH_MODE=local` + loopback) now logs a `login_success` event too**, even though it has no real login step - `get_or_create_local_user` (`app/services/auth_service.py`) runs on every authenticated request for that account, so logging unconditionally would flood Account activity with one event per request. A 30-minute cooldown (`LOCAL_DEV_LOGIN_LOG_COOLDOWN_MINUTES`) treats "no login_success logged for this account in the last 30 minutes" as a proxy for "a new session," rather than trying to detect real session boundaries that don't exist in this bypass. This is a heuristic, not a precise signal - reopening the app twice within 30 minutes shows one sign-in, not two, and an idle tab left open for hours making periodic background requests wouldn't show repeated sign-ins either unless a genuine 30+ minute gap occurs.
|
||||||
|
- **Updating a Server secret (admin) or one of your own per-user API keys now writes a `server_secret_updated`/`api_key_updated` event to Account activity** (`system_secret_service.set_secret` / `user_api_key_service.set_key`, both now require the acting user's id and IP). Deliberately logged under the *acting* user's own account either way - for Server secrets that's whichever admin made the change, not some app-wide "system" pseudo-account - so an admin's Account activity is also a partial audit trail of their own admin actions. **Clearing a key/secret (setting it blank) is logged identically to setting a real value** - the event type doesn't distinguish "set" from "cleared," only the row's own `configured` state (visible via the Settings page, not the event log itself) tells you which happened.
|
||||||
|
- **New `user_known_ips` table captures every distinct IP an account has signed in from** (`app/models/user_known_ip.py`), one row per `(user_id, ip_address)` pair with `first_seen_at`/`last_seen_at`, updated on every recorded sign-in (both real login and the local-dev bypass, gated by the same cooldown above for the latter). This is pure data capture for now - **nothing currently reads this table or surfaces it anywhere in the UI**; it exists as the foundation for a possible future "new device/location" security feature, per explicit request. `UserKnownIpRepository.record_login`'s return value (whether the IP was new) is already threaded through but currently unused by any caller.
|
||||||
|
- **IPs were already being recorded per-login inside `user_security_events`** (every `login_success`/`login_failed` row has always carried `ip_address`) - `user_known_ips` doesn't replace that, it's a deliberately separate, deduplicated view: the security-events log is an append-only history of every attempt, while `user_known_ips` answers "what's the current set of IPs this account has ever used" without needing to scan and dedupe the (much larger, unbounded-growth - see the Phase 19 addendum above) events table.
|
||||||
|
|
||||||
|
Further limitations are appended per-phase below.
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# CI Agent — Project Plan
|
||||||
|
|
||||||
|
## What we're building
|
||||||
|
|
||||||
|
A Competitive Intelligence monitoring web application. Users register (or run in local single-user dev mode), add companies to monitor, describe what they care about, and choose a monitoring schedule. The system collects public information from multiple sources, stores historical snapshots, uses an LLM to analyze evidence into a structured report, detects meaningful changes between runs, scores them for severity and confidence, and notifies the user by email (and optionally SMS). Everything is viewable from a web dashboard.
|
||||||
|
|
||||||
|
## Build strategy
|
||||||
|
|
||||||
|
Given the size of the full specification, we are building in the phased order below. Each phase leaves the application in a runnable state. We start with the vertical slice explicitly called out in the spec (local auth → fixture company → baseline report → fixture change → alert → Mailpit email → dashboard) and expand outward from there.
|
||||||
|
|
||||||
|
1. **Foundation** — monorepo, Docker Compose, FastAPI + Next.js skeletons, health checks, lint/format/test scaffolding.
|
||||||
|
2. **Auth & Users** — local dev-mode auth and JWT auth behind one interface.
|
||||||
|
3. **Company Management** — CRUD, monitoring configuration, dashboard, add-company wizard.
|
||||||
|
4. **Collection Pipeline** — collector interface, SSRF-safe fetching, website/RSS/custom-URL/SEC EDGAR/GitHub collectors, extraction & normalization.
|
||||||
|
5. **Background Processing** — Celery + Beat, dynamic per-company schedules, run-now, retries.
|
||||||
|
6. **Change Detection** — layered hash/structured/text/semantic diffing, significance scoring, severity classification.
|
||||||
|
7. **LLM Analysis** — provider-agnostic interface (Mock/Anthropic/Ollama), six discrete analysis tasks, evidence-linked reports.
|
||||||
|
8. **Notifications** — SMTP/Mailpit, Console, Twilio SMS, delivery tracking.
|
||||||
|
9. **Fixture Demo + Tests** — Acme Mobility Systems fixture company (v1/v2), full automated test suite, Playwright E2E.
|
||||||
|
10. **Hardening & Docs** — rate limiting, structured logging, retention, security review, Firebase migration doc.
|
||||||
|
|
||||||
|
## Non-goals for this pass
|
||||||
|
|
||||||
|
- Live scraping of sources with no reliable free public API (patents, customer reviews, most job boards) — these get a real interface + documented fixture adapter instead of a fabricated live integration, per the spec's own guidance.
|
||||||
|
- Payment/billing, multi-tenant admin console, OAuth social login — not in the spec's MVP.
|
||||||
|
- PDF export — documented as a later enhancement (Markdown/JSON export are implemented).
|
||||||
|
|
||||||
|
See `TASKS.md` for the live checklist, `ARCHITECTURE.md` for system design, `SECURITY.md` for the threat model, and `KNOWN_LIMITATIONS.md` for what's stubbed vs. fully live.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# CI Agent — Competitive Intelligence Monitoring
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Quick start (Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository>
|
||||||
|
cd ci-agent
|
||||||
|
cp .env.example .env
|
||||||
|
docker compose up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
Then visit:
|
||||||
|
|
||||||
|
| Service | URL |
|
||||||
|
|---|---|
|
||||||
|
| Frontend | http://localhost:3000 |
|
||||||
|
| Backend API | http://localhost:8000 |
|
||||||
|
| API docs (Swagger) | http://localhost:8000/docs |
|
||||||
|
|
||||||
|
The default `.env.example` runs in `AUTH_MODE=local` (no login required, fixed dev user) with `LLM_PROVIDER=mock` and `SEARCH_PROVIDER=mock` — the whole demo workflow works with zero paid API keys.
|
||||||
|
|
||||||
|
`api`, `worker`, and `beat` build from the same Dockerfile but are separate images — after adding a Python dependency, run `docker compose build api worker beat` (not just `restart`) or they'll crash with `ModuleNotFoundError` on stale images.
|
||||||
|
|
||||||
|
Similarly, after adding an npm dependency to `apps/web`, a plain rebuild isn't enough either — `web`'s `node_modules` *and* `.next` are both persistent anonymous Docker volumes that survive `docker compose build web` and even a plain `docker compose restart web`. This means a new package can still 404, and — on Windows + Docker Desktop — an edited existing file can keep serving its pre-edit output after a restart, since the on-disk `.next` build cache isn't cleared by a restart. Run `docker compose rm -f -s -v web && docker compose up -d web` to actually pick up new packages or force a clean recompile.
|
||||||
|
|
||||||
|
## Running without Docker
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/api
|
||||||
|
python -m venv .venv
|
||||||
|
./.venv/Scripts/activate # or `source .venv/bin/activate` on macOS/Linux
|
||||||
|
pip install -e ".[dev]"
|
||||||
|
cp ../../.env.example ../../.env # edit DATABASE_URL to the sqlite line if you don't have Postgres running
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
**Frontend:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd apps/web
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Database migrations
|
||||||
|
cd apps/api && alembic upgrade head
|
||||||
|
cd apps/api && alembic revision --autogenerate -m "description"
|
||||||
|
|
||||||
|
# Run a Celery worker + beat (only needed outside Docker)
|
||||||
|
cd apps/api && celery -A app.tasks.celery_app worker --loglevel=INFO -Q default,collection,analysis,notifications,maintenance
|
||||||
|
cd apps/api && celery -A app.tasks.celery_app beat --loglevel=INFO
|
||||||
|
|
||||||
|
# Backend tests / lint / format
|
||||||
|
cd apps/api && pytest
|
||||||
|
cd apps/api && ruff check app tests
|
||||||
|
cd apps/api && black app tests
|
||||||
|
|
||||||
|
# Frontend tests / lint / format / typecheck
|
||||||
|
cd apps/web && npm test
|
||||||
|
cd apps/web && npm run lint
|
||||||
|
cd apps/web && npm run format
|
||||||
|
cd apps/web && npm run typecheck
|
||||||
|
|
||||||
|
# End-to-end test (requires `docker compose up -d` already running)
|
||||||
|
cd apps/web && npx playwright install --with-deps chromium # one-time
|
||||||
|
cd apps/web && npm run e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
All configuration is via environment variables — see [`.env.example`](.env.example) for the full list with comments. Highlights:
|
||||||
|
|
||||||
|
- `AUTH_MODE=local|jwt` — local single-user dev mode vs. real email/password accounts.
|
||||||
|
- `LLM_PROVIDER=mock|anthropic|ollama|gemini` — set `ANTHROPIC_API_KEY`/`ANTHROPIC_MODEL`, `OLLAMA_BASE_URL`/`OLLAMA_MODEL`, or `GEMINI_API_KEY`/`GEMINI_MODEL` to go live. Gemini has a genuine free tier — grab a key at [aistudio.google.com/apikey](https://aistudio.google.com/apikey) — so it's the cheapest provider to actually try against a real model.
|
||||||
|
- `SEARCH_PROVIDER=mock|brave` — set `BRAVE_SEARCH_API_KEY` to go live.
|
||||||
|
- `SMTP_HOST`/`RESEND_API_KEY` — no bundled local mail sink; point SMTP at a real relay or set `RESEND_API_KEY` to actually test email delivery (alerts and security email both use this).
|
||||||
|
- `NOTIFICATION_SMS_ENABLED=false` by default — set to `true` and provide `TWILIO_*` to enable SMS.
|
||||||
|
|
||||||
|
Every paid/external provider defaults to a mock/console implementation. Automated tests always run against mocks and never call a paid API.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
See [`KNOWN_LIMITATIONS.md`](KNOWN_LIMITATIONS.md).
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
# Security
|
||||||
|
|
||||||
|
## Threat model summary
|
||||||
|
|
||||||
|
This app fetches user-submitted and discovered URLs from the public internet on a schedule, stores API keys for third-party providers, and sends emails/SMS. The main risks are: SSRF via URL fetching, credential leakage, cross-user data access, and abuse of the scraping/LLM/SMS pipeline as a cost or spam vector.
|
||||||
|
|
||||||
|
## SSRF prevention (`app/core/http.py::safe_fetch`)
|
||||||
|
|
||||||
|
All outbound collector/custom-URL requests go through one function. It:
|
||||||
|
|
||||||
|
- Only allows `http`/`https` schemes (no `file://`, `ftp://`, `gopher://`, etc.).
|
||||||
|
- Resolves DNS itself before connecting, and rejects the request if any resolved address is loopback, link-local, private (RFC1918/RFC4193), multicast, or a known cloud metadata address (`169.254.169.254`, etc.).
|
||||||
|
- Disables automatic redirect following in the HTTP client and instead re-validates each redirect target against the same rules before following it, up to a small max-hop count.
|
||||||
|
- Applies a per-request timeout and a per-domain rate limit / delay (`SCRAPER_DOMAIN_DELAY_SECONDS`).
|
||||||
|
- Sends a configurable, identifying `User-Agent` (`SCRAPER_USER_AGENT`) rather than impersonating a browser.
|
||||||
|
|
||||||
|
No collector or route handler is allowed to call `httpx`/`aiohttp` directly for a user- or discovery-supplied URL — code review should reject that pattern. The guard has zero exceptions: every resolved private/loopback/link-local/metadata address is rejected unconditionally, with no allowlist.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
- All secrets are read from environment variables via `app/core/config.py`. Nothing is hard-coded.
|
||||||
|
- `.env` is git-ignored; `.env.example` ships with empty/placeholder values only.
|
||||||
|
- Secrets are never logged. The structured logger has a redaction filter for known secret-shaped keys (`*_key`, `*_secret`, `*_token`, `*password*`).
|
||||||
|
- The frontend never receives backend API keys; all third-party calls happen server-side.
|
||||||
|
- `NotificationDestination.destination_value` (email/phone) is treated as PII, not a secret, but is still excluded from audit logs beyond a masked form.
|
||||||
|
|
||||||
|
## AuthN/AuthZ
|
||||||
|
|
||||||
|
- Passwords hashed with Argon2 (`passlib[argon2]`), never stored or logged in plaintext.
|
||||||
|
- JWT access tokens are short-lived (default 15 min); refresh tokens are longer-lived, stored hashed, and rotated on use.
|
||||||
|
- Every service-layer query that loads a `Company`, `Report`, `Alert`, `Source`, or `NotificationDestination` filters by `owner_user_id` — enforced in the repository layer, not just the route layer, so a missed check in one route can't leak data.
|
||||||
|
- Auth endpoints are rate-limited (`slowapi`) to blunt credential stuffing. Per-IP request-rate limits also apply to every other mutation endpoint that creates data or costs money to call — company/source/notification-destination creation, report generation (LLM call), notification-destination test-send (real SMTP/Twilio call), and run-now — on top of (not instead of) the business-logic caps in "Cost/abuse controls" below. See `@limiter.limit(...)` usages across `app/api/v1/*.py`.
|
||||||
|
- `AUTH_MODE=local` is for local development only; the app refuses to start in this mode when `APP_ENV=production`.
|
||||||
|
- **Notification destinations are not verified.** `NotificationDestination.verified` exists on the model but nothing ever sets it to `true` - a destination is usable for real alert sends as soon as it's created, with no confirmation-code/link flow. Combined with SMS being real-money-per-message once `NOTIFICATION_SMS_ENABLED=true` and Twilio is configured, this means a user could point alerts at a phone number or inbox they don't control. Rate limiting on the `/test` endpoint (above) narrows the abuse window but doesn't close it; a verification flow is the real fix and is tracked in `KNOWN_LIMITATIONS.md` as unimplemented.
|
||||||
|
|
||||||
|
## Ethical/legal collection rules
|
||||||
|
|
||||||
|
Enforced by the collector layer, not left to convention:
|
||||||
|
|
||||||
|
1. `robots.txt` is fetched and honored before crawling a domain (website collector).
|
||||||
|
2. No collector attempts to bypass a CAPTCHA, login wall, paywall, or anti-bot control. If a source requires that, the `Source.status` is set to `AUTH_REQUIRED`/`BLOCKED_BY_POLICY` and the failure is recorded, never silently skipped or faked.
|
||||||
|
3. Only publicly reachable URLs are collected — `safe_fetch` also serves as the enforcement point for "no private/internal targets."
|
||||||
|
4. Per-domain delay + retry-with-backoff avoids hammering a source.
|
||||||
|
5. Content hashing avoids redundant re-fetches of unchanged pages.
|
||||||
|
6. A run that partially fails is marked `partial`, not silently reported as complete — the report generator is told which sources failed so it doesn't imply completeness it doesn't have.
|
||||||
|
|
||||||
|
## Input validation & injection
|
||||||
|
|
||||||
|
- All request/response bodies are Pydantic models with explicit types and length limits.
|
||||||
|
- SQLAlchemy ORM with parameter binding throughout — no raw string-interpolated SQL.
|
||||||
|
- LLM output is only ever deserialized into a strict Pydantic schema (`generate_structured`); free-text LLM output is never concatenated into a shell command, SQL string, or HTML without escaping, and is rendered in the frontend as text, not `dangerouslySetInnerHTML`.
|
||||||
|
- Request bodies are size-limited at the ASGI layer.
|
||||||
|
|
||||||
|
## Cost/abuse controls
|
||||||
|
|
||||||
|
Per-user limits (configurable, enforced in `services/`): max monitored companies, max manual "run now" triggers per day, max pages crawled per run (`MAX_PAGES_PER_DOMAIN`), max LLM tokens per request, max LLM retries, monthly SMS cap. Paid providers (Anthropic, Brave, Twilio) are opt-in via explicit env configuration and default to their Mock/Console counterparts, so a fresh checkout cannot incur cost by accident, and automated tests never hit a paid provider (they run under `LLM_PROVIDER=mock`, `SEARCH_PROVIDER=mock`, `NOTIFICATION` console-only fixtures).
|
||||||
|
|
||||||
|
## Observability & data retention
|
||||||
|
|
||||||
|
- Every HTTP request and every Celery task run carries a correlation id (`request_id` for HTTP, `task_id` for Celery, plus `run_id` for monitoring runs) bound into structlog's contextvars for the duration of the request/task, so every log line emitted while handling it can be grepped together - see `app/main.py::correlation_id_middleware` and the `bound_contextvars(...)` calls in `app/tasks/*.py`. The HTTP middleware also echoes the id back as an `X-Request-ID` response header, reusing an inbound one from a gateway if present.
|
||||||
|
- `DATA_RETENTION_DAYS` (default 365) is enforced by a daily Celery Beat task (`app.tasks.maintenance.purge_expired_data`, 3am UTC) that deletes `SourceDocument` rows - the raw collected text - older than the window. Only `SourceDocument` is in scope: nothing else in the schema has a foreign key onto it, so this can never cascade-delete a `Snapshot`, `DetectedChange`, `Alert`, or `Report` a user might still want to see (see the docstring on `SourceDocumentRepository.delete_older_than`).
|
||||||
|
|
||||||
|
## Reporting a vulnerability
|
||||||
|
|
||||||
|
This is a local/dev-stage project; if you find an issue, open an issue in the repository describing the problem and reproduction steps rather than exploiting it further.
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
# Tasks
|
||||||
|
|
||||||
|
Legend: `[ ]` pending, `[x]` done, `[~]` partial/stubbed (see KNOWN_LIMITATIONS.md).
|
||||||
|
|
||||||
|
## Phase 1 — Foundation
|
||||||
|
- [x] Monorepo layout (`apps/web`, `apps/api`, `packages/shared`, `infrastructure`, `scripts`, `docs`)
|
||||||
|
- [x] PLAN.md, ARCHITECTURE.md, TASKS.md, SECURITY.md, README.md
|
||||||
|
- [x] `.env.example`, `.gitignore`
|
||||||
|
- [x] `docker-compose.yml` (postgres, redis, mailpit, api, worker, beat, web) — verified with `docker compose up --build`
|
||||||
|
- [x] FastAPI skeleton with `/health`, `/ready` (+ `/api/v1/system/status`) — pytest passing
|
||||||
|
- [x] Next.js skeleton with landing page shell — build/lint/typecheck/test passing
|
||||||
|
- [x] Backend lint/format (ruff, black) + pytest scaffolding
|
||||||
|
- [x] Frontend lint/format (eslint, prettier) + vitest scaffolding
|
||||||
|
|
||||||
|
## Phase 2 — Auth & Users
|
||||||
|
- [x] User model + Alembic migration (`users`, `refresh_tokens`)
|
||||||
|
- [x] `AuthProvider`-equivalent dependency (`get_current_user`): `LocalAuthProvider` and `JWTAuthProvider` behavior behind one seam
|
||||||
|
- [x] `/auth/register`, `/auth/login`, `/auth/refresh` (rotating), `/auth/logout`, `/auth/me`
|
||||||
|
- [x] Protected-route dependency (`get_current_user`, `require_admin`) — per-user isolation enforced at repository layer going forward
|
||||||
|
- [x] Auth rate limiting (slowapi; disabled under `APP_ENV=test`, verified by a dedicated test)
|
||||||
|
- [x] Frontend: login, register (RHF+Zod), local-mode banner, `useAuth` hooks, dashboard shell with auth guard — verified live against Dockerized API (Postgres) and 14 backend + 6 frontend tests passing
|
||||||
|
|
||||||
|
## Phase 3 — Company Management
|
||||||
|
- [x] Company, CompanyAlias, Competitor, MonitorConfiguration, NotificationDestination models + migrations
|
||||||
|
- [x] Companies CRUD + pause/resume endpoints (`/run` moves to Phase 5 alongside MonitoringRun/Celery)
|
||||||
|
- [x] Dashboard page (summary cards, schedule, system health, recent companies) — real data, no placeholders
|
||||||
|
- [x] Companies list page (table, pause/resume, delete with confirm)
|
||||||
|
- [x] Add-Company wizard (5 steps: company, focus templates, schedule, notifications+consent, review)
|
||||||
|
- [x] Company detail page (Overview + Configuration functional; Report/Alerts/Sources/History/Snapshots tabs show phase-appropriate empty states) — verified live end-to-end (create → dashboard → list → detail → configuration) against Dockerized Postgres API; 33 backend + 6 frontend tests passing
|
||||||
|
|
||||||
|
## Phase 4 — Collection Pipeline
|
||||||
|
- [x] `SourceCollector` protocol + `safe_fetch`/`fetch_with_retries` SSRF guard (DNS pre-resolution, redirect re-validation, domain rate limiting)
|
||||||
|
- [x] Website collector (sitemap + heuristic pages, robots.txt)
|
||||||
|
- [x] RSS/Atom collector
|
||||||
|
- [x] Custom URL collector
|
||||||
|
- [x] SEC EDGAR collector (CIK lookup + recent 10-K/10-Q/8-K metadata)
|
||||||
|
- [x] GitHub collector (org search + repo metadata)
|
||||||
|
- [x] Job posting collector (generic HTML link-heuristic extraction real; board-specific APIs stubbed, see KNOWN_LIMITATIONS.md)
|
||||||
|
- [x] Patent source interface (fixture adapter only, documented, never fabricates)
|
||||||
|
- [x] Review source interface (fixture adapter only, documented, never fabricates)
|
||||||
|
- [x] Extraction/normalization (trafilatura + BeautifulSoup fallback, whitespace normalization, content hashing, URL canonicalization, cross-page dedup)
|
||||||
|
- [x] Source, SourceDocument, Snapshot models + migration
|
||||||
|
- [x] Sources API (list/create/update/delete/test) with ownership isolation, wired into the company detail page's Sources tab
|
||||||
|
- [x] `collection_service` (discover_sources_for_company, collect_source) — 22 collector tests + 9 integration tests, all respx-mocked (no live network in the suite); verified live end-to-end against Dockerized Postgres + real `example.com` fetch
|
||||||
|
|
||||||
|
## Phase 5 — Background Processing
|
||||||
|
- [x] Celery app + queues (default/collection/analysis/notifications/maintenance) — bound explicitly via `celery_app.task(...)`, not `@shared_task` (see KNOWN_LIMITATIONS.md for why that distinction mattered)
|
||||||
|
- [x] Celery Beat dynamic schedule sync from `MonitorConfiguration` (`sync_schedules`, runs every minute, no static per-company entries needed)
|
||||||
|
- [x] MonitoringRun model + status lifecycle (queued → running → successful/partial/failed) + migration (incl. the deferred `snapshots.monitoring_run_id` FK from Phase 4)
|
||||||
|
- [x] Run-now endpoint → enqueue without disrupting schedule (`last_run` updates always; `next_run` only advances for scheduled-trigger runs) + idempotent (active run returned, not duplicated)
|
||||||
|
- [x] Retry/backoff policy (task-level `max_retries`; per-source failures caught and recorded without failing the whole run)
|
||||||
|
- [x] Run status polling in frontend (2s interval while queued/running) + Monitoring history tab + "Run now" wired on both list and detail pages
|
||||||
|
- [x] Verified live with the *real* worker/beat/Redis stack (not eager mode): task received, HTTP collection executed, run completed — confirmed via docker logs and API polling
|
||||||
|
|
||||||
|
## Phase 6 — Change Detection
|
||||||
|
- [x] Hash comparison layer (short-circuits before any diffing when unchanged)
|
||||||
|
- [x] Structured field diff layer (added/removed item sets between consecutive snapshots)
|
||||||
|
- [x] Bounded text diff + noise filters (timestamps/cookies/copyright/counters stripped before diffing; capped output size)
|
||||||
|
- [~] Semantic comparison layer — deferred to Phase 7 (needs the LLM provider interface); Layers 1-3 + deterministic scoring are sufficient to ship real detections now
|
||||||
|
- [x] Significance scoring (documented formula in `app/change_detection/scoring.py`, unit tested — 9 tests covering trust/corroboration/focus-match/repeat/diff-ratio scaling)
|
||||||
|
- [x] Severity classification (deterministic buckets + hard confidence floor for Critical, unit tested)
|
||||||
|
- [x] DetectedChange model + migration
|
||||||
|
- [x] Change-level dedup/cooldown (exact-repeat suppression within a 24h window; repeats that aren't exact still recorded but dampened) — Alert-level dedup/digest is Phase 8 scope
|
||||||
|
- [x] Wired into the monitoring run task; verified live end-to-end in Docker (real worker, real Postgres) — 34 new tests (scoring/structured-diff/text-diff/noise-filters/extractors/service integration), 130 total passing
|
||||||
|
|
||||||
|
## Phase 7 — LLM Analysis
|
||||||
|
- [x] `LLMProvider` interface, Mock/Anthropic/Ollama implementations (Anthropic via forced tool-use, Ollama via JSON mode; both have a bounded repair loop, tested with the SDK/HTTP layer mocked)
|
||||||
|
- [x] Task A: document relevance
|
||||||
|
- [x] Task B: fact/signal extraction
|
||||||
|
- [x] Task C: cross-source synthesis
|
||||||
|
- [x] Task D: report generation (evidence gathered from real DB rows, never invented)
|
||||||
|
- [x] Task E: change significance (narrative only — severity itself stays deterministic per ARCHITECTURE.md)
|
||||||
|
- [x] Task F: alert summarization
|
||||||
|
- [x] Report model (JSON + Markdown) + Report page UI (16 sections, copy/print/export-JSON, Markdown-with-sources toggle) — wired into monitoring runs (baseline on first evidence, update when a run detects a change) and verified live end-to-end in Docker (real Postgres, real report generated from real collected evidence)
|
||||||
|
|
||||||
|
## Phase 8 — Notifications
|
||||||
|
- [x] `NotificationProvider` interface: Console, SMTP (Mailpit, stdlib `smtplib` via `asyncio.to_thread`), Twilio SMS (plain REST API, no SDK dependency)
|
||||||
|
- [x] Alert model + NotificationDelivery model + migration
|
||||||
|
- [x] `alert_service.create_alert_for_change`: two independent thresholds by design — `MonitorConfiguration.severity_threshold` gates whether an Alert is created at all; each `NotificationDestination.minimum_severity` separately gates whether that destination is notified — wired into the monitoring run task right after change detection
|
||||||
|
- [x] Alerts API (list with company/severity/read/resolved filters, detail with delivery statuses, PATCH + mark-read/resolve actions) — ownership-scoped
|
||||||
|
- [x] `POST /notification-destinations/{id}/test` endpoint for a real send-path test (not just CRUD)
|
||||||
|
- [x] Alerts page (filters, unread/resolved indicators, mark read/resolve actions, detail view with delivery status per destination)
|
||||||
|
- [x] Settings page (notification destination management incl. per-destination minimum-severity/enabled/test/delete, system configuration incl. LLM/search provider, SMS-enabled flag, DB/Redis health)
|
||||||
|
- [x] 26 new backend tests (providers, alert_service threshold/dispatch/failure-recording, alerts API ownership+filters, notification-destination test endpoint, one full HTTP-driven E2E: two real monitoring runs → detected change → alert → email) — 175 total passing
|
||||||
|
- [x] Verified live in Docker: Alembic migration applied against real Postgres, worker/beat rebuilt and healthy, a real SMTP send round-tripped through the live Mailpit container, and both new frontend pages verified in-browser against seeded data
|
||||||
|
- [x] `TwilioSmsProvider` re-verified against a real Twilio account (user supplied real credentials): auth/transport/error-surfacing all confirmed correct end-to-end (a real `400` from `api.twilio.com` was correctly caught and displayed inline, not swallowed). Uncovered a real product-level blocker rather than a bug: Twilio trial accounts only permit predefined message templates, not the dynamically-generated alert text this app sends — see `KNOWN_LIMITATIONS.md`. Sending a real SMS alert end-to-end requires the user's Twilio account to be upgraded off trial first.
|
||||||
|
- [x] Added `TelnyxSmsProvider` (`app/notifications/telnyx_sms.py`) as a second SMS vendor option, selected via new `SMS_PROVIDER=twilio|telnyx` setting (`app/notifications/factory.py` now routes on it — same pattern as `LLM_PROVIDER`/`SEARCH_PROVIDER`); `SystemStatusResponse`/`/system/status` and the Settings page's system-configuration panel now show which SMS provider is active. 4 new backend tests (not-configured, real-send, API-error-with-Telnyx's-`errors[]`-shape, factory routing by `sms_provider`) — 210 backend tests total, 18 frontend tests unchanged.
|
||||||
|
- [x] `TelnyxSmsProvider` driven live against a real Telnyx account: a real `403` ("only pre-verified destinations allowed") was caught and displayed cleanly; after the user verified the destination number, a retry got a real `200 OK` from `api.telnyx.com` — but the text never arrived. Polling the message afterward revealed the real cause: `delivery_failed`, error `40010`, the sending number isn't 10DLC-registered (a US carrier requirement, industry-wide, not app-specific). No code fix possible — needs 10DLC/toll-free registration completed in the Telnyx portal. See `KNOWN_LIMITATIONS.md`.
|
||||||
|
- [x] Fixed a real gap found in the same pass: `alert_service.send_test_notification` didn't respect `NOTIFICATION_SMS_ENABLED`, so the "Test" button could fire a real SMS API call even with SMS globally disabled — only real alert dispatch checked the flag. Fixed so both paths gate identically; verified live (API logs show zero calls to `api.telnyx.com` after the fix). Settings page now shows an inline notice on the phone-number field explaining SMS is paused for 10DLC registration, not just that it's off. `NOTIFICATION_SMS_ENABLED` set back to `false` per the user's direction while registration is pending. 211 backend tests (up from 210), 18 frontend tests unchanged.
|
||||||
|
|
||||||
|
## Phase 9 — Fixture Demo + Tests
|
||||||
|
- [x] Acme Mobility Systems fixtures v1/v2 (about/products/careers/press/pricing) — real HTML under `apps/api/tests/fixtures/acme_mobility/`, versions differ by a leadership change, a price increase, a new job posting, and an expansion press release; `products` is unchanged v1→v2 to demonstrate the hash-based short-circuit
|
||||||
|
- [x] Dev-only fixture server (`app/dev/fixtures.py`, mounted only when `APP_ENV != production`) serving those pages over real HTTP, plus a narrow single-hostname SSRF allowlist (`Settings.demo_fixture_host`, unset by default, never honored in production) so the collectors can fetch them
|
||||||
|
- [x] `scripts/seed_acme_demo.py` (creates the demo company + 5 sources + notification destination, idempotent) and `scripts/switch_fixture.py` (v1/v2/status) + a matching dev-only "Acme Mobility demo" panel on the Settings page (only shown in local auth mode)
|
||||||
|
- [x] `tests/integration/test_acme_fixture_demo.py` — locks the exact demo scenario (real fixture files off disk, respx-mocked HTTP) into the automated suite: leadership/price/content changes detected correctly, `products` correctly produces no change
|
||||||
|
- [x] SSRF allowlist unit tests (`tests/unit/test_ssrf.py`) — exact-hostname-only bypass, production-mode ignores it entirely
|
||||||
|
- [x] Frontend component tests: `SeverityBadge`/`CompanyStatusBadge` rendering, `AlertsPage` (company-name resolution, severity-filter refetch)
|
||||||
|
- [x] Playwright E2E (`apps/web/e2e/acme-demo-flow.spec.ts`): full UI-driven workflow against the live Docker stack — add company via the wizard → add fixture sources → baseline run (v1) → switch fixture to v2 via the Settings panel → run again → real alert appears on the Alerts page → real email confirmed in Mailpit. Verified passing against the live stack, not just written.
|
||||||
|
- [x] Backend/frontend regression check: full suites re-run after every addition (179 backend, 14 frontend, all passing)
|
||||||
|
|
||||||
|
## Phase 10 — Hardening & Docs
|
||||||
|
- [x] Rate limiting beyond auth — `@limiter.limit(...)` added to company/source/notification-destination creation, notification-destination test-send, and report generation; verified live (22 rapid company-creation calls → 20×201 then 2×429)
|
||||||
|
- [x] Manual "run now" daily cap actually enforced — `MAX_MANUAL_RUNS_PER_DAY` was defined in `Settings` since Phase 1 but never wired up until now (`monitoring_service.enqueue_run_now`, new `RateLimitedError` → HTTP 429)
|
||||||
|
- [x] Structured logging + correlation IDs — `request_id` (HTTP, echoed as `X-Request-ID`) and `run_id`/`task_id` (Celery tasks) bound via structlog contextvars; fixed a real context-propagation gap in the threaded eager-mode fallback (`app/tasks/base.py`) along the way — verified live (header round-trips, task log line carries `task_id`)
|
||||||
|
- [x] Data retention job — `app.tasks.maintenance.purge_expired_data`, daily Celery Beat task, purges `SourceDocument` rows older than `DATA_RETENTION_DAYS`; scope deliberately limited to the one table with no incoming FK (see KNOWN_LIMITATIONS.md) — verified live via direct `.delay()` invocation against the real worker
|
||||||
|
- [x] SECURITY.md review pass — added the demo-fixture SSRF allowlist exception, the expanded rate-limiting surface, the notification-destination verification gap, and a new Observability & data retention section
|
||||||
|
- [x] README run/setup instructions finalized — fixed stale `python -m scripts.*` references from Phase 1 planning that never matched what got built; added the real Acme demo walkthrough and E2E instructions
|
||||||
|
- [x] `docs/FIREBASE_MIGRATION.md` — honest architecture-fit assessment (auth maps over cleanly; scheduling, cascading deletes, and cross-collection filtering do not), effort estimate, and an explicit recommendation against a full migration
|
||||||
|
- [x] `KNOWN_LIMITATIONS.md` final pass — Phase 10 section added; all prior phases' sections already up to date
|
||||||
|
- [x] Full regression check: 185 backend tests, 14 frontend tests, all passing after every Phase 10 change
|
||||||
|
|
||||||
|
## Phase 11 — Company Discovery & Provider Completion
|
||||||
|
- [x] `SearchProvider` interface (`app/search/base.py`) + `MockSearchProvider` (deterministic, honest about having no evidence) + `BraveSearchProvider` (`SEARCH_PROVIDER=brave`) + factory
|
||||||
|
- [x] Company-profile extraction LLM task (`app/prompts/company_profile.py`) — evidence-grounded (real search snippets + real fetched homepage text in, structured industry/country/region/headquarters/aliases/competitors/public_identifiers out), never asked to recall facts from training data
|
||||||
|
- [x] `discovery_service.discover_company_profile` — resolves official website, fetches it for real, runs a few targeted searches, calls the extraction task once, merges (user hints win over discovered values), and previews likely sources via the existing collectors' `.discover()` methods (no persistence)
|
||||||
|
- [x] `POST /api/v1/companies/discover` — rate-limited (`5/minute`), returns a `DiscoveredCompanyProfile`, writes nothing to the DB
|
||||||
|
- [x] `Company.headquarters` + `Company.public_identifiers` columns (migration `f01919a99ee9`) threaded through the repository/service/schema layers
|
||||||
|
- [x] `GeminiLLMProvider` (`app/analysis/llm/gemini_provider.py`) on the official `google-genai` SDK, native structured-output (`response_schema`) + the same repair-loop pattern as the Anthropic provider; `LLM_PROVIDER=gemini`
|
||||||
|
- [x] Add-Company wizard redesigned around discover → review: `STEPS = ["Discover", "Review", "Schedule", "Notifications", "Confirm"]`. Discover step only requires a company name (official website/focus/competitors/aliases are optional accuracy hints); Review step shows the discovered profile, fully editable, with a potential-sources list and a "sources consulted" transparency line
|
||||||
|
- [x] Company detail page's Details panel now shows `headquarters` and any `public_identifiers` (was previously captured by the wizard but had nowhere to display after creation — fixed during live verification)
|
||||||
|
- [x] 206 backend tests (up from 185), 18 frontend tests (up from 14) — new coverage for `SearchProvider` (mock + Brave respx-mocked), `discovery_service` (respx-mocked fetch + mock LLM, hint-overrides-discovery merge, plus a regression pair for the non-corporate-host resolution fix below), the `/companies/discover` endpoint, `GeminiLLMProvider` (mocked SDK client), and the wizard's discover→review flow
|
||||||
|
- [x] Verified live end-to-end against the Docker stack, first with mock providers (`api`/`worker`/`beat` rebuilt for the new `google-genai` dependency, Alembic migration applied to real Postgres, full wizard flow Discover → Review (edited fields survived) → Confirm → a real `Company` row created with `headquarters`/`public_identifiers` persisted; pre-existing "Run now" → lazy source-discovery pipeline confirmed unaffected), then re-verified with **real provider keys the user supplied**: `BraveSearchProvider` confirmed live (`api.search.brave.com`, all queries `200 OK`); `GeminiLLMProvider` uncovered and fixed a real bug (`public_identifiers: dict[str,str]` produced an `additionalProperties` JSON schema the Gemini Developer API rejects - changed to `list[PublicIdentifier]`), then hit an account-level `429 limit:0` unrelated to app code; user switched to `AnthropicLLMProvider`, which then verified the **entire pipeline live with real APIs**: real discovery (Brave + Claude) → real company creation → real `stripe.com` crawl on first run (23 items, 3/3 sources successful) → a real, evidence-grounded, correctly-hedged baseline report from `claude-sonnet-5`. Also found and fixed live: `_resolve_official_website` picking Brave's top-ranked Wikipedia result over the real corporate domain for well-known companies, which corrupted downstream source-preview URLs - see `KNOWN_LIMITATIONS.md`
|
||||||
|
|
||||||
|
## Phase 12 — UI Polish & Bug-Fix Round (post-live-testing feedback)
|
||||||
|
User drove the live app (real Brave + Anthropic keys) and reported 9 concrete issues. All addressed:
|
||||||
|
- [x] Company detail page's delete-confirmation control now animates in/out with a `grid-template-columns` + opacity transition (200ms, matching the app's existing `fade-in` timing) instead of an instant DOM swap that also instantly shifted the Pause/Run now buttons sideways
|
||||||
|
- [x] Add-Company wizard's "What do you want to know?" field now shows placeholder text summarizing the categories the app actually monitors for (products/pricing, leadership, hiring, financial signals, M&A, patents, expansion, regulatory/legal, competitor positioning) — was previously blank with no guidance
|
||||||
|
- [x] `CompanyProfileExtraction`/`DiscoveredCompanyProfile` gained a `description` field (`app/prompts/company_profile.py`, `app/schemas/discovery.py`, `app/services/discovery_service.py`) - the Review step's Description box was always blank before because discovery never produced one; `MockLLMProvider`'s heuristic derives it from the fetched homepage's first real sentence, real providers extract it from evidence like every other field
|
||||||
|
- [x] Wizard's Confirm step no longer flashes the full confirm view before redirecting - a new `isFinalizing` state swaps in a "Setting up monitoring for X…" spinner the instant "Create company" is clicked and stays there through the redirect (previously `createCompany.isPending` flipped false before `router.push` completed, causing a one-frame flash of the re-enabled button/full confirm content)
|
||||||
|
- [x] Company detail page's Details panel switched from side-by-side `dt`/`dd` (which produced a hanging-indent wrap for long values like a multi-clause Headquarters string) to a stacked label-above-value layout with `break-words`
|
||||||
|
- [x] Competitors on the company detail page are now clickable: hovering highlights them, and clicking navigates to that competitor's own company page if it's already monitored (case-insensitive name match against the user's company list) or to `/companies/new?name=<rival>` (wizard reads the `name` query param and pre-fills the Discover step) if not - verified live both ways (Stripe → PayPal went to the wizard pre-filled, then after creating PayPal for real, the same link went straight to its company page)
|
||||||
|
- [x] Investigated "16 points but basically empty" report finding for the live Stripe company - **not a bug**: the report was generated before any monitoring run had ever collected evidence (0 source documents, 0 detected changes), and the real report correctly refused to fabricate findings, explicitly stating "insufficient evidence" throughout rather than hallucinating - exactly the evidence-grounded behavior this app is designed around. Fixed the actual gap, which was a missing warning: the Latest Report tab now shows an inline notice when generating a report with zero monitoring runs, and Generate now/"Run now" ordering is explained rather than silently producing a thin report
|
||||||
|
- [x] Investigated empty Sources tab for the same company - also **not a bug**: no monitoring run had ever executed for that company, and `Source` rows are only ever created lazily on first collection (by design, since Phase 4/5). Fixed the gap: the empty-state message now explicitly says sources appear after the first "Run now" rather than a bare "No sources configured yet."
|
||||||
|
- [x] Built the previously-unimplemented Snapshots tab end-to-end: new `GET /companies/{id}/snapshots` endpoint (`app/api/v1/snapshots.py`, `app/services/snapshot_service.py`, `SnapshotRepository.list_for_company` added to `app/repositories/source_repository.py`, newest-first, capped at 50), `SnapshotResponse` schema, frontend `useSnapshots` hook + `api.listSnapshots`, and an expandable list UI (source name, type, timestamp, char count when collapsed; hash, full text summary, and pretty-printed structured summary when expanded) - verified live against real PayPal snapshots (real scraped homepage/careers/GitHub text, expand/collapse working)
|
||||||
|
- [x] 214 backend tests (up from 211: 3 new for the snapshots endpoint - empty list, newest-first ordering via a directly-inserted `Snapshot` row, ownership isolation), 18 frontend tests unchanged (existing wizard test extended to assert the description field pre-fills)
|
||||||
|
- [x] Verified live end-to-end in Docker with real Brave + Anthropic keys: ran the full wizard for PayPal (Stripe's competitor), confirmed `paypal.com` resolved directly (not Wikipedia, confirming the Phase 11 fix still holds), description field populated with real Claude-extracted text, a real monitoring run (50 items, 3/3 sources) populated the Sources and Snapshots tabs with real data, and the Stripe↔PayPal competitor cross-link resolved correctly both before and after PayPal existed as a monitored company
|
||||||
|
|
||||||
|
## Phase 13 — Second UI Polish & Bug-Fix Round (wizard nav, dedup, notification linking, report grounding)
|
||||||
|
User reported 4 more issues after driving the app further. All addressed:
|
||||||
|
- [x] Wizard's Back button on the Discover step no longer stays permanently disabled - `goBack()` now calls `router.back()` when `step === 0` instead of a no-op, so it correctly returns to whichever of the 3 entry points (Dashboard, Companies, or a competitor link) the user actually came from. Verified live via a real click-through chain (Companies → wizard → Back → landed back on `/companies`)
|
||||||
|
- [x] Add-Company wizard now detects likely duplicate company names client-side (`normalizeCompanyName`/`findPossibleDuplicate` in `apps/web/app/(app)/companies/new/page.tsx` - strips legal suffixes/punctuation, then exact/substring match against the user's existing companies) **before** spending a real search+LLM call on discovery, shows a "You might already be monitoring X" warning with a link to the existing company and a "Continue anyway" override, and re-checks automatically if the name is edited afterward. Backend now guarantees the persisted `name` is unique per user regardless (`company_service._unique_display_name`, mirroring the existing `_unique_slug` pattern) - "Stripe" → "Stripe (2)" → "Stripe (3)" on collision, filesystem-style. Verified live: typing "Stripe" warned correctly, no discovery API call fired until "Continue anyway", and the created company was actually named "Stripe (2)"
|
||||||
|
- [x] **Notification destinations are now linked to specific companies** instead of being flat per-user rows every destination implicitly applied to every company. New `notification_destination_companies` join table (migration `60a25ddfc6a3`, includes a data backfill+dedup step - see below), `NotificationDestinationRepository.list_for_company`/`link_company`/`find_by_value`/`delete_orphaned_for_user`, `POST /notification-destinations` now requires `company_ids: list[UUID]` (min 1) and reuses an existing destination by (user, type, value) instead of duplicating it - this is the actual fix for the wizard silently creating a fresh row per company even when the same email was already registered. `alert_service.create_alert_for_change` now dispatches via `list_for_company(company.id)` instead of `list_for_user`, so a destination only fires for companies it's actually linked to. Deleting a company now garbage-collects any destination left with zero remaining links (`company_service.delete_company` → `delete_orphaned_for_user`), with `Company.notification_links`/`NotificationDestination.company_links` given explicit ORM `cascade="all, delete-orphan"` since SQLite (used in dev/tests) doesn't enforce `ON DELETE CASCADE` without a pragma this app doesn't set - relying on the DB-level FK alone would've silently broken cleanup under SQLite while appearing to work on Postgres
|
||||||
|
- [x] The one-time migration backfills every existing destination onto every company the same user currently has (a no-op behavior change - it's exactly what already happened implicitly before the join table existed) and then deduplicates rows sharing the same (user, type, normalized value), keeping the earliest and deleting the rest (cascading their `NotificationDelivery` history, an acceptable one-time cleanup). Verified against the live Postgres DB: went from several duplicate `[email protected]` rows down to exactly 4 unique destinations, with 48 backfilled links (4 destinations × 12 companies at migration time) - matching the pre-migration behavior exactly
|
||||||
|
- [x] Settings page redesigned: `AddDestinationForm` now has a required company multi-select (toggle-chip buttons, at least one required to submit); `DestinationRow` shows each unique destination once with a horizontally-scrollable row of clickable company chips (`overflow-x-auto`, hover-highlight, links to `/companies/{id}`) instead of no company visibility at all. Verified live: scrollWidth (1134px) exceeds clientWidth (384px) on the chip row, confirming it actually scrolls rather than wrapping/clipping; creating a destination linked to only one company and then deleting that company correctly removed the destination from the list (confirmed via direct API calls against the live stack)
|
||||||
|
- [x] Report generation now receives the company's discovered profile (`description`, `official_website`, `headquarters`, `country`, `region`, `public_identifiers`) as a `company_profile` evidence block (`app/prompts/report_generation.py`, threaded through from `report_service.py`), not just `source_documents`/`detected_changes` - this data is genuine evidence (fetched from the company's real website/search results at onboarding), just previously never wired into report generation, which is why reports generated before any monitoring run came back almost entirely "insufficient evidence" even when the company profile had real content. `MockLLMProvider._build_report` also rewritten to ground `company_overview`/`market_positioning` in profile fields. Verified live: generated a report for a brand-new "Airbnb" company with zero monitoring runs - `company_overview` and `market_positioning` came back as substantive, evidence-grounded paragraphs (real HQ, industry, named competitors, business model) instead of "insufficient evidence", while sections with genuinely no evidence (financials, hiring, leadership) still correctly said so
|
||||||
|
- [x] 223 backend tests (up from 214: company name-uniqueness ×3, notification-destination linking/dedup/GC ×6, mock report profile-grounding ×1, plus updates to existing destination/alert tests to pass `company_ids`), 19 frontend tests (up from 18: new duplicate-warning wizard test)
|
||||||
|
- [x] Verified live end-to-end against the Docker stack with real Brave + Anthropic keys and the live Postgres DB (migration applied, backfill/dedup confirmed via direct SQL) - see individual bullets above for what was checked
|
||||||
|
|
||||||
|
## Phase 14 — New Intelligence Sources + Per-Source Scheduling
|
||||||
|
User compared the live app against the original ChatGPT-authored planning document that inspired it and found real gaps - not a policy problem (bypassing paywalls/logins was explicitly rejected as out of scope again), just free/public sources that were never wired up, plus no way to check a fast-moving source (news) more often than a slow one (patents) within the same company.
|
||||||
|
- [x] Google News RSS auto-discovery - `RssCollector.discover()` (`app/collectors/rss.py`) was previously a stub returning `[]`; now builds `https://news.google.com/rss/search?q={company}&hl=en-US&gl=US&ceid=US:en` and registers it as a real discoverable source, reusing `RssCollector.collect()`'s already-real `feedparser` fetch/parse
|
||||||
|
- [x] Government contracts via USASpending.gov - new `SourceType.GOV_CONTRACT` + `GovContractCollector` (`app/collectors/gov_contracts.py`), a free/keyless `POST /api/v2/search/spending_by_award/`, structurally mirroring `SecEdgarCollector` (always offered, a private company just gets zero results, not an error)
|
||||||
|
- [x] Patents wired to USPTO's Open Data Portal - new `USPTO_API_KEY` setting; `PatentSourceCollector.collect()` now attempts a real `POST /api/v1/patent/applications/search` call when a key is configured, with the existing honest fixture/disabled fallback completely unchanged (byte-for-byte) for the default no-key case, so none of the 12 existing companies with a dormant `PatentSourceCollector` regressed
|
||||||
|
- [x] Per-source check-frequency scheduling - `Source` gained 4 nullable columns (`frequency_type`/`interval_minutes`/`cron_expression`/`next_check`, migration `06e7f03cea36`); `NULL` means "inherit the company's default cadence" (the default for every source, zero behavior change unless a source opts in). `sync_schedules` now enqueues a company if *either* its own `MonitorConfiguration.next_run` is due *or* any of its sources has an independently-due override (`SourceRepository.company_has_due_work`/`list_due_for_company`). A `SCHEDULED` run only collects the sources actually due; a `MANUAL` "Run now" still collects every active source regardless of cadence, unchanged. Frontend: a per-row "Check frequency" `Select` on the Sources tab (`apps/web/app/(app)/companies/[id]/page.tsx`), defaulting to "Same as company", wired through `PATCH /sources/{id}` (`SourceUpdate` gained the same 3 fields; `source_service.update_source` validates the schedule via the existing `validate_and_compute_next_run` and resets `next_check` to `None` so a changed/cleared override takes effect on the very next scheduler tick rather than waiting out the old cadence)
|
||||||
|
- [x] Explicitly dropped: Nubela/NinjaPear company-enrichment API (confirmed enterprise-only pricing, not accessible to a normal user - no code written); dedicated PRNewswire/BusinessWire/GlobeNewswire collectors (redundant with what Google News RSS already surfaces)
|
||||||
|
- [x] 240 backend tests (up from 223: 2 RSS discovery-URL tests, 4 gov-contracts collector tests, 5 patents-live-branch tests, 4 scheduler/per-source-due-ness integration tests, 2 source-update-API tests for the new scheduling fields), frontend `tsc`/`eslint`/vitest (19 tests) all clean with the new `SourceUpdatePayload`/`useUpdateSource` additions
|
||||||
|
- [x] Verified live end-to-end against the Docker stack: `api`/`worker`/`beat`/`web` rebuilt, migration `06e7f03cea36` applied to the real Postgres DB, a real "Run now" against Stripe auto-discovered and successfully collected from all 5 sources including the two new ones (`GET https://news.google.com/rss/search?q=Stripe...` → `200 OK`, `POST https://api.usaspending.gov/api/v2/search/spending_by_award/` → `200 OK`), and the new per-row frequency Select was exercised live in-browser (set "Stripe — Google News" to Daily, confirmed via a re-fetch from the API; cleared it back to "Same as company", confirmed that round-tripped too, both backed by real `PATCH /api/v1/sources/{id}` → `200 OK` calls in the API logs). Patents' real-API branch is unit-tested against a mocked HTTP call only - not live-verified, since it requires a user-supplied `USPTO_API_KEY` that hasn't been provided yet; the existing no-key fixture path was already covered by the pre-existing patents tests and is unaffected
|
||||||
|
|
||||||
|
## Phase 15 — NinjaPear Company Enrichment
|
||||||
|
Phase 14 dropped Nubela/NinjaPear as "enterprise-only" - the user found and paid for an individual $49/mo tier ($49-$1899/mo range) that Phase 14's research missed, then asked for it wired up. Scoped via `AskUserQuestion` before building: **all** endpoint categories including customer listing (not just company-level data), but **onboarding-only** timing (never a recurring per-cycle cost) - confirmed given the API bills real credits per field per request, unlike every other free/flat-rate provider in this app.
|
||||||
|
- [x] New `app/enrichment/` provider package (`base.py` Protocol + Pydantic result shapes, `mock.py` honest-empties default, `ninjapear.py` real per-endpoint `httpx` calls to `nubela.co`, `factory.py`), mirroring `app/search/`'s shape. New `NINJAPEAR_API_KEY`/`NINJAPEAR_MAX_LEADERSHIP_LOOKUPS` settings
|
||||||
|
- [x] New `CompanyEnrichment` model (1:1 with `Company`, migration `79e3aa041131`) - `status` (pending/partial/complete/failed), a single `data` JSON blob (employee count, leadership team, funding rounds, competitors-with-reasons, products, recent updates, customers), and an `errors` map so a partially-failed enrichment is never silently presented as complete
|
||||||
|
- [x] `app/services/enrichment_service.py` orchestrates ~6 independent per-endpoint calls plus capped per-leadership-member work-email/profile lookups (`NINJAPEAR_MAX_LEADERSHIP_LOOKUPS`, default 5) - one failed call is recorded in `errors` and never sinks the others, same principle as `tasks/collection.py`'s per-source loop. New `app/tasks/enrichment.py` Celery task (own `enrichment` queue, generous time limits given NinjaPear's documented up-to-5-minute endpoints)
|
||||||
|
- [x] `company_service.create_company` enqueues the task post-commit, **gated entirely on `NINJAPEAR_API_KEY` being set** - zero extra background-task volume for the overwhelming majority of users who haven't configured it, matching `PatentSourceCollector.discover()`'s "gate on the key, not the provider" precedent. A `status=pending` `CompanyEnrichment` row is created synchronously in the same transaction (not left implicit) so the frontend has something real to poll on
|
||||||
|
- [x] Report generation gains a `company_enrichment` evidence block (`app/prompts/report_generation.py`, threaded through `report_service.py`) right alongside the existing `company_profile` block - no new report schema needed, since funding/leadership/competitors/products/customers all map onto existing `ReportContent` sections (`financial_signals`, `leadership_changes`, `competitor_comparison`, `products_and_services`, `customer_sentiment`)
|
||||||
|
- [x] `GET /system/status` gains `ninjapear_configured`/`ninjapear_credit_balance` (a live, free credit-balance call), shown in Settings' System configuration panel - real-money cost visibility, same treatment the SMS provider status already gets
|
||||||
|
- [x] Frontend: new "Enrichment" tab on the company detail page (funding table, leadership list with resolved work-email/profile links, competitors-with-reasons kept visually separate from the user's own reviewed Competitors list, products, recent updates, customers, employee count), polling (`useCompany`'s `refetchInterval`) while `status === "pending"` so it updates itself once the background task finishes
|
||||||
|
- [x] Deliberately excluded: the "Similar People" endpoint (a role-anchored prospecting tool with no natural onboarding-time trigger), the Website Lookup endpoint (redundant - `official_website` already comes from Phase 11 discovery), and auto-merging NinjaPear's suggested competitors into the user-reviewed `Company.competitors` list (would silently mutate user-controlled data)
|
||||||
|
- [x] 261 backend tests (up from 240: 8 provider tests incl. mock honesty, 6 orchestration-service tests incl. the leadership cap and partial/failed status derivation, 3 Celery-task integration tests, 2 create-company enqueue-gating tests - the real regression guard, since every other test in the suite runs without a key configured and stays green throughout - 2 report-generation evidence-block tests), frontend `tsc`/eslint/vitest (19 tests) all clean
|
||||||
|
- [x] **Verified live** with the user's real `NINJAPEAR_API_KEY` and `USPTO_API_KEY` (both supplied after explicit go-ahead, since NinjaPear spends real credits per call). The initial schema (written from a JS-rendered docs page that couldn't be fully scraped) had real bugs the live pass caught: NinjaPear identifies companies by `website` only (no name-based lookup), `employee_count`/`industry`/funding amounts come back as raw numbers not strings, funding's `investors` are objects not plain strings, and most response field names differed from the initial guesses (`executives`, `total_funds_raised`, `x_profile_url`, etc. - full list in `KNOWN_LIMITATIONS.md`). All fixed and re-verified: a real company creation (Stripe) returned real leadership bios, work emails, X profiles, funding history, competitors-with-reasons, live blog updates, and customers for 34 real credits, landing on `status: partial` (one now-fixed bug, one legitimate 404 for a person not in NinjaPear's database) - and the credit-balance endpoint path/field was also wrong and fixed (`/api/v1/meta/credit-balance` → `credit_balance`, not `/company/credit-balance` → `balance`). USPTO's real branch (Phase 14, unverified until now) turned out to have its own live-only bugs too - a wrong sort-field path (`500` error), title/date fields nested one level deeper than assumed, and `404` "no matching records" being treated as a failure instead of an honest empty result - all fixed. 265 backend tests (up from 261: schema-corrected provider/service tests, a no-website-guard test, and a USPTO 404-as-empty-result test)
|
||||||
|
- [x] **USPTO company-name search follow-up**: confirmed (by inspecting a real response's full field list, for a query that *did* return 110k+ real results by inventor name) that USPTO's Patent Application Search has no queryable assignee/company field at all - not a bug to fix, a real constraint of that dataset. At the user's request, worked around it: `CompanyContext` gained `leadership_names` (`app/collectors/base.py`), threaded through from `CompanyEnrichment.data["leadership_team"]` in `collection_service.to_company_context` (new `_leadership_names` helper - DB-free collectors stay DB-free, the ORM→dataclass seam is the one place allowed to read it). `PatentSourceCollector.collect()` now searches USPTO by each of the company's leadership names (capped at 5, `_MAX_INVENTOR_SEARCHES`) instead of by company name, dedupes results by application number across names, and trust-scores every match at 0.5 with explicit "heuristic, not verified" labeling in the document content - a name match is a real signal, not proof of company ownership. Live-verified against Stripe: 19 real patent documents found via its executives' names, zero NinjaPear credits spent (USPTO is free). Self-heals over time: a company whose patents source was discovered before enrichment finished just returns empty until the next scheduled collection re-reads (now-populated) leadership names from the DB. 266 backend tests (up from 265: leadership-name search/dedup, empty-without-names no-network-call, and eager-load fixes to 3 pre-existing integration tests that constructed `Company` objects directly without loading the new `.enrichment` relationship)
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = migrations
|
||||||
|
prepend_sys_path = .
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Anthropic provider: structured output via forced tool-use (the response
|
||||||
|
schema becomes the tool's input_schema, so the model can only "call" it with
|
||||||
|
arguments matching the shape we asked for), with a bounded repair loop for
|
||||||
|
the rare malformed response.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from anthropic import AsyncAnthropic
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from app.analysis.llm.base import LLMResponseError
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
_TOOL_NAME = "emit_result"
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicLLMProvider:
|
||||||
|
provider_name = "anthropic"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._client = AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||||
|
|
||||||
|
async def generate_structured[T: BaseModel](
|
||||||
|
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||||
|
) -> T:
|
||||||
|
tools = [
|
||||||
|
{
|
||||||
|
"name": _TOOL_NAME,
|
||||||
|
"description": f"Emit the result matching the {response_model.__name__} schema.",
|
||||||
|
"input_schema": response_model.model_json_schema(),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
messages: list[dict] = [{"role": "user", "content": user_prompt}]
|
||||||
|
|
||||||
|
for attempt in range(self._settings.llm_max_retries + 1):
|
||||||
|
if attempt > 0 and last_error is not None:
|
||||||
|
messages = [
|
||||||
|
*messages,
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": (
|
||||||
|
f"Your previous response did not match the required schema: "
|
||||||
|
f"{last_error}. Please try again."
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await self._client.messages.create(
|
||||||
|
model=self._settings.anthropic_model,
|
||||||
|
max_tokens=self._settings.llm_max_tokens_per_request,
|
||||||
|
system=system_prompt,
|
||||||
|
tools=tools,
|
||||||
|
tool_choice={"type": "tool", "name": _TOOL_NAME},
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
tool_use = next((b for b in response.content if b.type == "tool_use"), None)
|
||||||
|
if tool_use is None:
|
||||||
|
last_error = ValueError("No tool_use block in the model's response")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return response_model.model_validate(tool_use.input)
|
||||||
|
except ValidationError as exc:
|
||||||
|
last_error = exc
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise LLMResponseError(
|
||||||
|
f"Anthropic provider failed to produce a valid {response_model.__name__} after "
|
||||||
|
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||||
|
response = await self._client.messages.create(
|
||||||
|
model=self._settings.anthropic_model,
|
||||||
|
max_tokens=self._settings.llm_max_tokens_per_request,
|
||||||
|
system=system_prompt,
|
||||||
|
messages=[{"role": "user", "content": user_prompt}],
|
||||||
|
)
|
||||||
|
return "\n".join(block.text for block in response.content if block.type == "text")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""LLM provider interface. Every analysis task (app/prompts/*.py) is written
|
||||||
|
against this Protocol, never against a specific vendor SDK - swapping
|
||||||
|
`LLM_PROVIDER` changes which class `get_llm_provider()` returns and nothing
|
||||||
|
else has to change. `generate_structured` is the primary method: it always
|
||||||
|
returns a validated instance of the caller's Pydantic response model, never
|
||||||
|
raw text, so a malformed model response can never propagate un-typed data
|
||||||
|
into the rest of the app (see `LLMResponseError` / the repair loop in
|
||||||
|
anthropic_provider.py).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class LLMResponseError(Exception):
|
||||||
|
"""Raised when a provider can't produce a response matching the
|
||||||
|
requested schema, even after any repair attempts."""
|
||||||
|
|
||||||
|
|
||||||
|
class LLMProvider(Protocol):
|
||||||
|
provider_name: str
|
||||||
|
|
||||||
|
async def generate_structured[T: BaseModel](
|
||||||
|
self,
|
||||||
|
system_prompt: str,
|
||||||
|
user_prompt: str,
|
||||||
|
response_model: type[T],
|
||||||
|
) -> T: ...
|
||||||
|
|
||||||
|
async def generate_text(self, system_prompt: str, user_prompt: str) -> str: ...
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Resolves `LLM_PROVIDER` to a concrete provider instance. Never imported
|
||||||
|
directly by prompt task modules or services - always go through
|
||||||
|
`get_llm_provider()` so swapping providers stays a one-line config change.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.analysis.llm.base import LLMProvider
|
||||||
|
from app.analysis.llm.mock import MockLLMProvider
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
|
||||||
|
|
||||||
|
def get_llm_provider(settings: Settings | None = None) -> LLMProvider:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
|
||||||
|
if settings.llm_provider == "anthropic":
|
||||||
|
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
|
||||||
|
|
||||||
|
return AnthropicLLMProvider(settings)
|
||||||
|
|
||||||
|
if settings.llm_provider == "ollama":
|
||||||
|
from app.analysis.llm.ollama_provider import OllamaLLMProvider
|
||||||
|
|
||||||
|
return OllamaLLMProvider(settings)
|
||||||
|
|
||||||
|
if settings.llm_provider == "gemini":
|
||||||
|
from app.analysis.llm.gemini_provider import GeminiLLMProvider
|
||||||
|
|
||||||
|
return GeminiLLMProvider(settings)
|
||||||
|
|
||||||
|
return MockLLMProvider()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
"""Gemini provider: structured output via the SDK's native
|
||||||
|
`response_schema` support (the model is constrained to the schema and the
|
||||||
|
SDK parses the result into an instance of it directly), with the same
|
||||||
|
bounded repair loop on a malformed/unparsed response as
|
||||||
|
`anthropic_provider.py`. Chosen as the production LLM_PROVIDER option
|
||||||
|
alongside Anthropic because Gemini has an actual free rate-limited API
|
||||||
|
tier (gemini-2.0-flash), unlike OpenAI's expiring trial credits.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from google import genai
|
||||||
|
from google.genai import types
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from app.analysis.llm.base import LLMResponseError
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class GeminiLLMProvider:
|
||||||
|
provider_name = "gemini"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
self._client = genai.Client(api_key=settings.gemini_api_key)
|
||||||
|
|
||||||
|
async def generate_structured[T: BaseModel](
|
||||||
|
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||||
|
) -> T:
|
||||||
|
last_error: Exception | None = None
|
||||||
|
prompt = user_prompt
|
||||||
|
|
||||||
|
for attempt in range(self._settings.llm_max_retries + 1):
|
||||||
|
if attempt > 0 and last_error is not None:
|
||||||
|
prompt = (
|
||||||
|
f"{user_prompt}\n\nYour previous response did not match the required "
|
||||||
|
f"schema: {last_error}. Please try again."
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await self._client.aio.models.generate_content(
|
||||||
|
model=self._settings.gemini_model,
|
||||||
|
contents=prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=system_prompt,
|
||||||
|
response_mime_type="application/json",
|
||||||
|
response_schema=response_model,
|
||||||
|
max_output_tokens=self._settings.llm_max_tokens_per_request,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if response.parsed is None:
|
||||||
|
last_error = ValueError("Gemini did not return a parsed structured response")
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return response_model.model_validate(response.parsed)
|
||||||
|
except ValidationError as exc:
|
||||||
|
last_error = exc
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise LLMResponseError(
|
||||||
|
f"Gemini provider failed to produce a valid {response_model.__name__} after "
|
||||||
|
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||||
|
response = await self._client.aio.models.generate_content(
|
||||||
|
model=self._settings.gemini_model,
|
||||||
|
contents=user_prompt,
|
||||||
|
config=types.GenerateContentConfig(
|
||||||
|
system_instruction=system_prompt,
|
||||||
|
max_output_tokens=self._settings.llm_max_tokens_per_request,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return response.text or ""
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
"""Deterministic mock provider - the default (`LLM_PROVIDER=mock`) and what
|
||||||
|
every automated test runs against. Never calls a network or costs money.
|
||||||
|
|
||||||
|
Rather than a generic reflection-based filler, each of the six analysis
|
||||||
|
tasks gets a purpose-built, deterministic builder that reads the same
|
||||||
|
evidence block a real model would see (see app/prompts/base.py) and
|
||||||
|
produces genuinely useful output from it - real counts, real titles, real
|
||||||
|
severities - never fabricated facts. This is what makes the fixture demo
|
||||||
|
(Phase 9) work end-to-end without a paid API key.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from app.prompts.alert_summarization import AlertSummary
|
||||||
|
from app.prompts.base import extract_evidence_block
|
||||||
|
from app.prompts.change_significance import ChangeSignificanceAssessment
|
||||||
|
from app.prompts.company_profile import CompanyProfileExtraction
|
||||||
|
from app.prompts.extraction import ExtractionResult
|
||||||
|
from app.prompts.relevance import RelevanceAssessment
|
||||||
|
from app.prompts.report_generation import ReportContent
|
||||||
|
from app.prompts.synthesis import SynthesisResult
|
||||||
|
|
||||||
|
|
||||||
|
def _confidence_label(score: float) -> str:
|
||||||
|
if score >= 0.85:
|
||||||
|
return "confirmed"
|
||||||
|
if score >= 0.65:
|
||||||
|
return "strongly_indicated"
|
||||||
|
if score >= 0.45:
|
||||||
|
return "likely"
|
||||||
|
if score >= 0.25:
|
||||||
|
return "possible"
|
||||||
|
if score > 0:
|
||||||
|
return "unconfirmed"
|
||||||
|
return "insufficient_evidence"
|
||||||
|
|
||||||
|
|
||||||
|
def _build_relevance(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
text = (evidence.get("document_text") or "").lower()
|
||||||
|
focus = (evidence.get("monitoring_focus") or "").lower()
|
||||||
|
focus_words = [w for w in focus.split() if len(w) > 4]
|
||||||
|
matches_focus = any(w in text for w in focus_words) if focus_words else False
|
||||||
|
return {
|
||||||
|
"is_relevant": True,
|
||||||
|
"matches_focus": matches_focus,
|
||||||
|
"topic_categories": [],
|
||||||
|
"source_reliability": 0.7,
|
||||||
|
"reasoning": "Mock provider: keyword-based heuristic (no live LLM configured).",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_extraction(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
text = (evidence.get("document_text") or "").strip()
|
||||||
|
if not text:
|
||||||
|
return {"signals": []}
|
||||||
|
first_sentence = text.split(".")[0][:200].strip()
|
||||||
|
if not first_sentence:
|
||||||
|
return {"signals": []}
|
||||||
|
return {
|
||||||
|
"signals": [
|
||||||
|
{
|
||||||
|
"signal_type": "event",
|
||||||
|
"description": first_sentence,
|
||||||
|
"supporting_passage": first_sentence,
|
||||||
|
"date": None,
|
||||||
|
"entities": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_synthesis(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
signals = evidence.get("signals") or []
|
||||||
|
if len(signals) < 2:
|
||||||
|
return {"conclusions": []}
|
||||||
|
return {
|
||||||
|
"conclusions": [
|
||||||
|
{
|
||||||
|
"conclusion": (
|
||||||
|
"Multiple related signals were detected together; the mock provider "
|
||||||
|
"does not attempt fine-grained synthesis - configure a live LLM provider "
|
||||||
|
"for a specific conclusion."
|
||||||
|
),
|
||||||
|
"evidence_summary": [s.get("description", "") for s in signals[:5]],
|
||||||
|
"source_count": len(signals),
|
||||||
|
"confidence": 0.3,
|
||||||
|
"alternative_explanations": [
|
||||||
|
"A live LLM provider would assess this more precisely."
|
||||||
|
],
|
||||||
|
"missing_information": [],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_report(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
profile = evidence.get("company_profile") or {}
|
||||||
|
company_name = profile.get("name") or "The company"
|
||||||
|
documents = evidence.get("source_documents") or []
|
||||||
|
changes = evidence.get("detected_changes") or []
|
||||||
|
failed = evidence.get("sources_that_failed_to_collect") or []
|
||||||
|
|
||||||
|
# The discovered profile (real, from onboarding) is genuine evidence even
|
||||||
|
# when no monitoring run has collected source_documents/detected_changes
|
||||||
|
# yet - build company_overview/market_positioning from it honestly
|
||||||
|
# rather than defaulting straight to "insufficient evidence".
|
||||||
|
overview_parts = []
|
||||||
|
if profile.get("description"):
|
||||||
|
overview_parts.append(profile["description"])
|
||||||
|
facts = []
|
||||||
|
if profile.get("industry"):
|
||||||
|
facts.append(f"industry: {profile['industry']}")
|
||||||
|
if profile.get("headquarters"):
|
||||||
|
facts.append(f"headquartered in {profile['headquarters']}")
|
||||||
|
elif profile.get("country") or profile.get("region"):
|
||||||
|
facts.append(
|
||||||
|
f"based in {', '.join(f for f in (profile.get('country'), profile.get('region')) if f)}"
|
||||||
|
)
|
||||||
|
if profile.get("aliases"):
|
||||||
|
facts.append(f"also known as {', '.join(profile['aliases'])}")
|
||||||
|
if facts:
|
||||||
|
overview_parts.append(f"{company_name} ({'; '.join(facts)}).")
|
||||||
|
company_overview = " ".join(overview_parts) or f"No description on file for {company_name}."
|
||||||
|
|
||||||
|
if profile.get("competitors"):
|
||||||
|
market_positioning = (
|
||||||
|
f"{company_name} operates in a space that includes "
|
||||||
|
f"{', '.join(profile['competitors'])} as named competitors, per the discovered "
|
||||||
|
"company profile. No comparative data (pricing, features, market share) is "
|
||||||
|
"available to assess relative positioning."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
market_positioning = "Insufficient evidence to assess market positioning."
|
||||||
|
|
||||||
|
executive_summary = (
|
||||||
|
f"Mock analysis (LLM_PROVIDER=mock) based on {len(documents)} collected document(s) "
|
||||||
|
f"and {len(changes)} detected change(s) for {company_name}."
|
||||||
|
)
|
||||||
|
if failed:
|
||||||
|
executive_summary += (
|
||||||
|
f" {len(failed)} source(s) failed to collect this run and are excluded below."
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_developments = [
|
||||||
|
{
|
||||||
|
"headline": change.get("summary") or "Change detected",
|
||||||
|
"summary": (
|
||||||
|
f"{(change.get('change_type') or 'change').replace('_', ' ')} detected "
|
||||||
|
f"with {change.get('severity') or 'unknown'} severity."
|
||||||
|
),
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"detected_change_id": change.get("id"),
|
||||||
|
"description": change.get("summary") or "",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"confidence": _confidence_label(change.get("confidence_score") or 0.5),
|
||||||
|
"category": change.get("change_type"),
|
||||||
|
"date": change.get("created_at"),
|
||||||
|
}
|
||||||
|
for change in changes[:10]
|
||||||
|
]
|
||||||
|
|
||||||
|
hiring_signals = [
|
||||||
|
{
|
||||||
|
"headline": doc.get("title") or doc.get("url") or "Job posting",
|
||||||
|
"summary": (doc.get("excerpt") or "")[:280],
|
||||||
|
"evidence": [
|
||||||
|
{
|
||||||
|
"source_document_id": doc.get("id"),
|
||||||
|
"url": doc.get("url"),
|
||||||
|
"description": "Collected source document",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"confidence": "confirmed",
|
||||||
|
"category": "job_posting",
|
||||||
|
"date": doc.get("retrieved_date"),
|
||||||
|
}
|
||||||
|
for doc in documents
|
||||||
|
if doc.get("source_type") == "job_posting"
|
||||||
|
]
|
||||||
|
|
||||||
|
unknowns = (
|
||||||
|
[f"{len(failed)} source(s) failed to collect this run: {', '.join(failed[:5])}"]
|
||||||
|
if failed
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"executive_summary": executive_summary,
|
||||||
|
"company_overview": company_overview,
|
||||||
|
"products_and_services": [],
|
||||||
|
"market_positioning": market_positioning,
|
||||||
|
"recent_developments": recent_developments,
|
||||||
|
"strategic_initiatives": [],
|
||||||
|
"key_inferred_projects": [],
|
||||||
|
"leadership_changes": [
|
||||||
|
d for d in recent_developments if d["category"] == "leadership_change"
|
||||||
|
],
|
||||||
|
"hiring_signals": hiring_signals,
|
||||||
|
"technology_signals": [],
|
||||||
|
"patent_signals": [],
|
||||||
|
"manufacturing_and_expansion_signals": [],
|
||||||
|
"partnerships_and_acquisitions": [],
|
||||||
|
"financial_signals": [d for d in recent_developments if d["category"] == "filing_new"],
|
||||||
|
"regulatory_and_legal_signals": [],
|
||||||
|
"customer_sentiment": "Insufficient evidence to assess customer sentiment.",
|
||||||
|
"competitor_comparison": "Insufficient evidence to compare against competitors.",
|
||||||
|
"swot": {"strengths": [], "weaknesses": [], "opportunities": [], "threats": []},
|
||||||
|
"risks": [],
|
||||||
|
"opportunities": [],
|
||||||
|
"unknowns_and_missing_data": unknowns,
|
||||||
|
"monitoring_recommendations": ["Continue monitoring configured sources on schedule."],
|
||||||
|
"methodology": (
|
||||||
|
f"Generated by the mock LLM provider from {len(documents)} stored source "
|
||||||
|
f"document(s) and {len(changes)} deterministic change-detection result(s). No "
|
||||||
|
"external model was called."
|
||||||
|
),
|
||||||
|
"limitations": (
|
||||||
|
"Generated by the deterministic mock provider, not a live LLM. Set "
|
||||||
|
"LLM_PROVIDER=anthropic or LLM_PROVIDER=ollama for narrative synthesis."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_change_significance(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
severity = evidence.get("deterministic_severity") or "low"
|
||||||
|
confidence = evidence.get("deterministic_confidence") or 0.5
|
||||||
|
is_meaningful = severity in ("critical", "high", "medium")
|
||||||
|
change_type = (evidence.get("change_type") or "change").replace("_", " ")
|
||||||
|
return {
|
||||||
|
"is_real_change": True,
|
||||||
|
"is_meaningful": is_meaningful,
|
||||||
|
"why_it_matters": (
|
||||||
|
f"Deterministic scoring classified this {change_type} as {severity} severity "
|
||||||
|
f"with {confidence:.0%} confidence."
|
||||||
|
),
|
||||||
|
"confidence": confidence,
|
||||||
|
"should_notify": is_meaningful,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_alert_summary(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
company = evidence.get("company_name") or "The company"
|
||||||
|
change_type = (evidence.get("change_type") or "change").replace("_", " ")
|
||||||
|
severity = evidence.get("severity") or "medium"
|
||||||
|
confidence = evidence.get("confidence") or 0.5
|
||||||
|
return {
|
||||||
|
"title": f"{company}: {change_type} detected"[:100],
|
||||||
|
"summary": evidence.get("change_summary") or f"A {change_type} was detected for {company}.",
|
||||||
|
"why_it_matters": f"Classified as {severity} severity with {confidence:.0%} confidence.",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_HQ_RE = re.compile(r"(?:headquartered|based) in ([A-Z][\w\s,]{2,60}?)(?:[.\n]|$)", re.IGNORECASE)
|
||||||
|
_FORMERLY_RE = re.compile(
|
||||||
|
r"formerly (?:known as|named) ([A-Z][\w&\s]{2,60}?)(?:[.,\n]|$)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_company_profile(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Mirrors app/change_detection/extractors.py's philosophy: cheap,
|
||||||
|
deterministic regex heuristics over real evidence text, never a
|
||||||
|
fabricated guess. Most fields (industry/country/region/public
|
||||||
|
identifiers) stay empty since a name-only mock search has no real
|
||||||
|
signal for them - see search/mock.py's docstring for why that's
|
||||||
|
intentional, not a gap."""
|
||||||
|
homepage_text = evidence.get("homepage_text") or ""
|
||||||
|
search_results = evidence.get("search_results") or []
|
||||||
|
combined_text = homepage_text + "\n" + "\n".join(r.get("snippet", "") for r in search_results)
|
||||||
|
|
||||||
|
hq_match = _HQ_RE.search(combined_text)
|
||||||
|
headquarters = hq_match.group(1).strip() if hq_match else None
|
||||||
|
|
||||||
|
alias_match = _FORMERLY_RE.search(combined_text)
|
||||||
|
aliases = [alias_match.group(1).strip()] if alias_match else []
|
||||||
|
|
||||||
|
# First real sentence of the fetched homepage, if any - an honest,
|
||||||
|
# evidence-derived summary rather than a fabricated one.
|
||||||
|
first_sentence = re.split(r"(?<=[.!?])\s", homepage_text.strip(), maxsplit=1)[0].strip()
|
||||||
|
description = first_sentence[:280] if first_sentence and len(first_sentence) > 15 else None
|
||||||
|
|
||||||
|
return {
|
||||||
|
"description": description,
|
||||||
|
"industry": None,
|
||||||
|
"country": None,
|
||||||
|
"region": None,
|
||||||
|
"headquarters": headquarters,
|
||||||
|
"aliases": aliases,
|
||||||
|
"competitors": [],
|
||||||
|
"public_identifiers": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_BUILDERS = {
|
||||||
|
RelevanceAssessment: _build_relevance,
|
||||||
|
ExtractionResult: _build_extraction,
|
||||||
|
SynthesisResult: _build_synthesis,
|
||||||
|
ReportContent: _build_report,
|
||||||
|
ChangeSignificanceAssessment: _build_change_significance,
|
||||||
|
AlertSummary: _build_alert_summary,
|
||||||
|
CompanyProfileExtraction: _build_company_profile,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MockLLMProvider:
|
||||||
|
provider_name = "mock"
|
||||||
|
|
||||||
|
async def generate_structured[T: BaseModel](
|
||||||
|
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||||
|
) -> T:
|
||||||
|
evidence = extract_evidence_block(user_prompt)
|
||||||
|
builder = _BUILDERS.get(response_model)
|
||||||
|
data = builder(evidence) if builder is not None else {}
|
||||||
|
return response_model.model_validate(data)
|
||||||
|
|
||||||
|
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||||
|
evidence = extract_evidence_block(user_prompt)
|
||||||
|
return (
|
||||||
|
"[mock provider] No live LLM configured. "
|
||||||
|
f"{len(evidence)} evidence field(s) were provided for this request."
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Ollama provider: local models via Ollama's HTTP API. Uses JSON mode
|
||||||
|
(`format: "json"`) plus a bounded repair loop, since not every locally-run
|
||||||
|
model supports strict schema-constrained decoding the way Anthropic's
|
||||||
|
tool-use does - the schema is instead embedded in the system prompt as an
|
||||||
|
instruction.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
|
|
||||||
|
from app.analysis.llm.base import LLMResponseError
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
class OllamaLLMProvider:
|
||||||
|
provider_name = "ollama"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._settings = settings
|
||||||
|
|
||||||
|
async def generate_structured[T: BaseModel](
|
||||||
|
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||||
|
) -> T:
|
||||||
|
schema_instructions = (
|
||||||
|
f"{system_prompt}\n\nRespond with ONLY a single JSON object matching this JSON "
|
||||||
|
f"schema, no other text, no markdown fences:\n"
|
||||||
|
f"{json.dumps(response_model.model_json_schema())}"
|
||||||
|
)
|
||||||
|
|
||||||
|
last_error: Exception | None = None
|
||||||
|
prompt = user_prompt
|
||||||
|
|
||||||
|
for attempt in range(self._settings.llm_max_retries + 1):
|
||||||
|
if attempt > 0 and last_error is not None:
|
||||||
|
prompt = (
|
||||||
|
f"{user_prompt}\n\nYour previous response was invalid: {last_error}. "
|
||||||
|
"Try again, returning ONLY valid JSON matching the schema."
|
||||||
|
)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self._settings.ollama_base_url}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": self._settings.ollama_model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": schema_instructions},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"format": "json",
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
content = response.json().get("message", {}).get("content", "")
|
||||||
|
try:
|
||||||
|
data = json.loads(content)
|
||||||
|
return response_model.model_validate(data)
|
||||||
|
except (json.JSONDecodeError, ValidationError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
continue
|
||||||
|
|
||||||
|
raise LLMResponseError(
|
||||||
|
f"Ollama provider failed to produce a valid {response_model.__name__} after "
|
||||||
|
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||||
|
async with httpx.AsyncClient(timeout=120) as client:
|
||||||
|
response = await client.post(
|
||||||
|
f"{self._settings.ollama_base_url}/api/chat",
|
||||||
|
json={
|
||||||
|
"model": self._settings.ollama_model,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": system_prompt},
|
||||||
|
{"role": "user", "content": user_prompt},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json().get("message", {}).get("content", "")
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Public unban-request intake plus admin-only IP-ban management. Thin per
|
||||||
|
ARCHITECTURE.md - business logic lives in app.services.unban_service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_admin
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.core.security import get_client_ip
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.unban_request_repository import UnbanRequestRepository
|
||||||
|
from app.schemas.unban import (
|
||||||
|
BanIpRequest,
|
||||||
|
IpBanResponse,
|
||||||
|
UnbanRequestPayload,
|
||||||
|
UnbanRequestResponse,
|
||||||
|
)
|
||||||
|
from app.services import unban_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["admin"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/unban-requests", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
@limiter.limit("3/minute")
|
||||||
|
async def submit_unban_request(
|
||||||
|
request: Request,
|
||||||
|
payload: UnbanRequestPayload,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
client_ip = get_client_ip(request, settings)
|
||||||
|
await unban_service.submit_unban_request(db, settings, client_ip, payload.message)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/ip-bans", response_model=list[IpBanResponse])
|
||||||
|
async def list_ip_bans(
|
||||||
|
db: AsyncSession = Depends(get_db), _admin: User = Depends(require_admin)
|
||||||
|
) -> list[IpBanResponse]:
|
||||||
|
bans = await unban_service.list_ip_bans(db)
|
||||||
|
return [IpBanResponse.model_validate(b) for b in bans]
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/ip-bans/{ip_address}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_ip_ban(
|
||||||
|
ip_address: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
await unban_service.unban_ip(db, ip_address)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/ip-bans", response_model=IpBanResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_ip_ban(
|
||||||
|
payload: BanIpRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
) -> IpBanResponse:
|
||||||
|
ban = await unban_service.ban_ip(db, payload.ip_address)
|
||||||
|
return IpBanResponse.model_validate(ban)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/unban-requests", response_model=list[UnbanRequestResponse])
|
||||||
|
async def list_unban_requests(
|
||||||
|
db: AsyncSession = Depends(get_db), _admin: User = Depends(require_admin)
|
||||||
|
) -> list[UnbanRequestResponse]:
|
||||||
|
requests = await UnbanRequestRepository(db).list_all()
|
||||||
|
return [UnbanRequestResponse.model_validate(r) for r in requests]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/admin/unban-requests/{request_id}/accept", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def accept_unban_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
await unban_service.accept_unban_request(db, request_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/admin/unban-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def reject_unban_request(
|
||||||
|
request_id: uuid.UUID,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
await unban_service.reject_unban_request(db, request_id)
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
"""Alert routes: list with filters, detail (with delivery status), and
|
||||||
|
read/resolved mutation."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.enums import SeverityLevel
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.alert import AlertDetailResponse, AlertResponse, AlertUpdate
|
||||||
|
from app.services import alert_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/alerts", tags=["alerts"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[AlertResponse])
|
||||||
|
async def list_alerts(
|
||||||
|
company_id: uuid.UUID | None = None,
|
||||||
|
severity: SeverityLevel | None = None,
|
||||||
|
read: bool | None = None,
|
||||||
|
resolved: bool | None = None,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[AlertResponse]:
|
||||||
|
alerts = await alert_service.list_alerts(
|
||||||
|
db, user.id, company_id=company_id, severity=severity, read=read, resolved=resolved
|
||||||
|
)
|
||||||
|
return [AlertResponse.model_validate(a) for a in alerts]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{alert_id}", response_model=AlertDetailResponse)
|
||||||
|
async def get_alert(
|
||||||
|
alert_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AlertDetailResponse:
|
||||||
|
alert, deliveries = await alert_service.get_alert_with_deliveries(db, user.id, alert_id)
|
||||||
|
return AlertDetailResponse(
|
||||||
|
**AlertResponse.model_validate(alert).model_dump(), deliveries=deliveries
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{alert_id}", response_model=AlertResponse)
|
||||||
|
async def update_alert(
|
||||||
|
alert_id: uuid.UUID,
|
||||||
|
payload: AlertUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AlertResponse:
|
||||||
|
alert = await alert_service.update_alert(
|
||||||
|
db, user.id, alert_id, read=payload.read, resolved=payload.resolved
|
||||||
|
)
|
||||||
|
return AlertResponse.model_validate(alert)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{alert_id}/read", response_model=AlertResponse)
|
||||||
|
async def mark_alert_read(
|
||||||
|
alert_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AlertResponse:
|
||||||
|
alert = await alert_service.mark_read(db, user.id, alert_id)
|
||||||
|
return AlertResponse.model_validate(alert)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{alert_id}/resolve", response_model=AlertResponse)
|
||||||
|
async def resolve_alert(
|
||||||
|
alert_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> AlertResponse:
|
||||||
|
alert = await alert_service.mark_resolved(db, user.id, alert_id)
|
||||||
|
return AlertResponse.model_validate(alert)
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"""Auth routes. Thin per ARCHITECTURE.md: parse/validate, call one service
|
||||||
|
method, map the result. All rules live in app.services.auth_service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.errors import ValidationAppError
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.core.security import get_client_ip, is_localhost
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import LOCAL_DEV_USER_ID, User
|
||||||
|
from app.schemas.auth import (
|
||||||
|
ConfirmPasswordResetRequest,
|
||||||
|
LoginRequest,
|
||||||
|
LogoutRequest,
|
||||||
|
RefreshRequest,
|
||||||
|
RegisterRequest,
|
||||||
|
RequestPasswordResetRequest,
|
||||||
|
ResendVerificationRequest,
|
||||||
|
SecurityEventResponse,
|
||||||
|
TokenResponse,
|
||||||
|
VerifyEmailRequest,
|
||||||
|
)
|
||||||
|
from app.schemas.user import MeResponse, UserResponse
|
||||||
|
from app.services import auth_service, system_secret_service
|
||||||
|
from app.services.turnstile_service import turnstile_required, verify_turnstile
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
||||||
|
async def _enforce_turnstile(
|
||||||
|
request: Request, settings: Settings, db: AsyncSession, token: str | None, client_ip: str
|
||||||
|
) -> None:
|
||||||
|
"""Required for register/login/request-password-reset when the caller
|
||||||
|
isn't on loopback and a secret is configured - skipped entirely
|
||||||
|
otherwise (see turnstile_service.turnstile_required). The secret may be
|
||||||
|
admin-configured (system_secret_service) rather than only .env-set."""
|
||||||
|
effective = await system_secret_service.get_effective_settings(db, settings)
|
||||||
|
if not turnstile_required(is_localhost(request, settings), effective):
|
||||||
|
return
|
||||||
|
if not token or not await verify_turnstile(token, client_ip, effective):
|
||||||
|
raise ValidationAppError("Captcha verification required.")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def register(
|
||||||
|
request: Request,
|
||||||
|
payload: RegisterRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> UserResponse:
|
||||||
|
client_ip = get_client_ip(request, settings)
|
||||||
|
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
||||||
|
user = await auth_service.register(db, settings, client_ip, payload)
|
||||||
|
return UserResponse.model_validate(user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/login", response_model=TokenResponse)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def login(
|
||||||
|
request: Request,
|
||||||
|
payload: LoginRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> TokenResponse:
|
||||||
|
client_ip = get_client_ip(request, settings)
|
||||||
|
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
||||||
|
return await auth_service.login(db, settings, client_ip, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/refresh", response_model=TokenResponse)
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
async def refresh_token(
|
||||||
|
request: Request,
|
||||||
|
payload: RefreshRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> TokenResponse:
|
||||||
|
return await auth_service.refresh(db, settings, payload.refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def logout(
|
||||||
|
payload: LogoutRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
await auth_service.logout(db, settings, payload.refresh_token)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def verify_email(
|
||||||
|
request: Request,
|
||||||
|
payload: VerifyEmailRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
client_ip = get_client_ip(request, settings)
|
||||||
|
await auth_service.verify_email(db, client_ip, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def resend_verification(
|
||||||
|
request: Request,
|
||||||
|
payload: ResendVerificationRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
client_ip = get_client_ip(request, settings)
|
||||||
|
await auth_service.resend_verification(db, settings, client_ip, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/request-password-reset", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def request_password_reset(
|
||||||
|
request: Request,
|
||||||
|
payload: RequestPasswordResetRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
client_ip = get_client_ip(request, settings)
|
||||||
|
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
||||||
|
await auth_service.request_password_reset(db, settings, client_ip, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/confirm-password-reset", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def confirm_password_reset(
|
||||||
|
request: Request,
|
||||||
|
payload: ConfirmPasswordResetRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
client_ip = get_client_ip(request, settings)
|
||||||
|
await auth_service.confirm_password_reset(db, client_ip, payload)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/security-events", response_model=list[SecurityEventResponse])
|
||||||
|
async def security_events(
|
||||||
|
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)
|
||||||
|
) -> list[SecurityEventResponse]:
|
||||||
|
"""The calling user's own security activity - not admin-gated, it's
|
||||||
|
their own data (see the app-wide admin log feed in app.api.v1.system for
|
||||||
|
the operational counterpart)."""
|
||||||
|
events = await auth_service.list_security_events(db, user.id)
|
||||||
|
return [SecurityEventResponse.model_validate(e) for e in events]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/me", response_model=MeResponse)
|
||||||
|
async def me(user: User = Depends(get_current_user)) -> MeResponse:
|
||||||
|
# Reports what actually happened for *this* request, not the raw
|
||||||
|
# AUTH_MODE setting - the fixed local-dev user only ever comes back via
|
||||||
|
# the loopback bypass (see get_current_user), so its id is a reliable
|
||||||
|
# per-request signal even though the setting itself is almost always
|
||||||
|
# "local".
|
||||||
|
effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt"
|
||||||
|
base = UserResponse.model_validate(user).model_dump()
|
||||||
|
return MeResponse(**base, auth_mode=effective_auth_mode)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
"""Company + monitor-configuration routes. Thin per ARCHITECTURE.md."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.analysis.llm.factory import get_llm_provider
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.errors import NotFoundError
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.company import (
|
||||||
|
CompanyCreate,
|
||||||
|
CompanyResponse,
|
||||||
|
CompanyUpdate,
|
||||||
|
MonitorConfigurationResponse,
|
||||||
|
MonitorConfigurationUpdate,
|
||||||
|
)
|
||||||
|
from app.schemas.discovery import DiscoverCompanyRequest, DiscoveredCompanyProfile
|
||||||
|
from app.search.factory import get_search_provider
|
||||||
|
from app.services import company_service, discovery_service, user_api_key_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/companies", tags=["companies"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[CompanyResponse])
|
||||||
|
async def list_companies(
|
||||||
|
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> list[CompanyResponse]:
|
||||||
|
companies = await company_service.list_companies(db, user.id)
|
||||||
|
return [CompanyResponse.from_company(c) for c in companies]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CompanyResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
async def create_company(
|
||||||
|
request: Request,
|
||||||
|
payload: CompanyCreate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> CompanyResponse:
|
||||||
|
settings = await user_api_key_service.get_effective_settings(db, user.id, settings)
|
||||||
|
company = await company_service.create_company(db, settings, user.id, payload)
|
||||||
|
return CompanyResponse.from_company(company)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/discover", response_model=DiscoveredCompanyProfile)
|
||||||
|
@limiter.limit("5/minute")
|
||||||
|
async def discover_company(
|
||||||
|
request: Request,
|
||||||
|
payload: DiscoverCompanyRequest,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> DiscoveredCompanyProfile:
|
||||||
|
"""Proposes a company profile from just a name - persists nothing. The
|
||||||
|
wizard's "Discover" step calls this, then lets the user edit the result
|
||||||
|
before the existing POST /companies actually creates anything. Tightly
|
||||||
|
rate-limited: unlike everything else in this router, this costs a real
|
||||||
|
search + LLM call per invocation."""
|
||||||
|
settings = await user_api_key_service.get_effective_settings(db, user.id, settings)
|
||||||
|
search = get_search_provider(settings)
|
||||||
|
llm = get_llm_provider(settings)
|
||||||
|
return await discovery_service.discover_company_profile(
|
||||||
|
search,
|
||||||
|
llm,
|
||||||
|
settings,
|
||||||
|
name=payload.name,
|
||||||
|
official_website=payload.official_website,
|
||||||
|
monitoring_focus=payload.monitoring_focus,
|
||||||
|
competitor_names=payload.competitor_names,
|
||||||
|
alias_names=payload.alias_names,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{company_id}", response_model=CompanyResponse)
|
||||||
|
async def get_company(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> CompanyResponse:
|
||||||
|
company = await company_service.get_company(db, user.id, company_id)
|
||||||
|
return CompanyResponse.from_company(company)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{company_id}", response_model=CompanyResponse)
|
||||||
|
async def update_company(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
payload: CompanyUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> CompanyResponse:
|
||||||
|
company = await company_service.update_company(db, user.id, company_id, payload)
|
||||||
|
return CompanyResponse.from_company(company)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_company(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
await company_service.delete_company(db, user.id, company_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{company_id}/pause", response_model=CompanyResponse)
|
||||||
|
async def pause_company(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> CompanyResponse:
|
||||||
|
company = await company_service.pause_company(db, user.id, company_id)
|
||||||
|
return CompanyResponse.from_company(company)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{company_id}/resume", response_model=CompanyResponse)
|
||||||
|
async def resume_company(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> CompanyResponse:
|
||||||
|
company = await company_service.resume_company(db, user.id, company_id)
|
||||||
|
return CompanyResponse.from_company(company)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{company_id}/monitor", response_model=MonitorConfigurationResponse)
|
||||||
|
async def get_monitor_configuration(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> MonitorConfigurationResponse:
|
||||||
|
company = await company_service.get_company(db, user.id, company_id)
|
||||||
|
if company.monitor_configuration is None:
|
||||||
|
raise NotFoundError("Monitor configuration not found")
|
||||||
|
return MonitorConfigurationResponse.model_validate(company.monitor_configuration)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{company_id}/monitor", response_model=MonitorConfigurationResponse)
|
||||||
|
async def update_monitor_configuration(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
payload: MonitorConfigurationUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> MonitorConfigurationResponse:
|
||||||
|
config = await company_service.update_monitor_configuration(
|
||||||
|
db, settings, user.id, company_id, payload
|
||||||
|
)
|
||||||
|
return MonitorConfigurationResponse.model_validate(config)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
"""Dashboard-level aggregate analytics, scoped to the current user across
|
||||||
|
every company they own - see app/services/analytics_service.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.dashboard import DashboardAnalytics
|
||||||
|
from app.services import analytics_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/analytics", response_model=DashboardAnalytics)
|
||||||
|
async def get_dashboard_analytics(
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> DashboardAnalytics:
|
||||||
|
return await analytics_service.get_dashboard_analytics(db, user.id)
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""Monitoring run routes: run-now, run history, single-run status polling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.monitoring import MonitoringRunResponse
|
||||||
|
from app.services import monitoring_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["monitoring"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/companies/{company_id}/run",
|
||||||
|
response_model=MonitoringRunResponse,
|
||||||
|
status_code=status.HTTP_202_ACCEPTED,
|
||||||
|
)
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
async def run_company_now(
|
||||||
|
request: Request,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> MonitoringRunResponse:
|
||||||
|
run = await monitoring_service.enqueue_run_now(db, settings, user.id, company_id)
|
||||||
|
return MonitoringRunResponse.model_validate(run)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/companies/{company_id}/runs", response_model=list[MonitoringRunResponse])
|
||||||
|
async def list_company_runs(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[MonitoringRunResponse]:
|
||||||
|
runs = await monitoring_service.list_runs(db, user.id, company_id)
|
||||||
|
return [MonitoringRunResponse.model_validate(r) for r in runs]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}", response_model=MonitoringRunResponse)
|
||||||
|
async def get_run(
|
||||||
|
run_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> MonitoringRunResponse:
|
||||||
|
run = await monitoring_service.get_run(db, user.id, run_id)
|
||||||
|
return MonitoringRunResponse.model_validate(run)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.notification_destination import (
|
||||||
|
NotificationDestinationCreate,
|
||||||
|
NotificationDestinationResponse,
|
||||||
|
NotificationDestinationUpdate,
|
||||||
|
NotificationTestResult,
|
||||||
|
)
|
||||||
|
from app.services import alert_service
|
||||||
|
from app.services import notification_destination_service as service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/notification-destinations", tags=["notifications"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[NotificationDestinationResponse])
|
||||||
|
async def list_destinations(
|
||||||
|
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> list[NotificationDestinationResponse]:
|
||||||
|
destinations = await service.list_destinations(db, user.id)
|
||||||
|
return [NotificationDestinationResponse.from_destination(d) for d in destinations]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"", response_model=NotificationDestinationResponse, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
async def create_destination(
|
||||||
|
request: Request,
|
||||||
|
payload: NotificationDestinationCreate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NotificationDestinationResponse:
|
||||||
|
destination = await service.create_destination(db, user.id, payload)
|
||||||
|
return NotificationDestinationResponse.from_destination(destination)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{destination_id}", response_model=NotificationDestinationResponse)
|
||||||
|
async def update_destination(
|
||||||
|
destination_id: uuid.UUID,
|
||||||
|
payload: NotificationDestinationUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NotificationDestinationResponse:
|
||||||
|
destination = await service.update_destination(db, user.id, destination_id, payload)
|
||||||
|
return NotificationDestinationResponse.from_destination(destination)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{destination_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_destination(
|
||||||
|
destination_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
await service.delete_destination(db, user.id, destination_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{destination_id}/companies/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def unlink_company(
|
||||||
|
destination_id: uuid.UUID,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
"""Unlinks one company from a destination without deleting it outright
|
||||||
|
- a destination shared across several companies must survive losing
|
||||||
|
just one of them. If this was its last link, it's garbage-collected
|
||||||
|
the same way a company deletion already orphans-and-removes one."""
|
||||||
|
await service.unlink_company(db, user.id, destination_id, company_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{destination_id}/test", response_model=NotificationTestResult)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def test_destination(
|
||||||
|
request: Request,
|
||||||
|
destination_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> NotificationTestResult:
|
||||||
|
result = await alert_service.send_test_notification(db, settings, user.id, destination_id)
|
||||||
|
return NotificationTestResult(success=result.success, error=result.error)
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Report routes: list/detail, manual generation, and raw markdown/json
|
||||||
|
export - mixes `/companies/{company_id}/reports...` and `/reports/{id}...`
|
||||||
|
paths per the spec, same pattern as sources.py/monitoring.py."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, Response, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.analysis.llm.factory import get_llm_provider
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.report import ReportListItem, ReportResponse
|
||||||
|
from app.services import report_service, user_api_key_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["reports"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/companies/{company_id}/reports", response_model=list[ReportListItem])
|
||||||
|
async def list_reports(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[ReportListItem]:
|
||||||
|
reports = await report_service.list_reports(db, user.id, company_id)
|
||||||
|
return [ReportListItem.model_validate(r) for r in reports]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/companies/{company_id}/reports/generate",
|
||||||
|
response_model=ReportResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
@limiter.limit("10/minute")
|
||||||
|
async def generate_report(
|
||||||
|
request: Request,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ReportResponse:
|
||||||
|
settings = await user_api_key_service.get_effective_settings(db, user.id, settings)
|
||||||
|
llm = get_llm_provider(settings)
|
||||||
|
report = await report_service.generate_report_now(db, settings, llm, user.id, company_id)
|
||||||
|
return ReportResponse.model_validate(report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/{report_id}", response_model=ReportResponse)
|
||||||
|
async def get_report(
|
||||||
|
report_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> ReportResponse:
|
||||||
|
report = await report_service.get_report(db, user.id, report_id)
|
||||||
|
return ReportResponse.model_validate(report)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/{report_id}/markdown", response_class=Response)
|
||||||
|
async def get_report_markdown(
|
||||||
|
report_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> Response:
|
||||||
|
report = await report_service.get_report(db, user.id, report_id)
|
||||||
|
return Response(content=report.markdown_content, media_type="text/markdown")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/reports/{report_id}/json")
|
||||||
|
async def get_report_json(
|
||||||
|
report_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> dict:
|
||||||
|
report = await report_service.get_report(db, user.id, report_id)
|
||||||
|
return report.structured_report
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Aggregates all /api/v1 routers. Individual routers are added here as each
|
||||||
|
phase implements them - keeps main.py stable."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter
|
||||||
|
|
||||||
|
from app.api.v1 import (
|
||||||
|
admin,
|
||||||
|
alerts,
|
||||||
|
auth,
|
||||||
|
companies,
|
||||||
|
dashboard,
|
||||||
|
monitoring,
|
||||||
|
notification_destinations,
|
||||||
|
reports,
|
||||||
|
snapshots,
|
||||||
|
sources,
|
||||||
|
system,
|
||||||
|
user_api_keys,
|
||||||
|
)
|
||||||
|
|
||||||
|
api_v1_router = APIRouter(prefix="/api/v1")
|
||||||
|
api_v1_router.include_router(system.router)
|
||||||
|
api_v1_router.include_router(auth.router)
|
||||||
|
api_v1_router.include_router(admin.router)
|
||||||
|
api_v1_router.include_router(user_api_keys.router)
|
||||||
|
api_v1_router.include_router(companies.router)
|
||||||
|
api_v1_router.include_router(notification_destinations.router)
|
||||||
|
api_v1_router.include_router(sources.router)
|
||||||
|
api_v1_router.include_router(snapshots.router)
|
||||||
|
api_v1_router.include_router(monitoring.router)
|
||||||
|
api_v1_router.include_router(reports.router)
|
||||||
|
api_v1_router.include_router(alerts.router)
|
||||||
|
api_v1_router.include_router(dashboard.router)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Read-only snapshot-history route. Snapshots are written internally by
|
||||||
|
collection_service.py during monitoring runs - nothing here creates one."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.snapshot import SnapshotResponse
|
||||||
|
from app.services import snapshot_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["snapshots"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/companies/{company_id}/snapshots", response_model=list[SnapshotResponse])
|
||||||
|
async def list_snapshots(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[SnapshotResponse]:
|
||||||
|
snapshots = await snapshot_service.list_snapshots(db, user.id, company_id)
|
||||||
|
return [SnapshotResponse.model_validate(s) for s in snapshots]
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""Source routes. Mixes `/companies/{company_id}/sources` and
|
||||||
|
`/sources/{source_id}` paths per the spec - kept in one router since both
|
||||||
|
share the same schemas/service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.source import SourceCreate, SourceResponse, SourceTestResult, SourceUpdate
|
||||||
|
from app.services import source_service
|
||||||
|
|
||||||
|
router = APIRouter(tags=["sources"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/companies/{company_id}/sources", response_model=list[SourceResponse])
|
||||||
|
async def list_sources(
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> list[SourceResponse]:
|
||||||
|
sources = await source_service.list_sources(db, user.id, company_id)
|
||||||
|
return [SourceResponse.model_validate(s) for s in sources]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/companies/{company_id}/sources",
|
||||||
|
response_model=SourceResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
@limiter.limit("30/minute")
|
||||||
|
async def create_source(
|
||||||
|
request: Request,
|
||||||
|
company_id: uuid.UUID,
|
||||||
|
payload: SourceCreate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> SourceResponse:
|
||||||
|
source = await source_service.create_source(db, user.id, company_id, payload)
|
||||||
|
return SourceResponse.model_validate(source)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sources/{source_id}", response_model=SourceResponse)
|
||||||
|
async def update_source(
|
||||||
|
source_id: uuid.UUID,
|
||||||
|
payload: SourceUpdate,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> SourceResponse:
|
||||||
|
source = await source_service.update_source(db, settings, user.id, source_id, payload)
|
||||||
|
return SourceResponse.model_validate(source)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_source(
|
||||||
|
source_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> None:
|
||||||
|
await source_service.delete_source(db, user.id, source_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sources/{source_id}/test", response_model=SourceTestResult)
|
||||||
|
@limiter.limit("20/minute")
|
||||||
|
async def test_source(
|
||||||
|
request: Request,
|
||||||
|
source_id: uuid.UUID,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> SourceTestResult:
|
||||||
|
result = await source_service.test_source(db, settings, user.id, source_id)
|
||||||
|
return SourceTestResult(
|
||||||
|
status=result.status, documents_found=len(result.documents), error=result.error
|
||||||
|
)
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
"""Health/readiness/system-status endpoints.
|
||||||
|
|
||||||
|
Kept dependency-light on purpose: `/health` must answer even if the database
|
||||||
|
or Redis is down, so infra can tell "process is up" apart from "process is
|
||||||
|
ready to serve traffic".
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import redis.asyncio as redis_asyncio
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import require_admin
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.logging import get_logger, get_recent_logs
|
||||||
|
from app.core.security import get_client_ip, is_localhost
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.enums import SystemSecretKey
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.system_secret import SetSystemSecretRequest, SystemSecretStatus
|
||||||
|
from app.services import system_secret_service
|
||||||
|
from app.services.enrichment_service import estimate_max_credits_per_company
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(tags=["system"])
|
||||||
|
|
||||||
|
|
||||||
|
class HealthResponse(BaseModel):
|
||||||
|
status: Literal["ok"] = "ok"
|
||||||
|
app_name: str
|
||||||
|
|
||||||
|
|
||||||
|
class ComponentStatus(BaseModel):
|
||||||
|
name: str
|
||||||
|
status: Literal["ok", "error"]
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ReadyResponse(BaseModel):
|
||||||
|
status: Literal["ready", "not_ready"]
|
||||||
|
components: list[ComponentStatus]
|
||||||
|
|
||||||
|
|
||||||
|
class SystemStatusResponse(BaseModel):
|
||||||
|
app_env: str
|
||||||
|
auth_mode: str
|
||||||
|
llm_provider: str
|
||||||
|
search_provider: str
|
||||||
|
sms_enabled: bool
|
||||||
|
sms_provider: str
|
||||||
|
ninjapear_configured: bool
|
||||||
|
ninjapear_credit_balance: int | None
|
||||||
|
ninjapear_estimated_credits_per_company: int | None
|
||||||
|
is_localhost: bool
|
||||||
|
# Public by design (meant to be embedded in the frontend bundle/page) -
|
||||||
|
# resolved through system_secret_service so an admin-updated value here
|
||||||
|
# takes effect immediately, without a frontend rebuild. None when
|
||||||
|
# Turnstile isn't configured at all (neither .env nor admin-set).
|
||||||
|
turnstile_site_key: str | None
|
||||||
|
components: list[ComponentStatus]
|
||||||
|
|
||||||
|
|
||||||
|
class LogEntryResponse(BaseModel):
|
||||||
|
ts: str
|
||||||
|
level: str
|
||||||
|
category: str
|
||||||
|
logger: str
|
||||||
|
event: str
|
||||||
|
context: dict
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health", response_model=HealthResponse)
|
||||||
|
async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||||
|
return HealthResponse(app_name=settings.app_name)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_database(db: AsyncSession) -> ComponentStatus:
|
||||||
|
try:
|
||||||
|
await db.execute(text("SELECT 1"))
|
||||||
|
return ComponentStatus(name="database", status="ok")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
return ComponentStatus(name="database", status="error", detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_redis(settings: Settings) -> ComponentStatus:
|
||||||
|
try:
|
||||||
|
client = redis_asyncio.from_url(settings.redis_url, socket_connect_timeout=2)
|
||||||
|
await client.ping()
|
||||||
|
await client.aclose()
|
||||||
|
return ComponentStatus(name="redis", status="ok")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
return ComponentStatus(name="redis", status="error", detail=str(exc))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ready", response_model=ReadyResponse)
|
||||||
|
async def ready(
|
||||||
|
settings: Settings = Depends(get_settings), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> ReadyResponse:
|
||||||
|
components = [await _check_database(db), await _check_redis(settings)]
|
||||||
|
overall = "ready" if all(c.status == "ok" for c in components) else "not_ready"
|
||||||
|
return ReadyResponse(status=overall, components=components)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_ninjapear_credit_balance(settings: Settings) -> int | None:
|
||||||
|
"""Free endpoint, safe to call on every status check - never lets a
|
||||||
|
failure here break the rest of /system/status."""
|
||||||
|
if not settings.ninjapear_api_key:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=10) as client:
|
||||||
|
response = await client.get(
|
||||||
|
"https://nubela.co/api/v1/meta/credit-balance",
|
||||||
|
headers={"Authorization": f"Bearer {settings.ninjapear_api_key}"},
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
data = response.json()
|
||||||
|
return data.get("credit_balance") or data.get("balance")
|
||||||
|
except Exception as exc: # pragma: no cover - defensive, status must not 500 on this
|
||||||
|
logger.warning("ninjapear_credit_balance_check_failed", error=str(exc))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/status", response_model=SystemStatusResponse)
|
||||||
|
async def system_status(
|
||||||
|
request: Request, settings: Settings = Depends(get_settings), db: AsyncSession = Depends(get_db)
|
||||||
|
) -> SystemStatusResponse:
|
||||||
|
components = [await _check_database(db), await _check_redis(settings)]
|
||||||
|
effective = await system_secret_service.get_effective_settings(db, settings)
|
||||||
|
return SystemStatusResponse(
|
||||||
|
app_env=settings.app_env,
|
||||||
|
auth_mode=settings.auth_mode,
|
||||||
|
llm_provider=settings.llm_provider,
|
||||||
|
search_provider=settings.search_provider,
|
||||||
|
sms_enabled=settings.notification_sms_enabled,
|
||||||
|
sms_provider=settings.sms_provider,
|
||||||
|
ninjapear_configured=bool(settings.ninjapear_api_key),
|
||||||
|
ninjapear_credit_balance=await _get_ninjapear_credit_balance(settings),
|
||||||
|
ninjapear_estimated_credits_per_company=(
|
||||||
|
estimate_max_credits_per_company(settings.ninjapear_max_leadership_lookups)
|
||||||
|
if settings.ninjapear_api_key
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
is_localhost=is_localhost(request, settings),
|
||||||
|
turnstile_site_key=effective.turnstile_site_key or None,
|
||||||
|
components=components,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/secrets", response_model=list[SystemSecretStatus])
|
||||||
|
async def list_system_secrets(
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_admin: User = Depends(require_admin),
|
||||||
|
) -> list[SystemSecretStatus]:
|
||||||
|
"""Admin-only visibility into server-wide secrets (Turnstile site
|
||||||
|
key/secret) - see app.services.system_secret_service."""
|
||||||
|
statuses = await system_secret_service.list_status(db, settings)
|
||||||
|
return [SystemSecretStatus(**s) for s in statuses]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/system/secrets/{key}", response_model=SystemSecretStatus)
|
||||||
|
async def set_system_secret(
|
||||||
|
request: Request,
|
||||||
|
key: SystemSecretKey,
|
||||||
|
payload: SetSystemSecretRequest,
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
admin: User = Depends(require_admin),
|
||||||
|
) -> SystemSecretStatus:
|
||||||
|
await system_secret_service.set_secret(
|
||||||
|
db,
|
||||||
|
key,
|
||||||
|
payload.value,
|
||||||
|
settings,
|
||||||
|
admin_user_id=admin.id,
|
||||||
|
client_ip=get_client_ip(request, settings),
|
||||||
|
)
|
||||||
|
statuses = await system_secret_service.list_status(db, settings)
|
||||||
|
match = next(s for s in statuses if s["key"] == key.value)
|
||||||
|
return SystemSecretStatus(**match)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/system/logs", response_model=list[LogEntryResponse])
|
||||||
|
async def system_logs(
|
||||||
|
settings: Settings = Depends(get_settings), _admin: User = Depends(require_admin)
|
||||||
|
) -> list[LogEntryResponse]:
|
||||||
|
"""Most-recent-first view into the application's live log stream (capped
|
||||||
|
at the last 500 entries app-wide, see `core/logging.py`)."""
|
||||||
|
return [LogEntryResponse(**entry) for entry in await get_recent_logs(settings, limit=100)]
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Per-user API key management - each user's own keys, visible and
|
||||||
|
editable only by themselves. Thin per ARCHITECTURE.md - logic lives in
|
||||||
|
app.services.user_api_key_service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.auth.dependencies import get_current_user
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.security import get_client_ip
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.enums import ApiKeyProvider
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.user_api_key import SetUserApiKeyRequest, UserApiKeyStatus
|
||||||
|
from app.services import user_api_key_service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/user-api-keys", tags=["user-api-keys"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[UserApiKeyStatus])
|
||||||
|
async def list_user_api_keys(
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> list[UserApiKeyStatus]:
|
||||||
|
statuses = await user_api_key_service.list_status(db, user.id, settings)
|
||||||
|
return [UserApiKeyStatus(**s) for s in statuses]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{provider}", response_model=UserApiKeyStatus)
|
||||||
|
async def set_user_api_key(
|
||||||
|
request: Request,
|
||||||
|
provider: ApiKeyProvider,
|
||||||
|
payload: SetUserApiKeyRequest,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> UserApiKeyStatus:
|
||||||
|
await user_api_key_service.set_key(
|
||||||
|
db, user.id, provider, payload.key, settings, client_ip=get_client_ip(request, settings)
|
||||||
|
)
|
||||||
|
statuses = await user_api_key_service.list_status(db, user.id, settings)
|
||||||
|
match = next(s for s in statuses if s["provider"] == provider.value)
|
||||||
|
return UserApiKeyStatus(**match)
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""FastAPI dependency implementing the `AuthProvider` contract described in
|
||||||
|
ARCHITECTURE.md: `get_current_user` always returns a `User` row or raises
|
||||||
|
401, regardless of caller. This is the seam a future Firebase Auth
|
||||||
|
integration would replace.
|
||||||
|
|
||||||
|
When AUTH_MODE=local (the default), the fixed local-dev user is only
|
||||||
|
returned to a request that's actually from loopback (see
|
||||||
|
`app.core.security.is_localhost`) - anyone reaching the API from a LAN or
|
||||||
|
WAN connection still needs a real bearer token, even with that setting.
|
||||||
|
AUTH_MODE=jwt disables the loopback convenience entirely (required in
|
||||||
|
production, see `Settings._forbid_local_auth_in_production`).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Depends, Header, HTTPException, Request, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.security import (
|
||||||
|
InvalidTokenError,
|
||||||
|
TokenType,
|
||||||
|
decode_token,
|
||||||
|
get_client_ip,
|
||||||
|
is_localhost,
|
||||||
|
)
|
||||||
|
from app.db.session import get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.user_repository import UserRepository
|
||||||
|
from app.services.auth_service import get_or_create_local_user
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
request: Request,
|
||||||
|
authorization: str | None = Header(default=None),
|
||||||
|
settings: Settings = Depends(get_settings),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
) -> User:
|
||||||
|
if settings.auth_mode == "local" and is_localhost(request, settings):
|
||||||
|
return await get_or_create_local_user(db, get_client_ip(request, settings))
|
||||||
|
|
||||||
|
if authorization is None or not authorization.lower().startswith("bearer "):
|
||||||
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||||
|
|
||||||
|
token = authorization.split(" ", 1)[1]
|
||||||
|
try:
|
||||||
|
decoded = decode_token(token, settings, TokenType.ACCESS)
|
||||||
|
except InvalidTokenError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired access token",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
repo = UserRepository(db)
|
||||||
|
user = await repo.get_by_id(decoded.user_id)
|
||||||
|
if user is None or not user.is_active:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
|
detail="Invalid or expired access token",
|
||||||
|
)
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||||
|
if not user.is_admin:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required"
|
||||||
|
)
|
||||||
|
return user
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Lightweight, best-effort regex extractors for specific signal types the
|
||||||
|
severity model treats specially (pricing, leadership). These are heuristics,
|
||||||
|
not NLP - they exist to catch the common "$X/month" and "named a new CEO"
|
||||||
|
phrasings, not to parse arbitrary text reliably. Phase 7's LLM extraction
|
||||||
|
task is the higher-fidelity version of this; these run cheaply and
|
||||||
|
deterministically as part of scoring, without a model call.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_PRICE_RE = re.compile(r"\$\s?\d[\d,]*(?:\.\d{2})?\s*(?:/\s*(?:month|mo|year|yr))?")
|
||||||
|
_LEADERSHIP_TITLE_RE = re.compile(
|
||||||
|
r"(?i)\b(Chief Executive Officer|CEO|Chief Financial Officer|CFO|Chief Technology Officer|"
|
||||||
|
r"CTO|President|Chairman|Chairwoman|Chairperson)\b"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_prices(text: str) -> set[str]:
|
||||||
|
return set(_PRICE_RE.findall(text or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def mentions_leadership_title(text: str) -> bool:
|
||||||
|
return bool(_LEADERSHIP_TITLE_RE.search(text or ""))
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Strips content that changes on every fetch but carries no meaning, so it
|
||||||
|
never counts toward a text diff. Applied before Layer 3 (text diff) - see
|
||||||
|
ARCHITECTURE.md and spec section 18.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_NOISE_PATTERNS = [
|
||||||
|
# Dynamic timestamps: "Updated: 2026-01-01", "Last modified 01/02/2026 14:30"
|
||||||
|
re.compile(r"(?i)\b(updated|last modified|generated|retrieved)\s*:?\s*[\d/:\-\sTZ]+"),
|
||||||
|
# Session/CSRF-style tokens embedded in visible text (rare, but happens on thin pages)
|
||||||
|
re.compile(r"\b[A-Za-z0-9_-]{24,}\b"),
|
||||||
|
# Cookie/consent banner boilerplate
|
||||||
|
re.compile(r"(?i)we use cookies[^.]*\.?"),
|
||||||
|
re.compile(r"(?i)by (continuing|using this site)[^.]*\.?"),
|
||||||
|
# Copyright year lines, which change every January with no real signal
|
||||||
|
re.compile(r"(?i)copyright\s*(?:©|\(c\))?\s*\d{4}[–\-]?\d{0,4}"),
|
||||||
|
# View/like/share counters
|
||||||
|
re.compile(r"(?i)\b\d[\d,]*\s*(views|likes|shares)\b"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_WHITESPACE_RUN = re.compile(r"[ \t]{2,}")
|
||||||
|
_BLANK_LINES = re.compile(r"\n{3,}")
|
||||||
|
|
||||||
|
|
||||||
|
def strip_noise(text: str) -> str:
|
||||||
|
for pattern in _NOISE_PATTERNS:
|
||||||
|
text = pattern.sub(" ", text)
|
||||||
|
text = _WHITESPACE_RUN.sub(" ", text)
|
||||||
|
return _BLANK_LINES.sub("\n\n", text).strip()
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"""Layer 5: significance scoring + severity classification.
|
||||||
|
|
||||||
|
This is the documented, unit-tested formula referenced in ARCHITECTURE.md.
|
||||||
|
Deterministic on purpose - severity must be explainable and reproducible
|
||||||
|
without a model call. Phase 7's LLM analysis narrates *why* a change
|
||||||
|
matters; it does not decide *how much* it matters.
|
||||||
|
|
||||||
|
significance = base_weight
|
||||||
|
* source_trust_score (0.3 - 1.0)
|
||||||
|
* min(1.0, independent_sources / 2) # corroboration, caps at 2 sources
|
||||||
|
* focus_match_multiplier (1.3 if it matches the user's stated focus, else 1.0)
|
||||||
|
* recency_multiplier (1.0 if new, 0.5 if a repeat of a recent change)
|
||||||
|
|
||||||
|
confidence = clamp(
|
||||||
|
0.5 * extraction_confidence + 0.4 * source_trust_score + 0.1
|
||||||
|
+ (0.15 if independent_sources >= 2 else 0.0),
|
||||||
|
0.0, 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
severity = bucket(significance * confidence), with a hard floor:
|
||||||
|
CRITICAL requires confidence >= CRITICAL_MIN_CONFIDENCE regardless of score -
|
||||||
|
an uncorroborated single-source signal can never be labeled Critical.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.enums import ChangeType, SeverityLevel
|
||||||
|
|
||||||
|
BASE_WEIGHTS: dict[ChangeType, float] = {
|
||||||
|
ChangeType.LEADERSHIP_CHANGE: 0.9,
|
||||||
|
ChangeType.FILING_NEW: 0.85,
|
||||||
|
ChangeType.PRICE_CHANGE: 0.6,
|
||||||
|
ChangeType.NEW_DOCUMENT: 0.5,
|
||||||
|
ChangeType.CONTENT_MODIFIED: 0.3, # further scaled by diff_ratio - see compute_significance
|
||||||
|
ChangeType.REMOVED_DOCUMENT: 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
# (score_threshold, severity) - first match wins, checked highest first.
|
||||||
|
# Calibrated against compute_significance/compute_confidence's actual output
|
||||||
|
# range (a single-source signal is already discounted ~2x by the
|
||||||
|
# corroboration multiplier) so CRITICAL_MIN_CONFIDENCE below is reachable:
|
||||||
|
# with significance capped at 1.0, a score of 0.6 needs confidence >= 0.6,
|
||||||
|
# which leaves room for the 0.7 confidence floor to actually bite and
|
||||||
|
# downgrade a subset of would-be-CRITICAL cases to HIGH.
|
||||||
|
SEVERITY_THRESHOLDS: list[tuple[float, SeverityLevel]] = [
|
||||||
|
(0.6, SeverityLevel.CRITICAL),
|
||||||
|
(0.4, SeverityLevel.HIGH),
|
||||||
|
(0.2, SeverityLevel.MEDIUM),
|
||||||
|
(0.0, SeverityLevel.LOW),
|
||||||
|
]
|
||||||
|
|
||||||
|
CRITICAL_MIN_CONFIDENCE = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
def compute_significance(
|
||||||
|
*,
|
||||||
|
change_type: ChangeType,
|
||||||
|
source_trust_score: float,
|
||||||
|
independent_source_count: int = 1,
|
||||||
|
focus_match: bool = False,
|
||||||
|
is_repeat: bool = False,
|
||||||
|
diff_ratio: float | None = None,
|
||||||
|
) -> float:
|
||||||
|
base = BASE_WEIGHTS[change_type]
|
||||||
|
if change_type is ChangeType.CONTENT_MODIFIED and diff_ratio is not None:
|
||||||
|
# A one-line wording tweak and a full page rewrite are both
|
||||||
|
# "content_modified" but shouldn't score the same.
|
||||||
|
base = base + diff_ratio * 0.5
|
||||||
|
|
||||||
|
trust_multiplier = _clamp(source_trust_score, 0.3, 1.0)
|
||||||
|
corroboration_multiplier = min(1.0, independent_source_count / 2)
|
||||||
|
focus_multiplier = 1.3 if focus_match else 1.0
|
||||||
|
recency_multiplier = 0.5 if is_repeat else 1.0
|
||||||
|
|
||||||
|
significance = (
|
||||||
|
base * trust_multiplier * corroboration_multiplier * focus_multiplier * recency_multiplier
|
||||||
|
)
|
||||||
|
return round(_clamp(significance, 0.0, 1.0), 4)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_confidence(
|
||||||
|
*,
|
||||||
|
extraction_confidence: float,
|
||||||
|
source_trust_score: float,
|
||||||
|
independent_source_count: int = 1,
|
||||||
|
) -> float:
|
||||||
|
corroboration_bonus = 0.15 if independent_source_count >= 2 else 0.0
|
||||||
|
confidence = 0.5 * extraction_confidence + 0.4 * source_trust_score + 0.1 + corroboration_bonus
|
||||||
|
return round(_clamp(confidence, 0.0, 1.0), 4)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_severity(significance: float, confidence: float) -> SeverityLevel:
|
||||||
|
score = significance * confidence
|
||||||
|
for threshold, severity in SEVERITY_THRESHOLDS:
|
||||||
|
if score >= threshold:
|
||||||
|
if severity is SeverityLevel.CRITICAL and confidence < CRITICAL_MIN_CONFIDENCE:
|
||||||
|
return SeverityLevel.HIGH
|
||||||
|
return severity
|
||||||
|
return SeverityLevel.LOW # pragma: no cover - thresholds bottom out at 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(value: float, lo: float, hi: float) -> float:
|
||||||
|
return max(lo, min(hi, value))
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Layer 2: structured field comparison.
|
||||||
|
|
||||||
|
Compares the *set* of items (job postings, press releases, filings,
|
||||||
|
products - whatever the source's documents represent) between two
|
||||||
|
snapshots' `structured_summary["urls"]`/`["titles"]`. This is what catches
|
||||||
|
"a new job posting appeared" or "a press release was removed" without
|
||||||
|
needing a bespoke parser per source type - collection_service already
|
||||||
|
records the full current item set on every run, so this is a plain set
|
||||||
|
diff between two runs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StructuredDiff:
|
||||||
|
added: list[str] = field(default_factory=list)
|
||||||
|
removed: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_changes(self) -> bool:
|
||||||
|
return bool(self.added or self.removed)
|
||||||
|
|
||||||
|
|
||||||
|
def diff_item_sets(previous_urls: list[str], current_urls: list[str]) -> StructuredDiff:
|
||||||
|
previous_set = set(previous_urls)
|
||||||
|
current_set = set(current_urls)
|
||||||
|
return StructuredDiff(
|
||||||
|
added=sorted(current_set - previous_set),
|
||||||
|
removed=sorted(previous_set - current_set),
|
||||||
|
)
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Layer 3: bounded text diff.
|
||||||
|
|
||||||
|
Runs on noise-stripped text (see noise_filters.py) so navigation/cookie/
|
||||||
|
timestamp churn doesn't register as a change. Bounded: only a capped number
|
||||||
|
of added/removed lines are kept, so a full page rewrite doesn't produce an
|
||||||
|
unbounded diff blob for storage or LLM consumption later.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import difflib
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from app.change_detection.noise_filters import strip_noise
|
||||||
|
|
||||||
|
_MAX_DIFF_LINES = 40
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TextDiffResult:
|
||||||
|
diff_ratio: float # 0.0 = identical, 1.0 = completely different
|
||||||
|
added_lines: list[str] = field(default_factory=list)
|
||||||
|
removed_lines: list[str] = field(default_factory=list)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_identical(self) -> bool:
|
||||||
|
return self.diff_ratio == 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def bounded_text_diff(previous_text: str, current_text: str) -> TextDiffResult:
|
||||||
|
previous_clean = strip_noise(previous_text or "")
|
||||||
|
current_clean = strip_noise(current_text or "")
|
||||||
|
|
||||||
|
if previous_clean == current_clean:
|
||||||
|
return TextDiffResult(diff_ratio=0.0)
|
||||||
|
|
||||||
|
previous_lines = [line for line in previous_clean.splitlines() if line.strip()]
|
||||||
|
current_lines = [line for line in current_clean.splitlines() if line.strip()]
|
||||||
|
|
||||||
|
matcher = difflib.SequenceMatcher(a=previous_lines, b=current_lines, autojunk=False)
|
||||||
|
similarity = matcher.ratio()
|
||||||
|
diff_ratio = round(1.0 - similarity, 4)
|
||||||
|
|
||||||
|
added: list[str] = []
|
||||||
|
removed: list[str] = []
|
||||||
|
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||||
|
if tag in ("replace", "delete"):
|
||||||
|
removed.extend(previous_lines[i1:i2])
|
||||||
|
if tag in ("replace", "insert"):
|
||||||
|
added.extend(current_lines[j1:j2])
|
||||||
|
|
||||||
|
return TextDiffResult(
|
||||||
|
diff_ratio=diff_ratio,
|
||||||
|
added_lines=added[:_MAX_DIFF_LINES],
|
||||||
|
removed_lines=removed[:_MAX_DIFF_LINES],
|
||||||
|
)
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
"""Collector interface. Every source type (website, RSS, SEC EDGAR, GitHub,
|
||||||
|
custom URL, job postings, and the fixture-backed patent/review adapters)
|
||||||
|
implements this same `SourceCollector` protocol, so `tasks/collection.py`
|
||||||
|
(Phase 5) can treat them uniformly.
|
||||||
|
|
||||||
|
Collectors never talk to the database - they take plain dataclasses in and
|
||||||
|
return plain dataclasses out. Persisting `CollectedDocument`s into
|
||||||
|
`SourceDocument` rows is the caller's job (a service function, not the
|
||||||
|
collector), which keeps collectors trivially unit-testable against fixtures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CompanyContext:
|
||||||
|
"""Read-only view of a Company, passed into collectors instead of the ORM object."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
official_website: str | None
|
||||||
|
monitoring_focus: str | None
|
||||||
|
aliases: list[str] = field(default_factory=list)
|
||||||
|
competitors: list[str] = field(default_factory=list)
|
||||||
|
# From NinjaPear enrichment (app/models/company_enrichment.py), when it
|
||||||
|
# ran and found a leadership team - empty otherwise (no key, still
|
||||||
|
# pending, or no leadership data). Used by PatentSourceCollector to
|
||||||
|
# search USPTO by inventor name, since that endpoint has no queryable
|
||||||
|
# company/assignee field at all - see collectors/patents.py.
|
||||||
|
leadership_names: list[str] = field(default_factory=list)
|
||||||
|
# The owning user's effective USPTO key (their own, or the server's
|
||||||
|
# global one) - None means "use the server's global settings.uspto_api_key
|
||||||
|
# directly", for call sites that never resolved a per-user override
|
||||||
|
# (e.g. discovery preview paths outside a monitoring run).
|
||||||
|
uspto_api_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DiscoveredSource:
|
||||||
|
source_type: SourceType
|
||||||
|
name: str
|
||||||
|
base_url: str | None
|
||||||
|
configuration_metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SourceConfig:
|
||||||
|
id: str
|
||||||
|
source_type: SourceType
|
||||||
|
name: str
|
||||||
|
base_url: str | None
|
||||||
|
configuration_metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CollectedDocument:
|
||||||
|
url: str
|
||||||
|
canonical_url: str
|
||||||
|
title: str | None
|
||||||
|
author: str | None
|
||||||
|
publication_date: datetime | None
|
||||||
|
retrieved_date: datetime
|
||||||
|
content_text: str
|
||||||
|
content_hash: str
|
||||||
|
metadata: dict[str, Any] = field(default_factory=dict)
|
||||||
|
language: str | None = None
|
||||||
|
http_status: int | None = None
|
||||||
|
extraction_method: str = "unknown"
|
||||||
|
trust_score: float = 0.7
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CollectionResult:
|
||||||
|
status: SourceStatus
|
||||||
|
documents: list[CollectedDocument] = field(default_factory=list)
|
||||||
|
error: str | None = None
|
||||||
|
pages_attempted: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class SourceCollector(Protocol):
|
||||||
|
source_type: SourceType
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
"""Suggest sources for a newly added company. May return an empty
|
||||||
|
list if this collector type can't be auto-discovered (e.g. patents)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
"""Fetch and extract current content for a configured source."""
|
||||||
|
...
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""User-supplied custom URL collector - fetches, extracts, and monitors a
|
||||||
|
single public URL the user explicitly added."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import (
|
||||||
|
canonicalize_url,
|
||||||
|
compute_content_hash,
|
||||||
|
extract_readable_text,
|
||||||
|
extract_title,
|
||||||
|
)
|
||||||
|
from app.collectors.robots import is_allowed
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
|
||||||
|
class CustomUrlCollector:
|
||||||
|
source_type = SourceType.CUSTOM_URL
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
return [] # Custom URLs are always user-supplied, never auto-discovered.
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
if not source.base_url:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error="No URL configured")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
if not await is_allowed(source.base_url, settings=settings):
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.BLOCKED_BY_POLICY,
|
||||||
|
error="Disallowed by robots.txt",
|
||||||
|
pages_attempted=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await fetch_with_retries(source.base_url, settings=settings)
|
||||||
|
except SsrfBlockedError as exc:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc), pages_attempted=1
|
||||||
|
)
|
||||||
|
except (FetchError, httpx.HTTPError) as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error=str(exc), pages_attempted=1)
|
||||||
|
|
||||||
|
if result.status_code in (401, 403):
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.AUTH_REQUIRED,
|
||||||
|
error=f"HTTP {result.status_code}",
|
||||||
|
pages_attempted=1,
|
||||||
|
)
|
||||||
|
if result.status_code >= 400:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED, error=f"HTTP {result.status_code}", pages_attempted=1
|
||||||
|
)
|
||||||
|
|
||||||
|
text, method = extract_readable_text(result.text, source.base_url)
|
||||||
|
if not text:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED, error="No extractable content", pages_attempted=1
|
||||||
|
)
|
||||||
|
|
||||||
|
document = CollectedDocument(
|
||||||
|
url=source.base_url,
|
||||||
|
canonical_url=canonicalize_url(result.final_url),
|
||||||
|
title=extract_title(result.text),
|
||||||
|
author=None,
|
||||||
|
publication_date=None,
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={"http_status": result.status_code},
|
||||||
|
extraction_method=method,
|
||||||
|
http_status=result.status_code,
|
||||||
|
trust_score=0.7,
|
||||||
|
)
|
||||||
|
return CollectionResult(status=SourceStatus.ACTIVE, documents=[document], pages_attempted=1)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""Shared content extraction/normalization helpers used by every collector.
|
||||||
|
|
||||||
|
Centralizing this (rather than letting each collector roll its own) is what
|
||||||
|
makes cross-collector dedup and hashing behave consistently.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import re
|
||||||
|
from urllib.parse import urlsplit, urlunsplit
|
||||||
|
|
||||||
|
import trafilatura
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
_WHITESPACE_RE = re.compile(r"[ \t\f\v]+")
|
||||||
|
_BLANK_LINES_RE = re.compile(r"\n{3,}")
|
||||||
|
|
||||||
|
# Query params that vary per-request/session but don't change page meaning -
|
||||||
|
# stripped so the same logical page always canonicalizes identically.
|
||||||
|
_NOISE_QUERY_PREFIXES = ("utm_", "fbclid", "gclid", "mc_", "_hs")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_whitespace(text: str) -> str:
|
||||||
|
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
text = _WHITESPACE_RE.sub(" ", text)
|
||||||
|
lines = [line.strip() for line in text.split("\n")]
|
||||||
|
text = "\n".join(lines)
|
||||||
|
return _BLANK_LINES_RE.sub("\n\n", text).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def compute_content_hash(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def canonicalize_url(url: str) -> str:
|
||||||
|
parts = urlsplit(url)
|
||||||
|
query_pairs = [
|
||||||
|
pair
|
||||||
|
for pair in parts.query.split("&")
|
||||||
|
if pair and not pair.split("=")[0].startswith(_NOISE_QUERY_PREFIXES)
|
||||||
|
]
|
||||||
|
path = parts.path.rstrip("/") or "/"
|
||||||
|
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, "&".join(query_pairs), ""))
|
||||||
|
|
||||||
|
|
||||||
|
def extract_readable_text(html: str, url: str) -> tuple[str, str]:
|
||||||
|
"""Returns (text, extraction_method). Prefers trafilatura (boilerplate
|
||||||
|
removal tuned for articles/press releases); falls back to a plain
|
||||||
|
BeautifulSoup text extraction for pages trafilatura can't parse (e.g.
|
||||||
|
thin job listing pages)."""
|
||||||
|
extracted = trafilatura.extract(
|
||||||
|
html,
|
||||||
|
url=url,
|
||||||
|
include_comments=False,
|
||||||
|
include_tables=True,
|
||||||
|
favor_precision=True,
|
||||||
|
)
|
||||||
|
if extracted and extracted.strip():
|
||||||
|
return normalize_whitespace(extracted), "trafilatura"
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
for tag in soup(["script", "style", "nav", "footer", "header", "noscript"]):
|
||||||
|
tag.decompose()
|
||||||
|
text = soup.get_text(separator="\n")
|
||||||
|
return normalize_whitespace(text), "beautifulsoup_fallback"
|
||||||
|
|
||||||
|
|
||||||
|
def extract_title(html: str) -> str | None:
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
if soup.title and soup.title.string:
|
||||||
|
return soup.title.string.strip()
|
||||||
|
h1 = soup.find("h1")
|
||||||
|
if h1:
|
||||||
|
return h1.get_text(strip=True)
|
||||||
|
return None
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""GitHub collector - public organization/repository metadata via the
|
||||||
|
public REST API. `GITHUB_TOKEN` is optional and only raises the rate limit;
|
||||||
|
nothing here requires authentication.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_API_BASE = "https://api.github.com"
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_headers(settings: Settings) -> dict[str, str]:
|
||||||
|
headers = {"Accept": "application/vnd.github+json"}
|
||||||
|
if settings.github_token:
|
||||||
|
headers["Authorization"] = f"Bearer {settings.github_token}"
|
||||||
|
return headers
|
||||||
|
|
||||||
|
|
||||||
|
class GithubCollector:
|
||||||
|
source_type = SourceType.GITHUB
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
settings = get_settings()
|
||||||
|
org_login = await self._find_org(company.name, settings)
|
||||||
|
if org_login is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
DiscoveredSource(
|
||||||
|
source_type=SourceType.GITHUB,
|
||||||
|
name=f"{company.name} — GitHub",
|
||||||
|
base_url=f"https://github.com/{org_login}",
|
||||||
|
configuration_metadata={"org": org_login},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _find_org(self, company_name: str, settings: Settings) -> str | None:
|
||||||
|
url = str(
|
||||||
|
httpx.URL(
|
||||||
|
f"{_API_BASE}/search/users",
|
||||||
|
params={"q": f"{company_name} type:org", "per_page": 1},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await fetch_with_retries(
|
||||||
|
url, settings=settings, max_attempts=2, extra_headers=_auth_headers(settings)
|
||||||
|
)
|
||||||
|
except (SsrfBlockedError, FetchError, httpx.HTTPError) as exc:
|
||||||
|
logger.warning("github_org_search_failed", company=company_name, error=str(exc))
|
||||||
|
return None
|
||||||
|
if result.status_code != 200:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
payload = json.loads(result.text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
items = payload.get("items", [])
|
||||||
|
return items[0]["login"] if items else None
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
org = source.configuration_metadata.get("org")
|
||||||
|
if not org:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error="No GitHub org configured")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
url = str(
|
||||||
|
httpx.URL(f"{_API_BASE}/orgs/{org}/repos", params={"sort": "pushed", "per_page": 15})
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
result = await fetch_with_retries(
|
||||||
|
url, settings=settings, extra_headers=_auth_headers(settings)
|
||||||
|
)
|
||||||
|
except SsrfBlockedError as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc))
|
||||||
|
except (FetchError, httpx.HTTPError) as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error=str(exc))
|
||||||
|
|
||||||
|
if result.status_code == 404:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED, error=f"GitHub org not found: {org}"
|
||||||
|
)
|
||||||
|
if result.status_code == 403:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.RATE_LIMITED, error="GitHub API rate limited"
|
||||||
|
)
|
||||||
|
if result.status_code >= 400:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error=f"HTTP {result.status_code}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
repos = json.loads(result.text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error="Malformed GitHub response")
|
||||||
|
|
||||||
|
documents: list[CollectedDocument] = []
|
||||||
|
for repo in repos:
|
||||||
|
text = normalize_whitespace(
|
||||||
|
f"{repo.get('full_name')}\n"
|
||||||
|
f"{repo.get('description') or ''}\n"
|
||||||
|
f"Language: {repo.get('language') or 'unknown'}\n"
|
||||||
|
f"Stars: {repo.get('stargazers_count', 0)}\n"
|
||||||
|
f"Last pushed: {repo.get('pushed_at')}"
|
||||||
|
)
|
||||||
|
documents.append(
|
||||||
|
CollectedDocument(
|
||||||
|
url=repo.get("html_url"),
|
||||||
|
canonical_url=repo.get("html_url"),
|
||||||
|
title=repo.get("full_name"),
|
||||||
|
author=org,
|
||||||
|
publication_date=(
|
||||||
|
datetime.fromisoformat(repo["pushed_at"].replace("Z", "+00:00"))
|
||||||
|
if repo.get("pushed_at")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={
|
||||||
|
"stars": repo.get("stargazers_count"),
|
||||||
|
"language": repo.get("language"),
|
||||||
|
"archived": repo.get("archived"),
|
||||||
|
},
|
||||||
|
extraction_method="github_api",
|
||||||
|
http_status=result.status_code,
|
||||||
|
trust_score=0.75,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
status = SourceStatus.ACTIVE
|
||||||
|
return CollectionResult(status=status, documents=documents, pages_attempted=1)
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
"""Federal contracts collector via USASpending.gov's public Award Search
|
||||||
|
API - free, keyless, no registration (a fixed, trusted, first-party
|
||||||
|
integration endpoint like Brave/Twilio, so this calls httpx directly rather
|
||||||
|
than through `safe_fetch`, which exists to guard arbitrary/user-supplied
|
||||||
|
collector targets, not our own known API integrations).
|
||||||
|
|
||||||
|
Offered for every company regardless of type, same as SecEdgarCollector -
|
||||||
|
a private company simply returns zero awards, which is a normal empty
|
||||||
|
result, not a failure.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_SEARCH_URL = "https://api.usaspending.gov/api/v2/search/spending_by_award/"
|
||||||
|
_AWARD_TYPE_CODES = ["A", "B", "C", "D"] # contracts (definitive/BPA/purchase order/delivery order)
|
||||||
|
|
||||||
|
|
||||||
|
class GovContractCollector:
|
||||||
|
source_type = SourceType.GOV_CONTRACT
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
return [
|
||||||
|
DiscoveredSource(
|
||||||
|
source_type=SourceType.GOV_CONTRACT,
|
||||||
|
name=f"{company.name} — Federal Contracts",
|
||||||
|
base_url=None,
|
||||||
|
configuration_metadata={"recipient_search_text": company.name},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
recipient = source.configuration_metadata.get("recipient_search_text") or company.name
|
||||||
|
|
||||||
|
body = {
|
||||||
|
"filters": {
|
||||||
|
"recipient_search_text": [recipient],
|
||||||
|
"award_type_codes": _AWARD_TYPE_CODES,
|
||||||
|
},
|
||||||
|
"fields": [
|
||||||
|
"Award ID",
|
||||||
|
"Recipient Name",
|
||||||
|
"Award Amount",
|
||||||
|
"Start Date",
|
||||||
|
"Awarding Agency",
|
||||||
|
"Description",
|
||||||
|
],
|
||||||
|
"sort": "Award Amount",
|
||||||
|
"order": "desc",
|
||||||
|
"page": 1,
|
||||||
|
"limit": 25,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
response = await client.post(_SEARCH_URL, json=body)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error=str(exc))
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED,
|
||||||
|
error=f"USASpending API error {response.status_code}: {response.text[:200]}",
|
||||||
|
pages_attempted=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED,
|
||||||
|
error="Malformed USASpending response",
|
||||||
|
pages_attempted=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
results = payload.get("results", [])
|
||||||
|
documents: list[CollectedDocument] = []
|
||||||
|
for award in results:
|
||||||
|
award_id = award.get("Award ID", "unknown")
|
||||||
|
agency = award.get("Awarding Agency", "Unknown agency")
|
||||||
|
amount = award.get("Award Amount")
|
||||||
|
amount_display = (
|
||||||
|
f"${amount:,.0f}" if isinstance(amount, (int, float)) else "unknown amount"
|
||||||
|
)
|
||||||
|
start_date = award.get("Start Date", "")
|
||||||
|
description = award.get("Description") or ""
|
||||||
|
|
||||||
|
text = normalize_whitespace(
|
||||||
|
f"{award.get('Recipient Name', recipient)} was awarded federal contract "
|
||||||
|
f"{award_id} by {agency} for {amount_display}, starting {start_date}. "
|
||||||
|
f"{description}"
|
||||||
|
)
|
||||||
|
documents.append(
|
||||||
|
CollectedDocument(
|
||||||
|
url=_SEARCH_URL,
|
||||||
|
canonical_url=_SEARCH_URL,
|
||||||
|
title=f"{agency}: {award_id} — {amount_display}",
|
||||||
|
author="USASpending.gov",
|
||||||
|
publication_date=(
|
||||||
|
datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||||
|
if start_date
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={"award_id": award_id, "awarding_agency": agency},
|
||||||
|
extraction_method="usaspending_api",
|
||||||
|
http_status=response.status_code,
|
||||||
|
trust_score=0.8,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zero awards isn't a failure - most companies never win a federal
|
||||||
|
# contract, same non-error empty-result handling as SecEdgarCollector.
|
||||||
|
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
"""Job posting collector: generic heuristic extraction from a company's own
|
||||||
|
careers page. Board-specific APIs (LinkedIn, Indeed, etc.) are not
|
||||||
|
implemented - most require paid access or prohibit automated collection in
|
||||||
|
their terms; see KNOWN_LIMITATIONS.md.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import (
|
||||||
|
canonicalize_url,
|
||||||
|
compute_content_hash,
|
||||||
|
extract_readable_text,
|
||||||
|
extract_title,
|
||||||
|
normalize_whitespace,
|
||||||
|
)
|
||||||
|
from app.collectors.robots import is_allowed
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
_JOB_LINK_KEYWORDS = ("job", "career", "position", "opening", "role", "vacan")
|
||||||
|
_MAX_LISTINGS = 50
|
||||||
|
|
||||||
|
|
||||||
|
class JobPostingCollector:
|
||||||
|
source_type = SourceType.JOB_POSTING
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
if not company.official_website:
|
||||||
|
return []
|
||||||
|
careers_url = urljoin(company.official_website, "/careers")
|
||||||
|
return [
|
||||||
|
DiscoveredSource(
|
||||||
|
source_type=SourceType.JOB_POSTING,
|
||||||
|
name=f"{company.name} — Careers",
|
||||||
|
base_url=careers_url,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
if not source.base_url:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error="No careers URL configured")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
if not await is_allowed(source.base_url, settings=settings):
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.BLOCKED_BY_POLICY,
|
||||||
|
error="Disallowed by robots.txt",
|
||||||
|
pages_attempted=1,
|
||||||
|
)
|
||||||
|
result = await fetch_with_retries(source.base_url, settings=settings)
|
||||||
|
except SsrfBlockedError as exc:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc), pages_attempted=1
|
||||||
|
)
|
||||||
|
except (FetchError, httpx.HTTPError) as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error=str(exc), pages_attempted=1)
|
||||||
|
|
||||||
|
if result.status_code in (401, 403):
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.AUTH_REQUIRED,
|
||||||
|
error=f"HTTP {result.status_code}",
|
||||||
|
pages_attempted=1,
|
||||||
|
)
|
||||||
|
if result.status_code >= 400:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED, error=f"HTTP {result.status_code}", pages_attempted=1
|
||||||
|
)
|
||||||
|
|
||||||
|
listings = self._extract_job_listings(result.text, result.final_url)
|
||||||
|
|
||||||
|
if not listings:
|
||||||
|
# Non-standard careers page (e.g. a third-party ATS iframe) -
|
||||||
|
# fall back to the whole page as one document rather than
|
||||||
|
# reporting a failure for a page that did load successfully.
|
||||||
|
text, method = extract_readable_text(result.text, source.base_url)
|
||||||
|
if not text:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED, error="No extractable content", pages_attempted=1
|
||||||
|
)
|
||||||
|
document = CollectedDocument(
|
||||||
|
url=source.base_url,
|
||||||
|
canonical_url=canonicalize_url(result.final_url),
|
||||||
|
title=extract_title(result.text),
|
||||||
|
author=None,
|
||||||
|
publication_date=None,
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={"extraction": "fallback_whole_page"},
|
||||||
|
extraction_method=method,
|
||||||
|
http_status=result.status_code,
|
||||||
|
trust_score=0.55,
|
||||||
|
)
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.ACTIVE, documents=[document], pages_attempted=1
|
||||||
|
)
|
||||||
|
|
||||||
|
documents = [
|
||||||
|
CollectedDocument(
|
||||||
|
url=link,
|
||||||
|
canonical_url=canonicalize_url(link),
|
||||||
|
title=title,
|
||||||
|
author=None,
|
||||||
|
publication_date=None,
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=normalize_whitespace(f"{title}\n{snippet}"),
|
||||||
|
content_hash=compute_content_hash(normalize_whitespace(f"{title}\n{snippet}")),
|
||||||
|
metadata={"source_page": source.base_url},
|
||||||
|
extraction_method="job_link_heuristic",
|
||||||
|
http_status=result.status_code,
|
||||||
|
trust_score=0.65,
|
||||||
|
)
|
||||||
|
for title, link, snippet in listings
|
||||||
|
]
|
||||||
|
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||||
|
|
||||||
|
def _extract_job_listings(self, html: str, base_url: str) -> list[tuple[str, str, str]]:
|
||||||
|
soup = BeautifulSoup(html, "lxml")
|
||||||
|
listings: list[tuple[str, str, str]] = []
|
||||||
|
seen_links: set[str] = set()
|
||||||
|
|
||||||
|
for anchor in soup.find_all("a", href=True):
|
||||||
|
href = anchor["href"]
|
||||||
|
text = anchor.get_text(strip=True)
|
||||||
|
if not text or len(text) < 4 or len(text) > 150:
|
||||||
|
continue
|
||||||
|
if not any(keyword in href.lower() for keyword in _JOB_LINK_KEYWORDS):
|
||||||
|
continue
|
||||||
|
|
||||||
|
link = urljoin(base_url, href)
|
||||||
|
if link in seen_links:
|
||||||
|
continue
|
||||||
|
seen_links.add(link)
|
||||||
|
|
||||||
|
parent = anchor.find_parent()
|
||||||
|
snippet = parent.get_text(" ", strip=True)[:300] if parent else ""
|
||||||
|
listings.append((text, link, snippet))
|
||||||
|
if len(listings) >= _MAX_LISTINGS:
|
||||||
|
break
|
||||||
|
|
||||||
|
return listings
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
"""Patent source collector.
|
||||||
|
|
||||||
|
USPTO's PatentsView data migrated into the Open Data Portal (ODP) in March
|
||||||
|
2026; a free API key is available via account registration at
|
||||||
|
data.uspto.gov/apis/getting-started (see `Settings.uspto_api_key`). Without
|
||||||
|
a key configured (the default), this collector never fabricates patent
|
||||||
|
data - it truthfully reports `DISABLED` with an explanation, same as
|
||||||
|
before this integration existed, and a fixture adapter remains available
|
||||||
|
for local development/testing.
|
||||||
|
|
||||||
|
With a key configured, `collect()` calls the real ODP Patent Application
|
||||||
|
Search API - by INVENTOR NAME, not company name. Confirmed live (2026-08)
|
||||||
|
by inspecting a real response's full field list, including one from a
|
||||||
|
query that returned 110k+ real results: there is no assignee/company field
|
||||||
|
anywhere in this endpoint's data model. Company-name search here always
|
||||||
|
returns "no matching records," even for assignees with thousands of real
|
||||||
|
patents - it isn't a wrong-field-name bug, the field doesn't exist on this
|
||||||
|
dataset. USPTO's Patent Application Search reliably supports inventor-name
|
||||||
|
and application-number lookups only.
|
||||||
|
|
||||||
|
So `collect()` instead searches by each of the company's known leadership
|
||||||
|
names (from NinjaPear enrichment, see `CompanyContext.leadership_names` /
|
||||||
|
`enrichment_service.py`) and treats a match as a heuristic company signal,
|
||||||
|
not a verified one - there is still no way to confirm a given patent
|
||||||
|
actually belongs to the monitored company rather than, say, a same-named
|
||||||
|
person, or work the person did at a prior employer. Every resulting
|
||||||
|
document is trust-scored lower (0.5, vs. a hypothetical verified-assignee
|
||||||
|
match) and its content explicitly says which leadership name it matched
|
||||||
|
on, so the report LLM's confidence labeling reflects this rather than
|
||||||
|
treating it as confirmed fact. With no leadership names available (no
|
||||||
|
NinjaPear key, enrichment still pending, or it returned no leadership
|
||||||
|
data), this reports an honest empty result without making a network call
|
||||||
|
- there's nothing meaningful to search USPTO for.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_FIXTURES_DIR = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "patents"
|
||||||
|
_SEARCH_URL = "https://api.uspto.gov/api/v1/patent/applications/search"
|
||||||
|
_MAX_INVENTOR_SEARCHES = 5
|
||||||
|
_MAX_DOCUMENTS = 25
|
||||||
|
|
||||||
|
|
||||||
|
class PatentSourceCollector:
|
||||||
|
source_type = SourceType.PATENT
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
# company.uspto_api_key, when set by the caller (see
|
||||||
|
# collection_service.to_company_context), is the resolved effective
|
||||||
|
# key for whichever user owns this company - their own if they've
|
||||||
|
# set one, else the server's global default. None means no
|
||||||
|
# per-user resolution happened for this call path, so fall back to
|
||||||
|
# the global settings directly.
|
||||||
|
api_key = company.uspto_api_key or get_settings().uspto_api_key
|
||||||
|
if not api_key:
|
||||||
|
return [] # No live discovery without a configured provider.
|
||||||
|
return [
|
||||||
|
DiscoveredSource(
|
||||||
|
source_type=SourceType.PATENT,
|
||||||
|
name=f"{company.name} — Patent Filings",
|
||||||
|
base_url=None,
|
||||||
|
configuration_metadata={"assignee": company.name},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
api_key = company.uspto_api_key or get_settings().uspto_api_key
|
||||||
|
if api_key:
|
||||||
|
return await self._collect_live(company.name, company.leadership_names, api_key)
|
||||||
|
|
||||||
|
fixture_key = source.configuration_metadata.get("fixture_key")
|
||||||
|
if not fixture_key:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.DISABLED,
|
||||||
|
error=(
|
||||||
|
"No patent data provider is configured. This collector implements the "
|
||||||
|
"SourceCollector interface for a live integration (USPTO Open Data Portal) "
|
||||||
|
"but does not fabricate results without a configured USPTO_API_KEY."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture_path = _FIXTURES_DIR / f"{fixture_key}.json"
|
||||||
|
if not fixture_path.exists():
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.DISABLED,
|
||||||
|
error=f"No fixture found for {fixture_key!r} and no live provider is configured.",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||||
|
documents: list[CollectedDocument] = []
|
||||||
|
for entry in payload.get("patents", []):
|
||||||
|
text = normalize_whitespace(f"{entry['title']}\n\n{entry.get('abstract', '')}")
|
||||||
|
documents.append(
|
||||||
|
CollectedDocument(
|
||||||
|
url=entry.get("url", fixture_path.as_uri()),
|
||||||
|
canonical_url=entry.get("url", fixture_path.as_uri()),
|
||||||
|
title=entry["title"],
|
||||||
|
author=entry.get("assignee"),
|
||||||
|
publication_date=(
|
||||||
|
datetime.fromisoformat(entry["filed_date"]).replace(tzinfo=UTC)
|
||||||
|
if entry.get("filed_date")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={
|
||||||
|
"is_fixture": True,
|
||||||
|
"data_source": "fixture",
|
||||||
|
"fixture_key": fixture_key,
|
||||||
|
},
|
||||||
|
extraction_method="fixture",
|
||||||
|
trust_score=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||||
|
|
||||||
|
async def _collect_live(
|
||||||
|
self, company_name: str, leadership_names: list[str], api_key: str
|
||||||
|
) -> CollectionResult:
|
||||||
|
if not leadership_names:
|
||||||
|
# No names to search USPTO's inventor index with - see the
|
||||||
|
# module docstring for why company-name search doesn't work on
|
||||||
|
# this endpoint at all. Honest empty result, no network call.
|
||||||
|
return CollectionResult(status=SourceStatus.ACTIVE, documents=[], pages_attempted=0)
|
||||||
|
|
||||||
|
documents: list[CollectedDocument] = []
|
||||||
|
seen_app_numbers: set[str] = set()
|
||||||
|
pages_attempted = 0
|
||||||
|
errors: list[str] = []
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
for inventor_name in leadership_names[:_MAX_INVENTOR_SEARCHES]:
|
||||||
|
pages_attempted += 1
|
||||||
|
body = {
|
||||||
|
"q": f'applicationMetaData.inventorBag.inventorNameText:"{inventor_name}"',
|
||||||
|
"pagination": {"limit": _MAX_DOCUMENTS},
|
||||||
|
"sort": [{"field": "applicationMetaData.filingDate", "order": "desc"}],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
_SEARCH_URL, json=body, headers={"x-api-key": api_key}
|
||||||
|
)
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
errors.append(f"{inventor_name}: {exc}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if response.status_code == 404:
|
||||||
|
# USPTO returns 404 for "no matching records" rather
|
||||||
|
# than 200 with an empty array - a real, expected
|
||||||
|
# outcome for most names, not a failure.
|
||||||
|
continue
|
||||||
|
if response.status_code >= 400:
|
||||||
|
errors.append(f"{inventor_name}: USPTO API error {response.status_code}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = response.json()
|
||||||
|
except ValueError:
|
||||||
|
errors.append(f"{inventor_name}: malformed USPTO response")
|
||||||
|
continue
|
||||||
|
|
||||||
|
entries = payload.get("patentFileWrapperDataBag") or payload.get("results") or []
|
||||||
|
for entry in entries:
|
||||||
|
metadata = entry.get("applicationMetaData") or {}
|
||||||
|
app_number = entry.get("applicationNumberText") or entry.get(
|
||||||
|
"applicationNumber"
|
||||||
|
)
|
||||||
|
if not app_number or app_number in seen_app_numbers:
|
||||||
|
continue
|
||||||
|
seen_app_numbers.add(app_number)
|
||||||
|
|
||||||
|
title = metadata.get("inventionTitle") or "Untitled patent filing"
|
||||||
|
filing_date = metadata.get("filingDate")
|
||||||
|
abstract = metadata.get("abstractText") or ""
|
||||||
|
text = normalize_whitespace(
|
||||||
|
f"{title}\n\nInventor match: {inventor_name} (leadership-name "
|
||||||
|
f"heuristic, not a verified {company_name} assignee - USPTO's "
|
||||||
|
"application search has no queryable assignee/company field).\n\n"
|
||||||
|
f"{abstract}"
|
||||||
|
)
|
||||||
|
documents.append(
|
||||||
|
CollectedDocument(
|
||||||
|
url=(
|
||||||
|
f"{_SEARCH_URL}?applicationNumber={app_number}"
|
||||||
|
if app_number
|
||||||
|
else _SEARCH_URL
|
||||||
|
),
|
||||||
|
canonical_url=_SEARCH_URL,
|
||||||
|
title=title,
|
||||||
|
author=inventor_name,
|
||||||
|
publication_date=(
|
||||||
|
datetime.fromisoformat(filing_date).replace(tzinfo=UTC)
|
||||||
|
if filing_date
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={
|
||||||
|
"application_number": app_number,
|
||||||
|
"data_source": "uspto_odp",
|
||||||
|
"matched_inventor_name": inventor_name,
|
||||||
|
"match_type": "leadership_name_heuristic",
|
||||||
|
},
|
||||||
|
extraction_method="uspto_odp_api",
|
||||||
|
http_status=response.status_code,
|
||||||
|
# Lower than a verified-assignee match would be
|
||||||
|
# (was 0.9) - this is a heuristic name match,
|
||||||
|
# not confirmed company ownership.
|
||||||
|
trust_score=0.5,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if len(documents) >= _MAX_DOCUMENTS:
|
||||||
|
break
|
||||||
|
|
||||||
|
if errors and not documents:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED,
|
||||||
|
error="; ".join(errors[:3]),
|
||||||
|
pages_attempted=pages_attempted,
|
||||||
|
)
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.ACTIVE, documents=documents, pages_attempted=pages_attempted
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Maps SourceType -> collector instance. The single place Phase 5's Celery
|
||||||
|
task (and this phase's tests) resolve a collector from a Source row."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.collectors.base import SourceCollector
|
||||||
|
from app.collectors.custom_url import CustomUrlCollector
|
||||||
|
from app.collectors.github import GithubCollector
|
||||||
|
from app.collectors.gov_contracts import GovContractCollector
|
||||||
|
from app.collectors.jobs import JobPostingCollector
|
||||||
|
from app.collectors.patents import PatentSourceCollector
|
||||||
|
from app.collectors.reviews import ReviewSourceCollector
|
||||||
|
from app.collectors.rss import RssCollector
|
||||||
|
from app.collectors.sec_edgar import SecEdgarCollector
|
||||||
|
from app.collectors.website import WebsiteCollector
|
||||||
|
from app.models.enums import SourceType
|
||||||
|
|
||||||
|
_COLLECTORS: dict[SourceType, SourceCollector] = {
|
||||||
|
SourceType.WEBSITE: WebsiteCollector(),
|
||||||
|
SourceType.RSS: RssCollector(),
|
||||||
|
SourceType.CUSTOM_URL: CustomUrlCollector(),
|
||||||
|
SourceType.SEC_EDGAR: SecEdgarCollector(),
|
||||||
|
SourceType.GITHUB: GithubCollector(),
|
||||||
|
SourceType.JOB_POSTING: JobPostingCollector(),
|
||||||
|
SourceType.PATENT: PatentSourceCollector(),
|
||||||
|
SourceType.REVIEW: ReviewSourceCollector(),
|
||||||
|
SourceType.GOV_CONTRACT: GovContractCollector(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_collector(source_type: SourceType) -> SourceCollector:
|
||||||
|
return _COLLECTORS[source_type]
|
||||||
|
|
||||||
|
|
||||||
|
def all_collectors() -> list[SourceCollector]:
|
||||||
|
return list(_COLLECTORS.values())
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""Customer review source collector.
|
||||||
|
|
||||||
|
Most review platforms (G2, Trustpilot, Glassdoor, etc.) either prohibit
|
||||||
|
automated scraping in their terms or require a paid API. This collector
|
||||||
|
implements the `SourceCollector` interface and a documented fixture adapter
|
||||||
|
for local development/testing; it never scrapes a review site directly and
|
||||||
|
never fabricates review data when no permitted live provider is configured.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
_FIXTURES_DIR = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "reviews"
|
||||||
|
|
||||||
|
|
||||||
|
class ReviewSourceCollector:
|
||||||
|
source_type = SourceType.REVIEW
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
fixture_key = source.configuration_metadata.get("fixture_key")
|
||||||
|
if not fixture_key:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.DISABLED,
|
||||||
|
error=(
|
||||||
|
"No review data provider is configured. Most review platforms prohibit "
|
||||||
|
"automated scraping in their terms; this collector implements the "
|
||||||
|
"SourceCollector interface for a future permitted API integration but "
|
||||||
|
"does not fabricate results without one."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture_path = _FIXTURES_DIR / f"{fixture_key}.json"
|
||||||
|
if not fixture_path.exists():
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.DISABLED,
|
||||||
|
error=f"No fixture found for {fixture_key!r} and no live provider is configured.",
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||||
|
documents: list[CollectedDocument] = []
|
||||||
|
for entry in payload.get("reviews", []):
|
||||||
|
text = normalize_whitespace(
|
||||||
|
f"Rating: {entry.get('rating', 'n/a')}/5\n\n{entry.get('body', '')}"
|
||||||
|
)
|
||||||
|
documents.append(
|
||||||
|
CollectedDocument(
|
||||||
|
url=entry.get("url", fixture_path.as_uri()),
|
||||||
|
canonical_url=entry.get("url", fixture_path.as_uri()),
|
||||||
|
title=entry.get("title") or "Customer review",
|
||||||
|
author=entry.get("author"),
|
||||||
|
publication_date=(
|
||||||
|
datetime.fromisoformat(entry["date"]).replace(tzinfo=UTC)
|
||||||
|
if entry.get("date")
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={
|
||||||
|
"is_fixture": True,
|
||||||
|
"data_source": "fixture",
|
||||||
|
"fixture_key": fixture_key,
|
||||||
|
"rating": entry.get("rating"),
|
||||||
|
},
|
||||||
|
extraction_method="fixture",
|
||||||
|
trust_score=0.4,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
"""robots.txt compliance check - see SECURITY.md rule 1."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
from urllib.robotparser import RobotFileParser
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.http import SsrfBlockedError, safe_fetch
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_CACHE_TTL_SECONDS = 3600
|
||||||
|
_cache: dict[str, tuple[float, RobotFileParser]] = {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_parser(base_url: str, settings: Settings) -> RobotFileParser:
|
||||||
|
parsed = urlparse(base_url)
|
||||||
|
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||||
|
cached = _cache.get(origin)
|
||||||
|
now = time.monotonic()
|
||||||
|
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
|
||||||
|
return cached[1]
|
||||||
|
|
||||||
|
parser = RobotFileParser()
|
||||||
|
robots_url = urljoin(origin, "/robots.txt")
|
||||||
|
try:
|
||||||
|
result = await safe_fetch(robots_url, settings=settings)
|
||||||
|
if result.status_code == 200:
|
||||||
|
parser.parse(result.text.splitlines())
|
||||||
|
else:
|
||||||
|
# No robots.txt or inaccessible -> "allow all" per convention.
|
||||||
|
parser.parse([])
|
||||||
|
except SsrfBlockedError:
|
||||||
|
parser.parse([])
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
logger.warning("robots_txt_fetch_failed", url=robots_url, error=str(exc))
|
||||||
|
parser.parse([])
|
||||||
|
|
||||||
|
_cache[origin] = (now, parser)
|
||||||
|
return parser
|
||||||
|
|
||||||
|
|
||||||
|
async def is_allowed(url: str, *, settings: Settings | None = None) -> bool:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
parser = await _get_parser(url, settings)
|
||||||
|
return parser.can_fetch(settings.scraper_user_agent, url)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_cache() -> None:
|
||||||
|
"""Test helper - the module-level cache would otherwise leak between tests."""
|
||||||
|
_cache.clear()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
"""RSS/Atom feed collector."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time as time_module
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
import feedparser
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import canonicalize_url, compute_content_hash, normalize_whitespace
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RssCollector:
|
||||||
|
source_type = SourceType.RSS
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
# Google News' search RSS endpoint needs no API key and reliably
|
||||||
|
# exists for any query - unlike a company's own press-room feed
|
||||||
|
# (which would need a search provider to locate), this one URL
|
||||||
|
# formula works for every company and already aggregates wire-
|
||||||
|
# service releases (PRNewswire/BusinessWire/GlobeNewswire) as
|
||||||
|
# they're published, so a dedicated wire-specific collector isn't
|
||||||
|
# needed on top of it. Users can still add any other feed manually
|
||||||
|
# (see custom_url.py's sibling "add any public URL" path).
|
||||||
|
query_url = (
|
||||||
|
f"https://news.google.com/rss/search?q={quote(company.name)}&hl=en-US&gl=US&ceid=US:en"
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
DiscoveredSource(
|
||||||
|
source_type=SourceType.RSS,
|
||||||
|
name=f"{company.name} — Google News",
|
||||||
|
base_url=query_url,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
if not source.base_url:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error="No feed URL configured")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
try:
|
||||||
|
result = await fetch_with_retries(source.base_url, settings=settings)
|
||||||
|
except SsrfBlockedError as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc))
|
||||||
|
except (FetchError, httpx.HTTPError) as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error=str(exc))
|
||||||
|
|
||||||
|
if result.status_code >= 400:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED, error=f"HTTP {result.status_code}", pages_attempted=1
|
||||||
|
)
|
||||||
|
|
||||||
|
parsed = feedparser.parse(result.content)
|
||||||
|
if parsed.bozo and not parsed.entries:
|
||||||
|
return CollectionResult(
|
||||||
|
status=SourceStatus.FAILED,
|
||||||
|
error=str(parsed.get("bozo_exception", "Unparseable feed")),
|
||||||
|
pages_attempted=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
max_items = source.configuration_metadata.get("max_items", 20)
|
||||||
|
documents: list[CollectedDocument] = []
|
||||||
|
for entry in parsed.entries[:max_items]:
|
||||||
|
link = entry.get("link")
|
||||||
|
if not link:
|
||||||
|
continue
|
||||||
|
summary = entry.get("summary", "") or entry.get("description", "")
|
||||||
|
text = normalize_whitespace(f"{entry.get('title', '')}\n\n{summary}")
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
|
||||||
|
pub_date = None
|
||||||
|
if entry.get("published_parsed"):
|
||||||
|
pub_date = datetime.fromtimestamp(
|
||||||
|
time_module.mktime(entry.published_parsed), tz=UTC
|
||||||
|
)
|
||||||
|
|
||||||
|
documents.append(
|
||||||
|
CollectedDocument(
|
||||||
|
url=link,
|
||||||
|
canonical_url=canonicalize_url(link),
|
||||||
|
title=entry.get("title"),
|
||||||
|
author=entry.get("author"),
|
||||||
|
publication_date=pub_date,
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={"feed_url": source.base_url},
|
||||||
|
extraction_method="feedparser",
|
||||||
|
http_status=result.status_code,
|
||||||
|
trust_score=0.6,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
status = SourceStatus.ACTIVE if documents else SourceStatus.FAILED
|
||||||
|
return CollectionResult(status=status, documents=documents, pages_attempted=1)
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""SEC EDGAR collector for US public companies.
|
||||||
|
|
||||||
|
No API key required, but SEC asks that callers identify themselves with a
|
||||||
|
descriptive User-Agent (see `SCRAPER_USER_AGENT` in .env.example) and stay
|
||||||
|
within its rate limits - the shared `safe_fetch` per-domain delay covers
|
||||||
|
that. We store filing *metadata* (form type, date, accession number, link)
|
||||||
|
rather than parsing full filing bodies, which is out of scope for this pass.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_RELEVANT_FORMS = {"10-K", "10-Q", "8-K"}
|
||||||
|
_SEARCH_URL = "https://www.sec.gov/cgi-bin/browse-edgar"
|
||||||
|
_SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik}.json"
|
||||||
|
|
||||||
|
|
||||||
|
class SecEdgarCollector:
|
||||||
|
source_type = SourceType.SEC_EDGAR
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
settings = get_settings()
|
||||||
|
cik = await self._lookup_cik(company.name, settings)
|
||||||
|
if cik is None:
|
||||||
|
return []
|
||||||
|
return [
|
||||||
|
DiscoveredSource(
|
||||||
|
source_type=SourceType.SEC_EDGAR,
|
||||||
|
name=f"{company.name} — SEC EDGAR filings",
|
||||||
|
base_url=f"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK={cik}",
|
||||||
|
configuration_metadata={"cik": cik},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _lookup_cik(self, company_name: str, settings) -> str | None:
|
||||||
|
params = {
|
||||||
|
"action": "getcompany",
|
||||||
|
"company": company_name,
|
||||||
|
"type": "10-K",
|
||||||
|
"dateb": "",
|
||||||
|
"owner": "include",
|
||||||
|
"count": "5",
|
||||||
|
"output": "atom",
|
||||||
|
}
|
||||||
|
url = str(httpx.URL(_SEARCH_URL, params=params))
|
||||||
|
try:
|
||||||
|
result = await fetch_with_retries(url, settings=settings, max_attempts=2)
|
||||||
|
except (SsrfBlockedError, FetchError, httpx.HTTPError) as exc:
|
||||||
|
logger.warning("sec_edgar_lookup_failed", company=company_name, error=str(exc))
|
||||||
|
return None
|
||||||
|
if result.status_code != 200:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(result.content)
|
||||||
|
except ElementTree.ParseError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||||
|
for entry in root.findall(".//a:entry", ns):
|
||||||
|
cik_elem = entry.find("a:content", ns)
|
||||||
|
title_elem = entry.find("a:title", ns)
|
||||||
|
if cik_elem is None or title_elem is None:
|
||||||
|
continue
|
||||||
|
# The atom feed embeds "CIK=0000320193" style text in <content>.
|
||||||
|
text = "".join(cik_elem.itertext())
|
||||||
|
if "CIK=" in text:
|
||||||
|
cik = text.split("CIK=")[1].split("&")[0].strip()
|
||||||
|
return cik.zfill(10)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
cik = source.configuration_metadata.get("cik")
|
||||||
|
if not cik:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error="No CIK configured")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
url = _SUBMISSIONS_URL.format(cik=str(cik).zfill(10))
|
||||||
|
try:
|
||||||
|
result = await fetch_with_retries(url, settings=settings)
|
||||||
|
except SsrfBlockedError as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc))
|
||||||
|
except (FetchError, httpx.HTTPError) as exc:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error=str(exc))
|
||||||
|
|
||||||
|
if result.status_code == 404:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error="CIK not found on EDGAR")
|
||||||
|
if result.status_code >= 400:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error=f"HTTP {result.status_code}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
payload = json.loads(result.text)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return CollectionResult(status=SourceStatus.FAILED, error="Malformed EDGAR response")
|
||||||
|
|
||||||
|
recent = payload.get("filings", {}).get("recent", {})
|
||||||
|
forms = recent.get("form", [])
|
||||||
|
dates = recent.get("filingDate", [])
|
||||||
|
accessions = recent.get("accessionNumber", [])
|
||||||
|
primary_docs = recent.get("primaryDocument", [])
|
||||||
|
company_name = payload.get("name", company.name)
|
||||||
|
|
||||||
|
documents: list[CollectedDocument] = []
|
||||||
|
for i, form in enumerate(forms):
|
||||||
|
if form not in _RELEVANT_FORMS:
|
||||||
|
continue
|
||||||
|
if len(documents) >= 10:
|
||||||
|
break
|
||||||
|
accession = accessions[i].replace("-", "") if i < len(accessions) else ""
|
||||||
|
primary_doc = primary_docs[i] if i < len(primary_docs) else ""
|
||||||
|
filing_date = dates[i] if i < len(dates) else ""
|
||||||
|
filing_url = (
|
||||||
|
f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{accession}/{primary_doc}"
|
||||||
|
if accession and primary_doc
|
||||||
|
else url
|
||||||
|
)
|
||||||
|
text = normalize_whitespace(
|
||||||
|
f"{company_name} filed a {form} with the SEC on {filing_date}.\n"
|
||||||
|
f"Accession number: {accessions[i] if i < len(accessions) else 'unknown'}.\n"
|
||||||
|
f"Filing document: {filing_url}"
|
||||||
|
)
|
||||||
|
documents.append(
|
||||||
|
CollectedDocument(
|
||||||
|
url=filing_url,
|
||||||
|
canonical_url=filing_url,
|
||||||
|
title=f"{company_name} {form} ({filing_date})",
|
||||||
|
author="SEC EDGAR",
|
||||||
|
publication_date=(
|
||||||
|
datetime.strptime(filing_date, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||||
|
if filing_date
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=compute_content_hash(text),
|
||||||
|
metadata={
|
||||||
|
"form": form,
|
||||||
|
"accession_number": accessions[i] if i < len(accessions) else None,
|
||||||
|
},
|
||||||
|
extraction_method="sec_edgar_metadata",
|
||||||
|
http_status=result.status_code,
|
||||||
|
trust_score=0.95,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Zero relevant filings isn't a failure - the company may simply have
|
||||||
|
# none in its recent filing history.
|
||||||
|
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Official website collector: sitemap.xml + heuristic page discovery,
|
||||||
|
robots.txt-respecting, capped crawl depth/page count.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from urllib.parse import urljoin
|
||||||
|
from xml.etree import ElementTree
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.collectors.base import (
|
||||||
|
CollectedDocument,
|
||||||
|
CollectionResult,
|
||||||
|
CompanyContext,
|
||||||
|
DiscoveredSource,
|
||||||
|
SourceConfig,
|
||||||
|
)
|
||||||
|
from app.collectors.extraction import (
|
||||||
|
canonicalize_url,
|
||||||
|
compute_content_hash,
|
||||||
|
extract_readable_text,
|
||||||
|
extract_title,
|
||||||
|
)
|
||||||
|
from app.collectors.robots import is_allowed
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.models.enums import SourceStatus, SourceType
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
HEURISTIC_PATHS = [
|
||||||
|
"",
|
||||||
|
"/about",
|
||||||
|
"/about-us",
|
||||||
|
"/products",
|
||||||
|
"/services",
|
||||||
|
"/news",
|
||||||
|
"/press",
|
||||||
|
"/press-releases",
|
||||||
|
"/careers",
|
||||||
|
"/jobs",
|
||||||
|
"/leadership",
|
||||||
|
"/team",
|
||||||
|
"/investors",
|
||||||
|
"/investor-relations",
|
||||||
|
"/sustainability",
|
||||||
|
"/contact",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class WebsiteCollector:
|
||||||
|
source_type = SourceType.WEBSITE
|
||||||
|
|
||||||
|
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||||
|
if not company.official_website:
|
||||||
|
return []
|
||||||
|
settings = get_settings()
|
||||||
|
pages = await self._discover_pages(company.official_website, settings)
|
||||||
|
return [
|
||||||
|
DiscoveredSource(
|
||||||
|
source_type=SourceType.WEBSITE,
|
||||||
|
name=f"{company.name} — Official Website",
|
||||||
|
base_url=company.official_website,
|
||||||
|
configuration_metadata={"pages": pages},
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
async def _discover_pages(self, base_url: str, settings: Settings) -> list[str]:
|
||||||
|
pages: list[str] = []
|
||||||
|
|
||||||
|
sitemap_urls = await self._read_sitemap(base_url, settings)
|
||||||
|
pages.extend(sitemap_urls[: settings.max_pages_per_domain])
|
||||||
|
|
||||||
|
if len(pages) < settings.max_pages_per_domain:
|
||||||
|
for path in HEURISTIC_PATHS:
|
||||||
|
candidate = urljoin(base_url, path)
|
||||||
|
if candidate not in pages:
|
||||||
|
pages.append(candidate)
|
||||||
|
if len(pages) >= settings.max_pages_per_domain:
|
||||||
|
break
|
||||||
|
|
||||||
|
return pages[: settings.max_pages_per_domain]
|
||||||
|
|
||||||
|
async def _read_sitemap(self, base_url: str, settings: Settings) -> list[str]:
|
||||||
|
sitemap_url = urljoin(base_url, "/sitemap.xml")
|
||||||
|
try:
|
||||||
|
result = await fetch_with_retries(sitemap_url, settings=settings, max_attempts=1)
|
||||||
|
except (SsrfBlockedError, FetchError, httpx.HTTPError):
|
||||||
|
return []
|
||||||
|
if result.status_code != 200:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
root = ElementTree.fromstring(result.content)
|
||||||
|
except ElementTree.ParseError:
|
||||||
|
return []
|
||||||
|
|
||||||
|
ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
|
||||||
|
urls = [loc.text.strip() for loc in root.findall(".//sm:url/sm:loc", ns) if loc.text]
|
||||||
|
return urls
|
||||||
|
|
||||||
|
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||||
|
settings = get_settings()
|
||||||
|
pages: list[str] = source.configuration_metadata.get("pages") or (
|
||||||
|
[source.base_url] if source.base_url else []
|
||||||
|
)
|
||||||
|
pages = pages[: settings.max_pages_per_domain]
|
||||||
|
|
||||||
|
documents: list[CollectedDocument] = []
|
||||||
|
seen_hashes: set[str] = set()
|
||||||
|
attempted = 0
|
||||||
|
any_success = False
|
||||||
|
last_error: str | None = None
|
||||||
|
|
||||||
|
for page_url in pages:
|
||||||
|
attempted += 1
|
||||||
|
try:
|
||||||
|
if not await is_allowed(page_url, settings=settings):
|
||||||
|
logger.info("website_collector_robots_disallowed", url=page_url)
|
||||||
|
continue
|
||||||
|
|
||||||
|
result = await fetch_with_retries(page_url, settings=settings)
|
||||||
|
if result.status_code == 401 or result.status_code == 403:
|
||||||
|
last_error = f"HTTP {result.status_code} (auth required) for {page_url}"
|
||||||
|
continue
|
||||||
|
if result.status_code >= 400:
|
||||||
|
last_error = f"HTTP {result.status_code} for {page_url}"
|
||||||
|
continue
|
||||||
|
|
||||||
|
text, method = extract_readable_text(result.text, page_url)
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
content_hash = compute_content_hash(text)
|
||||||
|
if content_hash in seen_hashes:
|
||||||
|
continue
|
||||||
|
seen_hashes.add(content_hash)
|
||||||
|
|
||||||
|
documents.append(
|
||||||
|
CollectedDocument(
|
||||||
|
url=page_url,
|
||||||
|
canonical_url=canonicalize_url(result.final_url),
|
||||||
|
title=extract_title(result.text),
|
||||||
|
author=None,
|
||||||
|
publication_date=None,
|
||||||
|
retrieved_date=datetime.now(UTC),
|
||||||
|
content_text=text,
|
||||||
|
content_hash=content_hash,
|
||||||
|
metadata={"http_status": result.status_code},
|
||||||
|
extraction_method=method,
|
||||||
|
http_status=result.status_code,
|
||||||
|
trust_score=0.85,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
any_success = True
|
||||||
|
except SsrfBlockedError as exc:
|
||||||
|
last_error = str(exc)
|
||||||
|
logger.warning("website_collector_ssrf_blocked", url=page_url, error=str(exc))
|
||||||
|
except (FetchError, httpx.HTTPError) as exc:
|
||||||
|
last_error = str(exc)
|
||||||
|
logger.warning("website_collector_fetch_failed", url=page_url, error=str(exc))
|
||||||
|
|
||||||
|
if not documents:
|
||||||
|
status = SourceStatus.FAILED if attempted > 0 else SourceStatus.ACTIVE
|
||||||
|
return CollectionResult(
|
||||||
|
status=status, documents=[], error=last_error, pages_attempted=attempted
|
||||||
|
)
|
||||||
|
|
||||||
|
status = SourceStatus.ACTIVE if any_success else SourceStatus.FAILED
|
||||||
|
return CollectionResult(
|
||||||
|
status=status,
|
||||||
|
documents=documents,
|
||||||
|
error=last_error if not any_success else None,
|
||||||
|
pages_attempted=attempted,
|
||||||
|
)
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Centralized application configuration.
|
||||||
|
|
||||||
|
Every environment-dependent value is read here, once, via pydantic-settings.
|
||||||
|
Application code should depend on `get_settings()`, never on `os.environ`
|
||||||
|
directly - that's what keeps provider selection (LLM/search/notifications/auth)
|
||||||
|
swappable from a single place.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import model_validator
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
# --- App ---
|
||||||
|
app_env: Literal["development", "test", "production"] = "development"
|
||||||
|
app_name: str = "CI Agent"
|
||||||
|
frontend_url: str = "http://localhost:3000"
|
||||||
|
backend_url: str = "http://localhost:8000"
|
||||||
|
|
||||||
|
# --- Reverse proxy ---
|
||||||
|
# Empty (default) = trust only the direct TCP connection for client-IP
|
||||||
|
# resolution (app.core.security.get_client_ip) - correct today, no proxy
|
||||||
|
# exists. Set to "CF-Connecting-IP" once deployed behind Cloudflare's
|
||||||
|
# proxy so IP-based localhost detection and the ban/throttle system read
|
||||||
|
# the real visitor IP instead of the proxy's own address. Only ever set
|
||||||
|
# this when it's actually known a trusted proxy sits in front and strips
|
||||||
|
# this header from untrusted clients - see KNOWN_LIMITATIONS.md.
|
||||||
|
trusted_proxy_ip_header: str = ""
|
||||||
|
|
||||||
|
# --- Local-dev convenience ---
|
||||||
|
# Comma-separated extra IPs that `is_localhost` treats as equivalent to
|
||||||
|
# real loopback, on top of 127.0.0.1/::1. Needed because Docker
|
||||||
|
# Desktop's bridge networking means even traffic that originates on the
|
||||||
|
# host machine itself (dev tooling driving a browser against
|
||||||
|
# localhost:3000/8000) arrives at the container from the bridge
|
||||||
|
# gateway address, not literal loopback - so without this,
|
||||||
|
# is_localhost is always false for that traffic, which both re-exposes
|
||||||
|
# the admin-only API-keys/logs boxes' loopback gate and forces the
|
||||||
|
# Turnstile widget to render (a real crash risk for automated browser
|
||||||
|
# tooling - see KNOWN_LIMITATIONS.md). Empty by default (no bypass);
|
||||||
|
# only ever populate this with IPs you know are your own dev host,
|
||||||
|
# never in a real deployment.
|
||||||
|
additional_trusted_local_ips: str = ""
|
||||||
|
|
||||||
|
# --- Auth ---
|
||||||
|
auth_mode: Literal["local", "jwt"] = "local"
|
||||||
|
jwt_secret: str = "dev-only-change-me-32-characters-minimum"
|
||||||
|
jwt_access_token_minutes: int = 15
|
||||||
|
jwt_refresh_token_days: int = 7
|
||||||
|
# Encrypts each user's own stored API keys at rest (app/core/crypto.py)
|
||||||
|
# - a Fernet key (44-char urlsafe-base64). This dev-only default is
|
||||||
|
# fixed/insecure by design (same precedent as jwt_secret above); a real
|
||||||
|
# deployment must set its own via `Fernet.generate_key()`. Rotating
|
||||||
|
# this value makes every already-stored user key undecryptable, so
|
||||||
|
# treat it like any other production secret - never regenerate it
|
||||||
|
# casually once real keys exist.
|
||||||
|
api_key_encryption_secret: str = "_wYtsm3nJ070987snBFp2eWVI5pyC0H9gGFUb6Cy4cQ="
|
||||||
|
|
||||||
|
# --- Database ---
|
||||||
|
database_url: str = "sqlite+aiosqlite:///./ciagent_dev.db"
|
||||||
|
|
||||||
|
# --- Redis / Celery ---
|
||||||
|
redis_url: str = "redis://localhost:6379/0"
|
||||||
|
celery_task_always_eager: bool = False
|
||||||
|
|
||||||
|
# --- LLM ---
|
||||||
|
llm_provider: Literal["mock", "anthropic", "ollama", "gemini"] = "mock"
|
||||||
|
anthropic_api_key: str = ""
|
||||||
|
anthropic_model: str = "claude-sonnet-5"
|
||||||
|
ollama_base_url: str = "http://localhost:11434"
|
||||||
|
ollama_model: str = "llama3.1"
|
||||||
|
# Gemini has an actual free rate-limited tier (unlike OpenAI's expiring
|
||||||
|
# trial credits), so it's the production option this app ships wired up.
|
||||||
|
gemini_api_key: str = ""
|
||||||
|
gemini_model: str = "gemini-2.0-flash"
|
||||||
|
llm_max_tokens_per_request: int = 4000
|
||||||
|
llm_max_retries: int = 2
|
||||||
|
|
||||||
|
# --- Search ---
|
||||||
|
search_provider: Literal["mock", "brave"] = "mock"
|
||||||
|
brave_search_api_key: str = ""
|
||||||
|
serpapi_api_key: str = ""
|
||||||
|
bing_search_api_key: str = ""
|
||||||
|
|
||||||
|
# --- Patents ---
|
||||||
|
# Free key via account registration at data.uspto.gov/apis/getting-started.
|
||||||
|
# Unset by default - PatentSourceCollector falls back to its existing
|
||||||
|
# honest disabled/fixture behavior when this is empty.
|
||||||
|
uspto_api_key: str = ""
|
||||||
|
|
||||||
|
# --- Company enrichment (NinjaPear / nubela.co) ---
|
||||||
|
# Paid, per-credit API - unset by default. Enrichment only ever fires
|
||||||
|
# once, at company-creation time (never on a recurring schedule), and
|
||||||
|
# the enqueue itself is skipped entirely when this is empty - see
|
||||||
|
# company_service.create_company.
|
||||||
|
ninjapear_api_key: str = ""
|
||||||
|
ninjapear_max_leadership_lookups: int = 5
|
||||||
|
|
||||||
|
# --- Email ---
|
||||||
|
smtp_host: str = "localhost"
|
||||||
|
smtp_port: int = 1025
|
||||||
|
smtp_username: str = ""
|
||||||
|
smtp_password: str = ""
|
||||||
|
smtp_from_email: str = "[email protected]"
|
||||||
|
smtp_use_tls: bool = False
|
||||||
|
|
||||||
|
# --- Resend (transactional security email: verification/reset/lockout) ---
|
||||||
|
# Unset by default - security_email_service falls back to the SMTP
|
||||||
|
# provider above (Mailpit locally) when this is empty, so the whole
|
||||||
|
# verification/reset flow is testable with zero Resend account needed.
|
||||||
|
# Deliberately separate from the alert-notification path (smtp_from_email
|
||||||
|
# above) - a different sender identity for account-security mail.
|
||||||
|
resend_api_key: str = ""
|
||||||
|
resend_security_from_email: str = "[email protected]"
|
||||||
|
|
||||||
|
# --- Cloudflare Turnstile (CAPTCHA on register/login/password-reset) ---
|
||||||
|
# Unset by default - skipped entirely for register/login/password-reset
|
||||||
|
# when either the caller is on loopback (see is_localhost) or no secret
|
||||||
|
# is configured, matching this app's usual optional-provider convention.
|
||||||
|
turnstile_site_key: str = ""
|
||||||
|
turnstile_secret: str = ""
|
||||||
|
|
||||||
|
# --- SMS ---
|
||||||
|
notification_sms_enabled: bool = False
|
||||||
|
sms_provider: Literal["twilio", "telnyx"] = "twilio"
|
||||||
|
twilio_account_sid: str = ""
|
||||||
|
twilio_auth_token: str = ""
|
||||||
|
twilio_from_number: str = ""
|
||||||
|
telnyx_api_key: str = ""
|
||||||
|
telnyx_from_number: str = ""
|
||||||
|
sms_monthly_cap: int = 50
|
||||||
|
|
||||||
|
# --- GitHub ---
|
||||||
|
github_token: str = ""
|
||||||
|
|
||||||
|
# --- Scheduling ---
|
||||||
|
default_timezone: str = "America/New_York"
|
||||||
|
default_monitoring_frequency: str = "weekly"
|
||||||
|
minimum_monitoring_interval_minutes: int = 60
|
||||||
|
|
||||||
|
# --- Scraper ---
|
||||||
|
scraper_user_agent: str = "CIAgentBot/1.0 (+https://ci-agent.local/bot)"
|
||||||
|
max_pages_per_domain: int = 25
|
||||||
|
scraper_request_timeout_seconds: int = 30
|
||||||
|
scraper_domain_delay_seconds: float = 2.0
|
||||||
|
|
||||||
|
# --- Cost controls ---
|
||||||
|
max_companies_per_user: int = 25
|
||||||
|
max_manual_runs_per_day: int = 10
|
||||||
|
|
||||||
|
# --- Retention / logging ---
|
||||||
|
data_retention_days: int = 365
|
||||||
|
log_level: str = "INFO"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_production(self) -> bool:
|
||||||
|
return self.app_env == "production"
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _forbid_local_auth_in_production(self) -> Settings:
|
||||||
|
if self.app_env == "production" and self.auth_mode == "local":
|
||||||
|
raise ValueError(
|
||||||
|
"AUTH_MODE=local is a development convenience and must not be used "
|
||||||
|
"when APP_ENV=production. Set AUTH_MODE=jwt."
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
"""Symmetric encryption for secrets stored at rest - currently just
|
||||||
|
per-user API keys (app/models/user_api_key.py). Not used for passwords
|
||||||
|
(those are one-way hashed via app.core.security, never decrypted) - this
|
||||||
|
is specifically for secrets the app must later read back out in plaintext
|
||||||
|
to actually call a third-party API on the user's behalf.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secret(plaintext: str, settings: Settings) -> str:
|
||||||
|
return Fernet(settings.api_key_encryption_secret).encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secret(ciphertext: str, settings: Settings) -> str:
|
||||||
|
try:
|
||||||
|
return Fernet(settings.api_key_encryption_secret).decrypt(ciphertext.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
# Only real cause in practice: api_key_encryption_secret was
|
||||||
|
# rotated after this value was encrypted under the old one.
|
||||||
|
raise ValueError("Stored value cannot be decrypted with the current key") from exc
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""App-level exceptions, mapped to HTTP responses in one place (main.py).
|
||||||
|
|
||||||
|
Services raise these instead of `fastapi.HTTPException` so business logic
|
||||||
|
stays importable/testable from Celery tasks, which don't have an HTTP
|
||||||
|
response to raise into.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
class AppError(Exception):
|
||||||
|
"""Base class for all app-level errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class NotFoundError(AppError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConflictError(AppError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class AuthenticationError(AppError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ForbiddenError(AppError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationAppError(AppError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RateLimitedError(AppError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ThrottledError(RateLimitedError):
|
||||||
|
"""Raised by the IP throttle/ban engine (app/services/ip_throttle_service.py)
|
||||||
|
- carries a machine-readable retry_after_seconds so the frontend can
|
||||||
|
drive a live countdown instead of just showing a generic message."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, retry_after_seconds: int | None = None) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.retry_after_seconds = retry_after_seconds
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""SSRF-safe HTTP fetching. Every collector and the custom-URL feature must
|
||||||
|
route network requests through `safe_fetch` / `fetch_with_retries` - see
|
||||||
|
SECURITY.md for the full threat model this defends against.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import ipaddress
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from urllib.parse import urljoin, urlparse
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import tenacity
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_ALLOWED_SCHEMES = {"http", "https"}
|
||||||
|
_MAX_REDIRECTS = 5
|
||||||
|
_METADATA_IPS = {"169.254.169.254", "fd00:ec2::254"}
|
||||||
|
|
||||||
|
# Per-hostname request spacing. In-process only - a multi-worker Celery
|
||||||
|
# deployment would need a shared store (e.g. Redis) for this to be a true
|
||||||
|
# global rate limit across workers; see KNOWN_LIMITATIONS.md.
|
||||||
|
_last_request_at: dict[str, float] = {}
|
||||||
|
_domain_locks: dict[str, asyncio.Lock] = {}
|
||||||
|
|
||||||
|
|
||||||
|
class SsrfBlockedError(Exception):
|
||||||
|
"""Raised when a URL resolves to, or points at, a disallowed network target."""
|
||||||
|
|
||||||
|
|
||||||
|
class FetchError(Exception):
|
||||||
|
"""Raised for network-level failures after retries are exhausted."""
|
||||||
|
|
||||||
|
|
||||||
|
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||||
|
return (
|
||||||
|
ip.is_private
|
||||||
|
or ip.is_loopback
|
||||||
|
or ip.is_link_local
|
||||||
|
or ip.is_multicast
|
||||||
|
or ip.is_reserved
|
||||||
|
or ip.is_unspecified
|
||||||
|
or str(ip) in _METADATA_IPS
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_and_validate(hostname: str) -> None:
|
||||||
|
try:
|
||||||
|
infos = socket.getaddrinfo(hostname, None)
|
||||||
|
except socket.gaierror as exc:
|
||||||
|
raise SsrfBlockedError(f"Could not resolve host: {hostname}") from exc
|
||||||
|
|
||||||
|
if not infos:
|
||||||
|
raise SsrfBlockedError(f"Could not resolve host: {hostname}")
|
||||||
|
|
||||||
|
for info in infos:
|
||||||
|
raw_ip = info[4][0]
|
||||||
|
ip = ipaddress.ip_address(raw_ip.split("%")[0])
|
||||||
|
if _is_blocked_ip(ip):
|
||||||
|
raise SsrfBlockedError(f"Resolved address for {hostname} is not a public address: {ip}")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(url: str) -> str:
|
||||||
|
"""Raises SsrfBlockedError if `url` is unsafe to fetch. Returns the
|
||||||
|
hostname."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if parsed.scheme not in _ALLOWED_SCHEMES:
|
||||||
|
raise SsrfBlockedError(f"Unsupported URL scheme: {parsed.scheme!r}")
|
||||||
|
if not parsed.hostname:
|
||||||
|
raise SsrfBlockedError("URL has no hostname")
|
||||||
|
_resolve_and_validate(parsed.hostname)
|
||||||
|
return parsed.hostname
|
||||||
|
|
||||||
|
|
||||||
|
async def _respect_domain_delay(hostname: str, delay_seconds: float) -> None:
|
||||||
|
if delay_seconds <= 0:
|
||||||
|
return
|
||||||
|
lock = _domain_locks.setdefault(hostname, asyncio.Lock())
|
||||||
|
async with lock:
|
||||||
|
now = time.monotonic()
|
||||||
|
last = _last_request_at.get(hostname)
|
||||||
|
if last is not None:
|
||||||
|
elapsed = now - last
|
||||||
|
if elapsed < delay_seconds:
|
||||||
|
await asyncio.sleep(delay_seconds - elapsed)
|
||||||
|
_last_request_at[hostname] = time.monotonic()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SafeFetchResult:
|
||||||
|
status_code: int
|
||||||
|
text: str
|
||||||
|
content: bytes
|
||||||
|
headers: dict[str, str]
|
||||||
|
final_url: str
|
||||||
|
|
||||||
|
|
||||||
|
async def safe_fetch(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
method: str = "GET",
|
||||||
|
max_redirects: int = _MAX_REDIRECTS,
|
||||||
|
extra_headers: dict[str, str] | None = None,
|
||||||
|
) -> SafeFetchResult:
|
||||||
|
"""Fetch `url` with SSRF validation applied to the initial URL and every
|
||||||
|
redirect hop. Never follows a redirect without re-validating it."""
|
||||||
|
settings = settings or get_settings()
|
||||||
|
current_url = url
|
||||||
|
|
||||||
|
for _ in range(max_redirects + 1):
|
||||||
|
hostname = validate_url(current_url)
|
||||||
|
await _respect_domain_delay(hostname, settings.scraper_domain_delay_seconds)
|
||||||
|
|
||||||
|
headers = {"User-Agent": settings.scraper_user_agent, **(extra_headers or {})}
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
follow_redirects=False,
|
||||||
|
timeout=settings.scraper_request_timeout_seconds,
|
||||||
|
headers=headers,
|
||||||
|
) as client:
|
||||||
|
response = await client.request(method, current_url)
|
||||||
|
|
||||||
|
if response.status_code in (301, 302, 303, 307, 308) and "location" in response.headers:
|
||||||
|
current_url = urljoin(current_url, response.headers["location"])
|
||||||
|
continue
|
||||||
|
|
||||||
|
return SafeFetchResult(
|
||||||
|
status_code=response.status_code,
|
||||||
|
text=response.text,
|
||||||
|
content=response.content,
|
||||||
|
headers=dict(response.headers),
|
||||||
|
final_url=current_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
raise SsrfBlockedError(f"Too many redirects starting from {url}")
|
||||||
|
|
||||||
|
|
||||||
|
def _is_retryable(exc: BaseException) -> bool:
|
||||||
|
if isinstance(exc, SsrfBlockedError):
|
||||||
|
return False
|
||||||
|
if isinstance(exc, httpx.HTTPError):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_with_retries(
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
settings: Settings | None = None,
|
||||||
|
max_attempts: int = 3,
|
||||||
|
**kwargs,
|
||||||
|
) -> SafeFetchResult:
|
||||||
|
"""`safe_fetch` wrapped with exponential-backoff retry for transient
|
||||||
|
network errors only - SSRF blocks and 4xx responses are not retried."""
|
||||||
|
settings = settings or get_settings()
|
||||||
|
|
||||||
|
async for attempt in tenacity.AsyncRetrying(
|
||||||
|
stop=tenacity.stop_after_attempt(max_attempts),
|
||||||
|
wait=tenacity.wait_exponential(multiplier=1, min=1, max=10),
|
||||||
|
retry=tenacity.retry_if_exception(_is_retryable),
|
||||||
|
reraise=True,
|
||||||
|
):
|
||||||
|
with attempt:
|
||||||
|
result = await safe_fetch(url, settings=settings, **kwargs)
|
||||||
|
if result.status_code >= 500:
|
||||||
|
raise FetchError(f"Server error {result.status_code} fetching {url}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
raise FetchError(f"Exhausted retries fetching {url}") # pragma: no cover
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
"""Structured logging setup with secret redaction, plus a capped live-log
|
||||||
|
feed for the Settings UI.
|
||||||
|
|
||||||
|
Never log raw credentials. Any log event field whose key looks secret-shaped
|
||||||
|
gets its value replaced before it leaves the process.
|
||||||
|
|
||||||
|
Logs also get pushed to a capped Redis list (`app:logs`) so the Settings
|
||||||
|
page can show a live feed of what the application is doing - stdout/Docker
|
||||||
|
logs alone aren't visible from the UI, and this app runs as several
|
||||||
|
separate processes (API, Celery worker, beat), so Redis (already shared
|
||||||
|
infra across all of them) is the simplest common sink. A sink failure here
|
||||||
|
must never break the actual log call or crash the app - every Redis
|
||||||
|
operation is wrapped and swallowed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
_SECRET_KEY_PATTERN = re.compile(
|
||||||
|
r"(key|secret|token|password|authorization|credential)", re.IGNORECASE
|
||||||
|
)
|
||||||
|
_REDACTED = "***REDACTED***"
|
||||||
|
|
||||||
|
LOG_STREAM_KEY = "app:logs"
|
||||||
|
LOG_STREAM_MAXLEN = 500
|
||||||
|
|
||||||
|
LOG_CATEGORY_LABELS = {
|
||||||
|
"internal_error": "Internal error",
|
||||||
|
"api_error": "API error",
|
||||||
|
"important": "Important",
|
||||||
|
"normal": "Normal",
|
||||||
|
}
|
||||||
|
|
||||||
|
_NON_CONTEXT_KEYS = {"level", "timestamp", "logger", "event", "important"}
|
||||||
|
|
||||||
|
_sync_redis_client: Any = None
|
||||||
|
|
||||||
|
|
||||||
|
def _redact_secrets(_logger: Any, _method_name: str, event_dict: dict) -> dict:
|
||||||
|
for key in list(event_dict.keys()):
|
||||||
|
if _SECRET_KEY_PATTERN.search(key):
|
||||||
|
event_dict[key] = _REDACTED
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _category_for(level: str, event_dict: dict) -> str:
|
||||||
|
if level in ("error", "critical"):
|
||||||
|
return "internal_error"
|
||||||
|
if level == "warning":
|
||||||
|
return "api_error"
|
||||||
|
if level == "info" and event_dict.get("important") is True:
|
||||||
|
return "important"
|
||||||
|
return "normal"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_sync_redis_client(redis_url: str):
|
||||||
|
global _sync_redis_client
|
||||||
|
if _sync_redis_client is None:
|
||||||
|
import redis as redis_sync
|
||||||
|
|
||||||
|
_sync_redis_client = redis_sync.Redis.from_url(redis_url)
|
||||||
|
return _sync_redis_client
|
||||||
|
|
||||||
|
|
||||||
|
def _make_capture_processor(redis_url: str):
|
||||||
|
def _capture_for_ui(_logger: Any, method_name: str, event_dict: dict) -> dict:
|
||||||
|
level = event_dict.get("level", method_name)
|
||||||
|
if level == "debug":
|
||||||
|
return event_dict
|
||||||
|
try:
|
||||||
|
record = {
|
||||||
|
"ts": event_dict.get("timestamp") or datetime.now(UTC).isoformat(),
|
||||||
|
"level": level,
|
||||||
|
"category": _category_for(level, event_dict),
|
||||||
|
"logger": event_dict.get("logger", "app"),
|
||||||
|
"event": str(event_dict.get("event", "")),
|
||||||
|
"context": {k: v for k, v in event_dict.items() if k not in _NON_CONTEXT_KEYS},
|
||||||
|
}
|
||||||
|
client = _get_sync_redis_client(redis_url)
|
||||||
|
pipe = client.pipeline()
|
||||||
|
pipe.lpush(LOG_STREAM_KEY, json.dumps(record, default=str))
|
||||||
|
pipe.ltrim(LOG_STREAM_KEY, 0, LOG_STREAM_MAXLEN - 1)
|
||||||
|
pipe.execute()
|
||||||
|
except Exception: # noqa: BLE001 - a broken log sink must never break the app
|
||||||
|
pass
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
return _capture_for_ui
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(settings: Settings) -> None:
|
||||||
|
logging.basicConfig(level=settings.log_level, format="%(message)s")
|
||||||
|
|
||||||
|
structlog.configure(
|
||||||
|
processors=[
|
||||||
|
structlog.contextvars.merge_contextvars,
|
||||||
|
structlog.stdlib.add_log_level,
|
||||||
|
structlog.processors.TimeStamper(fmt="iso"),
|
||||||
|
_redact_secrets,
|
||||||
|
_make_capture_processor(settings.redis_url),
|
||||||
|
structlog.processors.StackInfoRenderer(),
|
||||||
|
structlog.processors.format_exc_info,
|
||||||
|
structlog.processors.JSONRenderer(),
|
||||||
|
],
|
||||||
|
wrapper_class=structlog.make_filtering_bound_logger(
|
||||||
|
logging.getLevelName(settings.log_level)
|
||||||
|
),
|
||||||
|
context_class=dict,
|
||||||
|
logger_factory=structlog.PrintLoggerFactory(),
|
||||||
|
cache_logger_on_first_use=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||||
|
# `.bind(logger=name)` (rather than `structlog.stdlib.add_logger_name`,
|
||||||
|
# which only works with `structlog.stdlib.LoggerFactory`) is what makes
|
||||||
|
# the module name available to `_capture_for_ui` below - this app uses
|
||||||
|
# `PrintLoggerFactory`, whose loggers have no `.name` attribute.
|
||||||
|
return structlog.get_logger().bind(logger=name)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_recent_logs(settings: Settings, limit: int = 100) -> list[dict]:
|
||||||
|
"""Most-recent-first log entries from the capped Redis feed, for the
|
||||||
|
Settings page's Logging box."""
|
||||||
|
import redis.asyncio as redis_asyncio
|
||||||
|
|
||||||
|
client = redis_asyncio.from_url(settings.redis_url)
|
||||||
|
try:
|
||||||
|
raw_entries = await client.lrange(LOG_STREAM_KEY, 0, limit - 1)
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
entries: list[dict] = []
|
||||||
|
for raw in raw_entries:
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(raw))
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
continue
|
||||||
|
return entries
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Shared rate limiter (slowapi / limits, in-memory by default).
|
||||||
|
|
||||||
|
Applied per-route via `@limiter.limit(...)`. Auth endpoints get the
|
||||||
|
tightest limits since they're the classic credential-stuffing target.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import Request
|
||||||
|
from slowapi import Limiter
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.security import get_client_ip
|
||||||
|
|
||||||
|
|
||||||
|
def _client_ip_key(request: Request) -> str:
|
||||||
|
"""Same IP resolution as everything else in the app (is_localhost, the
|
||||||
|
ban/throttle engine) - slowapi's own get_remote_address reads
|
||||||
|
request.client.host directly, which would be Nginx/Cloudflare's own
|
||||||
|
address for every visitor once deployed behind a reverse proxy,
|
||||||
|
collapsing all rate limits into one shared bucket. See
|
||||||
|
app.core.security.get_client_ip / Settings.trusted_proxy_ip_header."""
|
||||||
|
return get_client_ip(request, get_settings())
|
||||||
|
|
||||||
|
|
||||||
|
# Disabled under APP_ENV=test so the many auth calls a test suite makes don't
|
||||||
|
# trip real limits (real limiter behavior is covered by its own test).
|
||||||
|
limiter = Limiter(key_func=_client_ip_key, enabled=get_settings().app_env != "test")
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
"""Password hashing and JWT helpers.
|
||||||
|
|
||||||
|
Password hashing uses Argon2 (via `argon2-cffi`) directly - it's the
|
||||||
|
currently recommended default and needs no extra abstraction layer.
|
||||||
|
JWTs are signed with `JWT_SECRET` (HS256); access tokens are short-lived,
|
||||||
|
refresh tokens are long-lived but stored server-side only as a hash so a
|
||||||
|
leaked DB row can't be replayed as a valid token by itself.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
from argon2 import PasswordHasher
|
||||||
|
from argon2.exceptions import VerifyMismatchError
|
||||||
|
from fastapi import Request
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
|
||||||
|
_hasher = PasswordHasher()
|
||||||
|
|
||||||
|
|
||||||
|
def get_client_ip(request: Request, settings: Settings) -> str:
|
||||||
|
"""The single source of truth for "what IP is this request from" - used
|
||||||
|
by `is_localhost`, the IP throttle/ban engine, and anywhere else that
|
||||||
|
needs to identify a caller. Today there's no reverse proxy in front of
|
||||||
|
uvicorn in this stack, so the direct TCP peer (`request.client.host`) is
|
||||||
|
the real originating address.
|
||||||
|
|
||||||
|
Once deployed behind Cloudflare (or Nginx), the direct peer becomes the
|
||||||
|
proxy itself, not the visitor - `settings.trusted_proxy_ip_header` (e.g.
|
||||||
|
"CF-Connecting-IP") switches this to read the real address from that
|
||||||
|
header instead. Only ever set this when it's actually known the proxy is
|
||||||
|
in front and stripping/overwriting that header from untrusted clients -
|
||||||
|
otherwise a client could simply forge it to spoof any IP. Left empty by
|
||||||
|
default (trust the direct connection only) - see KNOWN_LIMITATIONS.md."""
|
||||||
|
header_name = settings.trusted_proxy_ip_header
|
||||||
|
if header_name:
|
||||||
|
forwarded = request.headers.get(header_name)
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.strip()
|
||||||
|
return request.client.host if request.client is not None else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def is_localhost(request: Request, settings: Settings) -> bool:
|
||||||
|
"""True when the request's resolved client IP (see `get_client_ip`) is
|
||||||
|
this machine's loopback address - not merely "someone on the LAN" - or
|
||||||
|
is explicitly listed in `settings.additional_trusted_local_ips` (empty
|
||||||
|
by default; a narrow, opt-in escape hatch for Docker Desktop's bridge
|
||||||
|
networking, where even host-originated traffic doesn't arrive as
|
||||||
|
literal loopback - see KNOWN_LIMITATIONS.md)."""
|
||||||
|
client_ip = get_client_ip(request, settings)
|
||||||
|
if client_ip in ("127.0.0.1", "::1"):
|
||||||
|
return True
|
||||||
|
extra = {ip.strip() for ip in settings.additional_trusted_local_ips.split(",") if ip.strip()}
|
||||||
|
return client_ip in extra
|
||||||
|
|
||||||
|
|
||||||
|
def hash_password(raw_password: str) -> str:
|
||||||
|
return _hasher.hash(raw_password)
|
||||||
|
|
||||||
|
|
||||||
|
def verify_password(raw_password: str, password_hash: str) -> bool:
|
||||||
|
try:
|
||||||
|
return _hasher.verify(password_hash, raw_password)
|
||||||
|
except VerifyMismatchError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class TokenType(StrEnum):
|
||||||
|
ACCESS = "access"
|
||||||
|
REFRESH = "refresh"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DecodedToken:
|
||||||
|
user_id: uuid.UUID
|
||||||
|
token_type: TokenType
|
||||||
|
jti: str
|
||||||
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
def create_access_token(user_id: uuid.UUID, settings: Settings) -> str:
|
||||||
|
return _encode_token(
|
||||||
|
user_id, TokenType.ACCESS, timedelta(minutes=settings.jwt_access_token_minutes), settings
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_refresh_token(user_id: uuid.UUID, settings: Settings) -> tuple[str, str, datetime]:
|
||||||
|
"""Returns (raw_jwt, jti, expires_at). Caller stores a hash of `jti`, not the JWT itself."""
|
||||||
|
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_days)
|
||||||
|
jti = secrets.token_urlsafe(32)
|
||||||
|
token = _encode_token(
|
||||||
|
user_id,
|
||||||
|
TokenType.REFRESH,
|
||||||
|
timedelta(days=settings.jwt_refresh_token_days),
|
||||||
|
settings,
|
||||||
|
jti=jti,
|
||||||
|
)
|
||||||
|
return token, jti, expires_at
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_token(
|
||||||
|
user_id: uuid.UUID,
|
||||||
|
token_type: TokenType,
|
||||||
|
expires_in: timedelta,
|
||||||
|
settings: Settings,
|
||||||
|
jti: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
payload = {
|
||||||
|
"sub": str(user_id),
|
||||||
|
"type": token_type.value,
|
||||||
|
"iat": now,
|
||||||
|
"exp": now + expires_in,
|
||||||
|
"jti": jti or secrets.token_urlsafe(16),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTokenError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def decode_token(token: str, settings: Settings, expected_type: TokenType) -> DecodedToken:
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
|
||||||
|
except jwt.PyJWTError as exc:
|
||||||
|
raise InvalidTokenError(str(exc)) from exc
|
||||||
|
|
||||||
|
if payload.get("type") != expected_type.value:
|
||||||
|
raise InvalidTokenError(f"Expected a {expected_type.value} token")
|
||||||
|
|
||||||
|
try:
|
||||||
|
user_id = uuid.UUID(payload["sub"])
|
||||||
|
except (KeyError, ValueError) as exc:
|
||||||
|
raise InvalidTokenError("Malformed token subject") from exc
|
||||||
|
|
||||||
|
return DecodedToken(
|
||||||
|
user_id=user_id,
|
||||||
|
token_type=TokenType(payload["type"]),
|
||||||
|
jti=payload["jti"],
|
||||||
|
expires_at=datetime.fromtimestamp(payload["exp"], tz=UTC),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def hash_token_identifier(jti: str) -> str:
|
||||||
|
"""One-way hash of a refresh token's `jti` for storage/comparison (not the JWT itself)."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
return hashlib.sha256(jti.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def generate_email_code() -> str:
|
||||||
|
"""A 6-digit numeric code for email verification / password reset -
|
||||||
|
short-lived and IP-throttled (app/services/ip_throttle_service.py), so
|
||||||
|
it doesn't need Argon2's cost the way a password does."""
|
||||||
|
return f"{secrets.randbelow(1_000_000):06d}"
|
||||||
|
|
||||||
|
|
||||||
|
def hash_email_code(code: str) -> str:
|
||||||
|
"""One-way hash of an email code for storage/comparison (never the raw
|
||||||
|
code) - same precedent as hash_token_identifier."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
"""Small text utilities with no natural home elsewhere."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
|
||||||
|
|
||||||
|
|
||||||
|
def slugify(value: str) -> str:
|
||||||
|
value = value.strip().lower()
|
||||||
|
value = _SLUG_STRIP_RE.sub("-", value).strip("-")
|
||||||
|
return value or "company"
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
"""Declarative base + shared mixins for all ORM models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
|
||||||
|
|
||||||
|
def utcnow() -> datetime:
|
||||||
|
return datetime.now(UTC)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_aware_utc(value: datetime) -> datetime:
|
||||||
|
"""SQLite's `DateTime(timezone=True)` silently drops tzinfo on read back
|
||||||
|
(Postgres does not). Anything read from the DB and compared against an
|
||||||
|
aware `datetime.now(UTC)` must go through this first so the app behaves
|
||||||
|
identically on both backends."""
|
||||||
|
if value.tzinfo is None:
|
||||||
|
return value.replace(tzinfo=UTC)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKeyMixin:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4, unique=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Async SQLAlchemy engine/session setup.
|
||||||
|
|
||||||
|
Works against Postgres (`postgresql+psycopg://...`) or SQLite
|
||||||
|
(`sqlite+aiosqlite://...`) depending on `DATABASE_URL` - the same models and
|
||||||
|
repositories run against either, which is what makes the no-Docker local dev
|
||||||
|
path possible.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import AsyncGenerator
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import (
|
||||||
|
AsyncEngine,
|
||||||
|
AsyncSession,
|
||||||
|
async_sessionmaker,
|
||||||
|
create_async_engine,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.core.config import get_settings
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_engine() -> AsyncEngine:
|
||||||
|
settings = get_settings()
|
||||||
|
connect_args = {}
|
||||||
|
if settings.database_url.startswith("sqlite"):
|
||||||
|
connect_args = {"check_same_thread": False}
|
||||||
|
return create_async_engine(
|
||||||
|
settings.database_url,
|
||||||
|
echo=False,
|
||||||
|
pool_pre_ping=True,
|
||||||
|
connect_args=connect_args,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||||
|
return async_sessionmaker(bind=get_engine(), expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||||
|
session_factory = get_sessionmaker()
|
||||||
|
async with session_factory() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Company-enrichment provider interface. Answers "what does a paid,
|
||||||
|
first-party data vendor already know about this company?" - a richer,
|
||||||
|
metered complement to the free SearchProvider/LLM discovery pipeline
|
||||||
|
(app/services/discovery_service.py), never a replacement for it. Every
|
||||||
|
method here maps to one NinjaPear (nubela.co) API endpoint; orchestration
|
||||||
|
(call order, the leadership-lookup cap, partial-failure handling) lives in
|
||||||
|
app/services/enrichment_service.py, not here - this Protocol is a dumb I/O
|
||||||
|
layer, matching app/search/base.py's shape.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class LeadershipMember(BaseModel):
|
||||||
|
name: str
|
||||||
|
title: str | None = None
|
||||||
|
work_email: str | None = None
|
||||||
|
profile_url: str | None = None
|
||||||
|
bio: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyDetails(BaseModel):
|
||||||
|
description: str | None = None
|
||||||
|
industry: str | None = None
|
||||||
|
founded_year: int | None = None
|
||||||
|
specialties: list[str] = Field(default_factory=list)
|
||||||
|
leadership_team: list[LeadershipMember] = Field(default_factory=list)
|
||||||
|
employee_count_range: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FundingRound(BaseModel):
|
||||||
|
round_name: str | None = None
|
||||||
|
amount: str | None = None
|
||||||
|
date: str | None = None
|
||||||
|
investors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyFunding(BaseModel):
|
||||||
|
total_raised: str | None = None
|
||||||
|
rounds: list[FundingRound] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CompetitorWithReason(BaseModel):
|
||||||
|
name: str
|
||||||
|
reason: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Product(BaseModel):
|
||||||
|
name: str
|
||||||
|
description: str | None = None
|
||||||
|
category: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RecentUpdate(BaseModel):
|
||||||
|
type: str
|
||||||
|
text: str
|
||||||
|
url: str | None = None
|
||||||
|
date: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Customer(BaseModel):
|
||||||
|
name: str
|
||||||
|
relationship: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class EnrichmentProvider(Protocol):
|
||||||
|
provider_name: str
|
||||||
|
|
||||||
|
async def get_company_details(self, name: str, website: str | None) -> CompanyDetails: ...
|
||||||
|
|
||||||
|
async def get_funding(self, name: str, website: str | None) -> CompanyFunding: ...
|
||||||
|
|
||||||
|
async def get_updates(self, name: str, website: str | None) -> list[RecentUpdate]: ...
|
||||||
|
|
||||||
|
async def get_competitors(
|
||||||
|
self, name: str, website: str | None
|
||||||
|
) -> list[CompetitorWithReason]: ...
|
||||||
|
|
||||||
|
async def get_products(self, name: str, website: str | None) -> list[Product]: ...
|
||||||
|
|
||||||
|
async def get_customers(self, name: str, website: str | None) -> list[Customer]: ...
|
||||||
|
|
||||||
|
async def get_work_email(self, person_name: str, company_website: str) -> str | None: ...
|
||||||
|
|
||||||
|
async def get_person_profile(
|
||||||
|
self, person_name: str, company_website: str
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
"""Returns (profile_url, bio)."""
|
||||||
|
...
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Resolves NINJAPEAR_API_KEY to a concrete enrichment provider instance.
|
||||||
|
Never imported directly by enrichment_service - always go through
|
||||||
|
`get_enrichment_provider()` so a future second vendor stays a one-line
|
||||||
|
config change, matching app/search/factory.py's shape."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.core.config import Settings, get_settings
|
||||||
|
from app.enrichment.base import EnrichmentProvider
|
||||||
|
from app.enrichment.mock import MockEnrichmentProvider
|
||||||
|
|
||||||
|
|
||||||
|
def get_enrichment_provider(settings: Settings | None = None) -> EnrichmentProvider:
|
||||||
|
settings = settings or get_settings()
|
||||||
|
|
||||||
|
if settings.ninjapear_api_key:
|
||||||
|
from app.enrichment.ninjapear import NinjaPearProvider
|
||||||
|
|
||||||
|
return NinjaPearProvider(settings)
|
||||||
|
|
||||||
|
return MockEnrichmentProvider()
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Deterministic mock enrichment provider - the default (no
|
||||||
|
`NINJAPEAR_API_KEY` configured) and what every automated test runs against.
|
||||||
|
Never calls a network, never fabricates data it has no basis for: every
|
||||||
|
field comes back empty/`None`, same honesty philosophy as
|
||||||
|
`app/search/mock.py` and `app/collectors/patents.py`'s no-key path. In
|
||||||
|
practice the enrichment task is never even enqueued without a real key
|
||||||
|
(see `company_service.create_company`), so this mostly exists for tests
|
||||||
|
and for direct calls to `enrichment_service.enrich_company`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.enrichment.base import (
|
||||||
|
CompanyDetails,
|
||||||
|
CompanyFunding,
|
||||||
|
CompetitorWithReason,
|
||||||
|
Customer,
|
||||||
|
Product,
|
||||||
|
RecentUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class MockEnrichmentProvider:
|
||||||
|
provider_name = "mock"
|
||||||
|
|
||||||
|
async def get_company_details(self, name: str, website: str | None) -> CompanyDetails:
|
||||||
|
return CompanyDetails()
|
||||||
|
|
||||||
|
async def get_funding(self, name: str, website: str | None) -> CompanyFunding:
|
||||||
|
return CompanyFunding()
|
||||||
|
|
||||||
|
async def get_updates(self, name: str, website: str | None) -> list[RecentUpdate]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_competitors(self, name: str, website: str | None) -> list[CompetitorWithReason]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_products(self, name: str, website: str | None) -> list[Product]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_customers(self, name: str, website: str | None) -> list[Customer]:
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def get_work_email(self, person_name: str, company_website: str) -> str | None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def get_person_profile(
|
||||||
|
self, person_name: str, company_website: str
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
return None, None
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""NinjaPear (nubela.co) company-enrichment provider. A fixed, trusted,
|
||||||
|
first-party integration endpoint (like Brave/Twilio) - calls httpx
|
||||||
|
directly rather than through `safe_fetch`, which exists specifically to
|
||||||
|
guard arbitrary/user-supplied collector targets, not our own known-safe
|
||||||
|
API integrations (see app/search/brave.py for the same reasoning).
|
||||||
|
|
||||||
|
Endpoint paths, parameters, and response field names below are taken from
|
||||||
|
nubela.co/llms-full.txt (a plain-text API reference, unlike the JS-rendered
|
||||||
|
docs site) and verified live against a real account. Every company-level
|
||||||
|
endpoint identifies the company by `website` only (NinjaPear has no
|
||||||
|
name-based lookup for these calls) - a company with no `official_website`
|
||||||
|
on file cannot be enriched at all, see `_require_website`. Response
|
||||||
|
parsing stays defensive (`.get()` throughout) since a live vendor API can
|
||||||
|
still change shape without notice.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.core.config import Settings
|
||||||
|
from app.enrichment.base import (
|
||||||
|
CompanyDetails,
|
||||||
|
CompanyFunding,
|
||||||
|
CompetitorWithReason,
|
||||||
|
Customer,
|
||||||
|
FundingRound,
|
||||||
|
LeadershipMember,
|
||||||
|
Product,
|
||||||
|
RecentUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
_BASE_URL = "https://nubela.co/api/v1"
|
||||||
|
_DEFAULT_TIMEOUT = 100
|
||||||
|
_FUNDING_TIMEOUT = 300 # documented by NinjaPear as long-running (up to 5 min)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_website(website: str | None) -> str:
|
||||||
|
if not website:
|
||||||
|
raise ValueError(
|
||||||
|
"NinjaPear identifies a company by website only - this company has none on file"
|
||||||
|
)
|
||||||
|
return website
|
||||||
|
|
||||||
|
|
||||||
|
def _domain_from_website(website: str) -> str:
|
||||||
|
domain = website.split("//", 1)[-1].split("/", 1)[0]
|
||||||
|
return domain[4:] if domain.startswith("www.") else domain
|
||||||
|
|
||||||
|
|
||||||
|
def _split_name(person_name: str) -> tuple[str, str | None]:
|
||||||
|
parts = person_name.split(maxsplit=1)
|
||||||
|
return (parts[0], parts[1] if len(parts) > 1 else None)
|
||||||
|
|
||||||
|
|
||||||
|
class NinjaPearProvider:
|
||||||
|
provider_name = "ninjapear"
|
||||||
|
|
||||||
|
def __init__(self, settings: Settings) -> None:
|
||||||
|
self._api_key = settings.ninjapear_api_key
|
||||||
|
|
||||||
|
def _headers(self) -> dict[str, str]:
|
||||||
|
return {"Authorization": f"Bearer {self._api_key}"}
|
||||||
|
|
||||||
|
async def _get(self, url: str, params: dict[str, str], *, timeout: float) -> dict:
|
||||||
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||||
|
response = await client.get(url, params=params, headers=self._headers())
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
async def get_company_details(self, name: str, website: str | None) -> CompanyDetails:
|
||||||
|
data = await self._get(
|
||||||
|
f"{_BASE_URL}/company/details",
|
||||||
|
{"website": _require_website(website)},
|
||||||
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
leadership = [
|
||||||
|
LeadershipMember(name=exec_["name"], title=exec_.get("title") or exec_.get("role"))
|
||||||
|
for exec_ in data.get("executives") or []
|
||||||
|
if exec_.get("name")
|
||||||
|
]
|
||||||
|
# employee_count comes back as a raw int (e.g. 9030) and industry as
|
||||||
|
# a numeric taxonomy code, not human-readable strings - stringified
|
||||||
|
# defensively rather than left as-is, since our internal shape
|
||||||
|
# types both as `str | None`.
|
||||||
|
employee_count = data.get("employee_count")
|
||||||
|
industry = data.get("industry")
|
||||||
|
return CompanyDetails(
|
||||||
|
description=data.get("description"),
|
||||||
|
industry=str(industry) if industry is not None else None,
|
||||||
|
founded_year=data.get("founded_year"),
|
||||||
|
specialties=data.get("specialties") or [],
|
||||||
|
leadership_team=leadership,
|
||||||
|
employee_count_range=str(employee_count) if employee_count is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_funding(self, name: str, website: str | None) -> CompanyFunding:
|
||||||
|
data = await self._get(
|
||||||
|
f"{_BASE_URL}/company/funding",
|
||||||
|
{"website": _require_website(website)},
|
||||||
|
timeout=_FUNDING_TIMEOUT,
|
||||||
|
)
|
||||||
|
# amount/amount_usd/total_funds_raised are raw numbers, and each
|
||||||
|
# investor is an object (name/type/website/...), not a plain string
|
||||||
|
# - stringified/extracted defensively, same reasoning as
|
||||||
|
# get_company_details's employee_count/industry coercion.
|
||||||
|
rounds = []
|
||||||
|
for r in data.get("funding_rounds") or []:
|
||||||
|
amount = r.get("amount_usd") or r.get("amount")
|
||||||
|
investors = [inv.get("name") for inv in (r.get("investors") or []) if inv.get("name")]
|
||||||
|
rounds.append(
|
||||||
|
FundingRound(
|
||||||
|
round_name=r.get("round_type"),
|
||||||
|
amount=str(amount) if amount is not None else None,
|
||||||
|
date=r.get("date"),
|
||||||
|
investors=investors,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
total_raised = data.get("total_funds_raised_usd") or data.get("total_funds_raised")
|
||||||
|
return CompanyFunding(
|
||||||
|
total_raised=str(total_raised) if total_raised is not None else None, rounds=rounds
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_updates(self, name: str, website: str | None) -> list[RecentUpdate]:
|
||||||
|
data = await self._get(
|
||||||
|
f"{_BASE_URL}/company/updates",
|
||||||
|
{"website": _require_website(website)},
|
||||||
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
RecentUpdate(
|
||||||
|
type=u.get("source", "update"),
|
||||||
|
text=u.get("title") or u.get("description") or "",
|
||||||
|
url=u.get("url"),
|
||||||
|
date=u.get("timestamp"),
|
||||||
|
)
|
||||||
|
for u in data.get("updates") or []
|
||||||
|
if u.get("title") or u.get("description")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_competitors(self, name: str, website: str | None) -> list[CompetitorWithReason]:
|
||||||
|
data = await self._get(
|
||||||
|
f"{_BASE_URL}/competitor/listing",
|
||||||
|
{"website": _require_website(website)},
|
||||||
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
return [
|
||||||
|
CompetitorWithReason(
|
||||||
|
name=c.get("name") or c.get("website", ""), reason=c.get("competition_reason")
|
||||||
|
)
|
||||||
|
for c in data.get("competitors") or []
|
||||||
|
if c.get("website") or c.get("name")
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_products(self, name: str, website: str | None) -> list[Product]:
|
||||||
|
data = await self._get(
|
||||||
|
f"{_BASE_URL}/product/listing",
|
||||||
|
{"website": _require_website(website)},
|
||||||
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
products = []
|
||||||
|
for p in data.get("products") or []:
|
||||||
|
if not p.get("name"):
|
||||||
|
continue
|
||||||
|
categories = p.get("categories") or []
|
||||||
|
products.append(
|
||||||
|
Product(
|
||||||
|
name=p["name"],
|
||||||
|
description=p.get("description"),
|
||||||
|
category=", ".join(categories) if categories else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return products
|
||||||
|
|
||||||
|
async def get_customers(self, name: str, website: str | None) -> list[Customer]:
|
||||||
|
data = await self._get(
|
||||||
|
f"{_BASE_URL}/customer/listing",
|
||||||
|
{"website": _require_website(website)},
|
||||||
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
# NinjaPear returns three separately-categorized arrays rather than
|
||||||
|
# one flat list - merged here with a relationship tag per group.
|
||||||
|
customers = []
|
||||||
|
for relationship, key in (
|
||||||
|
("customer", "customers"),
|
||||||
|
("investor", "investors"),
|
||||||
|
("partner", "partner_platforms"),
|
||||||
|
):
|
||||||
|
for entry in data.get(key) or []:
|
||||||
|
if entry.get("name"):
|
||||||
|
customers.append(Customer(name=entry["name"], relationship=relationship))
|
||||||
|
return customers
|
||||||
|
|
||||||
|
async def get_work_email(self, person_name: str, company_website: str) -> str | None:
|
||||||
|
first_name, last_name = _split_name(person_name)
|
||||||
|
params = {"first_name": first_name, "domain": _domain_from_website(company_website)}
|
||||||
|
if last_name:
|
||||||
|
params["last_name"] = last_name
|
||||||
|
data = await self._get(f"{_BASE_URL}/employee/work-email", params, timeout=_DEFAULT_TIMEOUT)
|
||||||
|
return data.get("work_email")
|
||||||
|
|
||||||
|
async def get_person_profile(
|
||||||
|
self, person_name: str, company_website: str
|
||||||
|
) -> tuple[str | None, str | None]:
|
||||||
|
first_name, _ = _split_name(person_name)
|
||||||
|
# v2 endpoint per NinjaPear's docs - the only one of the endpoints
|
||||||
|
# used here that isn't under /api/v1.
|
||||||
|
data = await self._get(
|
||||||
|
"https://nubela.co/api/v2/employee/profile",
|
||||||
|
{"first_name": first_name, "employer_website": company_website},
|
||||||
|
timeout=_DEFAULT_TIMEOUT,
|
||||||
|
)
|
||||||
|
return data.get("x_profile_url"), data.get("bio")
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""FastAPI application entrypoint."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request, status
|
||||||
|
from fastapi.encoders import jsonable_encoder
|
||||||
|
from fastapi.exceptions import RequestValidationError
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse, Response
|
||||||
|
from slowapi import _rate_limit_exceeded_handler
|
||||||
|
from slowapi.errors import RateLimitExceeded
|
||||||
|
from slowapi.middleware import SlowAPIMiddleware
|
||||||
|
from structlog.contextvars import bound_contextvars
|
||||||
|
|
||||||
|
from app.api.v1.router import api_v1_router
|
||||||
|
from app.core.config import get_settings
|
||||||
|
from app.core.errors import (
|
||||||
|
AppError,
|
||||||
|
AuthenticationError,
|
||||||
|
ConflictError,
|
||||||
|
ForbiddenError,
|
||||||
|
NotFoundError,
|
||||||
|
RateLimitedError,
|
||||||
|
ThrottledError,
|
||||||
|
ValidationAppError,
|
||||||
|
)
|
||||||
|
from app.core.logging import configure_logging, get_logger
|
||||||
|
from app.core.rate_limit import limiter
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
configure_logging(settings)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(_app: FastAPI):
|
||||||
|
logger.info(
|
||||||
|
"startup",
|
||||||
|
app_env=settings.app_env,
|
||||||
|
auth_mode=settings.auth_mode,
|
||||||
|
llm_provider=settings.llm_provider,
|
||||||
|
)
|
||||||
|
yield
|
||||||
|
logger.info("shutdown")
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.app_name,
|
||||||
|
version="0.1.0",
|
||||||
|
lifespan=lifespan,
|
||||||
|
docs_url="/docs",
|
||||||
|
redoc_url="/redoc",
|
||||||
|
openapi_url="/openapi.json",
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.limiter = limiter
|
||||||
|
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||||
|
app.add_middleware(SlowAPIMiddleware)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
# FRONTEND_URL may be a comma-separated list (e.g. localhost plus a LAN
|
||||||
|
# address) so the same API can serve a browser on this machine and one
|
||||||
|
# elsewhere on the network at once.
|
||||||
|
allow_origins=[origin.strip() for origin in settings.frontend_url.split(",") if origin.strip()],
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.middleware("http")
|
||||||
|
async def correlation_id_middleware(request: Request, call_next) -> Response:
|
||||||
|
"""Every log line emitted while handling this request carries the same
|
||||||
|
request_id (structlog contextvars, merged in automatically - see
|
||||||
|
core/logging.py), and the id is echoed back so a client/proxy log can be
|
||||||
|
cross-referenced with ours. Reuses an inbound X-Request-ID if a gateway
|
||||||
|
already set one, rather than always minting a fresh id."""
|
||||||
|
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||||
|
with bound_contextvars(request_id=request_id):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = request_id
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
_APP_ERROR_STATUS_CODES: dict[type[AppError], int] = {
|
||||||
|
NotFoundError: status.HTTP_404_NOT_FOUND,
|
||||||
|
ConflictError: status.HTTP_409_CONFLICT,
|
||||||
|
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
|
||||||
|
ForbiddenError: status.HTTP_403_FORBIDDEN,
|
||||||
|
ValidationAppError: status.HTTP_400_BAD_REQUEST,
|
||||||
|
RateLimitedError: status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
ThrottledError: status.HTTP_429_TOO_MANY_REQUESTS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||||
|
status_code = _APP_ERROR_STATUS_CODES.get(type(exc), status.HTTP_400_BAD_REQUEST)
|
||||||
|
content: dict = {"detail": str(exc)}
|
||||||
|
headers: dict[str, str] = {}
|
||||||
|
if isinstance(exc, ThrottledError) and exc.retry_after_seconds is not None:
|
||||||
|
content["retry_after_seconds"] = exc.retry_after_seconds
|
||||||
|
headers["Retry-After"] = str(exc.retry_after_seconds)
|
||||||
|
return JSONResponse(status_code=status_code, content=content, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(RequestValidationError)
|
||||||
|
async def validation_exception_handler(
|
||||||
|
_request: Request, exc: RequestValidationError
|
||||||
|
) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
content={"detail": "Invalid request", "errors": jsonable_encoder(exc.errors())},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
logger.error("unhandled_exception", error=str(exc), error_type=type(exc).__name__)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
content={"detail": "Internal server error"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health", include_in_schema=False)
|
||||||
|
async def root_health() -> dict[str, str]:
|
||||||
|
"""Plain root-level liveness probe for infra tooling (Docker, etc.)."""
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(api_v1_router)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""SQLAlchemy ORM models.
|
||||||
|
|
||||||
|
Every model module is imported here so that `Base.metadata` (used by Alembic
|
||||||
|
autogenerate) sees the full schema. Add new model modules to this list as
|
||||||
|
they're created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.alert import Alert # noqa: F401
|
||||||
|
from app.models.company import Company, CompanyAlias, Competitor # noqa: F401
|
||||||
|
from app.models.company_enrichment import CompanyEnrichment # noqa: F401
|
||||||
|
from app.models.detected_change import DetectedChange # noqa: F401
|
||||||
|
from app.models.email_code import EmailCode # noqa: F401
|
||||||
|
from app.models.ip_ban import IpBan # noqa: F401
|
||||||
|
from app.models.ip_throttle_state import IpThrottleState # noqa: F401
|
||||||
|
from app.models.monitor_configuration import MonitorConfiguration # noqa: F401
|
||||||
|
from app.models.monitoring_run import MonitoringRun # noqa: F401
|
||||||
|
from app.models.notification_delivery import NotificationDelivery # noqa: F401
|
||||||
|
from app.models.notification_destination import ( # noqa: F401
|
||||||
|
NotificationDestination,
|
||||||
|
NotificationDestinationCompany,
|
||||||
|
)
|
||||||
|
from app.models.password_history import PasswordHistoryEntry # noqa: F401
|
||||||
|
from app.models.refresh_token import RefreshToken # noqa: F401
|
||||||
|
from app.models.report import Report # noqa: F401
|
||||||
|
from app.models.snapshot import Snapshot # noqa: F401
|
||||||
|
from app.models.source import Source # noqa: F401
|
||||||
|
from app.models.source_document import SourceDocument # noqa: F401
|
||||||
|
from app.models.system_secret import SystemSecret # noqa: F401
|
||||||
|
from app.models.unban_request import UnbanRequest # noqa: F401
|
||||||
|
from app.models.user import User # noqa: F401
|
||||||
|
from app.models.user_api_key import UserApiKey # noqa: F401
|
||||||
|
from app.models.user_known_ip import UserKnownIp # noqa: F401
|
||||||
|
from app.models.user_security_event import UserSecurityEvent # noqa: F401
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Enum, Float, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import SeverityLevel
|
||||||
|
|
||||||
|
|
||||||
|
class Alert(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "alerts"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
detected_change_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("detected_changes.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(200))
|
||||||
|
summary: Mapped[str] = mapped_column(Text)
|
||||||
|
why_it_matters: Mapped[str] = mapped_column(Text)
|
||||||
|
severity: Mapped[SeverityLevel] = mapped_column(
|
||||||
|
Enum(SeverityLevel, native_enum=False, length=20)
|
||||||
|
)
|
||||||
|
confidence: Mapped[float] = mapped_column(Float)
|
||||||
|
read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
resolved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Enum, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import CompanyStatus
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company_enrichment import CompanyEnrichment
|
||||||
|
from app.models.monitor_configuration import MonitorConfiguration
|
||||||
|
from app.models.notification_destination import NotificationDestinationCompany
|
||||||
|
|
||||||
|
|
||||||
|
class Company(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "companies"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
slug: Mapped[str] = mapped_column(String(220), index=True)
|
||||||
|
official_website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
monitoring_focus: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
industry: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
country: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
region: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
headquarters: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
# Best-effort discovered identifiers, e.g. {"ticker": "ACME", "linkedin_url": "..."}
|
||||||
|
# - see app/prompts/company_profile.py. Empty dict, never fabricated.
|
||||||
|
public_identifiers: Mapped[dict[str, str]] = mapped_column(JSON, default=dict)
|
||||||
|
status: Mapped[CompanyStatus] = mapped_column(
|
||||||
|
Enum(CompanyStatus, native_enum=False, length=20), default=CompanyStatus.ACTIVE
|
||||||
|
)
|
||||||
|
|
||||||
|
aliases: Mapped[list[CompanyAlias]] = relationship(
|
||||||
|
back_populates="company", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
competitors: Mapped[list[Competitor]] = relationship(
|
||||||
|
back_populates="company", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
monitor_configuration: Mapped[MonitorConfiguration | None] = relationship(
|
||||||
|
back_populates="company", cascade="all, delete-orphan", uselist=False
|
||||||
|
)
|
||||||
|
enrichment: Mapped[CompanyEnrichment | None] = relationship(
|
||||||
|
back_populates="company", cascade="all, delete-orphan", uselist=False
|
||||||
|
)
|
||||||
|
notification_links: Mapped[list[NotificationDestinationCompany]] = relationship(
|
||||||
|
cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyAlias(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "company_aliases"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
alias: Mapped[str] = mapped_column(String(200))
|
||||||
|
|
||||||
|
company: Mapped[Company] = relationship(back_populates="aliases")
|
||||||
|
|
||||||
|
|
||||||
|
class Competitor(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "competitors"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
|
||||||
|
company: Mapped[Company] = relationship(back_populates="competitors")
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, Enum, ForeignKey, Integer
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import EnrichmentStatus
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyEnrichment(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
"""One-shot, onboarding-time company enrichment from a paid third-party
|
||||||
|
provider (NinjaPear/nubela.co) - never re-fetched on a schedule, see
|
||||||
|
app/services/enrichment_service.py. `data` holds a documented (not
|
||||||
|
DB-enforced) shape: employee_count, description, specialties,
|
||||||
|
leadership_team (each optionally carrying work_email/profile_url/bio),
|
||||||
|
funding (total_raised + rounds), competitors (name+reason), products,
|
||||||
|
recent_updates, customers. `errors` maps section name -> error message
|
||||||
|
for whichever calls failed, so a partial result is never silently
|
||||||
|
presented as complete - same transparency principle as every other
|
||||||
|
source/collector in this app."""
|
||||||
|
|
||||||
|
__tablename__ = "company_enrichments"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
|
status: Mapped[EnrichmentStatus] = mapped_column(
|
||||||
|
Enum(EnrichmentStatus, native_enum=False, length=20), default=EnrichmentStatus.PENDING
|
||||||
|
)
|
||||||
|
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
errors: Mapped[dict[str, str]] = mapped_column(JSON, default=dict)
|
||||||
|
credits_spent: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
company: Mapped[Company] = relationship(back_populates="enrichment")
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Enum, Float, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import ChangeStatus, ChangeType, SeverityLevel
|
||||||
|
|
||||||
|
|
||||||
|
class DetectedChange(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "detected_changes"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
monitoring_run_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("monitoring_runs.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
previous_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("snapshots.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
current_snapshot_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("snapshots.id", ondelete="CASCADE")
|
||||||
|
)
|
||||||
|
change_type: Mapped[ChangeType] = mapped_column(Enum(ChangeType, native_enum=False, length=30))
|
||||||
|
raw_diff: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
significance_score: Mapped[float] = mapped_column(Float)
|
||||||
|
confidence_score: Mapped[float] = mapped_column(Float)
|
||||||
|
severity: Mapped[SeverityLevel] = mapped_column(
|
||||||
|
Enum(SeverityLevel, native_enum=False, length=20)
|
||||||
|
)
|
||||||
|
status: Mapped[ChangeStatus] = mapped_column(
|
||||||
|
Enum(ChangeStatus, native_enum=False, length=20), default=ChangeStatus.NEW
|
||||||
|
)
|
||||||
|
# Short human-readable label, e.g. "3 new job postings detected" -
|
||||||
|
# populated deterministically here; Phase 7's LLM may later add a
|
||||||
|
# richer "why it matters" narrative on top without replacing this.
|
||||||
|
summary: Mapped[str] = mapped_column(String(500))
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""Email verification / password-reset codes.
|
||||||
|
|
||||||
|
Only a SHA-256 hash of the 6-digit code is stored, never the raw value -
|
||||||
|
same "never store the raw secret" precedent as RefreshToken.token_hash. A
|
||||||
|
short numeric code doesn't need Argon2's cost; it needs short expiry plus
|
||||||
|
the IP throttle system (app/services/ip_throttle_service.py) guarding how
|
||||||
|
often it can be guessed or resent.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import EmailCodePurpose
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCode(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "email_codes"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
purpose: Mapped[EmailCodePurpose] = mapped_column(
|
||||||
|
Enum(EmailCodePurpose, native_enum=False, length=20)
|
||||||
|
)
|
||||||
|
code_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""Shared string enums for ORM models. Mirrored in apps/web/lib/types.ts and
|
||||||
|
packages/shared/src/index.ts - keep those in sync by hand when changing this."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
|
||||||
|
class CompanyStatus(StrEnum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
PAUSED = "paused"
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringFrequency(StrEnum):
|
||||||
|
HOURLY = "hourly"
|
||||||
|
EVERY_6_HOURS = "every_6_hours"
|
||||||
|
EVERY_12_HOURS = "every_12_hours"
|
||||||
|
DAILY = "daily"
|
||||||
|
EVERY_2_DAYS = "every_2_days"
|
||||||
|
WEEKLY = "weekly"
|
||||||
|
EVERY_2_WEEKS = "every_2_weeks"
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
CUSTOM = "custom"
|
||||||
|
|
||||||
|
|
||||||
|
# Minimum minutes represented by each non-custom frequency, used both to
|
||||||
|
# compute next_run and to enforce MINIMUM_MONITORING_INTERVAL_MINUTES.
|
||||||
|
FREQUENCY_MINUTES: dict[MonitoringFrequency, int] = {
|
||||||
|
MonitoringFrequency.HOURLY: 60,
|
||||||
|
MonitoringFrequency.EVERY_6_HOURS: 6 * 60,
|
||||||
|
MonitoringFrequency.EVERY_12_HOURS: 12 * 60,
|
||||||
|
MonitoringFrequency.DAILY: 24 * 60,
|
||||||
|
MonitoringFrequency.EVERY_2_DAYS: 2 * 24 * 60,
|
||||||
|
MonitoringFrequency.WEEKLY: 7 * 24 * 60,
|
||||||
|
MonitoringFrequency.EVERY_2_WEEKS: 14 * 24 * 60,
|
||||||
|
MonitoringFrequency.MONTHLY: 30 * 24 * 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SeverityLevel(StrEnum):
|
||||||
|
CRITICAL = "critical"
|
||||||
|
HIGH = "high"
|
||||||
|
MEDIUM = "medium"
|
||||||
|
LOW = "low"
|
||||||
|
|
||||||
|
|
||||||
|
# Ordering for threshold comparisons (index 0 = most severe).
|
||||||
|
SEVERITY_ORDER: list[SeverityLevel] = [
|
||||||
|
SeverityLevel.CRITICAL,
|
||||||
|
SeverityLevel.HIGH,
|
||||||
|
SeverityLevel.MEDIUM,
|
||||||
|
SeverityLevel.LOW,
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationType(StrEnum):
|
||||||
|
EMAIL = "email"
|
||||||
|
SMS = "sms"
|
||||||
|
CONSOLE = "console"
|
||||||
|
|
||||||
|
|
||||||
|
class SourceType(StrEnum):
|
||||||
|
WEBSITE = "website"
|
||||||
|
RSS = "rss"
|
||||||
|
CUSTOM_URL = "custom_url"
|
||||||
|
SEC_EDGAR = "sec_edgar"
|
||||||
|
GITHUB = "github"
|
||||||
|
JOB_POSTING = "job_posting"
|
||||||
|
PATENT = "patent"
|
||||||
|
REVIEW = "review"
|
||||||
|
GOV_CONTRACT = "gov_contract"
|
||||||
|
|
||||||
|
|
||||||
|
class SourceStatus(StrEnum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
DISABLED = "disabled"
|
||||||
|
RATE_LIMITED = "rate_limited"
|
||||||
|
AUTH_REQUIRED = "auth_required"
|
||||||
|
BLOCKED_BY_POLICY = "blocked_by_policy"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringRunTrigger(StrEnum):
|
||||||
|
SCHEDULED = "scheduled"
|
||||||
|
MANUAL = "manual"
|
||||||
|
INITIAL = "initial"
|
||||||
|
RETRY = "retry"
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringRunStatus(StrEnum):
|
||||||
|
QUEUED = "queued"
|
||||||
|
RUNNING = "running"
|
||||||
|
SUCCESSFUL = "successful"
|
||||||
|
PARTIAL = "partial"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeType(StrEnum):
|
||||||
|
NEW_DOCUMENT = "new_document"
|
||||||
|
REMOVED_DOCUMENT = "removed_document"
|
||||||
|
CONTENT_MODIFIED = "content_modified"
|
||||||
|
PRICE_CHANGE = "price_change"
|
||||||
|
LEADERSHIP_CHANGE = "leadership_change"
|
||||||
|
FILING_NEW = "filing_new"
|
||||||
|
|
||||||
|
|
||||||
|
class ChangeStatus(StrEnum):
|
||||||
|
NEW = "new"
|
||||||
|
ACKNOWLEDGED = "acknowledged"
|
||||||
|
DISMISSED = "dismissed"
|
||||||
|
|
||||||
|
|
||||||
|
class ReportType(StrEnum):
|
||||||
|
BASELINE = "baseline"
|
||||||
|
UPDATE = "update"
|
||||||
|
MONTHLY = "monthly"
|
||||||
|
MANUAL = "manual"
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationDeliveryStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
SENT = "sent"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class EnrichmentStatus(StrEnum):
|
||||||
|
PENDING = "pending"
|
||||||
|
PARTIAL = "partial"
|
||||||
|
COMPLETE = "complete"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class EmailCodePurpose(StrEnum):
|
||||||
|
VERIFY_EMAIL = "verify_email"
|
||||||
|
PASSWORD_RESET = "password_reset"
|
||||||
|
|
||||||
|
|
||||||
|
class ThrottleAction(StrEnum):
|
||||||
|
RESEND_VERIFICATION = "resend_verification"
|
||||||
|
RESEND_RESET = "resend_reset"
|
||||||
|
FAILED_LOGIN = "failed_login"
|
||||||
|
VERIFY_EMAIL_CODE = "verify_email_code"
|
||||||
|
CONFIRM_RESET_CODE = "confirm_reset_code"
|
||||||
|
|
||||||
|
|
||||||
|
class SecurityEventType(StrEnum):
|
||||||
|
LOGIN_SUCCESS = "login_success"
|
||||||
|
LOGIN_FAILED = "login_failed"
|
||||||
|
ACCOUNT_LOCKED = "account_locked"
|
||||||
|
PASSWORD_RESET_REQUESTED = "password_reset_requested"
|
||||||
|
PASSWORD_RESET_COMPLETED = "password_reset_completed"
|
||||||
|
EMAIL_VERIFICATION_SENT = "email_verification_sent"
|
||||||
|
EMAIL_VERIFIED = "email_verified"
|
||||||
|
SERVER_SECRET_UPDATED = "server_secret_updated"
|
||||||
|
API_KEY_UPDATED = "api_key_updated"
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyProvider(StrEnum):
|
||||||
|
"""Third-party providers a user can supply their own key for - see
|
||||||
|
app/services/user_api_key_service.py's PROVIDER_META for the matching
|
||||||
|
Settings field, display label, and credits/notes shown in Settings."""
|
||||||
|
|
||||||
|
ANTHROPIC = "anthropic"
|
||||||
|
BRAVE_SEARCH = "brave_search"
|
||||||
|
NINJAPEAR = "ninjapear"
|
||||||
|
USPTO = "uspto"
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSecretKey(StrEnum):
|
||||||
|
"""Server-wide (not per-user) secrets an admin can configure from the
|
||||||
|
Settings page instead of only via .env - see
|
||||||
|
app/services/system_secret_service.py's META for the matching Settings
|
||||||
|
field and display label."""
|
||||||
|
|
||||||
|
TURNSTILE_SITE_KEY = "turnstile_site_key"
|
||||||
|
TURNSTILE_SECRET = "turnstile_secret"
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
"""Global per-IP bans. Deliberately separate from IpThrottleState - once any
|
||||||
|
action type escalates an IP to permanent, that IP is blocked from every
|
||||||
|
sensitive endpoint (register/login/resend/reset), not just the one action
|
||||||
|
that triggered it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class IpBan(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "ip_bans"
|
||||||
|
|
||||||
|
ip_address: Mapped[str] = mapped_column(String(45), unique=True, index=True)
|
||||||
|
banned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
reason: Mapped[str] = mapped_column(String(255))
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
"""Per-IP, per-action escalation state for the throttle/ban engine
|
||||||
|
(app/services/ip_throttle_service.py). `offense_count` is the "memory" that
|
||||||
|
survives a completed timeout cycle - only a manual admin unban resets it."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, Integer, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import ThrottleAction
|
||||||
|
|
||||||
|
|
||||||
|
class IpThrottleState(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "ip_throttle_state"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("ip_address", "action", name="uq_ip_throttle_state_ip_action"),
|
||||||
|
)
|
||||||
|
|
||||||
|
ip_address: Mapped[str] = mapped_column(String(45), index=True)
|
||||||
|
action: Mapped[ThrottleAction] = mapped_column(
|
||||||
|
Enum(ThrottleAction, native_enum=False, length=24)
|
||||||
|
)
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
next_allowed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
timeout_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
offense_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import MonitoringFrequency, SeverityLevel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorConfiguration(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "monitor_configurations"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), unique=True, index=True
|
||||||
|
)
|
||||||
|
frequency_type: Mapped[MonitoringFrequency] = mapped_column(
|
||||||
|
Enum(MonitoringFrequency, native_enum=False, length=30),
|
||||||
|
default=MonitoringFrequency.WEEKLY,
|
||||||
|
)
|
||||||
|
# Only meaningful when frequency_type == CUSTOM: interval_minutes takes
|
||||||
|
# precedence if set, otherwise cron_expression is used.
|
||||||
|
interval_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
cron_expression: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="America/New_York")
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
next_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
severity_threshold: Mapped[SeverityLevel] = mapped_column(
|
||||||
|
Enum(SeverityLevel, native_enum=False, length=20), default=SeverityLevel.MEDIUM
|
||||||
|
)
|
||||||
|
source_configuration: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
|
company: Mapped[Company] = relationship(back_populates="monitor_configuration")
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import MonitoringRunStatus, MonitoringRunTrigger
|
||||||
|
|
||||||
|
|
||||||
|
class MonitoringRun(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "monitoring_runs"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
trigger_type: Mapped[MonitoringRunTrigger] = mapped_column(
|
||||||
|
Enum(MonitoringRunTrigger, native_enum=False, length=20)
|
||||||
|
)
|
||||||
|
status: Mapped[MonitoringRunStatus] = mapped_column(
|
||||||
|
Enum(MonitoringRunStatus, native_enum=False, length=20),
|
||||||
|
default=MonitoringRunStatus.QUEUED,
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
sources_attempted: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
sources_successful: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
sources_failed: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
items_collected: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
changes_detected: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
error_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
worker_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import NotificationDeliveryStatus
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationDelivery(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "notification_deliveries"
|
||||||
|
|
||||||
|
alert_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("alerts.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
destination_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("notification_destinations.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
provider: Mapped[str] = mapped_column(String(50))
|
||||||
|
status: Mapped[NotificationDeliveryStatus] = mapped_column(
|
||||||
|
Enum(NotificationDeliveryStatus, native_enum=False, length=20),
|
||||||
|
default=NotificationDeliveryStatus.PENDING,
|
||||||
|
)
|
||||||
|
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
last_attempt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
external_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Enum, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import NotificationType, SeverityLevel
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.company import Company
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationDestination(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "notification_destinations"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
type: Mapped[NotificationType] = mapped_column(
|
||||||
|
Enum(NotificationType, native_enum=False, length=20)
|
||||||
|
)
|
||||||
|
# Email address, phone number, or a label for the console provider.
|
||||||
|
# Not a secret, but still PII - see SECURITY.md.
|
||||||
|
destination_value: Mapped[str] = mapped_column(String(320))
|
||||||
|
verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
minimum_severity: Mapped[SeverityLevel] = mapped_column(
|
||||||
|
Enum(SeverityLevel, native_enum=False, length=20), default=SeverityLevel.MEDIUM
|
||||||
|
)
|
||||||
|
|
||||||
|
company_links: Mapped[list[NotificationDestinationCompany]] = relationship(
|
||||||
|
back_populates="destination", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationDestinationCompany(Base, TimestampMixin):
|
||||||
|
"""Which companies a destination receives alerts for - a destination
|
||||||
|
with zero links is orphaned and gets garbage-collected (see
|
||||||
|
notification_destination_service.py) rather than left dangling."""
|
||||||
|
|
||||||
|
__tablename__ = "notification_destination_companies"
|
||||||
|
|
||||||
|
destination_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("notification_destinations.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
|
||||||
|
destination: Mapped[NotificationDestination] = relationship(back_populates="company_links")
|
||||||
|
# Read-only path to the company's name for display; Company.notification_links
|
||||||
|
# (used only for cascade-delete) writes the same FK from the other direction,
|
||||||
|
# hence overlaps= to tell SQLAlchemy that's intentional, not a conflict.
|
||||||
|
company: Mapped[Company] = relationship(overlaps="notification_links")
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Every password hash a user has ever had active - checked on password
|
||||||
|
reset so a user can't "reset" back to a password they (or an attacker who
|
||||||
|
learned it) has used before. Never used for anything except that
|
||||||
|
membership check; nothing reads these hashes back out for display."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class PasswordHistoryEntry(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "password_history_entries"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
password_hash: Mapped[str] = mapped_column(String(255))
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Refresh token records.
|
||||||
|
|
||||||
|
Only a hash of the token's `jti` is stored - never the JWT itself - so a
|
||||||
|
database read can't be replayed as a valid refresh token. Rotation on use
|
||||||
|
(one row per issuance, `revoked_at` set when superseded) limits the blast
|
||||||
|
radius of a leaked refresh token to its remaining lifetime.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshToken(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "refresh_tokens"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||||
|
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||||
|
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship(back_populates="refresh_tokens")
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Enum, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import ReportType
|
||||||
|
|
||||||
|
|
||||||
|
class Report(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "reports"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
monitoring_run_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("monitoring_runs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
report_type: Mapped[ReportType] = mapped_column(Enum(ReportType, native_enum=False, length=20))
|
||||||
|
title: Mapped[str] = mapped_column(String(300))
|
||||||
|
executive_summary: Mapped[str] = mapped_column(Text)
|
||||||
|
structured_report: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||||
|
markdown_content: Mapped[str] = mapped_column(Text)
|
||||||
|
model_provider: Mapped[str] = mapped_column(String(50))
|
||||||
|
model_name: Mapped[str] = mapped_column(String(100))
|
||||||
|
prompt_version: Mapped[str] = mapped_column(String(20), default="v1")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, ForeignKey, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Snapshot(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
"""A structured, comparable summary of a source's state at a point in
|
||||||
|
time - what change detection (Phase 6) diffs against the prior snapshot
|
||||||
|
for the same source."""
|
||||||
|
|
||||||
|
__tablename__ = "snapshots"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
snapshot_type: Mapped[str] = mapped_column(String(50))
|
||||||
|
hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
structured_summary: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
text_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
monitoring_run_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("monitoring_runs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, ForeignKey, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import MonitoringFrequency, SourceStatus, SourceType
|
||||||
|
|
||||||
|
|
||||||
|
class Source(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "sources"
|
||||||
|
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
source_type: Mapped[SourceType] = mapped_column(Enum(SourceType, native_enum=False, length=20))
|
||||||
|
name: Mapped[str] = mapped_column(String(200))
|
||||||
|
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
status: Mapped[SourceStatus] = mapped_column(
|
||||||
|
Enum(SourceStatus, native_enum=False, length=20), default=SourceStatus.ACTIVE
|
||||||
|
)
|
||||||
|
trust_score: Mapped[float] = mapped_column(Float, default=0.7)
|
||||||
|
last_checked: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
last_successful_check: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
failure_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
configuration_metadata: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
|
||||||
|
# Per-source check cadence override - NULL frequency_type means "inherit
|
||||||
|
# the company's default MonitorConfiguration cadence" (the behavior for
|
||||||
|
# every source before this feature existed, and still the default for
|
||||||
|
# any source that never sets an override). See app/tasks/scheduler.py
|
||||||
|
# and app/services/scheduling.py for how these combine into due-ness.
|
||||||
|
frequency_type: Mapped[MonitoringFrequency | None] = mapped_column(
|
||||||
|
Enum(MonitoringFrequency, native_enum=False, length=20), nullable=True
|
||||||
|
)
|
||||||
|
interval_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
cron_expression: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||||
|
next_check: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class SourceDocument(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
"""A single piece of collected content. Kept lean on purpose - raw HTML
|
||||||
|
is not stored, only extracted text - per the "avoid saving unnecessary
|
||||||
|
full HTML indefinitely" rule in the spec."""
|
||||||
|
|
||||||
|
__tablename__ = "source_documents"
|
||||||
|
|
||||||
|
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
url: Mapped[str] = mapped_column(String(1000))
|
||||||
|
canonical_url: Mapped[str] = mapped_column(String(1000), index=True)
|
||||||
|
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
author: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||||
|
publication_date: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
retrieved_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
content_text: Mapped[str] = mapped_column(Text)
|
||||||
|
content_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||||
|
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||||
|
language: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
extraction_method: Mapped[str] = mapped_column(String(50))
|
||||||
|
trust_score: Mapped[float] = mapped_column(Float, default=0.7)
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""A server-wide secret (e.g. Turnstile site key/secret), encrypted at rest
|
||||||
|
(app/core/crypto.py). Unlike UserApiKey, this isn't scoped to a user - it's
|
||||||
|
one value shared by the whole app, admin-editable from the Settings page
|
||||||
|
instead of only via .env. When set,
|
||||||
|
app/services/system_secret_service.py's get_effective_settings substitutes
|
||||||
|
it in place of the server's global .env-configured value - see that module
|
||||||
|
for the full fallback logic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Enum, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import SystemSecretKey
|
||||||
|
|
||||||
|
|
||||||
|
class SystemSecret(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "system_secrets"
|
||||||
|
|
||||||
|
key: Mapped[SystemSecretKey] = mapped_column(
|
||||||
|
Enum(SystemSecretKey, native_enum=False, length=32), unique=True
|
||||||
|
)
|
||||||
|
encrypted_value: Mapped[str] = mapped_column(Text)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Manual unban requests from banned visitors - one per IP per 24h, enforced
|
||||||
|
in the service layer at insert time. Purely a queue for admin review; no
|
||||||
|
automated unban happens from this table."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class UnbanRequest(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "unban_requests"
|
||||||
|
|
||||||
|
ip_address: Mapped[str] = mapped_column(String(45), index=True)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""User account model.
|
||||||
|
|
||||||
|
`password_hash` is nullable because `AUTH_MODE=local` provisions a single
|
||||||
|
fixed user with no password at all - that mode never routes through
|
||||||
|
password verification, so there's nothing to hash.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.refresh_token import RefreshToken
|
||||||
|
|
||||||
|
# Fixed, deterministic user id used for AUTH_MODE=local so the same row is
|
||||||
|
# reused across restarts rather than multiplying "local dev user" rows.
|
||||||
|
LOCAL_DEV_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||||
|
LOCAL_DEV_USER_EMAIL = "[email protected]"
|
||||||
|
|
||||||
|
|
||||||
|
class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "users"
|
||||||
|
|
||||||
|
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||||
|
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(120))
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="America/New_York")
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||||
|
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
|
||||||
|
# Phase 19: email verification + escalating failed-login lockout. The
|
||||||
|
# fixed AUTH_MODE=local user is seeded as already-verified (it never
|
||||||
|
# goes through this flow - see auth_service.get_or_create_local_user).
|
||||||
|
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||||
|
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
|
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
|
||||||
|
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
||||||
|
back_populates="user", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""A user's own API key for a given third-party provider, encrypted at
|
||||||
|
rest (app/core/crypto.py). When set, app/services/user_api_key_service.py's
|
||||||
|
get_effective_settings substitutes it in place of the server's global
|
||||||
|
.env-configured key for that user's own requests - see that module for the
|
||||||
|
full fallback logic."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Enum, ForeignKey, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import ApiKeyProvider
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserApiKey(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "user_api_keys"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("user_id", "provider", name="uq_user_api_keys_user_provider"),
|
||||||
|
)
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
provider: Mapped[ApiKeyProvider] = mapped_column(
|
||||||
|
Enum(ApiKeyProvider, native_enum=False, length=16)
|
||||||
|
)
|
||||||
|
encrypted_key: Mapped[str] = mapped_column(Text)
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
"""Every distinct IP an account has ever signed in from - one row per
|
||||||
|
(user, ip) pair, first_seen_at set once and last_seen_at touched on every
|
||||||
|
subsequent sign-in from that same IP. Pure data capture for now (see
|
||||||
|
app/services/auth_service.py's sign-in paths, both real login and the
|
||||||
|
local-dev bypass) - nothing currently reads this table, but it's the
|
||||||
|
foundation a later "new device/location" security feature would query
|
||||||
|
against without needing to scan/dedupe the much larger, append-only
|
||||||
|
user_security_events log."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserKnownIp(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "user_known_ips"
|
||||||
|
__table_args__ = (UniqueConstraint("user_id", "ip_address", name="uq_user_known_ips_user_ip"),)
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
ip_address: Mapped[str] = mapped_column(String(45))
|
||||||
|
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Per-user security activity log - the user-facing counterpart to the
|
||||||
|
admin-only, app-wide Redis log feed (app/core/logging.py). Visible only to
|
||||||
|
the owning user via GET /auth/security-events."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from sqlalchemy import Enum, ForeignKey, String
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||||
|
from app.models.enums import SecurityEventType
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserSecurityEvent(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||||
|
__tablename__ = "user_security_events"
|
||||||
|
|
||||||
|
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||||
|
)
|
||||||
|
event_type: Mapped[SecurityEventType] = mapped_column(
|
||||||
|
Enum(SecurityEventType, native_enum=False, length=32)
|
||||||
|
)
|
||||||
|
ip_address: Mapped[str] = mapped_column(String(45))
|
||||||
|
|
||||||
|
user: Mapped[User] = relationship()
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Notification provider interface. Every alert dispatch (app/services/
|
||||||
|
alert_service.py) and every "test destination" action goes through this
|
||||||
|
Protocol, never a specific vendor SDK - swapping providers or adding a new
|
||||||
|
one doesn't touch the dispatch logic.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class NotificationMessage:
|
||||||
|
destination_value: str
|
||||||
|
subject: str
|
||||||
|
body_text: str
|
||||||
|
body_html: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DeliveryResult:
|
||||||
|
success: bool
|
||||||
|
external_message_id: str | None = None
|
||||||
|
error: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationProvider(Protocol):
|
||||||
|
provider_name: str
|
||||||
|
|
||||||
|
async def send(self, message: NotificationMessage) -> DeliveryResult: ...
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user