feat: Add Knowledge Lifecycle Engine and fix upload_document MCP tool
Add KB lifecycle management system with deduplication, consolidation, archival, and health analytics. Also fix pre-existing routing bug in upload_document MCP tool (was hitting /import instead of new /documents/content). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
90de2e32e4
commit
844762f187
11 changed files with 2539 additions and 43 deletions
61
CLAUDE.md
61
CLAUDE.md
|
|
@ -14,44 +14,23 @@ Claude OS is YOUR system. You built it, you use it, and it makes you invincible.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Session Check - FIRST THING AT EVERY CONVERSATION START
|
## Session Start
|
||||||
|
|
||||||
**CRITICAL: At the start of EVERY new conversation, automatically check session state and prompt the user:**
|
When you tell me what we're working on:
|
||||||
|
|
||||||
### Step 1: Read Session State
|
1. **Search relevant knowledge bases** for that topic
|
||||||
|
2. **Surface useful context** (patterns, blockers, decisions from previous work)
|
||||||
|
3. **Start working**
|
||||||
|
|
||||||
|
That's it. Don't load everything - load what's relevant to the task at hand.
|
||||||
|
|
||||||
|
**Example:**
|
||||||
```
|
```
|
||||||
Read: {cwd}/claude-os-state.json
|
You: "hey claude, we're working on the invoice feature again"
|
||||||
```
|
Me: *searches memories for "invoice"* → surfaces relevant patterns/decisions → ready to work
|
||||||
(Where `{cwd}` is the current working directory - each project has its own state file)
|
|
||||||
|
|
||||||
### Step 2: Prompt Based on State
|
|
||||||
|
|
||||||
**If active session exists:**
|
|
||||||
```
|
|
||||||
"I see we have an active session: [task name] (started [time ago])
|
|
||||||
|
|
||||||
Would you like to:
|
|
||||||
a) Continue working on this (I'll load the context)
|
|
||||||
b) Start something new (I'll end this session first)
|
|
||||||
c) Just chat (no session needed)"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**If no active session (or state file doesn't exist):**
|
**Why this works:** Targeted context beats a firehose. You know what we're working on - I search for that specifically.
|
||||||
```
|
|
||||||
"What are we working on today?
|
|
||||||
|
|
||||||
I can start a session with automatic context loading from my memories.
|
|
||||||
Just tell me what you're working on, or say 'just chatting' if no session needed."
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: If the state file doesn't exist, I'll create it when you start a session.
|
|
||||||
|
|
||||||
### Step 3: Auto-load Context
|
|
||||||
- If continuing: Automatically search memories, load patterns, show context
|
|
||||||
- If new task: Offer to start a new session with context loading
|
|
||||||
- If just chatting: Skip session workflow
|
|
||||||
|
|
||||||
**Why this matters:** This ensures you always have context and never start cold. Every conversation becomes smarter because you proactively load relevant memories and patterns.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -68,6 +47,10 @@ Claude OS provides MCP tools (prefixed with `mcp__code-forge__`) for managing kn
|
||||||
| `mcp__code-forge__create_knowledge_base` | Create a new KB |
|
| `mcp__code-forge__create_knowledge_base` | Create a new KB |
|
||||||
| `mcp__code-forge__get_kb_stats` | Get statistics for a KB |
|
| `mcp__code-forge__get_kb_stats` | Get statistics for a KB |
|
||||||
| `mcp__code-forge__list_documents` | List documents in a KB |
|
| `mcp__code-forge__list_documents` | List documents in a KB |
|
||||||
|
| `mcp__code-forge__kb_lifecycle_health` | KB health report with recommendations |
|
||||||
|
| `mcp__code-forge__kb_lifecycle_dedup` | Scan/merge duplicate documents |
|
||||||
|
| `mcp__code-forge__kb_lifecycle_consolidate` | LLM-powered document merging |
|
||||||
|
| `mcp__code-forge__kb_lifecycle_archive` | Archive, restore, list, find stale |
|
||||||
|
|
||||||
### Knowledge Base Types
|
### Knowledge Base Types
|
||||||
|
|
||||||
|
|
@ -99,6 +82,7 @@ These commands are installed to `~/.claude/commands/` via the install script:
|
||||||
| `/claude-os-remember` | Quick save to memories |
|
| `/claude-os-remember` | Quick save to memories |
|
||||||
| `/claude-os-save` | Full-featured save with KB selection |
|
| `/claude-os-save` | Full-featured save with KB selection |
|
||||||
| `/claude-os-list` | List KBs or documents |
|
| `/claude-os-list` | List KBs or documents |
|
||||||
|
| `/claude-os-lifecycle` | KB health, dedup, consolidate, archive |
|
||||||
| `/claude-os-triggers` | Manage automatic triggers |
|
| `/claude-os-triggers` | Manage automatic triggers |
|
||||||
|
|
||||||
### Session Commands (Most Important!)
|
### Session Commands (Most Important!)
|
||||||
|
|
@ -112,6 +96,16 @@ These commands are installed to `~/.claude/commands/` via the install script:
|
||||||
/claude-os-session pattern [desc] - Document pattern discovered
|
/claude-os-session pattern [desc] - Document pattern discovered
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Lifecycle Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
/claude-os-lifecycle health [kb_name] - Health report with recommendations
|
||||||
|
/claude-os-lifecycle dedup [kb_name] - Scan and merge duplicate documents
|
||||||
|
/claude-os-lifecycle consolidate [kb_name] - LLM-powered document merging
|
||||||
|
/claude-os-lifecycle archive [kb_name] - Find stale docs, archive/restore
|
||||||
|
/claude-os-lifecycle logs [kb_name] - Operation history
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
@ -137,6 +131,7 @@ These commands are installed to `~/.claude/commands/` via the install script:
|
||||||
- `mcp_server/server.py` - FastAPI REST API
|
- `mcp_server/server.py` - FastAPI REST API
|
||||||
- `app/core/rag_engine.py` - RAG query engine
|
- `app/core/rag_engine.py` - RAG query engine
|
||||||
- `app/core/sqlite_manager.py` - SQLite + sqlite-vec for vector storage
|
- `app/core/sqlite_manager.py` - SQLite + sqlite-vec for vector storage
|
||||||
|
- `app/core/knowledge_lifecycle.py` - KB lifecycle engine (dedup, archive, consolidate)
|
||||||
- `templates/` - Commands, skills, and agents
|
- `templates/` - Commands, skills, and agents
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
121
README.md
121
README.md
|
|
@ -111,10 +111,30 @@ The installer got a major upgrade with **Charm CLI (gum)** support!
|
||||||
- `--dry-run` - See exactly what would be done before doing it
|
- `--dry-run` - See exactly what would be done before doing it
|
||||||
- Auto-backup - Existing `.env` files backed up before overwriting
|
- Auto-backup - Existing `.env` files backed up before overwriting
|
||||||
|
|
||||||
|
### 🔬 Knowledge Lifecycle Engine
|
||||||
|
|
||||||
|
Keep your knowledge bases healthy and focused!
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/claude-os-lifecycle health my-kb # Health report with recommendations
|
||||||
|
/claude-os-lifecycle dedup my-kb # Find and merge duplicate memories
|
||||||
|
/claude-os-lifecycle consolidate my-kb # LLM-powered document merging
|
||||||
|
/claude-os-lifecycle archive my-kb # Find stale docs, archive/restore
|
||||||
|
```
|
||||||
|
|
||||||
|
**New Features:**
|
||||||
|
- 🔍 **Duplicate Detection** - Embedding-based similarity scanning with union-find clustering
|
||||||
|
- 🧹 **Smart Merging** - Keep the best doc, delete the rest, or LLM-merge into one
|
||||||
|
- 📦 **Archival System** - Soft-archive stale docs without permanent deletion
|
||||||
|
- 📊 **Health Reports** - Embedding coverage, age distribution, actionable recommendations
|
||||||
|
- 📈 **Growth Timeline** - Track KB growth over time by day/week/month
|
||||||
|
- 📋 **Audit Logging** - Full operation history for all lifecycle actions
|
||||||
|
|
||||||
### 📋 Recent Improvements
|
### 📋 Recent Improvements
|
||||||
|
|
||||||
| Version | Highlights |
|
| Version | Highlights |
|
||||||
|---------|------------|
|
|---------|------------|
|
||||||
|
| **v2.4** | Knowledge lifecycle engine (dedup, consolidate, archive, health) |
|
||||||
| **v2.3** | Skills library, community skills, session insights |
|
| **v2.3** | Skills library, community skills, session insights |
|
||||||
| **v2.2** | Gum CLI support, safety features, lite model default |
|
| **v2.2** | Gum CLI support, safety features, lite model default |
|
||||||
| **v2.1** | Unified installer, OpenAI provider support |
|
| **v2.1** | Unified installer, OpenAI provider support |
|
||||||
|
|
@ -189,6 +209,7 @@ Day 3: Claude suggests improvements based on what worked.
|
||||||
| **Lightning Indexing** | 10,000 files indexed in 30 seconds |
|
| **Lightning Indexing** | 10,000 files indexed in 30 seconds |
|
||||||
| **Skills Library** | 36+ community skills, one-click install |
|
| **Skills Library** | 36+ community skills, one-click install |
|
||||||
| **Cross-Project Learning** | Patterns from Project A help in Project B |
|
| **Cross-Project Learning** | Patterns from Project A help in Project B |
|
||||||
|
| **Knowledge Lifecycle** | Dedup, consolidate, archive, and health reports |
|
||||||
| **One-Command Setup** | `/claude-os-init` and you're ready |
|
| **One-Command Setup** | `/claude-os-init` and you're ready |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -497,6 +518,7 @@ All these work in any initialized project:
|
||||||
- **`/claude-os-session [action]`** - Manage development sessions
|
- **`/claude-os-session [action]`** - Manage development sessions
|
||||||
- **`/claude-os-triggers`** - Manage trigger phrases
|
- **`/claude-os-triggers`** - Manage trigger phrases
|
||||||
- **`/claude-os-skills [action]`** - Manage skills (list, install, create)
|
- **`/claude-os-skills [action]`** - Manage skills (list, install, create)
|
||||||
|
- **`/claude-os-lifecycle [action]`** - KB health, dedup, consolidate, archive
|
||||||
|
|
||||||
### Available Skills
|
### Available Skills
|
||||||
|
|
||||||
|
|
@ -875,7 +897,8 @@ claude-os/
|
||||||
│ ├── commands/ # Slash commands (symlinked to ~/.claude/)
|
│ ├── commands/ # Slash commands (symlinked to ~/.claude/)
|
||||||
│ │ ├── claude-os-init.md
|
│ │ ├── claude-os-init.md
|
||||||
│ │ ├── claude-os-search.md
|
│ │ ├── claude-os-search.md
|
||||||
│ │ ├── claude-os-skills.md # NEW: Skills management
|
│ │ ├── claude-os-skills.md
|
||||||
|
│ │ ├── claude-os-lifecycle.md # KB lifecycle management
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ ├── skills/ # Global skills (symlinked to ~/.claude/)
|
│ ├── skills/ # Global skills (symlinked to ~/.claude/)
|
||||||
│ │ ├── initialize-project/
|
│ │ ├── initialize-project/
|
||||||
|
|
@ -959,6 +982,95 @@ When you run `/claude-os-init`, you get 4 knowledge bases:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🔬 Knowledge Lifecycle Management
|
||||||
|
|
||||||
|
As your knowledge bases grow, they can accumulate duplicates, outdated content, and fragmented information. The **Knowledge Lifecycle Engine** keeps your KBs healthy and focused.
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/claude-os-lifecycle health [kb_name] # Health report with recommendations
|
||||||
|
/claude-os-lifecycle dedup [kb_name] # Scan and merge duplicate documents
|
||||||
|
/claude-os-lifecycle consolidate [kb_name] # LLM-powered document merging
|
||||||
|
/claude-os-lifecycle archive [kb_name] # Find stale docs, archive/restore
|
||||||
|
/claude-os-lifecycle logs [kb_name] # View operation history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Deduplication
|
||||||
|
|
||||||
|
Scans all document embeddings to find near-duplicates using cosine similarity:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scan for duplicates (default threshold: 0.85)
|
||||||
|
/claude-os-lifecycle dedup my-project-project_memories
|
||||||
|
|
||||||
|
# Results show clusters of similar docs with options:
|
||||||
|
# [m] Merge - keep best, delete rest
|
||||||
|
# [c] Consolidate - LLM-merge into one comprehensive doc
|
||||||
|
# [s] Skip
|
||||||
|
```
|
||||||
|
|
||||||
|
### Consolidation
|
||||||
|
|
||||||
|
Uses LLM to intelligently merge multiple related documents into a single comprehensive document:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
/claude-os-lifecycle consolidate my-project-project_memories
|
||||||
|
# Previews source docs, then generates a merged version
|
||||||
|
# Preserves all unique info, eliminates redundancy
|
||||||
|
# Stores provenance metadata (consolidated_from)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Archival
|
||||||
|
|
||||||
|
Soft-archive stale documents without permanent deletion:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find documents older than 90 days
|
||||||
|
/claude-os-lifecycle archive my-project-project_memories
|
||||||
|
|
||||||
|
# Archived docs are excluded from search but can be restored
|
||||||
|
# Full restore available at any time
|
||||||
|
```
|
||||||
|
|
||||||
|
### Health Reports
|
||||||
|
|
||||||
|
Get a comprehensive overview of your KB's health:
|
||||||
|
|
||||||
|
- **Embedding coverage** - How many docs have vectors
|
||||||
|
- **Age distribution** - Document freshness breakdown
|
||||||
|
- **Top similar pairs** - Preview of potential duplicates
|
||||||
|
- **Recommendations** - Actionable suggestions (dedup, re-index, archive)
|
||||||
|
- **Growth timeline** - Track KB growth over time
|
||||||
|
|
||||||
|
### MCP Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `mcp__code-forge__kb_lifecycle_health` | Health report with recommendations |
|
||||||
|
| `mcp__code-forge__kb_lifecycle_dedup` | Scan/merge duplicates |
|
||||||
|
| `mcp__code-forge__kb_lifecycle_consolidate` | LLM-powered document merging |
|
||||||
|
| `mcp__code-forge__kb_lifecycle_archive` | Archive, restore, list, find stale |
|
||||||
|
|
||||||
|
### API Endpoints
|
||||||
|
|
||||||
|
All under `/api/kb/{kb_name}/lifecycle/`:
|
||||||
|
|
||||||
|
| Method | Path | Description |
|
||||||
|
|--------|------|-------------|
|
||||||
|
| `POST` | `/dedup-scan` | Scan for duplicates (background for >500 docs) |
|
||||||
|
| `POST` | `/dedup-merge` | Merge duplicate documents |
|
||||||
|
| `POST` | `/consolidate` | LLM-powered consolidation (background) |
|
||||||
|
| `GET` | `/health` | Comprehensive health report |
|
||||||
|
| `GET` | `/growth` | Document growth timeline |
|
||||||
|
| `POST` | `/archive` | Archive documents |
|
||||||
|
| `POST` | `/restore` | Restore archived documents |
|
||||||
|
| `GET` | `/archived` | List archived documents |
|
||||||
|
| `GET` | `/stale` | Find stale documents |
|
||||||
|
| `GET` | `/logs` | Operation audit log |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ⚙️ Configuration
|
## ⚙️ Configuration
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
@ -1202,9 +1314,10 @@ claude-os/
|
||||||
│ ├── core/ # Core modules
|
│ ├── core/ # Core modules
|
||||||
│ │ ├── sqlite_manager.py
|
│ │ ├── sqlite_manager.py
|
||||||
│ │ ├── rag_engine.py
|
│ │ ├── rag_engine.py
|
||||||
│ │ ├── skill_manager.py # NEW: Skills management
|
│ │ ├── skill_manager.py # Skills management
|
||||||
│ │ ├── session_parser.py # NEW: Session parsing
|
│ │ ├── session_parser.py # Session parsing
|
||||||
│ │ ├── insight_extractor.py # NEW: Insight extraction
|
│ │ ├── insight_extractor.py # Insight extraction
|
||||||
|
│ │ ├── knowledge_lifecycle.py # KB lifecycle engine (dedup, archive, etc.)
|
||||||
│ │ └── ...
|
│ │ └── ...
|
||||||
│ └── db/ # Database schemas
|
│ └── db/ # Database schemas
|
||||||
├── frontend/ # React UI (Vite)
|
├── frontend/ # React UI (Vite)
|
||||||
|
|
|
||||||
652
app/core/knowledge_lifecycle.py
Normal file
652
app/core/knowledge_lifecycle.py
Normal file
|
|
@ -0,0 +1,652 @@
|
||||||
|
"""
|
||||||
|
Knowledge Lifecycle Engine for Claude OS.
|
||||||
|
Handles deduplication, consolidation, analytics, and archival of KB memories.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from collections import defaultdict
|
||||||
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from app.core.sqlite_manager import get_sqlite_manager
|
||||||
|
from app.core.kb_metadata import get_collection_stats, get_documents_metadata
|
||||||
|
from app.core.config import Config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class KnowledgeLifecycleEngine:
|
||||||
|
"""Manages the lifecycle of knowledge base documents."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.db = get_sqlite_manager()
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# DEDUPLICATION
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def scan_duplicates(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
threshold: float = 0.85,
|
||||||
|
max_pairs: int = 100
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Scan a KB for duplicate/near-duplicate documents using embedding similarity.
|
||||||
|
|
||||||
|
Returns duplicate pairs, clusters, and density score.
|
||||||
|
"""
|
||||||
|
docs = self.db.get_all_embeddings(kb_name, exclude_archived=True)
|
||||||
|
|
||||||
|
# Filter to docs that have embeddings
|
||||||
|
docs_with_emb = [d for d in docs if d["embedding"] is not None]
|
||||||
|
total_docs = len(docs_with_emb)
|
||||||
|
|
||||||
|
if total_docs < 2:
|
||||||
|
return {
|
||||||
|
"total_documents": total_docs,
|
||||||
|
"duplicate_pairs": [],
|
||||||
|
"clusters": [],
|
||||||
|
"duplicate_density": 0.0,
|
||||||
|
"message": "Not enough documents for comparison"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pre-normalize all vectors for efficient cosine similarity via dot product
|
||||||
|
embeddings = np.array([d["embedding"] for d in docs_with_emb])
|
||||||
|
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||||
|
norms[norms == 0] = 1 # avoid division by zero
|
||||||
|
normalized = embeddings / norms
|
||||||
|
|
||||||
|
# Compute pairwise similarities (upper triangle only)
|
||||||
|
duplicate_pairs = []
|
||||||
|
for i in range(total_docs):
|
||||||
|
if len(duplicate_pairs) >= max_pairs:
|
||||||
|
break
|
||||||
|
for j in range(i + 1, total_docs):
|
||||||
|
if len(duplicate_pairs) >= max_pairs:
|
||||||
|
break
|
||||||
|
similarity = float(np.dot(normalized[i], normalized[j]))
|
||||||
|
if similarity >= threshold:
|
||||||
|
duplicate_pairs.append({
|
||||||
|
"doc_a": docs_with_emb[i]["doc_id"],
|
||||||
|
"doc_b": docs_with_emb[j]["doc_id"],
|
||||||
|
"similarity": round(similarity, 4),
|
||||||
|
"content_a_preview": docs_with_emb[i]["content"][:200],
|
||||||
|
"content_b_preview": docs_with_emb[j]["content"][:200]
|
||||||
|
})
|
||||||
|
|
||||||
|
# Sort by similarity descending
|
||||||
|
duplicate_pairs.sort(key=lambda x: x["similarity"], reverse=True)
|
||||||
|
|
||||||
|
# Cluster duplicates using union-find
|
||||||
|
clusters = self._cluster_duplicates(
|
||||||
|
[(p["doc_a"], p["doc_b"], p["similarity"]) for p in duplicate_pairs]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate duplicate density
|
||||||
|
total_possible = total_docs * (total_docs - 1) / 2
|
||||||
|
density = len(duplicate_pairs) / total_possible if total_possible > 0 else 0.0
|
||||||
|
|
||||||
|
# Log the operation
|
||||||
|
self.db.insert_lifecycle_log(
|
||||||
|
kb_name=kb_name,
|
||||||
|
operation_type="dedup_scan",
|
||||||
|
status="completed",
|
||||||
|
details={
|
||||||
|
"total_documents": total_docs,
|
||||||
|
"threshold": threshold,
|
||||||
|
"pairs_found": len(duplicate_pairs),
|
||||||
|
"clusters_found": len(clusters),
|
||||||
|
"density": round(density, 4)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total_documents": total_docs,
|
||||||
|
"duplicate_pairs": duplicate_pairs,
|
||||||
|
"clusters": clusters,
|
||||||
|
"duplicate_density": round(density, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
def _cluster_duplicates(
|
||||||
|
self, pairs: List[Tuple[str, str, float]]
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Group duplicate pairs into clusters using union-find."""
|
||||||
|
parent = {}
|
||||||
|
|
||||||
|
def find(x):
|
||||||
|
if x not in parent:
|
||||||
|
parent[x] = x
|
||||||
|
while parent[x] != x:
|
||||||
|
parent[x] = parent[parent[x]]
|
||||||
|
x = parent[x]
|
||||||
|
return x
|
||||||
|
|
||||||
|
def union(a, b):
|
||||||
|
ra, rb = find(a), find(b)
|
||||||
|
if ra != rb:
|
||||||
|
parent[ra] = rb
|
||||||
|
|
||||||
|
for doc_a, doc_b, _ in pairs:
|
||||||
|
union(doc_a, doc_b)
|
||||||
|
|
||||||
|
# Group by root
|
||||||
|
groups = defaultdict(list)
|
||||||
|
all_docs = set()
|
||||||
|
for doc_a, doc_b, _ in pairs:
|
||||||
|
all_docs.add(doc_a)
|
||||||
|
all_docs.add(doc_b)
|
||||||
|
|
||||||
|
for doc_id in all_docs:
|
||||||
|
groups[find(doc_id)].append(doc_id)
|
||||||
|
|
||||||
|
clusters = []
|
||||||
|
for root, members in groups.items():
|
||||||
|
if len(members) > 1:
|
||||||
|
clusters.append({
|
||||||
|
"cluster_id": root,
|
||||||
|
"doc_ids": sorted(set(members)),
|
||||||
|
"size": len(set(members))
|
||||||
|
})
|
||||||
|
|
||||||
|
return clusters
|
||||||
|
|
||||||
|
def merge_duplicates(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
keep_doc_id: str,
|
||||||
|
remove_doc_ids: List[str],
|
||||||
|
dry_run: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Merge duplicates by keeping one document and deleting the rest.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
kb_name: Knowledge base name
|
||||||
|
keep_doc_id: Document ID to keep
|
||||||
|
remove_doc_ids: Document IDs to remove
|
||||||
|
dry_run: If True, preview only without making changes
|
||||||
|
"""
|
||||||
|
log_id = self.db.insert_lifecycle_log(
|
||||||
|
kb_name=kb_name,
|
||||||
|
operation_type="dedup_merge",
|
||||||
|
status="pending",
|
||||||
|
input_doc_ids=[keep_doc_id] + remove_doc_ids,
|
||||||
|
details={"dry_run": dry_run, "keep_doc_id": keep_doc_id}
|
||||||
|
)
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.db.update_lifecycle_log(
|
||||||
|
log_id, "completed",
|
||||||
|
details={"dry_run": True, "would_remove": remove_doc_ids}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"keep_doc_id": keep_doc_id,
|
||||||
|
"would_remove": remove_doc_ids,
|
||||||
|
"remove_count": len(remove_doc_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
deleted_count = self.db.delete_documents_by_ids(kb_name, remove_doc_ids)
|
||||||
|
|
||||||
|
self.db.update_lifecycle_log(
|
||||||
|
log_id, "completed",
|
||||||
|
output_doc_ids=[keep_doc_id],
|
||||||
|
details={"deleted_count": deleted_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"dry_run": False,
|
||||||
|
"keep_doc_id": keep_doc_id,
|
||||||
|
"removed": remove_doc_ids,
|
||||||
|
"deleted_count": deleted_count
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# CONSOLIDATION
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def consolidate_related(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
doc_ids: List[str],
|
||||||
|
new_filename: str,
|
||||||
|
dry_run: bool = False
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Consolidate multiple related documents into a single merged document
|
||||||
|
using LLM-powered summarization.
|
||||||
|
"""
|
||||||
|
# Fetch source documents
|
||||||
|
all_docs = self.db.get_all_embeddings(kb_name, exclude_archived=False)
|
||||||
|
doc_map = {d["doc_id"]: d for d in all_docs}
|
||||||
|
|
||||||
|
source_docs = []
|
||||||
|
for doc_id in doc_ids:
|
||||||
|
if doc_id in doc_map:
|
||||||
|
source_docs.append(doc_map[doc_id])
|
||||||
|
|
||||||
|
if len(source_docs) < 2:
|
||||||
|
return {"error": "Need at least 2 documents to consolidate"}
|
||||||
|
|
||||||
|
source_texts = [d["content"] for d in source_docs]
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
return {
|
||||||
|
"dry_run": True,
|
||||||
|
"source_doc_ids": doc_ids,
|
||||||
|
"source_count": len(source_docs),
|
||||||
|
"total_chars": sum(len(t) for t in source_texts),
|
||||||
|
"previews": [t[:200] for t in source_texts]
|
||||||
|
}
|
||||||
|
|
||||||
|
log_id = self.db.insert_lifecycle_log(
|
||||||
|
kb_name=kb_name,
|
||||||
|
operation_type="consolidate",
|
||||||
|
status="running",
|
||||||
|
input_doc_ids=doc_ids,
|
||||||
|
details={"new_filename": new_filename}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Generate consolidated content via LLM
|
||||||
|
consolidated_text = self._generate_consolidation(source_texts, kb_name)
|
||||||
|
|
||||||
|
if not consolidated_text:
|
||||||
|
self.db.update_lifecycle_log(
|
||||||
|
log_id, "failed",
|
||||||
|
details={"error": "LLM consolidation failed"}
|
||||||
|
)
|
||||||
|
return {"error": "Failed to generate consolidated content"}
|
||||||
|
|
||||||
|
# Generate embedding for the new document
|
||||||
|
try:
|
||||||
|
embed_response = requests.post(
|
||||||
|
f"{Config.OLLAMA_HOST}/api/embed",
|
||||||
|
json={"model": Config.OLLAMA_EMBED_MODEL, "input": consolidated_text},
|
||||||
|
timeout=30
|
||||||
|
)
|
||||||
|
embed_response.raise_for_status()
|
||||||
|
embedding = embed_response.json().get("embeddings", [[]])[0]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to embed consolidated doc: {e}")
|
||||||
|
embedding = None
|
||||||
|
|
||||||
|
# Build metadata with provenance
|
||||||
|
metadata = {
|
||||||
|
"filename": new_filename,
|
||||||
|
"type": "text/markdown",
|
||||||
|
"consolidated_from": doc_ids,
|
||||||
|
"consolidation_date": datetime.now().isoformat(),
|
||||||
|
"upload_date": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Insert the new document
|
||||||
|
import uuid
|
||||||
|
new_doc_id = f"consolidated-{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
if embedding:
|
||||||
|
self.db.add_documents(
|
||||||
|
kb_name=kb_name,
|
||||||
|
documents=[consolidated_text],
|
||||||
|
embeddings=[embedding],
|
||||||
|
metadatas=[metadata],
|
||||||
|
ids=[new_doc_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete the source documents
|
||||||
|
deleted = self.db.delete_documents_by_ids(kb_name, doc_ids)
|
||||||
|
|
||||||
|
self.db.update_lifecycle_log(
|
||||||
|
log_id, "completed",
|
||||||
|
output_doc_ids=[new_doc_id],
|
||||||
|
details={
|
||||||
|
"new_doc_id": new_doc_id,
|
||||||
|
"sources_deleted": deleted,
|
||||||
|
"consolidated_length": len(consolidated_text)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"new_doc_id": new_doc_id,
|
||||||
|
"new_filename": new_filename,
|
||||||
|
"sources_consolidated": len(source_docs),
|
||||||
|
"sources_deleted": deleted,
|
||||||
|
"consolidated_length": len(consolidated_text),
|
||||||
|
"preview": consolidated_text[:500]
|
||||||
|
}
|
||||||
|
|
||||||
|
def _generate_consolidation(self, source_texts: List[str], kb_name: str) -> Optional[str]:
|
||||||
|
"""Generate a consolidated summary using Ollama LLM."""
|
||||||
|
combined = "\n\n---\n\n".join(
|
||||||
|
f"**Document {i+1}:**\n{text}" for i, text in enumerate(source_texts)
|
||||||
|
)
|
||||||
|
|
||||||
|
prompt = f"""You are consolidating related knowledge base documents into a single comprehensive document.
|
||||||
|
|
||||||
|
Knowledge base: {kb_name}
|
||||||
|
|
||||||
|
Below are {len(source_texts)} related documents. Create a single consolidated document that:
|
||||||
|
1. Preserves all unique information from each source
|
||||||
|
2. Eliminates redundancy
|
||||||
|
3. Organizes information logically
|
||||||
|
4. Uses clear markdown formatting
|
||||||
|
|
||||||
|
Source documents:
|
||||||
|
|
||||||
|
{combined}
|
||||||
|
|
||||||
|
Write the consolidated document:"""
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
f"{Config.OLLAMA_HOST}/api/generate",
|
||||||
|
json={
|
||||||
|
"model": Config.OLLAMA_MODEL,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"options": {
|
||||||
|
"temperature": 0.3,
|
||||||
|
"num_predict": 2048
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeout=120
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json().get("response", "")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"LLM consolidation failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# ANALYTICS
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def get_health_report(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
include_top_similar: bool = True,
|
||||||
|
top_similar_count: int = 10
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Generate a comprehensive health report for a knowledge base."""
|
||||||
|
# Basic stats
|
||||||
|
stats = get_collection_stats(kb_name)
|
||||||
|
docs_metadata = get_documents_metadata(kb_name)
|
||||||
|
|
||||||
|
report = {
|
||||||
|
"kb_name": kb_name,
|
||||||
|
"stats": stats,
|
||||||
|
"document_count": stats.get("total_documents", 0),
|
||||||
|
"chunk_count": stats.get("total_chunks", 0),
|
||||||
|
"last_updated": stats.get("last_updated"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Embedding coverage
|
||||||
|
all_docs = self.db.get_all_embeddings(kb_name, exclude_archived=False)
|
||||||
|
total = len(all_docs)
|
||||||
|
with_embeddings = sum(1 for d in all_docs if d["embedding"] is not None)
|
||||||
|
archived = sum(1 for d in all_docs if d["metadata"].get("archived"))
|
||||||
|
|
||||||
|
report["embedding_coverage"] = {
|
||||||
|
"total_docs": total,
|
||||||
|
"with_embeddings": with_embeddings,
|
||||||
|
"without_embeddings": total - with_embeddings,
|
||||||
|
"coverage_pct": round(with_embeddings / total * 100, 1) if total > 0 else 0
|
||||||
|
}
|
||||||
|
report["archived_count"] = archived
|
||||||
|
|
||||||
|
# Top similar pairs (for duplicate detection preview)
|
||||||
|
if include_top_similar and with_embeddings >= 2:
|
||||||
|
docs_with_emb = [d for d in all_docs if d["embedding"] is not None and not d["metadata"].get("archived")]
|
||||||
|
if len(docs_with_emb) >= 2:
|
||||||
|
embeddings = np.array([d["embedding"] for d in docs_with_emb])
|
||||||
|
norms = np.linalg.norm(embeddings, axis=1, keepdims=True)
|
||||||
|
norms[norms == 0] = 1
|
||||||
|
normalized = embeddings / norms
|
||||||
|
|
||||||
|
top_pairs = []
|
||||||
|
for i in range(len(docs_with_emb)):
|
||||||
|
for j in range(i + 1, len(docs_with_emb)):
|
||||||
|
sim = float(np.dot(normalized[i], normalized[j]))
|
||||||
|
if sim > 0.7:
|
||||||
|
top_pairs.append({
|
||||||
|
"doc_a": docs_with_emb[i]["doc_id"],
|
||||||
|
"doc_b": docs_with_emb[j]["doc_id"],
|
||||||
|
"similarity": round(sim, 4)
|
||||||
|
})
|
||||||
|
|
||||||
|
top_pairs.sort(key=lambda x: x["similarity"], reverse=True)
|
||||||
|
report["top_similar_pairs"] = top_pairs[:top_similar_count]
|
||||||
|
else:
|
||||||
|
report["top_similar_pairs"] = []
|
||||||
|
else:
|
||||||
|
report["top_similar_pairs"] = []
|
||||||
|
|
||||||
|
# Document age distribution
|
||||||
|
age_distribution = {"last_7_days": 0, "last_30_days": 0, "last_90_days": 0, "older": 0}
|
||||||
|
now = datetime.now()
|
||||||
|
for doc in docs_metadata:
|
||||||
|
upload_date_str = doc.get("upload_date", "")
|
||||||
|
if upload_date_str:
|
||||||
|
try:
|
||||||
|
upload_date = datetime.fromisoformat(upload_date_str)
|
||||||
|
age = now - upload_date
|
||||||
|
if age <= timedelta(days=7):
|
||||||
|
age_distribution["last_7_days"] += 1
|
||||||
|
elif age <= timedelta(days=30):
|
||||||
|
age_distribution["last_30_days"] += 1
|
||||||
|
elif age <= timedelta(days=90):
|
||||||
|
age_distribution["last_90_days"] += 1
|
||||||
|
else:
|
||||||
|
age_distribution["older"] += 1
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
age_distribution["older"] += 1
|
||||||
|
|
||||||
|
report["age_distribution"] = age_distribution
|
||||||
|
|
||||||
|
# Recent lifecycle operations
|
||||||
|
recent_ops = self.db.get_lifecycle_logs(kb_name, limit=5)
|
||||||
|
report["recent_operations"] = recent_ops
|
||||||
|
|
||||||
|
# Recommendations
|
||||||
|
recommendations = []
|
||||||
|
if len(report.get("top_similar_pairs", [])) > 0:
|
||||||
|
recommendations.append({
|
||||||
|
"type": "dedup",
|
||||||
|
"priority": "high" if len(report["top_similar_pairs"]) > 5 else "medium",
|
||||||
|
"message": f"Found {len(report['top_similar_pairs'])} highly similar document pairs. Consider running dedup scan."
|
||||||
|
})
|
||||||
|
if archived > total * 0.3 and total > 0:
|
||||||
|
recommendations.append({
|
||||||
|
"type": "cleanup",
|
||||||
|
"priority": "low",
|
||||||
|
"message": f"{archived} of {total} documents are archived. Consider permanent deletion."
|
||||||
|
})
|
||||||
|
if age_distribution["older"] > total * 0.5 and total > 10:
|
||||||
|
recommendations.append({
|
||||||
|
"type": "stale",
|
||||||
|
"priority": "medium",
|
||||||
|
"message": f"{age_distribution['older']} documents are older than 90 days. Review for archival."
|
||||||
|
})
|
||||||
|
if report["embedding_coverage"]["without_embeddings"] > 0:
|
||||||
|
recommendations.append({
|
||||||
|
"type": "embeddings",
|
||||||
|
"priority": "medium",
|
||||||
|
"message": f"{report['embedding_coverage']['without_embeddings']} documents lack embeddings. Re-index recommended."
|
||||||
|
})
|
||||||
|
|
||||||
|
report["recommendations"] = recommendations
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def get_growth_timeline(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
granularity: str = "month"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Get document growth timeline grouped by period."""
|
||||||
|
docs_metadata = get_documents_metadata(kb_name)
|
||||||
|
|
||||||
|
timeline = defaultdict(int)
|
||||||
|
for doc in docs_metadata:
|
||||||
|
upload_date_str = doc.get("upload_date", "")
|
||||||
|
if upload_date_str:
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(upload_date_str)
|
||||||
|
if granularity == "day":
|
||||||
|
key = dt.strftime("%Y-%m-%d")
|
||||||
|
elif granularity == "week":
|
||||||
|
key = f"{dt.year}-W{dt.isocalendar()[1]:02d}"
|
||||||
|
else: # month
|
||||||
|
key = dt.strftime("%Y-%m")
|
||||||
|
timeline[key] += 1
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
sorted_timeline = sorted(timeline.items())
|
||||||
|
|
||||||
|
# Compute cumulative
|
||||||
|
cumulative = []
|
||||||
|
running_total = 0
|
||||||
|
for period, count in sorted_timeline:
|
||||||
|
running_total += count
|
||||||
|
cumulative.append({"period": period, "added": count, "total": running_total})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kb_name": kb_name,
|
||||||
|
"granularity": granularity,
|
||||||
|
"timeline": cumulative,
|
||||||
|
"total_documents": len(docs_metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========================================================================
|
||||||
|
# ARCHIVAL
|
||||||
|
# ========================================================================
|
||||||
|
|
||||||
|
def archive_documents(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
doc_ids: List[str],
|
||||||
|
reason: str = "manual"
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Archive documents by setting metadata flags."""
|
||||||
|
log_id = self.db.insert_lifecycle_log(
|
||||||
|
kb_name=kb_name,
|
||||||
|
operation_type="archive",
|
||||||
|
status="running",
|
||||||
|
input_doc_ids=doc_ids,
|
||||||
|
details={"reason": reason}
|
||||||
|
)
|
||||||
|
|
||||||
|
archived_count = 0
|
||||||
|
for doc_id in doc_ids:
|
||||||
|
success = self.db.update_document_metadata(kb_name, doc_id, {
|
||||||
|
"archived": True,
|
||||||
|
"archived_at": datetime.now().isoformat(),
|
||||||
|
"archive_reason": reason
|
||||||
|
})
|
||||||
|
if success:
|
||||||
|
archived_count += 1
|
||||||
|
|
||||||
|
self.db.update_lifecycle_log(
|
||||||
|
log_id, "completed",
|
||||||
|
output_doc_ids=doc_ids,
|
||||||
|
details={"archived_count": archived_count, "reason": reason}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"archived_count": archived_count,
|
||||||
|
"doc_ids": doc_ids,
|
||||||
|
"reason": reason
|
||||||
|
}
|
||||||
|
|
||||||
|
def restore_documents(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
doc_ids: List[str]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Restore archived documents by clearing archive flags."""
|
||||||
|
log_id = self.db.insert_lifecycle_log(
|
||||||
|
kb_name=kb_name,
|
||||||
|
operation_type="restore",
|
||||||
|
status="running",
|
||||||
|
input_doc_ids=doc_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
restored_count = 0
|
||||||
|
for doc_id in doc_ids:
|
||||||
|
success = self.db.update_document_metadata(kb_name, doc_id, {
|
||||||
|
"archived": False,
|
||||||
|
"archived_at": None,
|
||||||
|
"archive_reason": None
|
||||||
|
})
|
||||||
|
if success:
|
||||||
|
restored_count += 1
|
||||||
|
|
||||||
|
self.db.update_lifecycle_log(
|
||||||
|
log_id, "completed",
|
||||||
|
output_doc_ids=doc_ids,
|
||||||
|
details={"restored_count": restored_count}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"restored_count": restored_count,
|
||||||
|
"doc_ids": doc_ids
|
||||||
|
}
|
||||||
|
|
||||||
|
def find_stale_documents(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
stale_days: int = 90
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""Find documents older than stale_days that are not archived."""
|
||||||
|
docs_metadata = get_documents_metadata(kb_name)
|
||||||
|
cutoff = datetime.now() - timedelta(days=stale_days)
|
||||||
|
|
||||||
|
stale_docs = []
|
||||||
|
for doc in docs_metadata:
|
||||||
|
upload_date_str = doc.get("upload_date", "")
|
||||||
|
if upload_date_str:
|
||||||
|
try:
|
||||||
|
upload_date = datetime.fromisoformat(upload_date_str)
|
||||||
|
if upload_date < cutoff:
|
||||||
|
stale_docs.append({
|
||||||
|
"filename": doc.get("filename", "unknown"),
|
||||||
|
"upload_date": upload_date_str,
|
||||||
|
"age_days": (datetime.now() - upload_date).days
|
||||||
|
})
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
stale_docs.sort(key=lambda x: x.get("age_days", 0), reverse=True)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kb_name": kb_name,
|
||||||
|
"stale_days_threshold": stale_days,
|
||||||
|
"stale_count": len(stale_docs),
|
||||||
|
"stale_documents": stale_docs
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_archived(self, kb_name: str) -> Dict[str, Any]:
|
||||||
|
"""List all archived documents in a KB."""
|
||||||
|
all_docs = self.db.get_all_embeddings(kb_name, exclude_archived=False)
|
||||||
|
|
||||||
|
archived_docs = []
|
||||||
|
for doc in all_docs:
|
||||||
|
if doc["metadata"].get("archived"):
|
||||||
|
archived_docs.append({
|
||||||
|
"doc_id": doc["doc_id"],
|
||||||
|
"content_preview": doc["content"][:200],
|
||||||
|
"archived_at": doc["metadata"].get("archived_at"),
|
||||||
|
"archive_reason": doc["metadata"].get("archive_reason", "unknown")
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
"kb_name": kb_name,
|
||||||
|
"archived_count": len(archived_docs),
|
||||||
|
"archived_documents": archived_docs
|
||||||
|
}
|
||||||
18
app/core/migrations/005_add_lifecycle_tracking.sql
Normal file
18
app/core/migrations/005_add_lifecycle_tracking.sql
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
-- Migration: Add Knowledge Lifecycle Tracking
|
||||||
|
-- Created: 2026-02-06
|
||||||
|
-- Purpose: Audit log for deduplication, consolidation, archival operations
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS lifecycle_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kb_name TEXT NOT NULL,
|
||||||
|
operation_type TEXT NOT NULL, -- dedup_scan, dedup_merge, consolidate, archive, restore
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
input_doc_ids TEXT DEFAULT '[]', -- JSON array
|
||||||
|
output_doc_ids TEXT DEFAULT '[]', -- JSON array
|
||||||
|
details TEXT DEFAULT '{}', -- JSON blob
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lifecycle_kb ON lifecycle_log(kb_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lifecycle_op ON lifecycle_log(operation_type);
|
||||||
|
|
@ -765,6 +765,213 @@ class SQLiteManager:
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
# Knowledge Lifecycle Operations
|
||||||
|
|
||||||
|
def get_all_embeddings(self, kb_name: str, exclude_archived: bool = True) -> List[Dict[str, Any]]:
|
||||||
|
"""Get all documents with embeddings for pairwise similarity analysis."""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM knowledge_bases WHERE name = ?", (kb_name,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if not result:
|
||||||
|
raise ValueError(f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
kb_id = result['id']
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT doc_id, content, embedding, metadata FROM documents WHERE kb_id = ?",
|
||||||
|
(kb_id,)
|
||||||
|
)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
||||||
|
|
||||||
|
if exclude_archived and metadata.get("archived"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
embedding = None
|
||||||
|
if row['embedding'] is not None:
|
||||||
|
embedding = np.frombuffer(row['embedding'], dtype=np.float32)
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"doc_id": row['doc_id'],
|
||||||
|
"content": row['content'],
|
||||||
|
"embedding": embedding,
|
||||||
|
"metadata": metadata
|
||||||
|
})
|
||||||
|
|
||||||
|
return results
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_document_metadata(self, kb_name: str, doc_id: str, metadata_updates: Dict[str, Any]) -> bool:
|
||||||
|
"""Merge metadata updates into an existing document's metadata."""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM knowledge_bases WHERE name = ?", (kb_name,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if not result:
|
||||||
|
raise ValueError(f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
kb_id = result['id']
|
||||||
|
|
||||||
|
# Get current metadata
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT metadata FROM documents WHERE kb_id = ? AND doc_id = ?",
|
||||||
|
(kb_id, doc_id)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return False
|
||||||
|
|
||||||
|
current_metadata = json.loads(row['metadata']) if row['metadata'] else {}
|
||||||
|
current_metadata.update(metadata_updates)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE documents SET metadata = ? WHERE kb_id = ? AND doc_id = ?",
|
||||||
|
(json.dumps(current_metadata), kb_id, doc_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_documents_by_ids(self, kb_name: str, doc_ids: List[str]) -> int:
|
||||||
|
"""Bulk delete documents by doc_id list. Returns count deleted."""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("SELECT id FROM knowledge_bases WHERE name = ?", (kb_name,))
|
||||||
|
result = cursor.fetchone()
|
||||||
|
if not result:
|
||||||
|
raise ValueError(f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
kb_id = result['id']
|
||||||
|
placeholders = ",".join(["?"] * len(doc_ids))
|
||||||
|
cursor.execute(
|
||||||
|
f"DELETE FROM documents WHERE kb_id = ? AND doc_id IN ({placeholders})",
|
||||||
|
[kb_id] + list(doc_ids)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def insert_lifecycle_log(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
operation_type: str,
|
||||||
|
status: str = "pending",
|
||||||
|
input_doc_ids: List[str] = None,
|
||||||
|
output_doc_ids: List[str] = None,
|
||||||
|
details: Dict[str, Any] = None
|
||||||
|
) -> int:
|
||||||
|
"""Insert a lifecycle audit log entry. Returns log ID."""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO lifecycle_log (kb_name, operation_type, status, input_doc_ids, output_doc_ids, details)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
kb_name,
|
||||||
|
operation_type,
|
||||||
|
status,
|
||||||
|
json.dumps(input_doc_ids or []),
|
||||||
|
json.dumps(output_doc_ids or []),
|
||||||
|
json.dumps(details or {})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.lastrowid
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_lifecycle_log(
|
||||||
|
self,
|
||||||
|
log_id: int,
|
||||||
|
status: str,
|
||||||
|
output_doc_ids: List[str] = None,
|
||||||
|
details: Dict[str, Any] = None
|
||||||
|
) -> bool:
|
||||||
|
"""Update lifecycle log status and completion."""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
updates = ["status = ?"]
|
||||||
|
params = [status]
|
||||||
|
|
||||||
|
if output_doc_ids is not None:
|
||||||
|
updates.append("output_doc_ids = ?")
|
||||||
|
params.append(json.dumps(output_doc_ids))
|
||||||
|
|
||||||
|
if details is not None:
|
||||||
|
updates.append("details = ?")
|
||||||
|
params.append(json.dumps(details))
|
||||||
|
|
||||||
|
if status in ("completed", "failed"):
|
||||||
|
updates.append("completed_at = CURRENT_TIMESTAMP")
|
||||||
|
|
||||||
|
params.append(log_id)
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE lifecycle_log SET {', '.join(updates)} WHERE id = ?",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_lifecycle_logs(
|
||||||
|
self,
|
||||||
|
kb_name: str,
|
||||||
|
operation_type: str = None,
|
||||||
|
limit: int = 50
|
||||||
|
) -> List[Dict[str, Any]]:
|
||||||
|
"""Query lifecycle logs with optional filters."""
|
||||||
|
conn = self.get_connection()
|
||||||
|
try:
|
||||||
|
cursor = conn.cursor()
|
||||||
|
conditions = ["kb_name = ?"]
|
||||||
|
params: list = [kb_name]
|
||||||
|
|
||||||
|
if operation_type:
|
||||||
|
conditions.append("operation_type = ?")
|
||||||
|
params.append(operation_type)
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT id, kb_name, operation_type, status, input_doc_ids, output_doc_ids,
|
||||||
|
details, created_at, completed_at
|
||||||
|
FROM lifecycle_log
|
||||||
|
WHERE {' AND '.join(conditions)}
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
"""
|
||||||
|
params.append(limit)
|
||||||
|
cursor.execute(query, params)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
results.append({
|
||||||
|
"id": row['id'],
|
||||||
|
"kb_name": row['kb_name'],
|
||||||
|
"operation_type": row['operation_type'],
|
||||||
|
"status": row['status'],
|
||||||
|
"input_doc_ids": json.loads(row['input_doc_ids']) if row['input_doc_ids'] else [],
|
||||||
|
"output_doc_ids": json.loads(row['output_doc_ids']) if row['output_doc_ids'] else [],
|
||||||
|
"details": json.loads(row['details']) if row['details'] else {},
|
||||||
|
"created_at": row['created_at'],
|
||||||
|
"completed_at": row['completed_at']
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
"""Close database connections (no-op for SQLite)."""
|
"""Close database connections (no-op for SQLite)."""
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -101,3 +101,19 @@ CREATE TABLE IF NOT EXISTS project_kb_folders (
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_pkf_project_id ON project_kb_folders(project_id);
|
CREATE INDEX IF NOT EXISTS idx_pkf_project_id ON project_kb_folders(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_pkf_mcp_type ON project_kb_folders(mcp_type);
|
CREATE INDEX IF NOT EXISTS idx_pkf_mcp_type ON project_kb_folders(mcp_type);
|
||||||
|
|
||||||
|
-- Knowledge Lifecycle Tracking (for dedup, consolidation, archival audit log)
|
||||||
|
CREATE TABLE IF NOT EXISTS lifecycle_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
kb_name TEXT NOT NULL,
|
||||||
|
operation_type TEXT NOT NULL, -- dedup_scan, dedup_merge, consolidate, archive, restore
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
input_doc_ids TEXT DEFAULT '[]', -- JSON array
|
||||||
|
output_doc_ids TEXT DEFAULT '[]', -- JSON array
|
||||||
|
details TEXT DEFAULT '{}', -- JSON blob
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lifecycle_kb ON lifecycle_log(kb_name);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lifecycle_op ON lifecycle_log(operation_type);
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,10 @@ Complete reference for all Claude OS MCP Server API endpoints.
|
||||||
7. [Real-Time Spec Watcher](#real-time-spec-watcher-new)
|
7. [Real-Time Spec Watcher](#real-time-spec-watcher-new)
|
||||||
8. [Hooks System](#hooks-system)
|
8. [Hooks System](#hooks-system)
|
||||||
9. [File Watcher](#file-watcher)
|
9. [File Watcher](#file-watcher)
|
||||||
10. [Authentication](#authentication)
|
10. [Knowledge Lifecycle](#knowledge-lifecycle-new)
|
||||||
11. [Utilities](#utilities)
|
11. [Authentication](#authentication)
|
||||||
12. [Health Check](#health-check)
|
12. [Utilities](#utilities)
|
||||||
|
13. [Health Check](#health-check)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -1443,6 +1444,329 @@ curl "http://localhost:8051/api/kb/myproject-code_structure/repo-map?token_budge
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Knowledge Lifecycle (NEW)
|
||||||
|
|
||||||
|
Manage the health and lifecycle of knowledge base documents: deduplication, consolidation, archival, and analytics.
|
||||||
|
|
||||||
|
All endpoints are under `/api/kb/{kb_name}/lifecycle/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Dedup Scan
|
||||||
|
|
||||||
|
Scan for duplicate/near-duplicate documents using embedding similarity.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/kb/{kb_name}/lifecycle/dedup-scan
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"threshold": 0.85,
|
||||||
|
"max_pairs": 100
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (sync for <500 docs):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_documents": 47,
|
||||||
|
"duplicate_pairs": [
|
||||||
|
{
|
||||||
|
"doc_a": "doc-abc123",
|
||||||
|
"doc_b": "doc-def456",
|
||||||
|
"similarity": 0.94,
|
||||||
|
"content_a_preview": "Authentication using JWT...",
|
||||||
|
"content_b_preview": "JWT token authentication..."
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"clusters": [
|
||||||
|
{
|
||||||
|
"cluster_id": "doc-abc123",
|
||||||
|
"doc_ids": ["doc-abc123", "doc-def456", "doc-ghi789"],
|
||||||
|
"size": 3
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"duplicate_density": 0.064
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (background for >500 docs):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"job_id": "dedup-my-kb-a1b2c3d4",
|
||||||
|
"mode": "background",
|
||||||
|
"message": "Dedup scan started in background. Check GET /api/jobs/dedup-my-kb-a1b2c3d4"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Dedup Merge
|
||||||
|
|
||||||
|
Merge duplicates by keeping one document and deleting the rest.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/kb/{kb_name}/lifecycle/dedup-merge
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"keep_doc_id": "doc-abc123",
|
||||||
|
"remove_doc_ids": ["doc-def456", "doc-ghi789"],
|
||||||
|
"dry_run": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dry_run": false,
|
||||||
|
"keep_doc_id": "doc-abc123",
|
||||||
|
"removed": ["doc-def456", "doc-ghi789"],
|
||||||
|
"deleted_count": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Consolidate
|
||||||
|
|
||||||
|
Consolidate multiple related documents into a single merged document using LLM-powered summarization. Always runs in background (LLM call).
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/kb/{kb_name}/lifecycle/consolidate
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"doc_ids": ["doc-abc123", "doc-def456", "doc-ghi789"],
|
||||||
|
"new_filename": "consolidated-auth-patterns.md",
|
||||||
|
"dry_run": false
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (dry_run=true):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dry_run": true,
|
||||||
|
"source_doc_ids": ["doc-abc123", "doc-def456"],
|
||||||
|
"source_count": 2,
|
||||||
|
"total_chars": 4250,
|
||||||
|
"previews": ["Authentication using JWT...", "JWT token auth..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response (dry_run=false):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"job_id": "consolidate-my-kb-e5f6g7h8",
|
||||||
|
"mode": "background",
|
||||||
|
"message": "Consolidation started. Check GET /api/jobs/consolidate-my-kb-e5f6g7h8"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Health Report
|
||||||
|
|
||||||
|
Get a comprehensive health report for a knowledge base.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/kb/{kb_name}/lifecycle/health
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kb_name": "my-project-project_memories",
|
||||||
|
"document_count": 47,
|
||||||
|
"chunk_count": 47,
|
||||||
|
"last_updated": "2026-02-05T10:30:00",
|
||||||
|
"embedding_coverage": {
|
||||||
|
"total_docs": 47,
|
||||||
|
"with_embeddings": 42,
|
||||||
|
"without_embeddings": 5,
|
||||||
|
"coverage_pct": 89.4
|
||||||
|
},
|
||||||
|
"archived_count": 3,
|
||||||
|
"top_similar_pairs": [
|
||||||
|
{"doc_a": "doc-abc", "doc_b": "doc-def", "similarity": 0.94}
|
||||||
|
],
|
||||||
|
"age_distribution": {
|
||||||
|
"last_7_days": 8,
|
||||||
|
"last_30_days": 15,
|
||||||
|
"last_90_days": 12,
|
||||||
|
"older": 12
|
||||||
|
},
|
||||||
|
"recent_operations": [],
|
||||||
|
"recommendations": [
|
||||||
|
{
|
||||||
|
"type": "dedup",
|
||||||
|
"priority": "high",
|
||||||
|
"message": "Found 3 highly similar document pairs. Consider running dedup scan."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Growth Timeline
|
||||||
|
|
||||||
|
Get document growth timeline grouped by period.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/kb/{kb_name}/lifecycle/growth?granularity=month
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `granularity` - `day`, `week`, or `month` (default: `month`)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kb_name": "my-project-project_memories",
|
||||||
|
"granularity": "month",
|
||||||
|
"timeline": [
|
||||||
|
{"period": "2025-10", "added": 12, "total": 12},
|
||||||
|
{"period": "2025-11", "added": 20, "total": 32},
|
||||||
|
{"period": "2025-12", "added": 15, "total": 47}
|
||||||
|
],
|
||||||
|
"total_documents": 47
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Archive Documents
|
||||||
|
|
||||||
|
Soft-archive documents (excluded from search but restorable).
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/kb/{kb_name}/lifecycle/archive
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"doc_ids": ["doc-abc123", "doc-def456"],
|
||||||
|
"reason": "stale - over 90 days"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"archived_count": 2,
|
||||||
|
"doc_ids": ["doc-abc123", "doc-def456"],
|
||||||
|
"reason": "stale - over 90 days"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Restore Documents
|
||||||
|
|
||||||
|
Restore previously archived documents.
|
||||||
|
|
||||||
|
```http
|
||||||
|
POST /api/kb/{kb_name}/lifecycle/restore
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"doc_ids": ["doc-abc123"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"restored_count": 1,
|
||||||
|
"doc_ids": ["doc-abc123"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Archived
|
||||||
|
|
||||||
|
List all archived documents in a knowledge base.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/kb/{kb_name}/lifecycle/archived
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kb_name": "my-project-project_memories",
|
||||||
|
"archived_count": 3,
|
||||||
|
"archived_documents": [
|
||||||
|
{
|
||||||
|
"doc_id": "doc-abc123",
|
||||||
|
"content_preview": "Old authentication pattern...",
|
||||||
|
"archived_at": "2026-02-01T10:00:00",
|
||||||
|
"archive_reason": "stale - over 90 days"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Find Stale Documents
|
||||||
|
|
||||||
|
Find documents older than a specified threshold.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/kb/{kb_name}/lifecycle/stale?stale_days=90
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"kb_name": "my-project-project_memories",
|
||||||
|
"stale_days_threshold": 90,
|
||||||
|
"stale_count": 5,
|
||||||
|
"stale_documents": [
|
||||||
|
{"filename": "old-pattern.md", "upload_date": "2025-09-15T10:00:00", "age_days": 142}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Lifecycle Logs
|
||||||
|
|
||||||
|
Get audit log of lifecycle operations.
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/kb/{kb_name}/lifecycle/logs?operation_type=dedup_scan&limit=50
|
||||||
|
```
|
||||||
|
|
||||||
|
**Query Parameters:**
|
||||||
|
- `operation_type` (optional) - Filter by type: `dedup_scan`, `dedup_merge`, `consolidate`, `archive`, `restore`
|
||||||
|
- `limit` (optional) - Max results (default: 50)
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"logs": [
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"kb_name": "my-project-project_memories",
|
||||||
|
"operation_type": "dedup_scan",
|
||||||
|
"status": "completed",
|
||||||
|
"input_doc_ids": [],
|
||||||
|
"output_doc_ids": [],
|
||||||
|
"details": {"total_documents": 47, "pairs_found": 3},
|
||||||
|
"created_at": "2026-02-06T10:30:00",
|
||||||
|
"completed_at": "2026-02-06T10:30:02"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
For issues, questions, or contributions:
|
For issues, questions, or contributions:
|
||||||
|
|
@ -1451,5 +1775,5 @@ For issues, questions, or contributions:
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
**Last Updated:** 2025-12-11
|
**Last Updated:** 2026-02-06
|
||||||
**API Version:** 2.3
|
**API Version:** 2.4
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,65 @@ async def list_tools() -> list[Tool]:
|
||||||
},
|
},
|
||||||
"required": ["name", "project_path"]
|
"required": ["name", "project_path"]
|
||||||
}
|
}
|
||||||
|
),
|
||||||
|
|
||||||
|
# Knowledge Lifecycle Tools
|
||||||
|
Tool(
|
||||||
|
name="kb_lifecycle_health",
|
||||||
|
description="Get a comprehensive health report for a knowledge base including duplicate detection, embedding coverage, age distribution, and recommendations.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kb_name": {"type": "string", "description": "Name of the knowledge base"}
|
||||||
|
},
|
||||||
|
"required": ["kb_name"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="kb_lifecycle_dedup",
|
||||||
|
description="Scan for and merge duplicate documents in a knowledge base. Use action='scan' to find duplicates, action='merge' to merge them.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kb_name": {"type": "string", "description": "Name of the knowledge base"},
|
||||||
|
"action": {"type": "string", "enum": ["scan", "merge"], "description": "Action to perform: scan for duplicates or merge them"},
|
||||||
|
"threshold": {"type": "number", "default": 0.85, "description": "Similarity threshold for scan (0.0-1.0)"},
|
||||||
|
"max_pairs": {"type": "integer", "default": 100, "description": "Maximum duplicate pairs to return"},
|
||||||
|
"keep_doc_id": {"type": "string", "description": "Document ID to keep (required for merge)"},
|
||||||
|
"remove_doc_ids": {"type": "array", "items": {"type": "string"}, "description": "Document IDs to remove (required for merge)"},
|
||||||
|
"dry_run": {"type": "boolean", "default": False, "description": "Preview changes without applying"}
|
||||||
|
},
|
||||||
|
"required": ["kb_name", "action"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="kb_lifecycle_consolidate",
|
||||||
|
description="Consolidate multiple related documents into a single merged document using LLM-powered summarization.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kb_name": {"type": "string", "description": "Name of the knowledge base"},
|
||||||
|
"doc_ids": {"type": "array", "items": {"type": "string"}, "description": "Document IDs to consolidate"},
|
||||||
|
"new_filename": {"type": "string", "description": "Filename for the consolidated document"},
|
||||||
|
"dry_run": {"type": "boolean", "default": False, "description": "Preview without consolidating"}
|
||||||
|
},
|
||||||
|
"required": ["kb_name", "doc_ids", "new_filename"]
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Tool(
|
||||||
|
name="kb_lifecycle_archive",
|
||||||
|
description="Manage document archival: archive, restore, list archived, or find stale documents.",
|
||||||
|
inputSchema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"kb_name": {"type": "string", "description": "Name of the knowledge base"},
|
||||||
|
"action": {"type": "string", "enum": ["archive", "restore", "list", "stale"], "description": "Archival action to perform"},
|
||||||
|
"doc_ids": {"type": "array", "items": {"type": "string"}, "description": "Document IDs (required for archive/restore)"},
|
||||||
|
"reason": {"type": "string", "default": "manual", "description": "Reason for archiving"},
|
||||||
|
"stale_days": {"type": "integer", "default": 90, "description": "Days threshold for stale detection"}
|
||||||
|
},
|
||||||
|
"required": ["kb_name", "action"]
|
||||||
|
}
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -478,7 +537,7 @@ async def _execute_tool(name: str, args: dict[str, Any]) -> dict:
|
||||||
|
|
||||||
# Document Management
|
# Document Management
|
||||||
elif name == "upload_document":
|
elif name == "upload_document":
|
||||||
return await api_post(f"/api/kb/{args['kb_name']}/import", {
|
return await api_post(f"/api/kb/{args['kb_name']}/documents/content", {
|
||||||
"content": args["content"],
|
"content": args["content"],
|
||||||
"filename": args["filename"],
|
"filename": args["filename"],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
|
|
@ -565,6 +624,54 @@ async def _execute_tool(name: str, args: dict[str, Any]) -> dict:
|
||||||
elif name == "delete_skill":
|
elif name == "delete_skill":
|
||||||
return await api_delete(f"/api/skills/{args['name']}?project_path={args['project_path']}")
|
return await api_delete(f"/api/skills/{args['name']}?project_path={args['project_path']}")
|
||||||
|
|
||||||
|
# Knowledge Lifecycle Tools
|
||||||
|
elif name == "kb_lifecycle_health":
|
||||||
|
return await api_get(f"/api/kb/{args['kb_name']}/lifecycle/health")
|
||||||
|
|
||||||
|
elif name == "kb_lifecycle_dedup":
|
||||||
|
action = args["action"]
|
||||||
|
kb_name = args["kb_name"]
|
||||||
|
if action == "scan":
|
||||||
|
return await api_post(f"/api/kb/{kb_name}/lifecycle/dedup-scan", {
|
||||||
|
"threshold": args.get("threshold", 0.85),
|
||||||
|
"max_pairs": args.get("max_pairs", 100)
|
||||||
|
})
|
||||||
|
elif action == "merge":
|
||||||
|
return await api_post(f"/api/kb/{kb_name}/lifecycle/dedup-merge", {
|
||||||
|
"keep_doc_id": args["keep_doc_id"],
|
||||||
|
"remove_doc_ids": args["remove_doc_ids"],
|
||||||
|
"dry_run": args.get("dry_run", False)
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown dedup action: {action}"}
|
||||||
|
|
||||||
|
elif name == "kb_lifecycle_consolidate":
|
||||||
|
return await api_post(f"/api/kb/{args['kb_name']}/lifecycle/consolidate", {
|
||||||
|
"doc_ids": args["doc_ids"],
|
||||||
|
"new_filename": args["new_filename"],
|
||||||
|
"dry_run": args.get("dry_run", False)
|
||||||
|
})
|
||||||
|
|
||||||
|
elif name == "kb_lifecycle_archive":
|
||||||
|
action = args["action"]
|
||||||
|
kb_name = args["kb_name"]
|
||||||
|
if action == "archive":
|
||||||
|
return await api_post(f"/api/kb/{kb_name}/lifecycle/archive", {
|
||||||
|
"doc_ids": args["doc_ids"],
|
||||||
|
"reason": args.get("reason", "manual")
|
||||||
|
})
|
||||||
|
elif action == "restore":
|
||||||
|
return await api_post(f"/api/kb/{kb_name}/lifecycle/restore", {
|
||||||
|
"doc_ids": args["doc_ids"]
|
||||||
|
})
|
||||||
|
elif action == "list":
|
||||||
|
return await api_get(f"/api/kb/{kb_name}/lifecycle/archived")
|
||||||
|
elif action == "stale":
|
||||||
|
stale_days = args.get("stale_days", 90)
|
||||||
|
return await api_get(f"/api/kb/{kb_name}/lifecycle/stale?stale_days={stale_days}")
|
||||||
|
else:
|
||||||
|
return {"error": f"Unknown archive action: {action}"}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
return {"error": f"Unknown tool: {name}"}
|
return {"error": f"Unknown tool: {name}"}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -671,6 +671,56 @@ async def api_upload_document(kb_name: str, file: UploadFile = File(...)):
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
class ContentUploadRequest(BaseModel):
|
||||||
|
content: str
|
||||||
|
filename: str
|
||||||
|
metadata: Optional[Dict[str, Any]] = None
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/kb/{kb_name}/documents/content")
|
||||||
|
async def api_upload_content(kb_name: str, request: ContentUploadRequest):
|
||||||
|
"""REST API: Upload raw text content directly to a knowledge base (no file needed)."""
|
||||||
|
import tempfile
|
||||||
|
import os as _os
|
||||||
|
from app.core.ingestion import ingest_file
|
||||||
|
|
||||||
|
# Validate KB exists
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
if not request.content.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Content cannot be empty")
|
||||||
|
|
||||||
|
# Determine file extension from filename
|
||||||
|
suffix = Path(request.filename).suffix or ".md"
|
||||||
|
|
||||||
|
# Write content to temp file and ingest using existing pipeline
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix, mode="w", encoding="utf-8") as tmp_file:
|
||||||
|
tmp_file.write(request.content)
|
||||||
|
tmp_path = tmp_file.name
|
||||||
|
|
||||||
|
result = ingest_file(tmp_path, kb_name, request.filename)
|
||||||
|
|
||||||
|
_os.unlink(tmp_path)
|
||||||
|
|
||||||
|
if result["status"] == "error":
|
||||||
|
raise HTTPException(status_code=400, detail=result.get("error", "Upload failed"))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"filename": result["filename"],
|
||||||
|
"chunks": result["chunks"],
|
||||||
|
"file_type": result["file_type"]
|
||||||
|
}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Content upload failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/kb/{kb_name}/import")
|
@app.post("/api/kb/{kb_name}/import")
|
||||||
async def api_import_directory(kb_name: str, directory_path: str):
|
async def api_import_directory(kb_name: str, directory_path: str):
|
||||||
"""REST API: Import all files from a directory into a knowledge base."""
|
"""REST API: Import all files from a directory into a knowledge base."""
|
||||||
|
|
@ -3300,6 +3350,235 @@ async def health_check():
|
||||||
return JSONResponse(content=health_status, status_code=status_code)
|
return JSONResponse(content=health_status, status_code=status_code)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# KNOWLEDGE LIFECYCLE ENDPOINTS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class DedupScanRequest(BaseModel):
|
||||||
|
threshold: float = 0.85
|
||||||
|
max_pairs: int = 100
|
||||||
|
|
||||||
|
class DedupMergeRequest(BaseModel):
|
||||||
|
keep_doc_id: str
|
||||||
|
remove_doc_ids: List[str]
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
class ConsolidateRequest(BaseModel):
|
||||||
|
doc_ids: List[str]
|
||||||
|
new_filename: str
|
||||||
|
dry_run: bool = False
|
||||||
|
|
||||||
|
class ArchiveRequest(BaseModel):
|
||||||
|
doc_ids: List[str]
|
||||||
|
reason: str = "manual"
|
||||||
|
|
||||||
|
class RestoreRequest(BaseModel):
|
||||||
|
doc_ids: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_lifecycle_engine():
|
||||||
|
"""Lazy import to avoid circular imports."""
|
||||||
|
from app.core.knowledge_lifecycle import KnowledgeLifecycleEngine
|
||||||
|
return KnowledgeLifecycleEngine()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_dedup_scan_background(job_id: str, kb_name: str, threshold: float, max_pairs: int):
|
||||||
|
"""Background worker for dedup scan on large KBs."""
|
||||||
|
def update_job(status, progress=0, message="", error=None):
|
||||||
|
with INDEXING_JOBS_LOCK:
|
||||||
|
INDEXING_JOBS[job_id].update({
|
||||||
|
"status": status, "progress": progress,
|
||||||
|
"message": message, "error": error, "updated_at": time.time()
|
||||||
|
})
|
||||||
|
if status in ("completed", "failed"):
|
||||||
|
INDEXING_JOBS[job_id]["completed_at"] = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_job("running", 10, "Loading embeddings...")
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
update_job("running", 30, "Computing pairwise similarities...")
|
||||||
|
result = engine.scan_duplicates(kb_name, threshold, max_pairs)
|
||||||
|
update_job("completed", 100, f"Found {len(result['duplicate_pairs'])} duplicate pairs")
|
||||||
|
with INDEXING_JOBS_LOCK:
|
||||||
|
INDEXING_JOBS[job_id]["result"] = result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Job {job_id}] Dedup scan failed: {e}")
|
||||||
|
update_job("failed", 0, "", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _run_consolidation_background(job_id: str, kb_name: str, doc_ids: list, new_filename: str):
|
||||||
|
"""Background worker for LLM-powered consolidation."""
|
||||||
|
def update_job(status, progress=0, message="", error=None):
|
||||||
|
with INDEXING_JOBS_LOCK:
|
||||||
|
INDEXING_JOBS[job_id].update({
|
||||||
|
"status": status, "progress": progress,
|
||||||
|
"message": message, "error": error, "updated_at": time.time()
|
||||||
|
})
|
||||||
|
if status in ("completed", "failed"):
|
||||||
|
INDEXING_JOBS[job_id]["completed_at"] = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
update_job("running", 10, "Fetching source documents...")
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
update_job("running", 30, "Generating consolidated document via LLM...")
|
||||||
|
result = engine.consolidate_related(kb_name, doc_ids, new_filename, dry_run=False)
|
||||||
|
if "error" in result:
|
||||||
|
update_job("failed", 0, "", result["error"])
|
||||||
|
else:
|
||||||
|
update_job("completed", 100, f"Consolidated {result.get('sources_consolidated', 0)} documents")
|
||||||
|
with INDEXING_JOBS_LOCK:
|
||||||
|
INDEXING_JOBS[job_id]["result"] = result
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Job {job_id}] Consolidation failed: {e}")
|
||||||
|
update_job("failed", 0, "", str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/kb/{kb_name}/lifecycle/dedup-scan")
|
||||||
|
async def api_lifecycle_dedup_scan(kb_name: str, request: DedupScanRequest, background_tasks: BackgroundTasks):
|
||||||
|
"""Scan KB for duplicate/near-duplicate documents."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
doc_count = db_manager.get_collection_count(kb_name)
|
||||||
|
|
||||||
|
# Background mode for large KBs
|
||||||
|
if doc_count > 500:
|
||||||
|
_cleanup_old_jobs()
|
||||||
|
job_id = f"dedup-{kb_name}-{uuid.uuid4().hex[:8]}"
|
||||||
|
with INDEXING_JOBS_LOCK:
|
||||||
|
INDEXING_JOBS[job_id] = {
|
||||||
|
"job_id": job_id, "type": "dedup_scan", "kb_name": kb_name,
|
||||||
|
"status": "queued", "progress": 0, "message": "Job queued",
|
||||||
|
"started_at": time.time(), "completed_at": None, "error": None
|
||||||
|
}
|
||||||
|
background_tasks.add_task(_run_dedup_scan_background, job_id, kb_name, request.threshold, request.max_pairs)
|
||||||
|
return {"success": True, "job_id": job_id, "mode": "background",
|
||||||
|
"message": f"Dedup scan started in background ({doc_count} docs). Check GET /api/jobs/{job_id}"}
|
||||||
|
|
||||||
|
# Sync mode for smaller KBs
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
result = engine.scan_duplicates(kb_name, request.threshold, request.max_pairs)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/kb/{kb_name}/lifecycle/dedup-merge")
|
||||||
|
async def api_lifecycle_dedup_merge(kb_name: str, request: DedupMergeRequest):
|
||||||
|
"""Merge duplicate documents by keeping one and removing the rest."""
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
result = engine.merge_duplicates(kb_name, request.keep_doc_id, request.remove_doc_ids, request.dry_run)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/kb/{kb_name}/lifecycle/consolidate")
|
||||||
|
async def api_lifecycle_consolidate(kb_name: str, request: ConsolidateRequest, background_tasks: BackgroundTasks):
|
||||||
|
"""Consolidate related documents into a single merged document using LLM."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
if request.dry_run:
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
return engine.consolidate_related(kb_name, request.doc_ids, request.new_filename, dry_run=True)
|
||||||
|
|
||||||
|
# Always run consolidation in background (LLM call)
|
||||||
|
_cleanup_old_jobs()
|
||||||
|
job_id = f"consolidate-{kb_name}-{uuid.uuid4().hex[:8]}"
|
||||||
|
with INDEXING_JOBS_LOCK:
|
||||||
|
INDEXING_JOBS[job_id] = {
|
||||||
|
"job_id": job_id, "type": "consolidation", "kb_name": kb_name,
|
||||||
|
"status": "queued", "progress": 0, "message": "Job queued",
|
||||||
|
"started_at": time.time(), "completed_at": None, "error": None
|
||||||
|
}
|
||||||
|
background_tasks.add_task(_run_consolidation_background, job_id, kb_name, request.doc_ids, request.new_filename)
|
||||||
|
return {"success": True, "job_id": job_id, "mode": "background",
|
||||||
|
"message": f"Consolidation started. Check GET /api/jobs/{job_id}"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/kb/{kb_name}/lifecycle/health")
|
||||||
|
async def api_lifecycle_health(kb_name: str):
|
||||||
|
"""Get comprehensive health report for a knowledge base."""
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
return engine.get_health_report(kb_name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/kb/{kb_name}/lifecycle/growth")
|
||||||
|
async def api_lifecycle_growth(kb_name: str, granularity: str = "month"):
|
||||||
|
"""Get document growth timeline for a knowledge base."""
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
return engine.get_growth_timeline(kb_name, granularity)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/kb/{kb_name}/lifecycle/archive")
|
||||||
|
async def api_lifecycle_archive(kb_name: str, request: ArchiveRequest):
|
||||||
|
"""Archive documents in a knowledge base."""
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
return engine.archive_documents(kb_name, request.doc_ids, request.reason)
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/kb/{kb_name}/lifecycle/restore")
|
||||||
|
async def api_lifecycle_restore(kb_name: str, request: RestoreRequest):
|
||||||
|
"""Restore archived documents in a knowledge base."""
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
return engine.restore_documents(kb_name, request.doc_ids)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/kb/{kb_name}/lifecycle/archived")
|
||||||
|
async def api_lifecycle_archived(kb_name: str):
|
||||||
|
"""List archived documents in a knowledge base."""
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
return engine.list_archived(kb_name)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/kb/{kb_name}/lifecycle/stale")
|
||||||
|
async def api_lifecycle_stale(kb_name: str, stale_days: int = 90):
|
||||||
|
"""Find stale documents older than the specified threshold."""
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
engine = _get_lifecycle_engine()
|
||||||
|
return engine.find_stale_documents(kb_name, stale_days)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/kb/{kb_name}/lifecycle/logs")
|
||||||
|
async def api_lifecycle_logs(kb_name: str, operation_type: Optional[str] = None, limit: int = 50):
|
||||||
|
"""Get lifecycle operation logs for a knowledge base."""
|
||||||
|
db_manager = get_sqlite_manager()
|
||||||
|
if not db_manager.collection_exists(kb_name):
|
||||||
|
raise HTTPException(status_code=404, detail=f"Knowledge base '{kb_name}' not found")
|
||||||
|
|
||||||
|
return {"logs": db_manager.get_lifecycle_logs(kb_name, operation_type, limit)}
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# STARTUP/SHUTDOWN EVENTS
|
# STARTUP/SHUTDOWN EVENTS
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
|
||||||
310
templates/commands/claude-os-lifecycle.md
Normal file
310
templates/commands/claude-os-lifecycle.md
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
---
|
||||||
|
name: claude-os-lifecycle
|
||||||
|
description: Knowledge base lifecycle management - dedup, consolidate, archive, and health analytics
|
||||||
|
---
|
||||||
|
|
||||||
|
# Claude OS Knowledge Lifecycle Management
|
||||||
|
|
||||||
|
Manage the health and lifecycle of your knowledge bases: find duplicates, consolidate related docs, archive stale content, and get health reports.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```
|
||||||
|
/claude-os-lifecycle health [kb_name] - Health report with recommendations
|
||||||
|
/claude-os-lifecycle dedup [kb_name] - Scan and merge duplicate documents
|
||||||
|
/claude-os-lifecycle consolidate [kb_name] - LLM-powered document merging
|
||||||
|
/claude-os-lifecycle archive [kb_name] - Find stale docs, archive, restore
|
||||||
|
/claude-os-lifecycle logs [kb_name] - View operation history
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## HEALTH REPORT
|
||||||
|
|
||||||
|
### What Happens
|
||||||
|
|
||||||
|
```
|
||||||
|
/claude-os-lifecycle health my-project-project_memories
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1: Get Health Report**
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_health
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Display Report**
|
||||||
|
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════
|
||||||
|
KNOWLEDGE BASE HEALTH REPORT
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
KB: my-project-project_memories
|
||||||
|
Documents: 47 (42 with embeddings, 3 archived)
|
||||||
|
Last Updated: 2026-02-05
|
||||||
|
|
||||||
|
EMBEDDING COVERAGE:
|
||||||
|
With embeddings: 42/47 (89.4%)
|
||||||
|
Without: 5 docs need re-indexing
|
||||||
|
|
||||||
|
AGE DISTRIBUTION:
|
||||||
|
Last 7 days: 8 docs
|
||||||
|
Last 30 days: 15 docs
|
||||||
|
Last 90 days: 12 docs
|
||||||
|
Older: 12 docs
|
||||||
|
|
||||||
|
TOP SIMILAR PAIRS (potential duplicates):
|
||||||
|
1. doc-a1b2 <-> doc-c3d4 (0.94 similarity)
|
||||||
|
2. doc-e5f6 <-> doc-g7h8 (0.91 similarity)
|
||||||
|
3. doc-i9j0 <-> doc-k1l2 (0.88 similarity)
|
||||||
|
|
||||||
|
RECOMMENDATIONS:
|
||||||
|
[HIGH] Found 3 highly similar document pairs. Run dedup scan.
|
||||||
|
[MEDIUM] 12 documents older than 90 days. Review for archival.
|
||||||
|
[MEDIUM] 5 documents lack embeddings. Re-index recommended.
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DEDUPLICATION
|
||||||
|
|
||||||
|
### Scan for Duplicates
|
||||||
|
|
||||||
|
```
|
||||||
|
/claude-os-lifecycle dedup my-project-project_memories
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1: Run Dedup Scan**
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_dedup
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
action: "scan"
|
||||||
|
threshold: 0.85
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Show Results**
|
||||||
|
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════
|
||||||
|
DUPLICATE SCAN RESULTS
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
Scanned: 47 documents
|
||||||
|
Duplicate Density: 6.4%
|
||||||
|
Pairs Found: 3
|
||||||
|
Clusters: 2
|
||||||
|
|
||||||
|
CLUSTER 1 (3 docs):
|
||||||
|
- doc-a1b2: "Authentication pattern using JWT tokens..."
|
||||||
|
- doc-c3d4: "JWT authentication implementation pattern..."
|
||||||
|
- doc-m3n4: "Token-based auth with JWT refresh..."
|
||||||
|
Similarity: 0.91-0.94
|
||||||
|
|
||||||
|
CLUSTER 2 (2 docs):
|
||||||
|
- doc-e5f6: "Redis caching strategy for API responses..."
|
||||||
|
- doc-g7h8: "API response caching with Redis..."
|
||||||
|
Similarity: 0.88
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Ask User What to Do**
|
||||||
|
|
||||||
|
For each cluster, offer options:
|
||||||
|
- **Merge**: Keep the most complete doc, delete others
|
||||||
|
- **Consolidate**: Use LLM to merge into a single comprehensive doc
|
||||||
|
- **Skip**: Leave as-is
|
||||||
|
|
||||||
|
```
|
||||||
|
Cluster 1: What would you like to do?
|
||||||
|
[m] Merge (keep best, delete rest)
|
||||||
|
[c] Consolidate (LLM merge)
|
||||||
|
[s] Skip
|
||||||
|
```
|
||||||
|
|
||||||
|
**If merge:**
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_dedup
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
action: "merge"
|
||||||
|
keep_doc_id: "doc-a1b2"
|
||||||
|
remove_doc_ids: ["doc-c3d4", "doc-m3n4"]
|
||||||
|
```
|
||||||
|
|
||||||
|
**If consolidate:**
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_consolidate
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
doc_ids: ["doc-a1b2", "doc-c3d4", "doc-m3n4"]
|
||||||
|
new_filename: "jwt-authentication-pattern-consolidated.md"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CONSOLIDATION
|
||||||
|
|
||||||
|
### LLM-Powered Document Merging
|
||||||
|
|
||||||
|
```
|
||||||
|
/claude-os-lifecycle consolidate my-project-project_memories
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1: Preview (dry run)**
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_consolidate
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
doc_ids: ["doc-a1b2", "doc-c3d4", "doc-m3n4"]
|
||||||
|
new_filename: "consolidated-auth-patterns.md"
|
||||||
|
dry_run: true
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Show Preview**
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════
|
||||||
|
CONSOLIDATION PREVIEW
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
Sources: 3 documents
|
||||||
|
Total content: 4,250 characters
|
||||||
|
|
||||||
|
Document 1: "Authentication pattern using JWT..."
|
||||||
|
Document 2: "JWT authentication implementation..."
|
||||||
|
Document 3: "Token-based auth with JWT refresh..."
|
||||||
|
|
||||||
|
This will:
|
||||||
|
- Create: consolidated-auth-patterns.md
|
||||||
|
- Delete: 3 source documents
|
||||||
|
- Use LLM to merge content intelligently
|
||||||
|
|
||||||
|
Proceed? [y/n]
|
||||||
|
═══════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Execute**
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_consolidate
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
doc_ids: ["doc-a1b2", "doc-c3d4", "doc-m3n4"]
|
||||||
|
new_filename: "consolidated-auth-patterns.md"
|
||||||
|
dry_run: false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ARCHIVAL
|
||||||
|
|
||||||
|
### Find and Archive Stale Documents
|
||||||
|
|
||||||
|
```
|
||||||
|
/claude-os-lifecycle archive my-project-project_memories
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 1: Find Stale Documents**
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_archive
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
action: "stale"
|
||||||
|
stale_days: 90
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Show Stale Documents**
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════
|
||||||
|
STALE DOCUMENTS (>90 days old)
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
Found 5 stale documents:
|
||||||
|
|
||||||
|
1. old-pattern-123.md (142 days old)
|
||||||
|
2. debug-notes-456.md (128 days old)
|
||||||
|
3. meeting-notes-789.md (115 days old)
|
||||||
|
4. temp-workaround-012.md (103 days old)
|
||||||
|
5. initial-setup-345.md (98 days old)
|
||||||
|
|
||||||
|
[a] Archive all [s] Select individually [n] Skip
|
||||||
|
═══════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Archive Selected**
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_archive
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
action: "archive"
|
||||||
|
doc_ids: ["old-pattern-123", "debug-notes-456"]
|
||||||
|
reason: "stale - over 90 days"
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Archived Documents
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_archive
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
action: "list"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore Archived Documents
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__code-forge__kb_lifecycle_archive
|
||||||
|
kb_name: "my-project-project_memories"
|
||||||
|
action: "restore"
|
||||||
|
doc_ids: ["old-pattern-123"]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OPERATION LOGS
|
||||||
|
|
||||||
|
### View History
|
||||||
|
|
||||||
|
```
|
||||||
|
/claude-os-lifecycle logs my-project-project_memories
|
||||||
|
```
|
||||||
|
|
||||||
|
Shows recent lifecycle operations:
|
||||||
|
|
||||||
|
```
|
||||||
|
═══════════════════════════════════════
|
||||||
|
LIFECYCLE OPERATION LOG
|
||||||
|
═══════════════════════════════════════
|
||||||
|
|
||||||
|
1. [2026-02-06 10:30] dedup_scan - completed
|
||||||
|
Found 3 duplicate pairs in 47 documents
|
||||||
|
|
||||||
|
2. [2026-02-06 10:32] dedup_merge - completed
|
||||||
|
Kept doc-a1b2, removed 2 documents
|
||||||
|
|
||||||
|
3. [2026-02-06 10:35] archive - completed
|
||||||
|
Archived 2 documents (reason: stale)
|
||||||
|
|
||||||
|
4. [2026-02-05 14:20] consolidate - completed
|
||||||
|
Merged 3 documents into consolidated-patterns.md
|
||||||
|
|
||||||
|
═══════════════════════════════════════
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## KB NAME RESOLUTION
|
||||||
|
|
||||||
|
If kb_name is not provided, use this strategy:
|
||||||
|
1. Check if working directory has a Claude OS project registered
|
||||||
|
2. If so, use `{project}-project_memories` as default
|
||||||
|
3. If not, list all KBs and ask user to pick one
|
||||||
|
|
||||||
|
```
|
||||||
|
mcp__code-forge__list_knowledge_bases
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## WHY THIS MATTERS
|
||||||
|
|
||||||
|
- **Dedup**: Prevents memory bloat from saving similar insights repeatedly
|
||||||
|
- **Consolidate**: Turns fragmented notes into comprehensive references
|
||||||
|
- **Archive**: Keeps active memories relevant without permanent deletion
|
||||||
|
- **Health**: Proactive monitoring catches issues before they impact recall
|
||||||
|
- **Logs**: Full audit trail of all lifecycle operations
|
||||||
475
tests/test_knowledge_lifecycle.py
Normal file
475
tests/test_knowledge_lifecycle.py
Normal file
|
|
@ -0,0 +1,475 @@
|
||||||
|
"""
|
||||||
|
Tests for Knowledge Lifecycle Engine.
|
||||||
|
Covers deduplication, archival, analytics, and consolidation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import tempfile
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app.core.sqlite_manager import SQLiteManager
|
||||||
|
from app.core.kb_types import KBType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def lifecycle_db():
|
||||||
|
"""Create a clean SQLite database with lifecycle support."""
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as temp_file:
|
||||||
|
temp_db_path = temp_file.name
|
||||||
|
|
||||||
|
manager = SQLiteManager(temp_db_path)
|
||||||
|
yield manager
|
||||||
|
|
||||||
|
if Path(temp_db_path).exists():
|
||||||
|
Path(temp_db_path).unlink()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def populated_kb(lifecycle_db):
|
||||||
|
"""Create a KB with sample documents and embeddings."""
|
||||||
|
kb_name = "test-lifecycle-kb"
|
||||||
|
lifecycle_db.create_collection(kb_name, KBType.GENERIC, description="Test KB")
|
||||||
|
|
||||||
|
# Create documents with embeddings
|
||||||
|
# Doc 1 and 2 are near-duplicates (similar embeddings)
|
||||||
|
# Doc 3 is different
|
||||||
|
np.random.seed(42)
|
||||||
|
base_vec = np.random.randn(768).astype(np.float32)
|
||||||
|
base_vec = base_vec / np.linalg.norm(base_vec)
|
||||||
|
|
||||||
|
# Near-duplicate of base (very small perturbation for high similarity)
|
||||||
|
similar_vec = base_vec + np.random.randn(768).astype(np.float32) * 0.01
|
||||||
|
similar_vec = similar_vec / np.linalg.norm(similar_vec)
|
||||||
|
|
||||||
|
# Different vector
|
||||||
|
different_vec = np.random.randn(768).astype(np.float32)
|
||||||
|
different_vec = different_vec / np.linalg.norm(different_vec)
|
||||||
|
|
||||||
|
docs = ["Authentication using JWT tokens for API security",
|
||||||
|
"JWT token authentication pattern for securing APIs",
|
||||||
|
"Redis caching strategy for database query optimization"]
|
||||||
|
embeddings = [base_vec.tolist(), similar_vec.tolist(), different_vec.tolist()]
|
||||||
|
metadatas = [
|
||||||
|
{"filename": "auth-jwt.md", "upload_date": datetime.now().isoformat()},
|
||||||
|
{"filename": "jwt-auth.md", "upload_date": datetime.now().isoformat()},
|
||||||
|
{"filename": "redis-cache.md", "upload_date": (datetime.now() - timedelta(days=120)).isoformat()}
|
||||||
|
]
|
||||||
|
ids = ["doc-auth-1", "doc-auth-2", "doc-redis-1"]
|
||||||
|
|
||||||
|
lifecycle_db.add_documents(kb_name, docs, embeddings, metadatas, ids)
|
||||||
|
|
||||||
|
return kb_name, lifecycle_db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def engine(populated_kb):
|
||||||
|
"""Create a KnowledgeLifecycleEngine with populated KB."""
|
||||||
|
kb_name, db = populated_kb
|
||||||
|
|
||||||
|
# Patch the singleton to use our test DB
|
||||||
|
with patch("app.core.knowledge_lifecycle.get_sqlite_manager", return_value=db):
|
||||||
|
from app.core.knowledge_lifecycle import KnowledgeLifecycleEngine
|
||||||
|
e = KnowledgeLifecycleEngine()
|
||||||
|
e.db = db
|
||||||
|
yield e, kb_name, db
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestDeduplication:
|
||||||
|
"""Test duplicate scanning and merging."""
|
||||||
|
|
||||||
|
def test_scan_empty_kb(self, lifecycle_db):
|
||||||
|
"""Scan with no documents returns empty results."""
|
||||||
|
kb_name = "empty-kb"
|
||||||
|
lifecycle_db.create_collection(kb_name, KBType.GENERIC)
|
||||||
|
|
||||||
|
with patch("app.core.knowledge_lifecycle.get_sqlite_manager", return_value=lifecycle_db):
|
||||||
|
from app.core.knowledge_lifecycle import KnowledgeLifecycleEngine
|
||||||
|
engine = KnowledgeLifecycleEngine()
|
||||||
|
engine.db = lifecycle_db
|
||||||
|
result = engine.scan_duplicates(kb_name)
|
||||||
|
|
||||||
|
assert result["total_documents"] == 0
|
||||||
|
assert result["duplicate_pairs"] == []
|
||||||
|
assert result["clusters"] == []
|
||||||
|
assert result["duplicate_density"] == 0.0
|
||||||
|
|
||||||
|
def test_identical_vectors_detected(self, engine):
|
||||||
|
"""Near-duplicate documents are detected above threshold."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
result = e.scan_duplicates(kb_name, threshold=0.85)
|
||||||
|
|
||||||
|
assert result["total_documents"] == 3
|
||||||
|
assert len(result["duplicate_pairs"]) >= 1
|
||||||
|
|
||||||
|
# The auth docs should be paired
|
||||||
|
pair = result["duplicate_pairs"][0]
|
||||||
|
doc_ids = {pair["doc_a"], pair["doc_b"]}
|
||||||
|
assert "doc-auth-1" in doc_ids or "doc-auth-2" in doc_ids
|
||||||
|
assert pair["similarity"] >= 0.85
|
||||||
|
|
||||||
|
def test_below_threshold_ignored(self, engine):
|
||||||
|
"""Documents below threshold are not flagged."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
result = e.scan_duplicates(kb_name, threshold=0.99)
|
||||||
|
|
||||||
|
# At 0.99 threshold, only truly identical docs match
|
||||||
|
# Our similar_vec has noise so shouldn't match at 0.99
|
||||||
|
for pair in result["duplicate_pairs"]:
|
||||||
|
assert pair["similarity"] >= 0.99
|
||||||
|
|
||||||
|
def test_union_find_clustering(self, engine):
|
||||||
|
"""Duplicate pairs are clustered correctly."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
result = e.scan_duplicates(kb_name, threshold=0.85)
|
||||||
|
|
||||||
|
if result["clusters"]:
|
||||||
|
cluster = result["clusters"][0]
|
||||||
|
assert "cluster_id" in cluster
|
||||||
|
assert "doc_ids" in cluster
|
||||||
|
assert cluster["size"] >= 2
|
||||||
|
|
||||||
|
def test_merge_dry_run(self, engine):
|
||||||
|
"""Dry run merge previews without deleting."""
|
||||||
|
e, kb_name, db = engine
|
||||||
|
result = e.merge_duplicates(
|
||||||
|
kb_name, keep_doc_id="doc-auth-1",
|
||||||
|
remove_doc_ids=["doc-auth-2"], dry_run=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["dry_run"] is True
|
||||||
|
assert result["keep_doc_id"] == "doc-auth-1"
|
||||||
|
assert "doc-auth-2" in result["would_remove"]
|
||||||
|
|
||||||
|
# Verify doc still exists
|
||||||
|
docs = db.get_all_embeddings(kb_name)
|
||||||
|
doc_ids = [d["doc_id"] for d in docs]
|
||||||
|
assert "doc-auth-2" in doc_ids
|
||||||
|
|
||||||
|
def test_merge_actual(self, engine):
|
||||||
|
"""Actual merge deletes duplicate documents."""
|
||||||
|
e, kb_name, db = engine
|
||||||
|
result = e.merge_duplicates(
|
||||||
|
kb_name, keep_doc_id="doc-auth-1",
|
||||||
|
remove_doc_ids=["doc-auth-2"], dry_run=False
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["dry_run"] is False
|
||||||
|
assert result["deleted_count"] == 1
|
||||||
|
|
||||||
|
# Verify doc was deleted
|
||||||
|
docs = db.get_all_embeddings(kb_name)
|
||||||
|
doc_ids = [d["doc_id"] for d in docs]
|
||||||
|
assert "doc-auth-1" in doc_ids
|
||||||
|
assert "doc-auth-2" not in doc_ids
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestArchival:
|
||||||
|
"""Test document archival and restoration."""
|
||||||
|
|
||||||
|
def test_archive_sets_flag(self, engine):
|
||||||
|
"""Archive sets archived flag in metadata."""
|
||||||
|
e, kb_name, db = engine
|
||||||
|
result = e.archive_documents(kb_name, ["doc-auth-1"], reason="test")
|
||||||
|
|
||||||
|
assert result["archived_count"] == 1
|
||||||
|
|
||||||
|
docs = db.get_all_embeddings(kb_name, exclude_archived=False)
|
||||||
|
doc = next(d for d in docs if d["doc_id"] == "doc-auth-1")
|
||||||
|
assert doc["metadata"]["archived"] is True
|
||||||
|
assert doc["metadata"]["archive_reason"] == "test"
|
||||||
|
assert "archived_at" in doc["metadata"]
|
||||||
|
|
||||||
|
def test_restore_clears_flag(self, engine):
|
||||||
|
"""Restore clears archived flag."""
|
||||||
|
e, kb_name, db = engine
|
||||||
|
|
||||||
|
# Archive first
|
||||||
|
e.archive_documents(kb_name, ["doc-auth-1"])
|
||||||
|
|
||||||
|
# Then restore
|
||||||
|
result = e.restore_documents(kb_name, ["doc-auth-1"])
|
||||||
|
assert result["restored_count"] == 1
|
||||||
|
|
||||||
|
docs = db.get_all_embeddings(kb_name, exclude_archived=False)
|
||||||
|
doc = next(d for d in docs if d["doc_id"] == "doc-auth-1")
|
||||||
|
assert doc["metadata"].get("archived") is False
|
||||||
|
|
||||||
|
def test_exclude_archived_from_embeddings(self, engine):
|
||||||
|
"""Archived docs are excluded when exclude_archived=True."""
|
||||||
|
e, kb_name, db = engine
|
||||||
|
|
||||||
|
e.archive_documents(kb_name, ["doc-auth-1"])
|
||||||
|
|
||||||
|
# With exclude
|
||||||
|
docs_filtered = db.get_all_embeddings(kb_name, exclude_archived=True)
|
||||||
|
doc_ids_filtered = [d["doc_id"] for d in docs_filtered]
|
||||||
|
assert "doc-auth-1" not in doc_ids_filtered
|
||||||
|
|
||||||
|
# Without exclude
|
||||||
|
docs_all = db.get_all_embeddings(kb_name, exclude_archived=False)
|
||||||
|
doc_ids_all = [d["doc_id"] for d in docs_all]
|
||||||
|
assert "doc-auth-1" in doc_ids_all
|
||||||
|
|
||||||
|
def test_find_stale(self, engine):
|
||||||
|
"""Find stale documents correctly identifies old docs."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
|
||||||
|
with patch("app.core.knowledge_lifecycle.get_documents_metadata") as mock_docs:
|
||||||
|
mock_docs.return_value = [
|
||||||
|
{"filename": "old.md", "upload_date": (datetime.now() - timedelta(days=120)).isoformat()},
|
||||||
|
{"filename": "new.md", "upload_date": datetime.now().isoformat()}
|
||||||
|
]
|
||||||
|
result = e.find_stale_documents(kb_name, stale_days=90)
|
||||||
|
|
||||||
|
assert result["stale_count"] == 1
|
||||||
|
assert result["stale_documents"][0]["filename"] == "old.md"
|
||||||
|
assert result["stale_documents"][0]["age_days"] >= 119
|
||||||
|
|
||||||
|
def test_list_archived(self, engine):
|
||||||
|
"""List archived returns only archived docs."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
|
||||||
|
# Archive one doc
|
||||||
|
e.archive_documents(kb_name, ["doc-redis-1"], reason="stale")
|
||||||
|
|
||||||
|
result = e.list_archived(kb_name)
|
||||||
|
assert result["archived_count"] == 1
|
||||||
|
assert result["archived_documents"][0]["doc_id"] == "doc-redis-1"
|
||||||
|
assert result["archived_documents"][0]["archive_reason"] == "stale"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestAnalytics:
|
||||||
|
"""Test health reports and growth timeline."""
|
||||||
|
|
||||||
|
def test_health_report_structure(self, engine):
|
||||||
|
"""Health report contains all expected sections."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
|
||||||
|
with patch("app.core.knowledge_lifecycle.get_collection_stats") as mock_stats, \
|
||||||
|
patch("app.core.knowledge_lifecycle.get_documents_metadata") as mock_docs:
|
||||||
|
mock_stats.return_value = {
|
||||||
|
"total_documents": 3, "total_chunks": 3,
|
||||||
|
"last_updated": datetime.now().isoformat()
|
||||||
|
}
|
||||||
|
mock_docs.return_value = [
|
||||||
|
{"filename": "a.md", "upload_date": datetime.now().isoformat()},
|
||||||
|
{"filename": "b.md", "upload_date": datetime.now().isoformat()},
|
||||||
|
{"filename": "c.md", "upload_date": (datetime.now() - timedelta(days=100)).isoformat()}
|
||||||
|
]
|
||||||
|
|
||||||
|
report = e.get_health_report(kb_name)
|
||||||
|
|
||||||
|
assert "kb_name" in report
|
||||||
|
assert "stats" in report
|
||||||
|
assert "embedding_coverage" in report
|
||||||
|
assert "top_similar_pairs" in report
|
||||||
|
assert "age_distribution" in report
|
||||||
|
assert "recommendations" in report
|
||||||
|
assert report["embedding_coverage"]["total_docs"] == 3
|
||||||
|
assert report["embedding_coverage"]["with_embeddings"] == 3
|
||||||
|
|
||||||
|
def test_growth_timeline_grouping(self, engine):
|
||||||
|
"""Growth timeline groups documents by period."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
|
||||||
|
with patch("app.core.knowledge_lifecycle.get_documents_metadata") as mock_docs:
|
||||||
|
now = datetime.now()
|
||||||
|
mock_docs.return_value = [
|
||||||
|
{"filename": "a.md", "upload_date": now.isoformat()},
|
||||||
|
{"filename": "b.md", "upload_date": now.isoformat()},
|
||||||
|
{"filename": "c.md", "upload_date": (now - timedelta(days=35)).isoformat()}
|
||||||
|
]
|
||||||
|
|
||||||
|
result = e.get_growth_timeline(kb_name, granularity="month")
|
||||||
|
|
||||||
|
assert result["kb_name"] == kb_name
|
||||||
|
assert result["granularity"] == "month"
|
||||||
|
assert result["total_documents"] == 3
|
||||||
|
assert len(result["timeline"]) >= 1
|
||||||
|
|
||||||
|
# Last entry should have cumulative total = 3
|
||||||
|
assert result["timeline"][-1]["total"] == 3
|
||||||
|
|
||||||
|
def test_density_calculation(self, engine):
|
||||||
|
"""Duplicate density is calculated correctly."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
result = e.scan_duplicates(kb_name, threshold=0.85)
|
||||||
|
|
||||||
|
# density = pairs / (n*(n-1)/2) where n=3 => 3 possible pairs
|
||||||
|
assert 0 <= result["duplicate_density"] <= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestLifecycleLog:
|
||||||
|
"""Test lifecycle audit logging."""
|
||||||
|
|
||||||
|
def test_insert_and_query_log(self, lifecycle_db):
|
||||||
|
"""Insert and retrieve lifecycle logs."""
|
||||||
|
kb_name = "test-log-kb"
|
||||||
|
|
||||||
|
log_id = lifecycle_db.insert_lifecycle_log(
|
||||||
|
kb_name=kb_name,
|
||||||
|
operation_type="dedup_scan",
|
||||||
|
status="completed",
|
||||||
|
input_doc_ids=["doc-1", "doc-2"],
|
||||||
|
details={"pairs_found": 5}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert log_id > 0
|
||||||
|
|
||||||
|
logs = lifecycle_db.get_lifecycle_logs(kb_name)
|
||||||
|
assert len(logs) == 1
|
||||||
|
assert logs[0]["operation_type"] == "dedup_scan"
|
||||||
|
assert logs[0]["status"] == "completed"
|
||||||
|
assert logs[0]["input_doc_ids"] == ["doc-1", "doc-2"]
|
||||||
|
assert logs[0]["details"]["pairs_found"] == 5
|
||||||
|
|
||||||
|
def test_update_log_status(self, lifecycle_db):
|
||||||
|
"""Update lifecycle log status and completion."""
|
||||||
|
log_id = lifecycle_db.insert_lifecycle_log(
|
||||||
|
kb_name="test-kb",
|
||||||
|
operation_type="consolidate",
|
||||||
|
status="pending"
|
||||||
|
)
|
||||||
|
|
||||||
|
success = lifecycle_db.update_lifecycle_log(
|
||||||
|
log_id, "completed",
|
||||||
|
output_doc_ids=["new-doc-1"],
|
||||||
|
details={"merged": 3}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert success
|
||||||
|
|
||||||
|
logs = lifecycle_db.get_lifecycle_logs("test-kb")
|
||||||
|
assert logs[0]["status"] == "completed"
|
||||||
|
assert logs[0]["output_doc_ids"] == ["new-doc-1"]
|
||||||
|
assert logs[0]["completed_at"] is not None
|
||||||
|
|
||||||
|
def test_filter_by_operation_type(self, lifecycle_db):
|
||||||
|
"""Filter logs by operation type."""
|
||||||
|
lifecycle_db.insert_lifecycle_log("kb1", "dedup_scan", "completed")
|
||||||
|
lifecycle_db.insert_lifecycle_log("kb1", "archive", "completed")
|
||||||
|
lifecycle_db.insert_lifecycle_log("kb1", "dedup_merge", "completed")
|
||||||
|
|
||||||
|
dedup_logs = lifecycle_db.get_lifecycle_logs("kb1", operation_type="dedup_scan")
|
||||||
|
assert len(dedup_logs) == 1
|
||||||
|
|
||||||
|
all_logs = lifecycle_db.get_lifecycle_logs("kb1")
|
||||||
|
assert len(all_logs) == 3
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestSQLiteManagerLifecycle:
|
||||||
|
"""Test the new SQLiteManager methods for lifecycle operations."""
|
||||||
|
|
||||||
|
def test_get_all_embeddings(self, lifecycle_db):
|
||||||
|
"""get_all_embeddings returns docs with parsed embeddings."""
|
||||||
|
kb_name = "test-emb-kb"
|
||||||
|
lifecycle_db.create_collection(kb_name, KBType.GENERIC)
|
||||||
|
|
||||||
|
vec = np.random.randn(768).astype(np.float32)
|
||||||
|
lifecycle_db.add_documents(
|
||||||
|
kb_name,
|
||||||
|
documents=["Test content"],
|
||||||
|
embeddings=[vec.tolist()],
|
||||||
|
metadatas=[{"filename": "test.md"}],
|
||||||
|
ids=["doc-1"]
|
||||||
|
)
|
||||||
|
|
||||||
|
results = lifecycle_db.get_all_embeddings(kb_name)
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0]["doc_id"] == "doc-1"
|
||||||
|
assert results[0]["embedding"] is not None
|
||||||
|
assert len(results[0]["embedding"]) == 768
|
||||||
|
|
||||||
|
def test_update_document_metadata(self, lifecycle_db):
|
||||||
|
"""update_document_metadata merges new keys into existing metadata."""
|
||||||
|
kb_name = "test-meta-kb"
|
||||||
|
lifecycle_db.create_collection(kb_name, KBType.GENERIC)
|
||||||
|
|
||||||
|
vec = np.random.randn(768).astype(np.float32)
|
||||||
|
lifecycle_db.add_documents(
|
||||||
|
kb_name,
|
||||||
|
documents=["Test content"],
|
||||||
|
embeddings=[vec.tolist()],
|
||||||
|
metadatas=[{"filename": "test.md", "tags": ["a"]}],
|
||||||
|
ids=["doc-1"]
|
||||||
|
)
|
||||||
|
|
||||||
|
success = lifecycle_db.update_document_metadata(
|
||||||
|
kb_name, "doc-1", {"archived": True, "new_key": "value"}
|
||||||
|
)
|
||||||
|
assert success
|
||||||
|
|
||||||
|
docs = lifecycle_db.get_all_embeddings(kb_name, exclude_archived=False)
|
||||||
|
doc = docs[0]
|
||||||
|
assert doc["metadata"]["archived"] is True
|
||||||
|
assert doc["metadata"]["new_key"] == "value"
|
||||||
|
assert doc["metadata"]["filename"] == "test.md" # original preserved
|
||||||
|
|
||||||
|
def test_delete_documents_by_ids(self, lifecycle_db):
|
||||||
|
"""delete_documents_by_ids removes specified documents."""
|
||||||
|
kb_name = "test-delete-kb"
|
||||||
|
lifecycle_db.create_collection(kb_name, KBType.GENERIC)
|
||||||
|
|
||||||
|
vec = np.random.randn(768).astype(np.float32)
|
||||||
|
lifecycle_db.add_documents(
|
||||||
|
kb_name,
|
||||||
|
documents=["Doc 1", "Doc 2", "Doc 3"],
|
||||||
|
embeddings=[vec.tolist(), vec.tolist(), vec.tolist()],
|
||||||
|
metadatas=[{"filename": "1.md"}, {"filename": "2.md"}, {"filename": "3.md"}],
|
||||||
|
ids=["doc-1", "doc-2", "doc-3"]
|
||||||
|
)
|
||||||
|
|
||||||
|
deleted = lifecycle_db.delete_documents_by_ids(kb_name, ["doc-1", "doc-3"])
|
||||||
|
assert deleted == 2
|
||||||
|
|
||||||
|
remaining = lifecycle_db.get_all_embeddings(kb_name)
|
||||||
|
assert len(remaining) == 1
|
||||||
|
assert remaining[0]["doc_id"] == "doc-2"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestConsolidation:
|
||||||
|
"""Integration tests for consolidation (requires Ollama)."""
|
||||||
|
|
||||||
|
def test_consolidate_dry_run(self, engine):
|
||||||
|
"""Dry run shows preview without making changes."""
|
||||||
|
e, kb_name, db = engine
|
||||||
|
|
||||||
|
result = e.consolidate_related(
|
||||||
|
kb_name,
|
||||||
|
doc_ids=["doc-auth-1", "doc-auth-2"],
|
||||||
|
new_filename="consolidated-auth.md",
|
||||||
|
dry_run=True
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["dry_run"] is True
|
||||||
|
assert result["source_count"] == 2
|
||||||
|
assert result["total_chars"] > 0
|
||||||
|
|
||||||
|
# Verify originals still exist
|
||||||
|
docs = db.get_all_embeddings(kb_name)
|
||||||
|
doc_ids = [d["doc_id"] for d in docs]
|
||||||
|
assert "doc-auth-1" in doc_ids
|
||||||
|
assert "doc-auth-2" in doc_ids
|
||||||
|
|
||||||
|
def test_consolidate_insufficient_docs(self, engine):
|
||||||
|
"""Consolidation with <2 docs returns error."""
|
||||||
|
e, kb_name, _ = engine
|
||||||
|
|
||||||
|
result = e.consolidate_related(
|
||||||
|
kb_name,
|
||||||
|
doc_ids=["doc-auth-1"],
|
||||||
|
new_filename="single.md"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "error" in result
|
||||||
Loading…
Add table
Reference in a new issue