@@ -44,7 +44,7 @@ async function analyzeText(text, options = {}) {
4444 debug ( 'Input' , `text_length=${ text . length } chars, sentences=${ sentences . length } ` ) ;
4545
4646 const concepts = extractConcepts ( text , options ) ;
47- debug ( 'Concepts' , `extracted ${ concepts . allTerms . length } terms (topics=${ concepts . topics . length } , nouns=${ concepts . nouns . length } , verbs= ${ concepts . verbs . length } )` ) ;
47+ debug ( 'Concepts' , `extracted ${ concepts . allTerms . length } terms (topics=${ concepts . topics . length } , nouns=${ concepts . nouns . length } )` ) ;
4848
4949 if ( concepts . allTerms . length === 0 ) {
5050 debug ( 'Early exit' , 'no concepts extracted' ) ;
@@ -61,7 +61,19 @@ async function analyzeText(text, options = {}) {
6161 }
6262
6363 // Calculate betweenness centrality
64- const centrality = betweennessCentrality ( graph , { normalized : true } ) ;
64+ // Use non-normalized for sparse graphs to avoid all-zero values
65+ const centrality = betweennessCentrality ( graph , { normalized : false } ) ;
66+ debug ( 'Centrality' , `raw values: ${ JSON . stringify ( Object . values ( centrality ) . slice ( 0 , 5 ) ) } (showing first 5)` ) ;
67+
68+ // Manually normalize by graph size for consistency
69+ const maxPossiblePaths = ( graph . order - 1 ) * ( graph . order - 2 ) / 2 ;
70+ const normalizedCentrality = { } ;
71+ for ( const [ node , value ] of Object . entries ( centrality ) ) {
72+ normalizedCentrality [ node ] = maxPossiblePaths > 0 ? value / maxPossiblePaths : 0 ;
73+ }
74+
75+ const avgNormalized = Object . values ( normalizedCentrality ) . reduce ( ( a , b ) => a + b , 0 ) / graph . order ;
76+ debug ( 'Normalized Centrality' , `avg=${ avgNormalized . toFixed ( 4 ) } , max_paths=${ maxPossiblePaths } , sample normalized values: ${ JSON . stringify ( Object . values ( normalizedCentrality ) . slice ( 0 , 5 ) ) } ` ) ;
6577
6678 // Detect communities using Louvain algorithm
6779 const communityDetails = louvain . detailed ( graph , {
@@ -75,16 +87,16 @@ async function analyzeText(text, options = {}) {
7587 debug ( 'Gaps' , `found ${ gaps . length } content gaps between communities` ) ;
7688
7789 // Extract topical clusters
78- const clusters = extractTopicalClusters ( graph , communityDetails . communities , centrality ) ;
90+ const clusters = extractTopicalClusters ( graph , communityDetails . communities , normalizedCentrality ) ;
7991
8092 // Calculate quality score
81- const qualityScore = calculateQualityScore ( graph , centrality , communityDetails , gaps ) ;
93+ const qualityScore = calculateQualityScore ( graph , normalizedCentrality , communityDetails , gaps ) ;
8294
8395 // Extract main concepts (sorted by centrality)
84- const mainConcepts = extractMainConcepts ( graph , centrality , communityDetails . communities ) ;
96+ const mainConcepts = extractMainConcepts ( graph , normalizedCentrality , communityDetails . communities ) ;
8597
8698 // Find conceptual gateways (high centrality bridging communities)
87- const gateways = findConceptualGateways ( graph , centrality , communityDetails . communities ) ;
99+ const gateways = findConceptualGateways ( graph , normalizedCentrality , communityDetails . communities ) ;
88100 debug ( 'Gateways' , `identified ${ gateways . length } conceptual gateways bridging communities` ) ;
89101
90102 const duration = Date . now ( ) - startTime ;
@@ -100,7 +112,7 @@ async function analyzeText(text, options = {}) {
100112 edges : graph . size ,
101113 communities : communityDetails . count ,
102114 modularity : communityDetails . modularity ,
103- avgCentrality : Object . values ( centrality ) . reduce ( ( a , b ) => a + b , 0 ) / graph . order ,
115+ avgCentrality : Object . values ( normalizedCentrality ) . reduce ( ( a , b ) => a + b , 0 ) / graph . order ,
104116 density : ( 2 * graph . size ) / ( graph . order * ( graph . order - 1 ) )
105117 } ,
106118 content_gaps : gaps ,
@@ -110,7 +122,7 @@ async function analyzeText(text, options = {}) {
110122 top_relations : extractTopRelations ( graph ) ,
111123 top_bigrams : extractTopBigrams ( text ) ,
112124 raw : {
113- centrality,
125+ centrality : normalizedCentrality ,
114126 communities : communityDetails . communities ,
115127 dendrogram : communityDetails . dendrogram ,
116128 duration
@@ -130,20 +142,34 @@ function extractConcepts(text, options = {}) {
130142 const minLength = options . minTermLength || 3 ;
131143 const doc = nlp ( text ) ;
132144
133- // Extract different types of concepts
134- const topics = doc . topics ( ) . out ( 'array' ) . filter ( t => t . length >= minLength ) ;
135- const nouns = doc . nouns ( ) . out ( 'array' ) . filter ( t => t . length >= minLength ) ;
136- const verbs = doc . verbs ( ) . out ( 'array' ) . filter ( t => t . length >= minLength ) ;
137-
138- // Combine and deduplicate
139- const allTerms = [ ...new Set ( [ ...topics , ...nouns , ...verbs ] ) ] ;
145+ // Stopwords to filter out (common verbs that are poor concepts)
146+ const stopwords = new Set ( [
147+ 'use' , 'make' , 'get' , 'take' , 'give' , 'find' , 'tell' , 'ask' , 'work' ,
148+ 'seem' , 'feel' , 'try' , 'leave' , 'call' , 'keep' , 'let' , 'begin' ,
149+ 'help' , 'show' , 'hear' , 'play' , 'run' , 'move' , 'like' , 'live' , 'believe' ,
150+ 'hold' , 'bring' , 'happen' , 'write' , 'provide' , 'sit' , 'stand' , 'lose' ,
151+ 'pay' , 'meet' , 'include' , 'continue' , 'set' , 'learn' , 'change' , 'lead' ,
152+ 'convert' , 'showing' , 'delays' , 'scaling' , 'processes'
153+ ] ) ;
154+
155+ // Extract topics (proper nouns, places, organizations, key concepts)
156+ const topics = doc . topics ( ) . out ( 'array' )
157+ . map ( t => t . toLowerCase ( ) . replace ( / [ ^ \w \s ] / g, '' ) . trim ( ) )
158+ . filter ( t => t . length >= minLength ) ;
159+
160+ // Extract nouns (filter out stopwords and single letters)
161+ const nouns = doc . nouns ( ) . out ( 'array' )
162+ . map ( n => n . toLowerCase ( ) . replace ( / [ ^ \w \s ] / g, '' ) . trim ( ) )
163+ . filter ( n => n . length >= minLength && ! stopwords . has ( n ) && ! / ^ [ a - z ] $ / . test ( n ) ) ;
164+
165+ // Combine topics and nouns, prioritizing topics
166+ const allTerms = [ ...new Set ( [ ...topics , ...nouns ] ) ] ;
140167
141168 // Limit to maxTerms
142169 const maxTerms = options . maxTerms || 100 ;
143170 return {
144171 topics,
145172 nouns,
146- verbs,
147173 allTerms : allTerms . slice ( 0 , maxTerms )
148174 } ;
149175}
@@ -156,31 +182,36 @@ function buildCoOccurrenceGraph(text, concepts, options = {}) {
156182 const graph = new Graph ( { type : 'undirected' } ) ; // Louvain requires undirected graph
157183 const doc = nlp ( text ) ;
158184 const sentences = doc . sentences ( ) . out ( 'array' ) ;
159- const minLength = options . minTermLength || 3 ;
160185
161186 sentences . forEach ( sentence => {
162- const sentenceDoc = nlp ( sentence ) ;
163- const terms = sentenceDoc . terms ( ) . out ( 'array' )
164- . map ( t => t . toLowerCase ( ) . trim ( ) )
165- . filter ( t => t . length >= minLength && concepts . allTerms . includes ( t ) ) ;
187+ const sentenceLower = sentence . toLowerCase ( ) . replace ( / [ ^ \w \s ] / g, ' ' ) ;
188+
189+ // Find which concepts appear in this sentence
190+ const termsInSentence = concepts . allTerms . filter ( concept => {
191+ // Check if the concept appears in the sentence
192+ // Use word boundaries to avoid partial matches
193+ const regex = new RegExp ( '\\b' + concept . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, '\\$&' ) + '\\b' , 'i' ) ;
194+ return regex . test ( sentenceLower ) ;
195+ } ) ;
166196
167197 // Add nodes with frequency weights
168- terms . forEach ( term => {
198+ termsInSentence . forEach ( term => {
169199 if ( ! graph . hasNode ( term ) ) {
170200 graph . addNode ( term , { label : term , weight : 1 } ) ;
171201 } else {
172202 graph . updateNodeAttribute ( term , 'weight' , w => w + 1 ) ;
173203 }
174204 } ) ;
175205
176- // Add edges (co-occurrence within sentence)
177- for ( let i = 0 ; i < terms . length - 1 ; i ++ ) {
178- for ( let j = i + 1 ; j < terms . length ; j ++ ) {
179- if ( terms [ i ] !== terms [ j ] ) {
180- if ( ! graph . hasEdge ( terms [ i ] , terms [ j ] ) ) {
181- graph . addEdge ( terms [ i ] , terms [ j ] , { weight : 1 } ) ;
206+ // Add edges (sequential connections to avoid fully-connected cliques)
207+ // Connect each concept to the next 2 concepts (creates paths, not cliques)
208+ for ( let i = 0 ; i < termsInSentence . length - 1 ; i ++ ) {
209+ for ( let j = i + 1 ; j < Math . min ( i + 3 , termsInSentence . length ) ; j ++ ) {
210+ if ( termsInSentence [ i ] !== termsInSentence [ j ] ) {
211+ if ( ! graph . hasEdge ( termsInSentence [ i ] , termsInSentence [ j ] ) ) {
212+ graph . addEdge ( termsInSentence [ i ] , termsInSentence [ j ] , { weight : 1 } ) ;
182213 } else {
183- graph . updateEdgeAttribute ( terms [ i ] , terms [ j ] , 'weight' , w => w + 1 ) ;
214+ graph . updateEdgeAttribute ( termsInSentence [ i ] , termsInSentence [ j ] , 'weight' , w => w + 1 ) ;
184215 }
185216 }
186217 }
@@ -285,10 +316,13 @@ function calculateQualityScore(graph, centrality, communityDetails, gaps) {
285316 const balanceScore = Math . min ( ( metrics . edgeCount / metrics . nodeCount ) * 10 , 20 ) ;
286317
287318 // High centrality = well-connected (20 points)
288- const centralityScore = Math . min ( metrics . avgCentrality * 100 , 20 ) ;
319+ // Scale by 500 to account for normalized betweenness values (typically 0-0.04)
320+ const centralityScore = Math . min ( metrics . avgCentrality * 500 , 20 ) ;
289321
290- // Penalty for gaps (max -30 points)
291- const gapPenalty = Math . min ( metrics . gapCount * 5 , 30 ) ;
322+ // Penalty for gaps (max -20 points, scaled by community count)
323+ // More communities = expect more gaps naturally
324+ const gapWeight = communityDetails . count > 10 ? 1.5 : 3 ;
325+ const gapPenalty = Math . min ( metrics . gapCount * gapWeight , 20 ) ;
292326
293327 return Math . max ( 0 , Math . round (
294328 modularityScore + balanceScore + centralityScore - gapPenalty
0 commit comments