@team-semicolon/semo-cli 4.3.0 → 4.4.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
@@ -54,9 +54,24 @@ exports.ontoShow = ontoShow;
54
54
  exports.ontoListTypes = ontoListTypes;
55
55
  exports.ontoValidate = ontoValidate;
56
56
  exports.kbDigest = kbDigest;
57
+ exports.kbGet = kbGet;
58
+ exports.kbUpsert = kbUpsert;
59
+ exports.ontoListSchema = ontoListSchema;
60
+ exports.ontoRoutingTable = ontoRoutingTable;
61
+ exports.ontoListServices = ontoListServices;
62
+ exports.ontoListInstances = ontoListInstances;
57
63
  exports.ontoPullToLocal = ontoPullToLocal;
58
64
  const fs = __importStar(require("fs"));
59
65
  const path = __importStar(require("path"));
66
+ function splitKey(combinedKey) {
67
+ const idx = combinedKey.indexOf('/');
68
+ if (idx === -1)
69
+ return { key: combinedKey, subKey: '' };
70
+ return { key: combinedKey.substring(0, idx), subKey: combinedKey.substring(idx + 1) };
71
+ }
72
+ function combineKey(key, subKey) {
73
+ return subKey ? `${key}/${subKey}` : key;
74
+ }
60
75
  // ============================================================
61
76
  // Embedding
62
77
  // ============================================================
