pryscraper/pry_intelligence.py
cryptorugmunch 0ecc250349 refactor(pry): rename 19 root modules to pry_<name> to remove naming collision with routers/
The duplicate file names (advanced, agency, auth, compliance, costing, etc.) in
both root and routers/ made imports ambiguous. Renamed root modules to
pry_<name>.py so:

  from quality import X     ->  from pry_quality import X
  from routers.quality import router  (unchanged)

Also:
- Renamed x402.py to pry_x402/ package directory
- Fixed 21+ bare imports across api.py, deps.py, routers/, tests/, llm_providers/

Tests: 593 passed, 1 skipped (test_ready_returns_200 fails pre-existing
because Ollama is unreachable from test env, unrelated to this refactor).

Audit item 8.
2026-07-06 10:25:44 +02:00

283 lines
9.9 KiB
Python

"""Pry — Competitive Intelligence Engine.
Historical snapshots, anomaly detection, natural-language alerts, weekly reports."""
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 Rug Munch Media LLC
#
# Part of Pry — https://git.rugmunch.io/RugMunchMedia/pryscraper
# Licensed under MIT. See LICENSE.
import logging
import statistics
import time
from datetime import UTC, datetime, timedelta
from typing import Any
from sqlalchemy import desc
from sqlalchemy.exc import SQLAlchemyError
from db import IntelSnapshot, session_scope
logger = logging.getLogger(__name__)
def record_snapshot(
competitor_id: str,
competitor_name: str,
url: str,
fields: dict[str, Any],
) -> dict[str, Any]:
"""Record a data snapshot for a competitor."""
try:
with session_scope() as session:
snap = IntelSnapshot(
competitor_id=competitor_id,
competitor_name=competitor_name,
url=url,
fields=fields,
)
session.add(snap)
session.flush()
ts = snap.ts
result = {
"ts": ts.isoformat(),
"unix_ts": ts.timestamp(),
"competitor_id": competitor_id,
"competitor_name": competitor_name,
"url": url,
"fields": fields,
}
logger.info("snapshot_recorded", extra={"competitor": competitor_name})
return result
except SQLAlchemyError as e:
logger.error(
"snapshot_record_failed", extra={"competitor": competitor_name, "error": str(e)}
)
return {"error": str(e)}
def get_snapshots(
competitor_id: str,
limit: int = 50,
since_hours: int | None = None,
) -> list[dict[str, Any]]:
"""Get snapshots for a competitor, most recent first."""
try:
with session_scope() as session:
query = session.query(IntelSnapshot).filter(
IntelSnapshot.competitor_id == competitor_id,
)
if since_hours is not None:
cutoff = datetime.now(UTC) - timedelta(hours=since_hours)
query = query.filter(IntelSnapshot.ts >= cutoff)
query = query.order_by(desc(IntelSnapshot.ts)).limit(limit)
rows = query.all()
return [
{
"ts": row.ts.isoformat() if row.ts else "",
"unix_ts": row.ts.timestamp() if row.ts else 0,
"competitor_id": row.competitor_id,
"competitor_name": row.competitor_name,
"url": row.url,
"fields": row.fields or {},
}
for row in rows
]
except SQLAlchemyError as e:
logger.error("snapshot_query_failed", extra={"competitor": competitor_id, "error": str(e)})
return []
def compute_field_statistics(
snapshots: list[dict[str, Any]],
field: str,
) -> dict[str, Any]:
"""Compute statistics for a field across snapshots."""
values = [s.get("fields", {}).get(field) for s in snapshots]
values = [v for v in values if v is not None]
if not values or len(values) < 2:
return {"count": len(values), "has_history": False}
numeric_values = [v for v in values if isinstance(v, (int, float))]
string_values = [str(v) for v in values if isinstance(v, str)]
result: dict[str, Any] = {
"count": len(values),
"has_history": True,
"field": field,
}
if numeric_values:
result["mean"] = round(statistics.mean(numeric_values), 2)
result["median"] = round(statistics.median(numeric_values), 2)
result["min"] = min(numeric_values)
result["max"] = max(numeric_values)
if len(numeric_values) > 2:
result["stdev"] = round(statistics.stdev(numeric_values), 2)
result["latest"] = numeric_values[-1]
result["previous"] = numeric_values[-2] if len(numeric_values) >= 2 else None
if string_values:
result["unique_values"] = len(set(string_values))
result["latest"] = string_values[-1]
result["previous"] = string_values[-2] if len(string_values) >= 2 else None
return result
def detect_anomalies_numeric(
current_value: float,
history: list[float],
z_score_threshold: float = 2.0,
) -> dict[str, Any]:
"""Detect anomalies in a numeric field using z-score."""
if len(history) < 3:
return {"anomaly": False, "reason": "Insufficient history"}
mean = statistics.mean(history)
stdev = statistics.stdev(history) if len(history) > 1 else 1.0
if stdev == 0:
return {"anomaly": current_value != mean, "reason": "Value changed from constant history"}
z_score = abs((current_value - mean) / stdev)
pct_change = ((current_value - mean) / mean) * 100 if mean != 0 else 0
return {
"anomaly": z_score >= z_score_threshold,
"z_score": round(z_score, 2),
"pct_change": round(pct_change, 1),
"mean": round(mean, 2),
"stdev": round(stdev, 2),
"severity": "high" if z_score >= 3.0 else "medium" if z_score >= 2.0 else "low",
}
def generate_alert(
competitor_name: str,
field: str,
old_value: Any,
new_value: Any,
anomaly_info: dict[str, Any] | None = None,
) -> str:
"""Generate a natural-language alert for a detected change."""
intro = f"*{competitor_name}*"
if isinstance(new_value, (int, float)) and isinstance(old_value, (int, float)):
pct = ((new_value - old_value) / old_value) * 100 if old_value != 0 else 0
direction = "increased" if pct > 0 else "decreased"
change_part = f"{direction} {field} from {old_value} to {new_value} ({abs(pct):.1f}%)"
elif isinstance(new_value, str) and isinstance(old_value, str):
if len(new_value) > 50 or len(old_value) > 50:
change_part = (
f"changed {field} (length: {len(old_value)} \u2192 {len(new_value)} chars)"
)
else:
change_part = f'changed {field}: "{old_value}" \u2192 "{new_value}"'
else:
change_part = f"updated {field}"
severity = ""
if anomaly_info and anomaly_info.get("anomaly"):
severity = " \u26a0\ufe0f *ANOMALY DETECTED*"
alert = f"{intro} {change_part}{severity}"
if anomaly_info and anomaly_info.get("z_score"):
alert += f" (z-score: {anomaly_info['z_score']})"
if anomaly_info and anomaly_info.get("pct_change"):
alert += f" \u2014 unusual change of {anomaly_info['pct_change']}% vs historical average"
return alert
def generate_weekly_report(
competitors: list[dict[str, Any]],
days_back: int = 7,
) -> dict[str, Any]:
"""Generate a weekly competitive intelligence report."""
cutoff_ts = time.time() - (days_back * 86400)
report_sections: list[dict[str, Any]] = []
for comp in competitors:
comp_id = comp.get("id", comp.get("name", "").lower().replace(" ", "_"))
comp_name = comp.get("name", "Unknown")
snapshots = get_snapshots(comp_id)
weekly = [s for s in snapshots if s.get("unix_ts", 0) >= cutoff_ts]
if not weekly:
continue
if len(weekly) >= 2:
latest = weekly[0].get("fields", {})
oldest = weekly[-1].get("fields", {})
changes = []
all_fields = set(latest.keys()) | set(oldest.keys())
for field in all_fields:
old_val = oldest.get(field)
new_val = latest.get(field)
if old_val != new_val:
changes.append(
{
"field": field,
"from": old_val,
"to": new_val,
"alert": generate_alert(comp_name, field, old_val, new_val),
}
)
if changes:
report_sections.append(
{
"competitor": comp_name,
"changes_count": len(changes),
"snapshots_this_week": len(weekly),
"changes": changes,
}
)
total_changes = sum(s["changes_count"] for s in report_sections)
most_active = (
max(report_sections, key=lambda x: x["changes_count"]) if report_sections else None
)
return {
"report_period": f"Last {days_back} days",
"generated_at": datetime.now(UTC).isoformat(),
"competitors_tracked": len(competitors),
"competitors_with_changes": len(report_sections),
"total_changes": total_changes,
"most_active_competitor": most_active["competitor"] if most_active else None,
"sections": report_sections,
"summary": _generate_report_summary(report_sections, total_changes, len(competitors)),
}
def _generate_report_summary(
sections: list[dict[str, Any]],
total_changes: int,
total_competitors: int,
) -> str:
"""Generate a text summary of the weekly report."""
if not sections:
return f"No significant changes detected across {total_competitors} tracked competitors."
most_active = max(sections, key=lambda x: x["changes_count"])
lines = [
"Weekly Competitive Intelligence Summary",
"",
f"Tracked {total_competitors} competitors over the past 7 days.",
f"Detected {total_changes} changes across {len(sections)} competitors.",
"",
f"Most active: {most_active['competitor']} with {most_active['changes_count']} changes.",
]
for section in sections[:5]:
lines.append("")
lines.append(f"\u2500\u2500 {section['competitor']} \u2500\u2500")
for change in section["changes"][:3]:
lines.append(f" \u2022 {change['alert']}")
if len(section["changes"]) > 3:
lines.append(f" ... and {len(section['changes']) - 3} more changes")
return "\n".join(lines)