#4889 AI-Powered Pain Point Discovery App

#4889 AI-Powered Pain Point Discovery App

#4889 Describe a web app to search online daily for industries and user groups needing fixes and improvements to systems, services, products and processes

A web app designed to continuously surface market gaps, friction points, and improvement opportunities operates essentially as an Automated Problem Discovery Engine (often referred to as an Opportunity Mining or Pain Point Intelligence platform).

1. Core Architecture & Ingestion Pipeline

To deliver fresh opportunities every day, the application needs an automated multi-source ingestion layer:

  • Public Forums & Communities: Crawls Reddit (e.g., r/sysadmin, r/smallbusiness, r/logistics), Quora, Hacker News, and Discord communities where practitioners complain about daily workflows.

  • B2B & Software Review Platforms: Ingests low-rating (1–3 star) reviews and "cons" sections from G2, Capterra, Trustpilot, App Store, and Google Play to pinpoint missing features, poor support, and fragile integrations.

  • Workforce & Internal Sentiment: Scrapes public Glassdoor and Indeed reviews to identify internal process bottlenecks, manual spreadsheets, and broken tooling inside specific sectors.

  • Regulatory & Compliance Portals: Monitors industry-specific government and trade publications (e.g., FDA notices, OSHA updates, supply-chain compliance mandates) that force operational overhauls.

2. Intelligence & Natural Language Processing (NLP) Layer

Raw web text is noisy. The backend uses dedicated NLP classifiers to transform unstructured complaints into actionable business signals:

  • Friction & Intent Detection: Identifies complaint linguistics (e.g., "Why isn't there a tool that...", "We spend 5 hours a week manually copy-pasting...", "X platform broke our workflow").

  • Taxonomy & Categorization: Automatically maps every discovery into a structured classification:

    • Dimension: Product (bugs, UI friction), System (data silos, downtime), Service (poor vendor SLA, lack of onboarding), or Process (compliance delays, manual entry).

    • Industry & Segment: Healthcare, FinTech, Construction, E-commerce Logistics, Legal, etc.

    • User Persona: Operations Director, Solo Founder, Field Technician, Billing Specialist, etc.

  • Clustering & Deduplication: Aggregates isolated rants into persistent trends (e.g., grouping 120 separate mentions of Shopify inventory sync errors into a single high-priority trend).

3. Opportunity Scoring Algorithm

To prevent information overload, every surfaced issue receives an automated Viability Score based on four quantitative vectors:

MetricMeasurement SignalStrategic Value
Frequency / VelocityDay-over-day and week-over-week mention volumeSeparates chronic systemic problems from one-off bugs.
Willingness to Pay (WTP)Budget-indicator keywords ("paying $2k/mo for software that can't even...", commercial team size)Filters out low-value consumer gripes from high-value enterprise gaps.
Sentiment SeverityFrustration index, business impact metrics (lost revenue, compliance fines, employee churn)Prioritizes mission-critical systems over minor cosmetic annoyances.
Incumbent VulnerabilityNumber of complaints aimed at established legacy software or market leadersHighlights disruption and feature-gap opportunities.

4. Key User Features & UI

  • Daily Opportunity Feed: A curated morning briefing showing top emerging friction points grouped by vertical and severity.

  • Deep-Dive Problem Dossiers: Clicking an issue shows:

    • Synthesized executive summary of the core defect.

    • Direct links and quotes to original source conversations.

    • Existing vendor alternatives and where they fall short.

    • Suggested intervention vectors (e.g., "Micro-SaaS integration", "Managed BPO service", "Automation script").

  • Custom Watchlists & Alerts: Users can set tracking triggers (e.g., "Alert me if complaints about HIPAA billing systems spike above 20 mentions this week").

  • Export & Validation Toolkit: One-click exports to CSV/Notion, along with pre-generated cold outreach scripts and survey questions to validate the pain point with real practitioners.

5. Primary Target Audiences

  • Entrepreneurs & Indie Hackers: To validate and build products that solve verified, pre-existing market demand rather than guessing.

  • Product Managers & R&D Teams: To monitor competitor vulnerabilities and benchmark user dissatisfaction across their industry.

  • Consultants & System Integrators: To spot specific processes, manual tasks, and software handoffs ready for custom implementation contracts.

  • Venture Capitalists & Search Funds: For thesis generation, market due diligence, and finding sectors with poor net promoter dynamics.


Detail the technical architecture, crawler stack, and database schema for an automated web pain point discovery app.

Technical Architecture Overview

An automated pain point discovery engine operates as an asynchronous, event-driven data pipeline designed to ingest noisy web conversations, extract structured commercial signals, and score them for market viability.

[Target Sources] 
  (Reddit, G2, Trustpilot, App Stores, Forums, Regulatory Boards)
       │
       ▼
[Distributed Crawling & Ingestion Layer]
  (Playwright / Crawl4AI / Async HTTP workers via Celery/Redis)
       │
       ▼
[Raw Data Lake / Staging Store] 
  (PostgreSQL JSONB / S3-compatible Object Storage)
       │
       ▼
[NLP & Transformation Pipeline]
  (Deduplication -> Heuristic Filter -> Entity/Topic Extraction -> LLM Evaluation)
       │
       ▼
[Structured Relational & Vector Stores]
  (PostgreSQL + pgvector for semantic search & clustering)
       │
       ▼
[Application API & Front-End Service]
  (FastAPI / React Dashboard / Notification Workers)

1. Crawler & Ingestion Stack

The crawler tier decouples raw ingestion from NLP processing to handle rate limits, platform anti-bot measures, and asynchronous schedules cleanly.

Ingestion Components

  • Scraping Engines:

    • Headless Browsing & Dynamic Rendering: Crawl4AI or Playwright (Python) with stealth configurations for JavaScript-heavy review platforms (e.g., G2, Trustpilot, Capterra).

    • Lightweight Async API/HTML Crawling: httpx or aiohttp combined with selectolax / Beautiful Soup 4 for high-throughput endpoints (public RSS feeds, static forums, official APIs).

  • Job Orchestration & Scheduling:

    • Celery backed by Redis (or Temporal for long-running resilient crawler workflows).

    • Partitioned queues: queue_reddit_realtime, queue_reviews_daily, queue_enrichment_llm.

  • Anti-Bot & Proxy Routing:

    • Rotating residential proxy pools with dynamic user-agent injection and exponential backoff retry algorithms to prevent IP rate-limiting.

Execution Workflow

1.Ingest Target Sources:Runs on cron-scheduled Celery queues.

Workers poll platform APIs (Reddit API / PRAW) and crawl review aggregators using rotating proxies. Raw responses (HTML / JSON) are captured directly into an append-only staging store with source metadata.

2.Rule-Based Pre-Filtering:Dumps 80%+ irrelevant consumer noise before LLM stages.

Lightweight regex and heuristic rules flag obvious non-signals (e.g., self-promotion, memes, spam, unrelated customer support tickets like simple shipping delays). Text is tokenized and deduplicated using MinHash / SimHash.

3.Structured Extraction & Enrichment:Batch worker processing via local/hosted LLM inference.

Texts passing heuristic thresholds are dispatched to inference pipelines. LLMs classify pain category (System, Product, Service, Process), extract target personas, identify incumbent software mentioned, and output an extraction confidence score.

4.Vector Embedding & Semantic Clustering:pgvector pipeline.

Generate text embeddings using dense vector models (e.g., text-embedding-3-small or local bge-large-en). Store embeddings in PostgreSQL using pgvector to cluster matching problem statements across different platforms.

5.Opportunity Scoring & Indexing:Final relational commit.

Compute the composite Viability Score (combining volume velocity, sentiment intensity, and willingness-to-pay indicators). Write final records to queryable tables and push real-time alerts to subscribed users.

2. Relational Database Schema (PostgreSQL + pgvector)

Using PostgreSQL with pgvector eliminates the operational overhead of running separate relational and vector databases while maintaining full ACID compliance and relational integrity.

-- Enable necessary extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "vector";

