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:
@@ -0,0 +1,82 @@
|
||||
"""Anthropic provider: structured output via forced tool-use (the response
|
||||
schema becomes the tool's input_schema, so the model can only "call" it with
|
||||
arguments matching the shape we asked for), with a bounded repair loop for
|
||||
the rare malformed response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from anthropic import AsyncAnthropic
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.analysis.llm.base import LLMResponseError
|
||||
from app.core.config import Settings
|
||||
|
||||
_TOOL_NAME = "emit_result"
|
||||
|
||||
|
||||
class AnthropicLLMProvider:
|
||||
provider_name = "anthropic"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._client = AsyncAnthropic(api_key=settings.anthropic_api_key)
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
tools = [
|
||||
{
|
||||
"name": _TOOL_NAME,
|
||||
"description": f"Emit the result matching the {response_model.__name__} schema.",
|
||||
"input_schema": response_model.model_json_schema(),
|
||||
}
|
||||
]
|
||||
|
||||
last_error: Exception | None = None
|
||||
messages: list[dict] = [{"role": "user", "content": user_prompt}]
|
||||
|
||||
for attempt in range(self._settings.llm_max_retries + 1):
|
||||
if attempt > 0 and last_error is not None:
|
||||
messages = [
|
||||
*messages,
|
||||
{
|
||||
"role": "user",
|
||||
"content": (
|
||||
f"Your previous response did not match the required schema: "
|
||||
f"{last_error}. Please try again."
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
response = await self._client.messages.create(
|
||||
model=self._settings.anthropic_model,
|
||||
max_tokens=self._settings.llm_max_tokens_per_request,
|
||||
system=system_prompt,
|
||||
tools=tools,
|
||||
tool_choice={"type": "tool", "name": _TOOL_NAME},
|
||||
messages=messages,
|
||||
)
|
||||
tool_use = next((b for b in response.content if b.type == "tool_use"), None)
|
||||
if tool_use is None:
|
||||
last_error = ValueError("No tool_use block in the model's response")
|
||||
continue
|
||||
try:
|
||||
return response_model.model_validate(tool_use.input)
|
||||
except ValidationError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
raise LLMResponseError(
|
||||
f"Anthropic provider failed to produce a valid {response_model.__name__} after "
|
||||
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||
)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
response = await self._client.messages.create(
|
||||
model=self._settings.anthropic_model,
|
||||
max_tokens=self._settings.llm_max_tokens_per_request,
|
||||
system=system_prompt,
|
||||
messages=[{"role": "user", "content": user_prompt}],
|
||||
)
|
||||
return "\n".join(block.text for block in response.content if block.type == "text")
|
||||
@@ -0,0 +1,33 @@
|
||||
"""LLM provider interface. Every analysis task (app/prompts/*.py) is written
|
||||
against this Protocol, never against a specific vendor SDK - swapping
|
||||
`LLM_PROVIDER` changes which class `get_llm_provider()` returns and nothing
|
||||
else has to change. `generate_structured` is the primary method: it always
|
||||
returns a validated instance of the caller's Pydantic response model, never
|
||||
raw text, so a malformed model response can never propagate un-typed data
|
||||
into the rest of the app (see `LLMResponseError` / the repair loop in
|
||||
anthropic_provider.py).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class LLMResponseError(Exception):
|
||||
"""Raised when a provider can't produce a response matching the
|
||||
requested schema, even after any repair attempts."""
|
||||
|
||||
|
||||
class LLMProvider(Protocol):
|
||||
provider_name: str
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self,
|
||||
system_prompt: str,
|
||||
user_prompt: str,
|
||||
response_model: type[T],
|
||||
) -> T: ...
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str: ...
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Resolves `LLM_PROVIDER` to a concrete provider instance. Never imported
|
||||
directly by prompt task modules or services - always go through
|
||||
`get_llm_provider()` so swapping providers stays a one-line config change.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.analysis.llm.mock import MockLLMProvider
|
||||
from app.core.config import Settings, get_settings
|
||||
|
||||
|
||||
def get_llm_provider(settings: Settings | None = None) -> LLMProvider:
|
||||
settings = settings or get_settings()
|
||||
|
||||
if settings.llm_provider == "anthropic":
|
||||
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
|
||||
|
||||
return AnthropicLLMProvider(settings)
|
||||
|
||||
if settings.llm_provider == "ollama":
|
||||
from app.analysis.llm.ollama_provider import OllamaLLMProvider
|
||||
|
||||
return OllamaLLMProvider(settings)
|
||||
|
||||
if settings.llm_provider == "gemini":
|
||||
from app.analysis.llm.gemini_provider import GeminiLLMProvider
|
||||
|
||||
return GeminiLLMProvider(settings)
|
||||
|
||||
return MockLLMProvider()
|
||||
@@ -0,0 +1,73 @@
|
||||
"""Gemini provider: structured output via the SDK's native
|
||||
`response_schema` support (the model is constrained to the schema and the
|
||||
SDK parses the result into an instance of it directly), with the same
|
||||
bounded repair loop on a malformed/unparsed response as
|
||||
`anthropic_provider.py`. Chosen as the production LLM_PROVIDER option
|
||||
alongside Anthropic because Gemini has an actual free rate-limited API
|
||||
tier (gemini-2.0-flash), unlike OpenAI's expiring trial credits.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from google import genai
|
||||
from google.genai import types
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.analysis.llm.base import LLMResponseError
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class GeminiLLMProvider:
|
||||
provider_name = "gemini"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
self._client = genai.Client(api_key=settings.gemini_api_key)
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
last_error: Exception | None = None
|
||||
prompt = user_prompt
|
||||
|
||||
for attempt in range(self._settings.llm_max_retries + 1):
|
||||
if attempt > 0 and last_error is not None:
|
||||
prompt = (
|
||||
f"{user_prompt}\n\nYour previous response did not match the required "
|
||||
f"schema: {last_error}. Please try again."
|
||||
)
|
||||
|
||||
response = await self._client.aio.models.generate_content(
|
||||
model=self._settings.gemini_model,
|
||||
contents=prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=system_prompt,
|
||||
response_mime_type="application/json",
|
||||
response_schema=response_model,
|
||||
max_output_tokens=self._settings.llm_max_tokens_per_request,
|
||||
),
|
||||
)
|
||||
if response.parsed is None:
|
||||
last_error = ValueError("Gemini did not return a parsed structured response")
|
||||
continue
|
||||
try:
|
||||
return response_model.model_validate(response.parsed)
|
||||
except ValidationError as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
raise LLMResponseError(
|
||||
f"Gemini provider failed to produce a valid {response_model.__name__} after "
|
||||
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||
)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
response = await self._client.aio.models.generate_content(
|
||||
model=self._settings.gemini_model,
|
||||
contents=user_prompt,
|
||||
config=types.GenerateContentConfig(
|
||||
system_instruction=system_prompt,
|
||||
max_output_tokens=self._settings.llm_max_tokens_per_request,
|
||||
),
|
||||
)
|
||||
return response.text or ""
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Deterministic mock provider - the default (`LLM_PROVIDER=mock`) and what
|
||||
every automated test runs against. Never calls a network or costs money.
|
||||
|
||||
Rather than a generic reflection-based filler, each of the six analysis
|
||||
tasks gets a purpose-built, deterministic builder that reads the same
|
||||
evidence block a real model would see (see app/prompts/base.py) and
|
||||
produces genuinely useful output from it - real counts, real titles, real
|
||||
severities - never fabricated facts. This is what makes the fixture demo
|
||||
(Phase 9) work end-to-end without a paid API key.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.prompts.alert_summarization import AlertSummary
|
||||
from app.prompts.base import extract_evidence_block
|
||||
from app.prompts.change_significance import ChangeSignificanceAssessment
|
||||
from app.prompts.company_profile import CompanyProfileExtraction
|
||||
from app.prompts.extraction import ExtractionResult
|
||||
from app.prompts.relevance import RelevanceAssessment
|
||||
from app.prompts.report_generation import ReportContent
|
||||
from app.prompts.synthesis import SynthesisResult
|
||||
|
||||
|
||||
def _confidence_label(score: float) -> str:
|
||||
if score >= 0.85:
|
||||
return "confirmed"
|
||||
if score >= 0.65:
|
||||
return "strongly_indicated"
|
||||
if score >= 0.45:
|
||||
return "likely"
|
||||
if score >= 0.25:
|
||||
return "possible"
|
||||
if score > 0:
|
||||
return "unconfirmed"
|
||||
return "insufficient_evidence"
|
||||
|
||||
|
||||
def _build_relevance(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
text = (evidence.get("document_text") or "").lower()
|
||||
focus = (evidence.get("monitoring_focus") or "").lower()
|
||||
focus_words = [w for w in focus.split() if len(w) > 4]
|
||||
matches_focus = any(w in text for w in focus_words) if focus_words else False
|
||||
return {
|
||||
"is_relevant": True,
|
||||
"matches_focus": matches_focus,
|
||||
"topic_categories": [],
|
||||
"source_reliability": 0.7,
|
||||
"reasoning": "Mock provider: keyword-based heuristic (no live LLM configured).",
|
||||
}
|
||||
|
||||
|
||||
def _build_extraction(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
text = (evidence.get("document_text") or "").strip()
|
||||
if not text:
|
||||
return {"signals": []}
|
||||
first_sentence = text.split(".")[0][:200].strip()
|
||||
if not first_sentence:
|
||||
return {"signals": []}
|
||||
return {
|
||||
"signals": [
|
||||
{
|
||||
"signal_type": "event",
|
||||
"description": first_sentence,
|
||||
"supporting_passage": first_sentence,
|
||||
"date": None,
|
||||
"entities": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _build_synthesis(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
signals = evidence.get("signals") or []
|
||||
if len(signals) < 2:
|
||||
return {"conclusions": []}
|
||||
return {
|
||||
"conclusions": [
|
||||
{
|
||||
"conclusion": (
|
||||
"Multiple related signals were detected together; the mock provider "
|
||||
"does not attempt fine-grained synthesis - configure a live LLM provider "
|
||||
"for a specific conclusion."
|
||||
),
|
||||
"evidence_summary": [s.get("description", "") for s in signals[:5]],
|
||||
"source_count": len(signals),
|
||||
"confidence": 0.3,
|
||||
"alternative_explanations": [
|
||||
"A live LLM provider would assess this more precisely."
|
||||
],
|
||||
"missing_information": [],
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def _build_report(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
profile = evidence.get("company_profile") or {}
|
||||
company_name = profile.get("name") or "The company"
|
||||
documents = evidence.get("source_documents") or []
|
||||
changes = evidence.get("detected_changes") or []
|
||||
failed = evidence.get("sources_that_failed_to_collect") or []
|
||||
|
||||
# The discovered profile (real, from onboarding) is genuine evidence even
|
||||
# when no monitoring run has collected source_documents/detected_changes
|
||||
# yet - build company_overview/market_positioning from it honestly
|
||||
# rather than defaulting straight to "insufficient evidence".
|
||||
overview_parts = []
|
||||
if profile.get("description"):
|
||||
overview_parts.append(profile["description"])
|
||||
facts = []
|
||||
if profile.get("industry"):
|
||||
facts.append(f"industry: {profile['industry']}")
|
||||
if profile.get("headquarters"):
|
||||
facts.append(f"headquartered in {profile['headquarters']}")
|
||||
elif profile.get("country") or profile.get("region"):
|
||||
facts.append(
|
||||
f"based in {', '.join(f for f in (profile.get('country'), profile.get('region')) if f)}"
|
||||
)
|
||||
if profile.get("aliases"):
|
||||
facts.append(f"also known as {', '.join(profile['aliases'])}")
|
||||
if facts:
|
||||
overview_parts.append(f"{company_name} ({'; '.join(facts)}).")
|
||||
company_overview = " ".join(overview_parts) or f"No description on file for {company_name}."
|
||||
|
||||
if profile.get("competitors"):
|
||||
market_positioning = (
|
||||
f"{company_name} operates in a space that includes "
|
||||
f"{', '.join(profile['competitors'])} as named competitors, per the discovered "
|
||||
"company profile. No comparative data (pricing, features, market share) is "
|
||||
"available to assess relative positioning."
|
||||
)
|
||||
else:
|
||||
market_positioning = "Insufficient evidence to assess market positioning."
|
||||
|
||||
executive_summary = (
|
||||
f"Mock analysis (LLM_PROVIDER=mock) based on {len(documents)} collected document(s) "
|
||||
f"and {len(changes)} detected change(s) for {company_name}."
|
||||
)
|
||||
if failed:
|
||||
executive_summary += (
|
||||
f" {len(failed)} source(s) failed to collect this run and are excluded below."
|
||||
)
|
||||
|
||||
recent_developments = [
|
||||
{
|
||||
"headline": change.get("summary") or "Change detected",
|
||||
"summary": (
|
||||
f"{(change.get('change_type') or 'change').replace('_', ' ')} detected "
|
||||
f"with {change.get('severity') or 'unknown'} severity."
|
||||
),
|
||||
"evidence": [
|
||||
{
|
||||
"detected_change_id": change.get("id"),
|
||||
"description": change.get("summary") or "",
|
||||
}
|
||||
],
|
||||
"confidence": _confidence_label(change.get("confidence_score") or 0.5),
|
||||
"category": change.get("change_type"),
|
||||
"date": change.get("created_at"),
|
||||
}
|
||||
for change in changes[:10]
|
||||
]
|
||||
|
||||
hiring_signals = [
|
||||
{
|
||||
"headline": doc.get("title") or doc.get("url") or "Job posting",
|
||||
"summary": (doc.get("excerpt") or "")[:280],
|
||||
"evidence": [
|
||||
{
|
||||
"source_document_id": doc.get("id"),
|
||||
"url": doc.get("url"),
|
||||
"description": "Collected source document",
|
||||
}
|
||||
],
|
||||
"confidence": "confirmed",
|
||||
"category": "job_posting",
|
||||
"date": doc.get("retrieved_date"),
|
||||
}
|
||||
for doc in documents
|
||||
if doc.get("source_type") == "job_posting"
|
||||
]
|
||||
|
||||
unknowns = (
|
||||
[f"{len(failed)} source(s) failed to collect this run: {', '.join(failed[:5])}"]
|
||||
if failed
|
||||
else []
|
||||
)
|
||||
|
||||
return {
|
||||
"executive_summary": executive_summary,
|
||||
"company_overview": company_overview,
|
||||
"products_and_services": [],
|
||||
"market_positioning": market_positioning,
|
||||
"recent_developments": recent_developments,
|
||||
"strategic_initiatives": [],
|
||||
"key_inferred_projects": [],
|
||||
"leadership_changes": [
|
||||
d for d in recent_developments if d["category"] == "leadership_change"
|
||||
],
|
||||
"hiring_signals": hiring_signals,
|
||||
"technology_signals": [],
|
||||
"patent_signals": [],
|
||||
"manufacturing_and_expansion_signals": [],
|
||||
"partnerships_and_acquisitions": [],
|
||||
"financial_signals": [d for d in recent_developments if d["category"] == "filing_new"],
|
||||
"regulatory_and_legal_signals": [],
|
||||
"customer_sentiment": "Insufficient evidence to assess customer sentiment.",
|
||||
"competitor_comparison": "Insufficient evidence to compare against competitors.",
|
||||
"swot": {"strengths": [], "weaknesses": [], "opportunities": [], "threats": []},
|
||||
"risks": [],
|
||||
"opportunities": [],
|
||||
"unknowns_and_missing_data": unknowns,
|
||||
"monitoring_recommendations": ["Continue monitoring configured sources on schedule."],
|
||||
"methodology": (
|
||||
f"Generated by the mock LLM provider from {len(documents)} stored source "
|
||||
f"document(s) and {len(changes)} deterministic change-detection result(s). No "
|
||||
"external model was called."
|
||||
),
|
||||
"limitations": (
|
||||
"Generated by the deterministic mock provider, not a live LLM. Set "
|
||||
"LLM_PROVIDER=anthropic or LLM_PROVIDER=ollama for narrative synthesis."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _build_change_significance(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
severity = evidence.get("deterministic_severity") or "low"
|
||||
confidence = evidence.get("deterministic_confidence") or 0.5
|
||||
is_meaningful = severity in ("critical", "high", "medium")
|
||||
change_type = (evidence.get("change_type") or "change").replace("_", " ")
|
||||
return {
|
||||
"is_real_change": True,
|
||||
"is_meaningful": is_meaningful,
|
||||
"why_it_matters": (
|
||||
f"Deterministic scoring classified this {change_type} as {severity} severity "
|
||||
f"with {confidence:.0%} confidence."
|
||||
),
|
||||
"confidence": confidence,
|
||||
"should_notify": is_meaningful,
|
||||
}
|
||||
|
||||
|
||||
def _build_alert_summary(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
company = evidence.get("company_name") or "The company"
|
||||
change_type = (evidence.get("change_type") or "change").replace("_", " ")
|
||||
severity = evidence.get("severity") or "medium"
|
||||
confidence = evidence.get("confidence") or 0.5
|
||||
return {
|
||||
"title": f"{company}: {change_type} detected"[:100],
|
||||
"summary": evidence.get("change_summary") or f"A {change_type} was detected for {company}.",
|
||||
"why_it_matters": f"Classified as {severity} severity with {confidence:.0%} confidence.",
|
||||
}
|
||||
|
||||
|
||||
_HQ_RE = re.compile(r"(?:headquartered|based) in ([A-Z][\w\s,]{2,60}?)(?:[.\n]|$)", re.IGNORECASE)
|
||||
_FORMERLY_RE = re.compile(
|
||||
r"formerly (?:known as|named) ([A-Z][\w&\s]{2,60}?)(?:[.,\n]|$)", re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def _build_company_profile(evidence: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Mirrors app/change_detection/extractors.py's philosophy: cheap,
|
||||
deterministic regex heuristics over real evidence text, never a
|
||||
fabricated guess. Most fields (industry/country/region/public
|
||||
identifiers) stay empty since a name-only mock search has no real
|
||||
signal for them - see search/mock.py's docstring for why that's
|
||||
intentional, not a gap."""
|
||||
homepage_text = evidence.get("homepage_text") or ""
|
||||
search_results = evidence.get("search_results") or []
|
||||
combined_text = homepage_text + "\n" + "\n".join(r.get("snippet", "") for r in search_results)
|
||||
|
||||
hq_match = _HQ_RE.search(combined_text)
|
||||
headquarters = hq_match.group(1).strip() if hq_match else None
|
||||
|
||||
alias_match = _FORMERLY_RE.search(combined_text)
|
||||
aliases = [alias_match.group(1).strip()] if alias_match else []
|
||||
|
||||
# First real sentence of the fetched homepage, if any - an honest,
|
||||
# evidence-derived summary rather than a fabricated one.
|
||||
first_sentence = re.split(r"(?<=[.!?])\s", homepage_text.strip(), maxsplit=1)[0].strip()
|
||||
description = first_sentence[:280] if first_sentence and len(first_sentence) > 15 else None
|
||||
|
||||
return {
|
||||
"description": description,
|
||||
"industry": None,
|
||||
"country": None,
|
||||
"region": None,
|
||||
"headquarters": headquarters,
|
||||
"aliases": aliases,
|
||||
"competitors": [],
|
||||
"public_identifiers": [],
|
||||
}
|
||||
|
||||
|
||||
_BUILDERS = {
|
||||
RelevanceAssessment: _build_relevance,
|
||||
ExtractionResult: _build_extraction,
|
||||
SynthesisResult: _build_synthesis,
|
||||
ReportContent: _build_report,
|
||||
ChangeSignificanceAssessment: _build_change_significance,
|
||||
AlertSummary: _build_alert_summary,
|
||||
CompanyProfileExtraction: _build_company_profile,
|
||||
}
|
||||
|
||||
|
||||
class MockLLMProvider:
|
||||
provider_name = "mock"
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
evidence = extract_evidence_block(user_prompt)
|
||||
builder = _BUILDERS.get(response_model)
|
||||
data = builder(evidence) if builder is not None else {}
|
||||
return response_model.model_validate(data)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
evidence = extract_evidence_block(user_prompt)
|
||||
return (
|
||||
"[mock provider] No live LLM configured. "
|
||||
f"{len(evidence)} evidence field(s) were provided for this request."
|
||||
)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Ollama provider: local models via Ollama's HTTP API. Uses JSON mode
|
||||
(`format: "json"`) plus a bounded repair loop, since not every locally-run
|
||||
model supports strict schema-constrained decoding the way Anthropic's
|
||||
tool-use does - the schema is instead embedded in the system prompt as an
|
||||
instruction.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.analysis.llm.base import LLMResponseError
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
class OllamaLLMProvider:
|
||||
provider_name = "ollama"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._settings = settings
|
||||
|
||||
async def generate_structured[T: BaseModel](
|
||||
self, system_prompt: str, user_prompt: str, response_model: type[T]
|
||||
) -> T:
|
||||
schema_instructions = (
|
||||
f"{system_prompt}\n\nRespond with ONLY a single JSON object matching this JSON "
|
||||
f"schema, no other text, no markdown fences:\n"
|
||||
f"{json.dumps(response_model.model_json_schema())}"
|
||||
)
|
||||
|
||||
last_error: Exception | None = None
|
||||
prompt = user_prompt
|
||||
|
||||
for attempt in range(self._settings.llm_max_retries + 1):
|
||||
if attempt > 0 and last_error is not None:
|
||||
prompt = (
|
||||
f"{user_prompt}\n\nYour previous response was invalid: {last_error}. "
|
||||
"Try again, returning ONLY valid JSON matching the schema."
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
f"{self._settings.ollama_base_url}/api/chat",
|
||||
json={
|
||||
"model": self._settings.ollama_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": schema_instructions},
|
||||
{"role": "user", "content": prompt},
|
||||
],
|
||||
"format": "json",
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
content = response.json().get("message", {}).get("content", "")
|
||||
try:
|
||||
data = json.loads(content)
|
||||
return response_model.model_validate(data)
|
||||
except (json.JSONDecodeError, ValidationError) as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
|
||||
raise LLMResponseError(
|
||||
f"Ollama provider failed to produce a valid {response_model.__name__} after "
|
||||
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
|
||||
)
|
||||
|
||||
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
response = await client.post(
|
||||
f"{self._settings.ollama_base_url}/api/chat",
|
||||
json={
|
||||
"model": self._settings.ollama_model,
|
||||
"messages": [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_prompt},
|
||||
],
|
||||
"stream": False,
|
||||
},
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json().get("message", {}).get("content", "")
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Public unban-request intake plus admin-only IP-ban management. Thin per
|
||||
ARCHITECTURE.md - business logic lives in app.services.unban_service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import require_admin
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.rate_limit import limiter
|
||||
from app.core.security import get_client_ip
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.unban_request_repository import UnbanRequestRepository
|
||||
from app.schemas.unban import (
|
||||
BanIpRequest,
|
||||
IpBanResponse,
|
||||
UnbanRequestPayload,
|
||||
UnbanRequestResponse,
|
||||
)
|
||||
from app.services import unban_service
|
||||
|
||||
router = APIRouter(tags=["admin"])
|
||||
|
||||
|
||||
@router.post("/unban-requests", status_code=status.HTTP_204_NO_CONTENT)
|
||||
@limiter.limit("3/minute")
|
||||
async def submit_unban_request(
|
||||
request: Request,
|
||||
payload: UnbanRequestPayload,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
client_ip = get_client_ip(request, settings)
|
||||
await unban_service.submit_unban_request(db, settings, client_ip, payload.message)
|
||||
|
||||
|
||||
@router.get("/admin/ip-bans", response_model=list[IpBanResponse])
|
||||
async def list_ip_bans(
|
||||
db: AsyncSession = Depends(get_db), _admin: User = Depends(require_admin)
|
||||
) -> list[IpBanResponse]:
|
||||
bans = await unban_service.list_ip_bans(db)
|
||||
return [IpBanResponse.model_validate(b) for b in bans]
|
||||
|
||||
|
||||
@router.delete("/admin/ip-bans/{ip_address}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_ip_ban(
|
||||
ip_address: str,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
await unban_service.unban_ip(db, ip_address)
|
||||
|
||||
|
||||
@router.post("/admin/ip-bans", response_model=IpBanResponse, status_code=status.HTTP_201_CREATED)
|
||||
async def create_ip_ban(
|
||||
payload: BanIpRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> IpBanResponse:
|
||||
ban = await unban_service.ban_ip(db, payload.ip_address)
|
||||
return IpBanResponse.model_validate(ban)
|
||||
|
||||
|
||||
@router.get("/admin/unban-requests", response_model=list[UnbanRequestResponse])
|
||||
async def list_unban_requests(
|
||||
db: AsyncSession = Depends(get_db), _admin: User = Depends(require_admin)
|
||||
) -> list[UnbanRequestResponse]:
|
||||
requests = await UnbanRequestRepository(db).list_all()
|
||||
return [UnbanRequestResponse.model_validate(r) for r in requests]
|
||||
|
||||
|
||||
@router.post("/admin/unban-requests/{request_id}/accept", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def accept_unban_request(
|
||||
request_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
await unban_service.accept_unban_request(db, request_id)
|
||||
|
||||
|
||||
@router.delete("/admin/unban-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def reject_unban_request(
|
||||
request_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
await unban_service.reject_unban_request(db, request_id)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Alert routes: list with filters, detail (with delivery status), and
|
||||
read/resolved mutation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.enums import SeverityLevel
|
||||
from app.models.user import User
|
||||
from app.schemas.alert import AlertDetailResponse, AlertResponse, AlertUpdate
|
||||
from app.services import alert_service
|
||||
|
||||
router = APIRouter(prefix="/alerts", tags=["alerts"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[AlertResponse])
|
||||
async def list_alerts(
|
||||
company_id: uuid.UUID | None = None,
|
||||
severity: SeverityLevel | None = None,
|
||||
read: bool | None = None,
|
||||
resolved: bool | None = None,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[AlertResponse]:
|
||||
alerts = await alert_service.list_alerts(
|
||||
db, user.id, company_id=company_id, severity=severity, read=read, resolved=resolved
|
||||
)
|
||||
return [AlertResponse.model_validate(a) for a in alerts]
|
||||
|
||||
|
||||
@router.get("/{alert_id}", response_model=AlertDetailResponse)
|
||||
async def get_alert(
|
||||
alert_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AlertDetailResponse:
|
||||
alert, deliveries = await alert_service.get_alert_with_deliveries(db, user.id, alert_id)
|
||||
return AlertDetailResponse(
|
||||
**AlertResponse.model_validate(alert).model_dump(), deliveries=deliveries
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{alert_id}", response_model=AlertResponse)
|
||||
async def update_alert(
|
||||
alert_id: uuid.UUID,
|
||||
payload: AlertUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AlertResponse:
|
||||
alert = await alert_service.update_alert(
|
||||
db, user.id, alert_id, read=payload.read, resolved=payload.resolved
|
||||
)
|
||||
return AlertResponse.model_validate(alert)
|
||||
|
||||
|
||||
@router.post("/{alert_id}/read", response_model=AlertResponse)
|
||||
async def mark_alert_read(
|
||||
alert_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AlertResponse:
|
||||
alert = await alert_service.mark_read(db, user.id, alert_id)
|
||||
return AlertResponse.model_validate(alert)
|
||||
|
||||
|
||||
@router.post("/{alert_id}/resolve", response_model=AlertResponse)
|
||||
async def resolve_alert(
|
||||
alert_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> AlertResponse:
|
||||
alert = await alert_service.mark_resolved(db, user.id, alert_id)
|
||||
return AlertResponse.model_validate(alert)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""Auth routes. Thin per ARCHITECTURE.md: parse/validate, call one service
|
||||
method, map the result. All rules live in app.services.auth_service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.errors import ValidationAppError
|
||||
from app.core.rate_limit import limiter
|
||||
from app.core.security import get_client_ip, is_localhost
|
||||
from app.db.session import get_db
|
||||
from app.models.user import LOCAL_DEV_USER_ID, User
|
||||
from app.schemas.auth import (
|
||||
ConfirmPasswordResetRequest,
|
||||
LoginRequest,
|
||||
LogoutRequest,
|
||||
RefreshRequest,
|
||||
RegisterRequest,
|
||||
RequestPasswordResetRequest,
|
||||
ResendVerificationRequest,
|
||||
SecurityEventResponse,
|
||||
TokenResponse,
|
||||
VerifyEmailRequest,
|
||||
)
|
||||
from app.schemas.user import MeResponse, UserResponse
|
||||
from app.services import auth_service, system_secret_service
|
||||
from app.services.turnstile_service import turnstile_required, verify_turnstile
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
async def _enforce_turnstile(
|
||||
request: Request, settings: Settings, db: AsyncSession, token: str | None, client_ip: str
|
||||
) -> None:
|
||||
"""Required for register/login/request-password-reset when the caller
|
||||
isn't on loopback and a secret is configured - skipped entirely
|
||||
otherwise (see turnstile_service.turnstile_required). The secret may be
|
||||
admin-configured (system_secret_service) rather than only .env-set."""
|
||||
effective = await system_secret_service.get_effective_settings(db, settings)
|
||||
if not turnstile_required(is_localhost(request, settings), effective):
|
||||
return
|
||||
if not token or not await verify_turnstile(token, client_ip, effective):
|
||||
raise ValidationAppError("Captcha verification required.")
|
||||
|
||||
|
||||
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("5/minute")
|
||||
async def register(
|
||||
request: Request,
|
||||
payload: RegisterRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UserResponse:
|
||||
client_ip = get_client_ip(request, settings)
|
||||
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
||||
user = await auth_service.register(db, settings, client_ip, payload)
|
||||
return UserResponse.model_validate(user)
|
||||
|
||||
|
||||
@router.post("/login", response_model=TokenResponse)
|
||||
@limiter.limit("10/minute")
|
||||
async def login(
|
||||
request: Request,
|
||||
payload: LoginRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
client_ip = get_client_ip(request, settings)
|
||||
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
||||
return await auth_service.login(db, settings, client_ip, payload)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=TokenResponse)
|
||||
@limiter.limit("20/minute")
|
||||
async def refresh_token(
|
||||
request: Request,
|
||||
payload: RefreshRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> TokenResponse:
|
||||
return await auth_service.refresh(db, settings, payload.refresh_token)
|
||||
|
||||
|
||||
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def logout(
|
||||
payload: LogoutRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
await auth_service.logout(db, settings, payload.refresh_token)
|
||||
|
||||
|
||||
@router.post("/verify-email", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def verify_email(
|
||||
request: Request,
|
||||
payload: VerifyEmailRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
client_ip = get_client_ip(request, settings)
|
||||
await auth_service.verify_email(db, client_ip, payload)
|
||||
|
||||
|
||||
@router.post("/resend-verification", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def resend_verification(
|
||||
request: Request,
|
||||
payload: ResendVerificationRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
client_ip = get_client_ip(request, settings)
|
||||
await auth_service.resend_verification(db, settings, client_ip, payload)
|
||||
|
||||
|
||||
@router.post("/request-password-reset", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def request_password_reset(
|
||||
request: Request,
|
||||
payload: RequestPasswordResetRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
client_ip = get_client_ip(request, settings)
|
||||
await _enforce_turnstile(request, settings, db, payload.turnstile_token, client_ip)
|
||||
await auth_service.request_password_reset(db, settings, client_ip, payload)
|
||||
|
||||
|
||||
@router.post("/confirm-password-reset", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def confirm_password_reset(
|
||||
request: Request,
|
||||
payload: ConfirmPasswordResetRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
client_ip = get_client_ip(request, settings)
|
||||
await auth_service.confirm_password_reset(db, client_ip, payload)
|
||||
|
||||
|
||||
@router.get("/security-events", response_model=list[SecurityEventResponse])
|
||||
async def security_events(
|
||||
db: AsyncSession = Depends(get_db), user: User = Depends(get_current_user)
|
||||
) -> list[SecurityEventResponse]:
|
||||
"""The calling user's own security activity - not admin-gated, it's
|
||||
their own data (see the app-wide admin log feed in app.api.v1.system for
|
||||
the operational counterpart)."""
|
||||
events = await auth_service.list_security_events(db, user.id)
|
||||
return [SecurityEventResponse.model_validate(e) for e in events]
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(user: User = Depends(get_current_user)) -> MeResponse:
|
||||
# Reports what actually happened for *this* request, not the raw
|
||||
# AUTH_MODE setting - the fixed local-dev user only ever comes back via
|
||||
# the loopback bypass (see get_current_user), so its id is a reliable
|
||||
# per-request signal even though the setting itself is almost always
|
||||
# "local".
|
||||
effective_auth_mode = "local" if user.id == LOCAL_DEV_USER_ID else "jwt"
|
||||
base = UserResponse.model_validate(user).model_dump()
|
||||
return MeResponse(**base, auth_mode=effective_auth_mode)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Company + monitor-configuration routes. Thin per ARCHITECTURE.md."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.analysis.llm.factory import get_llm_provider
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.errors import NotFoundError
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.company import (
|
||||
CompanyCreate,
|
||||
CompanyResponse,
|
||||
CompanyUpdate,
|
||||
MonitorConfigurationResponse,
|
||||
MonitorConfigurationUpdate,
|
||||
)
|
||||
from app.schemas.discovery import DiscoverCompanyRequest, DiscoveredCompanyProfile
|
||||
from app.search.factory import get_search_provider
|
||||
from app.services import company_service, discovery_service, user_api_key_service
|
||||
|
||||
router = APIRouter(prefix="/companies", tags=["companies"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[CompanyResponse])
|
||||
async def list_companies(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
) -> list[CompanyResponse]:
|
||||
companies = await company_service.list_companies(db, user.id)
|
||||
return [CompanyResponse.from_company(c) for c in companies]
|
||||
|
||||
|
||||
@router.post("", response_model=CompanyResponse, status_code=status.HTTP_201_CREATED)
|
||||
@limiter.limit("20/minute")
|
||||
async def create_company(
|
||||
request: Request,
|
||||
payload: CompanyCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompanyResponse:
|
||||
settings = await user_api_key_service.get_effective_settings(db, user.id, settings)
|
||||
company = await company_service.create_company(db, settings, user.id, payload)
|
||||
return CompanyResponse.from_company(company)
|
||||
|
||||
|
||||
@router.post("/discover", response_model=DiscoveredCompanyProfile)
|
||||
@limiter.limit("5/minute")
|
||||
async def discover_company(
|
||||
request: Request,
|
||||
payload: DiscoverCompanyRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DiscoveredCompanyProfile:
|
||||
"""Proposes a company profile from just a name - persists nothing. The
|
||||
wizard's "Discover" step calls this, then lets the user edit the result
|
||||
before the existing POST /companies actually creates anything. Tightly
|
||||
rate-limited: unlike everything else in this router, this costs a real
|
||||
search + LLM call per invocation."""
|
||||
settings = await user_api_key_service.get_effective_settings(db, user.id, settings)
|
||||
search = get_search_provider(settings)
|
||||
llm = get_llm_provider(settings)
|
||||
return await discovery_service.discover_company_profile(
|
||||
search,
|
||||
llm,
|
||||
settings,
|
||||
name=payload.name,
|
||||
official_website=payload.official_website,
|
||||
monitoring_focus=payload.monitoring_focus,
|
||||
competitor_names=payload.competitor_names,
|
||||
alias_names=payload.alias_names,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{company_id}", response_model=CompanyResponse)
|
||||
async def get_company(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompanyResponse:
|
||||
company = await company_service.get_company(db, user.id, company_id)
|
||||
return CompanyResponse.from_company(company)
|
||||
|
||||
|
||||
@router.patch("/{company_id}", response_model=CompanyResponse)
|
||||
async def update_company(
|
||||
company_id: uuid.UUID,
|
||||
payload: CompanyUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompanyResponse:
|
||||
company = await company_service.update_company(db, user.id, company_id, payload)
|
||||
return CompanyResponse.from_company(company)
|
||||
|
||||
|
||||
@router.delete("/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_company(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
await company_service.delete_company(db, user.id, company_id)
|
||||
|
||||
|
||||
@router.post("/{company_id}/pause", response_model=CompanyResponse)
|
||||
async def pause_company(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompanyResponse:
|
||||
company = await company_service.pause_company(db, user.id, company_id)
|
||||
return CompanyResponse.from_company(company)
|
||||
|
||||
|
||||
@router.post("/{company_id}/resume", response_model=CompanyResponse)
|
||||
async def resume_company(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> CompanyResponse:
|
||||
company = await company_service.resume_company(db, user.id, company_id)
|
||||
return CompanyResponse.from_company(company)
|
||||
|
||||
|
||||
@router.get("/{company_id}/monitor", response_model=MonitorConfigurationResponse)
|
||||
async def get_monitor_configuration(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> MonitorConfigurationResponse:
|
||||
company = await company_service.get_company(db, user.id, company_id)
|
||||
if company.monitor_configuration is None:
|
||||
raise NotFoundError("Monitor configuration not found")
|
||||
return MonitorConfigurationResponse.model_validate(company.monitor_configuration)
|
||||
|
||||
|
||||
@router.patch("/{company_id}/monitor", response_model=MonitorConfigurationResponse)
|
||||
async def update_monitor_configuration(
|
||||
company_id: uuid.UUID,
|
||||
payload: MonitorConfigurationUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> MonitorConfigurationResponse:
|
||||
config = await company_service.update_monitor_configuration(
|
||||
db, settings, user.id, company_id, payload
|
||||
)
|
||||
return MonitorConfigurationResponse.model_validate(config)
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Dashboard-level aggregate analytics, scoped to the current user across
|
||||
every company they own - see app/services/analytics_service.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.dashboard import DashboardAnalytics
|
||||
from app.services import analytics_service
|
||||
|
||||
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/analytics", response_model=DashboardAnalytics)
|
||||
async def get_dashboard_analytics(
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> DashboardAnalytics:
|
||||
return await analytics_service.get_dashboard_analytics(db, user.id)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Monitoring run routes: run-now, run history, single-run status polling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.monitoring import MonitoringRunResponse
|
||||
from app.services import monitoring_service
|
||||
|
||||
router = APIRouter(tags=["monitoring"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/companies/{company_id}/run",
|
||||
response_model=MonitoringRunResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
@limiter.limit("20/minute")
|
||||
async def run_company_now(
|
||||
request: Request,
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> MonitoringRunResponse:
|
||||
run = await monitoring_service.enqueue_run_now(db, settings, user.id, company_id)
|
||||
return MonitoringRunResponse.model_validate(run)
|
||||
|
||||
|
||||
@router.get("/companies/{company_id}/runs", response_model=list[MonitoringRunResponse])
|
||||
async def list_company_runs(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[MonitoringRunResponse]:
|
||||
runs = await monitoring_service.list_runs(db, user.id, company_id)
|
||||
return [MonitoringRunResponse.model_validate(r) for r in runs]
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=MonitoringRunResponse)
|
||||
async def get_run(
|
||||
run_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> MonitoringRunResponse:
|
||||
run = await monitoring_service.get_run(db, user.id, run_id)
|
||||
return MonitoringRunResponse.model_validate(run)
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.notification_destination import (
|
||||
NotificationDestinationCreate,
|
||||
NotificationDestinationResponse,
|
||||
NotificationDestinationUpdate,
|
||||
NotificationTestResult,
|
||||
)
|
||||
from app.services import alert_service
|
||||
from app.services import notification_destination_service as service
|
||||
|
||||
router = APIRouter(prefix="/notification-destinations", tags=["notifications"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[NotificationDestinationResponse])
|
||||
async def list_destinations(
|
||||
user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db)
|
||||
) -> list[NotificationDestinationResponse]:
|
||||
destinations = await service.list_destinations(db, user.id)
|
||||
return [NotificationDestinationResponse.from_destination(d) for d in destinations]
|
||||
|
||||
|
||||
@router.post(
|
||||
"", response_model=NotificationDestinationResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
@limiter.limit("20/minute")
|
||||
async def create_destination(
|
||||
request: Request,
|
||||
payload: NotificationDestinationCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NotificationDestinationResponse:
|
||||
destination = await service.create_destination(db, user.id, payload)
|
||||
return NotificationDestinationResponse.from_destination(destination)
|
||||
|
||||
|
||||
@router.patch("/{destination_id}", response_model=NotificationDestinationResponse)
|
||||
async def update_destination(
|
||||
destination_id: uuid.UUID,
|
||||
payload: NotificationDestinationUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NotificationDestinationResponse:
|
||||
destination = await service.update_destination(db, user.id, destination_id, payload)
|
||||
return NotificationDestinationResponse.from_destination(destination)
|
||||
|
||||
|
||||
@router.delete("/{destination_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_destination(
|
||||
destination_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
await service.delete_destination(db, user.id, destination_id)
|
||||
|
||||
|
||||
@router.delete("/{destination_id}/companies/{company_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def unlink_company(
|
||||
destination_id: uuid.UUID,
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
"""Unlinks one company from a destination without deleting it outright
|
||||
- a destination shared across several companies must survive losing
|
||||
just one of them. If this was its last link, it's garbage-collected
|
||||
the same way a company deletion already orphans-and-removes one."""
|
||||
await service.unlink_company(db, user.id, destination_id, company_id)
|
||||
|
||||
|
||||
@router.post("/{destination_id}/test", response_model=NotificationTestResult)
|
||||
@limiter.limit("10/minute")
|
||||
async def test_destination(
|
||||
request: Request,
|
||||
destination_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> NotificationTestResult:
|
||||
result = await alert_service.send_test_notification(db, settings, user.id, destination_id)
|
||||
return NotificationTestResult(success=result.success, error=result.error)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Report routes: list/detail, manual generation, and raw markdown/json
|
||||
export - mixes `/companies/{company_id}/reports...` and `/reports/{id}...`
|
||||
paths per the spec, same pattern as sources.py/monitoring.py."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, Response, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.analysis.llm.factory import get_llm_provider
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.report import ReportListItem, ReportResponse
|
||||
from app.services import report_service, user_api_key_service
|
||||
|
||||
router = APIRouter(tags=["reports"])
|
||||
|
||||
|
||||
@router.get("/companies/{company_id}/reports", response_model=list[ReportListItem])
|
||||
async def list_reports(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[ReportListItem]:
|
||||
reports = await report_service.list_reports(db, user.id, company_id)
|
||||
return [ReportListItem.model_validate(r) for r in reports]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/companies/{company_id}/reports/generate",
|
||||
response_model=ReportResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
@limiter.limit("10/minute")
|
||||
async def generate_report(
|
||||
request: Request,
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ReportResponse:
|
||||
settings = await user_api_key_service.get_effective_settings(db, user.id, settings)
|
||||
llm = get_llm_provider(settings)
|
||||
report = await report_service.generate_report_now(db, settings, llm, user.id, company_id)
|
||||
return ReportResponse.model_validate(report)
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}", response_model=ReportResponse)
|
||||
async def get_report(
|
||||
report_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ReportResponse:
|
||||
report = await report_service.get_report(db, user.id, report_id)
|
||||
return ReportResponse.model_validate(report)
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}/markdown", response_class=Response)
|
||||
async def get_report_markdown(
|
||||
report_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> Response:
|
||||
report = await report_service.get_report(db, user.id, report_id)
|
||||
return Response(content=report.markdown_content, media_type="text/markdown")
|
||||
|
||||
|
||||
@router.get("/reports/{report_id}/json")
|
||||
async def get_report_json(
|
||||
report_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> dict:
|
||||
report = await report_service.get_report(db, user.id, report_id)
|
||||
return report.structured_report
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Aggregates all /api/v1 routers. Individual routers are added here as each
|
||||
phase implements them - keeps main.py stable."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
admin,
|
||||
alerts,
|
||||
auth,
|
||||
companies,
|
||||
dashboard,
|
||||
monitoring,
|
||||
notification_destinations,
|
||||
reports,
|
||||
snapshots,
|
||||
sources,
|
||||
system,
|
||||
user_api_keys,
|
||||
)
|
||||
|
||||
api_v1_router = APIRouter(prefix="/api/v1")
|
||||
api_v1_router.include_router(system.router)
|
||||
api_v1_router.include_router(auth.router)
|
||||
api_v1_router.include_router(admin.router)
|
||||
api_v1_router.include_router(user_api_keys.router)
|
||||
api_v1_router.include_router(companies.router)
|
||||
api_v1_router.include_router(notification_destinations.router)
|
||||
api_v1_router.include_router(sources.router)
|
||||
api_v1_router.include_router(snapshots.router)
|
||||
api_v1_router.include_router(monitoring.router)
|
||||
api_v1_router.include_router(reports.router)
|
||||
api_v1_router.include_router(alerts.router)
|
||||
api_v1_router.include_router(dashboard.router)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Read-only snapshot-history route. Snapshots are written internally by
|
||||
collection_service.py during monitoring runs - nothing here creates one."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.snapshot import SnapshotResponse
|
||||
from app.services import snapshot_service
|
||||
|
||||
router = APIRouter(tags=["snapshots"])
|
||||
|
||||
|
||||
@router.get("/companies/{company_id}/snapshots", response_model=list[SnapshotResponse])
|
||||
async def list_snapshots(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[SnapshotResponse]:
|
||||
snapshots = await snapshot_service.list_snapshots(db, user.id, company_id)
|
||||
return [SnapshotResponse.model_validate(s) for s in snapshots]
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Source routes. Mixes `/companies/{company_id}/sources` and
|
||||
`/sources/{source_id}` paths per the spec - kept in one router since both
|
||||
share the same schemas/service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.rate_limit import limiter
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.schemas.source import SourceCreate, SourceResponse, SourceTestResult, SourceUpdate
|
||||
from app.services import source_service
|
||||
|
||||
router = APIRouter(tags=["sources"])
|
||||
|
||||
|
||||
@router.get("/companies/{company_id}/sources", response_model=list[SourceResponse])
|
||||
async def list_sources(
|
||||
company_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> list[SourceResponse]:
|
||||
sources = await source_service.list_sources(db, user.id, company_id)
|
||||
return [SourceResponse.model_validate(s) for s in sources]
|
||||
|
||||
|
||||
@router.post(
|
||||
"/companies/{company_id}/sources",
|
||||
response_model=SourceResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
@limiter.limit("30/minute")
|
||||
async def create_source(
|
||||
request: Request,
|
||||
company_id: uuid.UUID,
|
||||
payload: SourceCreate,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SourceResponse:
|
||||
source = await source_service.create_source(db, user.id, company_id, payload)
|
||||
return SourceResponse.model_validate(source)
|
||||
|
||||
|
||||
@router.patch("/sources/{source_id}", response_model=SourceResponse)
|
||||
async def update_source(
|
||||
source_id: uuid.UUID,
|
||||
payload: SourceUpdate,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SourceResponse:
|
||||
source = await source_service.update_source(db, settings, user.id, source_id, payload)
|
||||
return SourceResponse.model_validate(source)
|
||||
|
||||
|
||||
@router.delete("/sources/{source_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_source(
|
||||
source_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> None:
|
||||
await source_service.delete_source(db, user.id, source_id)
|
||||
|
||||
|
||||
@router.post("/sources/{source_id}/test", response_model=SourceTestResult)
|
||||
@limiter.limit("20/minute")
|
||||
async def test_source(
|
||||
request: Request,
|
||||
source_id: uuid.UUID,
|
||||
user: User = Depends(get_current_user),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> SourceTestResult:
|
||||
result = await source_service.test_source(db, settings, user.id, source_id)
|
||||
return SourceTestResult(
|
||||
status=result.status, documents_found=len(result.documents), error=result.error
|
||||
)
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Health/readiness/system-status endpoints.
|
||||
|
||||
Kept dependency-light on purpose: `/health` must answer even if the database
|
||||
or Redis is down, so infra can tell "process is up" apart from "process is
|
||||
ready to serve traffic".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import httpx
|
||||
import redis.asyncio as redis_asyncio
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import require_admin
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.logging import get_logger, get_recent_logs
|
||||
from app.core.security import get_client_ip, is_localhost
|
||||
from app.db.session import get_db
|
||||
from app.models.enums import SystemSecretKey
|
||||
from app.models.user import User
|
||||
from app.schemas.system_secret import SetSystemSecretRequest, SystemSecretStatus
|
||||
from app.services import system_secret_service
|
||||
from app.services.enrichment_service import estimate_max_credits_per_company
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
router = APIRouter(tags=["system"])
|
||||
|
||||
|
||||
class HealthResponse(BaseModel):
|
||||
status: Literal["ok"] = "ok"
|
||||
app_name: str
|
||||
|
||||
|
||||
class ComponentStatus(BaseModel):
|
||||
name: str
|
||||
status: Literal["ok", "error"]
|
||||
detail: str | None = None
|
||||
|
||||
|
||||
class ReadyResponse(BaseModel):
|
||||
status: Literal["ready", "not_ready"]
|
||||
components: list[ComponentStatus]
|
||||
|
||||
|
||||
class SystemStatusResponse(BaseModel):
|
||||
app_env: str
|
||||
auth_mode: str
|
||||
llm_provider: str
|
||||
search_provider: str
|
||||
sms_enabled: bool
|
||||
sms_provider: str
|
||||
ninjapear_configured: bool
|
||||
ninjapear_credit_balance: int | None
|
||||
ninjapear_estimated_credits_per_company: int | None
|
||||
is_localhost: bool
|
||||
# Public by design (meant to be embedded in the frontend bundle/page) -
|
||||
# resolved through system_secret_service so an admin-updated value here
|
||||
# takes effect immediately, without a frontend rebuild. None when
|
||||
# Turnstile isn't configured at all (neither .env nor admin-set).
|
||||
turnstile_site_key: str | None
|
||||
components: list[ComponentStatus]
|
||||
|
||||
|
||||
class LogEntryResponse(BaseModel):
|
||||
ts: str
|
||||
level: str
|
||||
category: str
|
||||
logger: str
|
||||
event: str
|
||||
context: dict
|
||||
|
||||
|
||||
@router.get("/health", response_model=HealthResponse)
|
||||
async def health(settings: Settings = Depends(get_settings)) -> HealthResponse:
|
||||
return HealthResponse(app_name=settings.app_name)
|
||||
|
||||
|
||||
async def _check_database(db: AsyncSession) -> ComponentStatus:
|
||||
try:
|
||||
await db.execute(text("SELECT 1"))
|
||||
return ComponentStatus(name="database", status="ok")
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
return ComponentStatus(name="database", status="error", detail=str(exc))
|
||||
|
||||
|
||||
async def _check_redis(settings: Settings) -> ComponentStatus:
|
||||
try:
|
||||
client = redis_asyncio.from_url(settings.redis_url, socket_connect_timeout=2)
|
||||
await client.ping()
|
||||
await client.aclose()
|
||||
return ComponentStatus(name="redis", status="ok")
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
return ComponentStatus(name="redis", status="error", detail=str(exc))
|
||||
|
||||
|
||||
@router.get("/ready", response_model=ReadyResponse)
|
||||
async def ready(
|
||||
settings: Settings = Depends(get_settings), db: AsyncSession = Depends(get_db)
|
||||
) -> ReadyResponse:
|
||||
components = [await _check_database(db), await _check_redis(settings)]
|
||||
overall = "ready" if all(c.status == "ok" for c in components) else "not_ready"
|
||||
return ReadyResponse(status=overall, components=components)
|
||||
|
||||
|
||||
async def _get_ninjapear_credit_balance(settings: Settings) -> int | None:
|
||||
"""Free endpoint, safe to call on every status check - never lets a
|
||||
failure here break the rest of /system/status."""
|
||||
if not settings.ninjapear_api_key:
|
||||
return None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
response = await client.get(
|
||||
"https://nubela.co/api/v1/meta/credit-balance",
|
||||
headers={"Authorization": f"Bearer {settings.ninjapear_api_key}"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
return data.get("credit_balance") or data.get("balance")
|
||||
except Exception as exc: # pragma: no cover - defensive, status must not 500 on this
|
||||
logger.warning("ninjapear_credit_balance_check_failed", error=str(exc))
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/system/status", response_model=SystemStatusResponse)
|
||||
async def system_status(
|
||||
request: Request, settings: Settings = Depends(get_settings), db: AsyncSession = Depends(get_db)
|
||||
) -> SystemStatusResponse:
|
||||
components = [await _check_database(db), await _check_redis(settings)]
|
||||
effective = await system_secret_service.get_effective_settings(db, settings)
|
||||
return SystemStatusResponse(
|
||||
app_env=settings.app_env,
|
||||
auth_mode=settings.auth_mode,
|
||||
llm_provider=settings.llm_provider,
|
||||
search_provider=settings.search_provider,
|
||||
sms_enabled=settings.notification_sms_enabled,
|
||||
sms_provider=settings.sms_provider,
|
||||
ninjapear_configured=bool(settings.ninjapear_api_key),
|
||||
ninjapear_credit_balance=await _get_ninjapear_credit_balance(settings),
|
||||
ninjapear_estimated_credits_per_company=(
|
||||
estimate_max_credits_per_company(settings.ninjapear_max_leadership_lookups)
|
||||
if settings.ninjapear_api_key
|
||||
else None
|
||||
),
|
||||
is_localhost=is_localhost(request, settings),
|
||||
turnstile_site_key=effective.turnstile_site_key or None,
|
||||
components=components,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/system/secrets", response_model=list[SystemSecretStatus])
|
||||
async def list_system_secrets(
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_admin: User = Depends(require_admin),
|
||||
) -> list[SystemSecretStatus]:
|
||||
"""Admin-only visibility into server-wide secrets (Turnstile site
|
||||
key/secret) - see app.services.system_secret_service."""
|
||||
statuses = await system_secret_service.list_status(db, settings)
|
||||
return [SystemSecretStatus(**s) for s in statuses]
|
||||
|
||||
|
||||
@router.put("/system/secrets/{key}", response_model=SystemSecretStatus)
|
||||
async def set_system_secret(
|
||||
request: Request,
|
||||
key: SystemSecretKey,
|
||||
payload: SetSystemSecretRequest,
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
admin: User = Depends(require_admin),
|
||||
) -> SystemSecretStatus:
|
||||
await system_secret_service.set_secret(
|
||||
db,
|
||||
key,
|
||||
payload.value,
|
||||
settings,
|
||||
admin_user_id=admin.id,
|
||||
client_ip=get_client_ip(request, settings),
|
||||
)
|
||||
statuses = await system_secret_service.list_status(db, settings)
|
||||
match = next(s for s in statuses if s["key"] == key.value)
|
||||
return SystemSecretStatus(**match)
|
||||
|
||||
|
||||
@router.get("/system/logs", response_model=list[LogEntryResponse])
|
||||
async def system_logs(
|
||||
settings: Settings = Depends(get_settings), _admin: User = Depends(require_admin)
|
||||
) -> list[LogEntryResponse]:
|
||||
"""Most-recent-first view into the application's live log stream (capped
|
||||
at the last 500 entries app-wide, see `core/logging.py`)."""
|
||||
return [LogEntryResponse(**entry) for entry in await get_recent_logs(settings, limit=100)]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Per-user API key management - each user's own keys, visible and
|
||||
editable only by themselves. Thin per ARCHITECTURE.md - logic lives in
|
||||
app.services.user_api_key_service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_user
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.security import get_client_ip
|
||||
from app.db.session import get_db
|
||||
from app.models.enums import ApiKeyProvider
|
||||
from app.models.user import User
|
||||
from app.schemas.user_api_key import SetUserApiKeyRequest, UserApiKeyStatus
|
||||
from app.services import user_api_key_service
|
||||
|
||||
router = APIRouter(prefix="/user-api-keys", tags=["user-api-keys"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserApiKeyStatus])
|
||||
async def list_user_api_keys(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> list[UserApiKeyStatus]:
|
||||
statuses = await user_api_key_service.list_status(db, user.id, settings)
|
||||
return [UserApiKeyStatus(**s) for s in statuses]
|
||||
|
||||
|
||||
@router.put("/{provider}", response_model=UserApiKeyStatus)
|
||||
async def set_user_api_key(
|
||||
request: Request,
|
||||
provider: ApiKeyProvider,
|
||||
payload: SetUserApiKeyRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
settings: Settings = Depends(get_settings),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> UserApiKeyStatus:
|
||||
await user_api_key_service.set_key(
|
||||
db, user.id, provider, payload.key, settings, client_ip=get_client_ip(request, settings)
|
||||
)
|
||||
statuses = await user_api_key_service.list_status(db, user.id, settings)
|
||||
match = next(s for s in statuses if s["provider"] == provider.value)
|
||||
return UserApiKeyStatus(**match)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""FastAPI dependency implementing the `AuthProvider` contract described in
|
||||
ARCHITECTURE.md: `get_current_user` always returns a `User` row or raises
|
||||
401, regardless of caller. This is the seam a future Firebase Auth
|
||||
integration would replace.
|
||||
|
||||
When AUTH_MODE=local (the default), the fixed local-dev user is only
|
||||
returned to a request that's actually from loopback (see
|
||||
`app.core.security.is_localhost`) - anyone reaching the API from a LAN or
|
||||
WAN connection still needs a real bearer token, even with that setting.
|
||||
AUTH_MODE=jwt disables the loopback convenience entirely (required in
|
||||
production, see `Settings._forbid_local_auth_in_production`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, Header, HTTPException, Request, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.security import (
|
||||
InvalidTokenError,
|
||||
TokenType,
|
||||
decode_token,
|
||||
get_client_ip,
|
||||
is_localhost,
|
||||
)
|
||||
from app.db.session import get_db
|
||||
from app.models.user import User
|
||||
from app.repositories.user_repository import UserRepository
|
||||
from app.services.auth_service import get_or_create_local_user
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
authorization: str | None = Header(default=None),
|
||||
settings: Settings = Depends(get_settings),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
if settings.auth_mode == "local" and is_localhost(request, settings):
|
||||
return await get_or_create_local_user(db, get_client_ip(request, settings))
|
||||
|
||||
if authorization is None or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
|
||||
|
||||
token = authorization.split(" ", 1)[1]
|
||||
try:
|
||||
decoded = decode_token(token, settings, TokenType.ACCESS)
|
||||
except InvalidTokenError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired access token",
|
||||
) from exc
|
||||
|
||||
repo = UserRepository(db)
|
||||
user = await repo.get_by_id(decoded.user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired access token",
|
||||
)
|
||||
return user
|
||||
|
||||
|
||||
async def require_admin(user: User = Depends(get_current_user)) -> User:
|
||||
if not user.is_admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="Admin privileges required"
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Lightweight, best-effort regex extractors for specific signal types the
|
||||
severity model treats specially (pricing, leadership). These are heuristics,
|
||||
not NLP - they exist to catch the common "$X/month" and "named a new CEO"
|
||||
phrasings, not to parse arbitrary text reliably. Phase 7's LLM extraction
|
||||
task is the higher-fidelity version of this; these run cheaply and
|
||||
deterministically as part of scoring, without a model call.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_PRICE_RE = re.compile(r"\$\s?\d[\d,]*(?:\.\d{2})?\s*(?:/\s*(?:month|mo|year|yr))?")
|
||||
_LEADERSHIP_TITLE_RE = re.compile(
|
||||
r"(?i)\b(Chief Executive Officer|CEO|Chief Financial Officer|CFO|Chief Technology Officer|"
|
||||
r"CTO|President|Chairman|Chairwoman|Chairperson)\b"
|
||||
)
|
||||
|
||||
|
||||
def extract_prices(text: str) -> set[str]:
|
||||
return set(_PRICE_RE.findall(text or ""))
|
||||
|
||||
|
||||
def mentions_leadership_title(text: str) -> bool:
|
||||
return bool(_LEADERSHIP_TITLE_RE.search(text or ""))
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Strips content that changes on every fetch but carries no meaning, so it
|
||||
never counts toward a text diff. Applied before Layer 3 (text diff) - see
|
||||
ARCHITECTURE.md and spec section 18.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_NOISE_PATTERNS = [
|
||||
# Dynamic timestamps: "Updated: 2026-01-01", "Last modified 01/02/2026 14:30"
|
||||
re.compile(r"(?i)\b(updated|last modified|generated|retrieved)\s*:?\s*[\d/:\-\sTZ]+"),
|
||||
# Session/CSRF-style tokens embedded in visible text (rare, but happens on thin pages)
|
||||
re.compile(r"\b[A-Za-z0-9_-]{24,}\b"),
|
||||
# Cookie/consent banner boilerplate
|
||||
re.compile(r"(?i)we use cookies[^.]*\.?"),
|
||||
re.compile(r"(?i)by (continuing|using this site)[^.]*\.?"),
|
||||
# Copyright year lines, which change every January with no real signal
|
||||
re.compile(r"(?i)copyright\s*(?:©|\(c\))?\s*\d{4}[–\-]?\d{0,4}"),
|
||||
# View/like/share counters
|
||||
re.compile(r"(?i)\b\d[\d,]*\s*(views|likes|shares)\b"),
|
||||
]
|
||||
|
||||
_WHITESPACE_RUN = re.compile(r"[ \t]{2,}")
|
||||
_BLANK_LINES = re.compile(r"\n{3,}")
|
||||
|
||||
|
||||
def strip_noise(text: str) -> str:
|
||||
for pattern in _NOISE_PATTERNS:
|
||||
text = pattern.sub(" ", text)
|
||||
text = _WHITESPACE_RUN.sub(" ", text)
|
||||
return _BLANK_LINES.sub("\n\n", text).strip()
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Layer 5: significance scoring + severity classification.
|
||||
|
||||
This is the documented, unit-tested formula referenced in ARCHITECTURE.md.
|
||||
Deterministic on purpose - severity must be explainable and reproducible
|
||||
without a model call. Phase 7's LLM analysis narrates *why* a change
|
||||
matters; it does not decide *how much* it matters.
|
||||
|
||||
significance = base_weight
|
||||
* source_trust_score (0.3 - 1.0)
|
||||
* min(1.0, independent_sources / 2) # corroboration, caps at 2 sources
|
||||
* focus_match_multiplier (1.3 if it matches the user's stated focus, else 1.0)
|
||||
* recency_multiplier (1.0 if new, 0.5 if a repeat of a recent change)
|
||||
|
||||
confidence = clamp(
|
||||
0.5 * extraction_confidence + 0.4 * source_trust_score + 0.1
|
||||
+ (0.15 if independent_sources >= 2 else 0.0),
|
||||
0.0, 1.0
|
||||
)
|
||||
|
||||
severity = bucket(significance * confidence), with a hard floor:
|
||||
CRITICAL requires confidence >= CRITICAL_MIN_CONFIDENCE regardless of score -
|
||||
an uncorroborated single-source signal can never be labeled Critical.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.enums import ChangeType, SeverityLevel
|
||||
|
||||
BASE_WEIGHTS: dict[ChangeType, float] = {
|
||||
ChangeType.LEADERSHIP_CHANGE: 0.9,
|
||||
ChangeType.FILING_NEW: 0.85,
|
||||
ChangeType.PRICE_CHANGE: 0.6,
|
||||
ChangeType.NEW_DOCUMENT: 0.5,
|
||||
ChangeType.CONTENT_MODIFIED: 0.3, # further scaled by diff_ratio - see compute_significance
|
||||
ChangeType.REMOVED_DOCUMENT: 0.3,
|
||||
}
|
||||
|
||||
# (score_threshold, severity) - first match wins, checked highest first.
|
||||
# Calibrated against compute_significance/compute_confidence's actual output
|
||||
# range (a single-source signal is already discounted ~2x by the
|
||||
# corroboration multiplier) so CRITICAL_MIN_CONFIDENCE below is reachable:
|
||||
# with significance capped at 1.0, a score of 0.6 needs confidence >= 0.6,
|
||||
# which leaves room for the 0.7 confidence floor to actually bite and
|
||||
# downgrade a subset of would-be-CRITICAL cases to HIGH.
|
||||
SEVERITY_THRESHOLDS: list[tuple[float, SeverityLevel]] = [
|
||||
(0.6, SeverityLevel.CRITICAL),
|
||||
(0.4, SeverityLevel.HIGH),
|
||||
(0.2, SeverityLevel.MEDIUM),
|
||||
(0.0, SeverityLevel.LOW),
|
||||
]
|
||||
|
||||
CRITICAL_MIN_CONFIDENCE = 0.7
|
||||
|
||||
|
||||
def compute_significance(
|
||||
*,
|
||||
change_type: ChangeType,
|
||||
source_trust_score: float,
|
||||
independent_source_count: int = 1,
|
||||
focus_match: bool = False,
|
||||
is_repeat: bool = False,
|
||||
diff_ratio: float | None = None,
|
||||
) -> float:
|
||||
base = BASE_WEIGHTS[change_type]
|
||||
if change_type is ChangeType.CONTENT_MODIFIED and diff_ratio is not None:
|
||||
# A one-line wording tweak and a full page rewrite are both
|
||||
# "content_modified" but shouldn't score the same.
|
||||
base = base + diff_ratio * 0.5
|
||||
|
||||
trust_multiplier = _clamp(source_trust_score, 0.3, 1.0)
|
||||
corroboration_multiplier = min(1.0, independent_source_count / 2)
|
||||
focus_multiplier = 1.3 if focus_match else 1.0
|
||||
recency_multiplier = 0.5 if is_repeat else 1.0
|
||||
|
||||
significance = (
|
||||
base * trust_multiplier * corroboration_multiplier * focus_multiplier * recency_multiplier
|
||||
)
|
||||
return round(_clamp(significance, 0.0, 1.0), 4)
|
||||
|
||||
|
||||
def compute_confidence(
|
||||
*,
|
||||
extraction_confidence: float,
|
||||
source_trust_score: float,
|
||||
independent_source_count: int = 1,
|
||||
) -> float:
|
||||
corroboration_bonus = 0.15 if independent_source_count >= 2 else 0.0
|
||||
confidence = 0.5 * extraction_confidence + 0.4 * source_trust_score + 0.1 + corroboration_bonus
|
||||
return round(_clamp(confidence, 0.0, 1.0), 4)
|
||||
|
||||
|
||||
def classify_severity(significance: float, confidence: float) -> SeverityLevel:
|
||||
score = significance * confidence
|
||||
for threshold, severity in SEVERITY_THRESHOLDS:
|
||||
if score >= threshold:
|
||||
if severity is SeverityLevel.CRITICAL and confidence < CRITICAL_MIN_CONFIDENCE:
|
||||
return SeverityLevel.HIGH
|
||||
return severity
|
||||
return SeverityLevel.LOW # pragma: no cover - thresholds bottom out at 0.0
|
||||
|
||||
|
||||
def _clamp(value: float, lo: float, hi: float) -> float:
|
||||
return max(lo, min(hi, value))
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Layer 2: structured field comparison.
|
||||
|
||||
Compares the *set* of items (job postings, press releases, filings,
|
||||
products - whatever the source's documents represent) between two
|
||||
snapshots' `structured_summary["urls"]`/`["titles"]`. This is what catches
|
||||
"a new job posting appeared" or "a press release was removed" without
|
||||
needing a bespoke parser per source type - collection_service already
|
||||
records the full current item set on every run, so this is a plain set
|
||||
diff between two runs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StructuredDiff:
|
||||
added: list[str] = field(default_factory=list)
|
||||
removed: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def has_changes(self) -> bool:
|
||||
return bool(self.added or self.removed)
|
||||
|
||||
|
||||
def diff_item_sets(previous_urls: list[str], current_urls: list[str]) -> StructuredDiff:
|
||||
previous_set = set(previous_urls)
|
||||
current_set = set(current_urls)
|
||||
return StructuredDiff(
|
||||
added=sorted(current_set - previous_set),
|
||||
removed=sorted(previous_set - current_set),
|
||||
)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Layer 3: bounded text diff.
|
||||
|
||||
Runs on noise-stripped text (see noise_filters.py) so navigation/cookie/
|
||||
timestamp churn doesn't register as a change. Bounded: only a capped number
|
||||
of added/removed lines are kept, so a full page rewrite doesn't produce an
|
||||
unbounded diff blob for storage or LLM consumption later.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from app.change_detection.noise_filters import strip_noise
|
||||
|
||||
_MAX_DIFF_LINES = 40
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextDiffResult:
|
||||
diff_ratio: float # 0.0 = identical, 1.0 = completely different
|
||||
added_lines: list[str] = field(default_factory=list)
|
||||
removed_lines: list[str] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def is_identical(self) -> bool:
|
||||
return self.diff_ratio == 0.0
|
||||
|
||||
|
||||
def bounded_text_diff(previous_text: str, current_text: str) -> TextDiffResult:
|
||||
previous_clean = strip_noise(previous_text or "")
|
||||
current_clean = strip_noise(current_text or "")
|
||||
|
||||
if previous_clean == current_clean:
|
||||
return TextDiffResult(diff_ratio=0.0)
|
||||
|
||||
previous_lines = [line for line in previous_clean.splitlines() if line.strip()]
|
||||
current_lines = [line for line in current_clean.splitlines() if line.strip()]
|
||||
|
||||
matcher = difflib.SequenceMatcher(a=previous_lines, b=current_lines, autojunk=False)
|
||||
similarity = matcher.ratio()
|
||||
diff_ratio = round(1.0 - similarity, 4)
|
||||
|
||||
added: list[str] = []
|
||||
removed: list[str] = []
|
||||
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
||||
if tag in ("replace", "delete"):
|
||||
removed.extend(previous_lines[i1:i2])
|
||||
if tag in ("replace", "insert"):
|
||||
added.extend(current_lines[j1:j2])
|
||||
|
||||
return TextDiffResult(
|
||||
diff_ratio=diff_ratio,
|
||||
added_lines=added[:_MAX_DIFF_LINES],
|
||||
removed_lines=removed[:_MAX_DIFF_LINES],
|
||||
)
|
||||
@@ -0,0 +1,96 @@
|
||||
"""Collector interface. Every source type (website, RSS, SEC EDGAR, GitHub,
|
||||
custom URL, job postings, and the fixture-backed patent/review adapters)
|
||||
implements this same `SourceCollector` protocol, so `tasks/collection.py`
|
||||
(Phase 5) can treat them uniformly.
|
||||
|
||||
Collectors never talk to the database - they take plain dataclasses in and
|
||||
return plain dataclasses out. Persisting `CollectedDocument`s into
|
||||
`SourceDocument` rows is the caller's job (a service function, not the
|
||||
collector), which keeps collectors trivially unit-testable against fixtures.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Any, Protocol
|
||||
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompanyContext:
|
||||
"""Read-only view of a Company, passed into collectors instead of the ORM object."""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
official_website: str | None
|
||||
monitoring_focus: str | None
|
||||
aliases: list[str] = field(default_factory=list)
|
||||
competitors: list[str] = field(default_factory=list)
|
||||
# From NinjaPear enrichment (app/models/company_enrichment.py), when it
|
||||
# ran and found a leadership team - empty otherwise (no key, still
|
||||
# pending, or no leadership data). Used by PatentSourceCollector to
|
||||
# search USPTO by inventor name, since that endpoint has no queryable
|
||||
# company/assignee field at all - see collectors/patents.py.
|
||||
leadership_names: list[str] = field(default_factory=list)
|
||||
# The owning user's effective USPTO key (their own, or the server's
|
||||
# global one) - None means "use the server's global settings.uspto_api_key
|
||||
# directly", for call sites that never resolved a per-user override
|
||||
# (e.g. discovery preview paths outside a monitoring run).
|
||||
uspto_api_key: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredSource:
|
||||
source_type: SourceType
|
||||
name: str
|
||||
base_url: str | None
|
||||
configuration_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceConfig:
|
||||
id: str
|
||||
source_type: SourceType
|
||||
name: str
|
||||
base_url: str | None
|
||||
configuration_metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollectedDocument:
|
||||
url: str
|
||||
canonical_url: str
|
||||
title: str | None
|
||||
author: str | None
|
||||
publication_date: datetime | None
|
||||
retrieved_date: datetime
|
||||
content_text: str
|
||||
content_hash: str
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
language: str | None = None
|
||||
http_status: int | None = None
|
||||
extraction_method: str = "unknown"
|
||||
trust_score: float = 0.7
|
||||
|
||||
|
||||
@dataclass
|
||||
class CollectionResult:
|
||||
status: SourceStatus
|
||||
documents: list[CollectedDocument] = field(default_factory=list)
|
||||
error: str | None = None
|
||||
pages_attempted: int = 0
|
||||
|
||||
|
||||
class SourceCollector(Protocol):
|
||||
source_type: SourceType
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
"""Suggest sources for a newly added company. May return an empty
|
||||
list if this collector type can't be auto-discovered (e.g. patents)."""
|
||||
...
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
"""Fetch and extract current content for a configured source."""
|
||||
...
|
||||
@@ -0,0 +1,87 @@
|
||||
"""User-supplied custom URL collector - fetches, extracts, and monitors a
|
||||
single public URL the user explicitly added."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import (
|
||||
canonicalize_url,
|
||||
compute_content_hash,
|
||||
extract_readable_text,
|
||||
extract_title,
|
||||
)
|
||||
from app.collectors.robots import is_allowed
|
||||
from app.core.config import get_settings
|
||||
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
|
||||
class CustomUrlCollector:
|
||||
source_type = SourceType.CUSTOM_URL
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
return [] # Custom URLs are always user-supplied, never auto-discovered.
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
if not source.base_url:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error="No URL configured")
|
||||
|
||||
settings = get_settings()
|
||||
try:
|
||||
if not await is_allowed(source.base_url, settings=settings):
|
||||
return CollectionResult(
|
||||
status=SourceStatus.BLOCKED_BY_POLICY,
|
||||
error="Disallowed by robots.txt",
|
||||
pages_attempted=1,
|
||||
)
|
||||
|
||||
result = await fetch_with_retries(source.base_url, settings=settings)
|
||||
except SsrfBlockedError as exc:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc), pages_attempted=1
|
||||
)
|
||||
except (FetchError, httpx.HTTPError) as exc:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error=str(exc), pages_attempted=1)
|
||||
|
||||
if result.status_code in (401, 403):
|
||||
return CollectionResult(
|
||||
status=SourceStatus.AUTH_REQUIRED,
|
||||
error=f"HTTP {result.status_code}",
|
||||
pages_attempted=1,
|
||||
)
|
||||
if result.status_code >= 400:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED, error=f"HTTP {result.status_code}", pages_attempted=1
|
||||
)
|
||||
|
||||
text, method = extract_readable_text(result.text, source.base_url)
|
||||
if not text:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED, error="No extractable content", pages_attempted=1
|
||||
)
|
||||
|
||||
document = CollectedDocument(
|
||||
url=source.base_url,
|
||||
canonical_url=canonicalize_url(result.final_url),
|
||||
title=extract_title(result.text),
|
||||
author=None,
|
||||
publication_date=None,
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={"http_status": result.status_code},
|
||||
extraction_method=method,
|
||||
http_status=result.status_code,
|
||||
trust_score=0.7,
|
||||
)
|
||||
return CollectionResult(status=SourceStatus.ACTIVE, documents=[document], pages_attempted=1)
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Shared content extraction/normalization helpers used by every collector.
|
||||
|
||||
Centralizing this (rather than letting each collector roll its own) is what
|
||||
makes cross-collector dedup and hashing behave consistently.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
from urllib.parse import urlsplit, urlunsplit
|
||||
|
||||
import trafilatura
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
_WHITESPACE_RE = re.compile(r"[ \t\f\v]+")
|
||||
_BLANK_LINES_RE = re.compile(r"\n{3,}")
|
||||
|
||||
# Query params that vary per-request/session but don't change page meaning -
|
||||
# stripped so the same logical page always canonicalizes identically.
|
||||
_NOISE_QUERY_PREFIXES = ("utm_", "fbclid", "gclid", "mc_", "_hs")
|
||||
|
||||
|
||||
def normalize_whitespace(text: str) -> str:
|
||||
text = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||
text = _WHITESPACE_RE.sub(" ", text)
|
||||
lines = [line.strip() for line in text.split("\n")]
|
||||
text = "\n".join(lines)
|
||||
return _BLANK_LINES_RE.sub("\n\n", text).strip()
|
||||
|
||||
|
||||
def compute_content_hash(text: str) -> str:
|
||||
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def canonicalize_url(url: str) -> str:
|
||||
parts = urlsplit(url)
|
||||
query_pairs = [
|
||||
pair
|
||||
for pair in parts.query.split("&")
|
||||
if pair and not pair.split("=")[0].startswith(_NOISE_QUERY_PREFIXES)
|
||||
]
|
||||
path = parts.path.rstrip("/") or "/"
|
||||
return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, "&".join(query_pairs), ""))
|
||||
|
||||
|
||||
def extract_readable_text(html: str, url: str) -> tuple[str, str]:
|
||||
"""Returns (text, extraction_method). Prefers trafilatura (boilerplate
|
||||
removal tuned for articles/press releases); falls back to a plain
|
||||
BeautifulSoup text extraction for pages trafilatura can't parse (e.g.
|
||||
thin job listing pages)."""
|
||||
extracted = trafilatura.extract(
|
||||
html,
|
||||
url=url,
|
||||
include_comments=False,
|
||||
include_tables=True,
|
||||
favor_precision=True,
|
||||
)
|
||||
if extracted and extracted.strip():
|
||||
return normalize_whitespace(extracted), "trafilatura"
|
||||
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
for tag in soup(["script", "style", "nav", "footer", "header", "noscript"]):
|
||||
tag.decompose()
|
||||
text = soup.get_text(separator="\n")
|
||||
return normalize_whitespace(text), "beautifulsoup_fallback"
|
||||
|
||||
|
||||
def extract_title(html: str) -> str | None:
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
if soup.title and soup.title.string:
|
||||
return soup.title.string.strip()
|
||||
h1 = soup.find("h1")
|
||||
if h1:
|
||||
return h1.get_text(strip=True)
|
||||
return None
|
||||
@@ -0,0 +1,147 @@
|
||||
"""GitHub collector - public organization/repository metadata via the
|
||||
public REST API. `GITHUB_TOKEN` is optional and only raises the rate limit;
|
||||
nothing here requires authentication.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||
from app.core.logging import get_logger
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_API_BASE = "https://api.github.com"
|
||||
|
||||
|
||||
def _auth_headers(settings: Settings) -> dict[str, str]:
|
||||
headers = {"Accept": "application/vnd.github+json"}
|
||||
if settings.github_token:
|
||||
headers["Authorization"] = f"Bearer {settings.github_token}"
|
||||
return headers
|
||||
|
||||
|
||||
class GithubCollector:
|
||||
source_type = SourceType.GITHUB
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
settings = get_settings()
|
||||
org_login = await self._find_org(company.name, settings)
|
||||
if org_login is None:
|
||||
return []
|
||||
return [
|
||||
DiscoveredSource(
|
||||
source_type=SourceType.GITHUB,
|
||||
name=f"{company.name} — GitHub",
|
||||
base_url=f"https://github.com/{org_login}",
|
||||
configuration_metadata={"org": org_login},
|
||||
)
|
||||
]
|
||||
|
||||
async def _find_org(self, company_name: str, settings: Settings) -> str | None:
|
||||
url = str(
|
||||
httpx.URL(
|
||||
f"{_API_BASE}/search/users",
|
||||
params={"q": f"{company_name} type:org", "per_page": 1},
|
||||
)
|
||||
)
|
||||
try:
|
||||
result = await fetch_with_retries(
|
||||
url, settings=settings, max_attempts=2, extra_headers=_auth_headers(settings)
|
||||
)
|
||||
except (SsrfBlockedError, FetchError, httpx.HTTPError) as exc:
|
||||
logger.warning("github_org_search_failed", company=company_name, error=str(exc))
|
||||
return None
|
||||
if result.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(result.text)
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
items = payload.get("items", [])
|
||||
return items[0]["login"] if items else None
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
org = source.configuration_metadata.get("org")
|
||||
if not org:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error="No GitHub org configured")
|
||||
|
||||
settings = get_settings()
|
||||
url = str(
|
||||
httpx.URL(f"{_API_BASE}/orgs/{org}/repos", params={"sort": "pushed", "per_page": 15})
|
||||
)
|
||||
try:
|
||||
result = await fetch_with_retries(
|
||||
url, settings=settings, extra_headers=_auth_headers(settings)
|
||||
)
|
||||
except SsrfBlockedError as exc:
|
||||
return CollectionResult(status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc))
|
||||
except (FetchError, httpx.HTTPError) as exc:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error=str(exc))
|
||||
|
||||
if result.status_code == 404:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED, error=f"GitHub org not found: {org}"
|
||||
)
|
||||
if result.status_code == 403:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.RATE_LIMITED, error="GitHub API rate limited"
|
||||
)
|
||||
if result.status_code >= 400:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error=f"HTTP {result.status_code}")
|
||||
|
||||
try:
|
||||
repos = json.loads(result.text)
|
||||
except json.JSONDecodeError:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error="Malformed GitHub response")
|
||||
|
||||
documents: list[CollectedDocument] = []
|
||||
for repo in repos:
|
||||
text = normalize_whitespace(
|
||||
f"{repo.get('full_name')}\n"
|
||||
f"{repo.get('description') or ''}\n"
|
||||
f"Language: {repo.get('language') or 'unknown'}\n"
|
||||
f"Stars: {repo.get('stargazers_count', 0)}\n"
|
||||
f"Last pushed: {repo.get('pushed_at')}"
|
||||
)
|
||||
documents.append(
|
||||
CollectedDocument(
|
||||
url=repo.get("html_url"),
|
||||
canonical_url=repo.get("html_url"),
|
||||
title=repo.get("full_name"),
|
||||
author=org,
|
||||
publication_date=(
|
||||
datetime.fromisoformat(repo["pushed_at"].replace("Z", "+00:00"))
|
||||
if repo.get("pushed_at")
|
||||
else None
|
||||
),
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={
|
||||
"stars": repo.get("stargazers_count"),
|
||||
"language": repo.get("language"),
|
||||
"archived": repo.get("archived"),
|
||||
},
|
||||
extraction_method="github_api",
|
||||
http_status=result.status_code,
|
||||
trust_score=0.75,
|
||||
)
|
||||
)
|
||||
|
||||
status = SourceStatus.ACTIVE
|
||||
return CollectionResult(status=status, documents=documents, pages_attempted=1)
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Federal contracts collector via USASpending.gov's public Award Search
|
||||
API - free, keyless, no registration (a fixed, trusted, first-party
|
||||
integration endpoint like Brave/Twilio, so this calls httpx directly rather
|
||||
than through `safe_fetch`, which exists to guard arbitrary/user-supplied
|
||||
collector targets, not our own known API integrations).
|
||||
|
||||
Offered for every company regardless of type, same as SecEdgarCollector -
|
||||
a private company simply returns zero awards, which is a normal empty
|
||||
result, not a failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import httpx
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||
from app.core.logging import get_logger
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_SEARCH_URL = "https://api.usaspending.gov/api/v2/search/spending_by_award/"
|
||||
_AWARD_TYPE_CODES = ["A", "B", "C", "D"] # contracts (definitive/BPA/purchase order/delivery order)
|
||||
|
||||
|
||||
class GovContractCollector:
|
||||
source_type = SourceType.GOV_CONTRACT
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
return [
|
||||
DiscoveredSource(
|
||||
source_type=SourceType.GOV_CONTRACT,
|
||||
name=f"{company.name} — Federal Contracts",
|
||||
base_url=None,
|
||||
configuration_metadata={"recipient_search_text": company.name},
|
||||
)
|
||||
]
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
recipient = source.configuration_metadata.get("recipient_search_text") or company.name
|
||||
|
||||
body = {
|
||||
"filters": {
|
||||
"recipient_search_text": [recipient],
|
||||
"award_type_codes": _AWARD_TYPE_CODES,
|
||||
},
|
||||
"fields": [
|
||||
"Award ID",
|
||||
"Recipient Name",
|
||||
"Award Amount",
|
||||
"Start Date",
|
||||
"Awarding Agency",
|
||||
"Description",
|
||||
],
|
||||
"sort": "Award Amount",
|
||||
"order": "desc",
|
||||
"page": 1,
|
||||
"limit": 25,
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
response = await client.post(_SEARCH_URL, json=body)
|
||||
except httpx.HTTPError as exc:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error=str(exc))
|
||||
|
||||
if response.status_code >= 400:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED,
|
||||
error=f"USASpending API error {response.status_code}: {response.text[:200]}",
|
||||
pages_attempted=1,
|
||||
)
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED,
|
||||
error="Malformed USASpending response",
|
||||
pages_attempted=1,
|
||||
)
|
||||
|
||||
results = payload.get("results", [])
|
||||
documents: list[CollectedDocument] = []
|
||||
for award in results:
|
||||
award_id = award.get("Award ID", "unknown")
|
||||
agency = award.get("Awarding Agency", "Unknown agency")
|
||||
amount = award.get("Award Amount")
|
||||
amount_display = (
|
||||
f"${amount:,.0f}" if isinstance(amount, (int, float)) else "unknown amount"
|
||||
)
|
||||
start_date = award.get("Start Date", "")
|
||||
description = award.get("Description") or ""
|
||||
|
||||
text = normalize_whitespace(
|
||||
f"{award.get('Recipient Name', recipient)} was awarded federal contract "
|
||||
f"{award_id} by {agency} for {amount_display}, starting {start_date}. "
|
||||
f"{description}"
|
||||
)
|
||||
documents.append(
|
||||
CollectedDocument(
|
||||
url=_SEARCH_URL,
|
||||
canonical_url=_SEARCH_URL,
|
||||
title=f"{agency}: {award_id} — {amount_display}",
|
||||
author="USASpending.gov",
|
||||
publication_date=(
|
||||
datetime.strptime(start_date, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
if start_date
|
||||
else None
|
||||
),
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={"award_id": award_id, "awarding_agency": agency},
|
||||
extraction_method="usaspending_api",
|
||||
http_status=response.status_code,
|
||||
trust_score=0.8,
|
||||
)
|
||||
)
|
||||
|
||||
# Zero awards isn't a failure - most companies never win a federal
|
||||
# contract, same non-error empty-result handling as SecEdgarCollector.
|
||||
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Job posting collector: generic heuristic extraction from a company's own
|
||||
careers page. Board-specific APIs (LinkedIn, Indeed, etc.) are not
|
||||
implemented - most require paid access or prohibit automated collection in
|
||||
their terms; see KNOWN_LIMITATIONS.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import (
|
||||
canonicalize_url,
|
||||
compute_content_hash,
|
||||
extract_readable_text,
|
||||
extract_title,
|
||||
normalize_whitespace,
|
||||
)
|
||||
from app.collectors.robots import is_allowed
|
||||
from app.core.config import get_settings
|
||||
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
_JOB_LINK_KEYWORDS = ("job", "career", "position", "opening", "role", "vacan")
|
||||
_MAX_LISTINGS = 50
|
||||
|
||||
|
||||
class JobPostingCollector:
|
||||
source_type = SourceType.JOB_POSTING
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
if not company.official_website:
|
||||
return []
|
||||
careers_url = urljoin(company.official_website, "/careers")
|
||||
return [
|
||||
DiscoveredSource(
|
||||
source_type=SourceType.JOB_POSTING,
|
||||
name=f"{company.name} — Careers",
|
||||
base_url=careers_url,
|
||||
)
|
||||
]
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
if not source.base_url:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error="No careers URL configured")
|
||||
|
||||
settings = get_settings()
|
||||
try:
|
||||
if not await is_allowed(source.base_url, settings=settings):
|
||||
return CollectionResult(
|
||||
status=SourceStatus.BLOCKED_BY_POLICY,
|
||||
error="Disallowed by robots.txt",
|
||||
pages_attempted=1,
|
||||
)
|
||||
result = await fetch_with_retries(source.base_url, settings=settings)
|
||||
except SsrfBlockedError as exc:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc), pages_attempted=1
|
||||
)
|
||||
except (FetchError, httpx.HTTPError) as exc:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error=str(exc), pages_attempted=1)
|
||||
|
||||
if result.status_code in (401, 403):
|
||||
return CollectionResult(
|
||||
status=SourceStatus.AUTH_REQUIRED,
|
||||
error=f"HTTP {result.status_code}",
|
||||
pages_attempted=1,
|
||||
)
|
||||
if result.status_code >= 400:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED, error=f"HTTP {result.status_code}", pages_attempted=1
|
||||
)
|
||||
|
||||
listings = self._extract_job_listings(result.text, result.final_url)
|
||||
|
||||
if not listings:
|
||||
# Non-standard careers page (e.g. a third-party ATS iframe) -
|
||||
# fall back to the whole page as one document rather than
|
||||
# reporting a failure for a page that did load successfully.
|
||||
text, method = extract_readable_text(result.text, source.base_url)
|
||||
if not text:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED, error="No extractable content", pages_attempted=1
|
||||
)
|
||||
document = CollectedDocument(
|
||||
url=source.base_url,
|
||||
canonical_url=canonicalize_url(result.final_url),
|
||||
title=extract_title(result.text),
|
||||
author=None,
|
||||
publication_date=None,
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={"extraction": "fallback_whole_page"},
|
||||
extraction_method=method,
|
||||
http_status=result.status_code,
|
||||
trust_score=0.55,
|
||||
)
|
||||
return CollectionResult(
|
||||
status=SourceStatus.ACTIVE, documents=[document], pages_attempted=1
|
||||
)
|
||||
|
||||
documents = [
|
||||
CollectedDocument(
|
||||
url=link,
|
||||
canonical_url=canonicalize_url(link),
|
||||
title=title,
|
||||
author=None,
|
||||
publication_date=None,
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=normalize_whitespace(f"{title}\n{snippet}"),
|
||||
content_hash=compute_content_hash(normalize_whitespace(f"{title}\n{snippet}")),
|
||||
metadata={"source_page": source.base_url},
|
||||
extraction_method="job_link_heuristic",
|
||||
http_status=result.status_code,
|
||||
trust_score=0.65,
|
||||
)
|
||||
for title, link, snippet in listings
|
||||
]
|
||||
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||
|
||||
def _extract_job_listings(self, html: str, base_url: str) -> list[tuple[str, str, str]]:
|
||||
soup = BeautifulSoup(html, "lxml")
|
||||
listings: list[tuple[str, str, str]] = []
|
||||
seen_links: set[str] = set()
|
||||
|
||||
for anchor in soup.find_all("a", href=True):
|
||||
href = anchor["href"]
|
||||
text = anchor.get_text(strip=True)
|
||||
if not text or len(text) < 4 or len(text) > 150:
|
||||
continue
|
||||
if not any(keyword in href.lower() for keyword in _JOB_LINK_KEYWORDS):
|
||||
continue
|
||||
|
||||
link = urljoin(base_url, href)
|
||||
if link in seen_links:
|
||||
continue
|
||||
seen_links.add(link)
|
||||
|
||||
parent = anchor.find_parent()
|
||||
snippet = parent.get_text(" ", strip=True)[:300] if parent else ""
|
||||
listings.append((text, link, snippet))
|
||||
if len(listings) >= _MAX_LISTINGS:
|
||||
break
|
||||
|
||||
return listings
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Patent source collector.
|
||||
|
||||
USPTO's PatentsView data migrated into the Open Data Portal (ODP) in March
|
||||
2026; a free API key is available via account registration at
|
||||
data.uspto.gov/apis/getting-started (see `Settings.uspto_api_key`). Without
|
||||
a key configured (the default), this collector never fabricates patent
|
||||
data - it truthfully reports `DISABLED` with an explanation, same as
|
||||
before this integration existed, and a fixture adapter remains available
|
||||
for local development/testing.
|
||||
|
||||
With a key configured, `collect()` calls the real ODP Patent Application
|
||||
Search API - by INVENTOR NAME, not company name. Confirmed live (2026-08)
|
||||
by inspecting a real response's full field list, including one from a
|
||||
query that returned 110k+ real results: there is no assignee/company field
|
||||
anywhere in this endpoint's data model. Company-name search here always
|
||||
returns "no matching records," even for assignees with thousands of real
|
||||
patents - it isn't a wrong-field-name bug, the field doesn't exist on this
|
||||
dataset. USPTO's Patent Application Search reliably supports inventor-name
|
||||
and application-number lookups only.
|
||||
|
||||
So `collect()` instead searches by each of the company's known leadership
|
||||
names (from NinjaPear enrichment, see `CompanyContext.leadership_names` /
|
||||
`enrichment_service.py`) and treats a match as a heuristic company signal,
|
||||
not a verified one - there is still no way to confirm a given patent
|
||||
actually belongs to the monitored company rather than, say, a same-named
|
||||
person, or work the person did at a prior employer. Every resulting
|
||||
document is trust-scored lower (0.5, vs. a hypothetical verified-assignee
|
||||
match) and its content explicitly says which leadership name it matched
|
||||
on, so the report LLM's confidence labeling reflects this rather than
|
||||
treating it as confirmed fact. With no leadership names available (no
|
||||
NinjaPear key, enrichment still pending, or it returned no leadership
|
||||
data), this reports an honest empty result without making a network call
|
||||
- there's nothing meaningful to search USPTO for.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||
from app.core.config import get_settings
|
||||
from app.core.logging import get_logger
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_FIXTURES_DIR = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "patents"
|
||||
_SEARCH_URL = "https://api.uspto.gov/api/v1/patent/applications/search"
|
||||
_MAX_INVENTOR_SEARCHES = 5
|
||||
_MAX_DOCUMENTS = 25
|
||||
|
||||
|
||||
class PatentSourceCollector:
|
||||
source_type = SourceType.PATENT
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
# company.uspto_api_key, when set by the caller (see
|
||||
# collection_service.to_company_context), is the resolved effective
|
||||
# key for whichever user owns this company - their own if they've
|
||||
# set one, else the server's global default. None means no
|
||||
# per-user resolution happened for this call path, so fall back to
|
||||
# the global settings directly.
|
||||
api_key = company.uspto_api_key or get_settings().uspto_api_key
|
||||
if not api_key:
|
||||
return [] # No live discovery without a configured provider.
|
||||
return [
|
||||
DiscoveredSource(
|
||||
source_type=SourceType.PATENT,
|
||||
name=f"{company.name} — Patent Filings",
|
||||
base_url=None,
|
||||
configuration_metadata={"assignee": company.name},
|
||||
)
|
||||
]
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
api_key = company.uspto_api_key or get_settings().uspto_api_key
|
||||
if api_key:
|
||||
return await self._collect_live(company.name, company.leadership_names, api_key)
|
||||
|
||||
fixture_key = source.configuration_metadata.get("fixture_key")
|
||||
if not fixture_key:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.DISABLED,
|
||||
error=(
|
||||
"No patent data provider is configured. This collector implements the "
|
||||
"SourceCollector interface for a live integration (USPTO Open Data Portal) "
|
||||
"but does not fabricate results without a configured USPTO_API_KEY."
|
||||
),
|
||||
)
|
||||
|
||||
fixture_path = _FIXTURES_DIR / f"{fixture_key}.json"
|
||||
if not fixture_path.exists():
|
||||
return CollectionResult(
|
||||
status=SourceStatus.DISABLED,
|
||||
error=f"No fixture found for {fixture_key!r} and no live provider is configured.",
|
||||
)
|
||||
|
||||
payload = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
documents: list[CollectedDocument] = []
|
||||
for entry in payload.get("patents", []):
|
||||
text = normalize_whitespace(f"{entry['title']}\n\n{entry.get('abstract', '')}")
|
||||
documents.append(
|
||||
CollectedDocument(
|
||||
url=entry.get("url", fixture_path.as_uri()),
|
||||
canonical_url=entry.get("url", fixture_path.as_uri()),
|
||||
title=entry["title"],
|
||||
author=entry.get("assignee"),
|
||||
publication_date=(
|
||||
datetime.fromisoformat(entry["filed_date"]).replace(tzinfo=UTC)
|
||||
if entry.get("filed_date")
|
||||
else None
|
||||
),
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={
|
||||
"is_fixture": True,
|
||||
"data_source": "fixture",
|
||||
"fixture_key": fixture_key,
|
||||
},
|
||||
extraction_method="fixture",
|
||||
trust_score=0.5,
|
||||
)
|
||||
)
|
||||
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||
|
||||
async def _collect_live(
|
||||
self, company_name: str, leadership_names: list[str], api_key: str
|
||||
) -> CollectionResult:
|
||||
if not leadership_names:
|
||||
# No names to search USPTO's inventor index with - see the
|
||||
# module docstring for why company-name search doesn't work on
|
||||
# this endpoint at all. Honest empty result, no network call.
|
||||
return CollectionResult(status=SourceStatus.ACTIVE, documents=[], pages_attempted=0)
|
||||
|
||||
documents: list[CollectedDocument] = []
|
||||
seen_app_numbers: set[str] = set()
|
||||
pages_attempted = 0
|
||||
errors: list[str] = []
|
||||
|
||||
async with httpx.AsyncClient(timeout=20) as client:
|
||||
for inventor_name in leadership_names[:_MAX_INVENTOR_SEARCHES]:
|
||||
pages_attempted += 1
|
||||
body = {
|
||||
"q": f'applicationMetaData.inventorBag.inventorNameText:"{inventor_name}"',
|
||||
"pagination": {"limit": _MAX_DOCUMENTS},
|
||||
"sort": [{"field": "applicationMetaData.filingDate", "order": "desc"}],
|
||||
}
|
||||
try:
|
||||
response = await client.post(
|
||||
_SEARCH_URL, json=body, headers={"x-api-key": api_key}
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
errors.append(f"{inventor_name}: {exc}")
|
||||
continue
|
||||
|
||||
if response.status_code == 404:
|
||||
# USPTO returns 404 for "no matching records" rather
|
||||
# than 200 with an empty array - a real, expected
|
||||
# outcome for most names, not a failure.
|
||||
continue
|
||||
if response.status_code >= 400:
|
||||
errors.append(f"{inventor_name}: USPTO API error {response.status_code}")
|
||||
continue
|
||||
|
||||
try:
|
||||
payload = response.json()
|
||||
except ValueError:
|
||||
errors.append(f"{inventor_name}: malformed USPTO response")
|
||||
continue
|
||||
|
||||
entries = payload.get("patentFileWrapperDataBag") or payload.get("results") or []
|
||||
for entry in entries:
|
||||
metadata = entry.get("applicationMetaData") or {}
|
||||
app_number = entry.get("applicationNumberText") or entry.get(
|
||||
"applicationNumber"
|
||||
)
|
||||
if not app_number or app_number in seen_app_numbers:
|
||||
continue
|
||||
seen_app_numbers.add(app_number)
|
||||
|
||||
title = metadata.get("inventionTitle") or "Untitled patent filing"
|
||||
filing_date = metadata.get("filingDate")
|
||||
abstract = metadata.get("abstractText") or ""
|
||||
text = normalize_whitespace(
|
||||
f"{title}\n\nInventor match: {inventor_name} (leadership-name "
|
||||
f"heuristic, not a verified {company_name} assignee - USPTO's "
|
||||
"application search has no queryable assignee/company field).\n\n"
|
||||
f"{abstract}"
|
||||
)
|
||||
documents.append(
|
||||
CollectedDocument(
|
||||
url=(
|
||||
f"{_SEARCH_URL}?applicationNumber={app_number}"
|
||||
if app_number
|
||||
else _SEARCH_URL
|
||||
),
|
||||
canonical_url=_SEARCH_URL,
|
||||
title=title,
|
||||
author=inventor_name,
|
||||
publication_date=(
|
||||
datetime.fromisoformat(filing_date).replace(tzinfo=UTC)
|
||||
if filing_date
|
||||
else None
|
||||
),
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={
|
||||
"application_number": app_number,
|
||||
"data_source": "uspto_odp",
|
||||
"matched_inventor_name": inventor_name,
|
||||
"match_type": "leadership_name_heuristic",
|
||||
},
|
||||
extraction_method="uspto_odp_api",
|
||||
http_status=response.status_code,
|
||||
# Lower than a verified-assignee match would be
|
||||
# (was 0.9) - this is a heuristic name match,
|
||||
# not confirmed company ownership.
|
||||
trust_score=0.5,
|
||||
)
|
||||
)
|
||||
if len(documents) >= _MAX_DOCUMENTS:
|
||||
break
|
||||
|
||||
if errors and not documents:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED,
|
||||
error="; ".join(errors[:3]),
|
||||
pages_attempted=pages_attempted,
|
||||
)
|
||||
return CollectionResult(
|
||||
status=SourceStatus.ACTIVE, documents=documents, pages_attempted=pages_attempted
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Maps SourceType -> collector instance. The single place Phase 5's Celery
|
||||
task (and this phase's tests) resolve a collector from a Source row."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.collectors.base import SourceCollector
|
||||
from app.collectors.custom_url import CustomUrlCollector
|
||||
from app.collectors.github import GithubCollector
|
||||
from app.collectors.gov_contracts import GovContractCollector
|
||||
from app.collectors.jobs import JobPostingCollector
|
||||
from app.collectors.patents import PatentSourceCollector
|
||||
from app.collectors.reviews import ReviewSourceCollector
|
||||
from app.collectors.rss import RssCollector
|
||||
from app.collectors.sec_edgar import SecEdgarCollector
|
||||
from app.collectors.website import WebsiteCollector
|
||||
from app.models.enums import SourceType
|
||||
|
||||
_COLLECTORS: dict[SourceType, SourceCollector] = {
|
||||
SourceType.WEBSITE: WebsiteCollector(),
|
||||
SourceType.RSS: RssCollector(),
|
||||
SourceType.CUSTOM_URL: CustomUrlCollector(),
|
||||
SourceType.SEC_EDGAR: SecEdgarCollector(),
|
||||
SourceType.GITHUB: GithubCollector(),
|
||||
SourceType.JOB_POSTING: JobPostingCollector(),
|
||||
SourceType.PATENT: PatentSourceCollector(),
|
||||
SourceType.REVIEW: ReviewSourceCollector(),
|
||||
SourceType.GOV_CONTRACT: GovContractCollector(),
|
||||
}
|
||||
|
||||
|
||||
def get_collector(source_type: SourceType) -> SourceCollector:
|
||||
return _COLLECTORS[source_type]
|
||||
|
||||
|
||||
def all_collectors() -> list[SourceCollector]:
|
||||
return list(_COLLECTORS.values())
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Customer review source collector.
|
||||
|
||||
Most review platforms (G2, Trustpilot, Glassdoor, etc.) either prohibit
|
||||
automated scraping in their terms or require a paid API. This collector
|
||||
implements the `SourceCollector` interface and a documented fixture adapter
|
||||
for local development/testing; it never scrapes a review site directly and
|
||||
never fabricates review data when no permitted live provider is configured.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
_FIXTURES_DIR = Path(__file__).resolve().parents[2] / "tests" / "fixtures" / "reviews"
|
||||
|
||||
|
||||
class ReviewSourceCollector:
|
||||
source_type = SourceType.REVIEW
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
return []
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
fixture_key = source.configuration_metadata.get("fixture_key")
|
||||
if not fixture_key:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.DISABLED,
|
||||
error=(
|
||||
"No review data provider is configured. Most review platforms prohibit "
|
||||
"automated scraping in their terms; this collector implements the "
|
||||
"SourceCollector interface for a future permitted API integration but "
|
||||
"does not fabricate results without one."
|
||||
),
|
||||
)
|
||||
|
||||
fixture_path = _FIXTURES_DIR / f"{fixture_key}.json"
|
||||
if not fixture_path.exists():
|
||||
return CollectionResult(
|
||||
status=SourceStatus.DISABLED,
|
||||
error=f"No fixture found for {fixture_key!r} and no live provider is configured.",
|
||||
)
|
||||
|
||||
payload = json.loads(fixture_path.read_text(encoding="utf-8"))
|
||||
documents: list[CollectedDocument] = []
|
||||
for entry in payload.get("reviews", []):
|
||||
text = normalize_whitespace(
|
||||
f"Rating: {entry.get('rating', 'n/a')}/5\n\n{entry.get('body', '')}"
|
||||
)
|
||||
documents.append(
|
||||
CollectedDocument(
|
||||
url=entry.get("url", fixture_path.as_uri()),
|
||||
canonical_url=entry.get("url", fixture_path.as_uri()),
|
||||
title=entry.get("title") or "Customer review",
|
||||
author=entry.get("author"),
|
||||
publication_date=(
|
||||
datetime.fromisoformat(entry["date"]).replace(tzinfo=UTC)
|
||||
if entry.get("date")
|
||||
else None
|
||||
),
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={
|
||||
"is_fixture": True,
|
||||
"data_source": "fixture",
|
||||
"fixture_key": fixture_key,
|
||||
"rating": entry.get("rating"),
|
||||
},
|
||||
extraction_method="fixture",
|
||||
trust_score=0.4,
|
||||
)
|
||||
)
|
||||
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||
@@ -0,0 +1,54 @@
|
||||
"""robots.txt compliance check - see SECURITY.md rule 1."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.robotparser import RobotFileParser
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.http import SsrfBlockedError, safe_fetch
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_CACHE_TTL_SECONDS = 3600
|
||||
_cache: dict[str, tuple[float, RobotFileParser]] = {}
|
||||
|
||||
|
||||
async def _get_parser(base_url: str, settings: Settings) -> RobotFileParser:
|
||||
parsed = urlparse(base_url)
|
||||
origin = f"{parsed.scheme}://{parsed.netloc}"
|
||||
cached = _cache.get(origin)
|
||||
now = time.monotonic()
|
||||
if cached and now - cached[0] < _CACHE_TTL_SECONDS:
|
||||
return cached[1]
|
||||
|
||||
parser = RobotFileParser()
|
||||
robots_url = urljoin(origin, "/robots.txt")
|
||||
try:
|
||||
result = await safe_fetch(robots_url, settings=settings)
|
||||
if result.status_code == 200:
|
||||
parser.parse(result.text.splitlines())
|
||||
else:
|
||||
# No robots.txt or inaccessible -> "allow all" per convention.
|
||||
parser.parse([])
|
||||
except SsrfBlockedError:
|
||||
parser.parse([])
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.warning("robots_txt_fetch_failed", url=robots_url, error=str(exc))
|
||||
parser.parse([])
|
||||
|
||||
_cache[origin] = (now, parser)
|
||||
return parser
|
||||
|
||||
|
||||
async def is_allowed(url: str, *, settings: Settings | None = None) -> bool:
|
||||
settings = settings or get_settings()
|
||||
parser = await _get_parser(url, settings)
|
||||
return parser.can_fetch(settings.scraper_user_agent, url)
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""Test helper - the module-level cache would otherwise leak between tests."""
|
||||
_cache.clear()
|
||||
@@ -0,0 +1,111 @@
|
||||
"""RSS/Atom feed collector."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time as time_module
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import quote
|
||||
|
||||
import feedparser
|
||||
import httpx
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import canonicalize_url, compute_content_hash, normalize_whitespace
|
||||
from app.core.config import get_settings
|
||||
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||
from app.core.logging import get_logger
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class RssCollector:
|
||||
source_type = SourceType.RSS
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
# Google News' search RSS endpoint needs no API key and reliably
|
||||
# exists for any query - unlike a company's own press-room feed
|
||||
# (which would need a search provider to locate), this one URL
|
||||
# formula works for every company and already aggregates wire-
|
||||
# service releases (PRNewswire/BusinessWire/GlobeNewswire) as
|
||||
# they're published, so a dedicated wire-specific collector isn't
|
||||
# needed on top of it. Users can still add any other feed manually
|
||||
# (see custom_url.py's sibling "add any public URL" path).
|
||||
query_url = (
|
||||
f"https://news.google.com/rss/search?q={quote(company.name)}&hl=en-US&gl=US&ceid=US:en"
|
||||
)
|
||||
return [
|
||||
DiscoveredSource(
|
||||
source_type=SourceType.RSS,
|
||||
name=f"{company.name} — Google News",
|
||||
base_url=query_url,
|
||||
)
|
||||
]
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
if not source.base_url:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error="No feed URL configured")
|
||||
|
||||
settings = get_settings()
|
||||
try:
|
||||
result = await fetch_with_retries(source.base_url, settings=settings)
|
||||
except SsrfBlockedError as exc:
|
||||
return CollectionResult(status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc))
|
||||
except (FetchError, httpx.HTTPError) as exc:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error=str(exc))
|
||||
|
||||
if result.status_code >= 400:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED, error=f"HTTP {result.status_code}", pages_attempted=1
|
||||
)
|
||||
|
||||
parsed = feedparser.parse(result.content)
|
||||
if parsed.bozo and not parsed.entries:
|
||||
return CollectionResult(
|
||||
status=SourceStatus.FAILED,
|
||||
error=str(parsed.get("bozo_exception", "Unparseable feed")),
|
||||
pages_attempted=1,
|
||||
)
|
||||
|
||||
max_items = source.configuration_metadata.get("max_items", 20)
|
||||
documents: list[CollectedDocument] = []
|
||||
for entry in parsed.entries[:max_items]:
|
||||
link = entry.get("link")
|
||||
if not link:
|
||||
continue
|
||||
summary = entry.get("summary", "") or entry.get("description", "")
|
||||
text = normalize_whitespace(f"{entry.get('title', '')}\n\n{summary}")
|
||||
if not text:
|
||||
continue
|
||||
|
||||
pub_date = None
|
||||
if entry.get("published_parsed"):
|
||||
pub_date = datetime.fromtimestamp(
|
||||
time_module.mktime(entry.published_parsed), tz=UTC
|
||||
)
|
||||
|
||||
documents.append(
|
||||
CollectedDocument(
|
||||
url=link,
|
||||
canonical_url=canonicalize_url(link),
|
||||
title=entry.get("title"),
|
||||
author=entry.get("author"),
|
||||
publication_date=pub_date,
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={"feed_url": source.base_url},
|
||||
extraction_method="feedparser",
|
||||
http_status=result.status_code,
|
||||
trust_score=0.6,
|
||||
)
|
||||
)
|
||||
|
||||
status = SourceStatus.ACTIVE if documents else SourceStatus.FAILED
|
||||
return CollectionResult(status=status, documents=documents, pages_attempted=1)
|
||||
@@ -0,0 +1,167 @@
|
||||
"""SEC EDGAR collector for US public companies.
|
||||
|
||||
No API key required, but SEC asks that callers identify themselves with a
|
||||
descriptive User-Agent (see `SCRAPER_USER_AGENT` in .env.example) and stay
|
||||
within its rate limits - the shared `safe_fetch` per-domain delay covers
|
||||
that. We store filing *metadata* (form type, date, accession number, link)
|
||||
rather than parsing full filing bodies, which is out of scope for this pass.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import httpx
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import compute_content_hash, normalize_whitespace
|
||||
from app.core.config import get_settings
|
||||
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||
from app.core.logging import get_logger
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_RELEVANT_FORMS = {"10-K", "10-Q", "8-K"}
|
||||
_SEARCH_URL = "https://www.sec.gov/cgi-bin/browse-edgar"
|
||||
_SUBMISSIONS_URL = "https://data.sec.gov/submissions/CIK{cik}.json"
|
||||
|
||||
|
||||
class SecEdgarCollector:
|
||||
source_type = SourceType.SEC_EDGAR
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
settings = get_settings()
|
||||
cik = await self._lookup_cik(company.name, settings)
|
||||
if cik is None:
|
||||
return []
|
||||
return [
|
||||
DiscoveredSource(
|
||||
source_type=SourceType.SEC_EDGAR,
|
||||
name=f"{company.name} — SEC EDGAR filings",
|
||||
base_url=f"https://www.sec.gov/cgi-bin/browse-edgar?action=getcompany&CIK={cik}",
|
||||
configuration_metadata={"cik": cik},
|
||||
)
|
||||
]
|
||||
|
||||
async def _lookup_cik(self, company_name: str, settings) -> str | None:
|
||||
params = {
|
||||
"action": "getcompany",
|
||||
"company": company_name,
|
||||
"type": "10-K",
|
||||
"dateb": "",
|
||||
"owner": "include",
|
||||
"count": "5",
|
||||
"output": "atom",
|
||||
}
|
||||
url = str(httpx.URL(_SEARCH_URL, params=params))
|
||||
try:
|
||||
result = await fetch_with_retries(url, settings=settings, max_attempts=2)
|
||||
except (SsrfBlockedError, FetchError, httpx.HTTPError) as exc:
|
||||
logger.warning("sec_edgar_lookup_failed", company=company_name, error=str(exc))
|
||||
return None
|
||||
if result.status_code != 200:
|
||||
return None
|
||||
try:
|
||||
root = ElementTree.fromstring(result.content)
|
||||
except ElementTree.ParseError:
|
||||
return None
|
||||
|
||||
ns = {"a": "http://www.w3.org/2005/Atom"}
|
||||
for entry in root.findall(".//a:entry", ns):
|
||||
cik_elem = entry.find("a:content", ns)
|
||||
title_elem = entry.find("a:title", ns)
|
||||
if cik_elem is None or title_elem is None:
|
||||
continue
|
||||
# The atom feed embeds "CIK=0000320193" style text in <content>.
|
||||
text = "".join(cik_elem.itertext())
|
||||
if "CIK=" in text:
|
||||
cik = text.split("CIK=")[1].split("&")[0].strip()
|
||||
return cik.zfill(10)
|
||||
return None
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
cik = source.configuration_metadata.get("cik")
|
||||
if not cik:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error="No CIK configured")
|
||||
|
||||
settings = get_settings()
|
||||
url = _SUBMISSIONS_URL.format(cik=str(cik).zfill(10))
|
||||
try:
|
||||
result = await fetch_with_retries(url, settings=settings)
|
||||
except SsrfBlockedError as exc:
|
||||
return CollectionResult(status=SourceStatus.BLOCKED_BY_POLICY, error=str(exc))
|
||||
except (FetchError, httpx.HTTPError) as exc:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error=str(exc))
|
||||
|
||||
if result.status_code == 404:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error="CIK not found on EDGAR")
|
||||
if result.status_code >= 400:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error=f"HTTP {result.status_code}")
|
||||
|
||||
try:
|
||||
payload = json.loads(result.text)
|
||||
except json.JSONDecodeError:
|
||||
return CollectionResult(status=SourceStatus.FAILED, error="Malformed EDGAR response")
|
||||
|
||||
recent = payload.get("filings", {}).get("recent", {})
|
||||
forms = recent.get("form", [])
|
||||
dates = recent.get("filingDate", [])
|
||||
accessions = recent.get("accessionNumber", [])
|
||||
primary_docs = recent.get("primaryDocument", [])
|
||||
company_name = payload.get("name", company.name)
|
||||
|
||||
documents: list[CollectedDocument] = []
|
||||
for i, form in enumerate(forms):
|
||||
if form not in _RELEVANT_FORMS:
|
||||
continue
|
||||
if len(documents) >= 10:
|
||||
break
|
||||
accession = accessions[i].replace("-", "") if i < len(accessions) else ""
|
||||
primary_doc = primary_docs[i] if i < len(primary_docs) else ""
|
||||
filing_date = dates[i] if i < len(dates) else ""
|
||||
filing_url = (
|
||||
f"https://www.sec.gov/Archives/edgar/data/{int(cik)}/{accession}/{primary_doc}"
|
||||
if accession and primary_doc
|
||||
else url
|
||||
)
|
||||
text = normalize_whitespace(
|
||||
f"{company_name} filed a {form} with the SEC on {filing_date}.\n"
|
||||
f"Accession number: {accessions[i] if i < len(accessions) else 'unknown'}.\n"
|
||||
f"Filing document: {filing_url}"
|
||||
)
|
||||
documents.append(
|
||||
CollectedDocument(
|
||||
url=filing_url,
|
||||
canonical_url=filing_url,
|
||||
title=f"{company_name} {form} ({filing_date})",
|
||||
author="SEC EDGAR",
|
||||
publication_date=(
|
||||
datetime.strptime(filing_date, "%Y-%m-%d").replace(tzinfo=UTC)
|
||||
if filing_date
|
||||
else None
|
||||
),
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=compute_content_hash(text),
|
||||
metadata={
|
||||
"form": form,
|
||||
"accession_number": accessions[i] if i < len(accessions) else None,
|
||||
},
|
||||
extraction_method="sec_edgar_metadata",
|
||||
http_status=result.status_code,
|
||||
trust_score=0.95,
|
||||
)
|
||||
)
|
||||
|
||||
# Zero relevant filings isn't a failure - the company may simply have
|
||||
# none in its recent filing history.
|
||||
return CollectionResult(status=SourceStatus.ACTIVE, documents=documents, pages_attempted=1)
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Official website collector: sitemap.xml + heuristic page discovery,
|
||||
robots.txt-respecting, capped crawl depth/page count.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from urllib.parse import urljoin
|
||||
from xml.etree import ElementTree
|
||||
|
||||
import httpx
|
||||
|
||||
from app.collectors.base import (
|
||||
CollectedDocument,
|
||||
CollectionResult,
|
||||
CompanyContext,
|
||||
DiscoveredSource,
|
||||
SourceConfig,
|
||||
)
|
||||
from app.collectors.extraction import (
|
||||
canonicalize_url,
|
||||
compute_content_hash,
|
||||
extract_readable_text,
|
||||
extract_title,
|
||||
)
|
||||
from app.collectors.robots import is_allowed
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.http import FetchError, SsrfBlockedError, fetch_with_retries
|
||||
from app.core.logging import get_logger
|
||||
from app.models.enums import SourceStatus, SourceType
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
HEURISTIC_PATHS = [
|
||||
"",
|
||||
"/about",
|
||||
"/about-us",
|
||||
"/products",
|
||||
"/services",
|
||||
"/news",
|
||||
"/press",
|
||||
"/press-releases",
|
||||
"/careers",
|
||||
"/jobs",
|
||||
"/leadership",
|
||||
"/team",
|
||||
"/investors",
|
||||
"/investor-relations",
|
||||
"/sustainability",
|
||||
"/contact",
|
||||
]
|
||||
|
||||
|
||||
class WebsiteCollector:
|
||||
source_type = SourceType.WEBSITE
|
||||
|
||||
async def discover(self, company: CompanyContext) -> list[DiscoveredSource]:
|
||||
if not company.official_website:
|
||||
return []
|
||||
settings = get_settings()
|
||||
pages = await self._discover_pages(company.official_website, settings)
|
||||
return [
|
||||
DiscoveredSource(
|
||||
source_type=SourceType.WEBSITE,
|
||||
name=f"{company.name} — Official Website",
|
||||
base_url=company.official_website,
|
||||
configuration_metadata={"pages": pages},
|
||||
)
|
||||
]
|
||||
|
||||
async def _discover_pages(self, base_url: str, settings: Settings) -> list[str]:
|
||||
pages: list[str] = []
|
||||
|
||||
sitemap_urls = await self._read_sitemap(base_url, settings)
|
||||
pages.extend(sitemap_urls[: settings.max_pages_per_domain])
|
||||
|
||||
if len(pages) < settings.max_pages_per_domain:
|
||||
for path in HEURISTIC_PATHS:
|
||||
candidate = urljoin(base_url, path)
|
||||
if candidate not in pages:
|
||||
pages.append(candidate)
|
||||
if len(pages) >= settings.max_pages_per_domain:
|
||||
break
|
||||
|
||||
return pages[: settings.max_pages_per_domain]
|
||||
|
||||
async def _read_sitemap(self, base_url: str, settings: Settings) -> list[str]:
|
||||
sitemap_url = urljoin(base_url, "/sitemap.xml")
|
||||
try:
|
||||
result = await fetch_with_retries(sitemap_url, settings=settings, max_attempts=1)
|
||||
except (SsrfBlockedError, FetchError, httpx.HTTPError):
|
||||
return []
|
||||
if result.status_code != 200:
|
||||
return []
|
||||
try:
|
||||
root = ElementTree.fromstring(result.content)
|
||||
except ElementTree.ParseError:
|
||||
return []
|
||||
|
||||
ns = {"sm": "http://www.sitemaps.org/schemas/sitemap/0.9"}
|
||||
urls = [loc.text.strip() for loc in root.findall(".//sm:url/sm:loc", ns) if loc.text]
|
||||
return urls
|
||||
|
||||
async def collect(self, source: SourceConfig, company: CompanyContext) -> CollectionResult:
|
||||
settings = get_settings()
|
||||
pages: list[str] = source.configuration_metadata.get("pages") or (
|
||||
[source.base_url] if source.base_url else []
|
||||
)
|
||||
pages = pages[: settings.max_pages_per_domain]
|
||||
|
||||
documents: list[CollectedDocument] = []
|
||||
seen_hashes: set[str] = set()
|
||||
attempted = 0
|
||||
any_success = False
|
||||
last_error: str | None = None
|
||||
|
||||
for page_url in pages:
|
||||
attempted += 1
|
||||
try:
|
||||
if not await is_allowed(page_url, settings=settings):
|
||||
logger.info("website_collector_robots_disallowed", url=page_url)
|
||||
continue
|
||||
|
||||
result = await fetch_with_retries(page_url, settings=settings)
|
||||
if result.status_code == 401 or result.status_code == 403:
|
||||
last_error = f"HTTP {result.status_code} (auth required) for {page_url}"
|
||||
continue
|
||||
if result.status_code >= 400:
|
||||
last_error = f"HTTP {result.status_code} for {page_url}"
|
||||
continue
|
||||
|
||||
text, method = extract_readable_text(result.text, page_url)
|
||||
if not text:
|
||||
continue
|
||||
content_hash = compute_content_hash(text)
|
||||
if content_hash in seen_hashes:
|
||||
continue
|
||||
seen_hashes.add(content_hash)
|
||||
|
||||
documents.append(
|
||||
CollectedDocument(
|
||||
url=page_url,
|
||||
canonical_url=canonicalize_url(result.final_url),
|
||||
title=extract_title(result.text),
|
||||
author=None,
|
||||
publication_date=None,
|
||||
retrieved_date=datetime.now(UTC),
|
||||
content_text=text,
|
||||
content_hash=content_hash,
|
||||
metadata={"http_status": result.status_code},
|
||||
extraction_method=method,
|
||||
http_status=result.status_code,
|
||||
trust_score=0.85,
|
||||
)
|
||||
)
|
||||
any_success = True
|
||||
except SsrfBlockedError as exc:
|
||||
last_error = str(exc)
|
||||
logger.warning("website_collector_ssrf_blocked", url=page_url, error=str(exc))
|
||||
except (FetchError, httpx.HTTPError) as exc:
|
||||
last_error = str(exc)
|
||||
logger.warning("website_collector_fetch_failed", url=page_url, error=str(exc))
|
||||
|
||||
if not documents:
|
||||
status = SourceStatus.FAILED if attempted > 0 else SourceStatus.ACTIVE
|
||||
return CollectionResult(
|
||||
status=status, documents=[], error=last_error, pages_attempted=attempted
|
||||
)
|
||||
|
||||
status = SourceStatus.ACTIVE if any_success else SourceStatus.FAILED
|
||||
return CollectionResult(
|
||||
status=status,
|
||||
documents=documents,
|
||||
error=last_error if not any_success else None,
|
||||
pages_attempted=attempted,
|
||||
)
|
||||
@@ -0,0 +1,178 @@
|
||||
"""Centralized application configuration.
|
||||
|
||||
Every environment-dependent value is read here, once, via pydantic-settings.
|
||||
Application code should depend on `get_settings()`, never on `os.environ`
|
||||
directly - that's what keeps provider selection (LLM/search/notifications/auth)
|
||||
swappable from a single place.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# --- App ---
|
||||
app_env: Literal["development", "test", "production"] = "development"
|
||||
app_name: str = "CI Agent"
|
||||
frontend_url: str = "http://localhost:3000"
|
||||
backend_url: str = "http://localhost:8000"
|
||||
|
||||
# --- Reverse proxy ---
|
||||
# Empty (default) = trust only the direct TCP connection for client-IP
|
||||
# resolution (app.core.security.get_client_ip) - correct today, no proxy
|
||||
# exists. Set to "CF-Connecting-IP" once deployed behind Cloudflare's
|
||||
# proxy so IP-based localhost detection and the ban/throttle system read
|
||||
# the real visitor IP instead of the proxy's own address. Only ever set
|
||||
# this when it's actually known a trusted proxy sits in front and strips
|
||||
# this header from untrusted clients - see KNOWN_LIMITATIONS.md.
|
||||
trusted_proxy_ip_header: str = ""
|
||||
|
||||
# --- Local-dev convenience ---
|
||||
# Comma-separated extra IPs that `is_localhost` treats as equivalent to
|
||||
# real loopback, on top of 127.0.0.1/::1. Needed because Docker
|
||||
# Desktop's bridge networking means even traffic that originates on the
|
||||
# host machine itself (dev tooling driving a browser against
|
||||
# localhost:3000/8000) arrives at the container from the bridge
|
||||
# gateway address, not literal loopback - so without this,
|
||||
# is_localhost is always false for that traffic, which both re-exposes
|
||||
# the admin-only API-keys/logs boxes' loopback gate and forces the
|
||||
# Turnstile widget to render (a real crash risk for automated browser
|
||||
# tooling - see KNOWN_LIMITATIONS.md). Empty by default (no bypass);
|
||||
# only ever populate this with IPs you know are your own dev host,
|
||||
# never in a real deployment.
|
||||
additional_trusted_local_ips: str = ""
|
||||
|
||||
# --- Auth ---
|
||||
auth_mode: Literal["local", "jwt"] = "local"
|
||||
jwt_secret: str = "dev-only-change-me-32-characters-minimum"
|
||||
jwt_access_token_minutes: int = 15
|
||||
jwt_refresh_token_days: int = 7
|
||||
# Encrypts each user's own stored API keys at rest (app/core/crypto.py)
|
||||
# - a Fernet key (44-char urlsafe-base64). This dev-only default is
|
||||
# fixed/insecure by design (same precedent as jwt_secret above); a real
|
||||
# deployment must set its own via `Fernet.generate_key()`. Rotating
|
||||
# this value makes every already-stored user key undecryptable, so
|
||||
# treat it like any other production secret - never regenerate it
|
||||
# casually once real keys exist.
|
||||
api_key_encryption_secret: str = "_wYtsm3nJ070987snBFp2eWVI5pyC0H9gGFUb6Cy4cQ="
|
||||
|
||||
# --- Database ---
|
||||
database_url: str = "sqlite+aiosqlite:///./ciagent_dev.db"
|
||||
|
||||
# --- Redis / Celery ---
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
celery_task_always_eager: bool = False
|
||||
|
||||
# --- LLM ---
|
||||
llm_provider: Literal["mock", "anthropic", "ollama", "gemini"] = "mock"
|
||||
anthropic_api_key: str = ""
|
||||
anthropic_model: str = "claude-sonnet-5"
|
||||
ollama_base_url: str = "http://localhost:11434"
|
||||
ollama_model: str = "llama3.1"
|
||||
# Gemini has an actual free rate-limited tier (unlike OpenAI's expiring
|
||||
# trial credits), so it's the production option this app ships wired up.
|
||||
gemini_api_key: str = ""
|
||||
gemini_model: str = "gemini-2.0-flash"
|
||||
llm_max_tokens_per_request: int = 4000
|
||||
llm_max_retries: int = 2
|
||||
|
||||
# --- Search ---
|
||||
search_provider: Literal["mock", "brave"] = "mock"
|
||||
brave_search_api_key: str = ""
|
||||
serpapi_api_key: str = ""
|
||||
bing_search_api_key: str = ""
|
||||
|
||||
# --- Patents ---
|
||||
# Free key via account registration at data.uspto.gov/apis/getting-started.
|
||||
# Unset by default - PatentSourceCollector falls back to its existing
|
||||
# honest disabled/fixture behavior when this is empty.
|
||||
uspto_api_key: str = ""
|
||||
|
||||
# --- Company enrichment (NinjaPear / nubela.co) ---
|
||||
# Paid, per-credit API - unset by default. Enrichment only ever fires
|
||||
# once, at company-creation time (never on a recurring schedule), and
|
||||
# the enqueue itself is skipped entirely when this is empty - see
|
||||
# company_service.create_company.
|
||||
ninjapear_api_key: str = ""
|
||||
ninjapear_max_leadership_lookups: int = 5
|
||||
|
||||
# --- Email ---
|
||||
smtp_host: str = "localhost"
|
||||
smtp_port: int = 1025
|
||||
smtp_username: str = ""
|
||||
smtp_password: str = ""
|
||||
smtp_from_email: str = "[email protected]"
|
||||
smtp_use_tls: bool = False
|
||||
|
||||
# --- Resend (transactional security email: verification/reset/lockout) ---
|
||||
# Unset by default - security_email_service falls back to the SMTP
|
||||
# provider above (Mailpit locally) when this is empty, so the whole
|
||||
# verification/reset flow is testable with zero Resend account needed.
|
||||
# Deliberately separate from the alert-notification path (smtp_from_email
|
||||
# above) - a different sender identity for account-security mail.
|
||||
resend_api_key: str = ""
|
||||
resend_security_from_email: str = "[email protected]"
|
||||
|
||||
# --- Cloudflare Turnstile (CAPTCHA on register/login/password-reset) ---
|
||||
# Unset by default - skipped entirely for register/login/password-reset
|
||||
# when either the caller is on loopback (see is_localhost) or no secret
|
||||
# is configured, matching this app's usual optional-provider convention.
|
||||
turnstile_site_key: str = ""
|
||||
turnstile_secret: str = ""
|
||||
|
||||
# --- SMS ---
|
||||
notification_sms_enabled: bool = False
|
||||
sms_provider: Literal["twilio", "telnyx"] = "twilio"
|
||||
twilio_account_sid: str = ""
|
||||
twilio_auth_token: str = ""
|
||||
twilio_from_number: str = ""
|
||||
telnyx_api_key: str = ""
|
||||
telnyx_from_number: str = ""
|
||||
sms_monthly_cap: int = 50
|
||||
|
||||
# --- GitHub ---
|
||||
github_token: str = ""
|
||||
|
||||
# --- Scheduling ---
|
||||
default_timezone: str = "America/New_York"
|
||||
default_monitoring_frequency: str = "weekly"
|
||||
minimum_monitoring_interval_minutes: int = 60
|
||||
|
||||
# --- Scraper ---
|
||||
scraper_user_agent: str = "CIAgentBot/1.0 (+https://ci-agent.local/bot)"
|
||||
max_pages_per_domain: int = 25
|
||||
scraper_request_timeout_seconds: int = 30
|
||||
scraper_domain_delay_seconds: float = 2.0
|
||||
|
||||
# --- Cost controls ---
|
||||
max_companies_per_user: int = 25
|
||||
max_manual_runs_per_day: int = 10
|
||||
|
||||
# --- Retention / logging ---
|
||||
data_retention_days: int = 365
|
||||
log_level: str = "INFO"
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
return self.app_env == "production"
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _forbid_local_auth_in_production(self) -> Settings:
|
||||
if self.app_env == "production" and self.auth_mode == "local":
|
||||
raise ValueError(
|
||||
"AUTH_MODE=local is a development convenience and must not be used "
|
||||
"when APP_ENV=production. Set AUTH_MODE=jwt."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Symmetric encryption for secrets stored at rest - currently just
|
||||
per-user API keys (app/models/user_api_key.py). Not used for passwords
|
||||
(those are one-way hashed via app.core.security, never decrypted) - this
|
||||
is specifically for secrets the app must later read back out in plaintext
|
||||
to actually call a third-party API on the user's behalf.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
|
||||
def encrypt_secret(plaintext: str, settings: Settings) -> str:
|
||||
return Fernet(settings.api_key_encryption_secret).encrypt(plaintext.encode()).decode()
|
||||
|
||||
|
||||
def decrypt_secret(ciphertext: str, settings: Settings) -> str:
|
||||
try:
|
||||
return Fernet(settings.api_key_encryption_secret).decrypt(ciphertext.encode()).decode()
|
||||
except InvalidToken as exc:
|
||||
# Only real cause in practice: api_key_encryption_secret was
|
||||
# rotated after this value was encrypted under the old one.
|
||||
raise ValueError("Stored value cannot be decrypted with the current key") from exc
|
||||
@@ -0,0 +1,46 @@
|
||||
"""App-level exceptions, mapped to HTTP responses in one place (main.py).
|
||||
|
||||
Services raise these instead of `fastapi.HTTPException` so business logic
|
||||
stays importable/testable from Celery tasks, which don't have an HTTP
|
||||
response to raise into.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Base class for all app-level errors."""
|
||||
|
||||
|
||||
class NotFoundError(AppError):
|
||||
pass
|
||||
|
||||
|
||||
class ConflictError(AppError):
|
||||
pass
|
||||
|
||||
|
||||
class AuthenticationError(AppError):
|
||||
pass
|
||||
|
||||
|
||||
class ForbiddenError(AppError):
|
||||
pass
|
||||
|
||||
|
||||
class ValidationAppError(AppError):
|
||||
pass
|
||||
|
||||
|
||||
class RateLimitedError(AppError):
|
||||
pass
|
||||
|
||||
|
||||
class ThrottledError(RateLimitedError):
|
||||
"""Raised by the IP throttle/ban engine (app/services/ip_throttle_service.py)
|
||||
- carries a machine-readable retry_after_seconds so the frontend can
|
||||
drive a live countdown instead of just showing a generic message."""
|
||||
|
||||
def __init__(self, message: str, retry_after_seconds: int | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after_seconds = retry_after_seconds
|
||||
@@ -0,0 +1,176 @@
|
||||
"""SSRF-safe HTTP fetching. Every collector and the custom-URL feature must
|
||||
route network requests through `safe_fetch` / `fetch_with_retries` - see
|
||||
SECURITY.md for the full threat model this defends against.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
import socket
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin, urlparse
|
||||
|
||||
import httpx
|
||||
import tenacity
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.core.logging import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
_ALLOWED_SCHEMES = {"http", "https"}
|
||||
_MAX_REDIRECTS = 5
|
||||
_METADATA_IPS = {"169.254.169.254", "fd00:ec2::254"}
|
||||
|
||||
# Per-hostname request spacing. In-process only - a multi-worker Celery
|
||||
# deployment would need a shared store (e.g. Redis) for this to be a true
|
||||
# global rate limit across workers; see KNOWN_LIMITATIONS.md.
|
||||
_last_request_at: dict[str, float] = {}
|
||||
_domain_locks: dict[str, asyncio.Lock] = {}
|
||||
|
||||
|
||||
class SsrfBlockedError(Exception):
|
||||
"""Raised when a URL resolves to, or points at, a disallowed network target."""
|
||||
|
||||
|
||||
class FetchError(Exception):
|
||||
"""Raised for network-level failures after retries are exhausted."""
|
||||
|
||||
|
||||
def _is_blocked_ip(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
||||
return (
|
||||
ip.is_private
|
||||
or ip.is_loopback
|
||||
or ip.is_link_local
|
||||
or ip.is_multicast
|
||||
or ip.is_reserved
|
||||
or ip.is_unspecified
|
||||
or str(ip) in _METADATA_IPS
|
||||
)
|
||||
|
||||
|
||||
def _resolve_and_validate(hostname: str) -> None:
|
||||
try:
|
||||
infos = socket.getaddrinfo(hostname, None)
|
||||
except socket.gaierror as exc:
|
||||
raise SsrfBlockedError(f"Could not resolve host: {hostname}") from exc
|
||||
|
||||
if not infos:
|
||||
raise SsrfBlockedError(f"Could not resolve host: {hostname}")
|
||||
|
||||
for info in infos:
|
||||
raw_ip = info[4][0]
|
||||
ip = ipaddress.ip_address(raw_ip.split("%")[0])
|
||||
if _is_blocked_ip(ip):
|
||||
raise SsrfBlockedError(f"Resolved address for {hostname} is not a public address: {ip}")
|
||||
|
||||
|
||||
def validate_url(url: str) -> str:
|
||||
"""Raises SsrfBlockedError if `url` is unsafe to fetch. Returns the
|
||||
hostname."""
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme not in _ALLOWED_SCHEMES:
|
||||
raise SsrfBlockedError(f"Unsupported URL scheme: {parsed.scheme!r}")
|
||||
if not parsed.hostname:
|
||||
raise SsrfBlockedError("URL has no hostname")
|
||||
_resolve_and_validate(parsed.hostname)
|
||||
return parsed.hostname
|
||||
|
||||
|
||||
async def _respect_domain_delay(hostname: str, delay_seconds: float) -> None:
|
||||
if delay_seconds <= 0:
|
||||
return
|
||||
lock = _domain_locks.setdefault(hostname, asyncio.Lock())
|
||||
async with lock:
|
||||
now = time.monotonic()
|
||||
last = _last_request_at.get(hostname)
|
||||
if last is not None:
|
||||
elapsed = now - last
|
||||
if elapsed < delay_seconds:
|
||||
await asyncio.sleep(delay_seconds - elapsed)
|
||||
_last_request_at[hostname] = time.monotonic()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SafeFetchResult:
|
||||
status_code: int
|
||||
text: str
|
||||
content: bytes
|
||||
headers: dict[str, str]
|
||||
final_url: str
|
||||
|
||||
|
||||
async def safe_fetch(
|
||||
url: str,
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
method: str = "GET",
|
||||
max_redirects: int = _MAX_REDIRECTS,
|
||||
extra_headers: dict[str, str] | None = None,
|
||||
) -> SafeFetchResult:
|
||||
"""Fetch `url` with SSRF validation applied to the initial URL and every
|
||||
redirect hop. Never follows a redirect without re-validating it."""
|
||||
settings = settings or get_settings()
|
||||
current_url = url
|
||||
|
||||
for _ in range(max_redirects + 1):
|
||||
hostname = validate_url(current_url)
|
||||
await _respect_domain_delay(hostname, settings.scraper_domain_delay_seconds)
|
||||
|
||||
headers = {"User-Agent": settings.scraper_user_agent, **(extra_headers or {})}
|
||||
async with httpx.AsyncClient(
|
||||
follow_redirects=False,
|
||||
timeout=settings.scraper_request_timeout_seconds,
|
||||
headers=headers,
|
||||
) as client:
|
||||
response = await client.request(method, current_url)
|
||||
|
||||
if response.status_code in (301, 302, 303, 307, 308) and "location" in response.headers:
|
||||
current_url = urljoin(current_url, response.headers["location"])
|
||||
continue
|
||||
|
||||
return SafeFetchResult(
|
||||
status_code=response.status_code,
|
||||
text=response.text,
|
||||
content=response.content,
|
||||
headers=dict(response.headers),
|
||||
final_url=current_url,
|
||||
)
|
||||
|
||||
raise SsrfBlockedError(f"Too many redirects starting from {url}")
|
||||
|
||||
|
||||
def _is_retryable(exc: BaseException) -> bool:
|
||||
if isinstance(exc, SsrfBlockedError):
|
||||
return False
|
||||
if isinstance(exc, httpx.HTTPError):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
async def fetch_with_retries(
|
||||
url: str,
|
||||
*,
|
||||
settings: Settings | None = None,
|
||||
max_attempts: int = 3,
|
||||
**kwargs,
|
||||
) -> SafeFetchResult:
|
||||
"""`safe_fetch` wrapped with exponential-backoff retry for transient
|
||||
network errors only - SSRF blocks and 4xx responses are not retried."""
|
||||
settings = settings or get_settings()
|
||||
|
||||
async for attempt in tenacity.AsyncRetrying(
|
||||
stop=tenacity.stop_after_attempt(max_attempts),
|
||||
wait=tenacity.wait_exponential(multiplier=1, min=1, max=10),
|
||||
retry=tenacity.retry_if_exception(_is_retryable),
|
||||
reraise=True,
|
||||
):
|
||||
with attempt:
|
||||
result = await safe_fetch(url, settings=settings, **kwargs)
|
||||
if result.status_code >= 500:
|
||||
raise FetchError(f"Server error {result.status_code} fetching {url}")
|
||||
return result
|
||||
|
||||
raise FetchError(f"Exhausted retries fetching {url}") # pragma: no cover
|
||||
@@ -0,0 +1,149 @@
|
||||
"""Structured logging setup with secret redaction, plus a capped live-log
|
||||
feed for the Settings UI.
|
||||
|
||||
Never log raw credentials. Any log event field whose key looks secret-shaped
|
||||
gets its value replaced before it leaves the process.
|
||||
|
||||
Logs also get pushed to a capped Redis list (`app:logs`) so the Settings
|
||||
page can show a live feed of what the application is doing - stdout/Docker
|
||||
logs alone aren't visible from the UI, and this app runs as several
|
||||
separate processes (API, Celery worker, beat), so Redis (already shared
|
||||
infra across all of them) is the simplest common sink. A sink failure here
|
||||
must never break the actual log call or crash the app - every Redis
|
||||
operation is wrapped and swallowed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import structlog
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.core.config import Settings
|
||||
|
||||
_SECRET_KEY_PATTERN = re.compile(
|
||||
r"(key|secret|token|password|authorization|credential)", re.IGNORECASE
|
||||
)
|
||||
_REDACTED = "***REDACTED***"
|
||||
|
||||
LOG_STREAM_KEY = "app:logs"
|
||||
LOG_STREAM_MAXLEN = 500
|
||||
|
||||
LOG_CATEGORY_LABELS = {
|
||||
"internal_error": "Internal error",
|
||||
"api_error": "API error",
|
||||
"important": "Important",
|
||||
"normal": "Normal",
|
||||
}
|
||||
|
||||
_NON_CONTEXT_KEYS = {"level", "timestamp", "logger", "event", "important"}
|
||||
|
||||
_sync_redis_client: Any = None
|
||||
|
||||
|
||||
def _redact_secrets(_logger: Any, _method_name: str, event_dict: dict) -> dict:
|
||||
for key in list(event_dict.keys()):
|
||||
if _SECRET_KEY_PATTERN.search(key):
|
||||
event_dict[key] = _REDACTED
|
||||
return event_dict
|
||||
|
||||
|
||||
def _category_for(level: str, event_dict: dict) -> str:
|
||||
if level in ("error", "critical"):
|
||||
return "internal_error"
|
||||
if level == "warning":
|
||||
return "api_error"
|
||||
if level == "info" and event_dict.get("important") is True:
|
||||
return "important"
|
||||
return "normal"
|
||||
|
||||
|
||||
def _get_sync_redis_client(redis_url: str):
|
||||
global _sync_redis_client
|
||||
if _sync_redis_client is None:
|
||||
import redis as redis_sync
|
||||
|
||||
_sync_redis_client = redis_sync.Redis.from_url(redis_url)
|
||||
return _sync_redis_client
|
||||
|
||||
|
||||
def _make_capture_processor(redis_url: str):
|
||||
def _capture_for_ui(_logger: Any, method_name: str, event_dict: dict) -> dict:
|
||||
level = event_dict.get("level", method_name)
|
||||
if level == "debug":
|
||||
return event_dict
|
||||
try:
|
||||
record = {
|
||||
"ts": event_dict.get("timestamp") or datetime.now(UTC).isoformat(),
|
||||
"level": level,
|
||||
"category": _category_for(level, event_dict),
|
||||
"logger": event_dict.get("logger", "app"),
|
||||
"event": str(event_dict.get("event", "")),
|
||||
"context": {k: v for k, v in event_dict.items() if k not in _NON_CONTEXT_KEYS},
|
||||
}
|
||||
client = _get_sync_redis_client(redis_url)
|
||||
pipe = client.pipeline()
|
||||
pipe.lpush(LOG_STREAM_KEY, json.dumps(record, default=str))
|
||||
pipe.ltrim(LOG_STREAM_KEY, 0, LOG_STREAM_MAXLEN - 1)
|
||||
pipe.execute()
|
||||
except Exception: # noqa: BLE001 - a broken log sink must never break the app
|
||||
pass
|
||||
return event_dict
|
||||
|
||||
return _capture_for_ui
|
||||
|
||||
|
||||
def configure_logging(settings: Settings) -> None:
|
||||
logging.basicConfig(level=settings.log_level, format="%(message)s")
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.stdlib.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
_redact_secrets,
|
||||
_make_capture_processor(settings.redis_url),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.processors.JSONRenderer(),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(
|
||||
logging.getLevelName(settings.log_level)
|
||||
),
|
||||
context_class=dict,
|
||||
logger_factory=structlog.PrintLoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str) -> structlog.stdlib.BoundLogger:
|
||||
# `.bind(logger=name)` (rather than `structlog.stdlib.add_logger_name`,
|
||||
# which only works with `structlog.stdlib.LoggerFactory`) is what makes
|
||||
# the module name available to `_capture_for_ui` below - this app uses
|
||||
# `PrintLoggerFactory`, whose loggers have no `.name` attribute.
|
||||
return structlog.get_logger().bind(logger=name)
|
||||
|
||||
|
||||
async def get_recent_logs(settings: Settings, limit: int = 100) -> list[dict]:
|
||||
"""Most-recent-first log entries from the capped Redis feed, for the
|
||||
Settings page's Logging box."""
|
||||
import redis.asyncio as redis_asyncio
|
||||
|
||||
client = redis_asyncio.from_url(settings.redis_url)
|
||||
try:
|
||||
raw_entries = await client.lrange(LOG_STREAM_KEY, 0, limit - 1)
|
||||
finally:
|
||||
await client.aclose()
|
||||
|
||||
entries: list[dict] = []
|
||||
for raw in raw_entries:
|
||||
try:
|
||||
entries.append(json.loads(raw))
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
return entries
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Shared rate limiter (slowapi / limits, in-memory by default).
|
||||
|
||||
Applied per-route via `@limiter.limit(...)`. Auth endpoints get the
|
||||
tightest limits since they're the classic credential-stuffing target.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Request
|
||||
from slowapi import Limiter
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.security import get_client_ip
|
||||
|
||||
|
||||
def _client_ip_key(request: Request) -> str:
|
||||
"""Same IP resolution as everything else in the app (is_localhost, the
|
||||
ban/throttle engine) - slowapi's own get_remote_address reads
|
||||
request.client.host directly, which would be Nginx/Cloudflare's own
|
||||
address for every visitor once deployed behind a reverse proxy,
|
||||
collapsing all rate limits into one shared bucket. See
|
||||
app.core.security.get_client_ip / Settings.trusted_proxy_ip_header."""
|
||||
return get_client_ip(request, get_settings())
|
||||
|
||||
|
||||
# Disabled under APP_ENV=test so the many auth calls a test suite makes don't
|
||||
# trip real limits (real limiter behavior is covered by its own test).
|
||||
limiter = Limiter(key_func=_client_ip_key, enabled=get_settings().app_env != "test")
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Password hashing and JWT helpers.
|
||||
|
||||
Password hashing uses Argon2 (via `argon2-cffi`) directly - it's the
|
||||
currently recommended default and needs no extra abstraction layer.
|
||||
JWTs are signed with `JWT_SECRET` (HS256); access tokens are short-lived,
|
||||
refresh tokens are long-lived but stored server-side only as a hash so a
|
||||
leaked DB row can't be replayed as a valid token by itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from enum import StrEnum
|
||||
|
||||
import jwt
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import VerifyMismatchError
|
||||
from fastapi import Request
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def get_client_ip(request: Request, settings: Settings) -> str:
|
||||
"""The single source of truth for "what IP is this request from" - used
|
||||
by `is_localhost`, the IP throttle/ban engine, and anywhere else that
|
||||
needs to identify a caller. Today there's no reverse proxy in front of
|
||||
uvicorn in this stack, so the direct TCP peer (`request.client.host`) is
|
||||
the real originating address.
|
||||
|
||||
Once deployed behind Cloudflare (or Nginx), the direct peer becomes the
|
||||
proxy itself, not the visitor - `settings.trusted_proxy_ip_header` (e.g.
|
||||
"CF-Connecting-IP") switches this to read the real address from that
|
||||
header instead. Only ever set this when it's actually known the proxy is
|
||||
in front and stripping/overwriting that header from untrusted clients -
|
||||
otherwise a client could simply forge it to spoof any IP. Left empty by
|
||||
default (trust the direct connection only) - see KNOWN_LIMITATIONS.md."""
|
||||
header_name = settings.trusted_proxy_ip_header
|
||||
if header_name:
|
||||
forwarded = request.headers.get(header_name)
|
||||
if forwarded:
|
||||
return forwarded.strip()
|
||||
return request.client.host if request.client is not None else "unknown"
|
||||
|
||||
|
||||
def is_localhost(request: Request, settings: Settings) -> bool:
|
||||
"""True when the request's resolved client IP (see `get_client_ip`) is
|
||||
this machine's loopback address - not merely "someone on the LAN" - or
|
||||
is explicitly listed in `settings.additional_trusted_local_ips` (empty
|
||||
by default; a narrow, opt-in escape hatch for Docker Desktop's bridge
|
||||
networking, where even host-originated traffic doesn't arrive as
|
||||
literal loopback - see KNOWN_LIMITATIONS.md)."""
|
||||
client_ip = get_client_ip(request, settings)
|
||||
if client_ip in ("127.0.0.1", "::1"):
|
||||
return True
|
||||
extra = {ip.strip() for ip in settings.additional_trusted_local_ips.split(",") if ip.strip()}
|
||||
return client_ip in extra
|
||||
|
||||
|
||||
def hash_password(raw_password: str) -> str:
|
||||
return _hasher.hash(raw_password)
|
||||
|
||||
|
||||
def verify_password(raw_password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, raw_password)
|
||||
except VerifyMismatchError:
|
||||
return False
|
||||
|
||||
|
||||
class TokenType(StrEnum):
|
||||
ACCESS = "access"
|
||||
REFRESH = "refresh"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecodedToken:
|
||||
user_id: uuid.UUID
|
||||
token_type: TokenType
|
||||
jti: str
|
||||
expires_at: datetime
|
||||
|
||||
|
||||
def create_access_token(user_id: uuid.UUID, settings: Settings) -> str:
|
||||
return _encode_token(
|
||||
user_id, TokenType.ACCESS, timedelta(minutes=settings.jwt_access_token_minutes), settings
|
||||
)
|
||||
|
||||
|
||||
def create_refresh_token(user_id: uuid.UUID, settings: Settings) -> tuple[str, str, datetime]:
|
||||
"""Returns (raw_jwt, jti, expires_at). Caller stores a hash of `jti`, not the JWT itself."""
|
||||
expires_at = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_days)
|
||||
jti = secrets.token_urlsafe(32)
|
||||
token = _encode_token(
|
||||
user_id,
|
||||
TokenType.REFRESH,
|
||||
timedelta(days=settings.jwt_refresh_token_days),
|
||||
settings,
|
||||
jti=jti,
|
||||
)
|
||||
return token, jti, expires_at
|
||||
|
||||
|
||||
def _encode_token(
|
||||
user_id: uuid.UUID,
|
||||
token_type: TokenType,
|
||||
expires_in: timedelta,
|
||||
settings: Settings,
|
||||
jti: str | None = None,
|
||||
) -> str:
|
||||
now = datetime.now(UTC)
|
||||
payload = {
|
||||
"sub": str(user_id),
|
||||
"type": token_type.value,
|
||||
"iat": now,
|
||||
"exp": now + expires_in,
|
||||
"jti": jti or secrets.token_urlsafe(16),
|
||||
}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm="HS256")
|
||||
|
||||
|
||||
class InvalidTokenError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def decode_token(token: str, settings: Settings, expected_type: TokenType) -> DecodedToken:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.jwt_secret, algorithms=["HS256"])
|
||||
except jwt.PyJWTError as exc:
|
||||
raise InvalidTokenError(str(exc)) from exc
|
||||
|
||||
if payload.get("type") != expected_type.value:
|
||||
raise InvalidTokenError(f"Expected a {expected_type.value} token")
|
||||
|
||||
try:
|
||||
user_id = uuid.UUID(payload["sub"])
|
||||
except (KeyError, ValueError) as exc:
|
||||
raise InvalidTokenError("Malformed token subject") from exc
|
||||
|
||||
return DecodedToken(
|
||||
user_id=user_id,
|
||||
token_type=TokenType(payload["type"]),
|
||||
jti=payload["jti"],
|
||||
expires_at=datetime.fromtimestamp(payload["exp"], tz=UTC),
|
||||
)
|
||||
|
||||
|
||||
def hash_token_identifier(jti: str) -> str:
|
||||
"""One-way hash of a refresh token's `jti` for storage/comparison (not the JWT itself)."""
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(jti.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def generate_email_code() -> str:
|
||||
"""A 6-digit numeric code for email verification / password reset -
|
||||
short-lived and IP-throttled (app/services/ip_throttle_service.py), so
|
||||
it doesn't need Argon2's cost the way a password does."""
|
||||
return f"{secrets.randbelow(1_000_000):06d}"
|
||||
|
||||
|
||||
def hash_email_code(code: str) -> str:
|
||||
"""One-way hash of an email code for storage/comparison (never the raw
|
||||
code) - same precedent as hash_token_identifier."""
|
||||
import hashlib
|
||||
|
||||
return hashlib.sha256(code.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Small text utilities with no natural home elsewhere."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
_SLUG_STRIP_RE = re.compile(r"[^a-z0-9]+")
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
value = value.strip().lower()
|
||||
value = _SLUG_STRIP_RE.sub("-", value).strip("-")
|
||||
return value or "company"
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Declarative base + shared mixins for all ORM models."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy import DateTime
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
def ensure_aware_utc(value: datetime) -> datetime:
|
||||
"""SQLite's `DateTime(timezone=True)` silently drops tzinfo on read back
|
||||
(Postgres does not). Anything read from the DB and compared against an
|
||||
aware `datetime.now(UTC)` must go through this first so the app behaves
|
||||
identically on both backends."""
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class UUIDPrimaryKeyMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4, unique=True)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=utcnow, onupdate=utcnow
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Async SQLAlchemy engine/session setup.
|
||||
|
||||
Works against Postgres (`postgresql+psycopg://...`) or SQLite
|
||||
(`sqlite+aiosqlite://...`) depending on `DATABASE_URL` - the same models and
|
||||
repositories run against either, which is what makes the no-Docker local dev
|
||||
path possible.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from functools import lru_cache
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_engine() -> AsyncEngine:
|
||||
settings = get_settings()
|
||||
connect_args = {}
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False}
|
||||
return create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_pre_ping=True,
|
||||
connect_args=connect_args,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(bind=get_engine(), expire_on_commit=False)
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
session_factory = get_sessionmaker()
|
||||
async with session_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Company-enrichment provider interface. Answers "what does a paid,
|
||||
first-party data vendor already know about this company?" - a richer,
|
||||
metered complement to the free SearchProvider/LLM discovery pipeline
|
||||
(app/services/discovery_service.py), never a replacement for it. Every
|
||||
method here maps to one NinjaPear (nubela.co) API endpoint; orchestration
|
||||
(call order, the leadership-lookup cap, partial-failure handling) lives in
|
||||
app/services/enrichment_service.py, not here - this Protocol is a dumb I/O
|
||||
layer, matching app/search/base.py's shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class LeadershipMember(BaseModel):
|
||||
name: str
|
||||
title: str | None = None
|
||||
work_email: str | None = None
|
||||
profile_url: str | None = None
|
||||
bio: str | None = None
|
||||
|
||||
|
||||
class CompanyDetails(BaseModel):
|
||||
description: str | None = None
|
||||
industry: str | None = None
|
||||
founded_year: int | None = None
|
||||
specialties: list[str] = Field(default_factory=list)
|
||||
leadership_team: list[LeadershipMember] = Field(default_factory=list)
|
||||
employee_count_range: str | None = None
|
||||
|
||||
|
||||
class FundingRound(BaseModel):
|
||||
round_name: str | None = None
|
||||
amount: str | None = None
|
||||
date: str | None = None
|
||||
investors: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompanyFunding(BaseModel):
|
||||
total_raised: str | None = None
|
||||
rounds: list[FundingRound] = Field(default_factory=list)
|
||||
|
||||
|
||||
class CompetitorWithReason(BaseModel):
|
||||
name: str
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class Product(BaseModel):
|
||||
name: str
|
||||
description: str | None = None
|
||||
category: str | None = None
|
||||
|
||||
|
||||
class RecentUpdate(BaseModel):
|
||||
type: str
|
||||
text: str
|
||||
url: str | None = None
|
||||
date: str | None = None
|
||||
|
||||
|
||||
class Customer(BaseModel):
|
||||
name: str
|
||||
relationship: str | None = None
|
||||
|
||||
|
||||
class EnrichmentProvider(Protocol):
|
||||
provider_name: str
|
||||
|
||||
async def get_company_details(self, name: str, website: str | None) -> CompanyDetails: ...
|
||||
|
||||
async def get_funding(self, name: str, website: str | None) -> CompanyFunding: ...
|
||||
|
||||
async def get_updates(self, name: str, website: str | None) -> list[RecentUpdate]: ...
|
||||
|
||||
async def get_competitors(
|
||||
self, name: str, website: str | None
|
||||
) -> list[CompetitorWithReason]: ...
|
||||
|
||||
async def get_products(self, name: str, website: str | None) -> list[Product]: ...
|
||||
|
||||
async def get_customers(self, name: str, website: str | None) -> list[Customer]: ...
|
||||
|
||||
async def get_work_email(self, person_name: str, company_website: str) -> str | None: ...
|
||||
|
||||
async def get_person_profile(
|
||||
self, person_name: str, company_website: str
|
||||
) -> tuple[str | None, str | None]:
|
||||
"""Returns (profile_url, bio)."""
|
||||
...
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Resolves NINJAPEAR_API_KEY to a concrete enrichment provider instance.
|
||||
Never imported directly by enrichment_service - always go through
|
||||
`get_enrichment_provider()` so a future second vendor stays a one-line
|
||||
config change, matching app/search/factory.py's shape."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.config import Settings, get_settings
|
||||
from app.enrichment.base import EnrichmentProvider
|
||||
from app.enrichment.mock import MockEnrichmentProvider
|
||||
|
||||
|
||||
def get_enrichment_provider(settings: Settings | None = None) -> EnrichmentProvider:
|
||||
settings = settings or get_settings()
|
||||
|
||||
if settings.ninjapear_api_key:
|
||||
from app.enrichment.ninjapear import NinjaPearProvider
|
||||
|
||||
return NinjaPearProvider(settings)
|
||||
|
||||
return MockEnrichmentProvider()
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Deterministic mock enrichment provider - the default (no
|
||||
`NINJAPEAR_API_KEY` configured) and what every automated test runs against.
|
||||
Never calls a network, never fabricates data it has no basis for: every
|
||||
field comes back empty/`None`, same honesty philosophy as
|
||||
`app/search/mock.py` and `app/collectors/patents.py`'s no-key path. In
|
||||
practice the enrichment task is never even enqueued without a real key
|
||||
(see `company_service.create_company`), so this mostly exists for tests
|
||||
and for direct calls to `enrichment_service.enrich_company`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.enrichment.base import (
|
||||
CompanyDetails,
|
||||
CompanyFunding,
|
||||
CompetitorWithReason,
|
||||
Customer,
|
||||
Product,
|
||||
RecentUpdate,
|
||||
)
|
||||
|
||||
|
||||
class MockEnrichmentProvider:
|
||||
provider_name = "mock"
|
||||
|
||||
async def get_company_details(self, name: str, website: str | None) -> CompanyDetails:
|
||||
return CompanyDetails()
|
||||
|
||||
async def get_funding(self, name: str, website: str | None) -> CompanyFunding:
|
||||
return CompanyFunding()
|
||||
|
||||
async def get_updates(self, name: str, website: str | None) -> list[RecentUpdate]:
|
||||
return []
|
||||
|
||||
async def get_competitors(self, name: str, website: str | None) -> list[CompetitorWithReason]:
|
||||
return []
|
||||
|
||||
async def get_products(self, name: str, website: str | None) -> list[Product]:
|
||||
return []
|
||||
|
||||
async def get_customers(self, name: str, website: str | None) -> list[Customer]:
|
||||
return []
|
||||
|
||||
async def get_work_email(self, person_name: str, company_website: str) -> str | None:
|
||||
return None
|
||||
|
||||
async def get_person_profile(
|
||||
self, person_name: str, company_website: str
|
||||
) -> tuple[str | None, str | None]:
|
||||
return None, None
|
||||
@@ -0,0 +1,213 @@
|
||||
"""NinjaPear (nubela.co) company-enrichment provider. A fixed, trusted,
|
||||
first-party integration endpoint (like Brave/Twilio) - calls httpx
|
||||
directly rather than through `safe_fetch`, which exists specifically to
|
||||
guard arbitrary/user-supplied collector targets, not our own known-safe
|
||||
API integrations (see app/search/brave.py for the same reasoning).
|
||||
|
||||
Endpoint paths, parameters, and response field names below are taken from
|
||||
nubela.co/llms-full.txt (a plain-text API reference, unlike the JS-rendered
|
||||
docs site) and verified live against a real account. Every company-level
|
||||
endpoint identifies the company by `website` only (NinjaPear has no
|
||||
name-based lookup for these calls) - a company with no `official_website`
|
||||
on file cannot be enriched at all, see `_require_website`. Response
|
||||
parsing stays defensive (`.get()` throughout) since a live vendor API can
|
||||
still change shape without notice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.enrichment.base import (
|
||||
CompanyDetails,
|
||||
CompanyFunding,
|
||||
CompetitorWithReason,
|
||||
Customer,
|
||||
FundingRound,
|
||||
LeadershipMember,
|
||||
Product,
|
||||
RecentUpdate,
|
||||
)
|
||||
|
||||
_BASE_URL = "https://nubela.co/api/v1"
|
||||
_DEFAULT_TIMEOUT = 100
|
||||
_FUNDING_TIMEOUT = 300 # documented by NinjaPear as long-running (up to 5 min)
|
||||
|
||||
|
||||
def _require_website(website: str | None) -> str:
|
||||
if not website:
|
||||
raise ValueError(
|
||||
"NinjaPear identifies a company by website only - this company has none on file"
|
||||
)
|
||||
return website
|
||||
|
||||
|
||||
def _domain_from_website(website: str) -> str:
|
||||
domain = website.split("//", 1)[-1].split("/", 1)[0]
|
||||
return domain[4:] if domain.startswith("www.") else domain
|
||||
|
||||
|
||||
def _split_name(person_name: str) -> tuple[str, str | None]:
|
||||
parts = person_name.split(maxsplit=1)
|
||||
return (parts[0], parts[1] if len(parts) > 1 else None)
|
||||
|
||||
|
||||
class NinjaPearProvider:
|
||||
provider_name = "ninjapear"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._api_key = settings.ninjapear_api_key
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {self._api_key}"}
|
||||
|
||||
async def _get(self, url: str, params: dict[str, str], *, timeout: float) -> dict:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
response = await client.get(url, params=params, headers=self._headers())
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
async def get_company_details(self, name: str, website: str | None) -> CompanyDetails:
|
||||
data = await self._get(
|
||||
f"{_BASE_URL}/company/details",
|
||||
{"website": _require_website(website)},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
leadership = [
|
||||
LeadershipMember(name=exec_["name"], title=exec_.get("title") or exec_.get("role"))
|
||||
for exec_ in data.get("executives") or []
|
||||
if exec_.get("name")
|
||||
]
|
||||
# employee_count comes back as a raw int (e.g. 9030) and industry as
|
||||
# a numeric taxonomy code, not human-readable strings - stringified
|
||||
# defensively rather than left as-is, since our internal shape
|
||||
# types both as `str | None`.
|
||||
employee_count = data.get("employee_count")
|
||||
industry = data.get("industry")
|
||||
return CompanyDetails(
|
||||
description=data.get("description"),
|
||||
industry=str(industry) if industry is not None else None,
|
||||
founded_year=data.get("founded_year"),
|
||||
specialties=data.get("specialties") or [],
|
||||
leadership_team=leadership,
|
||||
employee_count_range=str(employee_count) if employee_count is not None else None,
|
||||
)
|
||||
|
||||
async def get_funding(self, name: str, website: str | None) -> CompanyFunding:
|
||||
data = await self._get(
|
||||
f"{_BASE_URL}/company/funding",
|
||||
{"website": _require_website(website)},
|
||||
timeout=_FUNDING_TIMEOUT,
|
||||
)
|
||||
# amount/amount_usd/total_funds_raised are raw numbers, and each
|
||||
# investor is an object (name/type/website/...), not a plain string
|
||||
# - stringified/extracted defensively, same reasoning as
|
||||
# get_company_details's employee_count/industry coercion.
|
||||
rounds = []
|
||||
for r in data.get("funding_rounds") or []:
|
||||
amount = r.get("amount_usd") or r.get("amount")
|
||||
investors = [inv.get("name") for inv in (r.get("investors") or []) if inv.get("name")]
|
||||
rounds.append(
|
||||
FundingRound(
|
||||
round_name=r.get("round_type"),
|
||||
amount=str(amount) if amount is not None else None,
|
||||
date=r.get("date"),
|
||||
investors=investors,
|
||||
)
|
||||
)
|
||||
total_raised = data.get("total_funds_raised_usd") or data.get("total_funds_raised")
|
||||
return CompanyFunding(
|
||||
total_raised=str(total_raised) if total_raised is not None else None, rounds=rounds
|
||||
)
|
||||
|
||||
async def get_updates(self, name: str, website: str | None) -> list[RecentUpdate]:
|
||||
data = await self._get(
|
||||
f"{_BASE_URL}/company/updates",
|
||||
{"website": _require_website(website)},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
return [
|
||||
RecentUpdate(
|
||||
type=u.get("source", "update"),
|
||||
text=u.get("title") or u.get("description") or "",
|
||||
url=u.get("url"),
|
||||
date=u.get("timestamp"),
|
||||
)
|
||||
for u in data.get("updates") or []
|
||||
if u.get("title") or u.get("description")
|
||||
]
|
||||
|
||||
async def get_competitors(self, name: str, website: str | None) -> list[CompetitorWithReason]:
|
||||
data = await self._get(
|
||||
f"{_BASE_URL}/competitor/listing",
|
||||
{"website": _require_website(website)},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
return [
|
||||
CompetitorWithReason(
|
||||
name=c.get("name") or c.get("website", ""), reason=c.get("competition_reason")
|
||||
)
|
||||
for c in data.get("competitors") or []
|
||||
if c.get("website") or c.get("name")
|
||||
]
|
||||
|
||||
async def get_products(self, name: str, website: str | None) -> list[Product]:
|
||||
data = await self._get(
|
||||
f"{_BASE_URL}/product/listing",
|
||||
{"website": _require_website(website)},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
products = []
|
||||
for p in data.get("products") or []:
|
||||
if not p.get("name"):
|
||||
continue
|
||||
categories = p.get("categories") or []
|
||||
products.append(
|
||||
Product(
|
||||
name=p["name"],
|
||||
description=p.get("description"),
|
||||
category=", ".join(categories) if categories else None,
|
||||
)
|
||||
)
|
||||
return products
|
||||
|
||||
async def get_customers(self, name: str, website: str | None) -> list[Customer]:
|
||||
data = await self._get(
|
||||
f"{_BASE_URL}/customer/listing",
|
||||
{"website": _require_website(website)},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
# NinjaPear returns three separately-categorized arrays rather than
|
||||
# one flat list - merged here with a relationship tag per group.
|
||||
customers = []
|
||||
for relationship, key in (
|
||||
("customer", "customers"),
|
||||
("investor", "investors"),
|
||||
("partner", "partner_platforms"),
|
||||
):
|
||||
for entry in data.get(key) or []:
|
||||
if entry.get("name"):
|
||||
customers.append(Customer(name=entry["name"], relationship=relationship))
|
||||
return customers
|
||||
|
||||
async def get_work_email(self, person_name: str, company_website: str) -> str | None:
|
||||
first_name, last_name = _split_name(person_name)
|
||||
params = {"first_name": first_name, "domain": _domain_from_website(company_website)}
|
||||
if last_name:
|
||||
params["last_name"] = last_name
|
||||
data = await self._get(f"{_BASE_URL}/employee/work-email", params, timeout=_DEFAULT_TIMEOUT)
|
||||
return data.get("work_email")
|
||||
|
||||
async def get_person_profile(
|
||||
self, person_name: str, company_website: str
|
||||
) -> tuple[str | None, str | None]:
|
||||
first_name, _ = _split_name(person_name)
|
||||
# v2 endpoint per NinjaPear's docs - the only one of the endpoints
|
||||
# used here that isn't under /api/v1.
|
||||
data = await self._get(
|
||||
"https://nubela.co/api/v2/employee/profile",
|
||||
{"first_name": first_name, "employer_website": company_website},
|
||||
timeout=_DEFAULT_TIMEOUT,
|
||||
)
|
||||
return data.get("x_profile_url"), data.get("bio")
|
||||
@@ -0,0 +1,136 @@
|
||||
"""FastAPI application entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, Request, status
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
from slowapi import _rate_limit_exceeded_handler
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from structlog.contextvars import bound_contextvars
|
||||
|
||||
from app.api.v1.router import api_v1_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.errors import (
|
||||
AppError,
|
||||
AuthenticationError,
|
||||
ConflictError,
|
||||
ForbiddenError,
|
||||
NotFoundError,
|
||||
RateLimitedError,
|
||||
ThrottledError,
|
||||
ValidationAppError,
|
||||
)
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.core.rate_limit import limiter
|
||||
|
||||
settings = get_settings()
|
||||
configure_logging(settings)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
logger.info(
|
||||
"startup",
|
||||
app_env=settings.app_env,
|
||||
auth_mode=settings.auth_mode,
|
||||
llm_provider=settings.llm_provider,
|
||||
)
|
||||
yield
|
||||
logger.info("shutdown")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title=settings.app_name,
|
||||
version="0.1.0",
|
||||
lifespan=lifespan,
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
openapi_url="/openapi.json",
|
||||
)
|
||||
|
||||
app.state.limiter = limiter
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
# FRONTEND_URL may be a comma-separated list (e.g. localhost plus a LAN
|
||||
# address) so the same API can serve a browser on this machine and one
|
||||
# elsewhere on the network at once.
|
||||
allow_origins=[origin.strip() for origin in settings.frontend_url.split(",") if origin.strip()],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def correlation_id_middleware(request: Request, call_next) -> Response:
|
||||
"""Every log line emitted while handling this request carries the same
|
||||
request_id (structlog contextvars, merged in automatically - see
|
||||
core/logging.py), and the id is echoed back so a client/proxy log can be
|
||||
cross-referenced with ours. Reuses an inbound X-Request-ID if a gateway
|
||||
already set one, rather than always minting a fresh id."""
|
||||
request_id = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||
with bound_contextvars(request_id=request_id):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Request-ID"] = request_id
|
||||
return response
|
||||
|
||||
|
||||
_APP_ERROR_STATUS_CODES: dict[type[AppError], int] = {
|
||||
NotFoundError: status.HTTP_404_NOT_FOUND,
|
||||
ConflictError: status.HTTP_409_CONFLICT,
|
||||
AuthenticationError: status.HTTP_401_UNAUTHORIZED,
|
||||
ForbiddenError: status.HTTP_403_FORBIDDEN,
|
||||
ValidationAppError: status.HTTP_400_BAD_REQUEST,
|
||||
RateLimitedError: status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
ThrottledError: status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
}
|
||||
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||
status_code = _APP_ERROR_STATUS_CODES.get(type(exc), status.HTTP_400_BAD_REQUEST)
|
||||
content: dict = {"detail": str(exc)}
|
||||
headers: dict[str, str] = {}
|
||||
if isinstance(exc, ThrottledError) and exc.retry_after_seconds is not None:
|
||||
content["retry_after_seconds"] = exc.retry_after_seconds
|
||||
headers["Retry-After"] = str(exc.retry_after_seconds)
|
||||
return JSONResponse(status_code=status_code, content=content, headers=headers)
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(
|
||||
_request: Request, exc: RequestValidationError
|
||||
) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||
content={"detail": "Invalid request", "errors": jsonable_encoder(exc.errors())},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(Exception)
|
||||
async def unhandled_exception_handler(_request: Request, exc: Exception) -> JSONResponse:
|
||||
logger.error("unhandled_exception", error=str(exc), error_type=type(exc).__name__)
|
||||
return JSONResponse(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
content={"detail": "Internal server error"},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", include_in_schema=False)
|
||||
async def root_health() -> dict[str, str]:
|
||||
"""Plain root-level liveness probe for infra tooling (Docker, etc.)."""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(api_v1_router)
|
||||
@@ -0,0 +1,35 @@
|
||||
"""SQLAlchemy ORM models.
|
||||
|
||||
Every model module is imported here so that `Base.metadata` (used by Alembic
|
||||
autogenerate) sees the full schema. Add new model modules to this list as
|
||||
they're created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.alert import Alert # noqa: F401
|
||||
from app.models.company import Company, CompanyAlias, Competitor # noqa: F401
|
||||
from app.models.company_enrichment import CompanyEnrichment # noqa: F401
|
||||
from app.models.detected_change import DetectedChange # noqa: F401
|
||||
from app.models.email_code import EmailCode # noqa: F401
|
||||
from app.models.ip_ban import IpBan # noqa: F401
|
||||
from app.models.ip_throttle_state import IpThrottleState # noqa: F401
|
||||
from app.models.monitor_configuration import MonitorConfiguration # noqa: F401
|
||||
from app.models.monitoring_run import MonitoringRun # noqa: F401
|
||||
from app.models.notification_delivery import NotificationDelivery # noqa: F401
|
||||
from app.models.notification_destination import ( # noqa: F401
|
||||
NotificationDestination,
|
||||
NotificationDestinationCompany,
|
||||
)
|
||||
from app.models.password_history import PasswordHistoryEntry # noqa: F401
|
||||
from app.models.refresh_token import RefreshToken # noqa: F401
|
||||
from app.models.report import Report # noqa: F401
|
||||
from app.models.snapshot import Snapshot # noqa: F401
|
||||
from app.models.source import Source # noqa: F401
|
||||
from app.models.source_document import SourceDocument # noqa: F401
|
||||
from app.models.system_secret import SystemSecret # noqa: F401
|
||||
from app.models.unban_request import UnbanRequest # noqa: F401
|
||||
from app.models.user import User # noqa: F401
|
||||
from app.models.user_api_key import UserApiKey # noqa: F401
|
||||
from app.models.user_known_ip import UserKnownIp # noqa: F401
|
||||
from app.models.user_security_event import UserSecurityEvent # noqa: F401
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Boolean, Enum, Float, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import SeverityLevel
|
||||
|
||||
|
||||
class Alert(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "alerts"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
detected_change_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("detected_changes.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(200))
|
||||
summary: Mapped[str] = mapped_column(Text)
|
||||
why_it_matters: Mapped[str] = mapped_column(Text)
|
||||
severity: Mapped[SeverityLevel] = mapped_column(
|
||||
Enum(SeverityLevel, native_enum=False, length=20)
|
||||
)
|
||||
confidence: Mapped[float] = mapped_column(Float)
|
||||
read: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
resolved: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
@@ -0,0 +1,76 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import JSON, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import CompanyStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.company_enrichment import CompanyEnrichment
|
||||
from app.models.monitor_configuration import MonitorConfiguration
|
||||
from app.models.notification_destination import NotificationDestinationCompany
|
||||
|
||||
|
||||
class Company(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "companies"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
slug: Mapped[str] = mapped_column(String(220), index=True)
|
||||
official_website: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
monitoring_focus: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
industry: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
region: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
headquarters: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
# Best-effort discovered identifiers, e.g. {"ticker": "ACME", "linkedin_url": "..."}
|
||||
# - see app/prompts/company_profile.py. Empty dict, never fabricated.
|
||||
public_identifiers: Mapped[dict[str, str]] = mapped_column(JSON, default=dict)
|
||||
status: Mapped[CompanyStatus] = mapped_column(
|
||||
Enum(CompanyStatus, native_enum=False, length=20), default=CompanyStatus.ACTIVE
|
||||
)
|
||||
|
||||
aliases: Mapped[list[CompanyAlias]] = relationship(
|
||||
back_populates="company", cascade="all, delete-orphan"
|
||||
)
|
||||
competitors: Mapped[list[Competitor]] = relationship(
|
||||
back_populates="company", cascade="all, delete-orphan"
|
||||
)
|
||||
monitor_configuration: Mapped[MonitorConfiguration | None] = relationship(
|
||||
back_populates="company", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
enrichment: Mapped[CompanyEnrichment | None] = relationship(
|
||||
back_populates="company", cascade="all, delete-orphan", uselist=False
|
||||
)
|
||||
notification_links: Mapped[list[NotificationDestinationCompany]] = relationship(
|
||||
cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class CompanyAlias(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "company_aliases"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
alias: Mapped[str] = mapped_column(String(200))
|
||||
|
||||
company: Mapped[Company] = relationship(back_populates="aliases")
|
||||
|
||||
|
||||
class Competitor(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "competitors"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
|
||||
company: Mapped[Company] = relationship(back_populates="competitors")
|
||||
@@ -0,0 +1,42 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Enum, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import EnrichmentStatus
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class CompanyEnrichment(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
"""One-shot, onboarding-time company enrichment from a paid third-party
|
||||
provider (NinjaPear/nubela.co) - never re-fetched on a schedule, see
|
||||
app/services/enrichment_service.py. `data` holds a documented (not
|
||||
DB-enforced) shape: employee_count, description, specialties,
|
||||
leadership_team (each optionally carrying work_email/profile_url/bio),
|
||||
funding (total_raised + rounds), competitors (name+reason), products,
|
||||
recent_updates, customers. `errors` maps section name -> error message
|
||||
for whichever calls failed, so a partial result is never silently
|
||||
presented as complete - same transparency principle as every other
|
||||
source/collector in this app."""
|
||||
|
||||
__tablename__ = "company_enrichments"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
status: Mapped[EnrichmentStatus] = mapped_column(
|
||||
Enum(EnrichmentStatus, native_enum=False, length=20), default=EnrichmentStatus.PENDING
|
||||
)
|
||||
data: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
errors: Mapped[dict[str, str]] = mapped_column(JSON, default=dict)
|
||||
credits_spent: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
company: Mapped[Company] = relationship(back_populates="enrichment")
|
||||
@@ -0,0 +1,44 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Enum, Float, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ChangeStatus, ChangeType, SeverityLevel
|
||||
|
||||
|
||||
class DetectedChange(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "detected_changes"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
monitoring_run_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("monitoring_runs.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
previous_snapshot_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("snapshots.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
current_snapshot_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("snapshots.id", ondelete="CASCADE")
|
||||
)
|
||||
change_type: Mapped[ChangeType] = mapped_column(Enum(ChangeType, native_enum=False, length=30))
|
||||
raw_diff: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
significance_score: Mapped[float] = mapped_column(Float)
|
||||
confidence_score: Mapped[float] = mapped_column(Float)
|
||||
severity: Mapped[SeverityLevel] = mapped_column(
|
||||
Enum(SeverityLevel, native_enum=False, length=20)
|
||||
)
|
||||
status: Mapped[ChangeStatus] = mapped_column(
|
||||
Enum(ChangeStatus, native_enum=False, length=20), default=ChangeStatus.NEW
|
||||
)
|
||||
# Short human-readable label, e.g. "3 new job postings detected" -
|
||||
# populated deterministically here; Phase 7's LLM may later add a
|
||||
# richer "why it matters" narrative on top without replacing this.
|
||||
summary: Mapped[str] = mapped_column(String(500))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Email verification / password-reset codes.
|
||||
|
||||
Only a SHA-256 hash of the 6-digit code is stored, never the raw value -
|
||||
same "never store the raw secret" precedent as RefreshToken.token_hash. A
|
||||
short numeric code doesn't need Argon2's cost; it needs short expiry plus
|
||||
the IP throttle system (app/services/ip_throttle_service.py) guarding how
|
||||
often it can be guessed or resent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import EmailCodePurpose
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class EmailCode(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "email_codes"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
purpose: Mapped[EmailCodePurpose] = mapped_column(
|
||||
Enum(EmailCodePurpose, native_enum=False, length=20)
|
||||
)
|
||||
code_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Shared string enums for ORM models. Mirrored in apps/web/lib/types.ts and
|
||||
packages/shared/src/index.ts - keep those in sync by hand when changing this."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class CompanyStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
class MonitoringFrequency(StrEnum):
|
||||
HOURLY = "hourly"
|
||||
EVERY_6_HOURS = "every_6_hours"
|
||||
EVERY_12_HOURS = "every_12_hours"
|
||||
DAILY = "daily"
|
||||
EVERY_2_DAYS = "every_2_days"
|
||||
WEEKLY = "weekly"
|
||||
EVERY_2_WEEKS = "every_2_weeks"
|
||||
MONTHLY = "monthly"
|
||||
CUSTOM = "custom"
|
||||
|
||||
|
||||
# Minimum minutes represented by each non-custom frequency, used both to
|
||||
# compute next_run and to enforce MINIMUM_MONITORING_INTERVAL_MINUTES.
|
||||
FREQUENCY_MINUTES: dict[MonitoringFrequency, int] = {
|
||||
MonitoringFrequency.HOURLY: 60,
|
||||
MonitoringFrequency.EVERY_6_HOURS: 6 * 60,
|
||||
MonitoringFrequency.EVERY_12_HOURS: 12 * 60,
|
||||
MonitoringFrequency.DAILY: 24 * 60,
|
||||
MonitoringFrequency.EVERY_2_DAYS: 2 * 24 * 60,
|
||||
MonitoringFrequency.WEEKLY: 7 * 24 * 60,
|
||||
MonitoringFrequency.EVERY_2_WEEKS: 14 * 24 * 60,
|
||||
MonitoringFrequency.MONTHLY: 30 * 24 * 60,
|
||||
}
|
||||
|
||||
|
||||
class SeverityLevel(StrEnum):
|
||||
CRITICAL = "critical"
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
|
||||
|
||||
# Ordering for threshold comparisons (index 0 = most severe).
|
||||
SEVERITY_ORDER: list[SeverityLevel] = [
|
||||
SeverityLevel.CRITICAL,
|
||||
SeverityLevel.HIGH,
|
||||
SeverityLevel.MEDIUM,
|
||||
SeverityLevel.LOW,
|
||||
]
|
||||
|
||||
|
||||
class NotificationType(StrEnum):
|
||||
EMAIL = "email"
|
||||
SMS = "sms"
|
||||
CONSOLE = "console"
|
||||
|
||||
|
||||
class SourceType(StrEnum):
|
||||
WEBSITE = "website"
|
||||
RSS = "rss"
|
||||
CUSTOM_URL = "custom_url"
|
||||
SEC_EDGAR = "sec_edgar"
|
||||
GITHUB = "github"
|
||||
JOB_POSTING = "job_posting"
|
||||
PATENT = "patent"
|
||||
REVIEW = "review"
|
||||
GOV_CONTRACT = "gov_contract"
|
||||
|
||||
|
||||
class SourceStatus(StrEnum):
|
||||
ACTIVE = "active"
|
||||
DISABLED = "disabled"
|
||||
RATE_LIMITED = "rate_limited"
|
||||
AUTH_REQUIRED = "auth_required"
|
||||
BLOCKED_BY_POLICY = "blocked_by_policy"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class MonitoringRunTrigger(StrEnum):
|
||||
SCHEDULED = "scheduled"
|
||||
MANUAL = "manual"
|
||||
INITIAL = "initial"
|
||||
RETRY = "retry"
|
||||
|
||||
|
||||
class MonitoringRunStatus(StrEnum):
|
||||
QUEUED = "queued"
|
||||
RUNNING = "running"
|
||||
SUCCESSFUL = "successful"
|
||||
PARTIAL = "partial"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ChangeType(StrEnum):
|
||||
NEW_DOCUMENT = "new_document"
|
||||
REMOVED_DOCUMENT = "removed_document"
|
||||
CONTENT_MODIFIED = "content_modified"
|
||||
PRICE_CHANGE = "price_change"
|
||||
LEADERSHIP_CHANGE = "leadership_change"
|
||||
FILING_NEW = "filing_new"
|
||||
|
||||
|
||||
class ChangeStatus(StrEnum):
|
||||
NEW = "new"
|
||||
ACKNOWLEDGED = "acknowledged"
|
||||
DISMISSED = "dismissed"
|
||||
|
||||
|
||||
class ReportType(StrEnum):
|
||||
BASELINE = "baseline"
|
||||
UPDATE = "update"
|
||||
MONTHLY = "monthly"
|
||||
MANUAL = "manual"
|
||||
|
||||
|
||||
class NotificationDeliveryStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
SENT = "sent"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class EnrichmentStatus(StrEnum):
|
||||
PENDING = "pending"
|
||||
PARTIAL = "partial"
|
||||
COMPLETE = "complete"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class EmailCodePurpose(StrEnum):
|
||||
VERIFY_EMAIL = "verify_email"
|
||||
PASSWORD_RESET = "password_reset"
|
||||
|
||||
|
||||
class ThrottleAction(StrEnum):
|
||||
RESEND_VERIFICATION = "resend_verification"
|
||||
RESEND_RESET = "resend_reset"
|
||||
FAILED_LOGIN = "failed_login"
|
||||
VERIFY_EMAIL_CODE = "verify_email_code"
|
||||
CONFIRM_RESET_CODE = "confirm_reset_code"
|
||||
|
||||
|
||||
class SecurityEventType(StrEnum):
|
||||
LOGIN_SUCCESS = "login_success"
|
||||
LOGIN_FAILED = "login_failed"
|
||||
ACCOUNT_LOCKED = "account_locked"
|
||||
PASSWORD_RESET_REQUESTED = "password_reset_requested"
|
||||
PASSWORD_RESET_COMPLETED = "password_reset_completed"
|
||||
EMAIL_VERIFICATION_SENT = "email_verification_sent"
|
||||
EMAIL_VERIFIED = "email_verified"
|
||||
SERVER_SECRET_UPDATED = "server_secret_updated"
|
||||
API_KEY_UPDATED = "api_key_updated"
|
||||
|
||||
|
||||
class ApiKeyProvider(StrEnum):
|
||||
"""Third-party providers a user can supply their own key for - see
|
||||
app/services/user_api_key_service.py's PROVIDER_META for the matching
|
||||
Settings field, display label, and credits/notes shown in Settings."""
|
||||
|
||||
ANTHROPIC = "anthropic"
|
||||
BRAVE_SEARCH = "brave_search"
|
||||
NINJAPEAR = "ninjapear"
|
||||
USPTO = "uspto"
|
||||
|
||||
|
||||
class SystemSecretKey(StrEnum):
|
||||
"""Server-wide (not per-user) secrets an admin can configure from the
|
||||
Settings page instead of only via .env - see
|
||||
app/services/system_secret_service.py's META for the matching Settings
|
||||
field and display label."""
|
||||
|
||||
TURNSTILE_SITE_KEY = "turnstile_site_key"
|
||||
TURNSTILE_SECRET = "turnstile_secret"
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Global per-IP bans. Deliberately separate from IpThrottleState - once any
|
||||
action type escalates an IP to permanent, that IP is blocked from every
|
||||
sensitive endpoint (register/login/resend/reset), not just the one action
|
||||
that triggered it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class IpBan(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "ip_bans"
|
||||
|
||||
ip_address: Mapped[str] = mapped_column(String(45), unique=True, index=True)
|
||||
banned_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
reason: Mapped[str] = mapped_column(String(255))
|
||||
@@ -0,0 +1,29 @@
|
||||
"""Per-IP, per-action escalation state for the throttle/ban engine
|
||||
(app/services/ip_throttle_service.py). `offense_count` is the "memory" that
|
||||
survives a completed timeout cycle - only a manual admin unban resets it."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, Integer, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ThrottleAction
|
||||
|
||||
|
||||
class IpThrottleState(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "ip_throttle_state"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("ip_address", "action", name="uq_ip_throttle_state_ip_action"),
|
||||
)
|
||||
|
||||
ip_address: Mapped[str] = mapped_column(String(45), index=True)
|
||||
action: Mapped[ThrottleAction] = mapped_column(
|
||||
Enum(ThrottleAction, native_enum=False, length=24)
|
||||
)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
next_allowed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
timeout_until: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
offense_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Enum, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import MonitoringFrequency, SeverityLevel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class MonitorConfiguration(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "monitor_configurations"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), unique=True, index=True
|
||||
)
|
||||
frequency_type: Mapped[MonitoringFrequency] = mapped_column(
|
||||
Enum(MonitoringFrequency, native_enum=False, length=30),
|
||||
default=MonitoringFrequency.WEEKLY,
|
||||
)
|
||||
# Only meaningful when frequency_type == CUSTOM: interval_minutes takes
|
||||
# precedence if set, otherwise cron_expression is used.
|
||||
interval_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cron_expression: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="America/New_York")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
next_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_run: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
severity_threshold: Mapped[SeverityLevel] = mapped_column(
|
||||
Enum(SeverityLevel, native_enum=False, length=20), default=SeverityLevel.MEDIUM
|
||||
)
|
||||
source_configuration: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
company: Mapped[Company] = relationship(back_populates="monitor_configuration")
|
||||
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import MonitoringRunStatus, MonitoringRunTrigger
|
||||
|
||||
|
||||
class MonitoringRun(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "monitoring_runs"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
trigger_type: Mapped[MonitoringRunTrigger] = mapped_column(
|
||||
Enum(MonitoringRunTrigger, native_enum=False, length=20)
|
||||
)
|
||||
status: Mapped[MonitoringRunStatus] = mapped_column(
|
||||
Enum(MonitoringRunStatus, native_enum=False, length=20),
|
||||
default=MonitoringRunStatus.QUEUED,
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
sources_attempted: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sources_successful: Mapped[int] = mapped_column(Integer, default=0)
|
||||
sources_failed: Mapped[int] = mapped_column(Integer, default=0)
|
||||
items_collected: Mapped[int] = mapped_column(Integer, default=0)
|
||||
changes_detected: Mapped[int] = mapped_column(Integer, default=0)
|
||||
error_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
worker_task_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Enum, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import NotificationDeliveryStatus
|
||||
|
||||
|
||||
class NotificationDelivery(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "notification_deliveries"
|
||||
|
||||
alert_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("alerts.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
destination_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("notification_destinations.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
provider: Mapped[str] = mapped_column(String(50))
|
||||
status: Mapped[NotificationDeliveryStatus] = mapped_column(
|
||||
Enum(NotificationDeliveryStatus, native_enum=False, length=20),
|
||||
default=NotificationDeliveryStatus.PENDING,
|
||||
)
|
||||
attempt_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
last_attempt: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
external_message_id: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import NotificationType, SeverityLevel
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.company import Company
|
||||
|
||||
|
||||
class NotificationDestination(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "notification_destinations"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
type: Mapped[NotificationType] = mapped_column(
|
||||
Enum(NotificationType, native_enum=False, length=20)
|
||||
)
|
||||
# Email address, phone number, or a label for the console provider.
|
||||
# Not a secret, but still PII - see SECURITY.md.
|
||||
destination_value: Mapped[str] = mapped_column(String(320))
|
||||
verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
minimum_severity: Mapped[SeverityLevel] = mapped_column(
|
||||
Enum(SeverityLevel, native_enum=False, length=20), default=SeverityLevel.MEDIUM
|
||||
)
|
||||
|
||||
company_links: Mapped[list[NotificationDestinationCompany]] = relationship(
|
||||
back_populates="destination", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class NotificationDestinationCompany(Base, TimestampMixin):
|
||||
"""Which companies a destination receives alerts for - a destination
|
||||
with zero links is orphaned and gets garbage-collected (see
|
||||
notification_destination_service.py) rather than left dangling."""
|
||||
|
||||
__tablename__ = "notification_destination_companies"
|
||||
|
||||
destination_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("notification_destinations.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
|
||||
destination: Mapped[NotificationDestination] = relationship(back_populates="company_links")
|
||||
# Read-only path to the company's name for display; Company.notification_links
|
||||
# (used only for cascade-delete) writes the same FK from the other direction,
|
||||
# hence overlaps= to tell SQLAlchemy that's intentional, not a conflict.
|
||||
company: Mapped[Company] = relationship(overlaps="notification_links")
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Every password hash a user has ever had active - checked on password
|
||||
reset so a user can't "reset" back to a password they (or an attacker who
|
||||
learned it) has used before. Never used for anything except that
|
||||
membership check; nothing reads these hashes back out for display."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class PasswordHistoryEntry(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "password_history_entries"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Refresh token records.
|
||||
|
||||
Only a hash of the token's `jti` is stored - never the JWT itself - so a
|
||||
database read can't be replayed as a valid refresh token. Rotation on use
|
||||
(one row per issuance, `revoked_at` set when superseded) limits the blast
|
||||
radius of a leaked refresh token to its remaining lifetime.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class RefreshToken(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "refresh_tokens"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="refresh_tokens")
|
||||
@@ -0,0 +1,29 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Enum, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ReportType
|
||||
|
||||
|
||||
class Report(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "reports"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
monitoring_run_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("monitoring_runs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
report_type: Mapped[ReportType] = mapped_column(Enum(ReportType, native_enum=False, length=20))
|
||||
title: Mapped[str] = mapped_column(String(300))
|
||||
executive_summary: Mapped[str] = mapped_column(Text)
|
||||
structured_report: Mapped[dict[str, Any]] = mapped_column(JSON)
|
||||
markdown_content: Mapped[str] = mapped_column(Text)
|
||||
model_provider: Mapped[str] = mapped_column(String(50))
|
||||
model_name: Mapped[str] = mapped_column(String(100))
|
||||
prompt_version: Mapped[str] = mapped_column(String(20), default="v1")
|
||||
@@ -0,0 +1,31 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class Snapshot(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
"""A structured, comparable summary of a source's state at a point in
|
||||
time - what change detection (Phase 6) diffs against the prior snapshot
|
||||
for the same source."""
|
||||
|
||||
__tablename__ = "snapshots"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
snapshot_type: Mapped[str] = mapped_column(String(50))
|
||||
hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
structured_summary: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
text_summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
monitoring_run_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("monitoring_runs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Enum, Float, ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import MonitoringFrequency, SourceStatus, SourceType
|
||||
|
||||
|
||||
class Source(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "sources"
|
||||
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
source_type: Mapped[SourceType] = mapped_column(Enum(SourceType, native_enum=False, length=20))
|
||||
name: Mapped[str] = mapped_column(String(200))
|
||||
base_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
status: Mapped[SourceStatus] = mapped_column(
|
||||
Enum(SourceStatus, native_enum=False, length=20), default=SourceStatus.ACTIVE
|
||||
)
|
||||
trust_score: Mapped[float] = mapped_column(Float, default=0.7)
|
||||
last_checked: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
last_successful_check: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
failure_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
configuration_metadata: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
|
||||
# Per-source check cadence override - NULL frequency_type means "inherit
|
||||
# the company's default MonitorConfiguration cadence" (the behavior for
|
||||
# every source before this feature existed, and still the default for
|
||||
# any source that never sets an override). See app/tasks/scheduler.py
|
||||
# and app/services/scheduling.py for how these combine into due-ness.
|
||||
frequency_type: Mapped[MonitoringFrequency | None] = mapped_column(
|
||||
Enum(MonitoringFrequency, native_enum=False, length=20), nullable=True
|
||||
)
|
||||
interval_minutes: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
cron_expression: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
next_check: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
@@ -0,0 +1,40 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, DateTime, Float, ForeignKey, Integer, String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class SourceDocument(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
"""A single piece of collected content. Kept lean on purpose - raw HTML
|
||||
is not stored, only extracted text - per the "avoid saving unnecessary
|
||||
full HTML indefinitely" rule in the spec."""
|
||||
|
||||
__tablename__ = "source_documents"
|
||||
|
||||
source_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("sources.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
company_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("companies.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
url: Mapped[str] = mapped_column(String(1000))
|
||||
canonical_url: Mapped[str] = mapped_column(String(1000), index=True)
|
||||
title: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
author: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
publication_date: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
retrieved_date: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
content_text: Mapped[str] = mapped_column(Text)
|
||||
content_hash: Mapped[str] = mapped_column(String(64), index=True)
|
||||
metadata_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
||||
language: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||
http_status: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
extraction_method: Mapped[str] = mapped_column(String(50))
|
||||
trust_score: Mapped[float] = mapped_column(Float, default=0.7)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""A server-wide secret (e.g. Turnstile site key/secret), encrypted at rest
|
||||
(app/core/crypto.py). Unlike UserApiKey, this isn't scoped to a user - it's
|
||||
one value shared by the whole app, admin-editable from the Settings page
|
||||
instead of only via .env. When set,
|
||||
app/services/system_secret_service.py's get_effective_settings substitutes
|
||||
it in place of the server's global .env-configured value - see that module
|
||||
for the full fallback logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import Enum, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import SystemSecretKey
|
||||
|
||||
|
||||
class SystemSecret(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "system_secrets"
|
||||
|
||||
key: Mapped[SystemSecretKey] = mapped_column(
|
||||
Enum(SystemSecretKey, native_enum=False, length=32), unique=True
|
||||
)
|
||||
encrypted_value: Mapped[str] = mapped_column(Text)
|
||||
@@ -0,0 +1,17 @@
|
||||
"""Manual unban requests from banned visitors - one per IP per 24h, enforced
|
||||
in the service layer at insert time. Purely a queue for admin review; no
|
||||
automated unban happens from this table."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import String, Text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
|
||||
class UnbanRequest(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "unban_requests"
|
||||
|
||||
ip_address: Mapped[str] = mapped_column(String(45), index=True)
|
||||
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""User account model.
|
||||
|
||||
`password_hash` is nullable because `AUTH_MODE=local` provisions a single
|
||||
fixed user with no password at all - that mode never routes through
|
||||
password verification, so there's nothing to hash.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Boolean, DateTime, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.refresh_token import RefreshToken
|
||||
|
||||
# Fixed, deterministic user id used for AUTH_MODE=local so the same row is
|
||||
# reused across restarts rather than multiplying "local dev user" rows.
|
||||
LOCAL_DEV_USER_ID = uuid.UUID("00000000-0000-0000-0000-000000000001")
|
||||
LOCAL_DEV_USER_EMAIL = "[email protected]"
|
||||
|
||||
|
||||
class User(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(320), unique=True, index=True)
|
||||
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
display_name: Mapped[str] = mapped_column(String(120))
|
||||
timezone: Mapped[str] = mapped_column(String(64), default="America/New_York")
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
# Phase 19: email verification + escalating failed-login lockout. The
|
||||
# fixed AUTH_MODE=local user is seeded as already-verified (it never
|
||||
# goes through this flow - see auth_service.get_or_create_local_user).
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
failed_login_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
refresh_tokens: Mapped[list[RefreshToken]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""A user's own API key for a given third-party provider, encrypted at
|
||||
rest (app/core/crypto.py). When set, app/services/user_api_key_service.py's
|
||||
get_effective_settings substitutes it in place of the server's global
|
||||
.env-configured key for that user's own requests - see that module for the
|
||||
full fallback logic."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import ApiKeyProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserApiKey(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "user_api_keys"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "provider", name="uq_user_api_keys_user_provider"),
|
||||
)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
provider: Mapped[ApiKeyProvider] = mapped_column(
|
||||
Enum(ApiKeyProvider, native_enum=False, length=16)
|
||||
)
|
||||
encrypted_key: Mapped[str] = mapped_column(Text)
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Every distinct IP an account has ever signed in from - one row per
|
||||
(user, ip) pair, first_seen_at set once and last_seen_at touched on every
|
||||
subsequent sign-in from that same IP. Pure data capture for now (see
|
||||
app/services/auth_service.py's sign-in paths, both real login and the
|
||||
local-dev bypass) - nothing currently reads this table, but it's the
|
||||
foundation a later "new device/location" security feature would query
|
||||
against without needing to scan/dedupe the much larger, append-only
|
||||
user_security_events log."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserKnownIp(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "user_known_ips"
|
||||
__table_args__ = (UniqueConstraint("user_id", "ip_address", name="uq_user_known_ips_user_ip"),)
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
ip_address: Mapped[str] = mapped_column(String(45))
|
||||
first_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
last_seen_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Per-user security activity log - the user-facing counterpart to the
|
||||
admin-only, app-wide Redis log feed (app/core/logging.py). Visible only to
|
||||
the owning user via GET /auth/security-events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import Enum, ForeignKey, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
|
||||
from app.models.enums import SecurityEventType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
class UserSecurityEvent(Base, UUIDPrimaryKeyMixin, TimestampMixin):
|
||||
__tablename__ = "user_security_events"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
event_type: Mapped[SecurityEventType] = mapped_column(
|
||||
Enum(SecurityEventType, native_enum=False, length=32)
|
||||
)
|
||||
ip_address: Mapped[str] = mapped_column(String(45))
|
||||
|
||||
user: Mapped[User] = relationship()
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Notification provider interface. Every alert dispatch (app/services/
|
||||
alert_service.py) and every "test destination" action goes through this
|
||||
Protocol, never a specific vendor SDK - swapping providers or adding a new
|
||||
one doesn't touch the dispatch logic.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NotificationMessage:
|
||||
destination_value: str
|
||||
subject: str
|
||||
body_text: str
|
||||
body_html: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeliveryResult:
|
||||
success: bool
|
||||
external_message_id: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class NotificationProvider(Protocol):
|
||||
provider_name: str
|
||||
|
||||
async def send(self, message: NotificationMessage) -> DeliveryResult: ...
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
@@ -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(),
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Task F: alert summarization. Produces the concise, non-exaggerated text
|
||||
shown in the alert/email/SMS (Phase 8) - short by design, honest about
|
||||
confidence, no hype language.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.analysis.llm.base import LLMProvider
|
||||
from app.prompts.base import build_user_prompt
|
||||
|
||||
SYSTEM_PROMPT = (
|
||||
"You write concise, factual alert summaries for a competitive intelligence tool. Never "
|
||||
"exaggerate. State what changed, cite the evidence type, and note the confidence level "
|
||||
"plainly. The title must be under 100 characters and must not use hype words like "
|
||||
"'huge', 'massive', or 'game-changing'."
|
||||
)
|
||||
|
||||
|
||||
class AlertSummary(BaseModel):
|
||||
title: str = Field(max_length=100)
|
||||
summary: str
|
||||
why_it_matters: str
|
||||
|
||||
|
||||
async def summarize_alert(
|
||||
llm: LLMProvider,
|
||||
*,
|
||||
company_name: str,
|
||||
change_type: str,
|
||||
change_summary: str,
|
||||
severity: str,
|
||||
confidence: float,
|
||||
evidence_snippets: list[str],
|
||||
) -> AlertSummary:
|
||||
evidence = {
|
||||
"company_name": company_name,
|
||||
"change_type": change_type,
|
||||
"change_summary": change_summary,
|
||||
"severity": severity,
|
||||
"confidence": confidence,
|
||||
"evidence_snippets": evidence_snippets[:10],
|
||||
}
|
||||
user_prompt = build_user_prompt("Write a concise alert summary for this change.", evidence)
|
||||
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, AlertSummary)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user