Add MCP server, note mutation endpoint, and updated_at tracking (v3.0.0)
New MCP server (mcp/) exposes kb operations as native MCP tools over
Streamable HTTP with Bearer token auth. Supports collections via tag
conventions, chunked file uploads, and agent-side search patterns.
Engine gains PATCH /api/v1/notes/{id} for in-place note updates with
transactional re-chunk/re-embed, and updated_at column on documents.
Go client adds updatenote command and Patch HTTP method.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -1 +1 @@
|
||||
2.1.0
|
||||
3.0.0
|
||||
|
||||
@@ -15,3 +15,17 @@ services:
|
||||
- KB_SEARCH_THRESHOLD=${KB_SEARCH_THRESHOLD:-0.01}
|
||||
- HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-}
|
||||
restart: unless-stopped
|
||||
|
||||
kb-mcp:
|
||||
build:
|
||||
context: ../mcp
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${KB_MCP_PORT:-3000}:3000"
|
||||
environment:
|
||||
- KB_ENGINE_URL=http://kb-engine:8000
|
||||
- KB_API_KEY=${KB_API_KEY:-}
|
||||
- KB_MCP_API_KEY=${KB_MCP_API_KEY:-}
|
||||
depends_on:
|
||||
- kb-engine
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -23,3 +23,17 @@ services:
|
||||
- KB_SEARCH_THRESHOLD=${KB_SEARCH_THRESHOLD:-0.01}
|
||||
- HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-}
|
||||
restart: unless-stopped
|
||||
|
||||
kb-mcp:
|
||||
build:
|
||||
context: ../mcp
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${KB_MCP_PORT:-3000}:3000"
|
||||
environment:
|
||||
- KB_ENGINE_URL=http://kb-engine:8000
|
||||
- KB_API_KEY=${KB_API_KEY:-}
|
||||
- KB_MCP_API_KEY=${KB_MCP_API_KEY:-}
|
||||
depends_on:
|
||||
- kb-engine
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -20,3 +20,17 @@ services:
|
||||
- KB_SEARCH_THRESHOLD=${KB_SEARCH_THRESHOLD:-0.01}
|
||||
- HF_HUB_OFFLINE=${HF_HUB_OFFLINE:-}
|
||||
restart: unless-stopped
|
||||
|
||||
kb-mcp:
|
||||
build:
|
||||
context: ../mcp
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "${KB_MCP_PORT:-3000}:3000"
|
||||
environment:
|
||||
- KB_ENGINE_URL=http://kb-engine:8000
|
||||
- KB_API_KEY=${KB_API_KEY:-}
|
||||
- KB_MCP_API_KEY=${KB_MCP_API_KEY:-}
|
||||
depends_on:
|
||||
- kb-engine
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -185,6 +185,10 @@ def init_schema(conn: sqlite3.Connection, embedding_dim: int) -> None:
|
||||
_backfill_enriched_text(conn)
|
||||
_rebuild_fts(conn)
|
||||
|
||||
# Migrate: add updated_at to documents if missing (v3.0.0)
|
||||
if "updated_at" not in doc_cols:
|
||||
conn.execute("ALTER TABLE documents ADD COLUMN updated_at TEXT")
|
||||
|
||||
conn.commit()
|
||||
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
from kb.routes import health, search, jobs, documents, tags, status, reindex, auth
|
||||
from kb.routes import health, search, jobs, documents, tags, status, reindex, auth, notes
|
||||
|
||||
@@ -26,7 +26,7 @@ async def list_documents(
|
||||
sql = """
|
||||
SELECT d.id, d.title, d.doc_type,
|
||||
(SELECT COUNT(*) FROM chunks c WHERE c.document_id = d.id) AS chunk_count,
|
||||
d.created_at
|
||||
d.created_at, d.updated_at
|
||||
FROM documents d
|
||||
"""
|
||||
joins: list[str] = []
|
||||
@@ -50,7 +50,7 @@ async def list_documents(
|
||||
if where:
|
||||
sql += " WHERE " + " AND ".join(where)
|
||||
|
||||
sql += " ORDER BY d.created_at DESC"
|
||||
sql += " ORDER BY COALESCE(d.updated_at, d.created_at) DESC"
|
||||
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
|
||||
@@ -74,6 +74,7 @@ async def list_documents(
|
||||
"tags": [t["name"] for t in tag_rows],
|
||||
"chunk_count": row["chunk_count"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
})
|
||||
|
||||
return results
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Note mutation endpoint — update existing notes in place."""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from main import app
|
||||
from kb.config import cfg
|
||||
from kb.database import (
|
||||
get_connection,
|
||||
build_enriched_text,
|
||||
insert_chunk,
|
||||
insert_embedding,
|
||||
)
|
||||
from kb.embeddings import embed_texts
|
||||
from kb.ingest.note import chunk_note
|
||||
|
||||
logger = logging.getLogger("kb.routes.notes")
|
||||
|
||||
|
||||
class NoteUpdateRequest(BaseModel):
|
||||
text: str
|
||||
|
||||
|
||||
@app.patch("/api/v1/notes/{doc_id}")
|
||||
async def update_note(doc_id: int, req: NoteUpdateRequest):
|
||||
conn = get_connection(cfg.db_path)
|
||||
try:
|
||||
doc = conn.execute(
|
||||
"SELECT id, title, doc_type FROM documents WHERE id = ?", (doc_id,)
|
||||
).fetchone()
|
||||
if not doc:
|
||||
raise HTTPException(status_code=404, detail="Document not found.")
|
||||
if doc["doc_type"] != "note":
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Only notes can be updated via this endpoint.",
|
||||
)
|
||||
|
||||
title = doc["title"]
|
||||
|
||||
# Delete existing chunks and their embeddings
|
||||
chunk_ids = conn.execute(
|
||||
"SELECT id FROM chunks WHERE document_id = ?", (doc_id,)
|
||||
).fetchall()
|
||||
for row in chunk_ids:
|
||||
conn.execute("DELETE FROM chunks_vec WHERE chunk_id = ?", (row["id"],))
|
||||
conn.execute("DELETE FROM chunks WHERE document_id = ?", (doc_id,))
|
||||
|
||||
# Run note chunking pipeline on new text
|
||||
chunks = chunk_note(req.text)
|
||||
chunk_texts = [c["text"] for c in chunks]
|
||||
chunk_metas = [
|
||||
{k: v for k, v in c.items() if k != "text"} or None for c in chunks
|
||||
]
|
||||
|
||||
enriched_texts = [
|
||||
build_enriched_text(title, ct, cm)
|
||||
for ct, cm in zip(chunk_texts, chunk_metas)
|
||||
]
|
||||
|
||||
# Embed — if this fails, the transaction rolls back
|
||||
vectors = embed_texts(enriched_texts)
|
||||
|
||||
for idx, (chunk_text, enriched, vector) in enumerate(
|
||||
zip(chunk_texts, enriched_texts, vectors)
|
||||
):
|
||||
chunk_id = insert_chunk(
|
||||
conn,
|
||||
document_id=doc_id,
|
||||
chunk_index=idx,
|
||||
text=chunk_text,
|
||||
enriched_text=enriched,
|
||||
metadata=chunk_metas[idx],
|
||||
)
|
||||
insert_embedding(conn, chunk_id, vector)
|
||||
|
||||
# Update content_hash and updated_at
|
||||
content_hash = hashlib.sha256(req.text.encode("utf-8")).hexdigest()
|
||||
conn.execute(
|
||||
"UPDATE documents SET content_hash = ?, updated_at = current_timestamp WHERE id = ?",
|
||||
(content_hash, doc_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
# Return updated document
|
||||
updated_doc = conn.execute(
|
||||
"SELECT * FROM documents WHERE id = ?", (doc_id,)
|
||||
).fetchone()
|
||||
|
||||
new_chunks = conn.execute(
|
||||
"SELECT * FROM chunks WHERE document_id = ? ORDER BY chunk_index",
|
||||
(doc_id,),
|
||||
).fetchall()
|
||||
|
||||
tag_rows = conn.execute(
|
||||
"""
|
||||
SELECT t.name FROM tags t
|
||||
JOIN document_tags dt ON t.id = dt.tag_id
|
||||
WHERE dt.document_id = ?
|
||||
ORDER BY t.name
|
||||
""",
|
||||
(doc_id,),
|
||||
).fetchall()
|
||||
|
||||
return {
|
||||
**dict(updated_doc),
|
||||
"tags": [t["name"] for t in tag_rows],
|
||||
"chunks": [dict(c) for c in new_chunks],
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
logger.exception("Failed to update note %d", doc_id)
|
||||
raise HTTPException(status_code=500, detail="Failed to update note.")
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -48,6 +48,13 @@ async def update_document_tags(doc_id: int, req: TagUpdateRequest):
|
||||
if req.remove:
|
||||
untag_document(conn, doc_id, req.remove)
|
||||
|
||||
if req.add or req.remove:
|
||||
conn.execute(
|
||||
"UPDATE documents SET updated_at = current_timestamp WHERE id = ?",
|
||||
(doc_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
tag_rows = conn.execute(
|
||||
"""
|
||||
SELECT t.name FROM tags t
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ async def lifespan(app: FastAPI):
|
||||
app = FastAPI(title="kb-engine", version=__version__, lifespan=lifespan)
|
||||
|
||||
# Import routes after app is created
|
||||
from kb.routes import health, search, jobs, documents, tags, status, reindex, auth # noqa: E402, F401
|
||||
from kb.routes import health, search, jobs, documents, tags, status, reindex, auth, notes # noqa: E402, F401
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
||||
Reference in New Issue
Block a user