Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
23 changes: 18 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ Eval + Harness 通过自动化评测发现问题,再由 LLM 优化 Skill 文

### 1. Skill Authoring (`skills/`)

Skills are markdown files with YAML frontmatter, organized by library. Each skill documents a chart type or visualization pattern with metadata (category, tags, difficulty, use cases) and content (best practices, API usage, code examples).
Skills are markdown files with YAML frontmatter, organized by library. Each skill documents a chart type or visualization pattern with metadata (category, tags, use cases) and content (best practices, API usage, code examples).

```
skills/
Expand Down Expand Up @@ -69,15 +69,28 @@ The CLI (`antv` command) provides four commands:
- `antv info <library>` - Show library core constraints (SKILL.md Section 1-2)

The retrieval engine uses **zvec** (in-process vector database) with two strategies:
- **`hybrid`** (default): zvec native multiQuery — FTS (jieba tokenizer, raw text) + Vector (HNSW ANN, 384d) + RRF fusion
- **`hybrid`** (default): zvec native multiQuery — FTS (jieba tokenizer, raw text) + Vector (HNSW ANN, 512d) + RRF fusion
- **`vector`**: Pure ANN vector similarity search

Embedding is handled by `SimpleEmbedder` (tokenizer + multi-hash, 384d, synchronous). No model download required.
Embedding is handled by `SimpleEmbedder` (tokenizer + multi-hash, 512d, synchronous). No model download required.

Public API (`src/api.ts`) exports `retrieve()`, `info()`, `getSkillById()`, `libraries()`, and `listSkills()` for programmatic use.

Additional components:
- **HTTP Server** (`http-server/`): Standalone REST API deployment package
### HTTP Server (`http-server/`)

Standalone REST API server (Express) providing POST endpoints for skill retrieval. Used by SKILL.md files for content retrieval via HTTP calls.

```bash
cd http-server && npm run dev # starts on http://localhost:3100 (configurable via PORT env)
```

| Endpoint | Method | Description |
|---|---|---|
| `/retrieve` | POST | Retrieve skills by query (hybrid/vector search). Body: `{query, library, topK, content, includeInfo, strategy, maxTokens, progressiveLevel}` |
| `/info` | POST | Get library core constraints. Body: `{library}` |
| `/get` | POST | Get single skill by exact ID. Body: `{id, library}` |
| `/list` | POST | List/filter skills. Body: `{library, category, tags}` |
| `/libraries` | GET | List available library names |

### 3. Evaluation (`eval/`)

Expand Down
120 changes: 120 additions & 0 deletions __tests__/embedder.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { SimpleEmbedder, isCJK } from '../src/core/retrieval/embedder';

describe('embedder', () => {
describe('isCJK', () => {
it('should identify CJK Unified characters', () => {
expect(isCJK('桑')).toBe(true);
expect(isCJK('图')).toBe(true);
expect(isCJK('树')).toBe(true);
});

it('should identify CJK Extension A characters', () => {
// CJK Ext-A range: 0x3400–0x4DBF
expect(isCJK('\u3447')).toBe(true);
});

it('should identify Hiragana and Katakana', () => {
expect(isCJK('\u3042')).toBe(true); // Hiragana あ
expect(isCJK('\u30AB')).toBe(true); // Katakana カ
});

it('should identify Hangul', () => {
expect(isCJK('\uAC00')).toBe(true); // Hangul 가
});

it('should not identify ASCII as CJK', () => {
expect(isCJK('a')).toBe(false);
expect(isCJK('Z')).toBe(false);
expect(isCJK('0')).toBe(false);
});

it('should not identify common punctuation as CJK', () => {
expect(isCJK('.')).toBe(false);
expect(isCJK('-')).toBe(false);
expect(isCJK(' ')).toBe(false);
});
});

describe('SimpleEmbedder', () => {
const embedder = new SimpleEmbedder();

it('should produce vectors of the correct dimension', async () => {
const vec = await embedder.embed('test query');
expect(vec.length).toBe(512);
});

it('should produce normalized vectors (L2 norm ≈ 1)', async () => {
const vec = await embedder.embed('test query');
let norm = 0;
for (const v of vec) norm += v * v;
norm = Math.sqrt(norm);
expect(Math.abs(norm - 1)).toBeLessThan(0.01);
});

it('should produce non-zero vectors for meaningful text', async () => {
const vec = await embedder.embed('桑基图 sankey');
const nonZeroCount = vec.filter(v => v !== 0).length;
expect(nonZeroCount).toBeGreaterThan(0);
});

it('should produce similar vectors for synonymous terms', async () => {
const vec1 = await embedder.embed('桑基图');
const vec2 = await embedder.embed('sankey');
// Both should expand to include each other's tokens via synonyms
// so their vectors should have some overlap
let dot = 0;
for (let i = 0; i < vec1.length; i++) dot += vec1[i] * vec2[i];
// Cosine similarity should be positive (they share synonym-expanded tokens)
expect(dot).toBeGreaterThan(0);
});

it('should produce different vectors for unrelated terms', async () => {
const vec1 = await embedder.embed('桑基图');
const vec2 = await embedder.embed('热力图');
let dot = 0;
for (let i = 0; i < vec1.length; i++) dot += vec1[i] * vec2[i];
// They may share some unigram overlap but should not be identical
expect(Math.abs(dot - 1)).toBeGreaterThan(0.01);
});

it('should support synchronous embedding via embedSync', () => {
const vec = embedder.embedSync('test query');
expect(vec.length).toBe(512);
});

it('should support batch embedding', async () => {
const vecs = await embedder.embedBatch(['query one', 'query two']);
expect(vecs.length).toBe(2);
expect(vecs[0].length).toBe(512);
expect(vecs[1].length).toBe(512);
});

it('should handle empty text gracefully', async () => {
const vec = await embedder.embed('');
// Empty text should still produce a 512-d vector (all zeros or near-zero)
expect(vec.length).toBe(512);
});

it('should weight CJK trigrams higher than unigrams', async () => {
// A query with trigrams should produce a vector with higher peak values
// than one with only unigrams (due to trigram weight = 2.0 vs unigram = 0.15)
const vecTrigram = await embedder.embed('矩形树图');
const maxTrigram = Math.max(...vecTrigram.map(Math.abs));
const vecUnigram = await embedder.embed('图型');
const maxUnigram = Math.max(...vecUnigram.map(Math.abs));
// Trigrams get higher weight, so peak values should be larger
expect(maxTrigram).toBeGreaterThan(maxUnigram * 0.5);
});

it('should expand synonyms during embedding', async () => {
// '树图' should get synonym expansion to 'treemap'
// So the embedding of '树图' should share hash buckets with 'treemap'
const vec1 = await embedder.embed('树图');
const vec2 = await embedder.embed('treemap');
let dot = 0;
for (let i = 0; i < vec1.length; i++) dot += vec1[i] * vec2[i];
expect(dot).toBeGreaterThan(0);
});
});
});
20 changes: 10 additions & 10 deletions __tests__/retrieve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ describe('retrieve API', () => {
}
};

it('should retrieve skills with default parameters', () => {
const results = retrieve('折线图');
it('should retrieve skills with default parameters', async () => {
const results = await retrieve('折线图');
// Default strategy is now 'hybrid'
if (zvecReady) {
expect(results.length).toBeGreaterThan(0);
Expand All @@ -30,30 +30,30 @@ describe('retrieve API', () => {
}
});

it('should respect topk parameter', () => {
const results = retrieve('bar chart', { library: 'g2', topK: 3 });
it('should respect topk parameter', async () => {
const results = await retrieve('bar chart', { library: 'g2', topK: 3 });
if (zvecReady) {
expect(results.length).toBeGreaterThan(0);
expect(results.length).toBeLessThanOrEqual(3);
}
});

it('should support g6 library parameter', () => {
const results = retrieve('graph layout', { library: 'g6' });
it('should support g6 library parameter', async () => {
const results = await retrieve('graph layout', { library: 'g6' });
expect(Array.isArray(results)).toBe(true);
});

it('should handle mixed Chinese/English query', () => {
const results = retrieve('饼图 tooltip', { library: 'g2', topK: 5 });
it('should handle mixed Chinese/English query', async () => {
const results = await retrieve('饼图 tooltip', { library: 'g2', topK: 5 });
if (zvecReady) {
expect(results.length).toBeGreaterThan(0);
expect(results.length).toBeLessThanOrEqual(5);
}
});

it('should load markdown content on demand', () => {
it('should load markdown content on demand', async () => {
// Use legacy positional overload for backward compat test
const results = retrieve('折线图', 'g2', 1, true);
const results = await retrieve('折线图', 'g2', 1, true);
if (zvecReady) {
expect(results.length).toBeGreaterThan(0);
expect(typeof results[0].content).toBe('string');
Expand Down
103 changes: 103 additions & 0 deletions __tests__/synonyms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { expandQuery, getSynonymsForToken, getSynonymMap } from '../src/core/synonyms';

describe('synonyms', () => {
describe('getSynonymMap', () => {
it('should return a bidirectional map with both Chinese and English entries', () => {
const map = getSynonymMap();

// Chinese → English
expect(map.get('桑基图')).toContainEqual('sankey');
expect(map.get('树图')).toContainEqual('treemap');

// English → Chinese (auto-generated reverse direction)
expect(map.get('sankey')).toContainEqual('桑基图');
expect(map.get('treemap')).toContainEqual('矩形树图');
expect(map.get('treemap')).toContainEqual('树图');
});

it('should handle multi-valued synonyms (marimekko → 马赛克图)', () => {
const map = getSynonymMap();
expect(map.get('马赛克图')).toContainEqual('mosaic');
expect(map.get('马赛克图')).toContainEqual('marimekko');
// Reverse: both mosaic and marimekko → 马赛克图
expect(map.get('mosaic')).toContainEqual('马赛克图');
expect(map.get('marimekko')).toContainEqual('马赛克图');
});

it('should not have duplicate entries in reverse direction', () => {
const map = getSynonymMap();
// 'treemap' reverse should have exactly '矩形树图' and '树图'
const treemapSynonyms = map.get('treemap');
expect(treemapSynonyms).toBeDefined();
const unique = new Set(treemapSynonyms);
expect(unique.size).toBe(treemapSynonyms!.length);
});
});

describe('expandQuery', () => {
it('should expand Chinese chart types to English equivalents', () => {
const result = expandQuery('桑基图');
expect(result).toContain('sankey');
expect(result).toContain('桑基图');
});

it('should expand English chart types to Chinese equivalents', () => {
const result = expandQuery('treemap');
expect(result).toContain('矩形树图');
expect(result).toContain('树图');
});

it('should expand mixed Chinese/English queries', () => {
const result = expandQuery('热力图 heatmap');
// Should add English synonyms for 热力图 (already has heatmap)
expect(result).toContain('热力图');
// Should add Chinese for heatmap
expect(result).toContain('heatmap');
});

it('should not duplicate terms already present in the query', () => {
const result = expandQuery('sankey');
// sankey is already in the query, so its synonym 桑基图 should be added
expect(result).toContain('sankey');
expect(result).toContain('桑基图');
// But sankey itself should not be added again (no duplication)
const parts = result.split(/\s+/);
const sankeyCount = parts.filter(p => p === 'sankey').length;
expect(sankeyCount).toBe(1);
});

it('should not expand unknown terms', () => {
const result = expandQuery('未知图表类型');
expect(result).toBe('未知图表类型');
});

it('should expand multiple terms in one query', () => {
const result = expandQuery('桑基图和热力图');
expect(result).toContain('sankey');
expect(result).toContain('heatmap');
});

it('should handle G6-specific terms', () => {
const result = expandQuery('思维导图');
expect(result).toContain('mindmap');
});
});

describe('getSynonymsForToken', () => {
it('should return synonyms for known tokens', () => {
const syns = getSynonymsForToken('桑基图');
expect(syns).toContainEqual('sankey');
});

it('should return English → Chinese synonyms', () => {
const syns = getSynonymsForToken('sankey');
expect(syns).toContainEqual('桑基图');
});

it('should return empty array for unknown tokens', () => {
const syns = getSynonymsForToken('unknown');
expect(syns).toEqual([]);
});
});
});
Loading
Loading