@@ -185,7 +200,7 @@ async function kbPull(pool, domain, cwd) {
185
200
  const client = await pool.connect();
186
201
  try {
187
202
  let query = `
188
- SELECT domain, key, content, metadata, created_by, version,
203
+ SELECT domain, key, sub_key, content, metadata, created_by, version,
189
204
  created_at::text, updated_at::text
190
205
  FROM semo.knowledge_base
191
206
  `;
@@ -242,12 +257,13 @@ async function kbPush(pool, entries, createdBy, cwd) {
242
257
  try {
243
258
  const embedding = embeddings[i];
244
259
  const embeddingStr = embedding ? `[${embedding.join(",")}]` : null;
245
- await client.query(`INSERT INTO semo.knowledge_base (domain, key, content, metadata, created_by, embedding)
246
- VALUES ($1, $2, $3, $4, $5, $6::vector)
247
- ON CONFLICT (domain, key) DO UPDATE SET
260
+ const { key: flatKey, subKey } = splitKey(entry.key);
261
+ await client.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
262
+ VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
263
+ ON CONFLICT (domain, key, sub_key) DO UPDATE SET
248
264
  content = EXCLUDED.content,
249
265
  metadata = EXCLUDED.metadata,
250
- embedding = EXCLUDED.embedding`, [entry.domain, entry.key, entry.content, JSON.stringify(entry.metadata || {}), entry.created_by || createdBy || "unknown", embeddingStr]);
266
+ embedding = EXCLUDED.embedding`, [entry.domain, flatKey, subKey, entry.content, JSON.stringify(entry.metadata || {}), entry.created_by || createdBy || "unknown", embeddingStr]);
251
267
  upserted++;
252
268
  }
253
269
  catch (err) {
@@ -307,7 +323,7 @@ async function kbList(pool, options) {
307
323
  const limit = options.limit || 50;
308
324
  const offset = options.offset || 0;
309
325
  try {
310
- let query = "SELECT domain, key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base";
326
+ let query = "SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text FROM semo.knowledge_base";
311
327
  const params = [];
312
328
  let paramIdx = 1;
313
329
  if (options.domain) {
@@ -322,7 +338,7 @@ async function kbList(pool, options) {
322
338
  params.push(serviceDomains);
323
339
  }
324
340
  }
325
- query += ` ORDER BY domain, key LIMIT $${paramIdx++} OFFSET $${paramIdx++}`;
341
+ query += ` ORDER BY domain, key, sub_key LIMIT $${paramIdx++} OFFSET $${paramIdx++}`;
326
342
  params.push(limit, offset);
327
343
  const result = await client.query(query, params);
328
344
  return result.rows;
@@ -352,7 +368,7 @@ async function kbSearch(pool, query, options) {
352
368
  const embeddingStr = `[${queryEmbedding.join(",")}]`;
353
369
  // Vector search on shared KB
354
370
  let sql = `
355
- SELECT domain, key, content, metadata, created_by, version, updated_at::text,
371
+ SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text,
356
372
  1 - (embedding <=> $1::vector) as score
357
373
  FROM semo.knowledge_base
358
374
  WHERE embedding IS NOT NULL
@@ -386,7 +402,7 @@ async function kbSearch(pool, query, options) {
386
402
  // Build per-token ILIKE conditions + count matching tokens for scoring
387
403
  const tokenConditions = tokens.map(token => {
388
404
  textParams.push(`%${token}%`);
389
- return `(CASE WHEN content ILIKE $${tIdx} OR key ILIKE $${tIdx++} THEN 1 ELSE 0 END)`;
405
+ return `(CASE WHEN content ILIKE $${tIdx} OR key ILIKE $${tIdx} OR sub_key ILIKE $${tIdx++} THEN 1 ELSE 0 END)`;
390
406
  });
391
407
  // Score = 0.7 base + 0.15 * (matched_tokens / total_tokens), capped at 0.95
392
408
  const matchCountExpr = tokenConditions.length > 0
@@ -394,12 +410,12 @@ async function kbSearch(pool, query, options) {
394
410
  : "0";
395
411
  const scoreExpr = `LEAST(0.95, 0.7 + 0.15 * (${matchCountExpr})::float / ${Math.max(tokens.length, 1)})`;
396
412
  // WHERE: any token matches
397
- const whereTokens = tokens.map((_, i) => `(content ILIKE $${i + 1} OR key ILIKE $${i + 1})`);
413
+ const whereTokens = tokens.map((_, i) => `(content ILIKE $${i + 1} OR key ILIKE $${i + 1} OR sub_key ILIKE $${i + 1})`);
398
414
  const whereClause = whereTokens.length > 0
399
415
  ? whereTokens.join(" OR ")
400
416
  : "FALSE";
401
417
  let textSql = `
402
- SELECT domain, key, content, metadata, created_by, version, updated_at::text,
418
+ SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text,
403
419
  ${scoreExpr} as score
404
420
  FROM semo.knowledge_base
405
421
  WHERE ${whereClause}
@@ -419,10 +435,10 @@ async function kbSearch(pool, query, options) {
419
435
  // Deduplicate by domain/key; if already in semantic results, boost its score
420
436
  const resultMap = new Map();
421
437
  for (const r of results) {
422
- resultMap.set(`${r.domain}/${r.key}`, r);
438
+ resultMap.set(`${r.domain}/${r.key}/${r.sub_key}`, r);
423
439
  }
424
440
  for (const row of textResult.rows) {
425
- const k = `${row.domain}/${row.key}`;
441
+ const k = `${row.domain}/${row.key}/${row.sub_key}`;
426
442
  const existing = resultMap.get(k);
427
443
  if (existing) {
428
444
  // Boost: semantic match + text match = highest relevance
@@ -440,9 +456,9 @@ async function kbSearch(pool, query, options) {
440
456
  catch {
441
457
  // Ultimate fallback: simple ILIKE
442
458
  let sql = `
443
- SELECT domain, key, content, metadata, created_by, version, updated_at::text
459
+ SELECT domain, key, sub_key, content, metadata, created_by, version, updated_at::text
444
460
  FROM semo.knowledge_base
445
- WHERE content ILIKE $1 OR key ILIKE $1
461
+ WHERE content ILIKE $1 OR key ILIKE $1 OR sub_key ILIKE $1
446
462
  `;
447
463
  const params = [`%${query}%`];
448
464
  let paramIdx = 2;
@@ -603,7 +619,7 @@ async function kbDigest(pool, since, domain) {
603
619
  const generatedAt = new Date().toISOString();
604
620
  try {
605
621
  let sql = `
606
- SELECT domain, key, content, version, updated_at::text,
622
+ SELECT domain, key, sub_key, content, version, updated_at::text,
607
623
  CASE WHEN created_at > $1 THEN 'new' ELSE 'updated' END as change_type
608
624
  FROM semo.knowledge_base
609
625
  WHERE updated_at > $1 OR created_at > $1
@@ -622,6 +638,239 @@ async function kbDigest(pool, since, domain) {
622
638
  client.release();
623
639
  }
624
640
  }
641
+ // ============================================================
642
+ // KB Get / Upsert (CLI counterparts of MCP kb functions)
643
+ // ============================================================
644
+ /**
645
+ * Get a single KB entry by domain + key + sub_key
646
+ */
647
+ async function kbGet(pool, domain, rawKey, rawSubKey) {
648
+ let key;
649
+ let subKey;
650
+ if (rawSubKey !== undefined) {
651
+ key = rawKey;
652
+ subKey = rawSubKey;
653
+ }
654
+ else {
655
+ const split = splitKey(rawKey);
656
+ key = split.key;
657
+ subKey = split.subKey;
658
+ }
659
+ const client = await pool.connect();
660
+ try {
661
+ const result = await client.query(`SELECT domain, key, sub_key, content, metadata, created_by, version,
662
+ created_at::text, updated_at::text
663
+ FROM semo.knowledge_base
664
+ WHERE domain = $1 AND key = $2 AND sub_key = $3`, [domain, key, subKey]);
665
+ return result.rows[0] || null;
666
+ }
667
+ finally {
668
+ client.release();
669
+ }
670
+ }
671
+ /**
672
+ * Upsert a single KB entry with domain/key validation and embedding generation
673
+ */
674
+ async function kbUpsert(pool, entry) {
675
+ let key;
676
+ let subKey;
677
+ if (entry.sub_key !== undefined) {
678
+ key = entry.key;
679
+ subKey = entry.sub_key;
680
+ }
681
+ else {
682
+ const split = splitKey(entry.key);
683
+ key = split.key;
684
+ subKey = split.subKey;
685
+ }
686
+ // Domain validation
687
+ const client = await pool.connect();
688
+ try {
689
+ const ontoCheck = await client.query("SELECT domain FROM semo.ontology WHERE domain = $1", [entry.domain]);
690
+ if (ontoCheck.rows.length === 0) {
691
+ const known = await client.query("SELECT domain FROM semo.ontology ORDER BY domain");
692
+ const knownDomains = known.rows.map((r) => r.domain);
693
+ return {
694
+ success: false,
695
+ error: `도메인 '${entry.domain}'은(는) 온톨로지에 등록되지 않았습니다. 등록된 도메인: [${knownDomains.join(", ")}]`,
696
+ };
697
+ }
698
+ }
699
+ finally {
700
+ client.release();
701
+ }
702
+ // Key validation against type schema
703
+ try {
704
+ const schemaClient = await pool.connect();
705
+ try {
706
+ const typeResult = await schemaClient.query("SELECT entity_type FROM semo.ontology WHERE domain = $1 AND entity_type IS NOT NULL", [entry.domain]);
707
+ if (typeResult.rows.length > 0) {
708
+ const entityType = typeResult.rows[0].entity_type;
709
+ 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]);
710
+ const schemas = schemaResult.rows;
711
+ if (schemas.length > 0) {
712
+ const match = schemas.find(s => s.scheme_key === key);
713
+ if (!match) {
714
+ const allowedKeys = schemas.map(s => s.key_type === "singleton" ? s.scheme_key : `${s.scheme_key}/{sub_key}`);
715
+ return {
716
+ success: false,
717
+ error: `키 '${key}'은(는) '${entityType}' 타입의 스키마에 허용되지 않습니다. 허용 키: [${allowedKeys.join(", ")}]`,
718
+ };
719
+ }
720
+ if (match.key_type === "singleton" && subKey !== "") {
721
+ return { success: false, error: `키 '${key}'은(는) singleton이므로 sub_key가 비어야 합니다.` };
722
+ }
723
+ if (match.key_type === "collection" && subKey === "") {
724
+ return { success: false, error: `키 '${key}'은(는) collection이므로 sub_key가 필요합니다.` };
725
+ }
726
+ }
727
+ }
728
+ }
729
+ finally {
730
+ schemaClient.release();
731
+ }
732
+ }
733
+ catch {
734
+ // Validation failure is non-fatal
735
+ }
736
+ // Generate embedding (mandatory)
737
+ const fullKey = combineKey(key, subKey);
738
+ const text = `${fullKey}: ${entry.content}`;
739
+ const embedding = await generateEmbedding(text);
740
+ if (!embedding) {
741
+ const reason = process.env.OPENAI_API_KEY
742
+ ? "임베딩 생성 API 호출 실패"
743
+ : "OPENAI_API_KEY가 설정되지 않음";
744
+ return {
745
+ success: false,
746
+ error: `임베딩 생성 실패 — ${reason}. 임베딩 없이 저장하면 벡터 검색에서 누락되므로 저장이 거부됩니다.`,
747
+ };
748
+ }
749
+ const embeddingStr = `[${embedding.join(",")}]`;
750
+ const writeClient = await pool.connect();
751
+ try {
752
+ await writeClient.query(`INSERT INTO semo.knowledge_base (domain, key, sub_key, content, metadata, created_by, embedding)
753
+ VALUES ($1, $2, $3, $4, $5, $6, $7::vector)
754
+ ON CONFLICT (domain, key, sub_key) DO UPDATE SET
755
+ content = EXCLUDED.content,
756
+ metadata = EXCLUDED.metadata,
757
+ embedding = EXCLUDED.embedding`, [
758
+ entry.domain,
759
+ key,
760
+ subKey,
761
+ entry.content,
762
+ JSON.stringify(entry.metadata || {}),
763
+ entry.created_by || "semo-cli",
764
+ embeddingStr,
765
+ ]);
766
+ return { success: true };
767
+ }
768
+ catch (err) {
769
+ return { success: false, error: String(err) };
770
+ }
771
+ finally {
772
+ writeClient.release();
773
+ }
774
+ }
775
+ /**
776
+ * List type schema entries for a given entity type
777
+ */
778
+ async function ontoListSchema(pool, typeKey) {
779
+ const client = await pool.connect();
780
+ try {
781
+ const result = await client.query(`SELECT type_key, scheme_key, scheme_description, required, value_hint, sort_order,
782
+ COALESCE(key_type, 'singleton') as key_type
783
+ FROM semo.kb_type_schema
784
+ WHERE type_key = $1
785
+ ORDER BY sort_order, scheme_key`, [typeKey]);
786
+ return result.rows;
787
+ }
788
+ catch {
789
+ return [];
790
+ }
791
+ finally {
792
+ client.release();
793
+ }
794
+ }
795
+ /**
796
+ * Full domain→key routing table for bot auto-classification
797
+ */
798
+ async function ontoRoutingTable(pool) {
799
+ const client = await pool.connect();
800
+ try {
801
+ const result = await client.query(`
802
+ SELECT
803
+ o.domain,
804
+ o.entity_type,
805
+ o.service,
806
+ o.description AS domain_description,
807
+ s.scheme_key,
808
+ s.key_type,
809
+ s.scheme_description,
810
+ s.value_hint
811
+ FROM semo.ontology o
812
+ JOIN semo.kb_type_schema s ON s.type_key = o.entity_type
813
+ ORDER BY o.domain, s.sort_order
814
+ `);
815
+ return result.rows;
816
+ }
817
+ finally {
818
+ client.release();
819
+ }
820
+ }
821
+ /**
822
+ * List services grouped with domain counts
823
+ */
824
+ async function ontoListServices(pool) {
825
+ const client = await pool.connect();
826
+ try {
827
+ const result = await client.query(`
828
+ SELECT service, COUNT(*)::int as domain_count,
829
+ ARRAY_AGG(domain ORDER BY domain) as domains
830
+ FROM semo.ontology
831
+ WHERE service IS NOT NULL
832
+ GROUP BY service
833
+ ORDER BY service
834
+ `);
835
+ return result.rows;
836
+ }
837
+ catch {
838
+ return [];
839
+ }
840
+ finally {
841
+ client.release();
842
+ }
843
+ }
844
+ /**
845
+ * List service instances (entity_type = 'service')
846
+ */
847
+ async function ontoListInstances(pool) {
848
+ const client = await pool.connect();
849
+ try {
850
+ const result = await client.query(`
851
+ SELECT o.domain, o.description, o.service, o.tags,
852
+ COALESCE(
853
+ (SELECT ARRAY_AGG(o2.domain ORDER BY o2.domain)
854
+ FROM semo.ontology o2
855
+ WHERE o2.service = o.service AND o2.domain != o.domain),
856
+ '{}'
857
+ ) as scoped_domains,
858
+ (SELECT COUNT(*)::int FROM semo.knowledge_base k
859
+ WHERE k.domain = o.domain
860
+ OR k.domain LIKE o.service || '.%') as entry_count
861
+ FROM semo.ontology o
862
+ WHERE o.entity_type = 'service'
863
+ ORDER BY o.domain
864
+ `);
865
+ return result.rows;
866
+ }
867
+ catch {
868
+ return [];
869
+ }
870
+ finally {
871
+ client.release();
872
+ }
873
+ }
625
874
  /**
626
875
  * Write ontology schemas to local cache
627
876
  */
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Slack 알림 유틸리티
3
+ *
4
+ * SLACK_WEBHOOK 환경변수에서 URL을 읽어 알림 전송.
5
+ * ~/.semo.env에서 자동 로드됨 (database.ts loadSemoEnv).
6
+ */
7
+ export declare function sendSlackNotification(message: string, webhookUrl?: string): Promise<boolean>;
8
+ export declare function formatTestFailureMessage(suiteId: string, runId: string, pass: number, fail: number, warn: number, failedLabels: string[]): string;
@@ -0,0 +1,45 @@
1
+ "use strict";
2
+ /**
3
+ * Slack 알림 유틸리티
4
+ *
5
+ * SLACK_WEBHOOK 환경변수에서 URL을 읽어 알림 전송.
6
+ * ~/.semo.env에서 자동 로드됨 (database.ts loadSemoEnv).
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
9
+ exports.sendSlackNotification = sendSlackNotification;
10
+ exports.formatTestFailureMessage = formatTestFailureMessage;
11
+ async function sendSlackNotification(message, webhookUrl) {
12
+ const url = webhookUrl || process.env.SLACK_WEBHOOK;
13
+ if (!url)
14
+ return false;
15
+ try {
16
+ const res = await fetch(url, {
17
+ method: "POST",
18
+ headers: { "Content-Type": "application/json" },
19
+ body: JSON.stringify({ text: message }),
20
+ signal: AbortSignal.timeout(10000),
21
+ });
22
+ return res.ok;
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function formatTestFailureMessage(suiteId, runId, pass, fail, warn, failedLabels) {
29
+ const timestamp = new Date().toLocaleString("ko-KR", {
30
+ timeZone: "Asia/Seoul",
31
+ });
32
+ const topFailed = failedLabels.slice(0, 10);
33
+ const more = failedLabels.length > 10 ? `\n ... +${failedLabels.length - 10}건` : "";
34
+ return [
35
+ `🚨 *Test Suite Failed* — ${suiteId}`,
36
+ `Run: \`${runId.substring(0, 8)}\` | ${timestamp}`,
37
+ `Pass: ${pass} | Fail: ${fail} | Warn: ${warn}`,
38
+ ``,
39
+ `Failed:`,
40
+ ...topFailed.map((l) => ` • ${l}`),
41
+ more,
42
+ ]
43
+ .filter(Boolean)
44
+ .join("\n");
45
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Declarative Workspace Audit Runner
3
+ *
4
+ * bot_workspace_standard 테이블에서 규칙을 로드하고,
5
+ * bot_status에서 봇 목록을 가져와 동적으로 TC를 생성·실행.
6
+ *
7
+ * 로컬 스크립트 의존성 없음 — DB가 SoT.
8
+ */
9
+ import { Pool } from "pg";
10
+ export interface TestOutputLine {
11
+ type: "case" | "summary";
12
+ id?: string;
13
+ status?: "pass" | "fail" | "warn" | "skip";
14
+ label?: string;
15
+ detail?: string;
16
+ }
17
+ export declare function runDeclarativeWorkspaceAudit(pool: Pool): Promise<TestOutputLine[]>;