Skip to content

Trend Analysis Architecture

The trend path runs in parallel with the main ingestion pipeline. It extracts topic signals from every transcript immediately — no validation, no registry step, no admin approval required. Trend data is available the moment intake completes.


1. Trend Extraction Pipeline

flowchart TD
    SEG([TranscriptSegments\nfrom intake pipeline])

    subgraph normalize["Utterance Normalization"]
        LNZ[TranscriptLineNormalizer\nSplit segment text on newlines\nCollapse whitespace · Strip edges\nInherit speaker · offset · privacy_class]
    end

    subgraph extract["Mention Extraction"]
        MX[MentionExtractor\nNLTK word_tokenize\npos_tag\nRegexpParser NP grammar\nDT? JJ* NN+]
        MASK{Person span\noverlap?}
        MOUT[RawMention\nnoun_phrase]
        POUT[RawMention\nperson_masked\ntext = PERSON]
    end

    subgraph sentiment["Sentiment Scoring"]
        SS[LineSentimentScorer\nVADER SentimentIntensityAnalyzer\ncompound · pos · neu · neg]
        SLBL{compound value}
        SPOS[positive\n≥ +0.05]
        SNEU[neutral\nbetween thresholds]
        SNEG[negative\n≤ -0.05]
    end

    subgraph link["Question Linkage"]
        QML[QuestionMentionLinker\nFor each question:\nFind anchor line in segment\nSearch ± window_lines\nCreate QuestionMentionLink\ndistance_lines · link_basis]
    end

    subgraph canon["Canonicalization"]
        FREQ{frequency\n≥ promotion_threshold?}
        IGN[Mark\nignored_low_frequency]
        SIM{Similarity score\nvs existing entities}
        AUTO[Auto-resolve\nAUTO_EXISTING\nmatch_score = similarity]
        PROMO[Auto-promote\nAUTO_PROMOTED\nCreate new CanonicalEntity]
        ADMIN[Admin review queue\nPENDING_ADMIN_REVIEW\nsuggested_entity_id]
    end

    subgraph persist["Persist + Project"]
        DB[(PostgreSQL\ntranscript_lines\nraw_mentions\nline_sentiments\nquestion_mention_links\ncanonical_entities\nmention_resolutions\nmention_clusters)]
        N4[(Neo4j\nTrendGraphProjector\nLine · Mention · Entity\nTaxonomy · Speaker\nTimeBucket nodes)]
    end

    SEG --> LNZ --> MX
    LNZ --> SS
    MX --> MASK
    MASK -->|yes| POUT
    MASK -->|no| MOUT
    SS --> SLBL
    SLBL --> SPOS & SNEU & SNEG

    MOUT & POUT --> QML
    MOUT & POUT --> FREQ
    FREQ -->|below| IGN
    FREQ -->|above| SIM
    SIM -->|≥ auto_resolution_threshold| AUTO
    SIM -->|cohesive cluster\nno match| PROMO
    SIM -->|unresolvable| ADMIN

    AUTO & PROMO & IGN & ADMIN --> DB
    DB --> N4

2. Canonicalization Workflow

flowchart LR
    subgraph input["Input"]
        M[RawMentions\nnormalized_text]
    end

    subgraph cluster["Build Clusters"]
        CL[Group by\nnormalized_text key]
        CF[Count frequency\nper cluster]
    end

    subgraph gate["Frequency Gate"]
        FG{frequency ≥\npromotion_threshold?}
        SKIP[MentionCluster\nignored_low_frequency\nno resolution]
    end

    subgraph match["Similarity Matching"]
        EM{Exact match\nvs active entities?}
        JC[Jaccard similarity\ntoken overlap ÷ union]
        BM{best_score ≥\nauto_resolution_threshold?}
    end

    subgraph resolve["Resolution"]
        RES_E[MentionResolution\nauto_existing\nlink to matched entity]
        RES_P[New CanonicalEntity\n+ MentionResolution\nauto_promoted\nadded to entity pool]
        RES_A[MentionCluster\npending_admin_review\nsuggested_entity_id if any]
    end

    M --> CL --> CF --> FG
    FG -->|no| SKIP
    FG -->|yes| EM
    EM -->|exact| RES_E
    EM -->|no exact| JC --> BM
    BM -->|yes| RES_E
    BM -->|no, cohesive| RES_P
    BM -->|no, incoherent| RES_A

Similarity scoring rules (in priority order):

  1. Exact normalized match → score 1.0
  2. Jaccard token overlap: |A ∩ B| / |A ∪ B| using whitespace-split token sets
  3. Default auto_resolution_threshold: 0.80
  4. Default promotion_threshold: 2 occurrences

3. Trend Report Data Flow

flowchart LR
    subgraph sources["Data Sources"]
        PG_M[(raw_mentions)]
        PG_R[(mention_resolutions)]
        PG_E[(canonical_entities)]
        PG_T[(canonical_entity_taxonomies)]
        PG_S[(line_sentiments)]
        PG_L[(transcript_lines)]
        PG_Q[(question_mention_links)]
    end

    subgraph svc["TrendReportService"]
        AGG[Aggregate\nby entity · week · scope · speaker]
        JOIN[Join with\ntaxonomies and sentiment]
        ROWS[TrendReportRow × N]
    end

    subgraph api["API Surfaces"]
        JSON[GET /v1/trends/report\nJSON response]
        CSV[GET /v1/trends/report.csv\nCSV download]
        DASH[GET /v1/dashboard/trends\nTop-N panel]
    end

    subgraph filters["Active Filters"]
        F1[from_date · to_date]
        F2[customer_scope]
        F3[speaker]
        F4[taxonomy_type · taxonomy_value]
        F5[canonical_entity_id]
    end

    PG_M & PG_R & PG_E & PG_T & PG_S & PG_L & PG_Q --> AGG
    filters --> AGG
    AGG --> JOIN --> ROWS
    ROWS --> JSON & CSV & DASH

