@ansvar/eu-regulations-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +242 -0
- package/data/seed/ai-act.json +1026 -0
- package/data/seed/applicability/dora.json +92 -0
- package/data/seed/applicability/gdpr.json +74 -0
- package/data/seed/applicability/nis2.json +83 -0
- package/data/seed/cra.json +690 -0
- package/data/seed/cybersecurity-act.json +534 -0
- package/data/seed/dora.json +719 -0
- package/data/seed/gdpr.json +732 -0
- package/data/seed/mappings/iso27001-dora.json +106 -0
- package/data/seed/mappings/iso27001-gdpr.json +114 -0
- package/data/seed/mappings/iso27001-nis2.json +98 -0
- package/data/seed/nis2.json +492 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +271 -0
- package/dist/index.js.map +1 -0
- package/dist/tools/applicability.d.ts +20 -0
- package/dist/tools/applicability.d.ts.map +1 -0
- package/dist/tools/applicability.js +42 -0
- package/dist/tools/applicability.js.map +1 -0
- package/dist/tools/article.d.ts +17 -0
- package/dist/tools/article.d.ts.map +1 -0
- package/dist/tools/article.js +29 -0
- package/dist/tools/article.js.map +1 -0
- package/dist/tools/compare.d.ts +18 -0
- package/dist/tools/compare.d.ts.map +1 -0
- package/dist/tools/compare.js +60 -0
- package/dist/tools/compare.js.map +1 -0
- package/dist/tools/definitions.d.ts +14 -0
- package/dist/tools/definitions.d.ts.map +1 -0
- package/dist/tools/definitions.js +26 -0
- package/dist/tools/definitions.js.map +1 -0
- package/dist/tools/list.d.ts +22 -0
- package/dist/tools/list.d.ts.map +1 -0
- package/dist/tools/list.js +67 -0
- package/dist/tools/list.js.map +1 -0
- package/dist/tools/map.d.ts +19 -0
- package/dist/tools/map.d.ts.map +1 -0
- package/dist/tools/map.js +44 -0
- package/dist/tools/map.js.map +1 -0
- package/dist/tools/search.d.ts +15 -0
- package/dist/tools/search.d.ts.map +1 -0
- package/dist/tools/search.js +62 -0
- package/dist/tools/search.js.map +1 -0
- package/package.json +70 -0
- package/scripts/build-db.ts +292 -0
- package/scripts/check-updates.ts +192 -0
- package/scripts/ingest-eurlex.ts +219 -0
- package/src/index.ts +294 -0
- package/src/tools/applicability.ts +84 -0
- package/src/tools/article.ts +61 -0
- package/src/tools/compare.ts +94 -0
- package/src/tools/definitions.ts +54 -0
- package/src/tools/list.ts +116 -0
- package/src/tools/map.ts +84 -0
- package/src/tools/search.ts +95 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Database } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export type Sector =
|
|
4
|
+
| 'financial'
|
|
5
|
+
| 'healthcare'
|
|
6
|
+
| 'energy'
|
|
7
|
+
| 'transport'
|
|
8
|
+
| 'digital_infrastructure'
|
|
9
|
+
| 'public_administration'
|
|
10
|
+
| 'manufacturing'
|
|
11
|
+
| 'other';
|
|
12
|
+
|
|
13
|
+
export interface ApplicabilityInput {
|
|
14
|
+
sector: Sector;
|
|
15
|
+
subsector?: string;
|
|
16
|
+
member_state?: string;
|
|
17
|
+
size?: 'sme' | 'large';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ApplicableRegulation {
|
|
21
|
+
regulation: string;
|
|
22
|
+
confidence: 'definite' | 'likely' | 'possible';
|
|
23
|
+
basis: string | null;
|
|
24
|
+
notes: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ApplicabilityResult {
|
|
28
|
+
entity: ApplicabilityInput;
|
|
29
|
+
applicable_regulations: ApplicableRegulation[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function checkApplicability(
|
|
33
|
+
db: Database,
|
|
34
|
+
input: ApplicabilityInput
|
|
35
|
+
): Promise<ApplicabilityResult> {
|
|
36
|
+
const { sector, subsector } = input;
|
|
37
|
+
|
|
38
|
+
// Query for matching rules - check both sector match and subsector match
|
|
39
|
+
let sql = `
|
|
40
|
+
SELECT DISTINCT
|
|
41
|
+
regulation,
|
|
42
|
+
confidence,
|
|
43
|
+
basis_article as basis,
|
|
44
|
+
notes
|
|
45
|
+
FROM applicability_rules
|
|
46
|
+
WHERE applies = 1
|
|
47
|
+
AND (
|
|
48
|
+
(sector = ? AND (subsector IS NULL OR subsector = ?))
|
|
49
|
+
OR (sector = ? AND subsector IS NULL)
|
|
50
|
+
)
|
|
51
|
+
ORDER BY
|
|
52
|
+
CASE confidence
|
|
53
|
+
WHEN 'definite' THEN 1
|
|
54
|
+
WHEN 'likely' THEN 2
|
|
55
|
+
WHEN 'possible' THEN 3
|
|
56
|
+
END,
|
|
57
|
+
regulation
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const rows = db.prepare(sql).all(sector, subsector || '', sector) as Array<{
|
|
61
|
+
regulation: string;
|
|
62
|
+
confidence: 'definite' | 'likely' | 'possible';
|
|
63
|
+
basis: string | null;
|
|
64
|
+
notes: string | null;
|
|
65
|
+
}>;
|
|
66
|
+
|
|
67
|
+
// Deduplicate by regulation, keeping highest confidence
|
|
68
|
+
const regulationMap = new Map<string, ApplicableRegulation>();
|
|
69
|
+
for (const row of rows) {
|
|
70
|
+
if (!regulationMap.has(row.regulation)) {
|
|
71
|
+
regulationMap.set(row.regulation, {
|
|
72
|
+
regulation: row.regulation,
|
|
73
|
+
confidence: row.confidence,
|
|
74
|
+
basis: row.basis,
|
|
75
|
+
notes: row.notes,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
entity: input,
|
|
82
|
+
applicable_regulations: Array.from(regulationMap.values()),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Database } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface GetArticleInput {
|
|
4
|
+
regulation: string;
|
|
5
|
+
article: string;
|
|
6
|
+
include_recitals?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface Article {
|
|
10
|
+
regulation: string;
|
|
11
|
+
article_number: string;
|
|
12
|
+
title: string | null;
|
|
13
|
+
text: string;
|
|
14
|
+
chapter: string | null;
|
|
15
|
+
recitals: string[] | null;
|
|
16
|
+
cross_references: string[] | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function getArticle(
|
|
20
|
+
db: Database,
|
|
21
|
+
input: GetArticleInput
|
|
22
|
+
): Promise<Article | null> {
|
|
23
|
+
const { regulation, article } = input;
|
|
24
|
+
|
|
25
|
+
const sql = `
|
|
26
|
+
SELECT
|
|
27
|
+
regulation,
|
|
28
|
+
article_number,
|
|
29
|
+
title,
|
|
30
|
+
text,
|
|
31
|
+
chapter,
|
|
32
|
+
recitals,
|
|
33
|
+
cross_references
|
|
34
|
+
FROM articles
|
|
35
|
+
WHERE regulation = ? AND article_number = ?
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
const row = db.prepare(sql).get(regulation, article) as {
|
|
39
|
+
regulation: string;
|
|
40
|
+
article_number: string;
|
|
41
|
+
title: string | null;
|
|
42
|
+
text: string;
|
|
43
|
+
chapter: string | null;
|
|
44
|
+
recitals: string | null;
|
|
45
|
+
cross_references: string | null;
|
|
46
|
+
} | undefined;
|
|
47
|
+
|
|
48
|
+
if (!row) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
regulation: row.regulation,
|
|
54
|
+
article_number: row.article_number,
|
|
55
|
+
title: row.title,
|
|
56
|
+
text: row.text,
|
|
57
|
+
chapter: row.chapter,
|
|
58
|
+
recitals: row.recitals ? JSON.parse(row.recitals) : null,
|
|
59
|
+
cross_references: row.cross_references ? JSON.parse(row.cross_references) : null,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { Database } from 'better-sqlite3';
|
|
2
|
+
import { searchRegulations } from './search.js';
|
|
3
|
+
|
|
4
|
+
export interface CompareInput {
|
|
5
|
+
topic: string;
|
|
6
|
+
regulations: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RegulationComparison {
|
|
10
|
+
regulation: string;
|
|
11
|
+
requirements: string[];
|
|
12
|
+
articles: string[];
|
|
13
|
+
timelines?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface CompareResult {
|
|
17
|
+
topic: string;
|
|
18
|
+
regulations: RegulationComparison[];
|
|
19
|
+
key_differences?: string[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Extract timeline mentions from text (e.g., "24 hours", "72 hours")
|
|
24
|
+
*/
|
|
25
|
+
function extractTimelines(text: string): string | undefined {
|
|
26
|
+
const timelinePatterns = [
|
|
27
|
+
/(\d+)\s*hours?/gi,
|
|
28
|
+
/(\d+)\s*days?/gi,
|
|
29
|
+
/without\s+undue\s+delay/gi,
|
|
30
|
+
/immediately/gi,
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
const matches: string[] = [];
|
|
34
|
+
for (const pattern of timelinePatterns) {
|
|
35
|
+
const found = text.match(pattern);
|
|
36
|
+
if (found) {
|
|
37
|
+
matches.push(...found);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return matches.length > 0 ? matches.join(', ') : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export async function compareRequirements(
|
|
45
|
+
db: Database,
|
|
46
|
+
input: CompareInput
|
|
47
|
+
): Promise<CompareResult> {
|
|
48
|
+
const { topic, regulations } = input;
|
|
49
|
+
|
|
50
|
+
const comparisons: RegulationComparison[] = [];
|
|
51
|
+
|
|
52
|
+
for (const regulation of regulations) {
|
|
53
|
+
// Search for articles matching the topic in this regulation
|
|
54
|
+
const results = await searchRegulations(db, {
|
|
55
|
+
query: topic,
|
|
56
|
+
regulations: [regulation],
|
|
57
|
+
limit: 5,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// Get full article text for timeline extraction
|
|
61
|
+
const articles: string[] = [];
|
|
62
|
+
const requirements: string[] = [];
|
|
63
|
+
let combinedText = '';
|
|
64
|
+
|
|
65
|
+
for (const result of results) {
|
|
66
|
+
articles.push(result.article);
|
|
67
|
+
requirements.push(result.snippet.replace(/>>>/g, '').replace(/<<</g, ''));
|
|
68
|
+
|
|
69
|
+
// Get full text for timeline extraction
|
|
70
|
+
const fullArticle = db.prepare(`
|
|
71
|
+
SELECT text FROM articles
|
|
72
|
+
WHERE regulation = ? AND article_number = ?
|
|
73
|
+
`).get(regulation, result.article) as { text: string } | undefined;
|
|
74
|
+
|
|
75
|
+
if (fullArticle) {
|
|
76
|
+
combinedText += ' ' + fullArticle.text;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const timelines = extractTimelines(combinedText);
|
|
81
|
+
|
|
82
|
+
comparisons.push({
|
|
83
|
+
regulation,
|
|
84
|
+
requirements,
|
|
85
|
+
articles,
|
|
86
|
+
timelines,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
topic,
|
|
92
|
+
regulations: comparisons,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Database } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface DefinitionsInput {
|
|
4
|
+
term: string;
|
|
5
|
+
regulation?: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface Definition {
|
|
9
|
+
term: string;
|
|
10
|
+
regulation: string;
|
|
11
|
+
article: string;
|
|
12
|
+
definition: string;
|
|
13
|
+
related_terms?: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function getDefinitions(
|
|
17
|
+
db: Database,
|
|
18
|
+
input: DefinitionsInput
|
|
19
|
+
): Promise<Definition[]> {
|
|
20
|
+
const { term, regulation } = input;
|
|
21
|
+
|
|
22
|
+
let sql = `
|
|
23
|
+
SELECT
|
|
24
|
+
term,
|
|
25
|
+
regulation,
|
|
26
|
+
article,
|
|
27
|
+
definition
|
|
28
|
+
FROM definitions
|
|
29
|
+
WHERE term LIKE ?
|
|
30
|
+
`;
|
|
31
|
+
|
|
32
|
+
const params: string[] = [`%${term}%`];
|
|
33
|
+
|
|
34
|
+
if (regulation) {
|
|
35
|
+
sql += ` AND regulation = ?`;
|
|
36
|
+
params.push(regulation);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
sql += ` ORDER BY regulation, term`;
|
|
40
|
+
|
|
41
|
+
const rows = db.prepare(sql).all(...params) as Array<{
|
|
42
|
+
term: string;
|
|
43
|
+
regulation: string;
|
|
44
|
+
article: string;
|
|
45
|
+
definition: string;
|
|
46
|
+
}>;
|
|
47
|
+
|
|
48
|
+
return rows.map(row => ({
|
|
49
|
+
term: row.term,
|
|
50
|
+
regulation: row.regulation,
|
|
51
|
+
article: row.article,
|
|
52
|
+
definition: row.definition,
|
|
53
|
+
}));
|
|
54
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { Database } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface ListInput {
|
|
4
|
+
regulation?: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface Chapter {
|
|
8
|
+
number: string;
|
|
9
|
+
title: string;
|
|
10
|
+
articles: string[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RegulationInfo {
|
|
14
|
+
id: string;
|
|
15
|
+
full_name: string;
|
|
16
|
+
celex_id: string;
|
|
17
|
+
effective_date: string | null;
|
|
18
|
+
article_count: number;
|
|
19
|
+
chapters?: Chapter[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface ListResult {
|
|
23
|
+
regulations: RegulationInfo[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function listRegulations(
|
|
27
|
+
db: Database,
|
|
28
|
+
input: ListInput
|
|
29
|
+
): Promise<ListResult> {
|
|
30
|
+
const { regulation } = input;
|
|
31
|
+
|
|
32
|
+
if (regulation) {
|
|
33
|
+
// Get specific regulation with chapters
|
|
34
|
+
const regRow = db.prepare(`
|
|
35
|
+
SELECT id, full_name, celex_id, effective_date
|
|
36
|
+
FROM regulations
|
|
37
|
+
WHERE id = ?
|
|
38
|
+
`).get(regulation) as {
|
|
39
|
+
id: string;
|
|
40
|
+
full_name: string;
|
|
41
|
+
celex_id: string;
|
|
42
|
+
effective_date: string | null;
|
|
43
|
+
} | undefined;
|
|
44
|
+
|
|
45
|
+
if (!regRow) {
|
|
46
|
+
return { regulations: [] };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Get articles grouped by chapter
|
|
50
|
+
const articles = db.prepare(`
|
|
51
|
+
SELECT article_number, title, chapter
|
|
52
|
+
FROM articles
|
|
53
|
+
WHERE regulation = ?
|
|
54
|
+
ORDER BY CAST(article_number AS INTEGER)
|
|
55
|
+
`).all(regulation) as Array<{
|
|
56
|
+
article_number: string;
|
|
57
|
+
title: string | null;
|
|
58
|
+
chapter: string | null;
|
|
59
|
+
}>;
|
|
60
|
+
|
|
61
|
+
// Group by chapter
|
|
62
|
+
const chapterMap = new Map<string, Chapter>();
|
|
63
|
+
for (const article of articles) {
|
|
64
|
+
const chapterKey = article.chapter || 'General';
|
|
65
|
+
if (!chapterMap.has(chapterKey)) {
|
|
66
|
+
chapterMap.set(chapterKey, {
|
|
67
|
+
number: chapterKey,
|
|
68
|
+
title: `Chapter ${chapterKey}`,
|
|
69
|
+
articles: [],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
chapterMap.get(chapterKey)!.articles.push(article.article_number);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return {
|
|
76
|
+
regulations: [{
|
|
77
|
+
id: regRow.id,
|
|
78
|
+
full_name: regRow.full_name,
|
|
79
|
+
celex_id: regRow.celex_id,
|
|
80
|
+
effective_date: regRow.effective_date,
|
|
81
|
+
article_count: articles.length,
|
|
82
|
+
chapters: Array.from(chapterMap.values()),
|
|
83
|
+
}],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// List all regulations with article counts
|
|
88
|
+
const rows = db.prepare(`
|
|
89
|
+
SELECT
|
|
90
|
+
r.id,
|
|
91
|
+
r.full_name,
|
|
92
|
+
r.celex_id,
|
|
93
|
+
r.effective_date,
|
|
94
|
+
COUNT(a.rowid) as article_count
|
|
95
|
+
FROM regulations r
|
|
96
|
+
LEFT JOIN articles a ON a.regulation = r.id
|
|
97
|
+
GROUP BY r.id
|
|
98
|
+
ORDER BY r.id
|
|
99
|
+
`).all() as Array<{
|
|
100
|
+
id: string;
|
|
101
|
+
full_name: string;
|
|
102
|
+
celex_id: string;
|
|
103
|
+
effective_date: string | null;
|
|
104
|
+
article_count: number;
|
|
105
|
+
}>;
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
regulations: rows.map(row => ({
|
|
109
|
+
id: row.id,
|
|
110
|
+
full_name: row.full_name,
|
|
111
|
+
celex_id: row.celex_id,
|
|
112
|
+
effective_date: row.effective_date,
|
|
113
|
+
article_count: row.article_count,
|
|
114
|
+
})),
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/tools/map.ts
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Database } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface MapControlsInput {
|
|
4
|
+
framework: 'ISO27001';
|
|
5
|
+
control?: string;
|
|
6
|
+
regulation?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ControlMappingEntry {
|
|
10
|
+
regulation: string;
|
|
11
|
+
articles: string[];
|
|
12
|
+
coverage: 'full' | 'partial' | 'related';
|
|
13
|
+
notes: string | null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ControlMapping {
|
|
17
|
+
control_id: string;
|
|
18
|
+
control_name: string;
|
|
19
|
+
mappings: ControlMappingEntry[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export async function mapControls(
|
|
23
|
+
db: Database,
|
|
24
|
+
input: MapControlsInput
|
|
25
|
+
): Promise<ControlMapping[]> {
|
|
26
|
+
const { control, regulation } = input;
|
|
27
|
+
|
|
28
|
+
let sql = `
|
|
29
|
+
SELECT
|
|
30
|
+
control_id,
|
|
31
|
+
control_name,
|
|
32
|
+
regulation,
|
|
33
|
+
articles,
|
|
34
|
+
coverage,
|
|
35
|
+
notes
|
|
36
|
+
FROM control_mappings
|
|
37
|
+
WHERE 1=1
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
const params: string[] = [];
|
|
41
|
+
|
|
42
|
+
if (control) {
|
|
43
|
+
sql += ` AND control_id = ?`;
|
|
44
|
+
params.push(control);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (regulation) {
|
|
48
|
+
sql += ` AND regulation = ?`;
|
|
49
|
+
params.push(regulation);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
sql += ` ORDER BY control_id, regulation`;
|
|
53
|
+
|
|
54
|
+
const rows = db.prepare(sql).all(...params) as Array<{
|
|
55
|
+
control_id: string;
|
|
56
|
+
control_name: string;
|
|
57
|
+
regulation: string;
|
|
58
|
+
articles: string;
|
|
59
|
+
coverage: 'full' | 'partial' | 'related';
|
|
60
|
+
notes: string | null;
|
|
61
|
+
}>;
|
|
62
|
+
|
|
63
|
+
// Group by control_id
|
|
64
|
+
const controlMap = new Map<string, ControlMapping>();
|
|
65
|
+
|
|
66
|
+
for (const row of rows) {
|
|
67
|
+
if (!controlMap.has(row.control_id)) {
|
|
68
|
+
controlMap.set(row.control_id, {
|
|
69
|
+
control_id: row.control_id,
|
|
70
|
+
control_name: row.control_name,
|
|
71
|
+
mappings: [],
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
controlMap.get(row.control_id)!.mappings.push({
|
|
76
|
+
regulation: row.regulation,
|
|
77
|
+
articles: JSON.parse(row.articles),
|
|
78
|
+
coverage: row.coverage,
|
|
79
|
+
notes: row.notes,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return Array.from(controlMap.values());
|
|
84
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Database } from 'better-sqlite3';
|
|
2
|
+
|
|
3
|
+
export interface SearchInput {
|
|
4
|
+
query: string;
|
|
5
|
+
regulations?: string[];
|
|
6
|
+
limit?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SearchResult {
|
|
10
|
+
regulation: string;
|
|
11
|
+
article: string;
|
|
12
|
+
title: string;
|
|
13
|
+
snippet: string;
|
|
14
|
+
relevance: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Escape special FTS5 query characters to prevent syntax errors.
|
|
19
|
+
* FTS5 uses double quotes for phrase queries and has special operators.
|
|
20
|
+
*/
|
|
21
|
+
function escapeFts5Query(query: string): string {
|
|
22
|
+
// Remove characters that have special meaning in FTS5
|
|
23
|
+
// and wrap each word in double quotes for exact matching
|
|
24
|
+
return query
|
|
25
|
+
.replace(/['"]/g, '') // Remove quotes
|
|
26
|
+
.split(/\s+/)
|
|
27
|
+
.filter(word => word.length > 0)
|
|
28
|
+
.map(word => `"${word}"`)
|
|
29
|
+
.join(' ');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function searchRegulations(
|
|
33
|
+
db: Database,
|
|
34
|
+
input: SearchInput
|
|
35
|
+
): Promise<SearchResult[]> {
|
|
36
|
+
const { query, regulations, limit = 10 } = input;
|
|
37
|
+
|
|
38
|
+
if (!query || query.trim().length === 0) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const escapedQuery = escapeFts5Query(query);
|
|
43
|
+
|
|
44
|
+
if (!escapedQuery) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Build the SQL query with optional regulation filter
|
|
49
|
+
let sql = `
|
|
50
|
+
SELECT
|
|
51
|
+
articles_fts.regulation,
|
|
52
|
+
articles_fts.article_number as article,
|
|
53
|
+
articles_fts.title,
|
|
54
|
+
snippet(articles_fts, 3, '>>>', '<<<', '...', 32) as snippet,
|
|
55
|
+
bm25(articles_fts) as relevance
|
|
56
|
+
FROM articles_fts
|
|
57
|
+
WHERE articles_fts MATCH ?
|
|
58
|
+
`;
|
|
59
|
+
|
|
60
|
+
const params: (string | number)[] = [escapedQuery];
|
|
61
|
+
|
|
62
|
+
if (regulations && regulations.length > 0) {
|
|
63
|
+
const placeholders = regulations.map(() => '?').join(', ');
|
|
64
|
+
sql += ` AND articles_fts.regulation IN (${placeholders})`;
|
|
65
|
+
params.push(...regulations);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Order by relevance (bm25 returns negative scores, more negative = more relevant)
|
|
69
|
+
sql += ` ORDER BY bm25(articles_fts)`;
|
|
70
|
+
sql += ` LIMIT ?`;
|
|
71
|
+
params.push(limit);
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const stmt = db.prepare(sql);
|
|
75
|
+
const rows = stmt.all(...params) as Array<{
|
|
76
|
+
regulation: string;
|
|
77
|
+
article: string;
|
|
78
|
+
title: string;
|
|
79
|
+
snippet: string;
|
|
80
|
+
relevance: number;
|
|
81
|
+
}>;
|
|
82
|
+
|
|
83
|
+
// Convert bm25 scores to positive values (higher = more relevant)
|
|
84
|
+
return rows.map(row => ({
|
|
85
|
+
...row,
|
|
86
|
+
relevance: Math.abs(row.relevance),
|
|
87
|
+
}));
|
|
88
|
+
} catch (error) {
|
|
89
|
+
// If FTS5 query fails (e.g., syntax error), return empty results
|
|
90
|
+
if (error instanceof Error && error.message.includes('fts5')) {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
}
|