@team-semicolon/semo-cli 4.12.0 → 4.15.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/dist/commands/commitments.js +25 -3
- package/dist/commands/commitments.test.d.ts +9 -0
- package/dist/commands/commitments.test.js +223 -0
- package/dist/commands/context.js +23 -3
- package/dist/commands/harness.d.ts +8 -0
- package/dist/commands/harness.js +412 -0
- package/dist/commands/incubator.d.ts +10 -0
- package/dist/commands/incubator.js +517 -0
- package/dist/commands/service.d.ts +10 -0
- package/dist/commands/service.js +283 -0
- package/dist/commands/sessions.js +156 -0
- package/dist/commands/skill-sync.d.ts +2 -1
- package/dist/commands/skill-sync.js +88 -23
- package/dist/commands/skill-sync.test.js +78 -45
- package/dist/database.d.ts +2 -1
- package/dist/database.js +109 -27
- package/dist/global-cache.js +26 -14
- package/dist/index.js +578 -522
- package/dist/kb.d.ts +4 -4
- package/dist/kb.js +211 -101
- package/dist/semo-workspace.js +51 -0
- package/dist/service-migrate.d.ts +114 -0
- package/dist/service-migrate.js +457 -0
- package/dist/templates/harness/commit-msg +1 -0
- package/dist/templates/harness/commitlint.config.js +11 -0
- package/dist/templates/harness/eslint.config.mjs +17 -0
- package/dist/templates/harness/pr-quality-gate.yml +19 -0
- package/dist/templates/harness/pre-commit +1 -0
- package/dist/templates/harness/pre-push +1 -0
- package/dist/templates/harness/prettierignore +5 -0
- package/dist/templates/harness/prettierrc.json +7 -0
- package/package.json +8 -4
package/dist/kb.js
CHANGED
|
@@ -81,7 +81,7 @@ function combineKey(key, subKey) {
|
|
|
81
81
|
// ============================================================
|
|
82
82
|
// Embedding
|
|
83
83
|
// ============================================================
|
|
84
|
-
const EMBEDDING_MODEL =
|
|
84
|
+
const EMBEDDING_MODEL = 'text-embedding-3-small';
|
|
85
85
|
const EMBEDDING_DIMENSIONS = 1024; // DB vector(1024) 유지 — OpenAI dimensions 파라미터로 축소
|
|
86
86
|
/**
|
|
87
87
|
* Generate embedding vector for text using OpenAI Embeddings API
|
|
@@ -92,11 +92,11 @@ async function generateEmbedding(text) {
|
|
|
92
92
|
if (!apiKey)
|
|
93
93
|
return null;
|
|
94
94
|
try {
|
|
95
|
-
const response = await fetch(
|
|
96
|
-
method:
|
|
95
|
+
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
|
96
|
+
method: 'POST',
|
|
97
97
|
headers: {
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
Authorization: `Bearer ${apiKey}`,
|
|
99
|
+
'Content-Type': 'application/json',
|
|
100
100
|
},
|
|
101
101
|
body: JSON.stringify({
|
|
102
102
|
model: EMBEDDING_MODEL,
|
|
@@ -109,7 +109,7 @@ async function generateEmbedding(text) {
|
|
|
109
109
|
console.error(`Embedding API error: ${response.status} ${err}`);
|
|
110
110
|
return null;
|
|
111
111
|
}
|
|
112
|
-
const data = await response.json();
|
|
112
|
+
const data = (await response.json());
|
|
113
113
|
return data.data?.[0]?.embedding || null;
|
|
114
114
|
}
|
|
115
115
|
catch (err) {
|
|
@@ -125,21 +125,21 @@ async function generateEmbeddings(texts) {
|
|
|
125
125
|
if (!apiKey)
|
|
126
126
|
return texts.map(() => null);
|
|
127
127
|
try {
|
|
128
|
-
const response = await fetch(
|
|
129
|
-
method:
|
|
128
|
+
const response = await fetch('https://api.openai.com/v1/embeddings', {
|
|
129
|
+
method: 'POST',
|
|
130
130
|
headers: {
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
Authorization: `Bearer ${apiKey}`,
|
|
132
|
+
'Content-Type': 'application/json',
|
|
133
133
|
},
|
|
134
134
|
body: JSON.stringify({
|
|
135
135
|
model: EMBEDDING_MODEL,
|
|
136
|
-
input: texts.map(t => t.substring(0, 8000)),
|
|
136
|
+
input: texts.map((t) => t.substring(0, 8000)),
|
|
137
137
|
dimensions: EMBEDDING_DIMENSIONS,
|
|
138
138
|
}),
|
|
139
139
|
});
|
|
140
140
|
if (!response.ok)
|
|
141
141
|
return texts.map(() => null);
|
|
142
|
-
const data = await response.json();
|
|
142
|
+
const data = (await response.json());
|
|
143
143
|
return data.data?.map((d) => d.embedding) || texts.map(() => null);
|
|
144
144
|
}
|
|
145
145
|
catch {
|
|
@@ -149,8 +149,8 @@ async function generateEmbeddings(texts) {
|
|
|
149
149
|
// ============================================================
|
|
150
150
|
// KB Directory Management
|
|
151
151
|
// ============================================================
|
|
152
|
-
const KB_DIR =
|
|
153
|
-
const SYNC_STATE_FILE =
|
|
152
|
+
const KB_DIR = '.kb';
|
|
153
|
+
const SYNC_STATE_FILE = '.sync-state.json';
|
|
154
154
|
function getKBDir(cwd) {
|
|
155
155
|
return path.join(cwd, KB_DIR);
|
|
156
156
|
}
|
|
@@ -159,7 +159,7 @@ function ensureKBDir(cwd) {
|
|
|
159
159
|
if (!fs.existsSync(kbDir)) {
|
|
160
160
|
fs.mkdirSync(kbDir, { recursive: true });
|
|
161
161
|
}
|
|
162
|
-
const ontoDir = path.join(kbDir,
|
|
162
|
+
const ontoDir = path.join(kbDir, 'ontology');
|
|
163
163
|
if (!fs.existsSync(ontoDir)) {
|
|
164
164
|
fs.mkdirSync(ontoDir, { recursive: true });
|
|
165
165
|
}
|
|
@@ -169,7 +169,7 @@ function readSyncState(cwd) {
|
|
|
169
169
|
const statePath = path.join(getKBDir(cwd), SYNC_STATE_FILE);
|
|
170
170
|
if (fs.existsSync(statePath)) {
|
|
171
171
|
try {
|
|
172
|
-
return JSON.parse(fs.readFileSync(statePath,
|
|
172
|
+
return JSON.parse(fs.readFileSync(statePath, 'utf-8'));
|
|
173
173
|
}
|
|
174
174
|
catch {
|
|
175
175
|
// corrupted file
|
|
@@ -190,7 +190,7 @@ function readKBFile(cwd, filename) {
|
|
|
190
190
|
if (!fs.existsSync(filePath))
|
|
191
191
|
return [];
|
|
192
192
|
try {
|
|
193
|
-
return JSON.parse(fs.readFileSync(filePath,
|
|
193
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
194
194
|
}
|
|
195
195
|
catch {
|
|
196
196
|
return [];
|
|
@@ -212,14 +212,14 @@ async function kbPull(pool, domain, cwd) {
|
|
|
212
212
|
`;
|
|
213
213
|
const params = [];
|
|
214
214
|
if (domain) {
|
|
215
|
-
query +=
|
|
215
|
+
query += ' WHERE domain = $1';
|
|
216
216
|
params.push(domain);
|
|
217
217
|
}
|
|
218
|
-
query +=
|
|
218
|
+
query += ' ORDER BY domain, key';
|
|
219
219
|
const result = await client.query(query, params);
|
|
220
220
|
const entries = result.rows;
|
|
221
221
|
if (cwd) {
|
|
222
|
-
writeKBFile(cwd,
|
|
222
|
+
writeKBFile(cwd, 'team.json', entries);
|
|
223
223
|
const state = readSyncState(cwd);
|
|
224
224
|
state.lastPull = new Date().toISOString();
|
|
225
225
|
state.sharedCount = entries.length;
|
|
@@ -240,7 +240,7 @@ async function kbPush(pool, entries, createdBy, cwd) {
|
|
|
240
240
|
const errors = [];
|
|
241
241
|
try {
|
|
242
242
|
// Domain validation: check all domains against ontology before transaction
|
|
243
|
-
const ontologyResult = await client.query(
|
|
243
|
+
const ontologyResult = await client.query('SELECT domain FROM semo.ontology');
|
|
244
244
|
const knownDomains = new Set(ontologyResult.rows.map((r) => r.domain));
|
|
245
245
|
const invalidEntries = [];
|
|
246
246
|
const validEntries = [];
|
|
@@ -255,28 +255,36 @@ async function kbPush(pool, entries, createdBy, cwd) {
|
|
|
255
255
|
if (invalidEntries.length > 0) {
|
|
256
256
|
errors.push(...invalidEntries);
|
|
257
257
|
}
|
|
258
|
-
await client.query(
|
|
259
|
-
const texts = validEntries.map(e => `${e.key}: ${e.content}`);
|
|
258
|
+
await client.query('BEGIN');
|
|
259
|
+
const texts = validEntries.map((e) => `${e.key}: ${e.content}`);
|
|
260
260
|
const embeddings = await generateEmbeddings(texts);
|
|
261
261
|
for (let i = 0; i < validEntries.length; i++) {
|
|
262
262
|
const entry = validEntries[i];
|
|
263
263
|
try {
|
|
264
264
|
const embedding = embeddings[i];
|
|
265
|
-
const embeddingStr = embedding ? `[${embedding.join(
|
|
265
|
+
const embeddingStr = embedding ? `[${embedding.join(',')}]` : null;
|
|
266
266
|
const { key: flatKey, subKey } = splitKey(entry.key);
|
|
267
267
|
await client.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
|
|
268
268
|
VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
|
|
269
269
|
ON CONFLICT (domain, key, sub_key) DO UPDATE SET
|
|
270
270
|
content = EXCLUDED.content,
|
|
271
271
|
metadata = EXCLUDED.metadata,
|
|
272
|
-
embedding = EXCLUDED.embedding`, [
|
|
272
|
+
embedding = EXCLUDED.embedding`, [
|
|
273
|
+
entry.domain,
|
|
274
|
+
flatKey,
|
|
275
|
+
subKey,
|
|
276
|
+
entry.content,
|
|
277
|
+
JSON.stringify(entry.metadata || {}),
|
|
278
|
+
entry.created_by || createdBy || 'unknown',
|
|
279
|
+
embeddingStr,
|
|
280
|
+
]);
|
|
273
281
|
upserted++;
|
|
274
282
|
}
|
|
275
283
|
catch (err) {
|
|
276
284
|
errors.push(`${entry.domain}/${entry.key}: ${err}`);
|
|
277
285
|
}
|
|
278
286
|
}
|
|
279
|
-
await client.query(
|
|
287
|
+
await client.query('COMMIT');
|
|
280
288
|
if (cwd) {
|
|
281
289
|
const state = readSyncState(cwd);
|
|
282
290
|
state.lastPush = new Date().toISOString();
|
|
@@ -284,7 +292,7 @@ async function kbPush(pool, entries, createdBy, cwd) {
|
|
|
284
292
|
}
|
|
285
293
|
}
|
|
286
294
|
catch (err) {
|
|
287
|
-
await client.query(
|
|
295
|
+
await client.query('ROLLBACK');
|
|
288
296
|
errors.push(`Transaction failed: ${err}`);
|
|
289
297
|
}
|
|
290
298
|
finally {
|
|
@@ -329,7 +337,7 @@ async function kbList(pool, options) {
|
|
|
329
337
|
const limit = options.limit || 50;
|
|
330
338
|
const offset = options.offset || 0;
|
|
331
339
|
try {
|
|
332
|
-
let query =
|
|
340
|
+
let query = 'SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base';
|
|
333
341
|
const params = [];
|
|
334
342
|
let paramIdx = 1;
|
|
335
343
|
if (options.domain) {
|
|
@@ -359,7 +367,7 @@ async function kbList(pool, options) {
|
|
|
359
367
|
async function kbSearch(pool, query, options) {
|
|
360
368
|
const client = await pool.connect();
|
|
361
369
|
const limit = options.limit || 10;
|
|
362
|
-
const mode = options.mode ||
|
|
370
|
+
const mode = options.mode || 'hybrid';
|
|
363
371
|
// Resolve service → domain list for filtering
|
|
364
372
|
let serviceDomains = null;
|
|
365
373
|
if (options.service && !options.domain) {
|
|
@@ -368,10 +376,10 @@ async function kbSearch(pool, query, options) {
|
|
|
368
376
|
try {
|
|
369
377
|
let results = [];
|
|
370
378
|
// Try semantic search first (if mode allows and embedding API available)
|
|
371
|
-
if (mode !==
|
|
379
|
+
if (mode !== 'text') {
|
|
372
380
|
const queryEmbedding = await generateEmbedding(query);
|
|
373
381
|
if (queryEmbedding) {
|
|
374
|
-
const embeddingStr = `[${queryEmbedding.join(
|
|
382
|
+
const embeddingStr = `[${queryEmbedding.join(',')}]`;
|
|
375
383
|
// Vector search on shared KB
|
|
376
384
|
let sql = `
|
|
377
385
|
SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text,
|
|
@@ -394,7 +402,7 @@ async function kbSearch(pool, query, options) {
|
|
|
394
402
|
const sharedResult = await client.query(sql, params);
|
|
395
403
|
results = sharedResult.rows;
|
|
396
404
|
// If we got results from semantic search and mode is not hybrid, return
|
|
397
|
-
if (results.length > 0 && mode ===
|
|
405
|
+
if (results.length > 0 && mode === 'semantic') {
|
|
398
406
|
return results;
|
|
399
407
|
}
|
|
400
408
|
}
|
|
@@ -403,8 +411,8 @@ async function kbSearch(pool, query, options) {
|
|
|
403
411
|
// Split query into tokens and match ANY token via ILIKE (Korean-friendly)
|
|
404
412
|
// For Korean tokens of 4+ chars with no spaces, add 2-char sub-tokens
|
|
405
413
|
// e.g. "노조관리" → ["노조관리", "노조", "관리"]
|
|
406
|
-
if (mode !==
|
|
407
|
-
const rawTokens = query.split(/\s+/).filter(t => t.length >= 2);
|
|
414
|
+
if (mode !== 'semantic' || results.length === 0) {
|
|
415
|
+
const rawTokens = query.split(/\s+/).filter((t) => t.length >= 2);
|
|
408
416
|
const tokens = [];
|
|
409
417
|
const KOREAN_RE = /[\uAC00-\uD7AF]/;
|
|
410
418
|
for (const t of rawTokens) {
|
|
@@ -420,20 +428,16 @@ async function kbSearch(pool, query, options) {
|
|
|
420
428
|
const textParams = [];
|
|
421
429
|
let tIdx = 1;
|
|
422
430
|
// Build per-token ILIKE conditions + count matching tokens for scoring
|
|
423
|
-
const tokenConditions = tokens.map(token => {
|
|
431
|
+
const tokenConditions = tokens.map((token) => {
|
|
424
432
|
textParams.push(`%${token}%`);
|
|
425
433
|
return `(CASE WHEN content ILIKE $${tIdx} OR key ILIKE $${tIdx} OR sub_key ILIKE $${tIdx++} THEN 1 ELSE 0 END)`;
|
|
426
434
|
});
|
|
427
435
|
// Score = 0.7 base + 0.15 * (matched_tokens / total_tokens), capped at 0.95
|
|
428
|
-
const matchCountExpr = tokenConditions.length > 0
|
|
429
|
-
? tokenConditions.join(" + ")
|
|
430
|
-
: "0";
|
|
436
|
+
const matchCountExpr = tokenConditions.length > 0 ? tokenConditions.join(' + ') : '0';
|
|
431
437
|
const scoreExpr = `LEAST(0.95, 0.7 + 0.15 * (${matchCountExpr})::float / ${Math.max(tokens.length, 1)})`;
|
|
432
438
|
// WHERE: any token matches
|
|
433
439
|
const whereTokens = tokens.map((_, i) => `(content ILIKE $${i + 1} OR key ILIKE $${i + 1} OR sub_key ILIKE $${i + 1})`);
|
|
434
|
-
const whereClause = whereTokens.length > 0
|
|
435
|
-
? whereTokens.join(" OR ")
|
|
436
|
-
: "FALSE";
|
|
440
|
+
const whereClause = whereTokens.length > 0 ? whereTokens.join(' OR ') : 'FALSE';
|
|
437
441
|
let textSql = `
|
|
438
442
|
SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text,
|
|
439
443
|
${scoreExpr} as score
|
|
@@ -574,7 +578,7 @@ async function resolveServiceDomainsLocal(client, service) {
|
|
|
574
578
|
async function ontoValidate(pool, domain, entries) {
|
|
575
579
|
const onto = await ontoShow(pool, domain);
|
|
576
580
|
if (!onto) {
|
|
577
|
-
return { valid: 0, invalid: [{ key:
|
|
581
|
+
return { valid: 0, invalid: [{ key: '*', errors: [`Ontology domain '${domain}' not found`] }] };
|
|
578
582
|
}
|
|
579
583
|
// If no entries provided, fetch from DB
|
|
580
584
|
if (!entries) {
|
|
@@ -595,7 +599,7 @@ async function ontoValidate(pool, domain, entries) {
|
|
|
595
599
|
const errors = [];
|
|
596
600
|
// Check required fields
|
|
597
601
|
for (const field of required) {
|
|
598
|
-
if (!entry[field] && entry[field] !==
|
|
602
|
+
if (!entry[field] && entry[field] !== '') {
|
|
599
603
|
errors.push(`Missing required field: ${field}`);
|
|
600
604
|
}
|
|
601
605
|
}
|
|
@@ -607,12 +611,12 @@ async function ontoValidate(pool, domain, entries) {
|
|
|
607
611
|
const val = entry.metadata[propKey];
|
|
608
612
|
if (val !== undefined) {
|
|
609
613
|
if (def.enum && !def.enum.includes(val)) {
|
|
610
|
-
errors.push(`metadata.${propKey}: '${val}' not in allowed values [${def.enum.join(
|
|
614
|
+
errors.push(`metadata.${propKey}: '${val}' not in allowed values [${def.enum.join(', ')}]`);
|
|
611
615
|
}
|
|
612
|
-
if (def.type ===
|
|
616
|
+
if (def.type === 'string' && typeof val !== 'string') {
|
|
613
617
|
errors.push(`metadata.${propKey}: expected string, got ${typeof val}`);
|
|
614
618
|
}
|
|
615
|
-
if (def.type ===
|
|
619
|
+
if (def.type === 'array' && !Array.isArray(val)) {
|
|
616
620
|
errors.push(`metadata.${propKey}: expected array, got ${typeof val}`);
|
|
617
621
|
}
|
|
618
622
|
}
|
|
@@ -737,13 +741,15 @@ async function kbUpsert(pool, entry) {
|
|
|
737
741
|
// Domain validation
|
|
738
742
|
const client = await pool.connect();
|
|
739
743
|
try {
|
|
740
|
-
const ontoCheck = await client.query(
|
|
744
|
+
const ontoCheck = await client.query('SELECT domain FROM semo.ontology WHERE domain = $1', [
|
|
745
|
+
entry.domain,
|
|
746
|
+
]);
|
|
741
747
|
if (ontoCheck.rows.length === 0) {
|
|
742
|
-
const known = await client.query(
|
|
748
|
+
const known = await client.query('SELECT domain FROM semo.ontology ORDER BY domain');
|
|
743
749
|
const knownDomains = known.rows.map((r) => r.domain);
|
|
744
750
|
return {
|
|
745
751
|
success: false,
|
|
746
|
-
error: `도메인 '${entry.domain}'은(는) 온톨로지에 등록되지 않았습니다. 등록된 도메인: [${knownDomains.join(
|
|
752
|
+
error: `도메인 '${entry.domain}'은(는) 온톨로지에 등록되지 않았습니다. 등록된 도메인: [${knownDomains.join(', ')}]`,
|
|
747
753
|
};
|
|
748
754
|
}
|
|
749
755
|
}
|
|
@@ -753,14 +759,14 @@ async function kbUpsert(pool, entry) {
|
|
|
753
759
|
// Naming convention check: kebab-case only
|
|
754
760
|
const warnings = [];
|
|
755
761
|
if (/_/.test(key)) {
|
|
756
|
-
const suggested = key.replace(/_/g,
|
|
762
|
+
const suggested = key.replace(/_/g, '-');
|
|
757
763
|
return {
|
|
758
764
|
success: false,
|
|
759
765
|
error: `키 '${key}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'`,
|
|
760
766
|
};
|
|
761
767
|
}
|
|
762
768
|
if (/_/.test(subKey)) {
|
|
763
|
-
const suggested = subKey.replace(/_/g,
|
|
769
|
+
const suggested = subKey.replace(/_/g, '-');
|
|
764
770
|
return {
|
|
765
771
|
success: false,
|
|
766
772
|
error: `sub_key '${subKey}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'`,
|
|
@@ -770,25 +776,41 @@ async function kbUpsert(pool, entry) {
|
|
|
770
776
|
{
|
|
771
777
|
const schemaClient = await pool.connect();
|
|
772
778
|
try {
|
|
773
|
-
const typeResult = await schemaClient.query(
|
|
779
|
+
const typeResult = await schemaClient.query('SELECT entity_type FROM semo.ontology WHERE domain = $1 AND entity_type IS NOT NULL', [entry.domain]);
|
|
774
780
|
if (typeResult.rows.length > 0) {
|
|
775
781
|
const entityType = typeResult.rows[0].entity_type;
|
|
776
|
-
const schemaResult = await schemaClient.query("SELECT scheme_key, COALESCE(key_type, 'singleton') as key_type FROM semo.kb_type_schema WHERE type_key = $1", [entityType]);
|
|
782
|
+
const schemaResult = await schemaClient.query("SELECT scheme_key, COALESCE(key_type, 'singleton') as key_type, COALESCE(source, 'manual') as source FROM semo.kb_type_schema WHERE type_key = $1", [entityType]);
|
|
777
783
|
const schemas = schemaResult.rows;
|
|
778
784
|
if (schemas.length > 0) {
|
|
779
|
-
const match = schemas.find(s => s.scheme_key === key);
|
|
785
|
+
const match = schemas.find((s) => s.scheme_key === key);
|
|
780
786
|
if (!match) {
|
|
781
|
-
const allowedKeys = schemas.map(s => s.key_type ===
|
|
787
|
+
const allowedKeys = schemas.map((s) => s.key_type === 'singleton' ? s.scheme_key : `${s.scheme_key}/{sub_key}`);
|
|
782
788
|
return {
|
|
783
789
|
success: false,
|
|
784
|
-
error: `키 '${key}'은(는) '${entityType}' 타입의 스키마에 허용되지 않습니다. 허용 키: [${allowedKeys.join(
|
|
790
|
+
error: `키 '${key}'은(는) '${entityType}' 타입의 스키마에 허용되지 않습니다. 허용 키: [${allowedKeys.join(', ')}]`,
|
|
785
791
|
};
|
|
786
792
|
}
|
|
787
|
-
|
|
788
|
-
|
|
793
|
+
// Projection key 차단: pm-pipeline만 쓰기 허용
|
|
794
|
+
if (match.source === 'projection') {
|
|
795
|
+
const createdBy = entry.created_by ?? '';
|
|
796
|
+
if (!createdBy.startsWith('pm-') && !createdBy.startsWith('gfp-')) {
|
|
797
|
+
return {
|
|
798
|
+
success: false,
|
|
799
|
+
error: `키 '${key}'은(는) projection 키입니다 (PM 파이프라인에서 자동 동기화). 직접 쓰기가 차단됩니다.`,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
789
802
|
}
|
|
790
|
-
if (match.key_type ===
|
|
791
|
-
return {
|
|
803
|
+
if (match.key_type === 'singleton' && subKey !== '') {
|
|
804
|
+
return {
|
|
805
|
+
success: false,
|
|
806
|
+
error: `키 '${key}'은(는) singleton이므로 sub_key가 비어야 합니다.`,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
if (match.key_type === 'collection' && subKey === '') {
|
|
810
|
+
return {
|
|
811
|
+
success: false,
|
|
812
|
+
error: `키 '${key}'은(는) collection이므로 sub_key가 필요합니다.`,
|
|
813
|
+
};
|
|
792
814
|
}
|
|
793
815
|
}
|
|
794
816
|
}
|
|
@@ -801,20 +823,67 @@ async function kbUpsert(pool, entry) {
|
|
|
801
823
|
schemaClient.release();
|
|
802
824
|
}
|
|
803
825
|
}
|
|
826
|
+
// Slug hygiene validation for decision/incident
|
|
827
|
+
if (key === 'decision' || key === 'incident') {
|
|
828
|
+
const slug = subKey.replace(/^\d{4}-\d{2}-\d{2}\//, ''); // strip date prefix
|
|
829
|
+
if (/^test/i.test(slug) || /test$/i.test(slug) || slug === 'test') {
|
|
830
|
+
return {
|
|
831
|
+
success: false,
|
|
832
|
+
error: `${key}의 sub_key에 'test'가 포함되어 있습니다. 테스트 데이터는 KB에 저장할 수 없습니다.`,
|
|
833
|
+
};
|
|
834
|
+
}
|
|
835
|
+
if (slug.length < 3) {
|
|
836
|
+
return {
|
|
837
|
+
success: false,
|
|
838
|
+
error: `${key}의 sub_key '${slug}'가 너무 짧습니다 (최소 3자). 구체적인 slug를 사용하세요 (예: 'github-seat-reduction').`,
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
const genericSlugs = ['결정사항', '검토된-대안', '변경사항', '결정', '메모', '임시'];
|
|
842
|
+
if (genericSlugs.includes(slug)) {
|
|
843
|
+
return {
|
|
844
|
+
success: false,
|
|
845
|
+
error: `${key}의 sub_key '${slug}'가 너무 일반적입니다. 구체적인 slug를 사용하세요 (예: 'github-seat-reduction').`,
|
|
846
|
+
};
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
// Metadata required fields validation by key type
|
|
850
|
+
{
|
|
851
|
+
const meta = (entry.metadata || {});
|
|
852
|
+
const requiredMetaByKey = {
|
|
853
|
+
decision: {
|
|
854
|
+
fields: ['decided_at', 'decided_by'],
|
|
855
|
+
label: '의사결정',
|
|
856
|
+
},
|
|
857
|
+
incident: {
|
|
858
|
+
fields: ['occurred_at', 'severity', 'status'],
|
|
859
|
+
label: '인시던트',
|
|
860
|
+
},
|
|
861
|
+
};
|
|
862
|
+
const rule = requiredMetaByKey[key];
|
|
863
|
+
if (rule) {
|
|
864
|
+
const missing = rule.fields.filter((f) => !meta[f]);
|
|
865
|
+
if (missing.length > 0) {
|
|
866
|
+
return {
|
|
867
|
+
success: false,
|
|
868
|
+
error: `${rule.label} 엔트리(key='${key}')는 metadata에 [${missing.join(', ')}] 필드가 필수입니다. --metadata '{"${missing[0]}":"..."}' 형태로 전달하세요.`,
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
804
873
|
// Generate embedding (mandatory)
|
|
805
874
|
const fullKey = combineKey(key, subKey);
|
|
806
875
|
const text = `${fullKey}: ${entry.content}`;
|
|
807
876
|
const embedding = await generateEmbedding(text);
|
|
808
877
|
if (!embedding) {
|
|
809
878
|
const reason = process.env.OPENAI_API_KEY
|
|
810
|
-
?
|
|
811
|
-
:
|
|
879
|
+
? '임베딩 생성 API 호출 실패'
|
|
880
|
+
: 'OPENAI_API_KEY가 설정되지 않음';
|
|
812
881
|
return {
|
|
813
882
|
success: false,
|
|
814
883
|
error: `임베딩 생성 실패 — ${reason}. 임베딩 없이 저장하면 벡터 검색에서 누락되므로 저장이 거부됩니다.`,
|
|
815
884
|
};
|
|
816
885
|
}
|
|
817
|
-
const embeddingStr = `[${embedding.join(
|
|
886
|
+
const embeddingStr = `[${embedding.join(',')}]`;
|
|
818
887
|
const writeClient = await pool.connect();
|
|
819
888
|
try {
|
|
820
889
|
await writeClient.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
|
|
@@ -828,10 +897,41 @@ async function kbUpsert(pool, entry) {
|
|
|
828
897
|
subKey,
|
|
829
898
|
entry.content,
|
|
830
899
|
JSON.stringify(entry.metadata || {}),
|
|
831
|
-
entry.created_by ||
|
|
900
|
+
entry.created_by || 'semo-cli',
|
|
832
901
|
embeddingStr,
|
|
833
902
|
]);
|
|
834
|
-
|
|
903
|
+
// KB→DB 동기화: Dashboard API에 위임 (파서 단일화)
|
|
904
|
+
if ((key === 'kpi' || key === 'action-item') && subKey) {
|
|
905
|
+
try {
|
|
906
|
+
const baseUrl = process.env.DASHBOARD_URL ||
|
|
907
|
+
process.env.NEXT_PUBLIC_BASE_URL ||
|
|
908
|
+
'https://semo.semi-colon.space';
|
|
909
|
+
const syncRes = await fetch(`${baseUrl}/api/kb-sync`, {
|
|
910
|
+
method: 'POST',
|
|
911
|
+
headers: { 'Content-Type': 'application/json' },
|
|
912
|
+
body: JSON.stringify({
|
|
913
|
+
domain: entry.domain,
|
|
914
|
+
key,
|
|
915
|
+
sub_key: subKey,
|
|
916
|
+
content: entry.content,
|
|
917
|
+
}),
|
|
918
|
+
});
|
|
919
|
+
if (syncRes.ok) {
|
|
920
|
+
const syncData = await syncRes.json();
|
|
921
|
+
if (syncData.synced > 0) {
|
|
922
|
+
console.error(` ↳ DB sync: ${syncData.synced} records`);
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
console.error(`[kb] ⚠️ DB sync failed: ${syncRes.status}`);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
catch (wtErr) {
|
|
930
|
+
// sync 실패는 KB 성공에 영향 없음 — 로깅만
|
|
931
|
+
console.error(`[kb] ⚠️ DB sync 실패 (KB 저장은 정상): ${wtErr}`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
return { success: true, warnings: warnings.length > 0 ? warnings : undefined };
|
|
835
935
|
}
|
|
836
936
|
catch (err) {
|
|
837
937
|
return { success: false, error: String(err) };
|
|
@@ -951,17 +1051,19 @@ async function ontoRegister(pool, opts) {
|
|
|
951
1051
|
const client = await pool.connect();
|
|
952
1052
|
try {
|
|
953
1053
|
// 1. Validate entity_type
|
|
954
|
-
const typeCheck = await client.query(
|
|
1054
|
+
const typeCheck = await client.query('SELECT type_key FROM semo.ontology_types WHERE type_key = $1', [opts.entity_type]);
|
|
955
1055
|
if (typeCheck.rows.length === 0) {
|
|
956
|
-
const known = await client.query(
|
|
1056
|
+
const known = await client.query('SELECT type_key FROM semo.ontology_types ORDER BY type_key');
|
|
957
1057
|
const knownTypes = known.rows.map((r) => r.type_key);
|
|
958
1058
|
return {
|
|
959
1059
|
success: false,
|
|
960
|
-
error: `타입 '${opts.entity_type}'은(는) 존재하지 않습니다. 사용 가능한 타입: [${knownTypes.join(
|
|
1060
|
+
error: `타입 '${opts.entity_type}'은(는) 존재하지 않습니다. 사용 가능한 타입: [${knownTypes.join(', ')}]`,
|
|
961
1061
|
};
|
|
962
1062
|
}
|
|
963
1063
|
// 2. Check domain doesn't already exist
|
|
964
|
-
const existCheck = await client.query(
|
|
1064
|
+
const existCheck = await client.query('SELECT domain FROM semo.ontology WHERE domain = $1', [
|
|
1065
|
+
opts.domain,
|
|
1066
|
+
]);
|
|
965
1067
|
if (existCheck.rows.length > 0) {
|
|
966
1068
|
return { success: false, error: `도메인 '${opts.domain}'은(는) 이미 등록되어 있습니다.` };
|
|
967
1069
|
}
|
|
@@ -971,7 +1073,7 @@ async function ontoRegister(pool, opts) {
|
|
|
971
1073
|
opts.domain,
|
|
972
1074
|
opts.entity_type,
|
|
973
1075
|
opts.description || null,
|
|
974
|
-
opts.service ||
|
|
1076
|
+
opts.service || '_global',
|
|
975
1077
|
opts.tags || [opts.entity_type],
|
|
976
1078
|
JSON.stringify({}),
|
|
977
1079
|
]);
|
|
@@ -984,19 +1086,17 @@ async function ontoRegister(pool, opts) {
|
|
|
984
1086
|
WHERE type_key = $1 AND required = true
|
|
985
1087
|
ORDER BY sort_order`, [opts.entity_type]);
|
|
986
1088
|
for (const s of schemaResult.rows) {
|
|
987
|
-
if (s.key_type ===
|
|
1089
|
+
if (s.key_type === 'collection')
|
|
988
1090
|
continue; // collection은 sub_key가 필요하므로 스킵
|
|
989
|
-
const placeholder = s.value_hint
|
|
990
|
-
? `(미입력 — hint: ${s.value_hint})`
|
|
991
|
-
: `(미입력)`;
|
|
1091
|
+
const placeholder = s.value_hint ? `(미입력 — hint: ${s.value_hint})` : `(미입력)`;
|
|
992
1092
|
const text = `${s.scheme_key}: ${placeholder}`;
|
|
993
1093
|
const embedding = await generateEmbedding(text);
|
|
994
|
-
const embeddingStr = embedding ? `[${embedding.join(
|
|
1094
|
+
const embeddingStr = embedding ? `[${embedding.join(',')}]` : null;
|
|
995
1095
|
try {
|
|
996
1096
|
await client.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
|
|
997
1097
|
VALUES ($1, $2, '', $3, '{}', 'semo-cli:onto-register', $4::vector)
|
|
998
1098
|
ON CONFLICT (domain, key, sub_key) DO NOTHING`, [opts.domain, s.scheme_key, placeholder, embeddingStr]);
|
|
999
|
-
createdEntries.push({ key: s.scheme_key, sub_key:
|
|
1099
|
+
createdEntries.push({ key: s.scheme_key, sub_key: '' });
|
|
1000
1100
|
}
|
|
1001
1101
|
catch {
|
|
1002
1102
|
// 개별 entry 실패는 무시 — 도메인 등록 자체는 성공
|
|
@@ -1021,12 +1121,12 @@ async function ontoUnregister(pool, domain, force) {
|
|
|
1021
1121
|
const client = await pool.connect();
|
|
1022
1122
|
try {
|
|
1023
1123
|
// 1. Check domain exists
|
|
1024
|
-
const domainCheck = await client.query(
|
|
1124
|
+
const domainCheck = await client.query('SELECT domain, entity_type, service FROM semo.ontology WHERE domain = $1', [domain]);
|
|
1025
1125
|
if (domainCheck.rows.length === 0) {
|
|
1026
1126
|
return { success: false, error: `도메인 '${domain}'은(는) 존재하지 않습니다.` };
|
|
1027
1127
|
}
|
|
1028
1128
|
// 2. Count KB entries
|
|
1029
|
-
const countResult = await client.query(
|
|
1129
|
+
const countResult = await client.query('SELECT COUNT(*)::int AS cnt FROM semo.knowledge_base WHERE domain = $1', [domain]);
|
|
1030
1130
|
const entryCount = countResult.rows[0].cnt;
|
|
1031
1131
|
// 3. If entries exist and no force → error
|
|
1032
1132
|
if (entryCount > 0 && !force) {
|
|
@@ -1036,19 +1136,21 @@ async function ontoUnregister(pool, domain, force) {
|
|
|
1036
1136
|
};
|
|
1037
1137
|
}
|
|
1038
1138
|
// 4. Transaction: delete KB entries (if any) → delete ontology
|
|
1039
|
-
await client.query(
|
|
1139
|
+
await client.query('BEGIN');
|
|
1040
1140
|
try {
|
|
1041
1141
|
let deletedEntries = 0;
|
|
1042
1142
|
if (entryCount > 0) {
|
|
1043
|
-
const delResult = await client.query(
|
|
1143
|
+
const delResult = await client.query('DELETE FROM semo.knowledge_base WHERE domain = $1', [
|
|
1144
|
+
domain,
|
|
1145
|
+
]);
|
|
1044
1146
|
deletedEntries = delResult.rowCount ?? 0;
|
|
1045
1147
|
}
|
|
1046
|
-
await client.query(
|
|
1047
|
-
await client.query(
|
|
1148
|
+
await client.query('DELETE FROM semo.ontology WHERE domain = $1', [domain]);
|
|
1149
|
+
await client.query('COMMIT');
|
|
1048
1150
|
return { success: true, deleted_entries: deletedEntries };
|
|
1049
1151
|
}
|
|
1050
1152
|
catch (err) {
|
|
1051
|
-
await client.query(
|
|
1153
|
+
await client.query('ROLLBACK');
|
|
1052
1154
|
return { success: false, error: String(err) };
|
|
1053
1155
|
}
|
|
1054
1156
|
}
|
|
@@ -1064,32 +1166,38 @@ async function ontoAddKey(pool, opts) {
|
|
|
1064
1166
|
try {
|
|
1065
1167
|
// Naming convention: kebab-case only
|
|
1066
1168
|
if (/_/.test(opts.scheme_key)) {
|
|
1067
|
-
const suggested = opts.scheme_key.replace(/_/g,
|
|
1068
|
-
return {
|
|
1169
|
+
const suggested = opts.scheme_key.replace(/_/g, '-');
|
|
1170
|
+
return {
|
|
1171
|
+
success: false,
|
|
1172
|
+
error: `키 '${opts.scheme_key}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'`,
|
|
1173
|
+
};
|
|
1069
1174
|
}
|
|
1070
1175
|
// Check type exists
|
|
1071
|
-
const typeCheck = await client.query(
|
|
1176
|
+
const typeCheck = await client.query('SELECT DISTINCT type_key FROM semo.kb_type_schema WHERE type_key = $1', [opts.type_key]);
|
|
1072
1177
|
if (typeCheck.rows.length === 0) {
|
|
1073
1178
|
// Check if this type exists in ontology at all
|
|
1074
|
-
const ontoCheck = await client.query(
|
|
1179
|
+
const ontoCheck = await client.query('SELECT DISTINCT entity_type FROM semo.ontology WHERE entity_type = $1', [opts.type_key]);
|
|
1075
1180
|
if (ontoCheck.rows.length === 0) {
|
|
1076
1181
|
return { success: false, error: `타입 '${opts.type_key}'이(가) 존재하지 않습니다.` };
|
|
1077
1182
|
}
|
|
1078
1183
|
}
|
|
1079
1184
|
// Check duplicate
|
|
1080
|
-
const dupCheck = await client.query(
|
|
1185
|
+
const dupCheck = await client.query('SELECT id FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2', [opts.type_key, opts.scheme_key]);
|
|
1081
1186
|
if (dupCheck.rows.length > 0) {
|
|
1082
|
-
return {
|
|
1187
|
+
return {
|
|
1188
|
+
success: false,
|
|
1189
|
+
error: `키 '${opts.scheme_key}'은(는) '${opts.type_key}' 타입에 이미 존재합니다.`,
|
|
1190
|
+
};
|
|
1083
1191
|
}
|
|
1084
1192
|
// Get max sort_order
|
|
1085
|
-
const maxOrder = await client.query(
|
|
1193
|
+
const maxOrder = await client.query('SELECT COALESCE(MAX(sort_order), 0) + 10 as next_order FROM semo.kb_type_schema WHERE type_key = $1', [opts.type_key]);
|
|
1086
1194
|
const sortOrder = maxOrder.rows[0].next_order;
|
|
1087
1195
|
await client.query(`INSERT INTO semo.kb_type_schema (type_key, scheme_key, scheme_description, key_type, required, value_hint, sort_order)
|
|
1088
1196
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`, [
|
|
1089
1197
|
opts.type_key,
|
|
1090
1198
|
opts.scheme_key,
|
|
1091
1199
|
opts.description || opts.scheme_key,
|
|
1092
|
-
opts.key_type ||
|
|
1200
|
+
opts.key_type || 'singleton',
|
|
1093
1201
|
opts.required || false,
|
|
1094
1202
|
opts.value_hint || null,
|
|
1095
1203
|
sortOrder,
|
|
@@ -1109,21 +1217,20 @@ async function ontoAddKey(pool, opts) {
|
|
|
1109
1217
|
async function ontoCreateType(pool, opts) {
|
|
1110
1218
|
// kebab-case 검증
|
|
1111
1219
|
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(opts.type_key)) {
|
|
1112
|
-
return {
|
|
1220
|
+
return {
|
|
1221
|
+
success: false,
|
|
1222
|
+
error: `타입 키 '${opts.type_key}'이(가) 유효하지 않습니다. kebab-case 소문자만 사용 가능 (예: my-type)`,
|
|
1223
|
+
};
|
|
1113
1224
|
}
|
|
1114
1225
|
const client = await pool.connect();
|
|
1115
1226
|
try {
|
|
1116
1227
|
// 중복 확인
|
|
1117
|
-
const dupCheck = await client.query(
|
|
1228
|
+
const dupCheck = await client.query('SELECT type_key FROM semo.ontology_types WHERE type_key = $1', [opts.type_key]);
|
|
1118
1229
|
if (dupCheck.rows.length > 0) {
|
|
1119
1230
|
return { success: false, error: `타입 '${opts.type_key}'은(는) 이미 존재합니다.` };
|
|
1120
1231
|
}
|
|
1121
1232
|
await client.query(`INSERT INTO semo.ontology_types (type_key, schema, description)
|
|
1122
|
-
VALUES ($1, $2, $3)`, [
|
|
1123
|
-
opts.type_key,
|
|
1124
|
-
JSON.stringify(opts.schema || {}),
|
|
1125
|
-
opts.description || opts.type_key,
|
|
1126
|
-
]);
|
|
1233
|
+
VALUES ($1, $2, $3)`, [opts.type_key, JSON.stringify(opts.schema || {}), opts.description || opts.type_key]);
|
|
1127
1234
|
return { success: true };
|
|
1128
1235
|
}
|
|
1129
1236
|
catch (err) {
|
|
@@ -1139,9 +1246,12 @@ async function ontoCreateType(pool, opts) {
|
|
|
1139
1246
|
async function ontoRemoveKey(pool, typeKey, schemeKey) {
|
|
1140
1247
|
const client = await pool.connect();
|
|
1141
1248
|
try {
|
|
1142
|
-
const result = await client.query(
|
|
1249
|
+
const result = await client.query('DELETE FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2 RETURNING id', [typeKey, schemeKey]);
|
|
1143
1250
|
if (result.rows.length === 0) {
|
|
1144
|
-
return {
|
|
1251
|
+
return {
|
|
1252
|
+
success: false,
|
|
1253
|
+
error: `키 '${schemeKey}'은(는) '${typeKey}' 타입에 존재하지 않습니다.`,
|
|
1254
|
+
};
|
|
1145
1255
|
}
|
|
1146
1256
|
return { success: true };
|
|
1147
1257
|
}
|
|
@@ -1155,7 +1265,7 @@ async function ontoRemoveKey(pool, typeKey, schemeKey) {
|
|
|
1155
1265
|
async function ontoPullToLocal(pool, cwd) {
|
|
1156
1266
|
const domains = await ontoList(pool);
|
|
1157
1267
|
const kbDir = ensureKBDir(cwd);
|
|
1158
|
-
const ontoDir = path.join(kbDir,
|
|
1268
|
+
const ontoDir = path.join(kbDir, 'ontology');
|
|
1159
1269
|
for (const d of domains) {
|
|
1160
1270
|
fs.writeFileSync(path.join(ontoDir, `${d.domain}.json`), JSON.stringify(d, null, 2));
|
|
1161
1271
|
}
|