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:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
+38
View File
@@ -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
View File
View File
@@ -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")
+33
View File
@@ -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: ...
+31
View File
@@ -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 ""
+327
View File
@@ -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", "")
View File
View File
+91
View File
@@ -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)
+78
View File
@@ -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)
+161
View File
@@ -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)
+155
View File
@@ -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)
+23
View File
@@ -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)
+55
View File
@@ -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)
+80
View File
@@ -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
+35
View File
@@ -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)
+27
View File
@@ -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]
+83
View File
@@ -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
)
+196
View File
@@ -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)]
+46
View File
@@ -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)
View File
+69
View File
@@ -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()
+103
View File
@@ -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],
)
View File
+96
View File
@@ -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."""
...
+87
View File
@@ -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)
+76
View File
@@ -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
+147
View File
@@ -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)
+131
View File
@@ -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)
+156
View File
@@ -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
+245
View File
@@ -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
)
+36
View File
@@ -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())
+85
View File
@@ -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)
+54
View File
@@ -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()
+111
View File
@@ -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)
+167
View File
@@ -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)
+176
View File
@@ -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,
)
View File
+178
View File
@@ -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()
+25
View File
@@ -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
+46
View File
@@ -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
+176
View File
@@ -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
+149
View File
@@ -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
+28
View File
@@ -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")
+171
View File
@@ -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()
+13
View File
@@ -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"
View File
+38
View File
@@ -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
)
+50
View File
@@ -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
View File
+93
View File
@@ -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)."""
...
+21
View File
@@ -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()
+50
View File
@@ -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
+213
View File
@@ -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")
+136
View File
@@ -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)
+35
View File
@@ -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
+32
View File
@@ -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)
+76
View File
@@ -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")
+42
View File
@@ -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")
+44
View File
@@ -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))
+37
View File
@@ -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()
+176
View File
@@ -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"
+21
View File
@@ -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))
+29
View File
@@ -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")
+34
View File
@@ -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")
+28
View File
@@ -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()
+32
View File
@@ -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")
+29
View File
@@ -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")
+31
View File
@@ -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
)
+45
View File
@@ -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)
+40
View File
@@ -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)
+24
View File
@@ -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)
+17
View File
@@ -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)
+47
View File
@@ -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"
)
+36
View File
@@ -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()
+36
View File
@@ -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()
+31
View File
@@ -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: ...
+23
View File
@@ -0,0 +1,23 @@
"""Console provider: logs the notification instead of sending it anywhere.
Used in local dev fallback and wherever a destination type isn't yet wired
to a real transport."""
from __future__ import annotations
from app.core.logging import get_logger
from app.notifications.base import DeliveryResult, NotificationMessage
logger = get_logger(__name__)
class ConsoleProvider:
provider_name = "console"
async def send(self, message: NotificationMessage) -> DeliveryResult:
logger.info(
"console_notification",
destination=message.destination_value,
subject=message.subject,
body=message.body_text,
)
return DeliveryResult(success=True)
+31
View File
@@ -0,0 +1,31 @@
"""Resolves a NotificationType to a concrete provider. Console is always
available and never costs anything; email/SMS are only meaningfully
configured when their settings are present (callers gate on
NOTIFICATION_SMS_ENABLED before ever routing to SMS - see alert_service.py).
SMS has two interchangeable vendor providers (`SMS_PROVIDER=twilio|telnyx`),
same pattern as LLM_PROVIDER/SEARCH_PROVIDER elsewhere in the app - swapping
the setting changes which class this returns and nothing else has to change.
"""
from __future__ import annotations
from app.core.config import Settings
from app.models.enums import NotificationType
from app.notifications.base import NotificationProvider
from app.notifications.console import ConsoleProvider
from app.notifications.smtp_email import SmtpEmailProvider
from app.notifications.telnyx_sms import TelnyxSmsProvider
from app.notifications.twilio_sms import TwilioSmsProvider
def get_notification_provider(
notification_type: NotificationType, settings: Settings
) -> NotificationProvider:
if notification_type == NotificationType.EMAIL:
return SmtpEmailProvider(settings)
if notification_type == NotificationType.SMS:
if settings.sms_provider == "telnyx":
return TelnyxSmsProvider(settings)
return TwilioSmsProvider(settings)
return ConsoleProvider()
@@ -0,0 +1,68 @@
"""Builds the actual email/SMS/console message bodies for an alert. Kept
separate from alert_service.py's dispatch loop so the message format can be
tested in isolation.
"""
from __future__ import annotations
from app.core.config import Settings
from app.models.alert import Alert
from app.models.company import Company
from app.models.enums import NotificationType
from app.notifications.base import NotificationMessage
def build_alert_message(
notification_type: NotificationType,
destination_value: str,
company: Company,
alert: Alert,
settings: Settings,
) -> NotificationMessage:
dashboard_link = f"{settings.frontend_url}/alerts"
if notification_type == NotificationType.SMS:
text = (
f"CI Alert [{alert.severity.value.upper()}]: {alert.title}. "
f"Confidence {alert.confidence:.0%}. View details: {dashboard_link}"
)
return NotificationMessage(
destination_value=destination_value, subject=alert.title, body_text=text[:480]
)
subject = f"[{alert.severity.value.upper()}] {company.name}: {alert.title}"
text_lines = [
f"Company: {company.name}",
f"Alert: {alert.title}",
f"Severity: {alert.severity.value.title()}",
f"Confidence: {alert.confidence:.0%}",
"",
"What changed:",
alert.summary,
"",
"Why it matters:",
alert.why_it_matters,
"",
f"View in dashboard: {dashboard_link}",
f"Manage notification preferences: {settings.frontend_url}/settings",
]
body_text = "\n".join(text_lines)
body_html = (
f"<h2>{subject}</h2>"
f"<p><strong>Company:</strong> {company.name}<br>"
f"<strong>Severity:</strong> {alert.severity.value.title()}<br>"
f"<strong>Confidence:</strong> {alert.confidence:.0%}</p>"
f"<p><strong>What changed:</strong><br>{alert.summary}</p>"
f"<p><strong>Why it matters:</strong><br>{alert.why_it_matters}</p>"
f'<p><a href="{dashboard_link}">View in dashboard</a></p>'
f'<p style="color:#666;font-size:12px">'
f'<a href="{settings.frontend_url}/settings">Manage notification preferences</a></p>'
)
return NotificationMessage(
destination_value=destination_value,
subject=subject,
body_text=body_text,
body_html=body_html,
)
@@ -0,0 +1,50 @@
"""Resend HTTP API email provider - a separate transport from SmtpEmailProvider
(which also happens to point at Resend's SMTP relay for alert notifications
in this deployment, but that's a different concern/sender identity; see
app/services/security_email_service.py). Fixed, trusted first-party vendor
endpoint - no SSRF guard needed, same reasoning as the NinjaPear/USPTO calls
elsewhere in this app.
"""
from __future__ import annotations
import httpx
from app.core.config import Settings
from app.core.logging import get_logger
from app.notifications.base import DeliveryResult, NotificationMessage
logger = get_logger(__name__)
_RESEND_API_URL = "https://api.resend.com/emails"
class ResendEmailProvider:
provider_name = "resend"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def send(self, message: NotificationMessage) -> DeliveryResult:
payload = {
"from": self._settings.resend_security_from_email,
"to": [message.destination_value],
"subject": message.subject,
"text": message.body_text,
}
if message.body_html:
payload["html"] = message.body_html
try:
async with httpx.AsyncClient(timeout=10) as client:
response = await client.post(
_RESEND_API_URL,
headers={"Authorization": f"Bearer {self._settings.resend_api_key}"},
json=payload,
)
response.raise_for_status()
data = response.json()
return DeliveryResult(success=True, external_message_id=data.get("id"))
except Exception as exc: # noqa: BLE001 - network/API failure path
logger.error("resend_send_failed", error=str(exc))
return DeliveryResult(success=False, error=str(exc))
+53
View File
@@ -0,0 +1,53 @@
"""SMTP email provider. Points at Mailpit in local dev (see docker-compose.yml)
and any real SMTP server in production - same code path either way. Uses
stdlib `smtplib` off the event loop via `asyncio.to_thread` rather than
adding an async SMTP dependency.
"""
from __future__ import annotations
import asyncio
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from app.core.config import Settings
from app.core.logging import get_logger
from app.notifications.base import DeliveryResult, NotificationMessage
logger = get_logger(__name__)
class SmtpEmailProvider:
provider_name = "smtp"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def send(self, message: NotificationMessage) -> DeliveryResult:
try:
await asyncio.to_thread(self._send_sync, message)
return DeliveryResult(success=True)
except Exception as exc: # pragma: no cover - network/SMTP failure path
logger.error("smtp_send_failed", error=str(exc))
return DeliveryResult(success=False, error=str(exc))
def _send_sync(self, message: NotificationMessage) -> None:
mime_message = MIMEMultipart("alternative")
mime_message["Subject"] = message.subject
mime_message["From"] = self._settings.smtp_from_email
mime_message["To"] = message.destination_value
mime_message.attach(MIMEText(message.body_text, "plain"))
if message.body_html:
mime_message.attach(MIMEText(message.body_html, "html"))
with smtplib.SMTP(self._settings.smtp_host, self._settings.smtp_port, timeout=10) as server:
if self._settings.smtp_use_tls:
server.starttls()
if self._settings.smtp_username:
server.login(self._settings.smtp_username, self._settings.smtp_password)
server.sendmail(
self._settings.smtp_from_email,
[message.destination_value],
mime_message.as_string(),
)
+50
View File
@@ -0,0 +1,50 @@
"""Telnyx SMS provider via Telnyx's Programmable Messaging REST API (no SDK
dependency - just a Bearer-authenticated POST), mirroring twilio_sms.py's
shape. No-ops with a clear error if Telnyx isn't configured; the caller
(alert_service) is what additionally gates on `NOTIFICATION_SMS_ENABLED`
before ever constructing this provider.
"""
from __future__ import annotations
import httpx
from app.core.config import Settings
from app.notifications.base import DeliveryResult, NotificationMessage
_API_URL = "https://api.telnyx.com/v2/messages"
class TelnyxSmsProvider:
provider_name = "telnyx_sms"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def send(self, message: NotificationMessage) -> DeliveryResult:
settings = self._settings
if not (settings.telnyx_api_key and settings.telnyx_from_number):
return DeliveryResult(success=False, error="Telnyx is not configured")
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(
_API_URL,
headers={"Authorization": f"Bearer {settings.telnyx_api_key}"},
json={
"from": settings.telnyx_from_number,
"to": message.destination_value,
"text": message.body_text,
},
)
except httpx.HTTPError as exc:
return DeliveryResult(success=False, error=str(exc))
data = response.json()
if response.status_code >= 400:
errors = data.get("errors") or []
detail = errors[0].get("detail") if errors else response.text[:200]
return DeliveryResult(
success=False, error=f"Telnyx error {response.status_code}: {detail}"
)
return DeliveryResult(success=True, external_message_id=data.get("data", {}).get("id"))
+52
View File
@@ -0,0 +1,52 @@
"""Twilio SMS provider via Twilio's plain REST API (no SDK dependency -
just an authenticated POST). No-ops with a clear error if Twilio isn't
configured; the caller (alert_service) is what additionally gates on
`NOTIFICATION_SMS_ENABLED` before ever constructing this provider.
"""
from __future__ import annotations
import httpx
from app.core.config import Settings
from app.notifications.base import DeliveryResult, NotificationMessage
_API_BASE = "https://api.twilio.com/2010-04-01"
class TwilioSmsProvider:
provider_name = "twilio_sms"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def send(self, message: NotificationMessage) -> DeliveryResult:
settings = self._settings
if not (
settings.twilio_account_sid
and settings.twilio_auth_token
and settings.twilio_from_number
):
return DeliveryResult(success=False, error="Twilio is not configured")
url = f"{_API_BASE}/Accounts/{settings.twilio_account_sid}/Messages.json"
try:
async with httpx.AsyncClient(timeout=15) as client:
response = await client.post(
url,
auth=(settings.twilio_account_sid, settings.twilio_auth_token),
data={
"From": settings.twilio_from_number,
"To": message.destination_value,
"Body": message.body_text,
},
)
except httpx.HTTPError as exc:
return DeliveryResult(success=False, error=str(exc))
if response.status_code >= 400:
return DeliveryResult(
success=False, error=f"Twilio error {response.status_code}: {response.text[:200]}"
)
data = response.json()
return DeliveryResult(success=True, external_message_id=data.get("sid"))
View File

Some files were not shown because too many files have changed in this diff Show More