Initial commit: CI Agent competitive-intelligence monitoring app

FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
This commit is contained in:
2026-08-05 10:48:20 -04:00
commit 1a4c80958f
365 changed files with 43541 additions and 0 deletions
@@ -0,0 +1,82 @@
"""Anthropic provider: structured output via forced tool-use (the response
schema becomes the tool's input_schema, so the model can only "call" it with
arguments matching the shape we asked for), with a bounded repair loop for
the rare malformed response.
"""
from __future__ import annotations
from anthropic import AsyncAnthropic
from pydantic import BaseModel, ValidationError
from app.analysis.llm.base import LLMResponseError
from app.core.config import Settings
_TOOL_NAME = "emit_result"
class AnthropicLLMProvider:
provider_name = "anthropic"
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._client = AsyncAnthropic(api_key=settings.anthropic_api_key)
async def generate_structured[T: BaseModel](
self, system_prompt: str, user_prompt: str, response_model: type[T]
) -> T:
tools = [
{
"name": _TOOL_NAME,
"description": f"Emit the result matching the {response_model.__name__} schema.",
"input_schema": response_model.model_json_schema(),
}
]
last_error: Exception | None = None
messages: list[dict] = [{"role": "user", "content": user_prompt}]
for attempt in range(self._settings.llm_max_retries + 1):
if attempt > 0 and last_error is not None:
messages = [
*messages,
{
"role": "user",
"content": (
f"Your previous response did not match the required schema: "
f"{last_error}. Please try again."
),
},
]
response = await self._client.messages.create(
model=self._settings.anthropic_model,
max_tokens=self._settings.llm_max_tokens_per_request,
system=system_prompt,
tools=tools,
tool_choice={"type": "tool", "name": _TOOL_NAME},
messages=messages,
)
tool_use = next((b for b in response.content if b.type == "tool_use"), None)
if tool_use is None:
last_error = ValueError("No tool_use block in the model's response")
continue
try:
return response_model.model_validate(tool_use.input)
except ValidationError as exc:
last_error = exc
continue
raise LLMResponseError(
f"Anthropic provider failed to produce a valid {response_model.__name__} after "
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
)
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
response = await self._client.messages.create(
model=self._settings.anthropic_model,
max_tokens=self._settings.llm_max_tokens_per_request,
system=system_prompt,
messages=[{"role": "user", "content": user_prompt}],
)
return "\n".join(block.text for block in response.content if block.type == "text")
+33
View File
@@ -0,0 +1,33 @@
"""LLM provider interface. Every analysis task (app/prompts/*.py) is written
against this Protocol, never against a specific vendor SDK - swapping
`LLM_PROVIDER` changes which class `get_llm_provider()` returns and nothing
else has to change. `generate_structured` is the primary method: it always
returns a validated instance of the caller's Pydantic response model, never
raw text, so a malformed model response can never propagate un-typed data
into the rest of the app (see `LLMResponseError` / the repair loop in
anthropic_provider.py).
"""
from __future__ import annotations
from typing import Protocol
from pydantic import BaseModel
class LLMResponseError(Exception):
"""Raised when a provider can't produce a response matching the
requested schema, even after any repair attempts."""
class LLMProvider(Protocol):
provider_name: str
async def generate_structured[T: BaseModel](
self,
system_prompt: str,
user_prompt: str,
response_model: type[T],
) -> T: ...
async def generate_text(self, system_prompt: str, user_prompt: str) -> str: ...
+31
View File
@@ -0,0 +1,31 @@
"""Resolves `LLM_PROVIDER` to a concrete provider instance. Never imported
directly by prompt task modules or services - always go through
`get_llm_provider()` so swapping providers stays a one-line config change.
"""
from __future__ import annotations
from app.analysis.llm.base import LLMProvider
from app.analysis.llm.mock import MockLLMProvider
from app.core.config import Settings, get_settings
def get_llm_provider(settings: Settings | None = None) -> LLMProvider:
settings = settings or get_settings()
if settings.llm_provider == "anthropic":
from app.analysis.llm.anthropic_provider import AnthropicLLMProvider
return AnthropicLLMProvider(settings)
if settings.llm_provider == "ollama":
from app.analysis.llm.ollama_provider import OllamaLLMProvider
return OllamaLLMProvider(settings)
if settings.llm_provider == "gemini":
from app.analysis.llm.gemini_provider import GeminiLLMProvider
return GeminiLLMProvider(settings)
return MockLLMProvider()
@@ -0,0 +1,73 @@
"""Gemini provider: structured output via the SDK's native
`response_schema` support (the model is constrained to the schema and the
SDK parses the result into an instance of it directly), with the same
bounded repair loop on a malformed/unparsed response as
`anthropic_provider.py`. Chosen as the production LLM_PROVIDER option
alongside Anthropic because Gemini has an actual free rate-limited API
tier (gemini-2.0-flash), unlike OpenAI's expiring trial credits.
"""
from __future__ import annotations
from google import genai
from google.genai import types
from pydantic import BaseModel, ValidationError
from app.analysis.llm.base import LLMResponseError
from app.core.config import Settings
class GeminiLLMProvider:
provider_name = "gemini"
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._client = genai.Client(api_key=settings.gemini_api_key)
async def generate_structured[T: BaseModel](
self, system_prompt: str, user_prompt: str, response_model: type[T]
) -> T:
last_error: Exception | None = None
prompt = user_prompt
for attempt in range(self._settings.llm_max_retries + 1):
if attempt > 0 and last_error is not None:
prompt = (
f"{user_prompt}\n\nYour previous response did not match the required "
f"schema: {last_error}. Please try again."
)
response = await self._client.aio.models.generate_content(
model=self._settings.gemini_model,
contents=prompt,
config=types.GenerateContentConfig(
system_instruction=system_prompt,
response_mime_type="application/json",
response_schema=response_model,
max_output_tokens=self._settings.llm_max_tokens_per_request,
),
)
if response.parsed is None:
last_error = ValueError("Gemini did not return a parsed structured response")
continue
try:
return response_model.model_validate(response.parsed)
except ValidationError as exc:
last_error = exc
continue
raise LLMResponseError(
f"Gemini provider failed to produce a valid {response_model.__name__} after "
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
)
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
response = await self._client.aio.models.generate_content(
model=self._settings.gemini_model,
contents=user_prompt,
config=types.GenerateContentConfig(
system_instruction=system_prompt,
max_output_tokens=self._settings.llm_max_tokens_per_request,
),
)
return response.text or ""
+327
View File
@@ -0,0 +1,327 @@
"""Deterministic mock provider - the default (`LLM_PROVIDER=mock`) and what
every automated test runs against. Never calls a network or costs money.
Rather than a generic reflection-based filler, each of the six analysis
tasks gets a purpose-built, deterministic builder that reads the same
evidence block a real model would see (see app/prompts/base.py) and
produces genuinely useful output from it - real counts, real titles, real
severities - never fabricated facts. This is what makes the fixture demo
(Phase 9) work end-to-end without a paid API key.
"""
from __future__ import annotations
import re
from typing import Any
from pydantic import BaseModel
from app.prompts.alert_summarization import AlertSummary
from app.prompts.base import extract_evidence_block
from app.prompts.change_significance import ChangeSignificanceAssessment
from app.prompts.company_profile import CompanyProfileExtraction
from app.prompts.extraction import ExtractionResult
from app.prompts.relevance import RelevanceAssessment
from app.prompts.report_generation import ReportContent
from app.prompts.synthesis import SynthesisResult
def _confidence_label(score: float) -> str:
if score >= 0.85:
return "confirmed"
if score >= 0.65:
return "strongly_indicated"
if score >= 0.45:
return "likely"
if score >= 0.25:
return "possible"
if score > 0:
return "unconfirmed"
return "insufficient_evidence"
def _build_relevance(evidence: dict[str, Any]) -> dict[str, Any]:
text = (evidence.get("document_text") or "").lower()
focus = (evidence.get("monitoring_focus") or "").lower()
focus_words = [w for w in focus.split() if len(w) > 4]
matches_focus = any(w in text for w in focus_words) if focus_words else False
return {
"is_relevant": True,
"matches_focus": matches_focus,
"topic_categories": [],
"source_reliability": 0.7,
"reasoning": "Mock provider: keyword-based heuristic (no live LLM configured).",
}
def _build_extraction(evidence: dict[str, Any]) -> dict[str, Any]:
text = (evidence.get("document_text") or "").strip()
if not text:
return {"signals": []}
first_sentence = text.split(".")[0][:200].strip()
if not first_sentence:
return {"signals": []}
return {
"signals": [
{
"signal_type": "event",
"description": first_sentence,
"supporting_passage": first_sentence,
"date": None,
"entities": [],
}
]
}
def _build_synthesis(evidence: dict[str, Any]) -> dict[str, Any]:
signals = evidence.get("signals") or []
if len(signals) < 2:
return {"conclusions": []}
return {
"conclusions": [
{
"conclusion": (
"Multiple related signals were detected together; the mock provider "
"does not attempt fine-grained synthesis - configure a live LLM provider "
"for a specific conclusion."
),
"evidence_summary": [s.get("description", "") for s in signals[:5]],
"source_count": len(signals),
"confidence": 0.3,
"alternative_explanations": [
"A live LLM provider would assess this more precisely."
],
"missing_information": [],
}
]
}
def _build_report(evidence: dict[str, Any]) -> dict[str, Any]:
profile = evidence.get("company_profile") or {}
company_name = profile.get("name") or "The company"
documents = evidence.get("source_documents") or []
changes = evidence.get("detected_changes") or []
failed = evidence.get("sources_that_failed_to_collect") or []
# The discovered profile (real, from onboarding) is genuine evidence even
# when no monitoring run has collected source_documents/detected_changes
# yet - build company_overview/market_positioning from it honestly
# rather than defaulting straight to "insufficient evidence".
overview_parts = []
if profile.get("description"):
overview_parts.append(profile["description"])
facts = []
if profile.get("industry"):
facts.append(f"industry: {profile['industry']}")
if profile.get("headquarters"):
facts.append(f"headquartered in {profile['headquarters']}")
elif profile.get("country") or profile.get("region"):
facts.append(
f"based in {', '.join(f for f in (profile.get('country'), profile.get('region')) if f)}"
)
if profile.get("aliases"):
facts.append(f"also known as {', '.join(profile['aliases'])}")
if facts:
overview_parts.append(f"{company_name} ({'; '.join(facts)}).")
company_overview = " ".join(overview_parts) or f"No description on file for {company_name}."
if profile.get("competitors"):
market_positioning = (
f"{company_name} operates in a space that includes "
f"{', '.join(profile['competitors'])} as named competitors, per the discovered "
"company profile. No comparative data (pricing, features, market share) is "
"available to assess relative positioning."
)
else:
market_positioning = "Insufficient evidence to assess market positioning."
executive_summary = (
f"Mock analysis (LLM_PROVIDER=mock) based on {len(documents)} collected document(s) "
f"and {len(changes)} detected change(s) for {company_name}."
)
if failed:
executive_summary += (
f" {len(failed)} source(s) failed to collect this run and are excluded below."
)
recent_developments = [
{
"headline": change.get("summary") or "Change detected",
"summary": (
f"{(change.get('change_type') or 'change').replace('_', ' ')} detected "
f"with {change.get('severity') or 'unknown'} severity."
),
"evidence": [
{
"detected_change_id": change.get("id"),
"description": change.get("summary") or "",
}
],
"confidence": _confidence_label(change.get("confidence_score") or 0.5),
"category": change.get("change_type"),
"date": change.get("created_at"),
}
for change in changes[:10]
]
hiring_signals = [
{
"headline": doc.get("title") or doc.get("url") or "Job posting",
"summary": (doc.get("excerpt") or "")[:280],
"evidence": [
{
"source_document_id": doc.get("id"),
"url": doc.get("url"),
"description": "Collected source document",
}
],
"confidence": "confirmed",
"category": "job_posting",
"date": doc.get("retrieved_date"),
}
for doc in documents
if doc.get("source_type") == "job_posting"
]
unknowns = (
[f"{len(failed)} source(s) failed to collect this run: {', '.join(failed[:5])}"]
if failed
else []
)
return {
"executive_summary": executive_summary,
"company_overview": company_overview,
"products_and_services": [],
"market_positioning": market_positioning,
"recent_developments": recent_developments,
"strategic_initiatives": [],
"key_inferred_projects": [],
"leadership_changes": [
d for d in recent_developments if d["category"] == "leadership_change"
],
"hiring_signals": hiring_signals,
"technology_signals": [],
"patent_signals": [],
"manufacturing_and_expansion_signals": [],
"partnerships_and_acquisitions": [],
"financial_signals": [d for d in recent_developments if d["category"] == "filing_new"],
"regulatory_and_legal_signals": [],
"customer_sentiment": "Insufficient evidence to assess customer sentiment.",
"competitor_comparison": "Insufficient evidence to compare against competitors.",
"swot": {"strengths": [], "weaknesses": [], "opportunities": [], "threats": []},
"risks": [],
"opportunities": [],
"unknowns_and_missing_data": unknowns,
"monitoring_recommendations": ["Continue monitoring configured sources on schedule."],
"methodology": (
f"Generated by the mock LLM provider from {len(documents)} stored source "
f"document(s) and {len(changes)} deterministic change-detection result(s). No "
"external model was called."
),
"limitations": (
"Generated by the deterministic mock provider, not a live LLM. Set "
"LLM_PROVIDER=anthropic or LLM_PROVIDER=ollama for narrative synthesis."
),
}
def _build_change_significance(evidence: dict[str, Any]) -> dict[str, Any]:
severity = evidence.get("deterministic_severity") or "low"
confidence = evidence.get("deterministic_confidence") or 0.5
is_meaningful = severity in ("critical", "high", "medium")
change_type = (evidence.get("change_type") or "change").replace("_", " ")
return {
"is_real_change": True,
"is_meaningful": is_meaningful,
"why_it_matters": (
f"Deterministic scoring classified this {change_type} as {severity} severity "
f"with {confidence:.0%} confidence."
),
"confidence": confidence,
"should_notify": is_meaningful,
}
def _build_alert_summary(evidence: dict[str, Any]) -> dict[str, Any]:
company = evidence.get("company_name") or "The company"
change_type = (evidence.get("change_type") or "change").replace("_", " ")
severity = evidence.get("severity") or "medium"
confidence = evidence.get("confidence") or 0.5
return {
"title": f"{company}: {change_type} detected"[:100],
"summary": evidence.get("change_summary") or f"A {change_type} was detected for {company}.",
"why_it_matters": f"Classified as {severity} severity with {confidence:.0%} confidence.",
}
_HQ_RE = re.compile(r"(?:headquartered|based) in ([A-Z][\w\s,]{2,60}?)(?:[.\n]|$)", re.IGNORECASE)
_FORMERLY_RE = re.compile(
r"formerly (?:known as|named) ([A-Z][\w&\s]{2,60}?)(?:[.,\n]|$)", re.IGNORECASE
)
def _build_company_profile(evidence: dict[str, Any]) -> dict[str, Any]:
"""Mirrors app/change_detection/extractors.py's philosophy: cheap,
deterministic regex heuristics over real evidence text, never a
fabricated guess. Most fields (industry/country/region/public
identifiers) stay empty since a name-only mock search has no real
signal for them - see search/mock.py's docstring for why that's
intentional, not a gap."""
homepage_text = evidence.get("homepage_text") or ""
search_results = evidence.get("search_results") or []
combined_text = homepage_text + "\n" + "\n".join(r.get("snippet", "") for r in search_results)
hq_match = _HQ_RE.search(combined_text)
headquarters = hq_match.group(1).strip() if hq_match else None
alias_match = _FORMERLY_RE.search(combined_text)
aliases = [alias_match.group(1).strip()] if alias_match else []
# First real sentence of the fetched homepage, if any - an honest,
# evidence-derived summary rather than a fabricated one.
first_sentence = re.split(r"(?<=[.!?])\s", homepage_text.strip(), maxsplit=1)[0].strip()
description = first_sentence[:280] if first_sentence and len(first_sentence) > 15 else None
return {
"description": description,
"industry": None,
"country": None,
"region": None,
"headquarters": headquarters,
"aliases": aliases,
"competitors": [],
"public_identifiers": [],
}
_BUILDERS = {
RelevanceAssessment: _build_relevance,
ExtractionResult: _build_extraction,
SynthesisResult: _build_synthesis,
ReportContent: _build_report,
ChangeSignificanceAssessment: _build_change_significance,
AlertSummary: _build_alert_summary,
CompanyProfileExtraction: _build_company_profile,
}
class MockLLMProvider:
provider_name = "mock"
async def generate_structured[T: BaseModel](
self, system_prompt: str, user_prompt: str, response_model: type[T]
) -> T:
evidence = extract_evidence_block(user_prompt)
builder = _BUILDERS.get(response_model)
data = builder(evidence) if builder is not None else {}
return response_model.model_validate(data)
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
evidence = extract_evidence_block(user_prompt)
return (
"[mock provider] No live LLM configured. "
f"{len(evidence)} evidence field(s) were provided for this request."
)
@@ -0,0 +1,85 @@
"""Ollama provider: local models via Ollama's HTTP API. Uses JSON mode
(`format: "json"`) plus a bounded repair loop, since not every locally-run
model supports strict schema-constrained decoding the way Anthropic's
tool-use does - the schema is instead embedded in the system prompt as an
instruction.
"""
from __future__ import annotations
import json
import httpx
from pydantic import BaseModel, ValidationError
from app.analysis.llm.base import LLMResponseError
from app.core.config import Settings
class OllamaLLMProvider:
provider_name = "ollama"
def __init__(self, settings: Settings) -> None:
self._settings = settings
async def generate_structured[T: BaseModel](
self, system_prompt: str, user_prompt: str, response_model: type[T]
) -> T:
schema_instructions = (
f"{system_prompt}\n\nRespond with ONLY a single JSON object matching this JSON "
f"schema, no other text, no markdown fences:\n"
f"{json.dumps(response_model.model_json_schema())}"
)
last_error: Exception | None = None
prompt = user_prompt
for attempt in range(self._settings.llm_max_retries + 1):
if attempt > 0 and last_error is not None:
prompt = (
f"{user_prompt}\n\nYour previous response was invalid: {last_error}. "
"Try again, returning ONLY valid JSON matching the schema."
)
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{self._settings.ollama_base_url}/api/chat",
json={
"model": self._settings.ollama_model,
"messages": [
{"role": "system", "content": schema_instructions},
{"role": "user", "content": prompt},
],
"format": "json",
"stream": False,
},
)
response.raise_for_status()
content = response.json().get("message", {}).get("content", "")
try:
data = json.loads(content)
return response_model.model_validate(data)
except (json.JSONDecodeError, ValidationError) as exc:
last_error = exc
continue
raise LLMResponseError(
f"Ollama provider failed to produce a valid {response_model.__name__} after "
f"{self._settings.llm_max_retries + 1} attempt(s): {last_error}"
)
async def generate_text(self, system_prompt: str, user_prompt: str) -> str:
async with httpx.AsyncClient(timeout=120) as client:
response = await client.post(
f"{self._settings.ollama_base_url}/api/chat",
json={
"model": self._settings.ollama_model,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
"stream": False,
},
)
response.raise_for_status()
return response.json().get("message", {}).get("content", "")