feat(embeddings): multi-language class text generation with language-patterns

This commit is contained in:
wangjichao 2026-04-14 10:25:41 +08:00
parent d4a8ce5623
commit b3d8964b03
2 changed files with 152 additions and 56 deletions

View file

@ -8,6 +8,8 @@
import type { EmbeddableNode, EmbeddingConfig } from './types.js';
import { DEFAULT_EMBEDDING_CONFIG, isShortLabel } from './types.js';
import { getLanguageFromFilename } from 'gitnexus-shared';
import { getPatternsForLanguage } from './language-patterns.js';
/**
* Truncate description to max length at sentence/word boundary
@ -118,9 +120,7 @@ const generateConstructorText = (
/**
* Generate embedding text for Class nodes
* Signature + properties + method name list only (no method bodies)
*
* NOTE: Method/property regex is currently tuned for JS/TS syntax.
* Multi-language support (Python, Kotlin, Rust, etc.) is a TODO.
* Multi-language: dispatches to language-patterns for method/property extraction.
*/
const generateClassText = (
node: EmbeddableNode,
@ -130,67 +130,56 @@ const generateClassText = (
const header = buildMetadataHeader(node, config);
const parts: string[] = [header];
// Extract method names and properties from content
const cleaned = cleanContent(codeBody);
const lines = cleaned.split('\n');
const methods: string[] = [];
const properties: string[] = [];
const classBodyLines: string[] = [];
let inClass = false;
// Try language-specific patterns first
const language = getLanguageFromFilename(node.filePath);
const patterns = language ? getPatternsForLanguage(language) : undefined;
for (const line of lines) {
const trimmed = line.trim();
// Detect class opening (JS/TS only — Python `class Foo:`, Kotlin `class Foo` etc. are not yet handled)
if (
trimmed.match(/^(?:export\s+)?(?:abstract\s+)?class\s/) ||
trimmed.startsWith('data class ')
) {
inClass = true;
classBodyLines.push(trimmed);
continue;
}
if (!inClass) {
classBodyLines.push(trimmed);
continue;
if (patterns) {
const methods = patterns.extractMethods(cleaned);
const properties = patterns.extractProperties(cleaned);
if (methods.length > 0) parts.push(`Methods: ${methods.join(', ')}`);
if (properties.length > 0) parts.push(`Properties: ${properties.join(', ')}`);
} else {
// Fallback: original JS/TS regex
const methods: string[] = [];
const properties: string[] = [];
const lines = cleaned.split('\n');
let inClass = false;
for (const line of lines) {
const trimmed = line.trim();
if (
trimmed.match(/^(?:export\s+)?(?:abstract\s+)?class\s/) ||
trimmed.startsWith('data class ')
) {
inClass = true;
continue;
}
if (!inClass) continue;
const methodMatch = trimmed.match(
/^(?:public|private|protected|static|async|abstract|\s)*\s*(\w+)\s*\(/,
);
if (methodMatch && !trimmed.startsWith('//') && !trimmed.startsWith('*')) {
methods.push(methodMatch[1]);
}
const propMatch = trimmed.match(
/^(?:public|private|protected|static|readonly)\s+(\w+)\s*[=:(]/,
);
if (propMatch) {
properties.push(propMatch[1]);
}
}
// Extract method names
const methodMatch = trimmed.match(
/^(?:public|private|protected|static|async|abstract|\s)*\s*(\w+)\s*\(/,
);
if (methodMatch && !trimmed.startsWith('//') && !trimmed.startsWith('*')) {
methods.push(methodMatch[1]);
}
// Extract property declarations
const propMatch = trimmed.match(
/^(?:public|private|protected|static|readonly)\s+(\w+)\s*[=:(]/,
);
if (propMatch) {
properties.push(propMatch[1]);
}
// Keep class declaration + property lines (no method bodies)
if (
trimmed.match(/^(?:export\s+)?(?:abstract\s+)?class\s/) ||
trimmed.startsWith('data class ') ||
trimmed.startsWith('{') ||
trimmed.startsWith('}') ||
trimmed === '' ||
propMatch ||
trimmed.endsWith(';') ||
!trimmed.includes('{')
) {
classBodyLines.push(trimmed);
}
if (methods.length > 0) parts.push(`Methods: ${methods.join(', ')}`);
if (properties.length > 0) parts.push(`Properties: ${properties.join(', ')}`);
}
if (methods.length > 0) parts.push(`Methods: ${methods.join(', ')}`);
if (properties.length > 0) parts.push(`Properties: ${properties.join(', ')}`);
// Class declaration only (no method bodies)
const declarationOnly = classBodyLines.join('\n').trim();
const declarationOnly = extractDeclarationOnly(cleaned);
if (declarationOnly) {
parts.push('', declarationOnly);
}
@ -198,6 +187,39 @@ const generateClassText = (
return parts.join('\n');
};
/**
* Extract class/interface/struct declaration lines (no method bodies)
*/
const extractDeclarationOnly = (content: string): string => {
const lines = content.split('\n');
const declLines: string[] = [];
let depth = 0;
let started = false;
for (const line of lines) {
const trimmed = line.trim();
if (
!started &&
(trimmed.match(/^(?:export\s+)?(?:abstract\s+)?(?:data\s+)?class\s/) ||
trimmed.match(/^(?:pub\s+)?struct\s/) ||
trimmed.match(/^(?:pub\s+)?enum\s/) ||
trimmed.match(/^type\s+\w+\s+struct/) ||
trimmed.match(/^class\s/) ||
trimmed.match(/^interface\s/))
) {
started = true;
}
if (started) {
depth += (trimmed.match(/\{/g) || []).length;
depth -= (trimmed.match(/\}/g) || []).length;
declLines.push(trimmed);
if (depth <= 0 && declLines.length > 1) break;
}
}
return declLines.join('\n').trim();
};
/**
* Generate embedding text for short nodes (TypeAlias, Const, etc.)
* No chunking, just metadata + full content

View file

@ -108,6 +108,80 @@ describe('text-generator', () => {
});
});
describe('multi-language class text', () => {
it('extracts methods from Python class', () => {
const node: EmbeddableNode = {
id: 'Class:src/models.py:User',
name: 'User',
label: 'Class',
filePath: 'src/models/user.py',
content: `class User:
def __init__(self, name):
self.name = name
def get_full_name(self):
return self.name`,
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Class: User');
expect(text).toContain('Methods:');
expect(text).toContain('__init__');
expect(text).toContain('get_full_name');
});
it('extracts methods from Kotlin class', () => {
const node: EmbeddableNode = {
id: 'Class:User.kt:User',
name: 'User',
label: 'Class',
filePath: 'src/models/User.kt',
content: `class User(val name: String) {
fun greet(): String = "Hello"
private fun validate() { }
}`,
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Class: User');
expect(text).toContain('Methods:');
expect(text).toContain('greet');
expect(text).toContain('validate');
expect(text).toContain('Properties:');
expect(text).toContain('name');
});
it('extracts struct fields from Rust', () => {
const node: EmbeddableNode = {
id: 'Struct:src/user.rs:User',
name: 'User',
label: 'Struct',
filePath: 'src/models/user.rs',
content: `struct User {
name: String,
age: u32,
}`,
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Struct: User');
});
it('falls back to JS/TS regex for unknown extension', () => {
const node: EmbeddableNode = {
...baseNode,
label: 'Class',
name: 'Parser',
filePath: 'src/parser.unknown',
content: `class Parser {
parse(input) { }
validate() { }
}`,
};
const text = generateEmbeddingText(node, node.content);
expect(text).toContain('Class: Parser');
expect(text).toContain('parse');
expect(text).toContain('validate');
});
});
describe('truncateDescription', () => {
it('returns short text unchanged', () => {
expect(truncateDescription('short text', 150)).toBe('short text');