rag-memory-epf-mcp 3.6.0 → 4.0.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/README.md +56 -4
- package/dist/index.d.ts +47 -4
- package/dist/index.js +584 -124
- package/dist/src/backup/preflight.d.ts +5 -0
- package/dist/src/backup/preflight.js +185 -0
- package/dist/src/migrations/migrations.d.ts +2 -0
- package/dist/src/migrations/migrations.js +110 -0
- package/dist/src/observations/history.d.ts +8 -0
- package/dist/src/observations/history.js +64 -0
- package/dist/src/observations/lifecycle.d.ts +45 -0
- package/dist/src/observations/lifecycle.js +101 -0
- package/dist/src/observations/projection.d.ts +3 -0
- package/dist/src/observations/projection.js +31 -0
- package/dist/src/observations/schema.d.ts +1 -0
- package/dist/src/observations/schema.js +115 -0
- package/dist/src/tools/graph-query-tools.js +29 -5
- package/dist/src/tools/knowledge-graph-tools.d.ts +14 -0
- package/dist/src/tools/knowledge-graph-tools.js +244 -5
- package/dist/src/tools/tool-registry.d.ts +7 -0
- package/dist/src/tools/tool-registry.js +43 -3
- package/dist/src/tools/types.d.ts +1 -0
- package/docs/UPDATING.md +57 -0
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -21,6 +21,10 @@ import modularity from 'graphology-metrics/graph/modularity.js';
|
|
|
21
21
|
import { getAllMCPTools, validateToolArgs, getSystemInfo } from './src/tools/tool-registry.js';
|
|
22
22
|
// Import migration system
|
|
23
23
|
import { MigrationManager } from './src/migrations/migration-manager.js';
|
|
24
|
+
import { backupBeforeMigration } from './src/backup/preflight.js';
|
|
25
|
+
import { rebuildProjection, deleteStaleKgChunks } from './src/observations/projection.js';
|
|
26
|
+
import { addRevision, correctRevision, transitionStatus, linkSources, nextProjectionOrder } from './src/observations/lifecycle.js';
|
|
27
|
+
import { getObservationHistory } from './src/observations/history.js';
|
|
24
28
|
// Import chunk text algorithm (extracted for publish-time invariant testing)
|
|
25
29
|
import { chunkText as splitTextIntoChunks } from './src/chunkText.js';
|
|
26
30
|
import { migrations } from './src/migrations/migrations.js';
|
|
@@ -129,6 +133,9 @@ export class RAGKnowledgeGraphManager {
|
|
|
129
133
|
// v3.6 (spec §3): initialize = DB + migrations + profile only. The embedding
|
|
130
134
|
// model is NEVER awaited here — main() connects the MCP server first and the
|
|
131
135
|
// gate loads in the background (lazy) or is awaited explicitly (eager).
|
|
136
|
+
// __testForceFkOff: 음성 대조군 전용. FK 게이트가 실제로 부팅을 막는지 시험한다.
|
|
137
|
+
// 이름에 __test 를 박아 둔 이유는 이것이 프로덕션 설정 표면이 아니라는 것을
|
|
138
|
+
// 호출부에서 읽히게 하기 위해서다.
|
|
132
139
|
async initialize(opts = {}) {
|
|
133
140
|
console.error('🚀 Initializing RAG Knowledge Graph MCP Server...');
|
|
134
141
|
this.db = new Database(DB_FILE_PATH);
|
|
@@ -140,6 +147,26 @@ export class RAGKnowledgeGraphManager {
|
|
|
140
147
|
this.db.pragma('temp_store = MEMORY');
|
|
141
148
|
this.db.pragma('mmap_size = 268435456');
|
|
142
149
|
this.db.pragma('foreign_keys = ON');
|
|
150
|
+
// spec §5.2: 관찰 lifecycle 의 무결성은 전부 FK CASCADE 를 전제한다 — root 를 지우면
|
|
151
|
+
// revision 이, revision 을 지우면 source 가 따라가야 history 가 고아로 남지 않는다.
|
|
152
|
+
// FK 가 꺼진 채 돌면 그 계약이 조용히 무효가 되고, 그게 최악이다. 스키마를 건드리기
|
|
153
|
+
// 전에(= runMigrations 앞에서) 멈춘다.
|
|
154
|
+
// 트랜잭션 내부에서는 이 pragma 가 no-op 이므로(실측 before=1·during=1·after=1)
|
|
155
|
+
// 부팅 시점 확인이 유일한 방어 지점이다.
|
|
156
|
+
//
|
|
157
|
+
// 음성 대조군 주입은 **인자로만** 받는다. 환경변수로 두면 프로덕션 경로에
|
|
158
|
+
// "부팅을 막는 스위치"가 상시 존재하게 되고, 오설정 한 줄로 서버가 안 뜬다
|
|
159
|
+
// (advisor beta 자기의심 2 = "더 나쁘다"). 테스트는 manager 를 직접 만들므로
|
|
160
|
+
// 인자 주입으로 충분하다.
|
|
161
|
+
if (opts.__testForceFkOff)
|
|
162
|
+
this.db.pragma('foreign_keys = OFF');
|
|
163
|
+
{
|
|
164
|
+
const fk = this.db.pragma('foreign_keys', { simple: true });
|
|
165
|
+
if (Number(fk) !== 1) {
|
|
166
|
+
throw new Error(`foreign_keys is ${fk}, expected 1. The observation lifecycle relies on FK CASCADE ` +
|
|
167
|
+
`for history integrity; refusing to run migrations without it.`);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
143
170
|
this.encoding = get_encoding("cl100k_base");
|
|
144
171
|
await this.runMigrations();
|
|
145
172
|
this.currentProfileId = this.ensureCurrentProfile();
|
|
@@ -322,18 +349,44 @@ export class RAGKnowledgeGraphManager {
|
|
|
322
349
|
// where another tool call can retrieve the pre-mutation vector, and a crash
|
|
323
350
|
// between mutation and re-embed leaves a clean missing state (backfill
|
|
324
351
|
// target), never a stale-searchable one.
|
|
352
|
+
// spec §4.5 단계 1: 관찰 변경 · projection 재합성 · entity vector 무효화 ·
|
|
353
|
+
// stale KG chunk 제거를 한 트랜잭션으로 묶는다. 하나만 되면 검색이 낡은
|
|
354
|
+
// 사실을 계속 반환한다.
|
|
355
|
+
// mutate 가 명시적으로 false 를 반환하면 "아무것도 바꾸지 않았다"는 뜻이고
|
|
356
|
+
// projection 재합성·벡터 무효화·KG 정리를 건너뛴다. 이 경로가 없으면
|
|
357
|
+
// 무변경 upsert 나 dedup-only add 가 **정상 벡터를 지우고 재임베딩도 안 해서**
|
|
358
|
+
// 검색 품질만 깎는다(advisor 구현리뷰 r1 발견 1, 실행 재현).
|
|
359
|
+
// 반환값 = 실제로 변경이 있었는가.
|
|
325
360
|
mutateEntityAndInvalidate(entityId, mutate) {
|
|
361
|
+
let changed = false;
|
|
326
362
|
const tx = this.db.transaction(() => {
|
|
327
|
-
mutate();
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
|
|
333
|
-
}
|
|
363
|
+
changed = mutate() !== false;
|
|
364
|
+
if (!changed)
|
|
365
|
+
return;
|
|
366
|
+
rebuildProjection(this.db, entityId);
|
|
367
|
+
this.invalidateDerivedForEntity(entityId);
|
|
334
368
|
});
|
|
335
369
|
tx();
|
|
336
|
-
|
|
370
|
+
if (changed)
|
|
371
|
+
this.coordinator?.invalidateCoverage();
|
|
372
|
+
return changed;
|
|
373
|
+
}
|
|
374
|
+
// 관찰이 바뀐 entity 의 파생 상태를 무효화한다: entity vector + stale KG chunk.
|
|
375
|
+
// **트랜잭션을 열지 않는다** — 호출자가 이미 하나의 단위 안에 있다고 가정한다.
|
|
376
|
+
//
|
|
377
|
+
// importGraph 가 이 단계를 건너뛰고 있었다: projection 만 재합성하고 파생 상태를
|
|
378
|
+
// 그대로 둬서, 이미 존재하는 entity 를 import 로 덮으면 옛 벡터·옛 KG chunk 가
|
|
379
|
+
// 계속 검색에 나왔다(advisor beta 발견 2, hybridSearch 로 실측 재현).
|
|
380
|
+
// 그래서 "모든 관찰 변경은 mutateEntityAndInvalidate 를 통한다"는 규칙에
|
|
381
|
+
// 예외가 하나 있었고, 그 예외가 정확히 그 규칙이 막으려던 결함을 만들었다.
|
|
382
|
+
invalidateDerivedForEntity(entityId) {
|
|
383
|
+
const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
|
|
384
|
+
.get(entityId);
|
|
385
|
+
if (meta) {
|
|
386
|
+
this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
|
|
387
|
+
this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`).run(entityId);
|
|
388
|
+
}
|
|
389
|
+
deleteStaleKgChunks(this.db, entityId);
|
|
337
390
|
}
|
|
338
391
|
// §6a-1 invariant: when an entity's embedding input changed but re-embedding
|
|
339
392
|
// is unavailable, its old vector must not stay searchable.
|
|
@@ -434,6 +487,11 @@ export class RAGKnowledgeGraphManager {
|
|
|
434
487
|
});
|
|
435
488
|
// Get pending migrations before running them
|
|
436
489
|
const pendingBefore = migrationManager.getPendingMigrations();
|
|
490
|
+
// spec §5.1: 대기 중 마이그레이션이 있으면 먼저 일관 스냅샷을 남긴다.
|
|
491
|
+
// 실패는 throw = fail-closed (백업 없이 스키마를 바꾸지 않는다).
|
|
492
|
+
// await: 백업은 Online Backup API 를 쓰므로 비동기다. 여기서 await 를 빠뜨리면
|
|
493
|
+
// 백업이 끝나기 전에 마이그레이션이 시작한다 = 백업 없이 스키마를 바꾸는 것이다.
|
|
494
|
+
await backupBeforeMigration(this.db, DB_FILE_PATH, pendingBefore.map(m => m.version), migrationManager.getCurrentVersion());
|
|
437
495
|
// Run pending migrations
|
|
438
496
|
const result = await migrationManager.runMigrations();
|
|
439
497
|
console.error(`🔧 Database schema ready (version ${result.currentVersion}, ${result.applied} migrations applied)`);
|
|
@@ -469,45 +527,73 @@ export class RAGKnowledgeGraphManager {
|
|
|
469
527
|
if (!this.db)
|
|
470
528
|
throw new Error('Database not initialized');
|
|
471
529
|
const result = [];
|
|
530
|
+
// v13: 관찰은 lifecycle 테이블이 정본이고 entities.observations 는 projection 이다.
|
|
531
|
+
// entity 행은 빈 배열로 만들고 rebuildProjection 이 채운다.
|
|
472
532
|
const insertStmt = this.db.prepare(`
|
|
473
533
|
INSERT OR IGNORE INTO entities (id, name, entityType, observations, metadata)
|
|
474
|
-
VALUES (?, ?, ?,
|
|
534
|
+
VALUES (?, ?, ?, '[]', ?)
|
|
475
535
|
`);
|
|
536
|
+
const stripDate = (s2) => s2.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
|
|
476
537
|
for (const entity of entities) {
|
|
477
538
|
const entityId = `entity_${entity.name.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
|
|
478
|
-
const
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
539
|
+
const ts = new Date().toISOString();
|
|
540
|
+
const ids = [];
|
|
541
|
+
let created = false;
|
|
542
|
+
let addedCount = 0;
|
|
543
|
+
let typeUpdated = false;
|
|
544
|
+
// entity INSERT 도 같은 트랜잭션 안이다. 밖에 두면 lifecycle INSERT 가
|
|
545
|
+
// 실패할 때 entity 행만 남는 split state 가 생긴다
|
|
546
|
+
// (advisor 구현리뷰 r1 발견 2, 실행 재현).
|
|
547
|
+
const changed = this.mutateEntityAndInvalidate(entityId, () => {
|
|
548
|
+
created = insertStmt.run(entityId, entity.name, entity.entityType, '{}').changes > 0;
|
|
549
|
+
if (!created && entity.entityType && entity.entityType !== 'CONCEPT') {
|
|
550
|
+
const cur = this.db.prepare(`SELECT entityType FROM entities WHERE id = ?`)
|
|
551
|
+
.get(entityId);
|
|
552
|
+
if (cur && cur.entityType !== entity.entityType) {
|
|
553
|
+
this.db.prepare(`UPDATE entities SET entityType = ? WHERE id = ?`)
|
|
554
|
+
.run(entity.entityType, entityId);
|
|
555
|
+
typeUpdated = true;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const activeRows = this.db.prepare(`SELECT observation_id, content FROM entity_observations
|
|
559
|
+
WHERE entity_id = ? AND status = 'active'`).all(entityId);
|
|
560
|
+
const activeByBare = new Map(activeRows.map(r => [stripDate(r.content), r.observation_id]));
|
|
561
|
+
let sourcesAdded = 0;
|
|
562
|
+
for (const raw of (entity.observations || [])) {
|
|
563
|
+
const content = this._timestampObservation(raw);
|
|
564
|
+
const bare = stripDate(content);
|
|
565
|
+
const dupId = activeByBare.get(bare);
|
|
566
|
+
if (dupId) {
|
|
567
|
+
// 같은 사실이 다른 출처에서 다시 왔다 = evidence 추가, 새 revision 아님.
|
|
568
|
+
if (entity.sources?.length)
|
|
569
|
+
sourcesAdded += linkSources(this.db, dupId, entity.sources, ts);
|
|
570
|
+
ids.push(null);
|
|
571
|
+
continue;
|
|
572
|
+
}
|
|
573
|
+
const id = addRevision(this.db, {
|
|
574
|
+
entityId, content, status: entity.status ?? 'active', sources: entity.sources, ts
|
|
575
|
+
});
|
|
576
|
+
activeByBare.set(bare, id);
|
|
577
|
+
ids.push(id);
|
|
578
|
+
addedCount++;
|
|
579
|
+
}
|
|
580
|
+
// 아무것도 안 바뀌었으면 projection·벡터·KG 를 건드리지 않는다.
|
|
581
|
+
return created || typeUpdated || addedCount > 0 || sourcesAdded > 0;
|
|
582
|
+
});
|
|
583
|
+
const projected = JSON.parse(this.db.prepare(`SELECT observations FROM entities WHERE id = ?`)
|
|
584
|
+
.get(entityId).observations);
|
|
585
|
+
// 재임베딩은 무효화가 실제로 일어났을 때만. 조건이 갈리면
|
|
586
|
+
// "벡터를 지우고 다시 만들지 않는" 창이 생긴다.
|
|
587
|
+
if (changed) {
|
|
588
|
+
console.error(created
|
|
589
|
+
? `🔮 Generating embedding for new entity: ${entity.name}`
|
|
590
|
+
: `♻️ Upserted entity: ${entity.name} (+${addedCount} obs${typeUpdated ? ', type→' + entity.entityType : ''})`);
|
|
485
591
|
const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
|
|
486
|
-
result.push({ ...entity, observations:
|
|
592
|
+
result.push({ ...entity, observations: projected, created,
|
|
593
|
+
observation_ids: ids, embedding_status });
|
|
487
594
|
}
|
|
488
595
|
else {
|
|
489
|
-
|
|
490
|
-
const existing = this.db.prepare(`SELECT observations, entityType FROM entities WHERE id = ?`)
|
|
491
|
-
.get(entityId);
|
|
492
|
-
if (existing) {
|
|
493
|
-
const currentObs = JSON.parse(existing.observations);
|
|
494
|
-
// Strip date prefix for dedup comparison
|
|
495
|
-
const stripDate = (s) => s.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
|
|
496
|
-
const currentBare = new Set(currentObs.map(stripDate));
|
|
497
|
-
const newObs = timestamped.filter(o => !currentBare.has(stripDate(o)));
|
|
498
|
-
const needsTypeUpdate = entity.entityType && entity.entityType !== 'CONCEPT' && entity.entityType !== existing.entityType;
|
|
499
|
-
if (newObs.length > 0 || needsTypeUpdate) {
|
|
500
|
-
const mergedObs = [...currentObs, ...newObs];
|
|
501
|
-
const updatedType = needsTypeUpdate ? entity.entityType : existing.entityType;
|
|
502
|
-
this.mutateEntityAndInvalidate(entityId, () => {
|
|
503
|
-
this.db.prepare(`UPDATE entities SET observations = ?, entityType = ? WHERE id = ?`)
|
|
504
|
-
.run(JSON.stringify(mergedObs), updatedType, entityId);
|
|
505
|
-
});
|
|
506
|
-
console.error(`♻️ Upserted entity: ${entity.name} (+${newObs.length} obs${needsTypeUpdate ? ', type→' + updatedType : ''})`);
|
|
507
|
-
const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
|
|
508
|
-
result.push({ ...entity, observations: mergedObs, embedding_status });
|
|
509
|
-
}
|
|
510
|
-
}
|
|
596
|
+
result.push({ ...entity, observations: projected, created, observation_ids: ids });
|
|
511
597
|
}
|
|
512
598
|
}
|
|
513
599
|
return result;
|
|
@@ -551,35 +637,144 @@ export class RAGKnowledgeGraphManager {
|
|
|
551
637
|
const results = [];
|
|
552
638
|
for (const obs of observations) {
|
|
553
639
|
const entityId = `entity_${obs.entityName.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
SELECT observations FROM entities WHERE id = ?
|
|
557
|
-
`).get(entityId);
|
|
558
|
-
if (!entity) {
|
|
640
|
+
const entity = this.db.prepare(`SELECT id FROM entities WHERE id = ?`).get(entityId);
|
|
641
|
+
if (!entity)
|
|
559
642
|
throw new Error(`Entity with name ${obs.entityName} not found`);
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
const
|
|
564
|
-
const
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
643
|
+
// dedup 기준은 v3.6 과 같다: 날짜 prefix 를 뗀 본문이 active 에 이미 있으면
|
|
644
|
+
// 새 revision 을 만들지 않는다. 다만 v13 에서는 같은 사실이 다른 출처에서 다시
|
|
645
|
+
// 온 것이므로 그 revision 에 source link 를 더한다(spec §8.3 T13).
|
|
646
|
+
const stripDate = (s2) => s2.replace(/^\[\d{4}-\d{2}-\d{2}\]\s*/, '');
|
|
647
|
+
const activeRows = this.db.prepare(`SELECT observation_id, content FROM entity_observations
|
|
648
|
+
WHERE entity_id = ? AND status = 'active'`).all(entityId);
|
|
649
|
+
const activeByBare = new Map(activeRows.map(r => [stripDate(r.content), r.observation_id]));
|
|
650
|
+
const ts = new Date().toISOString();
|
|
651
|
+
const ids = [];
|
|
652
|
+
const added = [];
|
|
653
|
+
let sourcesAdded = 0;
|
|
654
|
+
const changed = this.mutateEntityAndInvalidate(entityId, () => {
|
|
655
|
+
for (const raw of obs.contents) {
|
|
656
|
+
const content = this._timestampObservation(raw);
|
|
657
|
+
const bare = stripDate(content);
|
|
658
|
+
const dupId = activeByBare.get(bare);
|
|
659
|
+
if (dupId) {
|
|
660
|
+
if (obs.sources?.length)
|
|
661
|
+
sourcesAdded += linkSources(this.db, dupId, obs.sources, ts);
|
|
662
|
+
ids.push(null);
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
const id = addRevision(this.db, {
|
|
666
|
+
entityId, content, status: obs.status ?? 'active', sources: obs.sources, ts
|
|
667
|
+
});
|
|
668
|
+
activeByBare.set(bare, id);
|
|
669
|
+
ids.push(id);
|
|
670
|
+
added.push(content);
|
|
671
|
+
}
|
|
672
|
+
// 아무것도 안 바뀌었으면 projection·벡터·KG 를 건드리지 않는다.
|
|
673
|
+
// 이 반환이 없으면 빈 contents 나 dedup-only add 가 정상 벡터를
|
|
674
|
+
// 지우고 재임베딩도 안 한다(advisor 구현리뷰 r1 발견 1).
|
|
675
|
+
return added.length > 0 || sourcesAdded > 0;
|
|
676
|
+
});
|
|
677
|
+
let embedding_status;
|
|
678
|
+
if (changed) {
|
|
574
679
|
console.error(`🔮 Regenerating embedding for updated entity: ${obs.entityName}`);
|
|
575
|
-
|
|
576
|
-
results.push({ entityName: obs.entityName, addedObservations: newObservations, embedding_status });
|
|
577
|
-
continue;
|
|
680
|
+
embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
|
|
578
681
|
}
|
|
579
|
-
results.push({ entityName: obs.entityName,
|
|
682
|
+
results.push({ entityName: obs.entityName, observation_ids: ids,
|
|
683
|
+
addedObservations: added, embedding_status });
|
|
580
684
|
}
|
|
581
685
|
return results;
|
|
582
686
|
}
|
|
687
|
+
async correctObservation(observationId, content, changeKind = 'correction', reason) {
|
|
688
|
+
if (!this.db)
|
|
689
|
+
throw new Error('Database not initialized');
|
|
690
|
+
const row = this.db.prepare(`SELECT entity_id FROM entity_observations WHERE observation_id = ?`)
|
|
691
|
+
.get(observationId);
|
|
692
|
+
if (!row)
|
|
693
|
+
throw new Error(`observation ${observationId} not found`);
|
|
694
|
+
let newId = '';
|
|
695
|
+
const ts = new Date().toISOString();
|
|
696
|
+
this.mutateEntityAndInvalidate(row.entity_id, () => {
|
|
697
|
+
newId = correctRevision(this.db, {
|
|
698
|
+
observationId, content: this._timestampObservation(content),
|
|
699
|
+
changeKind, reason: reason ?? null, ts
|
|
700
|
+
});
|
|
701
|
+
});
|
|
702
|
+
await this.tryEmbedEntity(row.entity_id, 'bulk');
|
|
703
|
+
return newId;
|
|
704
|
+
}
|
|
705
|
+
async _transition(observationId, event, reason) {
|
|
706
|
+
if (!this.db)
|
|
707
|
+
throw new Error('Database not initialized');
|
|
708
|
+
const row = this.db.prepare(`SELECT entity_id FROM entity_observations WHERE observation_id = ?`)
|
|
709
|
+
.get(observationId);
|
|
710
|
+
if (!row)
|
|
711
|
+
throw new Error(`observation ${observationId} not found`);
|
|
712
|
+
const ts = new Date().toISOString();
|
|
713
|
+
this.mutateEntityAndInvalidate(row.entity_id, () => {
|
|
714
|
+
transitionStatus(this.db, { observationId, event, reason: reason ?? null, ts });
|
|
715
|
+
});
|
|
716
|
+
await this.tryEmbedEntity(row.entity_id, 'bulk');
|
|
717
|
+
}
|
|
718
|
+
async retractObservation(observationId, reason) {
|
|
719
|
+
return this._transition(observationId, 'retract', reason);
|
|
720
|
+
}
|
|
721
|
+
async restoreObservation(observationId, reason) {
|
|
722
|
+
return this._transition(observationId, 'restore', reason);
|
|
723
|
+
}
|
|
724
|
+
async approveObservation(observationId, reason) {
|
|
725
|
+
return this._transition(observationId, 'approve', reason);
|
|
726
|
+
}
|
|
727
|
+
async declineObservation(observationId, reason) {
|
|
728
|
+
return this._transition(observationId, 'decline', reason);
|
|
729
|
+
}
|
|
730
|
+
// DESTRUCTIVE. Physically removes revisions (and their sources via CASCADE).
|
|
731
|
+
//
|
|
732
|
+
// Chain contract (advisor 구현리뷰 r1 발견 4): a revision chain is
|
|
733
|
+
// rev1 <- rev2 <- ... and purging a middle revision would either fail on the
|
|
734
|
+
// supersedes_id FK or leave a chain pointing at a deleted row, plus events
|
|
735
|
+
// whose from_id/to_id dangle. So purge is defined as **suffix purge from the
|
|
736
|
+
// target to the newest revision of that root**, newest-first:
|
|
737
|
+
// - purging the newest revision removes exactly it
|
|
738
|
+
// - purging rev2 of a 3-revision chain removes rev3 then rev2
|
|
739
|
+
// - purging rev1 removes the whole chain
|
|
740
|
+
// Events for purged revisions are removed too, so no event dangles.
|
|
741
|
+
// The root row is always kept: its projection_order stays reserved, because
|
|
742
|
+
// reusing an order would make a later restore/approve fail on the
|
|
743
|
+
// active-order index.
|
|
744
|
+
async purgeObservation(observationId, confirm) {
|
|
745
|
+
if (!this.db)
|
|
746
|
+
throw new Error('Database not initialized');
|
|
747
|
+
if (confirm !== 'PURGE') {
|
|
748
|
+
throw new Error(`purgeObservation refused: pass confirm='PURGE' to physically delete a revision. ` +
|
|
749
|
+
`This destroys history — retractObservation() is almost always what you want.`);
|
|
750
|
+
}
|
|
751
|
+
const row = this.db.prepare(`SELECT entity_id, root_id, revision_no FROM entity_observations WHERE observation_id = ?`)
|
|
752
|
+
.get(observationId);
|
|
753
|
+
if (!row)
|
|
754
|
+
return { purged: 0 };
|
|
755
|
+
let purged = 0;
|
|
756
|
+
this.mutateEntityAndInvalidate(row.entity_id, () => {
|
|
757
|
+
// newest-first so each DELETE has no successor referencing it
|
|
758
|
+
const victims = this.db.prepare(`SELECT observation_id FROM entity_observations
|
|
759
|
+
WHERE root_id = ? AND revision_no >= ?
|
|
760
|
+
ORDER BY revision_no DESC`).all(row.root_id, row.revision_no);
|
|
761
|
+
for (const v of victims) {
|
|
762
|
+
this.db.prepare(`DELETE FROM observation_events WHERE from_id = ? OR to_id = ?`)
|
|
763
|
+
.run(v.observation_id, v.observation_id);
|
|
764
|
+
purged += this.db.prepare(`DELETE FROM entity_observations WHERE observation_id = ?`)
|
|
765
|
+
.run(v.observation_id).changes;
|
|
766
|
+
}
|
|
767
|
+
return purged > 0;
|
|
768
|
+
});
|
|
769
|
+
await this.tryEmbedEntity(row.entity_id, 'bulk');
|
|
770
|
+
return { purged };
|
|
771
|
+
}
|
|
772
|
+
// spec §6.2: 과거 판본은 여기서만 나온다. 일반 검색은 active 만 반환한다.
|
|
773
|
+
async getObservationHistory(sel) {
|
|
774
|
+
if (!this.db)
|
|
775
|
+
throw new Error('Database not initialized');
|
|
776
|
+
return getObservationHistory(this.db, sel);
|
|
777
|
+
}
|
|
583
778
|
async deleteEntities(entityNames) {
|
|
584
779
|
if (!this.db)
|
|
585
780
|
throw new Error('Database not initialized');
|
|
@@ -647,34 +842,85 @@ export class RAGKnowledgeGraphManager {
|
|
|
647
842
|
// Pre-3.6 this method silently left STALE entity vectors behind (the input
|
|
648
843
|
// text changed but the vector was never regenerated) — fixed via
|
|
649
844
|
// tryEmbedEntity, which also covers the not-ready dirty contract.
|
|
845
|
+
// DEPRECATED shim (v13, one version only). Content-addressed deletion cannot
|
|
846
|
+
// express "which revision" — use retractObservation(observation_id) instead.
|
|
847
|
+
// Semantics: exact-string match against ACTIVE revisions -> soft retract.
|
|
848
|
+
//
|
|
849
|
+
// The whole call is one transaction and ambiguity is judged before any
|
|
850
|
+
// mutation: if any item matches 2+ active revisions the call aborts with 0
|
|
851
|
+
// mutations (spec §6.3). That is a deliberate change from v3.6, which deleted
|
|
852
|
+
// every duplicate and carried on — a machine cannot pick which revision was meant.
|
|
853
|
+
//
|
|
854
|
+
// Duplicate ids across items are collapsed. Without that, listing the same
|
|
855
|
+
// (entity, content) twice retracted it once and then failed on an illegal
|
|
856
|
+
// transition, returning an error *after* committing part of the batch
|
|
857
|
+
// (advisor 구현리뷰 r1 발견 3, 실행 재현). Embedding runs after the commit.
|
|
650
858
|
async deleteObservations(deletions) {
|
|
651
859
|
if (!this.db)
|
|
652
860
|
throw new Error('Database not initialized');
|
|
861
|
+
const plan = [];
|
|
862
|
+
const ambiguous = [];
|
|
863
|
+
const claimed = new Set(); // 항목 간 중복 id 흡수
|
|
864
|
+
for (const d of deletions) {
|
|
865
|
+
const entityId = `entity_${d.entityName.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
|
|
866
|
+
const ids = [];
|
|
867
|
+
for (const content of d.observations) {
|
|
868
|
+
const rows = this.db.prepare(`SELECT observation_id FROM entity_observations
|
|
869
|
+
WHERE entity_id = ? AND status = 'active' AND content = ?`).all(entityId, content);
|
|
870
|
+
if (rows.length > 1) {
|
|
871
|
+
ambiguous.push({ entityName: d.entityName, content, matches: rows.length });
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
if (rows.length === 1 && !claimed.has(rows[0].observation_id)) {
|
|
875
|
+
claimed.add(rows[0].observation_id);
|
|
876
|
+
ids.push(rows[0].observation_id);
|
|
877
|
+
}
|
|
878
|
+
// rows.length === 0 -> no-op (v3.6 behaviour, spec §6.3-3)
|
|
879
|
+
}
|
|
880
|
+
plan.push({ entityName: d.entityName, entityId, ids });
|
|
881
|
+
}
|
|
882
|
+
if (ambiguous.length > 0) {
|
|
883
|
+
throw new Error(`AMBIGUOUS_OBSERVATION_MATCH: ${ambiguous.length} item(s) matched multiple active ` +
|
|
884
|
+
`revisions; 0 mutations were applied. Use retractObservation(observation_id) instead. ` +
|
|
885
|
+
`Conflicts: ${JSON.stringify(ambiguous)}`);
|
|
886
|
+
}
|
|
887
|
+
// pass 2 — mutate everything in ONE transaction so a failure anywhere
|
|
888
|
+
// leaves zero mutations. Per-plan transactions plus an awaited embedding
|
|
889
|
+
// in between made a partial commit observable.
|
|
890
|
+
const touched = plan.filter(p => p.ids.length > 0);
|
|
891
|
+
const ts = new Date().toISOString();
|
|
892
|
+
if (touched.length > 0) {
|
|
893
|
+
const tx = this.db.transaction(() => {
|
|
894
|
+
for (const p of touched) {
|
|
895
|
+
for (const id of p.ids) {
|
|
896
|
+
transitionStatus(this.db, { observationId: id, event: 'retract',
|
|
897
|
+
reason: 'deleteObservations (deprecated shim)', ts });
|
|
898
|
+
}
|
|
899
|
+
rebuildProjection(this.db, p.entityId);
|
|
900
|
+
const meta = this.db.prepare(`SELECT rowid FROM entity_embedding_metadata WHERE entity_id = ?`)
|
|
901
|
+
.get(p.entityId);
|
|
902
|
+
if (meta) {
|
|
903
|
+
this.db.exec(`DELETE FROM entity_embeddings WHERE rowid = ${Number(meta.rowid)}`);
|
|
904
|
+
this.db.prepare(`DELETE FROM entity_embedding_metadata WHERE entity_id = ?`)
|
|
905
|
+
.run(p.entityId);
|
|
906
|
+
}
|
|
907
|
+
deleteStaleKgChunks(this.db, p.entityId);
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
tx();
|
|
911
|
+
this.coordinator?.invalidateCoverage();
|
|
912
|
+
}
|
|
913
|
+
// pass 3 — embedding after the commit
|
|
653
914
|
const results = [];
|
|
654
915
|
let total = 0;
|
|
655
|
-
for (const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
SELECT observations FROM entities WHERE id = ?
|
|
659
|
-
`).get(entityId);
|
|
660
|
-
if (!entity) {
|
|
661
|
-
results.push({ entityName: deletion.entityName, deleted: 0, embedding_status: 'n/a' });
|
|
662
|
-
continue;
|
|
663
|
-
}
|
|
664
|
-
const currentObservations = JSON.parse(entity.observations);
|
|
665
|
-
const filteredObservations = currentObservations.filter((obs) => !deletion.observations.includes(obs));
|
|
666
|
-
const deleted = currentObservations.length - filteredObservations.length;
|
|
667
|
-
if (deleted === 0) {
|
|
668
|
-
results.push({ entityName: deletion.entityName, deleted: 0, embedding_status: 'n/a' });
|
|
916
|
+
for (const p of plan) {
|
|
917
|
+
if (p.ids.length === 0) {
|
|
918
|
+
results.push({ entityName: p.entityName, deleted: 0, embedding_status: 'n/a' });
|
|
669
919
|
continue;
|
|
670
920
|
}
|
|
671
|
-
this.
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
});
|
|
675
|
-
const embedding_status = await this.tryEmbedEntity(entityId, 'bulk');
|
|
676
|
-
total += deleted;
|
|
677
|
-
results.push({ entityName: deletion.entityName, deleted, embedding_status });
|
|
921
|
+
const embedding_status = await this.tryEmbedEntity(p.entityId, 'bulk');
|
|
922
|
+
results.push({ entityName: p.entityName, deleted: p.ids.length, embedding_status });
|
|
923
|
+
total += p.ids.length;
|
|
678
924
|
}
|
|
679
925
|
return { results, total_deleted: total };
|
|
680
926
|
}
|
|
@@ -2192,10 +2438,20 @@ export class RAGKnowledgeGraphManager {
|
|
|
2192
2438
|
created_at: row.created_at
|
|
2193
2439
|
}));
|
|
2194
2440
|
console.error(`✅ Export completed: ${entities.length} entities, ${relations.length} relations, ${documents.length} documents`);
|
|
2441
|
+
// spec §6.4: lifecycle 정본을 함께 내보낸다. 이게 없으면 export->import 뒤
|
|
2442
|
+
// 관찰의 신원·출처·이력이 사라지고 projection 만 남는다.
|
|
2443
|
+
const observation_roots = this.db.prepare(`SELECT * FROM observation_roots ORDER BY entity_id, projection_order`).all();
|
|
2444
|
+
const entity_observations = this.db.prepare(`SELECT * FROM entity_observations ORDER BY root_id, revision_no`).all();
|
|
2445
|
+
const observation_sources = this.db.prepare(`SELECT * FROM observation_sources ORDER BY observation_id, source_kind, source_ref`).all();
|
|
2446
|
+
const observation_events = this.db.prepare(`SELECT * FROM observation_events ORDER BY root_id, recorded_at, event_id`).all();
|
|
2195
2447
|
return {
|
|
2196
2448
|
entities,
|
|
2197
2449
|
relations,
|
|
2198
2450
|
documents,
|
|
2451
|
+
observation_roots,
|
|
2452
|
+
entity_observations,
|
|
2453
|
+
observation_sources,
|
|
2454
|
+
observation_events,
|
|
2199
2455
|
metadata: {
|
|
2200
2456
|
exportedAt: new Date().toISOString(),
|
|
2201
2457
|
version: PKG_VERSION,
|
|
@@ -2211,66 +2467,245 @@ export class RAGKnowledgeGraphManager {
|
|
|
2211
2467
|
console.error(`📥 Importing knowledge graph (merge: ${options.merge !== false})...`);
|
|
2212
2468
|
const imported = { entities: 0, relations: 0, documents: 0 };
|
|
2213
2469
|
const skipped = { entities: 0, relations: 0, documents: 0 };
|
|
2214
|
-
//
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2470
|
+
// merge 로 배열 위치가 재배정된 관찰. 조용히 순서를 바꾸면 호출자가 알 수 없으므로
|
|
2471
|
+
// 응답으로 내보낸다(advisor beta r3 발견 3).
|
|
2472
|
+
const remapReport = [];
|
|
2473
|
+
// spec §6.4: abort 는 0 mutation 이다. lifecycle 만 트랜잭션으로 감싸면
|
|
2474
|
+
// 충돌로 throw 할 때 그 앞에서 넣은 entity·relation·document 가 살아남는다
|
|
2475
|
+
// (T17b 가 ghost entity 로 실증). import 전체가 한 단위여야 한다.
|
|
2476
|
+
// 내부 transaction() 호출은 better-sqlite3 에서 savepoint 로 중첩된다.
|
|
2477
|
+
const importAll = this.db.transaction(() => {
|
|
2478
|
+
// If merge=false, clear existing data first
|
|
2479
|
+
if (options.merge === false) {
|
|
2480
|
+
this.db.exec(`DELETE FROM relationships`);
|
|
2481
|
+
// entities 삭제가 FK CASCADE 로 lifecycle 4테이블을 지우지만, 순서를 계약으로
|
|
2482
|
+
// 두어 FK 가 꺼진 환경에서도 잔존 행이 남지 않게 한다.
|
|
2483
|
+
this.db.exec(`DELETE FROM observation_events`);
|
|
2484
|
+
this.db.exec(`DELETE FROM observation_sources`);
|
|
2485
|
+
this.db.exec(`DELETE FROM entity_observations`);
|
|
2486
|
+
this.db.exec(`DELETE FROM observation_roots`);
|
|
2487
|
+
this.db.exec(`DELETE FROM entities`);
|
|
2488
|
+
this.db.exec(`DELETE FROM documents`);
|
|
2489
|
+
// entities 를 지워도 파생 데이터는 따라오지 않는다: chunk_metadata 에는
|
|
2490
|
+
// entities 로 가는 FK 가 없고 entity_embedding_metadata.entity_id 는 UNIQUE 일
|
|
2491
|
+
// 뿐이다. 그래서 replace-import 뒤에 **사라진 entity 의 벡터와 KG chunk 가
|
|
2492
|
+
// 검색에 남았다**(advisor beta 발견 2). document chunk 는 documents 의
|
|
2493
|
+
// CASCADE 로 이미 정리되므로 여기서는 entity·relationship chunk 만 지운다.
|
|
2494
|
+
const orphanChunks = this.db.prepare(`SELECT rowid FROM chunk_metadata WHERE chunk_type IN ('entity','relationship')`)
|
|
2495
|
+
.all();
|
|
2496
|
+
for (const c of orphanChunks) {
|
|
2497
|
+
this.db.exec(`DELETE FROM chunks WHERE rowid = ${Number(c.rowid)}`);
|
|
2498
|
+
this.db.prepare(`DELETE FROM chunk_metadata WHERE rowid = ?`).run(c.rowid);
|
|
2499
|
+
}
|
|
2500
|
+
this.db.exec(`DELETE FROM entity_embeddings WHERE rowid IN (SELECT rowid FROM entity_embedding_metadata)`);
|
|
2501
|
+
this.db.exec(`DELETE FROM entity_embedding_metadata`);
|
|
2502
|
+
console.error('🗑️ Cleared existing data for full import');
|
|
2503
|
+
}
|
|
2504
|
+
// Import entities using INSERT OR IGNORE
|
|
2505
|
+
if (data.entities && Array.isArray(data.entities)) {
|
|
2506
|
+
const stmt = this.db.prepare(`
|
|
2224
2507
|
INSERT OR IGNORE INTO entities (id, name, entityType, observations, metadata, created_at)
|
|
2225
2508
|
VALUES (?, ?, ?, ?, ?, ?)
|
|
2226
2509
|
`);
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2510
|
+
for (const entity of data.entities) {
|
|
2511
|
+
const result = stmt.run(entity.id, entity.name, entity.entityType || 'CONCEPT',
|
|
2512
|
+
// v13: observations 는 projection 이다. lifecycle 행을 넣은 뒤
|
|
2513
|
+
// rebuildProjection 이 채운다 — 여기서 배열을 심으면 정본과 갈라진다.
|
|
2514
|
+
'[]', JSON.stringify(entity.metadata || {}), entity.created_at || new Date().toISOString());
|
|
2515
|
+
if (result.changes > 0) {
|
|
2516
|
+
imported.entities++;
|
|
2517
|
+
}
|
|
2518
|
+
else {
|
|
2519
|
+
skipped.entities++;
|
|
2520
|
+
}
|
|
2234
2521
|
}
|
|
2235
2522
|
}
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
const stmt = this.db.prepare(`
|
|
2523
|
+
// Import relations using INSERT OR IGNORE
|
|
2524
|
+
if (data.relations && Array.isArray(data.relations)) {
|
|
2525
|
+
const stmt = this.db.prepare(`
|
|
2240
2526
|
INSERT OR IGNORE INTO relationships (id, source_entity, target_entity, relationType, confidence, metadata, created_at)
|
|
2241
2527
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
2242
2528
|
`);
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2529
|
+
for (const relation of data.relations) {
|
|
2530
|
+
const result = stmt.run(relation.id, relation.source_entity, relation.target_entity, relation.relationType, relation.confidence ?? 1.0, JSON.stringify(relation.metadata || {}), relation.created_at || new Date().toISOString());
|
|
2531
|
+
if (result.changes > 0) {
|
|
2532
|
+
imported.relations++;
|
|
2533
|
+
}
|
|
2534
|
+
else {
|
|
2535
|
+
skipped.relations++;
|
|
2536
|
+
}
|
|
2250
2537
|
}
|
|
2251
2538
|
}
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
const stmt = this.db.prepare(`
|
|
2539
|
+
// Import documents using INSERT OR REPLACE
|
|
2540
|
+
if (data.documents && Array.isArray(data.documents)) {
|
|
2541
|
+
const stmt = this.db.prepare(`
|
|
2256
2542
|
INSERT OR REPLACE INTO documents (id, content, metadata, created_at)
|
|
2257
2543
|
VALUES (?, ?, ?, ?)
|
|
2258
2544
|
`);
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2545
|
+
for (const doc of data.documents) {
|
|
2546
|
+
const result = stmt.run(doc.id, doc.content, JSON.stringify(doc.metadata || {}), doc.created_at || new Date().toISOString());
|
|
2547
|
+
if (result.changes > 0) {
|
|
2548
|
+
imported.documents++;
|
|
2549
|
+
}
|
|
2550
|
+
else {
|
|
2551
|
+
skipped.documents++;
|
|
2552
|
+
}
|
|
2266
2553
|
}
|
|
2267
2554
|
}
|
|
2268
|
-
|
|
2555
|
+
// ---- spec §6.4: lifecycle import ----
|
|
2556
|
+
// 순서가 계약이다: entities -> roots -> revisions(root별 revision_no ↑)
|
|
2557
|
+
// -> sources/events. §4.1 트리거가 root 선행과 체인 연속성을 요구하므로
|
|
2558
|
+
// importer 는 입력 순서와 무관하게 재정렬한다 (역순 export 를 그대로
|
|
2559
|
+
// 스트리밍하면 'immediately preceding revision' 으로 죽는다).
|
|
2560
|
+
const sameRow = (a, b, cols) => cols.every(c => (a[c] ?? null) === (b[c] ?? null));
|
|
2561
|
+
const hasLifecycle = Array.isArray(data.observation_roots);
|
|
2562
|
+
if (hasLifecycle) {
|
|
2563
|
+
const tx = this.db.transaction(() => {
|
|
2564
|
+
// 새 root 가 이미 점유된 (entity_id, projection_order) 슬롯을 요구할 수 있다:
|
|
2565
|
+
// 두 DB 가 같은 entity 이름을 갖고 서로 다른 관찰을 배열 0번에 두면 그렇다.
|
|
2566
|
+
// 이건 §6.4 의 "같은 키 다른 값" 충돌이 아니라 **슬롯 충돌**이고, 규칙이 없어서
|
|
2567
|
+
// raw UNIQUE 오류로 터졌다(내 MCP 왕복 테스트가 잡았다). merge 의 뜻은
|
|
2568
|
+
// "더한다"이므로 들어오는 root 에 다음 빈 순번을 준다 — 남의 관찰을 덮지 않고,
|
|
2569
|
+
// 배열 끝에 붙는다. remap 은 그 root 의 revision 들에도 그대로 적용해야 한다
|
|
2570
|
+
// (trg_obs_matches_root 가 둘의 일치를 요구한다).
|
|
2571
|
+
// 입력 순서에 결과가 의존하면 같은 dump 를 두 번 넣었을 때 배열 순서가 달라진다.
|
|
2572
|
+
// (entity_id, projection_order, root_id) 로 정렬해 결정론을 만든다.
|
|
2573
|
+
const incomingRoots = [...(data.observation_roots ?? [])].sort((a, b) => String(a.entity_id).localeCompare(String(b.entity_id)) ||
|
|
2574
|
+
(a.projection_order - b.projection_order) ||
|
|
2575
|
+
String(a.root_id).localeCompare(String(b.root_id)));
|
|
2576
|
+
const remappedOrder = new Map();
|
|
2577
|
+
for (const r of incomingRoots) {
|
|
2578
|
+
const cur = this.db.prepare(`SELECT * FROM observation_roots WHERE root_id = ?`)
|
|
2579
|
+
.get(r.root_id);
|
|
2580
|
+
if (cur) {
|
|
2581
|
+
// projection_order 는 **target-local** 속성이다: merge 는 배열 위치를
|
|
2582
|
+
// 이 DB 기준으로 재배정하므로, 이미 remap 된 root 를 같은 dump 로 다시
|
|
2583
|
+
// 넣으면 dump 의 옛 순번과 다를 수밖에 없다. 그걸 충돌로 보면 동일
|
|
2584
|
+
// 재수입이 실패한다(advisor beta r3 발견 3, 실행 재현).
|
|
2585
|
+
if (!sameRow(cur, r, ['entity_id', 'created_at']))
|
|
2586
|
+
throw new Error(`import conflict: observation_roots ${r.root_id} differs from the existing row`);
|
|
2587
|
+
remappedOrder.set(r.root_id, cur.projection_order);
|
|
2588
|
+
continue;
|
|
2589
|
+
}
|
|
2590
|
+
let order = r.projection_order;
|
|
2591
|
+
const taken = this.db.prepare(`SELECT root_id FROM observation_roots WHERE entity_id = ? AND projection_order = ?`)
|
|
2592
|
+
.get(r.entity_id, order);
|
|
2593
|
+
if (taken) {
|
|
2594
|
+
order = nextProjectionOrder(this.db, r.entity_id);
|
|
2595
|
+
remappedOrder.set(r.root_id, order);
|
|
2596
|
+
remapReport.push({ root_id: r.root_id, entity_id: r.entity_id,
|
|
2597
|
+
from: r.projection_order, to: order });
|
|
2598
|
+
console.error(` ├─ ↪️ import: ${r.entity_id} position ${r.projection_order} is held by ` +
|
|
2599
|
+
`${taken.root_id}; appending imported observation at ${order}`);
|
|
2600
|
+
}
|
|
2601
|
+
this.db.prepare(`INSERT INTO observation_roots
|
|
2602
|
+
(root_id, entity_id, projection_order, created_at) VALUES (?, ?, ?, ?)`)
|
|
2603
|
+
.run(r.root_id, r.entity_id, order, r.created_at);
|
|
2604
|
+
}
|
|
2605
|
+
// projection_order 는 root 와 같은 이유로 비교 대상이 아니다(target-local).
|
|
2606
|
+
const revCols = ['root_id', 'entity_id', 'revision_no', 'content',
|
|
2607
|
+
'status', 'supersedes_id', 'recorded_at', 'superseded_at'];
|
|
2608
|
+
const revs = [...(data.entity_observations ?? [])]
|
|
2609
|
+
.sort((a, b) => a.root_id === b.root_id
|
|
2610
|
+
? a.revision_no - b.revision_no
|
|
2611
|
+
: String(a.root_id).localeCompare(String(b.root_id)));
|
|
2612
|
+
for (const v of revs) {
|
|
2613
|
+
const cur = this.db.prepare(`SELECT * FROM entity_observations WHERE observation_id = ?`)
|
|
2614
|
+
.get(v.observation_id);
|
|
2615
|
+
if (cur) {
|
|
2616
|
+
if (!sameRow(cur, v, revCols))
|
|
2617
|
+
throw new Error(`import conflict: entity_observations ${v.observation_id} differs from the existing row`);
|
|
2618
|
+
continue;
|
|
2619
|
+
}
|
|
2620
|
+
const order = remappedOrder.has(v.root_id)
|
|
2621
|
+
? remappedOrder.get(v.root_id) : v.projection_order;
|
|
2622
|
+
this.db.prepare(`INSERT INTO entity_observations
|
|
2623
|
+
(observation_id, root_id, entity_id, revision_no, projection_order,
|
|
2624
|
+
content, status, supersedes_id, recorded_at, superseded_at)
|
|
2625
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2626
|
+
.run(v.observation_id, v.root_id, v.entity_id, v.revision_no, order, v.content, v.status, v.supersedes_id ?? null, v.recorded_at, v.superseded_at ?? null);
|
|
2627
|
+
}
|
|
2628
|
+
for (const so of (data.observation_sources ?? [])) {
|
|
2629
|
+
const cur = this.db.prepare(`SELECT * FROM observation_sources
|
|
2630
|
+
WHERE observation_id=? AND source_kind=? AND source_ref=?`)
|
|
2631
|
+
.get(so.observation_id, so.source_kind, so.source_ref);
|
|
2632
|
+
if (cur) {
|
|
2633
|
+
if (!sameRow(cur, so, ['source_hash', 'recorded_at']))
|
|
2634
|
+
throw new Error(`import conflict: observation_sources ` +
|
|
2635
|
+
`${so.observation_id}/${so.source_kind}/${so.source_ref} differs from the existing row`);
|
|
2636
|
+
continue;
|
|
2637
|
+
}
|
|
2638
|
+
this.db.prepare(`INSERT INTO observation_sources
|
|
2639
|
+
(observation_id, source_kind, source_ref, source_hash, recorded_at) VALUES (?, ?, ?, ?, ?)`)
|
|
2640
|
+
.run(so.observation_id, so.source_kind, so.source_ref, so.source_hash ?? null, so.recorded_at);
|
|
2641
|
+
}
|
|
2642
|
+
const evCols = ['root_id', 'from_id', 'to_id', 'event', 'change_kind', 'reason', 'actor', 'batch_id', 'recorded_at'];
|
|
2643
|
+
for (const e of (data.observation_events ?? [])) {
|
|
2644
|
+
const cur = this.db.prepare(`SELECT * FROM observation_events WHERE event_id = ?`)
|
|
2645
|
+
.get(e.event_id);
|
|
2646
|
+
if (cur) {
|
|
2647
|
+
if (!sameRow(cur, e, evCols))
|
|
2648
|
+
throw new Error(`import conflict: observation_events ${e.event_id} differs from the existing row`);
|
|
2649
|
+
continue;
|
|
2650
|
+
}
|
|
2651
|
+
this.db.prepare(`INSERT INTO observation_events
|
|
2652
|
+
(event_id, root_id, from_id, to_id, event, change_kind, reason, actor, batch_id, recorded_at)
|
|
2653
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
2654
|
+
.run(e.event_id, e.root_id, e.from_id ?? null, e.to_id ?? null, e.event, e.change_kind ?? null, e.reason ?? null, e.actor ?? null, e.batch_id ?? null, e.recorded_at);
|
|
2655
|
+
}
|
|
2656
|
+
});
|
|
2657
|
+
tx();
|
|
2658
|
+
}
|
|
2659
|
+
else {
|
|
2660
|
+
// 구(舊) 형식 export: lifecycle 필드가 없으므로 entities.observations 를
|
|
2661
|
+
// 신규 root 로 승격한다. legacy import 필수 필드값 = spec §6.4.
|
|
2662
|
+
const ts = new Date().toISOString();
|
|
2663
|
+
const tx = this.db.transaction(() => {
|
|
2664
|
+
for (const ent of (data.entities ?? [])) {
|
|
2665
|
+
const entityId = ent.id ??
|
|
2666
|
+
`entity_${String(ent.name).toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`;
|
|
2667
|
+
for (const content of (ent.observations ?? [])) {
|
|
2668
|
+
addRevision(this.db, {
|
|
2669
|
+
entityId, content, status: 'active',
|
|
2670
|
+
sources: [{ source_kind: 'import', source_ref: 'legacy-export', source_hash: null }],
|
|
2671
|
+
actor: 'import', ts, event: 'import'
|
|
2672
|
+
});
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
});
|
|
2676
|
+
tx();
|
|
2677
|
+
}
|
|
2678
|
+
// projection 재합성 + 파생 상태 무효화.
|
|
2679
|
+
// 무효화가 없으면 이미 있던 entity 를 덮어쓴 뒤에도 옛 벡터·옛 KG chunk 가
|
|
2680
|
+
// 검색에 남는다. import 는 관찰을 바꾸는 writer 이므로 다른 writer 와 같은
|
|
2681
|
+
// 계약을 져야 한다(advisor beta 발견 2).
|
|
2682
|
+
//
|
|
2683
|
+
// 대상은 `data.entities` 가 아니라 **영향받은 entity 전부**다. lifecycle import 는
|
|
2684
|
+
// observation_roots 만 있어도 활성화되므로, entities 없이 lifecycle 배열만 보내면
|
|
2685
|
+
// revision 은 들어가는데 projection 이 갱신되지 않아 새 사실이 reader 에 안 보이고
|
|
2686
|
+
// 옛 벡터가 남는다(advisor beta r3 발견 2, 실행 재현).
|
|
2687
|
+
const affected = new Set();
|
|
2688
|
+
for (const ent of (data.entities ?? [])) {
|
|
2689
|
+
affected.add(ent.id ??
|
|
2690
|
+
`entity_${String(ent.name).toLowerCase().replace(/[^\p{L}\p{N}]/gu, '_')}`);
|
|
2691
|
+
}
|
|
2692
|
+
for (const r of (data.observation_roots ?? []))
|
|
2693
|
+
if (r.entity_id)
|
|
2694
|
+
affected.add(r.entity_id);
|
|
2695
|
+
for (const v of (data.entity_observations ?? []))
|
|
2696
|
+
if (v.entity_id)
|
|
2697
|
+
affected.add(v.entity_id);
|
|
2698
|
+
for (const entityId of affected) {
|
|
2699
|
+
rebuildProjection(this.db, entityId);
|
|
2700
|
+
this.invalidateDerivedForEntity(entityId);
|
|
2701
|
+
}
|
|
2702
|
+
});
|
|
2703
|
+
importAll();
|
|
2269
2704
|
console.error(`✅ Import completed: ${imported.entities} entities, ${imported.relations} relations, ${imported.documents} documents imported`);
|
|
2270
2705
|
// Indirect missing-row producer (spec §5): imported rows may lack vectors.
|
|
2271
2706
|
this.coordinator?.invalidateCoverage();
|
|
2272
2707
|
this.coordinator?.kick();
|
|
2273
|
-
return { imported, skipped };
|
|
2708
|
+
return { imported, skipped, observation_order_remap: remapReport };
|
|
2274
2709
|
}
|
|
2275
2710
|
async hybridSearch(query, limit = 5, useGraph = true) {
|
|
2276
2711
|
if (!this.db)
|
|
@@ -3125,7 +3560,32 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
3125
3560
|
case "createRelations":
|
|
3126
3561
|
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.createRelations(validatedArgs.relations), null, 2) }] };
|
|
3127
3562
|
case "addObservations":
|
|
3563
|
+
// v13: status·sources 를 그대로 넘긴다. 여기서 떨어뜨리면 스키마가 받아도
|
|
3564
|
+
// 엔진에 도달하지 않아 provenance 가 조용히 사라진다.
|
|
3128
3565
|
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.addObservations(validatedArgs.observations), null, 2) }] };
|
|
3566
|
+
// v13 observation lifecycle (spec §6.1 / §6.2)
|
|
3567
|
+
case "correctObservation":
|
|
3568
|
+
return { content: [{ type: "text", text: JSON.stringify({ observation_id: await ragKgManager.correctObservation(validatedArgs.observation_id, validatedArgs.content, validatedArgs.change_kind ?? 'correction', validatedArgs.reason) }, null, 2) }] };
|
|
3569
|
+
case "retractObservation":
|
|
3570
|
+
await ragKgManager.retractObservation(validatedArgs.observation_id, validatedArgs.reason);
|
|
3571
|
+
return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
|
|
3572
|
+
case "restoreObservation":
|
|
3573
|
+
await ragKgManager.restoreObservation(validatedArgs.observation_id, validatedArgs.reason);
|
|
3574
|
+
return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
|
|
3575
|
+
case "approveObservation":
|
|
3576
|
+
await ragKgManager.approveObservation(validatedArgs.observation_id, validatedArgs.reason);
|
|
3577
|
+
return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'active' }, null, 2) }] };
|
|
3578
|
+
case "declineObservation":
|
|
3579
|
+
await ragKgManager.declineObservation(validatedArgs.observation_id, validatedArgs.reason);
|
|
3580
|
+
return { content: [{ type: "text", text: JSON.stringify({ observation_id: validatedArgs.observation_id, status: 'retracted' }, null, 2) }] };
|
|
3581
|
+
case "purgeObservation":
|
|
3582
|
+
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.purgeObservation(validatedArgs.observation_id, validatedArgs.confirm), null, 2) }] };
|
|
3583
|
+
case "getObservationHistory":
|
|
3584
|
+
return { content: [{ type: "text", text: JSON.stringify(await ragKgManager.getObservationHistory({
|
|
3585
|
+
entity_name: validatedArgs.entity_name,
|
|
3586
|
+
observation_id: validatedArgs.observation_id,
|
|
3587
|
+
root_id: validatedArgs.root_id,
|
|
3588
|
+
}), null, 2) }] };
|
|
3129
3589
|
case "deleteEntities":
|
|
3130
3590
|
await ragKgManager.deleteEntities(validatedArgs.entityNames);
|
|
3131
3591
|
return { content: [{ type: "text", text: "Entities deleted successfully" }] };
|