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,25 @@
"""Lightweight, best-effort regex extractors for specific signal types the
severity model treats specially (pricing, leadership). These are heuristics,
not NLP - they exist to catch the common "$X/month" and "named a new CEO"
phrasings, not to parse arbitrary text reliably. Phase 7's LLM extraction
task is the higher-fidelity version of this; these run cheaply and
deterministically as part of scoring, without a model call.
"""
from __future__ import annotations
import re
_PRICE_RE = re.compile(r"\$\s?\d[\d,]*(?:\.\d{2})?\s*(?:/\s*(?:month|mo|year|yr))?")
_LEADERSHIP_TITLE_RE = re.compile(
r"(?i)\b(Chief Executive Officer|CEO|Chief Financial Officer|CFO|Chief Technology Officer|"
r"CTO|President|Chairman|Chairwoman|Chairperson)\b"
)
def extract_prices(text: str) -> set[str]:
return set(_PRICE_RE.findall(text or ""))
def mentions_leadership_title(text: str) -> bool:
return bool(_LEADERSHIP_TITLE_RE.search(text or ""))
@@ -0,0 +1,32 @@
"""Strips content that changes on every fetch but carries no meaning, so it
never counts toward a text diff. Applied before Layer 3 (text diff) - see
ARCHITECTURE.md and spec section 18.
"""
from __future__ import annotations
import re
_NOISE_PATTERNS = [
# Dynamic timestamps: "Updated: 2026-01-01", "Last modified 01/02/2026 14:30"
re.compile(r"(?i)\b(updated|last modified|generated|retrieved)\s*:?\s*[\d/:\-\sTZ]+"),
# Session/CSRF-style tokens embedded in visible text (rare, but happens on thin pages)
re.compile(r"\b[A-Za-z0-9_-]{24,}\b"),
# Cookie/consent banner boilerplate
re.compile(r"(?i)we use cookies[^.]*\.?"),
re.compile(r"(?i)by (continuing|using this site)[^.]*\.?"),
# Copyright year lines, which change every January with no real signal
re.compile(r"(?i)copyright\s*(?:©|\(c\))?\s*\d{4}[\-]?\d{0,4}"),
# View/like/share counters
re.compile(r"(?i)\b\d[\d,]*\s*(views|likes|shares)\b"),
]
_WHITESPACE_RUN = re.compile(r"[ \t]{2,}")
_BLANK_LINES = re.compile(r"\n{3,}")
def strip_noise(text: str) -> str:
for pattern in _NOISE_PATTERNS:
text = pattern.sub(" ", text)
text = _WHITESPACE_RUN.sub(" ", text)
return _BLANK_LINES.sub("\n\n", text).strip()
+103
View File
@@ -0,0 +1,103 @@
"""Layer 5: significance scoring + severity classification.
This is the documented, unit-tested formula referenced in ARCHITECTURE.md.
Deterministic on purpose - severity must be explainable and reproducible
without a model call. Phase 7's LLM analysis narrates *why* a change
matters; it does not decide *how much* it matters.
significance = base_weight
* source_trust_score (0.3 - 1.0)
* min(1.0, independent_sources / 2) # corroboration, caps at 2 sources
* focus_match_multiplier (1.3 if it matches the user's stated focus, else 1.0)
* recency_multiplier (1.0 if new, 0.5 if a repeat of a recent change)
confidence = clamp(
0.5 * extraction_confidence + 0.4 * source_trust_score + 0.1
+ (0.15 if independent_sources >= 2 else 0.0),
0.0, 1.0
)
severity = bucket(significance * confidence), with a hard floor:
CRITICAL requires confidence >= CRITICAL_MIN_CONFIDENCE regardless of score -
an uncorroborated single-source signal can never be labeled Critical.
"""
from __future__ import annotations
from app.models.enums import ChangeType, SeverityLevel
BASE_WEIGHTS: dict[ChangeType, float] = {
ChangeType.LEADERSHIP_CHANGE: 0.9,
ChangeType.FILING_NEW: 0.85,
ChangeType.PRICE_CHANGE: 0.6,
ChangeType.NEW_DOCUMENT: 0.5,
ChangeType.CONTENT_MODIFIED: 0.3, # further scaled by diff_ratio - see compute_significance
ChangeType.REMOVED_DOCUMENT: 0.3,
}
# (score_threshold, severity) - first match wins, checked highest first.
# Calibrated against compute_significance/compute_confidence's actual output
# range (a single-source signal is already discounted ~2x by the
# corroboration multiplier) so CRITICAL_MIN_CONFIDENCE below is reachable:
# with significance capped at 1.0, a score of 0.6 needs confidence >= 0.6,
# which leaves room for the 0.7 confidence floor to actually bite and
# downgrade a subset of would-be-CRITICAL cases to HIGH.
SEVERITY_THRESHOLDS: list[tuple[float, SeverityLevel]] = [
(0.6, SeverityLevel.CRITICAL),
(0.4, SeverityLevel.HIGH),
(0.2, SeverityLevel.MEDIUM),
(0.0, SeverityLevel.LOW),
]
CRITICAL_MIN_CONFIDENCE = 0.7
def compute_significance(
*,
change_type: ChangeType,
source_trust_score: float,
independent_source_count: int = 1,
focus_match: bool = False,
is_repeat: bool = False,
diff_ratio: float | None = None,
) -> float:
base = BASE_WEIGHTS[change_type]
if change_type is ChangeType.CONTENT_MODIFIED and diff_ratio is not None:
# A one-line wording tweak and a full page rewrite are both
# "content_modified" but shouldn't score the same.
base = base + diff_ratio * 0.5
trust_multiplier = _clamp(source_trust_score, 0.3, 1.0)
corroboration_multiplier = min(1.0, independent_source_count / 2)
focus_multiplier = 1.3 if focus_match else 1.0
recency_multiplier = 0.5 if is_repeat else 1.0
significance = (
base * trust_multiplier * corroboration_multiplier * focus_multiplier * recency_multiplier
)
return round(_clamp(significance, 0.0, 1.0), 4)
def compute_confidence(
*,
extraction_confidence: float,
source_trust_score: float,
independent_source_count: int = 1,
) -> float:
corroboration_bonus = 0.15 if independent_source_count >= 2 else 0.0
confidence = 0.5 * extraction_confidence + 0.4 * source_trust_score + 0.1 + corroboration_bonus
return round(_clamp(confidence, 0.0, 1.0), 4)
def classify_severity(significance: float, confidence: float) -> SeverityLevel:
score = significance * confidence
for threshold, severity in SEVERITY_THRESHOLDS:
if score >= threshold:
if severity is SeverityLevel.CRITICAL and confidence < CRITICAL_MIN_CONFIDENCE:
return SeverityLevel.HIGH
return severity
return SeverityLevel.LOW # pragma: no cover - thresholds bottom out at 0.0
def _clamp(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
@@ -0,0 +1,33 @@
"""Layer 2: structured field comparison.
Compares the *set* of items (job postings, press releases, filings,
products - whatever the source's documents represent) between two
snapshots' `structured_summary["urls"]`/`["titles"]`. This is what catches
"a new job posting appeared" or "a press release was removed" without
needing a bespoke parser per source type - collection_service already
records the full current item set on every run, so this is a plain set
diff between two runs.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class StructuredDiff:
added: list[str] = field(default_factory=list)
removed: list[str] = field(default_factory=list)
@property
def has_changes(self) -> bool:
return bool(self.added or self.removed)
def diff_item_sets(previous_urls: list[str], current_urls: list[str]) -> StructuredDiff:
previous_set = set(previous_urls)
current_set = set(current_urls)
return StructuredDiff(
added=sorted(current_set - previous_set),
removed=sorted(previous_set - current_set),
)
@@ -0,0 +1,56 @@
"""Layer 3: bounded text diff.
Runs on noise-stripped text (see noise_filters.py) so navigation/cookie/
timestamp churn doesn't register as a change. Bounded: only a capped number
of added/removed lines are kept, so a full page rewrite doesn't produce an
unbounded diff blob for storage or LLM consumption later.
"""
from __future__ import annotations
import difflib
from dataclasses import dataclass, field
from app.change_detection.noise_filters import strip_noise
_MAX_DIFF_LINES = 40
@dataclass(frozen=True)
class TextDiffResult:
diff_ratio: float # 0.0 = identical, 1.0 = completely different
added_lines: list[str] = field(default_factory=list)
removed_lines: list[str] = field(default_factory=list)
@property
def is_identical(self) -> bool:
return self.diff_ratio == 0.0
def bounded_text_diff(previous_text: str, current_text: str) -> TextDiffResult:
previous_clean = strip_noise(previous_text or "")
current_clean = strip_noise(current_text or "")
if previous_clean == current_clean:
return TextDiffResult(diff_ratio=0.0)
previous_lines = [line for line in previous_clean.splitlines() if line.strip()]
current_lines = [line for line in current_clean.splitlines() if line.strip()]
matcher = difflib.SequenceMatcher(a=previous_lines, b=current_lines, autojunk=False)
similarity = matcher.ratio()
diff_ratio = round(1.0 - similarity, 4)
added: list[str] = []
removed: list[str] = []
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
if tag in ("replace", "delete"):
removed.extend(previous_lines[i1:i2])
if tag in ("replace", "insert"):
added.extend(current_lines[j1:j2])
return TextDiffResult(
diff_ratio=diff_ratio,
added_lines=added[:_MAX_DIFF_LINES],
removed_lines=removed[:_MAX_DIFF_LINES],
)