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
View File
@@ -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)
+39
View File
@@ -0,0 +1,39 @@
"""Shared helpers for building prompts and (for MockLLMProvider) recovering
the structured evidence a prompt was built from, without a real model call.
Every analysis task embeds its evidence as a fenced JSON block via
`build_user_prompt`, so this stays consistent across all six tasks and lets
the mock provider parse it back out deterministically.
"""
from __future__ import annotations
import json
from typing import Any
_EVIDENCE_FENCE_START = "```json evidence"
_EVIDENCE_FENCE_END = "```"
def build_user_prompt(instructions: str, evidence: dict[str, Any]) -> str:
evidence_json = json.dumps(evidence, indent=2, default=str)
return (
f"{instructions}\n\n"
"Evidence (only use what is provided here - never invent facts not present):\n"
f"{_EVIDENCE_FENCE_START}\n{evidence_json}\n{_EVIDENCE_FENCE_END}"
)
def extract_evidence_block(user_prompt: str) -> dict[str, Any]:
"""Recovers the evidence dict embedded by `build_user_prompt`. Used only
by MockLLMProvider, which has no model to actually read the prompt."""
start = user_prompt.find(_EVIDENCE_FENCE_START)
if start == -1:
return {}
start += len(_EVIDENCE_FENCE_START)
end = user_prompt.find(_EVIDENCE_FENCE_END, start)
if end == -1:
return {}
try:
return json.loads(user_prompt[start:end].strip())
except json.JSONDecodeError:
return {}
@@ -0,0 +1,60 @@
"""Task E: change significance narrative.
Severity itself stays deterministic (app/change_detection/scoring.py) - see
ARCHITECTURE.md: "the LLM narrates why a change matters; it does not decide
how much it matters." This task supplies the narrative explanation and a
second, independent opinion on whether the change looks real/meaningful and
notification-worthy, which the notification layer (Phase 8) can use as an
extra signal alongside the deterministic severity - it never overrides it.
"""
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 are a competitive intelligence analyst reviewing one detected change between two "
"snapshots of a company's public information. Explain in plain language why this change "
"would or wouldn't matter to someone monitoring this company, given their stated focus. "
"Be honest about uncertainty - do not overstate a routine wording tweak as significant, "
"and do not undersell a genuine signal."
)
class ChangeSignificanceAssessment(BaseModel):
is_real_change: bool = Field(description="Does this look like a genuine change, not noise?")
is_meaningful: bool
why_it_matters: str
confidence: float = Field(ge=0.0, le=1.0)
should_notify: bool
async def assess_change_significance(
llm: LLMProvider,
*,
company_name: str,
monitoring_focus: str | None,
change_type: str,
change_summary: str,
added_text: list[str],
removed_text: list[str],
deterministic_severity: str,
deterministic_confidence: float,
) -> ChangeSignificanceAssessment:
evidence = {
"company_name": company_name,
"monitoring_focus": monitoring_focus,
"change_type": change_type,
"change_summary": change_summary,
"text_added": added_text[:20],
"text_removed": removed_text[:20],
"deterministic_severity": deterministic_severity,
"deterministic_confidence": deterministic_confidence,
}
user_prompt = build_user_prompt(
"Assess this detected change and explain why it does or doesn't matter.", evidence
)
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, ChangeSignificanceAssessment)
+72
View File
@@ -0,0 +1,72 @@
"""Company-profile discovery extraction. Used once per company, at
onboarding time (see app/services/discovery_service.py) - never asked
"tell me everything about this company," only "given this fetched
homepage text and these search snippets, extract what's actually
supported." Leaves a field unset rather than guessing when the evidence
doesn't support it; the wizard's Review step shows unset fields as
"not found" for the user to fill in themselves.
"""
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 are a competitive intelligence analyst building an initial company profile from "
"search results and a fetched homepage. Extract only what the evidence actually states or "
"clearly implies - never use outside knowledge, never guess. Leave a field null (or an "
"empty list) rather than filling it with a plausible-sounding guess. Aliases means other "
"names the company is or was known by (former names, common abbreviations, brand names) - "
"not synonyms or descriptions. Competitors means other named companies the evidence "
"explicitly identifies as competing in the same space."
)
class PublicIdentifier(BaseModel):
key: str = Field(description="e.g. 'ticker', 'linkedin_url', 'cik'")
value: str
class CompanyProfileExtraction(BaseModel):
description: str | None = Field(
default=None,
description="A short (1-3 sentence) factual summary of what the company does, drawn "
"strictly from the evidence - not a marketing tagline.",
)
industry: str | None = None
country: str | None = None
region: str | None = None
headquarters: str | None = None
aliases: list[str] = Field(default_factory=list)
competitors: list[str] = Field(default_factory=list)
public_identifiers: list[PublicIdentifier] = Field(
default_factory=list,
description="Best-effort key/value pairs, e.g. ticker or linkedin_url - empty if none found. "
"A list of {key, value} pairs rather than a free-form object, since the Gemini Developer "
"API's structured-output mode rejects open-ended (additionalProperties) JSON schemas.",
)
async def extract_company_profile(
llm: LLMProvider,
*,
company_name: str,
homepage_url: str | None,
homepage_text: str | None,
search_results: list[dict],
) -> CompanyProfileExtraction:
evidence = {
"company_name": company_name,
"homepage_url": homepage_url,
"homepage_text": (homepage_text or "")[:4000],
"search_results": search_results[:15],
}
user_prompt = build_user_prompt(
"Build an initial company profile (description, industry, country, region, "
"headquarters, aliases, competitors, public identifiers) strictly from this evidence.",
evidence,
)
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, CompanyProfileExtraction)
+54
View File
@@ -0,0 +1,54 @@
"""Task B: fact and signal extraction. Pulls discrete, evidence-anchored
signals (hiring, partnerships, leadership, financial, etc.) out of a single
document - the atomic units Task C (synthesis) and Task D (report
generation) later combine."""
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 are a competitive intelligence analyst extracting discrete factual signals from a "
"single document. Extract only what the text actually states or clearly implies - never "
"add outside knowledge. Every signal must include the exact passage that supports it."
)
_SIGNAL_TYPES = (
"event, entity, date, location, product, leadership, partnership, hiring, investment, "
"manufacturing, technology, financial, regulatory, sentiment"
)
class ExtractedSignal(BaseModel):
signal_type: str = Field(description=f"One of: {_SIGNAL_TYPES}")
description: str
supporting_passage: str = Field(description="The exact quote from the document backing this")
date: str | None = None
entities: list[str] = Field(default_factory=list)
class ExtractionResult(BaseModel):
signals: list[ExtractedSignal] = Field(default_factory=list)
async def extract_signals(
llm: LLMProvider,
*,
document_title: str | None,
document_url: str,
document_text: str,
) -> ExtractionResult:
evidence = {
"document_title": document_title,
"document_url": document_url,
"document_text": document_text[:6000],
}
user_prompt = build_user_prompt(
"Extract every discrete factual signal from this document, each anchored to its "
"exact supporting passage.",
evidence,
)
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, ExtractionResult)
+51
View File
@@ -0,0 +1,51 @@
"""Task A: document relevance. Decides whether a collected document is
actually about the target company and matches the user's stated focus,
before it's used as evidence for anything else."""
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 are a competitive intelligence analyst. Given a single document and a company "
"profile, judge only whether the document is genuinely about that company and whether "
"it relates to the user's stated monitoring focus. Do not summarize the document's "
"content here - a later task does that. Be conservative: if the document could be about "
"a different company with a similar name, say so."
)
class RelevanceAssessment(BaseModel):
is_relevant: bool = Field(description="Is this document genuinely about the target company?")
matches_focus: bool = Field(description="Does it relate to the user's stated monitoring focus?")
topic_categories: list[str] = Field(default_factory=list)
source_reliability: float = Field(ge=0.0, le=1.0)
reasoning: str
async def assess_relevance(
llm: LLMProvider,
*,
company_name: str,
company_aliases: list[str],
monitoring_focus: str | None,
document_title: str | None,
document_text: str,
document_url: str,
) -> RelevanceAssessment:
evidence = {
"company_name": company_name,
"company_aliases": company_aliases,
"monitoring_focus": monitoring_focus,
"document_title": document_title,
"document_url": document_url,
"document_text": document_text[:4000],
}
user_prompt = build_user_prompt(
"Assess whether this document is about the target company and matches its monitoring focus.",
evidence,
)
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, RelevanceAssessment)
+135
View File
@@ -0,0 +1,135 @@
"""Task D: report generation. Turns accumulated evidence (source documents +
detected changes) for a company into the structured CI report (spec section
17). The LLM only ever sees evidence this pipeline collected - it is
explicitly instructed not to introduce outside knowledge, and every
non-trivial claim must carry a confidence label and evidence references.
"""
from __future__ import annotations
from pydantic import BaseModel, Field
from app.analysis.llm.base import LLMProvider
from app.prompts.base import build_user_prompt
from app.prompts.schemas import ConfidenceLabel, EvidenceRef, Finding
SYSTEM_PROMPT = (
"You are a competitive intelligence analyst producing a structured report about a "
"company, using ONLY the evidence provided - the company_profile block (discovered once "
"at onboarding from the company's real website and search results, not your own "
"knowledge), the company_enrichment block (when present - fetched once from a paid "
"third-party data provider at onboarding, also real evidence, not your own knowledge), "
"stored source documents, and previously detected changes. Never introduce "
"facts from outside knowledge, even if you recognize the company - if it isn't in the "
"evidence block, it doesn't go in the report. The company_profile and company_enrichment "
"fields ARE real evidence and should ground company_overview/market_positioning/"
"financial_signals/leadership_changes/products_and_services/competitor_comparison/"
"customer_sentiment/etc even when source_documents and detected_changes are sparse or "
"empty - do not say 'insufficient evidence' for a field company_profile or "
"company_enrichment already answers. Every finding must be "
"traceable to the evidence given and must carry an honest confidence label: confirmed "
"(the source states it directly), strongly_indicated, likely, possible, unconfirmed, or "
"insufficient_evidence. When evidence is thin or missing for a section, say so explicitly "
"in that section rather than inventing content. Distinguish clearly between what a source "
"states and what you are inferring."
)
class InferredProject(BaseModel):
project_name: str
status: ConfidenceLabel
summary: str
confidence: float = Field(ge=0.0, le=1.0)
evidence: list[EvidenceRef] = Field(default_factory=list)
signal_types: list[str] = Field(default_factory=list)
alternative_explanations: list[str] = Field(default_factory=list)
class SwotAnalysis(BaseModel):
strengths: list[str] = Field(default_factory=list)
weaknesses: list[str] = Field(default_factory=list)
opportunities: list[str] = Field(default_factory=list)
threats: list[str] = Field(default_factory=list)
class ReportContent(BaseModel):
executive_summary: str
company_overview: str
products_and_services: list[Finding] = Field(default_factory=list)
market_positioning: str
recent_developments: list[Finding] = Field(default_factory=list)
strategic_initiatives: list[Finding] = Field(default_factory=list)
key_inferred_projects: list[InferredProject] = Field(default_factory=list)
leadership_changes: list[Finding] = Field(default_factory=list)
hiring_signals: list[Finding] = Field(default_factory=list)
technology_signals: list[Finding] = Field(default_factory=list)
patent_signals: list[Finding] = Field(default_factory=list)
manufacturing_and_expansion_signals: list[Finding] = Field(default_factory=list)
partnerships_and_acquisitions: list[Finding] = Field(default_factory=list)
financial_signals: list[Finding] = Field(default_factory=list)
regulatory_and_legal_signals: list[Finding] = Field(default_factory=list)
customer_sentiment: str
competitor_comparison: str
swot: SwotAnalysis
risks: list[str] = Field(default_factory=list)
opportunities: list[str] = Field(default_factory=list)
unknowns_and_missing_data: list[str] = Field(default_factory=list)
monitoring_recommendations: list[str] = Field(default_factory=list)
methodology: str
limitations: str
async def generate_report(
llm: LLMProvider,
*,
company_name: str,
company_aliases: list[str],
competitors: list[str],
monitoring_focus: str | None,
industry: str | None,
documents: list[dict],
detected_changes: list[dict],
sources_failed: list[str],
description: str | None = None,
official_website: str | None = None,
headquarters: str | None = None,
country: str | None = None,
region: str | None = None,
public_identifiers: dict[str, str] | None = None,
enrichment: dict | None = None,
) -> ReportContent:
evidence = {
# Discovered once at onboarding (app/services/discovery_service.py)
# from the company's real website + search results - genuine
# evidence, not the LLM's own background knowledge, and the only
# evidence available before any monitoring run has collected
# source_documents/detected_changes.
"company_profile": {
"name": company_name,
"description": description,
"official_website": official_website,
"aliases": company_aliases,
"competitors": competitors,
"monitoring_focus": monitoring_focus,
"industry": industry,
"headquarters": headquarters,
"country": country,
"region": region,
"public_identifiers": public_identifiers or {},
},
# Fetched once, at onboarding, from a paid third-party provider
# (NinjaPear) - see app/services/enrichment_service.py. Absent for
# any company created before this feature existed, or whose
# enrichment never completed successfully - never fabricated.
"company_enrichment": enrichment or {},
"source_documents": documents,
"detected_changes": detected_changes,
"sources_that_failed_to_collect": sources_failed,
}
user_prompt = build_user_prompt(
"Produce a full structured competitive intelligence report from this evidence. "
"If a section has no supporting evidence, say so explicitly instead of leaving it "
"generic or inventing content.",
evidence,
)
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, ReportContent)
+35
View File
@@ -0,0 +1,35 @@
"""Shared building blocks for analysis-task response schemas. Every finding
that claims something happened carries an explicit confidence label from
this set - never presented as bare fact (spec section 16 / "Evidence and
Anti-Hallucination Requirements")."""
from __future__ import annotations
from enum import StrEnum
from pydantic import BaseModel, Field
class ConfidenceLabel(StrEnum):
CONFIRMED = "confirmed"
STRONGLY_INDICATED = "strongly_indicated"
LIKELY = "likely"
POSSIBLE = "possible"
UNCONFIRMED = "unconfirmed"
INSUFFICIENT_EVIDENCE = "insufficient_evidence"
class EvidenceRef(BaseModel):
source_document_id: str | None = None
detected_change_id: str | None = None
url: str | None = None
description: str = Field(description="What this piece of evidence shows, in one sentence")
class Finding(BaseModel):
headline: str
summary: str
evidence: list[EvidenceRef] = Field(default_factory=list)
confidence: ConfidenceLabel = ConfidenceLabel.UNCONFIRMED
category: str | None = None
date: str | None = None
+54
View File
@@ -0,0 +1,54 @@
"""Task C: cross-source synthesis. Combines related signals from multiple
documents/changes into a conclusion (e.g. "hiring battery engineers" +
"filed a battery patent" + "announced a facility expansion" = "possible
battery manufacturing initiative"), always with alternative explanations and
an explicit accounting of what's still unknown.
"""
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 are a competitive intelligence analyst. Given a set of independently-extracted "
"signals about a company, identify conclusions that multiple signals point toward "
"together. Only draw a conclusion when the evidence actually supports it - state your "
"confidence honestly, list plausible alternative explanations, and note what evidence "
"would be needed to be more certain. Do not synthesize a conclusion from a single signal."
)
class SynthesizedConclusion(BaseModel):
conclusion: str
evidence_summary: list[str] = Field(default_factory=list)
source_count: int = Field(ge=0)
confidence: float = Field(ge=0.0, le=1.0)
alternative_explanations: list[str] = Field(default_factory=list)
missing_information: list[str] = Field(default_factory=list)
class SynthesisResult(BaseModel):
conclusions: list[SynthesizedConclusion] = Field(default_factory=list)
async def synthesize_signals(
llm: LLMProvider,
*,
company_name: str,
monitoring_focus: str | None,
signals: list[dict],
) -> SynthesisResult:
evidence = {
"company_name": company_name,
"monitoring_focus": monitoring_focus,
"signals": signals,
}
user_prompt = build_user_prompt(
"Identify conclusions supported by two or more of these signals together. Do not "
"synthesize from a single signal alone.",
evidence,
)
return await llm.generate_structured(SYSTEM_PROMPT, user_prompt, SynthesisResult)