4. Trend Neo4j Graph — Cypher Analytics Library

The following Cypher queries are available in infrastructure/graph/cypher_queries.py and are used by the TrendGraphProjector.

Top Topics by Period

MATCH (m:RawMention)-[:RESOLVES_TO]->(e:CanonicalEntity)
MATCH (l:TranscriptLine)-[:HAS_MENTION]->(m)
MATCH (l)-[:IN_WEEK]->(tb:TimeBucket)
WHERE tb.week >= $from_week AND tb.week <= $to_week
WITH e, tb.week AS week, count(m) AS mention_count
ORDER BY mention_count DESC
RETURN e.canonical_entity_id AS entity_id, e.label AS label,
       week, mention_count
LIMIT $limit

Question Count by Canonical Entity

MATCH (q:Question)-[:MENTIONS]->(e:CanonicalEntity)
RETURN e.canonical_entity_id AS entity_id,
       e.label AS label,
       count(q) AS question_count
ORDER BY question_count DESC

Sentiment Trend by Entity and Week

MATCH (m:RawMention)-[:RESOLVES_TO]->(e:CanonicalEntity)
MATCH (l:TranscriptLine)-[:HAS_MENTION]->(m)
MATCH (l)-[:IN_WEEK]->(tb:TimeBucket)
WHERE tb.week >= $from_week AND tb.week <= $to_week
WITH e, tb.week AS week,
     count(CASE WHEN l.sentiment_label = 'positive' THEN 1 END) AS pos_count,
     count(CASE WHEN l.sentiment_label = 'neutral'  THEN 1 END) AS neu_count,
     count(CASE WHEN l.sentiment_label = 'negative' THEN 1 END) AS neg_count
RETURN e.canonical_entity_id AS entity_id, e.label AS label, week,
       pos_count, neu_count, neg_count
ORDER BY week, e.label

Speaker–Topic Heat Map

MATCH (l:TranscriptLine)-[:SPOKEN_BY]->(s:Speaker)
MATCH (l)-[:HAS_MENTION]->(m:RawMention)-[:RESOLVES_TO]->(e:CanonicalEntity)
RETURN s.name AS speaker,
       e.canonical_entity_id AS entity_id,
       e.label AS label,
       count(m) AS mention_count
ORDER BY mention_count DESC

Taxonomy Rollup (any type)

MATCH (e:CanonicalEntity)-[:IN_TAXONOMY]->(t:TaxonomyTerm {type: $taxonomy_type})
MATCH (m:RawMention)-[:RESOLVES_TO]->(e)
RETURN t.value AS taxonomy_value,
       count(DISTINCT e) AS entity_count,
       count(m) AS mention_count
ORDER BY mention_count DESC

Customer-Scope Comparison

MATCH (a:Artifact)-[:IN_SCOPE]->(cs:CustomerScope)
MATCH (l:TranscriptLine {artifact_id: a.artifact_id})
      -[:HAS_MENTION]->(m:RawMention)-[:RESOLVES_TO]->(e:CanonicalEntity)
WHERE cs.name IN $scope_names
RETURN cs.name AS customer_scope,
       e.canonical_entity_id AS entity_id,
       e.label AS label,
       count(m) AS mention_count
ORDER BY customer_scope, mention_count DESC

5. Admin Cluster Review Workflow

stateDiagram-v2
    [*] --> pending_admin_review : High-frequency\ncluster unresolved

    pending_admin_review --> admin_merged : POST .../merge\nActor merges into\nexisting entity
    pending_admin_review --> admin_created : POST .../create-entity\nActor creates\nnew canonical entity
    pending_admin_review --> suppressed : POST .../suppress\nActor marks noisy

    admin_merged --> [*] : MentionResolution created\nresolution_mode = admin_merge
    admin_created --> [*] : CanonicalEntity created\nMentionResolution created\nresolution_mode = admin_create
    suppressed --> [*] : No resolution created\nCluster excluded from reports

    note right of pending_admin_review
        Listed at GET /v1/trends/admin/clusters
        Shows frequency, cluster_key,
        and suggested_entity_id
    end note

6. Privacy Controls in the Trend Path

Data Behaviour
Raw transcript text Stays inside the CII boundary; never appears in trend API responses
Person-named mentions Detected via PrivacyTagger PERSON spans; mention_text stored as [PERSON]; privacy_class = pii_detected
Trend JSON API Returns only normalized_text and entity labels — no raw person strings
CSV export Same masking as JSON — no person clear-text
Neo4j trend graph RawMention.mention_text = [PERSON] for masked mentions
mention_resolutions Person-masked mentions never resolve to named-person canonical entities
Admin cluster queue Clusters with cluster_key = [person] are suppressed by convention

7. Configuration Reference

All trend policy parameters are explicit settings in src/raw_to_knowledge/config.py — no hidden constants.

Setting Default Description
trend_promotion_threshold 2 Minimum frequency for a cluster to leave ignored_low_frequency
trend_auto_resolution_threshold 0.80 Minimum Jaccard similarity for automatic entity resolution
trend_positive_sentiment_threshold 0.05 Minimum VADER compound for positive label
trend_negative_sentiment_threshold -0.05 Maximum VADER compound for negative label
trend_question_mention_window 5 Lines ± around a question anchor to search for mentions
trend_person_masking_strategy token token = [PERSON], label = <PERSON>, redact = blanked
trend_taxonomy_assignment_policy manual manual requires admin assignment; auto applies heuristics