@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/kb.js CHANGED
@@ -81,7 +81,7 @@ function combineKey(key, subKey) {
81
81
  // ============================================================
82
82
  // Embedding
83
83
  // ============================================================
84
- const EMBEDDING_MODEL = "text-embedding-3-small";
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("https://api.openai.com/v1/embeddings", {
96
- method: "POST",
95
+ const response = await fetch('https://api.openai.com/v1/embeddings', {
96
+ method: 'POST',
97
97
  headers: {
98
- "Authorization": `Bearer ${apiKey}`,
99
- "Content-Type": "application/json",
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("https://api.openai.com/v1/embeddings", {
129
- method: "POST",
128
+ const response = await fetch('https://api.openai.com/v1/embeddings', {
129
+ method: 'POST',
130
130
  headers: {
131
- "Authorization": `Bearer ${apiKey}`,
132
- "Content-Type": "application/json",
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 = ".kb";
153
- const SYNC_STATE_FILE = ".sync-state.json";
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, "ontology");
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, "utf-8"));
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, "utf-8"));
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 += " WHERE domain = $1";
215
+ query += ' WHERE domain = $1';
216
216
  params.push(domain);
217
217
  }
218
- query += " ORDER BY domain, key";
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, "team.json", entries);
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("SELECT domain FROM semo.ontology");
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("BEGIN");
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(",")}]` : null;
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`, [entry.domain, flatKey, subKey, entry.content, JSON.stringify(entry.metadata || {}), entry.created_by || createdBy || "unknown", embeddingStr]);
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("COMMIT");
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("ROLLBACK");
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 = "SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base";
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 || "hybrid";
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 !== "text") {
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 === "semantic") {
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 !== "semantic" || results.length === 0) {
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: "*", errors: [`Ontology domain '${domain}' not found`] }] };
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 === "string" && typeof val !== "string") {
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 === "array" && !Array.isArray(val)) {
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("SELECT domain FROM semo.ontology WHERE domain = $1", [entry.domain]);
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("SELECT domain FROM semo.ontology ORDER BY domain");
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("SELECT entity_type FROM semo.ontology WHERE domain = $1 AND entity_type IS NOT NULL", [entry.domain]);
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 === "singleton" ? s.scheme_key : `${s.scheme_key}/{sub_key}`);
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
- if (match.key_type === "singleton" && subKey !== "") {
788
- return { success: false, error: `키 '${key}'은(는) singleton이므로 sub_key가 비어야 합니다.` };
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 === "collection" && subKey === "") {
791
- return { success: false, error: `키 '${key}'은(는) collection이므로 sub_key가 필요합니다.` };
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
- ? "임베딩 생성 API 호출 실패"
811
- : "OPENAI_API_KEY가 설정되지 않음";
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 || "semo-cli",
900
+ entry.created_by || 'semo-cli',
832
901
  embeddingStr,
833
902
  ]);
834
- return { success: true };
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("SELECT type_key FROM semo.ontology_types WHERE type_key = $1", [opts.entity_type]);
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("SELECT type_key FROM semo.ontology_types ORDER BY type_key");
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("SELECT domain FROM semo.ontology WHERE domain = $1", [opts.domain]);
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 || "_global",
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 === "collection")
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(",")}]` : null;
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("SELECT domain, entity_type, service FROM semo.ontology WHERE domain = $1", [domain]);
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("SELECT COUNT(*)::int AS cnt FROM semo.knowledge_base WHERE domain = $1", [domain]);
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("BEGIN");
1139
+ await client.query('BEGIN');
1040
1140
  try {
1041
1141
  let deletedEntries = 0;
1042
1142
  if (entryCount > 0) {
1043
- const delResult = await client.query("DELETE FROM semo.knowledge_base WHERE domain = $1", [domain]);
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("DELETE FROM semo.ontology WHERE domain = $1", [domain]);
1047
- await client.query("COMMIT");
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("ROLLBACK");
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 { success: false, error: `키 '${opts.scheme_key}'에 snake_case가 포함되어 있습니다. kebab-case를 사용하세요: '${suggested}'` };
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("SELECT DISTINCT type_key FROM semo.kb_type_schema WHERE type_key = $1", [opts.type_key]);
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("SELECT DISTINCT entity_type FROM semo.ontology WHERE entity_type = $1", [opts.type_key]);
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("SELECT id FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2", [opts.type_key, opts.scheme_key]);
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 { success: false, error: `키 '${opts.scheme_key}'은(는) '${opts.type_key}' 타입에 이미 존재합니다.` };
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("SELECT COALESCE(MAX(sort_order), 0) + 10 as next_order FROM semo.kb_type_schema WHERE type_key = $1", [opts.type_key]);
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 || "singleton",
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 { success: false, error: `타입 키 '${opts.type_key}'이(가) 유효하지 않습니다. kebab-case 소문자만 사용 가능 (예: my-type)` };
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("SELECT type_key FROM semo.ontology_types WHERE type_key = $1", [opts.type_key]);
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("DELETE FROM semo.kb_type_schema WHERE type_key = $1 AND scheme_key = $2 RETURNING id", [typeKey, schemeKey]);
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 { success: false, error: `키 '${schemeKey}'은(는) '${typeKey}' 타입에 존재하지 않습니다.` };
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, "ontology");
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
  }