-- 1. Source Platforms & Tracking Registry
CREATE TABLE sources (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    platform_name VARCHAR(64) NOT NULL, -- e.g., 'reddit', 'g2', 'trustpilot', 'play_store'
    target_identifier VARCHAR(255) NOT NULL, -- e.g., 'r/sysadmin', 'salesforce-reviews', 'app-id'
    category_hint VARCHAR(128), -- e.g., 'IT Infrastructure', 'CRM', 'Healthcare Billing'
    is_active BOOLEAN DEFAULT TRUE,
    crawl_interval_minutes INT DEFAULT 1440,
    last_crawled_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

-- 2. Raw Ingested Posts / Reviews
CREATE TABLE raw_ingested_items (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    source_id UUID NOT NULL REFERENCES sources(id) ON DELETE CASCADE,
    external_id VARCHAR(255) NOT NULL, -- Unique ID on target platform
    author_handle VARCHAR(128),
    url TEXT NOT NULL,
    title TEXT,
    body TEXT NOT NULL,
    raw_payload JSONB, -- Full unmodified response payload
    published_at TIMESTAMPTZ NOT NULL,
    ingested_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    processed_status VARCHAR(32) DEFAULT 'pending', -- 'pending', 'processed', 'discarded'
    CONSTRAINT uq_source_external UNIQUE (source_id, external_id)
);

CREATE INDEX idx_raw_status ON raw_ingested_items(processed_status);
CREATE INDEX idx_raw_published ON raw_ingested_items(published_at DESC);

-- 3. High-Level Problem Clusters (Aggregated Market Opportunities)
CREATE TABLE problem_clusters (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    title VARCHAR(255) NOT NULL,
    executive_summary TEXT NOT NULL,
    industry VARCHAR(128) NOT NULL, -- e.g., 'Logistics', 'Construction', 'Healthcare'
    target_persona VARCHAR(128) NOT NULL, -- e.g., 'Fleet Dispatcher', 'Subcontractor'
    dimension VARCHAR(32) NOT NULL, -- 'system', 'product', 'service', 'process'
    incumbent_solutions TEXT[], -- e.g., ARRAY['SAP ERP', 'Salesforce']
    first_seen_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    last_updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    total_mentions INT DEFAULT 1,
    velocity_7d INT DEFAULT 0, -- Mentions in last 7 days
    willingness_to_pay_score NUMERIC(3,2), -- 0.00 to 1.00
    viability_score NUMERIC(4,2), -- Composite calculated metric
    centroid_embedding vector(1536) -- Running cluster centroid for similarity matching
);

CREATE INDEX idx_cluster_viability ON problem_clusters(viability_score DESC);
CREATE INDEX idx_cluster_industry ON problem_clusters(industry);

-- 4. Extracted Pain Points (Granular Observations Linked to Clusters)
CREATE TABLE extracted_pain_points (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    raw_item_id UUID NOT NULL REFERENCES raw_ingested_items(id) ON DELETE CASCADE,
    cluster_id UUID REFERENCES problem_clusters(id) ON DELETE SET NULL,
    extracted_quote TEXT NOT NULL,
    root_cause TEXT,
    severity_rating INT CHECK (severity_rating BETWEEN 1 AND 5),
    wtp_signals JSONB, -- Extracted quotes indicating budget/team size/switching cost
    embedding vector(1536), -- Vector representation of the individual complaint
    extracted_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_extracted_cluster ON extracted_pain_points(cluster_id);
-- HNSW Index for rapid approximate nearest neighbor (ANN) searches
CREATE INDEX idx_pain_point_vector ON extracted_pain_points 
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 5. User Watchlists & Alert Rules
CREATE TABLE user_alerts (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    user_id UUID NOT NULL,
    query_industry VARCHAR(128),
    query_dimension VARCHAR(32),
    min_viability_score NUMERIC(4,2) DEFAULT 7.00,
    notification_channel VARCHAR(32) DEFAULT 'email', -- 'email', 'webhook', 'slack'
    webhook_url TEXT,
    is_active BOOLEAN DEFAULT TRUE,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

3. Data Processing & Scoring Logic

The scoring engine evaluates each cluster on a periodic sweep (e.g., daily cron worker):

Viability Score = (0.35 x Velocity) + (0.30 x WTP) + (0.20 x Severity) + (0.15 x Incumbent Vulnerability)

  • Velocity: Scaled metric of mentions over rolling 7\-day vs 30-day} windows.

  • WTP (Willingness to Pay): Extracted by LLM based on specific markers: enterprise team sizes, budget mentions, software spend, or revenue impact.

  • Severity: Normalized average of practitioner friction ratings (1 to 5) and operational impact statements (e.g., audit failures, downtime, overtime hours).

  • Incumbent Vulnerability: Derived from the density of unaddressed feature requests or negative sentiment specifically targeted at dominant legacy vendors.


Write the Pydantic schemas and structured LLM extraction prompts for this pain point discovery pipeline.

Pydantic Extraction Schemas

The following schemas enforce strict type validation, enum constraints, and field-level bounds to capture granular problem taxonomy, customer sentiment severity, and commercial willingness to pay (WTP).

from enum import Enum
from typing import List, Optional
from pydantic import BaseModel, Field


class ProblemDimension(str, Enum):
    PRODUCT = "product"    # Software bugs, UX friction, missing features, poor API
    SYSTEM = "system"      # Architectural bottlenecks, sync errors, downtime, scale limits
    SERVICE = "service"    # Customer support failures, onboarding delays, vendor lock-in
    PROCESS = "process"    # Manual repetitive tasks, compliance friction, cross-team silos


class WTPSignal(BaseModel):
    has_budget_signal: bool = Field(
        ...,
        description="True if the text cites existing spend, software tiers, team headcount, or explicit budget."
    )
    exact_quote: Optional[str] = Field(
        None,
        description="Verbatim text demonstrating budget or financial impact (e.g., 'paying $1,200/mo', 'costing 10 hrs/week')."
    )
    wtp_tier: str = Field(
        ...,
        description="Estimated budget capacity: 'enterprise' (>$10k/yr), 'smb' ($1k-$10k/yr), 'prosumer' (<$1k/yr), or 'none'."
    )
    switching_intent: bool = Field(
        ...,
        description="True if the user actively seeks alternatives or threatens to cancel an existing vendor."
    )


class PainPointExtraction(BaseModel):
    is_valid_commercial_pain: bool = Field(
        ...,
        description="True if the text represents a genuine business, operational, or technical friction point. False if spam, low-effort rant, or consumer dispute."
    )
    industry: str = Field(
        ...,
        description="Target industry vertical (e.g., 'Healthcare & EHR', 'B2B Logistics', 'Civil Engineering', 'FinTech')."
    )
    target_persona: str = Field(
        ...,
        description="Role or user group experiencing the pain (e.g., 'Fleet Dispatcher', 'DevOps Lead', 'Site Safety Officer')."
    )
    dimension: ProblemDimension = Field(
        ...,
        description="Operational category of the problem: product, system, service, or process."
    )
    core_problem_statement: str = Field(
        ...,
        max_length=200,
        description="Concise, one-sentence summary of the underlying root pain point."
    )
    verbatim_quote: str = Field(
        ...,
        description="Direct quote demonstrating the user's specific operational bottleneck."
    )
    incumbent_solutions: List[str] = Field(
        default_factory=list,
        description="Existing software, vendors, or tools mentioned in the context (e.g., ['Salesforce', 'Jira', 'Excel'])."
    )
    workaround_in_use: Optional[str] = Field(
        None,
        description="Current manual workaround described (e.g., 'Daily copy-pasting into shared Google Sheets')."
    )
    severity_rating: int = Field(
        ...,
        ge=1,
        le=5,
        description="1 = Minor cosmetic inconvenience, 3 = Moderate workflow disruption, 5 = Critical downtime, compliance breach, or revenue loss."
    )
    wtp_signals: WTPSignal = Field(
        ...,
        description="Analysis of financial willingness to pay and switching indicators."
    )
    recommended_solution_vector: str = Field(
        ...,
        description="Recommended intervention category (e.g., 'Standalone Micro-SaaS', 'API Integration Middleware', 'Managed Automation Service')."
    )


class BatchPainPointResponse(BaseModel):
    extractions: List[PainPointExtraction] = Field(
        default_factory=list,
        description="List of valid B2B/professional pain points extracted from the document."
    )

Structured System & Extraction Prompts

1. System Prompt (SYSTEM_PROMPT)

You are an expert Enterprise Market Intelligence and B2B Venture Analyst. 
Your function is to analyze noisy web discussions, forum complaints, user reviews, and trade commentary to extract high-value operational bottlenecks, system failures, service flaws, and product deficiencies.

### Core Objectives:
1. Signal vs. Noise Discrimination: Reject consumer rants (e.g., retail shipping complaints, food delivery delays, gaming bugs). Focus strictly on B2B, industrial, organizational, and professional workflow bottlenecks.
2. Root-Cause Isolation: Distinguish surface symptoms ("the UI is confusing") from structural problems ("inventory sync between Shopify and SAP NetWeaver drops webhook payloads on high SKU counts").
3. Commercial Intent & WTP Extraction: Actively identify markers of purchasing capacity: team scale, enterprise tier names, wasted labor hours, regulatory compliance penalties, and explicit vendor replacement requests.

### Dimension Taxonomy:
- 'product': Software bugs, broken UI/UX, missing features, unhandled edge cases in digital tools.
- 'system': Infrastructure latency, data synchronization failures, scale thresholds, database locks.
- 'service': Vendor implementation failures, SLA violations, unresponsive support, opaque pricing.
- 'process': Manual data entry, cross-department handoffs, spreadsheet dependency, audit preparation friction.

Output strictly compliant JSON adhering to the target schema. Do not include markdown code block wrappings (```json) when tool calling or using structured output interfaces.

2. Few-Shot In-Context Prompt (USER_PROMPT_TEMPLATE)

Analyze the following source post/review and extract all distinct operational pain points according to the schema.

### Context Metadata:
- Source: {source_platform} (e.g., r/sysadmin, G2 Review, Capterra)
- Target Entity/Keyword: {target_identifier}
- Published Date: {published_at}

### Raw Text:
"""
{raw_body_text}
"""

Extract the structured pain points below:

Python Pipeline Execution with LiteLLM / Instructor

This script uses Instructor to validate the LLM's JSON output directly against the Pydantic schema with automatic schema retries.

import os
import instructor
from openai import OpenAI
from pydantic import ValidationError

# Initialize client with instructor patch
client = instructor.from_openai(OpenAI(api_key=os.getenv("OPENAI_API_KEY")))

def extract_pain_points_from_text(
    raw_text: str,
    source_platform: str,
    target_identifier: str,
    published_at: str
) -> Optional[BatchPainPointResponse]:
    """
    Parses unstructured text and returns validated structured pain point records.
    """
    prompt = f"""
    ### Context Metadata:
    - Source: {source_platform}
    - Target Entity/Keyword: {target_identifier}
    - Published Date: {published_at}

    ### Raw Text:
    \"\"\"
    {raw_text}
    \"\"\"
    """

    try:
        response: BatchPainPointResponse = client.chat.completions.create(
            model="gpt-4o-mini",  # or local Ollama endpoint via instructor
            response_model=BatchPainPointResponse,
            max_retries=2,
            messages=[
                {"role": "system", "content": SYSTEM_PROMPT},
                {"role": "user", "content": prompt}
            ],
            temperature=0.1
        )
        return response
    except ValidationError as e:
        print(f"Failed to validate schema: {e}")
        return None


Write the Python worker code for incremental vector clustering and centroid recalculation using pgvector and sentence-transformers.

This worker implementation performs continuous incremental clustering of newly extracted pain points using sentence-transformers, psycopg (v3), and pgvector.

Incremental Centroid Mathematics

When a newly extracted pain point vector vec{v} matches an existing cluster with centroid vec{C_old} and count N, the new centroid is updated using an online weighted average and re-normalized to maintain unit length for cosine distance:

vec{Cnew} = {N cdot vec{C_old} + vec{v}} / {N + 1}, quad vec{C_normalized} = {vec{C_new}} / {Vert{} vec{C_new} Vert{}_2}

If the cosine similarity $1 - \text{cosine\_distance}(\vec{v}, \vec{C})$ is below a defined threshold (e.g., $0.82$), a new cluster is provisioned.

Python Incremental Clustering Worker

import os
import uuid
import numpy as np
import psycopg
from psycopg.rows import dict_row
from pgvector.psycopg import register_vector
from sentence_transformers import SentenceTransformer

# ---------------------------------------------------------
# Configuration
# ---------------------------------------------------------
DB_URI = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/market_intel")
MODEL_NAME = "BAAI/bge-large-en-v1.5"  # 1024-dim, dense semantic embeddings
SIMILARITY_THRESHOLD = 0.82           # Cosine similarity cutoff for cluster assignment
COSINE_DISTANCE_THRESHOLD = 1.0 - SIMILARITY_THRESHOLD  # <= 0.18 for nearest cluster
BATCH_SIZE = 50

# Load embedding model once per worker lifecycle
embedder = SentenceTransformer(MODEL_NAME)


def get_db_connection():
    conn = psycopg.connect(DB_URI, row_factory=dict_row)
    register_vector(conn)
    return conn


def fetch_unclustered_pain_points(cursor, limit: int = BATCH_SIZE):
    """
    Retrieves unclustered pain points and verifies if their embeddings exist.
    """
    cursor.execute(
        """
        SELECT 
            p.id,
            p.extracted_quote,
            p.root_cause,
            p.severity_rating,
            p.embedding,
            r.title,
            r.body
        FROM extracted_pain_points p
        JOIN raw_ingested_items r ON p.raw_item_id = r.id
        WHERE p.cluster_id IS NULL
        LIMIT %s
        FOR UPDATE SKIP LOCKED;
        """,
        (limit,)
    )
    return cursor.fetchall()


def find_nearest_cluster(cursor, vector: np.ndarray, industry_hint: str = None):
    """
    Performs cosine distance search using pgvector's <=> operator against active cluster centroids.
    """
    # 1 - cosine_distance = cosine_similarity
    query = """
        SELECT 
            id,
            total_mentions,
            centroid_embedding,
            (centroid_embedding <=> %s) AS distance
        FROM problem_clusters
        WHERE (centroid_embedding <=> %s) <= %s
        ORDER BY distance ASC
        LIMIT 1;
    """
    cursor.execute(query, (vector, vector, COSINE_DISTANCE_THRESHOLD))
    return cursor.fetchone()


def update_cluster_centroid(cursor, cluster_id: uuid.UUID, new_vector: np.ndarray, current_mentions: int, current_centroid: list):
    """
    Recalculates running centroid in O(1) space and updates cluster stats.
    """
    old_c = np.array(current_centroid, dtype=np.float32)
    n = current_mentions
    
    # Weighted running average
    updated_centroid = (n * old_c + new_vector) / (n + 1)
    
    # L2 normalize back to unit sphere for stable cosine comparisons
    norm = np.linalg.norm(updated_centroid)
    if norm > 0:
        updated_centroid = updated_centroid / norm
        
    cursor.execute(
        """
        UPDATE problem_clusters
        SET 
            centroid_embedding = %s,
            total_mentions = total_mentions + 1,
            velocity_7d = velocity_7d + 1,
            last_updated_at = CURRENT_TIMESTAMP
        WHERE id = %s;
        """,
        (updated_centroid.tolist(), cluster_id)
    )


def create_new_cluster(cursor, item: dict, vector: np.ndarray) -> uuid.UUID:
    """
    Spawns a new problem cluster when similarity falls below threshold.
    """
    cluster_id = uuid.uuid4()
    # Normalize vector for initial centroid
    norm = np.linalg.norm(vector)
    unit_vector = (vector / norm) if norm > 0 else vector

    summary = item["root_cause"] or item["extracted_quote"][:200]
    title = item["title"] or f"Emerging: {summary[:60]}"

    cursor.execute(
        """
        INSERT INTO problem_clusters (
            id,
            title,
            executive_summary,
            industry,
            target_persona,
            dimension,
            total_mentions,
            velocity_7d,
            centroid_embedding,
            first_seen_at,
            last_updated_at
        ) VALUES (
            %s, %s, %s, %s, %s, %s, 1, 1, %s, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
        );
        """,
        (
            cluster_id,
            title,
            summary,
            "General / Unclassified",
            "Practitioner",
            "process",
            unit_vector.tolist()
        )
    )
    return cluster_id


def process_clustering_batch():
    """
    Main batch processing cycle.
    """
    with get_db_connection() as conn:
        with conn.cursor() as cur:
            items = fetch_unclustered_pain_points(cur, limit=BATCH_SIZE)
            if not items:
                return 0

            # Step 1: Ensure all items have embeddings
            texts_to_encode = []
            indices_needing_encoding = []

            for idx, item in enumerate(items):
                if item["embedding"] is None:
                    # Construct rich representation for embedding
                    semantic_text = f"Problem: {item['root_cause']} | Quote: {item['extracted_quote']}"
                    texts_to_encode.append(semantic_text)
                    indices_needing_encoding.append(idx)

            if texts_to_encode:
                embeddings = embedder.encode(texts_to_encode, normalize_embeddings=True)
                for local_idx, emb in zip(indices_needing_encoding, embeddings):
                    items[local_idx]["embedding"] = emb
                    # Persist individual vector to pain point
                    cur.execute(
                        "UPDATE extracted_pain_points SET embedding = %s WHERE id = %s;",
                        (emb.tolist(), items[local_idx]["id"])
                    )

            # Step 2: Route each point to nearest cluster or initialize a new one
            for item in items:
                vec = np.array(item["embedding"], dtype=np.float32)
                match = find_nearest_cluster(cur, vec)

                if match:
                    cluster_id = match["id"]
                    update_cluster_centroid(
                        cur,
                        cluster_id=cluster_id,
                        new_vector=vec,
                        current_mentions=match["total_mentions"],
                        current_centroid=match["centroid_embedding"]
                    )
                else:
                    cluster_id = create_new_cluster(cur, item, vec)

                # Link pain point to assigned cluster
                cur.execute(
                    "UPDATE extracted_pain_points SET cluster_id = %s WHERE id = %s;",
                    (cluster_id, item["id"])
                )

            conn.commit()
            return len(items)


if __name__ == "__main__":
    print(f"Starting vector clustering worker using {MODEL_NAME}...")
    while True:
        try:
            count = process_clustering_batch()
            if count > 0:
                print(f"Clustered {count} items.")
            else:
                import time
                time.sleep(5)  # Back off when queue is empty
        except KeyboardInterrupt:
            print("Shutting down worker.")
            break
        except Exception as e:
            print(f"Worker iteration failed: {e}")
            import time
            time.sleep(5)

Key Operational Optimizations

  • Row-Level Locking (FOR UPDATE SKIP LOCKED): Prevents race conditions across parallel worker instances, allowing horizontal scaling across multiple background containers without assigning duplicate items.

  • Unit Sphere Centroid Stability: By normalizing vectors after each online weighted addition, the inner product and cosine operations match Euclidean distances directly, avoiding cluster drift caused by raw unnormalized sum vectors.

  • HNSW Index Usage: The query (centroid_embedding <=> %s) <= %s routes through the HNSW cosine index defined on problem_clusters, executing similarity searches in logarithmic time rather than doing full-table scans.


Write an LLM summarization pipeline that regenerates the cluster title and executive summary when mention counts double.

This worker monitors problem clusters and triggers structured LLM re-summarization whenever a cluster's volume reaches a growth doubling threshold (e.g., N >= 2 x N_last_summarized). It gathers representative quotes and high-severity pain points across the cluster, runs schema-validated synthesis via instructor, and atomically updates the cluster metadata.

Database Schema Migration

Add threshold-tracking columns to problem_clusters so workers can track mention multiples without re-running against static clusters:

ALTER TABLE problem_clusters 
ADD COLUMN IF NOT EXISTS last_summarized_mentions INT DEFAULT 1,
ADD COLUMN IF NOT EXISTS last_summarized_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP;

Pipeline Implementation

import os
import uuid
from typing import List, Optional
import psycopg
from psycopg.rows import dict_row
from pydantic import BaseModel, Field
import instructor
from openai import OpenAI

# ---------------------------------------------------------
# Configuration & Client Setup
# ---------------------------------------------------------
DB_URI = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/market_intel")
DOUBLING_FACTOR = 2.0  # Trigger when total_mentions >= last_summarized_mentions * 2
MIN_MENTIONS_FLOOR = 4 # Do not run LLM re-summarization until at least 4 mentions exist

client = instructor.from_openai(
    OpenAI(
        api_key=os.getenv("OPENAI_API_KEY", "EMPTY"),
        base_url=os.getenv("OPENAI_BASE_URL", "https://api.openai.com/v1")
    )
)


# ---------------------------------------------------------
# Structured Synthesis Schemas
# ---------------------------------------------------------
class ClusterSynthesis(BaseModel):
    title: str = Field(
        ...,
        max_length=120,
        description="Punchy, actionable title capturing the core failure mode (e.g., 'EHR Inoperability During CMS Billing Reconciliation')."
    )
    executive_summary: str = Field(
        ...,
        description="Comprehensive 2-4 sentence executive overview synthesizing the root cause, operational friction, and business impact."
    )
    primary_industry: str = Field(
        ...,
        description="Refined industry classification based on accumulated evidence."
    )
    target_persona: str = Field(
        ...,
        description="Consolidated user group or job title bearing the brunt of the issue."
    )
    dominant_dimension: str = Field(
        ...,
        description="Classification: 'product', 'system', 'service', or 'process'."
    )
    incumbent_solutions: List[str] = Field(
        default_factory=list,
        description="De-duplicated list of incumbent vendors/tools cited in the pain points."
    )
    estimated_wtp_tier: str = Field(
        ...,
        description="'enterprise', 'smb', 'prosumer', or 'none' based on accumulated budget markers."
    )


# ---------------------------------------------------------
# Worker Logic
# ---------------------------------------------------------
def get_db_connection():
    return psycopg.connect(DB_URI, row_factory=dict_row)


def find_clusters_eligible_for_resummarization(cursor, limit: int = 10):
    """
    Finds active clusters where mention count has doubled since last summary
    and meets the baseline volume floor. Uses row locks to allow worker concurrency.
    """
    query = """
        SELECT 
            id,
            title,
            executive_summary,
            industry,
            target_persona,
            dimension,
            total_mentions,
            last_summarized_mentions
        FROM problem_clusters
        WHERE total_mentions >= %s 
          AND total_mentions >= (last_summarized_mentions * %s)
        ORDER BY total_mentions DESC
        LIMIT %s
        FOR UPDATE SKIP LOCKED;
    """
    cursor.execute(query, (MIN_MENTIONS_FLOOR, DOUBLING_FACTOR, limit))
    return cursor.fetchall()


def fetch_representative_points(cursor, cluster_id: uuid.UUID, limit: int = 12) -> List[dict]:
    """
    Selects high-severity and high-budget pain points to maximize signal density.
    """
    query = """
        SELECT 
            extracted_quote,
            root_cause,
            severity_rating,
            wtp_signals
        FROM extracted_pain_points
        WHERE cluster_id = %s
        ORDER BY severity_rating DESC, extracted_at DESC
        LIMIT %s;
    """
    cursor.execute(query, (cluster_id, limit))
    return cursor.fetchall()


def summarize_cluster_with_llm(cluster: dict, points: List[dict]) -> Optional[ClusterSynthesis]:
    """
    Invokes the LLM to synthesize disparate observations into a refined market opportunity dossier.
    """
    formatted_points = []
    for idx, p in enumerate(points, 1):
        wtp_info = p.get("wtp_signals") or {}
        budget_str = f" [WTP Tier: {wtp_info.get('wtp_tier', 'none')}, Quote: {wtp_info.get('exact_quote')}]" if wtp_info.get("has_budget_signal") else ""
        formatted_points.append(
            f"{idx}. [Severity {p['severity_rating']}/5] Cause: {p['root_cause']}\n"
            f"   Quote: \"{p['extracted_quote']}\"{budget_str}"
        )

    evidence_text = "\n".join(formatted_points)

    system_prompt = (
        "You are an enterprise systems auditor and venture market intelligence analyst. "
        "Your task is to analyze aggregated user friction reports within a single problem cluster "
        "and produce an updated, razor-sharp synthesis of the market defect, root cause, and incumbents involved."
    )

    user_prompt = f"""
### Current Cluster Context:
- Working Title: {cluster['title']}
- Industry: {cluster['industry']} | Persona: {cluster['target_persona']}
- Total Corroborating Mentions: {cluster['total_mentions']}

### Accumulated Evidence ({len(points)} Representative Cases):
{evidence_text}

Generate an updated, authoritative title, summary, and classification according to the schema.
"""

    try:
        synthesis: ClusterSynthesis = client.chat.completions.create(
            model="gpt-4o-mini",
            response_model=ClusterSynthesis,
            max_retries=2,
            messages=[
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            temperature=0.2
        )
        return synthesis
    except Exception as e:
        print(f"LLM Summarization failed for cluster {cluster['id']}: {e}")
        return None


def execute_cluster_update(cursor, cluster_id: uuid.UUID, total_mentions: int, synthesis: ClusterSynthesis):
    """
    Commits the LLM synthesis and sets the new last_summarized_mentions threshold baseline.
    """
    update_query = """
        UPDATE problem_clusters
        SET 
            title = %s,
            executive_summary = %s,
            industry = %s,
            target_persona = %s,
            dimension = %s,
            incumbent_solutions = %s,
            last_summarized_mentions = %s,
            last_summarized_at = CURRENT_TIMESTAMP
        WHERE id = %s;
    """
    cursor.execute(
        update_query,
        (
            synthesis.title,
            synthesis.executive_summary,
            synthesis.primary_industry,
            synthesis.target_persona,
            synthesis.dominant_dimension.lower(),
            synthesis.incumbent_solutions,
            total_mentions,
            cluster_id
        )
    )


def run_summarization_pass():
    """
    Executes a single processing batch across eligible clusters.
    """
    with get_db_connection() as conn:
        with conn.cursor() as cur:
            eligible_clusters = find_clusters_eligible_for_resummarization(cur, limit=10)
            if not eligible_clusters:
                return 0

            print(f"Found {len(eligible_clusters)} clusters ready for re-summarization.")

            for cluster in eligible_clusters:
                points = fetch_representative_points(cur, cluster["id"])
                if not points:
                    continue

                synthesis = summarize_cluster_with_llm(cluster, points)
                if synthesis:
                    execute_cluster_update(cur, cluster["id"], cluster["total_mentions"], synthesis)
                    print(f"Updated Cluster {cluster['id']} -> '{synthesis.title}' (Mentions: {cluster['total_mentions']})")

            conn.commit()
            return len(eligible_clusters)


if __name__ == "__main__":
    import time
    print("Starting Cluster Summarization Worker...")
    while True:
        try:
            processed = run_summarization_pass()
            if processed == 0:
                time.sleep(15)  # Idle poll interval
        except KeyboardInterrupt:
            print("Shutting down worker.")
            break
        except Exception as e:
            print(f"Worker iteration encountered error: {e}")
            time.sleep(15)

Production Operational Dynamics

  1. Logarithmic Recalculation Cadence: Because updates fire on multiples of volume (4 --> 8 --> 16 --> 32 --> 64), the LLM overhead drops asymptotically as the cluster matures, preventing API budget waste on high-frequency topics while capturing early narrative shifts.

  2. Deterministic Locking: FOR UPDATE SKIP LOCKED ensures multiple celery tasks or container pods can process the cluster queue concurrently without running redundant LLM generations on the same cluster.

  3. Representative Stratification: The query orders by severity_rating DESC, extracted_at DESC so the prompt window is populated by high-impact failures and recent developments rather than low-friction noise.


Implement the FastAPI endpoints to serve cluster details, representative quotes, and exportable dossiers.

Here is the complete FastAPI service providing structured endpoints for cluster listings, detail drill-downs, high-signal representative evidence, and multi-format exports (Markdown, JSON, and CSV).

FastAPI Implementation (app/main.py)

import io
import csv
import json
import os
import uuid
from datetime import datetime
from typing import List, Optional
from enum import Enum

from fastapi import FastAPI, HTTPException, Query, Response, status
from fastapi.middleware.cors import CORSMiddleware
from psycopg.rows import dict_row
from psycopg_pool import ConnectionPool
from pydantic import BaseModel, Field

# ---------------------------------------------------------
# Environment & Connection Pool Setup
# ---------------------------------------------------------
DB_URI = os.getenv("DATABASE_URL", "postgresql://postgres:postgres@localhost:5432/market_intel")

# Async/Threaded psycopg Connection Pool
pool = ConnectionPool(
    conninfo=DB_URI,
    min_size=4,
    max_size=20,
    kwargs={"row_factory": dict_row}
)

app = FastAPI(
    title="Market Friction & Opportunity API",
    version="1.0.0",
    description="Query aggregated B2B pain point clusters, inspect corroborating evidence, and export opportunity dossiers."
)

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)


# ---------------------------------------------------------
# Response Models
# ---------------------------------------------------------
class DimensionEnum(str, Enum):
    PRODUCT = "product"
    SYSTEM = "system"
    SERVICE = "service"
    PROCESS = "process"


class ExportFormat(str, Enum):
    MARKDOWN = "markdown"
    JSON = "json"
    CSV = "csv"


class WTPSignalSchema(BaseModel):
    has_budget_signal: bool = False
    exact_quote: Optional[str] = None
    wtp_tier: Optional[str] = "none"
    switching_intent: Optional[bool] = False


class ExtractedEvidenceSchema(BaseModel):
    id: uuid.UUID
    extracted_quote: str
    root_cause: Optional[str] = None
    severity_rating: int
    wtp_signals: WTPSignalSchema
    source_platform: str
    target_identifier: str
    raw_url: str
    published_at: datetime


class ClusterSummarySchema(BaseModel):
    id: uuid.UUID
    title: str
    executive_summary: str
    industry: str
    target_persona: str
    dimension: str
    total_mentions: int
    velocity_7d: int
    viability_score: Optional[float] = None
    first_seen_at: datetime
    last_updated_at: datetime


class ClusterDetailSchema(ClusterSummarySchema):
    incumbent_solutions: List[str] = []
    willingness_to_pay_score: Optional[float] = None
    top_evidence: List[ExtractedEvidenceSchema] = []


class PaginatedClustersResponse(BaseModel):
    total_count: int
    page: int
    page_size: int
    results: List[ClusterSummarySchema]


# ---------------------------------------------------------
# API Endpoints
# ---------------------------------------------------------

@app.get("/clusters", response_model=PaginatedClustersResponse, tags=["Clusters"])
def list_clusters(
    industry: Optional[str] = Query(None, description="Filter by target vertical"),
    dimension: Optional[DimensionEnum] = Query(None, description="Filter by operational friction type"),
    min_viability: float = Query(0.0, ge=0.0, le=10.0, description="Minimum viability score threshold"),
    page: int = Query(1, ge=1),
    page_size: int = Query(20, ge=1, le=100)
):
    """
    Search and filter aggregated market clusters ordered by viability score and mention velocity.
    """
    offset = (page - 1) * page_size
    filters = ["viability_score >= %s"]
    params = [min_viability]

    if industry:
        filters.append("industry ILIKE %s")
        params.append(f"%{industry}%")

    if dimension:
        filters.append("dimension = %s")
        params.append(dimension.value)

    where_clause = " AND ".join(filters)

    with pool.connection() as conn:
        with conn.cursor() as cur:
            # Count total
            cur.execute(f"SELECT COUNT(*) AS total FROM problem_clusters WHERE {where_clause};", tuple(params))
            total_count = cur.fetchone()["total"]

            # Select page
            query = f"""
                SELECT 
                    id, title, executive_summary, industry, target_persona,
                    dimension, total_mentions, velocity_7d, viability_score,
                    first_seen_at, last_updated_at
                FROM problem_clusters
                WHERE {where_clause}
                ORDER BY viability_score DESC NULLS LAST, velocity_7d DESC
                LIMIT %s OFFSET %s;
            """
            cur.execute(query, (*params, page_size, offset))
            rows = cur.fetchall()

    return PaginatedClustersResponse(
        total_count=total_count,
        page=page,
        page_size=page_size,
        results=[ClusterSummarySchema(**row) for row in rows]
    )


@app.get("/clusters/{cluster_id}", response_model=ClusterDetailSchema, tags=["Clusters"])
def get_cluster_detail(cluster_id: uuid.UUID):
    """
    Retrieve deep-dive metadata, incumbent vendors, and the top 10 corroborating pain points.
    """
    with pool.connection() as conn:
        with conn.cursor() as cur:
            # Fetch cluster base record
            cur.execute(
                """
                SELECT 
                    id, title, executive_summary, industry, target_persona,
                    dimension, incumbent_solutions, total_mentions, velocity_7d,
                    willingness_to_pay_score, viability_score, first_seen_at, last_updated_at
                FROM problem_clusters
                WHERE id = %s;
                """,
                (cluster_id,)
            )
            cluster = cur.fetchone()
            if not cluster:
                raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cluster not found.")

            # Fetch top representative quotes
            cur.execute(
                """
                SELECT 
                    p.id, p.extracted_quote, p.root_cause, p.severity_rating, p.wtp_signals,
                    s.platform_name AS source_platform,
                    s.target_identifier,
                    r.url AS raw_url,
                    r.published_at
                FROM extracted_pain_points p
                JOIN raw_ingested_items r ON p.raw_item_id = r.id
                JOIN sources s ON r.source_id = s.id
                WHERE p.cluster_id = %s
                ORDER BY p.severity_rating DESC, r.published_at DESC
                LIMIT 10;
                """,
                (cluster_id,)
            )
            evidence_rows = cur.fetchall()

    return ClusterDetailSchema(
        **cluster,
        top_evidence=[ExtractedEvidenceSchema(**ev) for ev in evidence_rows]
    )


@app.get("/clusters/{cluster_id}/evidence", response_model=List[ExtractedEvidenceSchema], tags=["Evidence"])
def list_cluster_evidence(
    cluster_id: uuid.UUID,
    min_severity: int = Query(1, ge=1, le=5),
    require_wtp: bool = Query(False, description="Filter only to items with detected WTP markers"),
    limit: int = Query(50, ge=1, le=200)
):
    """
    Fetch comprehensive extracted evidence and source links for human-in-the-loop review.
    """
    conditions = ["p.cluster_id = %s", "p.severity_rating >= %s"]
    params = [cluster_id, min_severity]

    if require_wtp:
        conditions.append("(p.wtp_signals->>'has_budget_signal')::boolean = TRUE")

    where_clause = " AND ".join(conditions)

    with pool.connection() as conn:
        with conn.cursor() as cur:
            cur.execute(
                f"""
                SELECT 
                    p.id, p.extracted_quote, p.root_cause, p.severity_rating, p.wtp_signals,
                    s.platform_name AS source_platform,
                    s.target_identifier,
                    r.url AS raw_url,
                    r.published_at
                FROM extracted_pain_points p
                JOIN raw_ingested_items r ON p.raw_item_id = r.id
                JOIN sources s ON r.source_id = s.id
                WHERE {where_clause}
                ORDER BY p.severity_rating DESC, r.published_at DESC
                LIMIT %s;
                """,
                (*params, limit)
            )
            evidence = cur.fetchall()

    return [ExtractedEvidenceSchema(**ev) for ev in evidence]


@app.get("/clusters/{cluster_id}/export", tags=["Exports"])
def export_opportunity_dossier(
    cluster_id: uuid.UUID,
    format: ExportFormat = Query(ExportFormat.MARKDOWN, description="Export format")
):
    """
    Generates a structured market intelligence dossier in Markdown, JSON, or CSV.
    """
    # 1. Fetch complete cluster profile & associated evidence
    with pool.connection() as conn:
        with conn.cursor() as cur:
            cur.execute("SELECT * FROM problem_clusters WHERE id = %s;", (cluster_id,))
            cluster = cur.fetchone()
            if not cluster:
                raise HTTPException(status_code=404, detail="Cluster not found.")

            cur.execute(
                """
                SELECT 
                    p.id, p.extracted_quote, p.root_cause, p.severity_rating, p.wtp_signals,
                    s.platform_name, s.target_identifier, r.url, r.published_at
                FROM extracted_pain_points p
                JOIN raw_ingested_items r ON p.raw_item_id = r.id
                JOIN sources s ON r.source_id = s.id
                WHERE p.cluster_id = %s
                ORDER BY p.severity_rating DESC;
                """,
                (cluster_id,)
            )
            evidence_rows = cur.fetchall()

    clean_title = cluster["title"].lower().replace(" ", "_")[:30]
    filename_base = f"dossier_{clean_title}_{datetime.now().strftime('%Y%m%d')}"

    # 2. Render JSON
    if format == ExportFormat.JSON:
        payload = {
            "cluster": {k: (str(v) if isinstance(v, uuid.UUID) else v) for k, v in cluster.items() if k != "centroid_embedding"},
            "evidence": [{k: (str(v) if isinstance(v, uuid.UUID) else v) for k, v in row.items()} for row in evidence_rows]
        }
        return Response(
            content=json.dumps(payload, default=str, indent=2),
            media_type="application/json",
            headers={"Content-Disposition": f"attachment; filename={filename_base}.json"}
        )

    # 3. Render CSV
    elif format == ExportFormat.CSV:
        output = io.StringIO()
        writer = csv.writer(output)
        writer.writerow(["Cluster Title", cluster["title"]])
        writer.writerow(["Industry", cluster["industry"]])
        writer.writerow(["Target Persona", cluster["target_persona"]])
        writer.writerow(["Dimension", cluster["dimension"]])
        writer.writerow(["Viability Score", cluster["viability_score"]])
        writer.writerow([])
        writer.writerow(["Evidence ID", "Severity (1-5)", "Source", "Root Cause", "Quote", "Budget Signal", "Source URL", "Published Date"])

        for row in evidence_rows:
            wtp = row.get("wtp_signals") or {}
            writer.writerow([
                str(row["id"]),
                row["severity_rating"],
                f"{row['platform_name']} ({row['target_identifier']})",
                row["root_cause"],
                row["extracted_quote"],
                wtp.get("has_budget_signal", False),
                row["url"],
                row["published_at"].isoformat()
            ])

        return Response(
            content=output.getvalue(),
            media_type="text/csv",
            headers={"Content-Disposition": f"attachment; filename={filename_base}.csv"}
        )

    # 4. Render Markdown
    incumbents = ", ".join(cluster["incumbent_solutions"] or []) or "None identified"
    md_content = f"""# Opportunity Dossier: {cluster['title']}

**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S UTC')}  
**Cluster ID:** `{cluster['id']}`  
**Viability Score:** `{cluster['viability_score'] or 'N/A'}` | **Mentions (Total/7d):** `{cluster['total_mentions']}` / `{cluster['velocity_7d']}`

---

## 1. Executive Summary
{cluster['executive_summary']}

* **Target Industry:** {cluster['industry']}
* **User Persona:** {cluster['target_persona']}
* **Problem Dimension:** {cluster['dimension'].capitalize()}
* **Incumbent Competitors / Systems:** {incumbents}

---

## 2. Market Evidence & Practitioner Citations ({len(evidence_rows)} recorded)

"""
    for idx, ev in enumerate(evidence_rows, 1):
        wtp = ev.get("wtp_signals") or {}
        budget_str = f" | **WTP Tier:** `{wtp.get('wtp_tier')}`" if wtp.get("has_budget_signal") else ""
        md_content += f"""### Observation {idx} [{ev['platform_name'].upper()} / {ev['target_identifier']}]
* **Severity:** `{ev['severity_rating']}/5`{budget_str}
* **Root Cause:** {ev['root_cause'] or 'Unspecified'}
* **Direct Citation:**
> "{ev['extracted_quote']}"

* **Source Link:** [{ev['url']}]({ev['url']})  
* **Timestamp:** {ev['published_at'].strftime('%Y-%m-%d %H:%M')}

---
"""

    return Response(
        content=md_content,
        media_type="text/markdown",
        headers={"Content-Disposition": f"attachment; filename={filename_base}.md"}
    )

Endpoint Catalog

MethodEndpointDescription
GET/clustersPaginated search filtering by industry, dimension, and minimum viability score.
GET/clusters/{id}Complete cluster overview including metadata, incumbents, and the top 10 evidence quotes.
GET/clusters/{id}/evidenceFiltered list of citations (filterable by severity rating or commercial WTP flags).
GET/clusters/{id}/exportDirect file download formatted as Markdown, JSON, or CSV dossiers.


Bring it altogether. Provide the end-to-end app for daily search and summary using Google Gemini API

This end-to-end Python application implements automated web problem discovery, structured pain point extraction, clustering, LLM summarization, and a dashboard API using Google Gemini via the google-genai SDK and SQLite.

The app uses:

  1. Google Search Grounding via Gemini: Queries live web discussions, industry complaints, and software reviews daily without requiring brittle third-party web scrapers.

  2. Native Pydantic Structured Outputs: Uses response_mime_type="application/json" and response_schema to guarantee valid JSON directly parsed into Pydantic models.

  3. Local Vector Embeddings & Clustering: Computes vector representations using local embedding models or semantic distance, incrementally updating cluster centroids.

  4. Volume-Triggered Executive Synthesis: Uses Gemini to rewrite executive summaries and strategic action dossiers whenever mention counts double.

  5. FastAPI & Async CLI Runner: Serves endpoints for review and triggerable cron execution.

Prerequisites & Installation

pip install google-genai pydantic fastapi uvicorn sqlite3 numpy httpx

Ensure your Gemini API key is exported:

export GEMINI_API_KEY="your-gemini-api-key"

Complete Application Script (app.py)

import os
import sys
import json
import uuid
import sqlite3
import datetime
from typing import List, Optional
from enum import Enum

from pydantic import BaseModel, Field
import numpy as np
from fastapi import FastAPI, HTTPException, Query, Response
from fastapi.responses import HTMLResponse
import uvicorn

# Google GenAI SDK
from google import genai
from google.genai import types

# -----------------------------------------------------------------------------
# 1. Domain Models & Pydantic Contracts
# -----------------------------------------------------------------------------

class ProblemDimension(str, Enum):
    PRODUCT = "product"    # Software bugs, UX friction, missing APIs
    SYSTEM = "system"      # Downtime, database scale bottlenecks, sync errors
    SERVICE = "service"    # Support failures, slow onboarding, opaque billing
    PROCESS = "process"    # Manual double-entry, compliance drag, spreadsheet handoffs


class WTPSignal(BaseModel):
    has_budget_signal: bool = Field(
        ..., 
        description="True if post quotes budget, enterprise seat counts, software spend, or labor waste."
    )
    exact_quote: Optional[str] = Field(
        None, 
        description="Verbatim text mentioning financial cost, software tier, or labor waste."
    )
    wtp_tier: str = Field(
        ..., 
        description="'enterprise' (>$10k/yr), 'smb' ($1k-$10k/yr), 'prosumer' (<$1k/yr), or 'none'."
    )
    switching_intent: bool = Field(
        ..., 
        description="True if author is shopping for a replacement tool or threatening vendor churn."
    )


class PainPointExtraction(BaseModel):
    is_valid_commercial_pain: bool = Field(
        ..., 
        description="True for genuine B2B/organizational workflow friction; False for spam/consumer retail rants."
    )
    industry: str = Field(
        ..., 
        description="Target industry vertical (e.g. Healthcare EHR, Cold-Chain Logistics, Civil Engineering)."
    )
    target_persona: str = Field(
        ..., 
        description="Impacted job role (e.g. Compliance Officer, Fleet Dispatcher, Site Supervisor)."
    )
    dimension: ProblemDimension = Field(..., description="Failure category.")
    core_problem_statement: str = Field(
        ..., 
        max_length=200, 
        description="Crisp one-sentence summary of the defect."
    )
    verbatim_quote: str = Field(..., description="Direct quote from the practitioner.")
    source_url: Optional[str] = Field(None, description="URL or community origin of the finding.")
    incumbent_solutions: List[str] = Field(
        default_factory=list, 
        description="Existing software platforms mentioned (e.g. Salesforce, Epic, Jira, QuickBooks)."
    )
    workaround_in_use: Optional[str] = Field(
        None, 
        description="Current manual workaround described (e.g. Daily CSV manual reconciliations)."
    )
    severity_rating: int = Field(
        ..., 
        ge=1, 
        le=5, 
        description="1 = minor annoyance, 5 = business downtime/audit failure."
    )
    wtp_signals: WTPSignal
    recommended_solution_vector: str = Field(
        ..., 
        description="Proposed build: 'Micro-SaaS', 'Integration Middleware', or 'Managed Automation Service'."
    )


class BatchExtractionResponse(BaseModel):
    items: List[PainPointExtraction] = Field(default_factory=list)


class ClusterSynthesis(BaseModel):
    title: str = Field(..., max_length=120)
    executive_summary: str = Field(..., description="Synthesized 2-3 sentence strategic executive brief.")
    primary_industry: str
    target_persona: str
    dominant_dimension: ProblemDimension
    incumbent_solutions: List[str] = Field(default_factory=list)
    actionable_intervention: str = Field(..., description="Specific software or process fix to monetize.")


# -----------------------------------------------------------------------------
# 2. Database Persistence Layer (SQLite)
# -----------------------------------------------------------------------------

DB_PATH = "pain_point_discovery.db"

def init_db():
    con = sqlite3.connect(DB_PATH)
    cur = con.cursor()
    cur.execute("""
    CREATE TABLE IF NOT EXISTS problem_clusters (
        id TEXT PRIMARY KEY,
        title TEXT NOT NULL,
        executive_summary TEXT NOT NULL,
        industry TEXT NOT NULL,
        target_persona TEXT NOT NULL,
        dimension TEXT NOT NULL,
        incumbent_solutions TEXT,
        total_mentions INTEGER DEFAULT 1,
        velocity_7d INTEGER DEFAULT 1,
        viability_score REAL DEFAULT 0.0,
        first_seen_at TEXT,
        last_updated_at TEXT,
        last_summarized_mentions INTEGER DEFAULT 1,
        centroid_blob BLOB
    );
    """)
    cur.execute("""
    CREATE TABLE IF NOT EXISTS extracted_pain_points (
        id TEXT PRIMARY KEY,
        cluster_id TEXT,
        industry TEXT,
        target_persona TEXT,
        dimension TEXT,
        core_problem_statement TEXT,
        extracted_quote TEXT NOT NULL,
        root_cause TEXT,
        source_url TEXT,
        severity_rating INTEGER,
        wtp_tier TEXT,
        has_budget_signal BOOLEAN,
        vector_blob BLOB,
        extracted_at TEXT,
        FOREIGN KEY(cluster_id) REFERENCES problem_clusters(id)
    );
    """)
    con.commit()
    con.close()


def get_db():
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    return conn


# -----------------------------------------------------------------------------
# 3. Gemini Pipeline: Search, Extraction, Clustering & Summarization
# -----------------------------------------------------------------------------

class DiscoveryEngine:
    def __init__(self):
        self.client = genai.Client()
        self.text_model = "gemini-2.5-flash"

    def run_daily_search(self, target_queries: List[str]) -> str:
        """
        Executes Google Search Grounding to aggregate current complaints and broken processes.
        """
        combined_prompt = (
            "You are a market discovery investigator. Search for recent (last 30 days) community discussions, "
            "forum complaints, B2B software reviews, and industry complaints regarding the following inquiries:\n"
            + "\n".join(f"- {q}" for q in target_queries) +
            "\nFocus on real people citing broken software workflows, manual data friction, legacy system failures, "
            "and vendor pricing frustration. Return the raw narrative evidence with source references."
        )
        
        # Grounding with Google Search
        response = self.client.models.generate_content(
            model=self.text_model,
            contents=combined_prompt,
            config=types.GenerateContentConfig(
                tools=[types.Tool(google_search=types.GoogleSearch())],
                temperature=0.2
            )
        )
        return response.text

    def extract_structured_pain_points(self, raw_web_report: str) -> List[PainPointExtraction]:
        """
        Parses raw text into validated PainPointExtraction records using Gemini Structured Outputs.
        """
        system_instruction = (
            "You are a Senior B2B Market Research Analyst. Parse the market findings and extract genuine "
            "organizational and commercial bottlenecks. Reject generic consumer complaints. Extract actionable "
            "workflow friction, budget signals, and specific incumbent tools."
        )
        
        response = self.client.models.generate_content(
            model=self.text_model,
            contents=f"Extract all verified pain points from this market briefing:\n\n{raw_web_report}",
            config=types.GenerateContentConfig(
                system_instruction=system_instruction,
                response_mime_type="application/json",
                response_schema=BatchExtractionResponse,
                temperature=0.1
            )
        )
        data = json.loads(response.text)
        batch = BatchExtractionResponse(**data)
        return [item for item in batch.items if item.is_valid_commercial_pain]

    def get_embedding(self, text: str) -> np.ndarray:
        """
        Generates dense embeddings via Gemini text-embedding-004.
        """
        result = self.client.models.embed_content(
            model="text-embedding-004",
            contents=text
        )
        vec = np.array(result.embedding.values, dtype=np.float32)
        norm = np.linalg.norm(vec)
        return (vec / norm) if norm > 0 else vec

    def cluster_pain_point(self, item: PainPointExtraction, vector: np.ndarray):
        """
        Performs nearest-centroid clustering in SQLite using cosine similarity.
        """
        conn = get_db()
        cur = conn.cursor()
        now = datetime.datetime.now(datetime.timezone.utc).isoformat()
        
        cur.execute("SELECT id, total_mentions, centroid_blob FROM problem_clusters")
        existing_clusters = cur.fetchall()
        
        best_cluster_id = None
        best_sim = -1.0
        SIMILARITY_THRESHOLD = 0.80

        for row in existing_clusters:
            if row["centroid_blob"]:
                c_vec = np.frombuffer(row["centroid_blob"], dtype=np.float32)
                sim = float(np.dot(vector, c_vec))
                if sim > best_sim:
                    best_sim = sim
                    best_cluster_id = row["id"]
                    best_mentions = row["total_mentions"]
                    best_c_vec = c_vec

        if best_cluster_id and best_sim >= SIMILARITY_THRESHOLD:
            cluster_id = best_cluster_id
            # Incremental unit centroid update: C_new = norm((N * C_old + V) / (N + 1))
            new_centroid = (best_mentions * best_c_vec + vector) / (best_mentions + 1)
            norm = np.linalg.norm(new_centroid)
            if norm > 0:
                new_centroid = new_centroid / norm
                
            cur.execute("""
                UPDATE problem_clusters 
                SET total_mentions = total_mentions + 1,
                    velocity_7d = velocity_7d + 1,
                    last_updated_at = ?,
                    centroid_blob = ?
                WHERE id = ?
            """, (now, new_centroid.tobytes(), cluster_id))
        else:
            # Provision new cluster
            cluster_id = str(uuid.uuid4())
            cur.execute("""
                INSERT INTO problem_clusters (
                    id, title, executive_summary, industry, target_persona, 
                    dimension, incumbent_solutions, total_mentions, velocity_7d, 
                    viability_score, first_seen_at, last_updated_at, 
                    last_summarized_mentions, centroid_blob
                ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 5.0, ?, ?, 1, ?)
            """, (
                cluster_id,
                f"Emerging: {item.core_problem_statement[:80]}",
                item.core_problem_statement,
                item.industry,
                item.target_persona,
                item.dimension.value,
                json.dumps(item.incumbent_solutions),
                now,
                now,
                vector.tobytes()
            ))

        # Insert granular pain point record
        cur.execute("""
            INSERT INTO extracted_pain_points (
                id, cluster_id, industry, target_persona, dimension, 
                core_problem_statement, extracted_quote, root_cause, 
                source_url, severity_rating, wtp_tier, has_budget_signal, 
                vector_blob, extracted_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
        """, (
            str(uuid.uuid4()),
            cluster_id,
            item.industry,
            item.target_persona,
            item.dimension.value,
            item.core_problem_statement,
            item.verbatim_quote,
            item.recommended_solution_vector,
            item.source_url or "Web Investigation",
            item.severity_rating,
            item.wtp_signals.wtp_tier,
            item.wtp_signals.has_budget_signal,
            vector.tobytes(),
            now
        ))

        conn.commit()
        conn.close()
        return cluster_id

    def check_and_resummarize_clusters(self):
        """
        Triggers Gemini structured re-synthesis when mentions double.
        """
        conn = get_db()
        cur = conn.cursor()
        cur.execute("""
            SELECT id, title, total_mentions, last_summarized_mentions 
            FROM problem_clusters
            WHERE total_mentions >= 4 
              AND total_mentions >= (last_summarized_mentions * 2)
        """)
        eligible = cur.fetchall()

        for c in eligible:
            cluster_id = c["id"]
            cur.execute("""
                SELECT extracted_quote, severity_rating, wtp_tier, core_problem_statement 
                FROM extracted_pain_points
                WHERE cluster_id = ?
                ORDER BY severity_rating DESC LIMIT 10
            """, (cluster_id,))
            quotes = cur.fetchall()

            evidence_block = "\n".join([
                f"- [Severity {q['severity_rating']}/5, WTP: {q['wtp_tier']}] {q['core_problem_statement']} | Quote: \"{q['extracted_quote']}\""
                for q in quotes
            ])

            prompt = f"Synthesize these corroborating user reports into a comprehensive market opportunity dossier:\n\n{evidence_block}"
            
            response = self.client.models.generate_content(
                model=self.text_model,
                contents=prompt,
                config=types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=ClusterSynthesis,
                    temperature=0.2
                )
            )
            data = json.loads(response.text)
            syn = ClusterSynthesis(**data)

            # Update viability score based on volume and severity
            viability = min(10.0, 5.0 + (c["total_mentions"] * 0.3))

            cur.execute("""
                UPDATE problem_clusters
                SET title = ?,
                    executive_summary = ?,
                    industry = ?,
                    target_persona = ?,
                    dimension = ?,
                    incumbent_solutions = ?,
                    viability_score = ?,
                    last_summarized_mentions = ?,
                    last_updated_at = ?
                WHERE id = ?
            """, (
                syn.title,
                syn.executive_summary,
                syn.primary_industry,
                syn.target_persona,
                syn.dominant_dimension.value,
                json.dumps(syn.incumbent_solutions),
                viability,
                c["total_mentions"],
                datetime.datetime.now(datetime.timezone.utc).isoformat(),
                cluster_id
            ))
            print(f"[*] Re-summarized cluster '{syn.title}' (Mentions: {c['total_mentions']})")

        conn.commit()
        conn.close()


# -----------------------------------------------------------------------------
# 4. FastAPI Web Server & UI Dashboard
# -----------------------------------------------------------------------------

app = FastAPI(title="Pain Point & Market Gap Intelligence Engine")
engine = DiscoveryEngine()

@app.on_event("startup")
def startup_event():
    init_db()

@app.get("/", response_class=HTMLResponse)
def dashboard():
    conn = get_db()
    cur = conn.cursor()
    cur.execute("""
        SELECT id, title, executive_summary, industry, target_persona, dimension, total_mentions, viability_score 
        FROM problem_clusters 
        ORDER BY viability_score DESC, total_mentions DESC
    """)
    clusters = cur.fetchall()
    conn.close()

    cards = ""
    for c in clusters:
        cards += f"""
        <div style="border: 1px solid #334155; background: #1e293b; border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; color: #f8fafc;">
            <div style="display: flex; justify-content: space-between; align-items: flex-start;">
                <h3 style="margin: 0; color: #38bdf8; font-size: 1.2rem;">{c['title']}</h3>
                <span style="background: #0284c7; padding: 2px 8px; border-radius: 4px; font-weight: bold; font-size: 0.85rem;">
                    Score: {c['viability_score']:.1f}/10
                </span>
            </div>
            <p style="margin: 0.5rem 0; color: #cbd5e1; font-size: 0.95rem;">{c['executive_summary']}</p>
            <div style="font-size: 0.8rem; color: #94a3b8; display: flex; gap: 1rem; margin-top: 0.5rem;">
                <span><strong>Industry:</strong> {c['industry']}</span>
                <span><strong>Persona:</strong> {c['target_persona']}</span>
                <span><strong>Type:</strong> {c['dimension']}</span>
                <span><strong>Mentions:</strong> {c['total_mentions']}</span>
            </div>
            <div style="margin-top: 0.75rem;">
                <a href="/clusters/{c['id']}/markdown" style="color: #67e8f9; text-decoration: none; font-size: 0.85rem; margin-right: 1rem;">Export Markdown Dossier &rarr;</a>
                <a href="/api/clusters/{c['id']}/evidence" style="color: #67e8f9; text-decoration: none; font-size: 0.85rem;">Inspect Citations &rarr;</a>
            </div>
        </div>
        """

    if not cards:
        cards = "<p style='color: #94a3b8;'>No clusters discovered yet. Run the /api/trigger-cycle endpoint to start.</p>"

    html = f"""
    <!DOCTYPE html>
    <html>
    <head>
        <title>Pain Point Discovery Engine</title>
        <style>body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; margin: 0; padding: 2rem; }}</style>
    </head>
    <body style="max-width: 900px; margin: auto;">
        <header style="margin-bottom: 2rem;">
            <h1 style="color: #f8fafc; margin: 0;">Market Friction & Systems Gap Engine</h1>
            <p style="color: #94a3b8; margin: 0.25rem 0 0 0;">Continuous discovery of enterprise bottlenecks, system failures, and process defects using Google Gemini Grounding.</p>
        </header>
        {cards}
    </body>
    </html>
    """
    return HTMLResponse(content=html)


@app.post("/api/trigger-cycle")
def trigger_daily_discovery_cycle():
    """
    Triggers web search grounding, pain point extraction, clustering, and summarization.
    """
    queries = [
        "broken software workflows r/sysadmin complaints",
        "EHR software billing reconciliation manual workarounds",
        "logistics dispatch ERP sync errors missing features",
        "construction contractor management software friction"
    ]
    raw_text = engine.run_daily_search(queries)
    points = engine.extract_structured_pain_points(raw_text)
    
    assigned = []
    for p in points:
        vec = engine.get_embedding(f"{p.industry} {p.target_persona} {p.core_problem_statement}")
        cid = engine.cluster_pain_point(p, vec)
        assigned.append(cid)

    # Check for clusters that crossed doubling threshold
    engine.check_and_resummarize_clusters()

    return {
        "status": "success",
        "extracted_pain_points": len(points),
        "affected_clusters": len(set(assigned))
    }


@app.get("/api/clusters/{cluster_id}/evidence")
def get_cluster_evidence(cluster_id: str):
    conn = get_db()
    cur = conn.cursor()
    cur.execute("SELECT * FROM extracted_pain_points WHERE cluster_id = ? ORDER BY severity_rating DESC", (cluster_id,))
    rows = [dict(r) for r in cur.fetchall()]
    conn.close()
    return rows


@app.get("/clusters/{cluster_id}/markdown")
def download_markdown_dossier(cluster_id: str):
    conn = get_db()
    cur = conn.cursor()
    cur.execute("SELECT * FROM problem_clusters WHERE id = ?", (cluster_id,))
    c = cur.fetchone()
    if not c:
        raise HTTPException(status_code=404, detail="Cluster not found")

    cur.execute("SELECT * FROM extracted_pain_points WHERE cluster_id = ? ORDER BY severity_rating DESC", (cluster_id,))
    points = cur.fetchall()
    conn.close()

    incumbents = c['incumbent_solutions'] or "None identified"
    md = f"""# Opportunity Dossier: {c['title']}

**Cluster ID:** `{c['id']}`  
**Viability Score:** `{c['viability_score']:.1f}/10` | **Total Mentions:** `{c['total_mentions']}`  
**Target Vertical:** {c['industry']}  
**Target Persona:** {c['target_persona']}  
**Dimension:** {c['dimension']}  
**Incumbent Systems:** {incumbents}  

---

## Executive Summary
{c['executive_summary']}

---

## Corroborating Field Evidence ({len(points)} citations)

"""
    for idx, p in enumerate(points, 1):
        md += f"""### Finding {idx}
* **Severity:** `{p['severity_rating']}/5` | **WTP Tier:** `{p['wtp_tier']}`
* **Problem:** {p['core_problem_statement']}
* **Direct Citation:**
> "{p['extracted_quote']}"
* **Source:** {p['source_url']}

---
"""
    return Response(
        content=md,
        media_type="text/markdown",
        headers={"Content-Disposition": f"attachment; filename=dossier_{cluster_id[:8]}.md"}
    )


# -----------------------------------------------------------------------------
# 5. CLI Entrypoint
# -----------------------------------------------------------------------------

if __name__ == "__main__":
    init_db()
    if len(sys.argv) > 1 and sys.argv[1] == "run-search":
        print("[*] Running standalone daily search and clustering pipeline...")
        queries = [
            "broken B2B software workflows complaints 2026",
            "manual spreadsheet data entry friction healthcare operations",
            "logistics freight dispatch software API problems"
        ]
        raw = engine.run_daily_search(queries)
        extracted = engine.extract_structured_pain_points(raw)
        print(f"[*] Extracted {len(extracted)} valid commercial pain points.")
        for item in extracted:
            v = engine.get_embedding(f"{item.industry} {item.target_persona} {item.core_problem_statement}")
            engine.cluster_pain_point(item, v)
        engine.check_and_resummarize_clusters()
        print("[*] Cycle complete.")
    else:
        print("[*] Starting web dashboard at http://localhost:8000 ...")
        uvicorn.run(app, host="0.0.0.0", port=8000)

How to Run

  1. Launch the Web Dashboard:

    python app.py
    

    Open http://localhost:8000 in your browser.

  2. Trigger a Discovery Cycle Manually or via Cron:

    • Via HTTP POST:

      curl -X POST http://localhost:8000/api/trigger-cycle
      
    • Via Standalone CLI Cron Job:

      python app.py run-search


Why would I want to user PostgreSQL and pgvector? Is that in addition to SQLite3 or in place of?

PostgreSQL with pgvector is typically adopted in place of SQLite3 when transitioning from a local proof-of-concept to a multi-user, production-scale ingestion pipeline. However, keeping both in a hybrid architecture (SQLite as an edge/worker-local scratchpad, PostgreSQL as the central intelligence store) is also a valid pattern depending on workload requirements.

Core Reasons to Use PostgreSQL + pgvector

  • Native Vector Indexing (HNSW & IVFFlat): While standard SQLite stores embeddings as raw binary blobs (BLOB) requiring an exhaustive, linear scan (O(N) dot products in memory), pgvector builds Hierarchical Navigable Small World (HNSW) or inverted file (IVFFlat) indexes. This executes approximate nearest neighbor (ANN) similarity queries in sub-linear logarithmic time ($O(\log N)$) directly within the database engine.

  • True Concurrent Write Concurrency: SQLite uses database-level locking during writes (or WAL mode with a single writer). If distributed scrapers, multiple Celery workers, and web API users write simultaneously, SQLite quickly encounters database is locked timeouts. PostgreSQL utilizes Multi-Version Concurrency Control (MVCC) and row-level locking (FOR UPDATE SKIP LOCKED), enabling dozens of workers to process and cluster items in parallel without blocking.

  • Relational Joins with Vector Distance Filters: In PostgreSQL, vector operations are first-class SQL expressions. You can filter by metadata, join tables, and calculate distances in a single execution plan without pulling raw vectors into Python memory:

    SELECT p.id, p.extracted_quote, (p.embedding <=> %s) AS distance
    FROM extracted_pain_points p
    JOIN sources s ON p.source_id = s.id
    WHERE s.platform_name = 'reddit'
      AND (p.embedding <=> %s) < 0.20
    ORDER BY distance ASC
    LIMIT 10;
    
  • JSONB & Specialized Indexing: PostgreSQL’s JSONB data type supports GIN indexing, allowing deep queries directly inside structured extraction payloads (e.g., checking WHERE (wtp_signals->>'has_budget_signal')::boolean = true) with index-backed speed.

In Addition to, or In Place of?

Deployment PatternArchitectural RoleBest Fit Scenario
In Place Of (Complete Replacement)PostgreSQL acts as the single source of truth for both relational metadata and dense vector centroids.Centralized cloud or server deployments where web crawlers, LLM enrichment pipelines, and user dashboards query one unified system.
In Addition To (Hybrid Pipeline)SQLite operates as a fast, local staging buffer on scraping nodes; PostgreSQL serves as the persistent, centralized analytics store.Distributed scrapers running on local machines or edge devices that stage and pre-filter noisy posts in SQLite before uploading enriched batches to PostgreSQL.

When to Stick with SQLite3

  • Zero-Setup / Single-Node Workflows: If the entire discovery system runs on one machine or as an internal desktop CLI tool with fewer than 50,000 pain point records, SQLite3 avoids managing a persistent daemon or Docker container.

  • Local Vector Extensions: If keeping SQLite’s single-file convenience is paramount, compiled extensions like sqlite-vec add vector similarity search directly into SQLite without needing PostgreSQL, though write concurrency limits remain.

Immediate Next Steps

  1. Keep SQLite3 if you are currently testing prompts, refining Pydantic schemas, and running single-threaded batch crawls.

  2. Migrate to PostgreSQL + pgvector as soon as you deploy background workers (Celery, RQ, or systemd daemons) running simultaneous ingestion cycles or when vector search latency starts degrading with growing cluster volume.


Are any online subscriptions needed to run the end-to-end application presented?

The end-to-end Python script as written requires no recurring paid software subscriptions, but it does depend on a single online cloud API credential:

1. Google Gemini API Key (The Only Online Requirement)

  • What it is: Access to the google-genai SDK for Google Search Grounding (types.GoogleSearch()), structured JSON extraction, cluster synthesis, and embeddings (text-embedding-004).

  • Cost / Subscription Model: It uses pay-as-you-go API consumption rather than a fixed monthly subscription. Google AI Studio provides:

    • A Free Tier with rate limits (requests per minute / day) that allows you to run development and test cycles at zero cost.

    • A Pay-as-you-go Tier if you exceed free-tier request limits or scale up high-volume daily runs. For typical daily scheduled batch runs (e.g., 5 to 10 search queries plus extractions and embeddings once per day), consumption is minimal.

2. What Is 100% Free and Local

Everything else in the provided architecture runs completely offline on your local machine with no accounts, keys, or subscriptions:

ComponentTechnologyCost / LicenseOnline Subscription?
Relational DatabaseSQLite (sqlite3)Built-in Python standard libraryNone
Vector Storage & MathnumpyOpen source (BSD)None
Web Server & UIFastAPI + uvicornOpen source (MIT)None
Data ValidationpydanticOpen source (MIT)None
Local Scraping / Headless Browsers (if added later)Playwright / Crawl4AIOpen source (Apache 2.0)None

3. How to Make It 100% Free and Offline (Zero Cloud Dependency)

If you want to eliminate the Google Gemini API key and run the entire pipeline fully self-hosted with no external network calls:

  1. Replace Gemini Search Grounding with Direct Crawlers:

    • Run local scrapers via crawl4ai, playwright, or platform RSS feeds (Reddit .json / RSS endpoints, public forum feeds) running locally on cron.

  2. Replace Gemini Extraction & Summarization with Local Ollama:

    • Point the client to a local model running on your machine (e.g., qwen2.5:14b or llama3.1:8b via Ollama's local OpenAI-compatible endpoint http://localhost:11434/v1).

  3. Replace Gemini Embeddings with Local Sentence Transformers:

    • Generate vector representations locally using sentence-transformers with models like BAAI/bge-large-en-v1.5 running on local CPU/GPU.


So Reddit, arXiv and other online service subscriptions are not needed or useful?

Online service subscriptions are not required for the provided end-to-end architecture, but whether individual platform APIs (like Reddit or arXiv) are useful depends on your target data sources and legal/operational constraints.

Why Subscriptions Were Bypassed: Gemini Grounding vs. Direct APIs

The end-to-end architecture uses Google Search Grounding (types.GoogleSearch()). This acts as an automated search index:

[Target Inquiry: "EHR software billing reconciliation manual workarounds"]
                               │
                               ▼
            [Google Gemini Search Grounding Engine]
       (Live-indexes Reddit, trade forums, news, Substacks, blogs)
                               │
                               ▼
        [Pre-Aggregated Narrative Snippets & Source Citations]
                               │
                               ▼
     [Structured Pydantic Extraction -> Clustering -> SQLite]

By querying Google's live web index directly, the pipeline avoids:

  • Managing developer accounts and OAuth tokens across a dozen individual platforms.

  • Building and maintaining separate scrapers or parsers whenever a site updates its CSS/HTML.

  • Buying residential IP proxy pools to dodge anti-bot services (Cloudflare, PerimeterX).

When Direct APIs Are Useful (and What They Cost)

If your discovery goals require deep, exhaustive, or vertical-specific monitoring rather than high-level web indexing, tapping directly into specific APIs becomes relevant:

PlatformUtility for Problem DiscoverySubscription / Access CostKey Limitations
Reddit APIHigh for consumer, SMB, DevOps, and practitioner complaints (e.g., r/sysadmin, r/msp, r/logistics).Free tier for non-commercial research (OAuth required, 100 QPM). Commercial products require negotiated enterprise licenses (starting at ~$12,000/mo).Unauthenticated .json scraping endpoints return HTTP 403. Crawlers without OAuth or commercial approval face aggressive IP blocks.
arXiv APIHigh for emerging technical/algorithmic bottlenecks and academic gaps; Low for day-to-day business workflow problems.100% Free (No API key or auth required; simple public HTTP GET/POST).Enforces a strict 3 requests/second rate limit. Academic papers describe research deficiencies rather than immediate B2B software purchasing intent.
G2 / CapterraHigh for identifying software feature gaps (1–3 star "cons" reviews).Closed / Enterprise Only. G2 does not offer a free public developer API for scraping reviews.Requires custom headless browser scrapers (Playwright/Crawl4AI) or third-party web scraping APIs (Bright Data, Apify).
SEC EDGAR (10-K Risk Factors)High for enterprise supply chain, operational, and regulatory risks.100% Free public API provided by the SEC.Requires setting a custom User-Agent declared with contact email; strict 10 requests/second limit.
Hacker News (Algolia API)High for developer tooling, infrastructure, and startup workflow pain points.100% Free public API without authentication.Rate limits apply, but it is one of the most developer-friendly open APIs available.

Recommended Pipeline Strategy

  1. For General Market & Problem Discovery: Stick with Google Search Grounding via Gemini. It crawls Reddit threads, forum rants, and reviews natively without you needing Reddit developer accounts or proxy subscriptions.

  2. For Deep Tech / Systems Research: Add direct queries to the arXiv API and Hacker News Algolia API. Both are completely free and require no accounts or subscriptions.

  3. For Direct Platform Scraping: If you need to monitor specific niche subreddits or review platforms continuously without Gemini Search Grounding, look into an open-source headless crawler stack (e.g., Crawl4AI + local browser) rather than expensive platform API enterprise tiers.


Show me how to integrate the free arXiv API and Hacker News Algolia API directly into app.py without needing any API keys.

Integrating the Hacker News Algolia API and arXiv API directly into app.py gives you authentic practitioner complaints (HN comments/Ask HNs) and systems bottleneck research (arXiv CS/systems abstracts) with zero authentication, zero tokens, and zero API keys.

Both APIs return standard public responses (JSON from Algolia; Atom XML from arXiv) using basic HTTP GET requests via Python’s standard urllib or httpx.

Step 1: Add the Ingestion Handlers to DiscoveryEngine

Add these two methods to your DiscoveryEngine class in app.py. They query the endpoints, parse the responses into normalized raw text snippets, and prepare them for your Pydantic extraction model.

import html
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from typing import List, Dict

class DiscoveryEngine:
    # ... existing __init__ and models ...

    def fetch_hacker_news_discussions(self, query: str, limit: int = 15) -> List[Dict[str, str]]:
        """
        Queries the free Hacker News Algolia Search API (no API key required).
        Filters for comments and Ask HN submissions where practitioners discuss bottlenecks.
        """
        base_url = "https://hn.algolia.com/api/v1/search"
        params = {
            "query": query,
            "tags": "(story,comment)",  # Both main threads (Ask HN) and deep comments
            "hitsPerPage": limit,
        }
        url = f"{base_url}?{urllib.parse.urlencode(params)}"
        
        req = urllib.request.Request(url, headers={"User-Agent": "ProblemDiscoveryBot/1.0"})
        records = []
        
        try:
            with urllib.request.urlopen(req, timeout=10) as response:
                data = json.loads(response.read().decode("utf-8"))
                for hit in data.get("hits", []):
                    # Stories use 'title'/'story_text'; comments use 'comment_text'
                    text = hit.get("comment_text") or hit.get("story_text") or hit.get("title") or ""
                    clean_text = html.unescape(text).replace("<p>", "\n").replace("</p>", "")
                    
                    if len(clean_text.strip()) > 60:
                        records.append({
                            "source": "HackerNews",
                            "title": hit.get("story_title") or hit.get("title") or "HN Comment",
                            "url": f"https://news.ycombinator.com/item?id={hit.get('objectID')}",
                            "body": clean_text
                        })
        except Exception as e:
            print(f"[!] Error fetching Hacker News items: {e}")
            
        return records

    def fetch_arxiv_papers(self, query: str, max_results: int = 10) -> List[Dict[str, str]]:
        """
        Queries the free arXiv Atom/XML API (no API key required).
        Extracts titles, abstracts, and identifiers for systems/engineering limitations.
        """
        # Target computer science, software engineering, and systems architectures (cs.SE, cs.DC, cs.NI)
        formatted_query = f"all:{query} AND (cat:cs.SE OR cat:cs.DC OR cat:cs.DB OR cat:cs.AI)"
        params = {
            "search_query": formatted_query,
            "start": 0,
            "max_results": max_results,
            "sortBy": "submittedDate",
            "sortOrder": "descending"
        }
        url = f"https://export.arxiv.org/api/query?{urllib.parse.urlencode(params)}"
        req = urllib.request.Request(url, headers={"User-Agent": "ProblemDiscoveryBot/1.0"})
        records = []

        try:
            with urllib.request.urlopen(req, timeout=15) as response:
                xml_data = response.read().decode("utf-8")
                root = ET.fromstring(xml_data)
                
                # Atom namespace mapping
                ns = {"atom": "http://www.w3.org/2005/Atom"}
                
                for entry in root.findall("atom:entry", ns):
                    title_elem = entry.find("atom:title", ns)
                    summary_elem = entry.find("atom:summary", ns)
                    id_elem = entry.find("atom:id", ns)
                    
                    title = title_elem.text.strip().replace("\n", " ") if title_elem is not None else "Untitled"
                    summary = summary_elem.text.strip().replace("\n", " ") if summary_elem is not None else ""
                    link = id_elem.text.strip() if id_elem is not None else "https://arxiv.org"
                    
                    if len(summary) > 50:
                        records.append({
                            "source": "arXiv",
                            "title": title,
                            "url": link,
                            "body": f"Research Title: {title}\nAbstract: {summary}"
                        })
        except Exception as e:
            print(f"[!] Error fetching arXiv papers: {e}")

        return records

Step 2: Wire Direct Endpoints into the /api/trigger-cycle Pipeline

Update your ingestion loop in app.py so that instead of relying exclusively on Gemini search grounding, it combines targeted direct API crawls with search results before passing them to the Pydantic structured extraction model:

@app.post("/api/trigger-cycle")
def trigger_daily_discovery_cycle(
    include_hn: bool = Query(True, description="Query Hacker News Algolia API"),
    include_arxiv: bool = Query(True, description="Query arXiv API"),
    include_web_grounding: bool = Query(True, description="Run Gemini Google Search grounding")
):
    """
    Executes a multi-channel discovery pass across free APIs and Google Search.
    """
    aggregated_findings: List[Dict[str, str]] = []

    # 1. Fetch from Hacker News (free, unauthenticated)
    if include_hn:
        hn_queries = [
            "Ask HN: software broken workflow",
            "enterprise billing reconciliation friction",
            "distributed systems data sync downtime failure"
        ]
        for q in hn_queries:
            items = engine.fetch_hacker_news_discussions(q, limit=8)
            aggregated_findings.extend(items)

    # 2. Fetch from arXiv (free, unauthenticated)
    if include_arxiv:
        arxiv_queries = [
            "microservices latency bottlenecks",
            "autonomous edge verification faults",
            "large-scale knowledge graph inconsistency"
        ]
        for q in arxiv_queries:
            items = engine.fetch_arxiv_papers(q, max_results=5)
            aggregated_findings.extend(items)

    # 3. Optional: Add Gemini Google Search Grounding
    if include_web_grounding:
        search_summary = engine.run_daily_search([
            "EHR integration errors healthcare complaints 2026",
            "field service technician software bugs"
        ])
        aggregated_findings.append({
            "source": "Google Search Grounding",
            "title": "Daily Web Grounded Sweep",
            "url": "https://google.com",
            "body": search_summary
        })

    # 4. Pack findings into structured text chunks for Pydantic LLM Extraction
    combined_raw_text = "\n\n---\n\n".join([
        f"}] (URL: {f['url']})\nTITLE: {f['title']}\nCONTENT:\n{f['body'][:1200]}"
        for f in aggregated_findings
    ])

    # Run Pydantic extraction over the multi-source feed
    points = engine.extract_structured_pain_points(combined_raw_text)

    # 5. Incremental Vector Clustering & Database Persistence
    assigned_cluster_ids = []
    for p in points:
        vec = engine.get_embedding(f"{p.industry} {p.target_persona} {p.core_problem_statement}")
        cid = engine.cluster_pain_point(p, vec)
        assigned_cluster_ids.append(cid)

    # 6. Re-summarize clusters that hit mention milestones
    engine.check_and_resummarize_clusters()

    return {
        "status": "success",
        "raw_sources_ingested": len(aggregated_findings),
        "extracted_pain_points": len(points),
        "affected_clusters": len(set(assigned_cluster_ids))
    }

Step 3: Verify the Unauthenticated Endpoints via CLI

Run a quick test script to confirm that neither endpoint requires tokens or hits blocking barriers:

# test_free_apis.py
from app import DiscoveryEngine

engine = DiscoveryEngine()

# Test HN
hn_results = engine.fetch_hacker_news_discussions("EHR software bug", limit=3)
print(f"Hacker News hits: {len(hn_results)}")
if hn_results:
    print(f"Sample HN: {hn_results[0]['title']} -> {hn_results[0]['url']}")

# Test arXiv
arxiv_results = engine.fetch_arxiv_papers("knowledge graph verification", max_results=3)
print(f"\narXiv hits: {len(arxiv_results)}")
if arxiv_results:
    print(f"Sample arXiv: {arxiv_results[0]['title']} -> {arxiv_results[0]['url']}")

Rate Limits & Operational Hygiene

  • arXiv API: Impose a time.sleep(3.0) delay between successive arXiv queries if fetching multiple topics sequentially; arXiv blocks IPs that exceed 1 request per 3 seconds.

  • Hacker News Algolia API: Algolia allows up to 10,000 requests/hour unauthenticated from a single IP, which is more than enough capacity for periodic daily runs.


How can I fetch Reddit pain points using public RSS and .json feeds without getting blocked or needing a developer account?

Reddit serves public listings through two unauthenticated interfaces: Atom/RSS feeds (.rss) and raw listing endpoints (.json).

Because Reddit aggressively filters automated traffic—especially from cloud datacenter IP blocks (AWS, GCP, DigitalOcean) and standard library headers—maintaining reliable ingestion without an OAuth app requires adhering to specific transport and header constraints.

Key Protocol Differences: RSS vs. JSON

FeatureRSS Feeds (.rss)Public JSON (.json)
Endpoint Format

[https://www.reddit.com/r/](https://www.reddit.com/r/){sub}/new.rss


[https://www.reddit.com/r/](https://www.reddit.com/r/){sub}/search.rss?q={term}&sort=new

[https://www.reddit.com/r/](https://www.reddit.com/r/){sub}/new.json?limit=50


[https://www.reddit.com/r/](https://www.reddit.com/r/){sub}/search.json?q={term}

Anti-Bot SensitivityLower (treated as syndication feed reader traffic)High (frequently returns 403 Forbidden to non-browser UAs)
Data PayloadTitle, author, post link, and HTML-escaped post bodyFull JSON AST: score, num_comments, flair, selftext
Ingestion Limits~25 most recent items per requestUp to 100 items per request via ?limit=100

4 Anti-Blocking Requirements

  1. Descriptive Non-Default User-Agent:

    Requests containing default headers (python-requests/*, Python-urllib/*, aiohttp/*) receive an immediate 429 Too Many Requests or 403 Forbidden. Use a custom identifier structured as:User-Agent: desktop:com.discovery.painpointminer:v1.0 (by /u/your_username_or_domain)

  2. Local/Residential IP Routing:

    Reddit drops unauthenticated .json requests from major hosting providers at the edge. Run these ingestion calls from your local workstation, home connection, or through a local desktop daemon rather than cloud VPS environments.

  3. Pacing & Backoff:

    Cap unauthenticated polling to no more than 1 request every 2 to 3 seconds across all subreddit targets. If an HTTP 429 is returned, extract the Retry-After header value (default to 60 seconds if omitted) and enter exponential backoff.

  4. Prefer .rss for Subreddit Monitoring:

    Reddit rarely returns 403 on .rss endpoints when queried at moderate rates because RSS reader clients do not authenticate.

Python Ingestion Implementation

This module uses Python's standard library (urllib.request, xml.etree.ElementTree, and json) to pull either RSS or JSON feeds with rate pacing, custom headers, and error recovery.

import html
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from typing import Dict, List, Optional


class UnauthenticatedRedditScraper:
    def __init__(self, app_name: str = "PainPointMiner", version: str = "1.0"):
        # Unique descriptive identifier prevents default library blacklisting
        self.user_agent = f"desktop:com.discovery.{app_name}:v{version} (feed reader)"
        self.last_request_ts = 0.0
        self.min_interval = 2.5  # Seconds between requests to prevent 429s

    def _rate_limit(self):
        elapsed = time.time() - self.last_request_ts
        if elapsed < self.min_interval:
            time.sleep(self.min_interval - elapsed)
        self.last_request_ts = time.time()

    def fetch_rss_pain_points(self, subreddit: str, search_query: Optional[str] = None, limit: int = 25) -> List[Dict[str, str]]:
        """
        Ingests posts via Reddit's Atom/RSS feeds.
        Highest reliability against 403 Forbidden blocks.
        """
        self._rate_limit()

        if search_query:
            encoded_query = urllib.parse.quote(search_query)
            url = f"https://www.reddit.com/r/{subreddit}/search.rss?q={encoded_query}&sort=new&restrict_sr=on"
        else:
            url = f"https://www.reddit.com/r/{subreddit}/new.rss?limit={limit}"

        headers = {
            "User-Agent": self.user_agent,
            "Accept": "application/atom+xml, application/rss+xml, text/xml;q=0.9",
            "Cache-Control": "no-cache",
        }

        req = urllib.request.Request(url, headers=headers)
        records = []

        try:
            with urllib.request.urlopen(req, timeout=12) as response:
                content = response.read().decode("utf-8")
                root = ET.fromstring(content)
                
                # Atom namespace mapping
                ns = {"atom": "http://www.w3.org/2005/Atom"}

                for entry in root.findall("atom:entry", ns):
                    title_elem = entry.find("atom:title", ns)
                    link_elem = entry.find("atom:link", ns)
                    content_elem = entry.find("atom:content", ns)
                    published_elem = entry.find("atom:published", ns)

                    title = title_elem.text if title_elem is not None else ""
                    link = link_elem.attrib.get("href", "") if link_elem is not None else ""
                    
                    # Unescape HTML embedded inside the Atom content tag
                    raw_html = content_elem.text if content_elem is not None else ""
                    clean_text = self._strip_html_tags(raw_html)

                    records.append({
                        "platform": "reddit",
                        "source_type": "rss",
                        "subreddit": subreddit,
                        "title": title,
                        "url": link,
                        "body": clean_text,
                        "published_at": published_elem.text if published_elem is not None else ""
                    })

        except urllib.error.HTTPError as e:
            if e.code == 429:
                retry_wait = int(e.headers.get("Retry-After", 60))
                print(f"[!] Rate limited on r/{subreddit}. Pausing for {retry_wait}s...")
                time.sleep(retry_wait)
            elif e.code == 403:
                print(f"[!] HTTP 403 on r/{subreddit}. Fall back to alternative subreddit or rotate IP.")
            else:
                print(f"[!] HTTP Error {e.code} fetching r/{subreddit}: {e.reason}")
        except Exception as e:
            print(f"[!] Error parsing RSS for r/{subreddit}: {e}")

        return records

    def fetch_json_pain_points(self, subreddit: str, limit: int = 50) -> List[Dict[str, str]]:
        """
        Queries Reddit's .json endpoints. Works best on residential/local IPs.
        """
        self._rate_limit()
        url = f"https://www.reddit.com/r/{subreddit}/new.json?limit={min(limit, 100)}"
        
        headers = {
            "User-Agent": self.user_agent,
            "Accept": "application/json",
            "Cache-Control": "no-cache"
        }

        req = urllib.request.Request(url, headers=headers)
        records = []

        try:
            with urllib.request.urlopen(req, timeout=12) as response:
                payload = json.loads(response.read().decode("utf-8"))
                posts = payload.get("data", {}).get("children", [])

                for p in posts:
                    data = p.get("data", {})
                    # Filter out stickied or link-only posts
                    if data.get("stickied") or data.get("is_self") is False:
                        continue

                    records.append({
                        "platform": "reddit",
                        "source_type": "json",
                        "subreddit": subreddit,
                        "title": data.get("title", ""),
                        "url": f"https://www.reddit.com{data.get('permalink', '')}",
                        "body": data.get("selftext", ""),
                        "score": data.get("score", 0),
                        "num_comments": data.get("num_comments", 0),
                        "published_at": time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(data.get("created_utc", 0)))
                    })

        except urllib.error.HTTPError as e:
            print(f"[!] HTTP {e.code} on .json endpoint for r/{subreddit} ({e.reason})")
        except Exception as e:
            print(f"[!] JSON parsing error for r/{subreddit}: {e}")

        return records

    @staticmethod
    def _strip_html_tags(raw_html: str) -> str:
        unescaped = html.unescape(raw_html)
        clean = re.sub(r"<[^>]+>", " ", unescaped)
        clean = re.sub(r"\s+", " ", clean).strip()
        return clean

Concrete Execution Steps

  1. Target Practitioner Subreddits: Direct queries at niche professional communities with recurring workflow friction rather than general discussion:

    • Systems & IT: sysadmin, msp, devops

    • Operations & Logistics: logistics, supplychain, freightbrokers

    • Specialized Software: quickbooks, servicenow, epic

  2. Execute Ingestion Loop: Run the module across your target subreddits using the .rss method as the primary scraper and .json as a secondary fallback:

    scraper = UnauthenticatedRedditScraper(app_name="OperationsAudit", version="1.0")
    
    # Pull recent operational complaints from r/sysadmin
    results = scraper.fetch_rss_pain_points(
        subreddit="sysadmin", 
        search_query="broken workflow OR manual spreadsheet OR migration failed"
    )
    print(f"Retrieved {len(results)} candidate pain point threads.")
    
  3. Feed into Pydantic Extraction: Forward the resulting records list directly into the existing extract_structured_pain_points() function in app.py.


How can I parse top comments from Reddit RSS entry threads without triggering 429 rate limits?

Reddit’s public RSS feeds (.rss / Atom) contain only top-level submission metadata and the post's initial body (<content>). They do not embed the comment tree. To inspect comments, your pipeline must make a secondary request to the thread's comment endpoint (/comments/{post_id}.json).

Because Reddit restricts unauthenticated IP traffic to approximately 10 requests per minute on .json endpoints, hammering every thread discovered via RSS immediately results in 429 Too Many Requests.

Core Strategies to Avoid 429s

  1. Selective Triage (Heuristic Pre-Filtering): Never fetch comments indiscriminately. Screen the RSS entry's title and selftext with keyword regex first. Only dispatch comment requests for the top 5–10% of posts showing high friction or specific commercial relevance.

  2. Minimal Tree Payload (depth=1 & limit=N): Reddit's default comment tree expands nested threads, returning massive payloads that strain rate limits. Appending ?sort=top&limit=5&depth=1 forces the server to return only the top-level parent responses in a lightweight payload.

  3. Respect Rate-Limit Headers: Reddit's servers provide real-time tracking in HTTP response headers:

    • x-ratelimit-remaining: Remaining request tokens in the current window.

    • x-ratelimit-reset: Seconds until the quota window resets.

    • Retry-After: Exact pause required when a 429 is triggered.

  4. Paced Token-Bucket Execution: Enforce a hard ceiling of one request every 6.0–7.0 seconds per IP for unauthenticated traffic, or use a free Reddit script-type OAuth client ID (which bumps the rate limit to 60–100 requests/minute at no financial cost).

Pipeline Implementation

This implementation consumes parsed RSS entries, identifies priority threads, and extracts top-level comments using adaptive backoff and parameter-tuned .json calls.

import html
import json
import re
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Dict, List, Optional


class RedditCommentHarvester:
    def __init__(self, app_name: str = "MarketIntelExtractor", version: str = "1.0"):
        # Custom descriptive User-Agent to avoid immediate generic blocks
        self.user_agent = f"desktop:com.intel.{app_name}:v{version} (comment-harvester)"
        # Default minimum spacing for unauthenticated calls (10 req/min => ~6.5s delay)
        self.min_interval = 6.5
        self.last_call_timestamp = 0.0

    def _enforce_rate_limit(self):
        """Maintains minimum delay between outgoing requests."""
        elapsed = time.time() - self.last_call_timestamp
        if elapsed < self.min_interval:
            time.sleep(self.min_interval - elapsed)
        self.last_call_timestamp = time.time()

    @staticmethod
    def extract_post_id(thread_url: str) -> Optional[str]:
        """
        Extracts the Base36 submission ID from a standard Reddit URL.
        Example: https://www.reddit.com/r/sysadmin/comments/1abc23/title/ -> 1abc23
        """
        match = re.search(r"/comments/([a-z0-9]+)/", thread_url)
        return match.group(1) if match else None

    def fetch_top_comments(
        self,
        post_url: str,
        max_comments: int = 5
    ) -> List[Dict[str, str]]:
        """
        Retrieves top parent comments for a post using minimal payload flags.
        """
        post_id = self.extract_post_id(post_url)
        if not post_id:
            return []

        # Target the comment endpoint restricted to depth 1 and top sort
        params = {
            "sort": "top",
            "limit": max_comments,
            "depth": 1  # Prevents expanding deep sub-trees
        }
        endpoint = f"https://www.reddit.com/comments/{post_id}.json?{urllib.parse.urlencode(params)}"

        headers = {
            "User-Agent": self.user_agent,
            "Accept": "application/json",
            "Cache-Control": "no-cache"
        }

        self._enforce_rate_limit()
        req = urllib.request.Request(endpoint, headers=headers)
        extracted_comments = []

        try:
            with urllib.request.urlopen(req, timeout=12) as response:
                # Dynamic header-based pacing
                remaining = response.headers.get("x-ratelimit-remaining")
                reset_sec = response.headers.get("x-ratelimit-reset")
                
                if remaining and float(remaining) < 2.0 and reset_sec:
                    # Approaching window exhaustion: dynamically pause
                    sleep_time = float(reset_sec) + 0.5
                    print(f"[*] Approaching quota ({remaining} left). Throttling {sleep_time:.1f}s...")
                    time.sleep(sleep_time)

                data = json.loads(response.read().decode("utf-8"))

                # Reddit returns a 2-element array: [Listing(Post), Listing(Comments)]
                if len(data) > 1 and "data" in data[1]:
                    children = data[1]["data"].get("children", [])
                    for child in children:
                        # Exclude 'more' pagination placeholders
                        if child.get("kind") != "t1":
                            continue
                        
                        c_data = child.get("data", {})
                        body = c_data.get("body", "").strip()
                        
                        # Filter out bot auto-moderators or empty removals
                        if body and body not in ["[deleted]", "[removed]"]:
                            extracted_comments.append({
                                "comment_id": c_data.get("id"),
                                "author": c_data.get("author", "[anonymous]"),
                                "score": c_data.get("score", 0),
                                "body": html.unescape(body)
                            })

        except urllib.error.HTTPError as e:
            if e.code == 429:
                retry_wait = int(e.headers.get("Retry-After", 60))
                print(f"[!] Hit 429 Rate Limit. Cooling down for {retry_wait}s...")
                time.sleep(retry_wait)
            else:
                print(f"[!] HTTP {e.code} error fetching comments for {post_id}: {e.reason}")
        except Exception as e:
            print(f"[!] Parsing error on {post_id}: {e}")

        return extracted_comments

    def process_rss_feed_selective(
        self,
        rss_entries: List[Dict[str, str]],
        keyword_filter: str = r"(broken|manual|failure|workaround|error|unusable|migration)",
        max_threads_to_expand: int = 5
    ) -> List[Dict[str, str]]:
        """
        Filters incoming RSS feed entries and enriches only high-signal threads with comments.
        """
        pattern = re.compile(keyword_filter, re.IGNORECASE)
        enriched_results = []
        expanded_count = 0

        for entry in rss_entries:
            combined_text = f"{entry.get('title', '')} {entry.get('body', '')}"
            
            # Check if this post warrants drilling into comments
            if pattern.search(combined_text) and expanded_count < max_threads_to_expand:
                print(f"[*] High-priority thread identified: '{entry.get('title')[:60]}...'")
                comments = self.fetch_top_comments(entry["url"], max_comments=3)
                
                # Format comments into context block
                if comments:
                    comment_block = "\n".join([
                        f"  - [Score {c['score']}] {c['body'][:400]}"
                        for c in comments
                    ])
                    entry["enriched_body"] = f"{entry.get('body', '')}\n\nTop Community Observations:\n{comment_block}"
                else:
                    entry["enriched_body"] = entry.get("body", "")
                
                expanded_count += 1
            else:
                entry["enriched_body"] = entry.get("body", "")

            enriched_results.append(entry)

        return enriched_results

Step-by-Step Flow

[Target Subreddit .rss Feed (25 Posts)]
                  │
                  ▼
   [Keyword / Friction Screener]
    (Discards 80% non-critical posts)
                  │
                  ▼
    [High-Signal Submissions (3-5 items)]
                  │
                  ▼
  [Paced Comment Extractor (6.5s delay)]
    • Requests: /comments/{id}.json?depth=1&limit=3&sort=top
    • Evaluates: x-ratelimit-remaining & Retry-After
                  │
                  ▼
 [Enriched Document (Post Body + Top Citations)]
                  │
                  ▼
  [Structured Pydantic Extraction in Gemini / LLM]

Key Operational Rules

  • Filter before querying: An unauthenticated IP has an effective budget of ~10 calls per minute. Running comment fetches against a raw 25-item RSS batch without pre-filtering will reliably trigger a 429 midway through the first run.

  • Keep depth=1: Setting depth=1 avoids traversing nested comment sub-trees, keeping payload sizes small and response processing fast.

  • Header Inspection: Inspecting x-ratelimit-remaining ensures you sleep proactively before the server triggers a hard 429 block.


Update the database insertion logic to store top comment text and scores alongside raw ingested items in SQLite.

Here is the updated database schema, upsert logic, and helper queries to store structured comments and engagement scores alongside raw ingested items in SQLite.

1. Updated Database Schema

The raw_ingested_items table is updated with structured JSON storage for full comment payloads (top_comments) and dedicated integer columns (top_comment_score_sum, max_comment_score, comment_count) so downstream filtering queries avoid parsing JSON for simple score thresholding:

CREATE TABLE IF NOT EXISTS raw_ingested_items (
    id TEXT PRIMARY KEY,
    source TEXT NOT NULL,                     -- e.g., 'reddit', 'hn', 'arxiv'
    external_id TEXT UNIQUE,                 -- e.g., 'reddit_1abc23' (prevents duplicates)
    url TEXT NOT NULL,
    title TEXT,
    body TEXT,
    published_at TEXT,
    ingested_at TEXT,
    processed_status TEXT DEFAULT 'pending', -- 'pending', 'extracted', 'discarded'
    
    -- Comment storage and metrics
    top_comments TEXT,                       -- JSON array: [{"comment_id": "...", "author": "...", "score": 45, "body": "..."}]
    comment_count INTEGER DEFAULT 0,
    top_comment_score_sum INTEGER DEFAULT 0, -- Aggregate upvotes/weight of harvested comments
    max_comment_score INTEGER DEFAULT 0      -- Score of the top single comment
);

CREATE INDEX IF NOT EXISTS idx_raw_source_external ON raw_ingested_items(source, external_id);
CREATE INDEX IF NOT EXISTS idx_raw_score_sum ON raw_ingested_items(top_comment_score_sum DESC);

2. Python Insertion & Upsert Logic

This function accepts the output from RedditCommentHarvester, computes scoring aggregates, and writes to SQLite using INSERT ... ON CONFLICT(external_id) DO UPDATE to gracefully handle re-scrapes without creating duplicate records:

import json
import sqlite3
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional


def upsert_raw_item_with_comments(
    conn: sqlite3.Connection,
    source: str,
    external_id: str,
    url: str,
    title: str,
    body: str,
    published_at: Optional[str] = None,
    comments: Optional[List[Dict[str, Any]]] = None
) -> str:
    """
    Inserts or updates raw ingested posts alongside structured top comments and scores.
    """
    cur = conn.cursor()
    item_id = str(uuid.uuid4())
    now = datetime.now(timezone.utc).isoformat()
    clean_comments = comments or []

    # Calculate engagement heuristics
    comment_count = len(clean_comments)
    scores = [int(c.get("score", 0)) for c in clean_comments]
    max_score = max(scores) if scores else 0
    score_sum = sum(scores)
    comments_json = json.dumps(clean_comments, ensure_ascii=False)

    sql = """
    INSERT INTO raw_ingested_items (
        id, source, external_id, url, title, body, published_at, ingested_at,
        processed_status, top_comments, comment_count, top_comment_score_sum, max_comment_score
    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?, ?)
    ON CONFLICT(external_id) DO UPDATE SET
        title = excluded.title,
        body = excluded.body,
        top_comments = excluded.top_comments,
        comment_count = excluded.comment_count,
        top_comment_score_sum = excluded.top_comment_score_sum,
        max_comment_score = excluded.max_comment_score,
        ingested_at = excluded.ingested_at;
    """

    cur.execute(sql, (
        item_id,
        source,
        external_id,
        url,
        title,
        body,
        published_at or now,
        now,
        comments_json,
        comment_count,
        score_sum,
        max_score
    ))
    conn.commit()
    return item_id

3. Integrated Harvest & Store Workflow

Integrating RedditCommentHarvester with the updated storage function:

def ingest_reddit_thread(conn: sqlite3.Connection, harvester, thread_url: str, title: str, post_body: str):
    post_id = harvester.extract_post_id(thread_url)
    if not post_id:
        return None

    # Harvest top comments without tripping rate limits
    comments = harvester.fetch_top_comments(thread_url, max_comments=5)

    # Persist post + comments to SQLite
    item_id = upsert_raw_item_with_comments(
        conn=conn,
        source="reddit",
        external_id=f"reddit_{post_id}",
        url=thread_url,
        title=title,
        body=post_body,
        comments=comments
    )
    return item_id

4. Querying Comment Data via SQLite JSON Functions

Because SQLite includes the json1 extension by default, you can query and aggregate individual comment fields directly in SQL without deserializing rows in Python:

  • Retrieve threads with high community validation (top comment score $\ge 25$):

    SELECT id, title, max_comment_score, top_comment_score_sum 
    FROM raw_ingested_items
    WHERE max_comment_score >= 25
    ORDER BY top_comment_score_sum DESC;
    
  • Extract the top comment text directly via json_extract:

    SELECT 
        title,
        json_extract(top_comments, '$[0].author') AS top_author,
        json_extract(top_comments, '$[0].score') AS top_score,
        json_extract(top_comments, '$[0].body') AS top_comment_body
    FROM raw_ingested_items
    WHERE comment_count > 0;
    
  • Unpack comments into individual rows via json_each:

    SELECT 
        r.id AS item_id,
        r.title,
        c.value ->> 'author' AS comment_author,
        c.value ->> 'score' AS comment_score,
        c.value ->> 'body' AS comment_body
    FROM raw_ingested_items r,
         json_each(r.top_comments) c
    WHERE c.value ->> 'score' > 10;


Show how to link individual comment extractions back to the parent item in the extracted_pain_points table using foreign keys.

To link individual comment extractions back to their parent record in extracted_pain_points, you normalize the schema by splitting unstructured JSON comment blobs into a dedicated child table (extracted_pain_point_comments) bound by a foreign key with cascade deletion.

1. Relational Schema Architecture

In SQLite, foreign keys must be explicitly enabled at the connection level via PRAGMA foreign_keys = ON;.

PRAGMA foreign_keys = ON;

-- Parent table: Granular pain point extractions
CREATE TABLE IF NOT EXISTS extracted_pain_points (
    id TEXT PRIMARY KEY,
    cluster_id TEXT,
    raw_item_id TEXT,                        -- References raw_ingested_items(id)
    industry TEXT,
    target_persona TEXT,
    dimension TEXT,
    core_problem_statement TEXT,
    extracted_quote TEXT NOT NULL,
    root_cause TEXT,
    source_url TEXT,
    severity_rating INTEGER,
    wtp_tier TEXT,
    has_budget_signal BOOLEAN,
    extracted_at TEXT,
    FOREIGN KEY(cluster_id) REFERENCES problem_clusters(id) ON DELETE SET NULL,
    FOREIGN KEY(raw_item_id) REFERENCES raw_ingested_items(id) ON DELETE CASCADE
);

-- Child table: Distinct corroborated comments linked to a pain point
CREATE TABLE IF NOT EXISTS extracted_pain_point_comments (
    id TEXT PRIMARY KEY,
    pain_point_id TEXT NOT NULL,             -- Foreign key to parent pain point
    raw_item_id TEXT,                        -- Traceability back to the raw source item
    external_comment_id TEXT UNIQUE,         -- Platform ID (e.g., Reddit fullname 't1_k9z2f8')
    author TEXT,
    upvotes INTEGER DEFAULT 0,
    comment_body TEXT NOT NULL,
    extracted_signal TEXT,                   -- Specific insight distilled from the comment
    corroboration_type TEXT,                 -- 'workaround', 'cost_impact', 'corroboration'
    ingested_at TEXT,
    FOREIGN KEY(pain_point_id) REFERENCES extracted_pain_points(id) ON DELETE CASCADE,
    FOREIGN KEY(raw_item_id) REFERENCES raw_ingested_items(id) ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS idx_comment_pain_point_id 
    ON extracted_pain_point_comments(pain_point_id);

CREATE INDEX IF NOT EXISTS idx_comment_upvotes 
    ON extracted_pain_point_comments(upvotes DESC);

2. Python Insertion Logic

This workflow writes the primary pain point record first, generates its id, and bulk-inserts all relevant comments tied to that ID within a single transaction:

import sqlite3
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional


def insert_pain_point_with_comments(
    conn: sqlite3.Connection,
    pain_point_data: Dict[str, Any],
    corroborating_comments: List[Dict[str, Any]],
    raw_item_id: Optional[str] = None,
    cluster_id: Optional[str] = None
) -> str:
    """
    Inserts a parent pain point and links its child comments via foreign key.
    """
    cur = conn.cursor()
    cur.execute("PRAGMA foreign_keys = ON;")
    
    pain_point_id = str(uuid.uuid4())
    now = datetime.now(timezone.utc).isoformat()

    # 1. Insert Parent: extracted_pain_points
    cur.execute("""
        INSERT INTO extracted_pain_points (
            id, cluster_id, raw_item_id, industry, target_persona,
            dimension, core_problem_statement, extracted_quote,
            root_cause, source_url, severity_rating, wtp_tier,
            has_budget_signal, extracted_at
        ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
    """, (
        pain_point_id,
        cluster_id,
        raw_item_id,
        pain_point_data.get("industry"),
        pain_point_data.get("target_persona"),
        pain_point_data.get("dimension"),
        pain_point_data.get("core_problem_statement"),
        pain_point_data.get("extracted_quote"),
        pain_point_data.get("root_cause"),
        pain_point_data.get("source_url"),
        pain_point_data.get("severity_rating", 3),
        pain_point_data.get("wtp_tier", "none"),
        pain_point_data.get("has_budget_signal", False),
        now
    ))

    # 2. Insert Children: extracted_pain_point_comments
    comment_rows = []
    for comment in corroborating_comments:
        comment_id = str(uuid.uuid4())
        external_id = comment.get("comment_id") or f"ext_{uuid.uuid4().hex[:10]}"
        
        comment_rows.append((
            comment_id,
            pain_point_id,
            raw_item_id,
            external_id,
            comment.get("author", "[deleted]"),
            int(comment.get("score", 0)),
            comment.get("body", ""),
            comment.get("extracted_signal"),
            comment.get("corroboration_type", "corroboration"),
            now
        ))

    if comment_rows:
        cur.executemany("""
            INSERT INTO extracted_pain_point_comments (
                id, pain_point_id, raw_item_id, external_comment_id,
                author, upvotes, comment_body, extracted_signal,
                corroboration_type, ingested_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
            ON CONFLICT(external_comment_id) DO UPDATE SET
                upvotes = excluded.upvotes,
                comment_body = excluded.comment_body,
                extracted_signal = excluded.extracted_signal;
        """, comment_rows)

    conn.commit()
    return pain_point_id

3. Querying Parent-Child Intelligence

With normalized foreign keys, you can run direct analytical joins to measure community agreement, surface workarounds, and pull corroborating quotes:

Aggregate Validation Metrics per Pain Point

SELECT 
    p.id AS pain_point_id,
    p.core_problem_statement,
    p.severity_rating,
    COUNT(c.id) AS corroborating_comment_count,
    COALESCE(SUM(c.upvotes), 0) AS total_community_upvotes,
    MAX(c.upvotes) AS peak_comment_upvotes
FROM extracted_pain_points p
LEFT JOIN extracted_pain_point_comments c ON p.id = c.pain_point_id
GROUP BY p.id
ORDER BY total_community_upvotes DESC;

Fetch Full Dossier with Thread Testimonies

SELECT 
    p.core_problem_statement,
    p.extracted_quote AS primary_post_quote,
    c.author AS comment_author,
    c.upvotes AS comment_score,
    c.comment_body,
    c.corroboration_type
FROM extracted_pain_points p
JOIN extracted_pain_point_comments c ON p.id = c.pain_point_id
WHERE p.id = 'target-pain-point-uuid'
ORDER BY c.upvotes DESC;

Verification and Next Steps

To verify table integrity, execute a test cascade delete on a mock parent record: deleting from extracted_pain_points should remove all child rows from extracted_pain_point_comments without orphaned records.


Update the markdown dossier generator to render parent pain points alongside indented top upvoted community comments.

Here is the updated Markdown dossier generator for app.py. It pulls each parent pain point associated with the cluster, queries all child comments linked via foreign key, groups them by parent, and formats them as indented blockquotes with upvote tallies, contributor handles, and corroboration tags.

Updated Dossier Endpoint (app.py)

Replace the existing download_markdown_dossier endpoint with this implementation:

import json
import sqlite3
from collections import defaultdict
from datetime import datetime, timezone
from fastapi import HTTPException, Response

@app.get("/clusters/{cluster_id}/markdown", tags=["Exports"])
def download_markdown_dossier(cluster_id: str):
    """
    Renders an opportunity dossier combining high-level cluster metrics,
    primary pain point findings, and indented, upvote-ranked community testimonies.
    """
    conn = get_db()
    cur = conn.cursor()

    # 1. Fetch Cluster Metadata
    cur.execute("""
        SELECT 
            id, title, executive_summary, industry, target_persona, 
            dimension, incumbent_solutions, total_mentions, velocity_7d, 
            viability_score, first_seen_at, last_updated_at
        FROM problem_clusters 
        WHERE id = ?;
    """, (cluster_id,))
    cluster = cur.fetchone()
    
    if not cluster:
        conn.close()
        raise HTTPException(status_code=404, detail="Cluster not found")

    # 2. Fetch Parent Pain Points
    cur.execute("""
        SELECT 
            id, industry, target_persona, dimension, core_problem_statement,
            extracted_quote, root_cause, source_url, severity_rating,
            wtp_tier, has_budget_signal, extracted_at
        FROM extracted_pain_points 
        WHERE cluster_id = ?
        ORDER BY severity_rating DESC, extracted_at DESC;
    """, (cluster_id,))
    pain_points = cur.fetchall()

    # 3. Fetch Child Comments for all retrieved pain points
    comments_by_parent = defaultdict(list)
    if pain_points:
        point_ids = [p["id"] for p in pain_points]
        placeholders = ",".join(["?"] * len(point_ids))
        
        cur.execute(f"""
            SELECT 
                id, pain_point_id, author, upvotes, 
                comment_body, extracted_signal, corroboration_type
            FROM extracted_pain_point_comments
            WHERE pain_point_id IN ({placeholders})
            ORDER BY upvotes DESC;
        """, point_ids)
        
        for c in cur.fetchall():
            comments_by_parent[c["pain_point_id"]].append(c)

    conn.close()

    # 4. Parse Incumbent Systems
    try:
        raw_incumbents = cluster["incumbent_solutions"]
        incumbents_list = json.loads(raw_incumbents) if raw_incumbents else []
        incumbents_str = ", ".join(incumbents_list) if incumbents_list else "None identified"
    except (json.JSONDecodeError, TypeError):
        incumbents_str = cluster["incumbent_solutions"] or "None identified"

    # 5. Build Markdown Content
    generated_ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
    clean_title = cluster["title"].strip()
    
    md_lines = [
        f"# Opportunity Dossier: {clean_title}",
        "",
        f"**Generated:** {generated_ts}  ",
        f"**Cluster ID:** `{cluster['id']}`  ",
        f"**Viability Score:** `{cluster['viability_score']:.1f}/10` | **Mentions (Total / 7d):** `{cluster['total_mentions']}` / `{cluster['velocity_7d']}`",
        "",
        "---",
        "",
        "## 1. Executive Summary",
        f"{cluster['executive_summary']}",
        "",
        f"* **Target Vertical:** {cluster['industry']}",
        f"* **Impacted Persona:** {cluster['target_persona']}",
        f"* **Friction Category:** {cluster['dimension'].capitalize()}",
        f"* **Incumbent Solutions Cited:** {incumbents_str}",
        "",
        "---",
        "",
        f"## 2. Market Evidence & Practitioner Citations ({len(pain_points)} primary findings)",
        ""
    ]

    for idx, pt in enumerate(pain_points, 1):
        wtp_badge = f" | **WTP Tier:** `{pt['wtp_tier'].upper()}`" if pt["has_budget_signal"] else ""
        root_cause = pt["root_cause"] or "Unspecified defect"
        source_link = f"[{pt['source_url']}]({pt['source_url']})" if pt["source_url"] else "Direct submission"

        md_lines.extend([
            f"### Finding {idx}: {pt['core_problem_statement']}",
            f"* **Severity:** `{pt['severity_rating']}/5`{wtp_badge}",
            f"* **Root Cause / Defect:** {root_cause}",
            f"* **Source Reference:** {source_link}",
            "* **Primary Testimony:**",
            f"> \"{pt['extracted_quote']}\"",
            ""
        ])

        # Child Comments block
        child_comments = comments_by_parent.get(pt["id"], [])
        if child_comments:
            md_lines.append("**Corroborating Community Evidence:**")
            md_lines.append("")
            for c in child_comments:
                tag = f" `[{c['corroboration_type'].upper()}]`" if c["corroboration_type"] else ""
                author = c["author"] or "anonymous"
                upvotes = c["upvotes"]
                body_clean = c["comment_body"].replace("\n", "\n> > ")
                
                md_lines.extend([
                    f"> * **▲ {upvotes}** by `u/{author}`{tag}:",
                    f"> > \"{body_clean}\"",
                    ">"
                ])
            # Strip trailing blank quote indicator
            if md_lines[-1] == ">":
                md_lines.pop()
            md_lines.append("")
        else:
            md_lines.extend([
                "> *(No secondary comment citations recorded for this thread)*",
                ""
            ])

        md_lines.extend(["---", ""])

    filename_safe = clean_title.lower().replace(" ", "_")[:32]
    filename = f"dossier_{filename_safe}_{cluster['id'][:8]}.md"

    return Response(
        content="\n".join(md_lines),
        media_type="text/markdown",
        headers={"Content-Disposition": f"attachment; filename={filename}"}
    )

Rendered Markdown Output Structure

When exported, the resulting .md file renders parent findings with clearly indented, blockquoted community confirmations:

### Finding 1: HL7 webhook drops during high-volume patient batch ingest
* **Severity:** `5/5` | **WTP Tier:** `ENTERPRISE`
* **Root Cause / Defect:** Unbounded memory buffer overflow on payloads > 500 records
* **Source Reference:** [https://reddit.com/r/sysadmin/comments/1abc23](https://reddit.com/r/sysadmin/comments/1abc23)
* **Primary Testimony:**
> "Epic interface engine drops sync packets on high-volume days without raising an alert."

**Corroborating Community Evidence:**

> * **▲ 64** by `u/hospital_dev` `[WORKAROUND]`:
> > "Can confirm. We had to write a secondary polling cron in Go just to catch silent dropouts before night audits."
>
> * **▲ 29** by `u/nurse_coder` `[COST_IMPACT]`:
> > "Compliance nearly fined us $40k last quarter due to this exact bug during state reporting."

---

Verification Checklist

  1. Hierarchy Integrity: Child comments are strictly scoped to their foreign key pain_point_id, preventing cross-thread leakage.

  2. Formatting Preservation: Multi-line comment text preserves indentation by escaping inner newlines (\n> > ).

  3. Graceful Fallbacks: If a parent post has no harvested comments, the generator renders a fallback indicator without breaking document formatting.


Bring it altogether. Provide the combined Python application using Google Gemini API and SQLite3

Here is the combined, production-ready Python application integrating multi-source web ingestion (Google Search Grounding, Hacker News Algolia, arXiv, and unauthenticated Reddit feeds with comment harvesting), Pydantic structured extractions via the google-genai SDK, vector clustering with normalized centroid updates in SQLite3, automated re-summarization, and the FastAPI dashboard with nested Markdown dossier exports.

Prerequisites & Dependencies

Install the required libraries:

pip install google-genai pydantic fastapi uvicorn numpy httpx

Ensure your Gemini API key is exported:

export GEMINI_API_KEY="your-gemini-api-key"

Complete Combined Application (discovery_app.py)

import datetime
import html
import json
import os
import re
import sqlite3
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
import uuid
import xml.etree.ElementTree as ET
from collections import defaultdict
from enum import Enum
from typing import Any, Dict, List, Optional

from fastapi import FastAPI, HTTPException, Query, Response
from fastapi.responses import HTMLResponse
from google import genai
from google.genai import types
import numpy as np
from pydantic import BaseModel, Field
import uvicorn

# -----------------------------------------------------------------------------
# 1. Pydantic Schemas & Domain Contracts
# -----------------------------------------------------------------------------

class ProblemDimension(str, Enum):
    PRODUCT = "product"    # Software bugs, UX friction, missing APIs
    SYSTEM = "system"      # Downtime, database scale bottlenecks, sync errors
    SERVICE = "service"    # Support failures, slow onboarding, opaque billing
    PROCESS = "process"    # Manual double-entry, compliance drag, spreadsheet handoffs


class WTPSignal(BaseModel):
    has_budget_signal: bool = Field(
        ...,
        description="True if post quotes budget, enterprise seat counts, software spend, or labor waste."
    )
    exact_quote: Optional[str] = Field(
        None,
        description="Verbatim text mentioning financial cost, software tier, or labor waste."
    )
    wtp_tier: str = Field(
        ...,
        description="'enterprise' (>$10k/yr), 'smb' ($1k-$10k/yr), 'prosumer' (<$1k/yr), or 'none'."
    )
    switching_intent: bool = Field(
        ...,
        description="True if author is shopping for a replacement tool or threatening vendor churn."
    )


class PainPointExtraction(BaseModel):
    is_valid_commercial_pain: bool = Field(
        ...,
        description="True for genuine B2B/organizational workflow friction; False for spam/consumer retail rants."
    )
    industry: str = Field(
        ...,
        description="Target industry vertical (e.g. Healthcare EHR, Cold-Chain Logistics, Civil Engineering)."
    )
    target_persona: str = Field(
        ...,
        description="Impacted job role (e.g. Compliance Officer, Fleet Dispatcher, Site Supervisor)."
    )
    dimension: ProblemDimension = Field(..., description="Failure category.")
    core_problem_statement: str = Field(
        ...,
        max_length=200,
        description="Crisp one-sentence summary of the defect."
    )
    verbatim_quote: str = Field(..., description="Direct quote from the practitioner.")
    source_url: Optional[str] = Field(None, description="URL or community origin of the finding.")
    incumbent_solutions: List[str] = Field(
        default_factory=list,
        description="Existing software platforms mentioned (e.g. Salesforce, Epic, Jira, QuickBooks)."
    )
    workaround_in_use: Optional[str] = Field(
        None,
        description="Current manual workaround described (e.g. Daily CSV manual reconciliations)."
    )
    severity_rating: int = Field(
        ...,
        ge=1,
        le=5,
        description="1 = minor annoyance, 5 = business downtime/audit failure."
    )
    wtp_signals: WTPSignal
    recommended_solution_vector: str = Field(
        ...,
        description="Proposed build: 'Micro-SaaS', 'Integration Middleware', or 'Managed Automation Service'."
    )


class BatchExtractionResponse(BaseModel):
    items: List[PainPointExtraction] = Field(default_factory=list)


class ClusterSynthesis(BaseModel):
    title: str = Field(..., max_length=120)
    executive_summary: str = Field(..., description="Synthesized 2-3 sentence strategic executive brief.")
    primary_industry: str
    target_persona: str
    dominant_dimension: ProblemDimension
    incumbent_solutions: List[str] = Field(default_factory=list)
    actionable_intervention: str = Field(..., description="Specific software or process fix to monetize.")


# -----------------------------------------------------------------------------
# 2. SQLite3 Normalized Database Layer
# -----------------------------------------------------------------------------

DB_PATH = "pain_point_discovery.db"

def init_db():
    conn = sqlite3.connect(DB_PATH)
    cur = conn.cursor()
    cur.execute("PRAGMA foreign_keys = ON;")

    # Raw ingested posts & threads
    cur.execute("""
    CREATE TABLE IF NOT EXISTS raw_ingested_items (
        id TEXT PRIMARY KEY,
        source TEXT NOT NULL,
        external_id TEXT UNIQUE,
        url TEXT NOT NULL,
        title TEXT,
        body TEXT,
        published_at TEXT,
        ingested_at TEXT,
        processed_status TEXT DEFAULT 'pending',
        top_comments TEXT,
        comment_count INTEGER DEFAULT 0,
        top_comment_score_sum INTEGER DEFAULT 0,
        max_comment_score INTEGER DEFAULT 0
    );
    """)

    # High-level problem clusters
    cur.execute("""
    CREATE TABLE IF NOT EXISTS problem_clusters (
        id TEXT PRIMARY KEY,
        title TEXT NOT NULL,
        executive_summary TEXT NOT NULL,
        industry TEXT NOT NULL,
        target_persona TEXT NOT NULL,
        dimension TEXT NOT NULL,
        incumbent_solutions TEXT,
        total_mentions INTEGER DEFAULT 1,
        velocity_7d INTEGER DEFAULT 1,
        viability_score REAL DEFAULT 0.0,
        first_seen_at TEXT,
        last_updated_at TEXT,
        last_summarized_mentions INTEGER DEFAULT 1,
        centroid_blob BLOB
    );
    """)

    # Granular pain point records (Parent)
    cur.execute("""
    CREATE TABLE IF NOT EXISTS extracted_pain_points (
        id TEXT PRIMARY KEY,
        cluster_id TEXT,
        raw_item_id TEXT,
        industry TEXT,
        target_persona TEXT,
        dimension TEXT,
        core_problem_statement TEXT,
        extracted_quote TEXT NOT NULL,
        root_cause TEXT,
        source_url TEXT,
        severity_rating INTEGER,
        wtp_tier TEXT,
        has_budget_signal BOOLEAN,
        vector_blob BLOB,
        extracted_at TEXT,
        FOREIGN KEY(cluster_id) REFERENCES problem_clusters(id) ON DELETE SET NULL,
        FOREIGN KEY(raw_item_id) REFERENCES raw_ingested_items(id) ON DELETE CASCADE
    );
    """)

    # Corroborating community comments (Child)
    cur.execute("""
    CREATE TABLE IF NOT EXISTS extracted_pain_point_comments (
        id TEXT PRIMARY KEY,
        pain_point_id TEXT NOT NULL,
        raw_item_id TEXT,
        external_comment_id TEXT UNIQUE,
        author TEXT,
        upvotes INTEGER DEFAULT 0,
        comment_body TEXT NOT NULL,
        extracted_signal TEXT,
        corroboration_type TEXT,
        ingested_at TEXT,
        FOREIGN KEY(pain_point_id) REFERENCES extracted_pain_points(id) ON DELETE CASCADE,
        FOREIGN KEY(raw_item_id) REFERENCES raw_ingested_items(id) ON DELETE SET NULL
    );
    """)

    conn.commit()
    conn.close()


def get_db() -> sqlite3.Connection:
    conn = sqlite3.connect(DB_PATH)
    conn.row_factory = sqlite3.Row
    conn.execute("PRAGMA foreign_keys = ON;")
    return conn


# -----------------------------------------------------------------------------
# 3. Ingestion Engines: Reddit RSS, HN Algolia & arXiv
# -----------------------------------------------------------------------------

class MultiSourceIngester:
    def __init__(self):
        self.user_agent = "desktop:com.discovery.painpointminer:v1.0 (market research feed parser)"
        self.last_reddit_req = 0.0
        self.reddit_rate_limit = 3.0  # seconds between unauthenticated Reddit calls

    def _throttle_reddit(self):
        elapsed = time.time() - self.last_reddit_req
        if elapsed < self.reddit_rate_limit:
            time.sleep(self.reddit_rate_limit - elapsed)
        self.last_reddit_req = time.time()

    def fetch_reddit_rss(self, subreddit: str, search_query: Optional[str] = None, limit: int = 15) -> List[Dict[str, Any]]:
        self._throttle_reddit()
        if search_query:
            q = urllib.parse.quote(search_query)
            url = f"https://www.reddit.com/r/{subreddit}/search.rss?q={q}&sort=new&restrict_sr=on"
        else:
            url = f"https://www.reddit.com/r/{subreddit}/new.rss?limit={limit}"

        headers = {"User-Agent": self.user_agent, "Accept": "application/atom+xml, text/xml"}
        records = []
        try:
            req = urllib.request.Request(url, headers=headers)
            with urllib.request.urlopen(req, timeout=12) as response:
                content = response.read().decode("utf-8")
                root = ET.fromstring(content)
                ns = {"atom": "http://www.w3.org/2005/Atom"}
                for entry in root.findall("atom:entry", ns):
                    title_elem = entry.find("atom:title", ns)
                    link_elem = entry.find("atom:link", ns)
                    content_elem = entry.find("atom:content", ns)
                    published_elem = entry.find("atom:published", ns)

                    raw_html = content_elem.text if content_elem is not None else ""
                    clean_text = re.sub(r"<[^>]+>", " ", html.unescape(raw_html))
                    clean_text = re.sub(r"\s+", " ", clean_text).strip()

                    records.append({
                        "platform": "reddit",
                        "subreddit": subreddit,
                        "title": title_elem.text if title_elem is not None else "",
                        "url": link_elem.attrib.get("href", "") if link_elem is not None else "",
                        "body": clean_text,
                        "published_at": published_elem.text if published_elem is not None else ""
                    })
        except Exception as e:
            print(f"[!] Reddit RSS error on r/{subreddit}: {e}")
        return records

    def fetch_reddit_top_comments(self, post_url: str, max_comments: int = 4) -> List[Dict[str, Any]]:
        match = re.search(r"/comments/([a-z0-9]+)/", post_url)
        if not match:
            return []
        post_id = match.group(1)
        endpoint = f"https://www.reddit.com/comments/{post_id}.json?sort=top&limit={max_comments}&depth=1"
        self._throttle_reddit()

        headers = {"User-Agent": self.user_agent, "Accept": "application/json"}
        extracted = []
        try:
            req = urllib.request.Request(endpoint, headers=headers)
            with urllib.request.urlopen(req, timeout=12) as resp:
                data = json.loads(resp.read().decode("utf-8"))
                if len(data) > 1 and "data" in data[1]:
                    for child in data[1]["data"].get("children", []):
                        if child.get("kind") == "t1":
                            c_data = child.get("data", {})
                            b = c_data.get("body", "").strip()
                            if b and b not in ["[deleted]", "[removed]"]:
                                extracted.append({
                                    "comment_id": c_data.get("id"),
                                    "author": c_data.get("author", "[deleted]"),
                                    "score": c_data.get("score", 0),
                                    "body": html.unescape(b)
                                })
        except Exception as e:
            print(f"[!] Comment harvest error for {post_id}: {e}")
        return extracted

    def fetch_hacker_news(self, query: str, limit: int = 10) -> List[Dict[str, Any]]:
        url = f"https://hn.algolia.com/api/v1/search?query={urllib.parse.quote(query)}&tags=(story,comment)&hitsPerPage={limit}"
        records = []
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "PainPointMiner/1.0"})
            with urllib.request.urlopen(req, timeout=10) as resp:
                data = json.loads(resp.read().decode("utf-8"))
                for hit in data.get("hits", []):
                    text = hit.get("comment_text") or hit.get("story_text") or hit.get("title") or ""
                    clean_text = html.unescape(text).replace("<p>", "\n").replace("</p>", "").strip()
                    if len(clean_text) > 60:
                        records.append({
                            "platform": "hackernews",
                            "title": hit.get("story_title") or hit.get("title") or "HN Observation",
                            "url": f"https://news.ycombinator.com/item?id={hit.get('objectID')}",
                            "body": clean_text,
                            "published_at": hit.get("created_at", "")
                        })
        except Exception as e:
            print(f"[!] Hacker News Algolia error: {e}")
        return records

    def fetch_arxiv(self, query: str, max_results: int = 6) -> List[Dict[str, Any]]:
        formatted_query = f"all:{query} AND (cat:cs.SE OR cat:cs.DC OR cat:cs.DB OR cat:cs.AI)"
        url = f"https://export.arxiv.org/api/query?search_query={urllib.parse.quote(formatted_query)}&max_results={max_results}&sortBy=submittedDate&sortOrder=descending"
        records = []
        try:
            req = urllib.request.Request(url, headers={"User-Agent": "PainPointMiner/1.0"})
            with urllib.request.urlopen(req, timeout=12) as resp:
                root = ET.fromstring(resp.read().decode("utf-8"))
                ns = {"atom": "http://www.w3.org/2005/Atom"}
                for entry in root.findall("atom:entry", ns):
                    title = entry.find("atom:title", ns).text.strip().replace("\n", " ")
                    summary = entry.find("atom:summary", ns).text.strip().replace("\n", " ")
                    link = entry.find("atom:id", ns).text.strip()
                    records.append({
                        "platform": "arxiv",
                        "title": title,
                        "url": link,
                        "body": f"Title: {title}\nAbstract: {summary}",
                        "published_at": entry.find("atom:published", ns).text
                    })
        except Exception as e:
            print(f"[!] arXiv API error: {e}")
        return records


# -----------------------------------------------------------------------------
# 4. Intelligence Engine: Gemini Extraction, Embeddings & Clustering
# -----------------------------------------------------------------------------

class IntelligenceEngine:
    def __init__(self):
        self.client = genai.Client()
        self.model_name = "gemini-2.5-flash"

    def run_google_search_grounding(self, queries: List[str]) -> str:
        prompt = (
            "You are a market discovery analyst. Search the public web for real practitioner complaints, "
            "broken software workflows, vendor lock-in friction, and manual spreadsheet workarounds from the last 30 days:\n"
            + "\n".join(f"- {q}" for q in queries)
        )
        response = self.client.models.generate_content(
            model=self.model_name,
            contents=prompt,
            config=types.GenerateContentConfig(
                tools=[types.Tool(google_search=types.GoogleSearch())],
                temperature=0.2
            )
        )
        return response.text

    def extract_pain_points(self, raw_text: str) -> List[PainPointExtraction]:
        system_instruction = (
            "You are a B2B Systems Market Analyst. Parse discussions into verified operational bottlenecks. "
            "Reject superficial consumer complaints. Extract actionable root-causes, incumbent systems, "
            "and commercial willingness to pay (WTP)."
        )
        response = self.client.models.generate_content(
            model=self.model_name,
            contents=f"Extract validated pain points from this market briefing:\n\n{raw_text}",
            config=types.GenerateContentConfig(
                system_instruction=system_instruction,
                response_mime_type="application/json",
                response_schema=BatchExtractionResponse,
                temperature=0.1
            )
        )
        data = json.loads(response.text)
        batch = BatchExtractionResponse(**data)
        return [p for p in batch.items if p.is_valid_commercial_pain]

    def get_embedding(self, text: str) -> np.ndarray:
        res = self.client.models.embed_content(
            model="text-embedding-004",
            contents=text
        )
        vec = np.array(res.embedding.values, dtype=np.float32)
        norm = np.linalg.norm(vec)
        return (vec / norm) if norm > 0 else vec

    def cluster_pain_point(
        self,
        item: PainPointExtraction,
        vector: np.ndarray,
        comments: Optional[List[Dict[str, Any]]] = None,
        raw_item_id: Optional[str] = None
    ) -> str:
        conn = get_db()
        cur = conn.cursor()
        now = datetime.datetime.now(datetime.timezone.utc).isoformat()

        cur.execute("SELECT id, total_mentions, centroid_blob FROM problem_clusters")
        existing_clusters = cur.fetchall()

        best_cluster_id = None
        best_sim = -1.0
        SIMILARITY_THRESHOLD = 0.80

        for row in existing_clusters:
            if row["centroid_blob"]:
                c_vec = np.frombuffer(row["centroid_blob"], dtype=np.float32)
                sim = float(np.dot(vector, c_vec))
                if sim > best_sim:
                    best_sim = sim
                    best_cluster_id = row["id"]
                    best_mentions = row["total_mentions"]
                    best_c_vec = c_vec

        if best_cluster_id and best_sim >= SIMILARITY_THRESHOLD:
            cluster_id = best_cluster_id
            new_centroid = (best_mentions * best_c_vec + vector) / (best_mentions + 1)
            norm = np.linalg.norm(new_centroid)
            if norm > 0:
                new_centroid = new_centroid / norm
            cur.execute("""
                UPDATE problem_clusters 
                SET total_mentions = total_mentions + 1,
                    velocity_7d = velocity_7d + 1,
                    last_updated_at = ?,
                    centroid_blob = ?
                WHERE id = ?;
            """, (now, new_centroid.tobytes(), cluster_id))
        else:
            cluster_id = str(uuid.uuid4())
            cur.execute("""
                INSERT INTO problem_clusters (
                    id, title, executive_summary, industry, target_persona, 
                    dimension, incumbent_solutions, total_mentions, velocity_7d, 
                    viability_score, first_seen_at, last_updated_at, 
                    last_summarized_mentions, centroid_blob
                ) VALUES (?, ?, ?, ?, ?, ?, ?, 1, 1, 5.0, ?, ?, 1, ?);
            """, (
                cluster_id,
                f"Emerging: {item.core_problem_statement[:80]}",
                item.core_problem_statement,
                item.industry,
                item.target_persona,
                item.dimension.value,
                json.dumps(item.incumbent_solutions),
                now,
                now,
                vector.tobytes()
            ))

        # Insert parent pain point
        pain_point_id = str(uuid.uuid4())
        cur.execute("""
            INSERT INTO extracted_pain_points (
                id, cluster_id, raw_item_id, industry, target_persona, dimension,
                core_problem_statement, extracted_quote, root_cause, source_url,
                severity_rating, wtp_tier, has_budget_signal, vector_blob, extracted_at
            ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);
        """, (
            pain_point_id,
            cluster_id,
            raw_item_id,
            item.industry,
            item.target_persona,
            item.dimension.value,
            item.core_problem_statement,
            item.verbatim_quote,
            item.recommended_solution_vector,
            item.source_url or "Unspecified Source",
            item.severity_rating,
            item.wtp_signals.wtp_tier,
            item.wtp_signals.has_budget_signal,
            vector.tobytes(),
            now
        ))

        # Insert linked child comments
        if comments:
            comment_rows = []
            for c in comments:
                cid = str(uuid.uuid4())
                ext_id = c.get("comment_id") or f"ext_{uuid.uuid4().hex[:10]}"
                comment_rows.append((
                    cid,
                    pain_point_id,
                    raw_item_id,
                    ext_id,
                    c.get("author", "[deleted]"),
                    int(c.get("score", 0)),
                    c.get("body", ""),
                    "Corroborating Community Testimony",
                    "corroboration",
                    now
                ))
            cur.executemany("""
                INSERT INTO extracted_pain_point_comments (
                    id, pain_point_id, raw_item_id, external_comment_id,
                    author, upvotes, comment_body, extracted_signal,
                    corroboration_type, ingested_at
                ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(external_comment_id) DO UPDATE SET
                    upvotes = excluded.upvotes,
                    comment_body = excluded.comment_body;
            """, comment_rows)

        conn.commit()
        conn.close()
        return cluster_id

    def check_and_resummarize(self):
        conn = get_db()
        cur = conn.cursor()
        cur.execute("""
            SELECT id, title, total_mentions, last_summarized_mentions 
            FROM problem_clusters
            WHERE total_mentions >= 4 
              AND total_mentions >= (last_summarized_mentions * 2);
        """)
        eligible = cur.fetchall()

        for c in eligible:
            cluster_id = c["id"]
            cur.execute("""
                SELECT p.core_problem_statement, p.extracted_quote, p.severity_rating, p.wtp_tier,
                       (SELECT COUNT(*) FROM extracted_pain_point_comments WHERE pain_point_id = p.id) as comment_count,
                       (SELECT COALESCE(SUM(upvotes), 0) FROM extracted_pain_point_comments WHERE pain_point_id = p.id) as comment_upvotes
                FROM extracted_pain_points p
                WHERE p.cluster_id = ?
                ORDER BY p.severity_rating DESC, comment_upvotes DESC
                LIMIT 8;
            """, (cluster_id,))
            rows = cur.fetchall()

            evidence_block = "\n".join([
                f"- [Severity {r['severity_rating']}/5, WTP: {r['wtp_tier']}, Community Upvotes: {r['comment_upvotes']}] "
                f"{r['core_problem_statement']} | Quote: \"{r['extracted_quote']}\""
                for r in rows
            ])

            prompt = (
                f"Synthesize these corroborated user reports into an updated market intelligence brief:\n\n"
                f"{evidence_block}"
            )
            response = self.client.models.generate_content(
                model=self.model_name,
                contents=prompt,
                config=types.GenerateContentConfig(
                    response_mime_type="application/json",
                    response_schema=ClusterSynthesis,
                    temperature=0.2
                )
            )
            data = json.loads(response.text)
            syn = ClusterSynthesis(**data)

            # Viability score derived from mention volume, severity, and community corroboration
            viability = min(10.0, 5.0 + (c["total_mentions"] * 0.25))

            cur.execute("""
                UPDATE problem_clusters
                SET title = ?,
                    executive_summary = ?,
                    industry = ?,
                    target_persona = ?,
                    dimension = ?,
                    incumbent_solutions = ?,
                    viability_score = ?,
                    last_summarized_mentions = ?,
                    last_updated_at = ?
                WHERE id = ?;
            """, (
                syn.title,
                syn.executive_summary,
                syn.primary_industry,
                syn.target_persona,
                syn.dominant_dimension.value,
                json.dumps(syn.incumbent_solutions),
                viability,
                c["total_mentions"],
                datetime.datetime.now(datetime.timezone.utc).isoformat(),
                cluster_id
            ))
            print(f"[*] Re-summarized cluster: '{syn.title}' (Mentions: {c['total_mentions']})")

        conn.commit()
        conn.close()


# -----------------------------------------------------------------------------
# 5. FastAPI Application & Web Interface
# -----------------------------------------------------------------------------

app = FastAPI(title="Market Gap & Pain Point Discovery Engine")
ingester = MultiSourceIngester()
intel = IntelligenceEngine()

@app.on_event("startup")
def startup():
    init_db()


@app.get("/", response_class=HTMLResponse)
def index():
    conn = get_db()
    cur = conn.cursor()
    cur.execute("""
        SELECT c.id, c.title, c.executive_summary, c.industry, c.target_persona, 
               c.dimension, c.total_mentions, c.viability_score,
               (SELECT COUNT(*) FROM extracted_pain_point_comments cm 
                JOIN extracted_pain_points p ON cm.pain_point_id = p.id 
                WHERE p.cluster_id = c.id) AS total_comments
        FROM problem_clusters c
        ORDER BY c.viability_score DESC, c.total_mentions DESC;
    """)
    clusters = cur.fetchall()
    conn.close()

    cards = ""
    for c in clusters:
        cards += f"""
        <div style="border: 1px solid #334155; background: #1e293b; border-radius: 8px; padding: 1.25rem; margin-bottom: 1rem; color: #f8fafc;">
            <div style="display: flex; justify-content: space-between; align-items: flex-start;">
                <h3 style="margin: 0; color: #38bdf8; font-size: 1.2rem;">{c['title']}</h3>
                <span style="background: #0284c7; padding: 2px 8px; border-radius: 4px; font-weight: bold; font-size: 0.85rem;">
                    Score: {c['viability_score']:.1f}/10
                </span>
            </div>
            <p style="margin: 0.5rem 0; color: #cbd5e1; font-size: 0.95rem;">{c['executive_summary']}</p>
            <div style="font-size: 0.8rem; color: #94a3b8; display: flex; gap: 1.2rem; margin-top: 0.5rem;">
                <span><strong>Industry:</strong> {c['industry']}</span>
                <span><strong>Persona:</strong> {c['target_persona']}</span>
                <span><strong>Type:</strong> {c['dimension']}</span>
                <span><strong>Mentions:</strong> {c['total_mentions']}</span>
                <span><strong>Corroborating Comments:</strong> {c['total_comments']}</span>
            </div>
            <div style="margin-top: 0.8rem;">
                <a href="/clusters/{c['id']}/markdown" style="color: #67e8f9; text-decoration: none; font-size: 0.85rem; font-weight: bold; margin-right: 1.2rem;">
                    Download Markdown Dossier &rarr;
                </a>
                <a href="/api/clusters/{c['id']}/evidence" style="color: #94a3b8; text-decoration: none; font-size: 0.85rem;">
                    Inspect Citations &rarr;
                </a>
            </div>
        </div>
        """

    if not cards:
        cards = "<p style='color: #94a3b8;'>No clusters discovered yet. Run the /api/trigger-cycle endpoint to start.</p>"

    html_content = f"""
    <!DOCTYPE html>
    <html>
    <head>
        <title>Pain Point & Systems Opportunity Engine</title>
        <style>body {{ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; margin: 0; padding: 2rem; }}</style>
    </head>
    <body style="max-width: 950px; margin: auto;">
        <header style="margin-bottom: 2rem;">
            <h1 style="color: #f8fafc; margin: 0;">Market Friction & Systems Gap Engine</h1>
            <p style="color: #94a3b8; margin: 0.25rem 0 0 0;">Unified multi-source discovery across Google Search, Reddit, Hacker News, and arXiv via Google Gemini API.</p>
        </header>
        {cards}
    </body>
    </html>
    """
    return HTMLResponse(content=html_content)


@app.post("/api/trigger-cycle")
def trigger_daily_cycle(
    include_reddit: bool = Query(True),
    include_hn: bool = Query(True),
    include_arxiv: bool = Query(True),
    include_search: bool = Query(True)
):
    """
    Executes a complete ingestion, extraction, clustering, and re-summarization pass.
    """
    conn = get_db()
    cur = conn.cursor()
    all_extracted_points = []

    # 1. Reddit RSS & Comments
    if include_reddit:
        reddit_targets = ["sysadmin", "logistics", "msp"]
        for sub in reddit_targets:
            posts = ingester.fetch_reddit_rss(sub, search_query="broken workflow OR manual spreadsheet OR sync error", limit=4)
            for p in posts:
                comments = ingester.fetch_reddit_top_comments(p["url"], max_comments=3)
                raw_id = str(uuid.uuid4())
                cur.execute("""
                    INSERT INTO raw_ingested_items (
                        id, source, url, title, body, published_at, ingested_at, top_comments, comment_count
                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
                """, (
                    raw_id, f"reddit_{sub}", p["url"], p["title"], p["body"], p["published_at"],
                    datetime.datetime.now(datetime.timezone.utc).isoformat(),
                    json.dumps(comments), len(comments)
                ))
                # Append comment testimonies to post body for richer extraction
                combined_text = p["body"]
                if comments:
                    combined_text += "\n\nCommunity Comments:\n" + "\n".join(f"- {c['body']}" for c in comments)
                pts = intel.extract_pain_points(combined_text)
                for pt in pts:
                    pt.source_url = p["url"]
                    all_extracted_points.append((pt, comments, raw_id))

    # 2. Hacker News
    if include_hn:
        hn_items = ingester.fetch_hacker_news("broken software workflow", limit=4)
        for h in hn_items:
            raw_id = str(uuid.uuid4())
            cur.execute("""
                INSERT INTO raw_ingested_items (id, source, url, title, body, published_at, ingested_at)
                VALUES (?, 'hackernews', ?, ?, ?, ?, ?)
            """, (raw_id, h["url"], h["title"], h["body"], h["published_at"], datetime.datetime.now(datetime.timezone.utc).isoformat()))
            pts = intel.extract_pain_points(h["body"])
            for pt in pts:
                pt.source_url = h["url"]
                all_extracted_points.append((pt, None, raw_id))

    # 3. arXiv Systems Limitations
    if include_arxiv:
        arxiv_items = ingester.fetch_arxiv("distributed systems latency bottlenecks", max_results=3)
        for a in arxiv_items:
            raw_id = str(uuid.uuid4())
            cur.execute("""
                INSERT INTO raw_ingested_items (id, source, url, title, body, published_at, ingested_at)
                VALUES (?, 'arxiv', ?, ?, ?, ?, ?)
            """, (raw_id, a["url"], a["title"], a["body"], a["published_at"], datetime.datetime.now(datetime.timezone.utc).isoformat()))
            pts = intel.extract_pain_points(a["body"])
            for pt in pts:
                pt.source_url = a["url"]
                all_extracted_points.append((pt, None, raw_id))

    # 4. Google Search Grounding Sweep
    if include_search:
        search_briefing = intel.run_google_search_grounding([
            "EHR reconciliation manual data entry complaints 2026",
            "logistics ERP sync webhook failures"
        ])
        raw_id = str(uuid.uuid4())
        cur.execute("""
            INSERT INTO raw_ingested_items (id, source, url, title, body, ingested_at)
            VALUES (?, 'google_grounding', 'https://google.com', 'Live Web Grounded Sweep', ?, ?)
        """, (raw_id, search_briefing, datetime.datetime.now(datetime.timezone.utc).isoformat()))
        pts = intel.extract_pain_points(search_briefing)
        for pt in pts:
            all_extracted_points.append((pt, None, raw_id))

    conn.commit()
    conn.close()

    # 5. Incremental Embeddings & Clustering
    affected_clusters = set()
    for pt, comments, raw_id in all_extracted_points:
        vec = intel.get_embedding(f"{pt.industry} {pt.target_persona} {pt.core_problem_statement}")
        cid = intel.cluster_pain_point(pt, vec, comments=comments, raw_item_id=raw_id)
        affected_clusters.add(cid)

    # 6. Check for Cluster Synthesis Milestones
    intel.check_and_resummarize()

    return {
        "status": "success",
        "extracted_pain_points": len(all_extracted_points),
        "updated_clusters": len(affected_clusters)
    }


@app.get("/api/clusters/{cluster_id}/evidence")
def get_cluster_evidence(cluster_id: str):
    conn = get_db()
    cur = conn.cursor()
    cur.execute("""
        SELECT id, core_problem_statement, extracted_quote, root_cause, 
               source_url, severity_rating, wtp_tier
        FROM extracted_pain_points 
        WHERE cluster_id = ? 
        ORDER BY severity_rating DESC;
    """, (cluster_id,))
    points = [dict(r) for r in cur.fetchall()]

    for p in points:
        cur.execute("""
            SELECT author, upvotes, comment_body, corroboration_type
            FROM extracted_pain_point_comments
            WHERE pain_point_id = ?
            ORDER BY upvotes DESC;
        """, (p["id"],))
        p["comments"] = [dict(c) for c in cur.fetchall()]

    conn.close()
    return points


@app.get("/clusters/{cluster_id}/markdown")
def download_markdown_dossier(cluster_id: str):
    conn = get_db()
    cur = conn.cursor()

    cur.execute("SELECT * FROM problem_clusters WHERE id = ?;", (cluster_id,))
    cluster = cur.fetchone()
    if not cluster:
        conn.close()
        raise HTTPException(status_code=404, detail="Cluster not found")

    cur.execute("""
        SELECT id, core_problem_statement, extracted_quote, root_cause, 
               source_url, severity_rating, wtp_tier, has_budget_signal
        FROM extracted_pain_points 
        WHERE cluster_id = ? 
        ORDER BY severity_rating DESC;
    """, (cluster_id,))
    pain_points = cur.fetchall()

    comments_by_parent = defaultdict(list)
    if pain_points:
        p_ids = [p["id"] for p in pain_points]
        placeholders = ",".join(["?"] * len(p_ids))
        cur.execute(f"""
            SELECT pain_point_id, author, upvotes, comment_body, corroboration_type
            FROM extracted_pain_point_comments
            WHERE pain_point_id IN ({placeholders})
            ORDER BY upvotes DESC;
        """, p_ids)
        for c in cur.fetchall():
            comments_by_parent[c["pain_point_id"]].append(c)

    conn.close()

    try:
        inc_list = json.loads(cluster["incumbent_solutions"])
        incumbents_str = ", ".join(inc_list) if inc_list else "None identified"
    except Exception:
        incumbents_str = cluster["incumbent_solutions"] or "None identified"

    md_lines = [
        f"# Opportunity Dossier: {cluster['title']}",
        "",
        f"**Generated:** {datetime.datetime.now(datetime.timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}  ",
        f"**Cluster ID:** `{cluster['id']}`  ",
        f"**Viability Score:** `{cluster['viability_score']:.1f}/10` | **Total Mentions:** `{cluster['total_mentions']}`  ",
        "",
        "---",
        "",
        "## 1. Executive Summary",
        f"{cluster['executive_summary']}",
        "",
        f"* **Target Vertical:** {cluster['industry']}",
        f"* **Impacted Persona:** {cluster['target_persona']}",
        f"* **Friction Category:** {cluster['dimension'].capitalize()}",
        f"* **Incumbent Software:** {incumbents_str}",
        "",
        "---",
        "",
        f"## 2. Practitioner Findings & Field Citations ({len(pain_points)} primary cases)",
        ""
    ]

    for idx, pt in enumerate(pain_points, 1):
        wtp_tag = f" | **WTP Tier:** `{pt['wtp_tier'].upper()}`" if pt["has_budget_signal"] else ""
        md_lines.extend([
            f"### Finding {idx}: {pt['core_problem_statement']}",
            f"* **Severity:** `{pt['severity_rating']}/5`{wtp_tag}",
            f"* **Root Cause / Defect:** {pt['root_cause'] or 'Unspecified'}",
            f"* **Source Reference:** [{pt['source_url']}]({pt['source_url']})",
            "* **Primary Post Citation:**",
            f"> \"{pt['extracted_quote']}\"",
            ""
        ])

        child_comments = comments_by_parent.get(pt["id"], [])
        if child_comments:
            md_lines.append("**Corroborating Community Evidence:**")
            md_lines.append("")
            for c in child_comments:
                tag = f" `[{c['corroboration_type'].upper()}]`" if c["corroboration_type"] else ""
                clean_comment = c["comment_body"].replace("\n", "\n> > ")
                md_lines.extend([
                    f"> * **▲ {c['upvotes']}** by `u/{c['author']}`{tag}:",
                    f"> > \"{clean_comment}\"",
                    ">"
                ])
            if md_lines[-1] == ">":
                md_lines.pop()
            md_lines.append("")
        else:
            md_lines.extend(["> *(No secondary comment citations recorded)*", ""])
        md_lines.extend(["---", ""])

    safe_title = cluster["title"].lower().replace(" ", "_")[:30]
    return Response(
        content="\n".join(md_lines),
        media_type="text/markdown",
        headers={"Content-Disposition": f"attachment; filename=dossier_{safe_title}_{cluster['id'][:8]}.md"}
    )


# -----------------------------------------------------------------------------
# 6. Execution Entrypoint
# -----------------------------------------------------------------------------

if __name__ == "__main__":
    init_db()
    if len(sys.argv) > 1 and sys.argv[1] == "run-search":
        print("[*] Running standalone daily search and clustering pipeline...")
        trigger_daily_cycle()
        print("[*] Pipeline execution finished.")
    else:
        print("[*] Starting web dashboard at http://localhost:8000 ...")
        uvicorn.run(app, host="0.0.0.0", port=8000)

Verification and Execution Workflow

  1. Start the Web UI & API Server:

    python discovery_app.py
    

    Open http://localhost:8000 in your browser.

  2. Trigger an Ingestion Cycle (Headless or HTTP):

    • In the background via CLI:

      python discovery_app.py run-search
      
    • Via HTTP POST:

      curl -X POST http://localhost:8000/api/trigger-cycle
      
  3. Verify Normalized Relational Integrity:

    • Raw ingested threads reside in raw_ingested_items.

    • Parent complaints cluster in problem_clusters and extracted_pain_points.

    • Top-upvoted corroborating comments populate extracted_pain_point_comments bound by foreign keys.

    • Access http://localhost:8000/clusters/<cluster_id>/markdown to download the structured opportunity dossier with nested quote blocks.


From <https://gemini.google.com/app/1bfa28173cb3e9d2>  Google Gemini (3.8 Flash)

Comments

Popular posts from this blog

#4297 ISS Astronaut Unable to Speak

#3356 PHP and JavaScript to Retrieve and Set an API_Key

#4188 Hybrid/GraphRAG