Spaces:
Sleeping
Sleeping
Samir Majzoub Claude Sonnet 4.6 commited on
Commit ·
ebc4fb4
1
Parent(s): f86c12c
Add admin panel backend: upload/edit/delete endpoints + .docx support
Browse files- New admin API endpoints: POST /admin/upload, GET/DELETE /admin/documents,
GET /admin/categories, GET/PUT /admin/documents/content (edit + re-ingest)
- ingestion_service: add _chunk_docx(), category metadata on all chunks,
ingest_single_file(), delete_document(), UPLOAD_ALLOWED_EXTENSIONS
- vector_store: add list_sources() with path-derived category fallback
for documents ingested before the category field was added
- schemas: DocumentInfo, DocumentListResponse, UploadResponse,
CategoryListResponse, DocumentContentResponse, UpdateContentRequest
- requirements: add python-docx==1.1.2
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- backend/.env.example +42 -0
- backend/app/api/ingestion.py +191 -10
- backend/app/models/schemas.py +40 -0
- backend/app/services/ingestion_service.py +265 -63
- backend/app/services/vector_store.py +38 -0
- backend/requirements.txt +1 -0
backend/.env.example
CHANGED
|
@@ -9,3 +9,45 @@ CHUNK_SIZE=700
|
|
| 9 |
CHUNK_OVERLAP=100
|
| 10 |
MAX_HISTORY_TURNS=10
|
| 11 |
KNOWLEDGE_BASE_PATH=../knowledge
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
CHUNK_OVERLAP=100
|
| 10 |
MAX_HISTORY_TURNS=10
|
| 11 |
KNOWLEDGE_BASE_PATH=../knowledge
|
| 12 |
+
|
| 13 |
+
# ── Admin panel ───────────────────────────────────────────────────────────────
|
| 14 |
+
# Required to call all /api/admin/* endpoints (upload, list, delete).
|
| 15 |
+
# Empty = all admin endpoints return 403. Generate: openssl rand -hex 32
|
| 16 |
+
ADMIN_TOKEN=
|
| 17 |
+
# Add the admin panel's Vercel URL here so the browser can reach the backend.
|
| 18 |
+
# Example: CORS_ORIGINS=http://localhost:3000,https://negoptim-admin.vercel.app
|
| 19 |
+
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
| 20 |
+
|
| 21 |
+
# ── Abuse protection / cost control ──────────────────────────────────────────
|
| 22 |
+
# Shared secret gating POST /chat (must match the frontend proxy's value).
|
| 23 |
+
# Empty = disabled (local dev). Generate with: openssl rand -hex 32
|
| 24 |
+
INTERNAL_AUTH_TOKEN=
|
| 25 |
+
RATE_LIMIT_CHAT_PER_MINUTE=20
|
| 26 |
+
CHAT_PER_IP_PER_DAY=60
|
| 27 |
+
CHAT_GLOBAL_PER_DAY=800
|
| 28 |
+
MAX_INPUT_CHARS=1500
|
| 29 |
+
MAX_OUTPUT_TOKENS=600
|
| 30 |
+
|
| 31 |
+
# ── ROI Snapshot analysis feature ────────────────────────────────────────────
|
| 32 |
+
ANALYSIS_ENABLED=true
|
| 33 |
+
ANALYSIS_PER_IP_PER_DAY=2
|
| 34 |
+
ANALYSIS_GLOBAL_PER_DAY=100
|
| 35 |
+
ANALYSIS_MAX_OUTPUT_TOKENS=450
|
| 36 |
+
BENCHMARK_DATA_TTL_HOURS=24
|
| 37 |
+
|
| 38 |
+
# ── Email ─────────────────────────────────────────────────────────────────────
|
| 39 |
+
# Delivery priority: Resend HTTP API → SMTP → simulation (all empty = logs only).
|
| 40 |
+
# Resend is the only path that works on the HF free tier (SMTP ports blocked).
|
| 41 |
+
RESEND_API_KEY=
|
| 42 |
+
# Sales inbox: where demo requests are addressed. REQUIRED for real delivery.
|
| 43 |
+
# With the Resend sandbox sender (onboarding@resend.dev), only the Resend
|
| 44 |
+
# account owner's address is deliverable until a domain is verified.
|
| 45 |
+
SALES_EMAIL=you@example.com
|
| 46 |
+
# General email mode may only target these domains (comma-separated) —
|
| 47 |
+
# open-relay protection for the public demo.
|
| 48 |
+
EMAIL_RECIPIENT_ALLOWLIST=usersloveit.com
|
| 49 |
+
# Force-route ALL outgoing mail to a test inbox. Empty = real recipients.
|
| 50 |
+
EMAIL_OVERRIDE_TO=
|
| 51 |
+
# SMTP fallback (local dev only)
|
| 52 |
+
SMTP_USER=
|
| 53 |
+
SMTP_PASSWORD=
|
backend/app/api/ingestion.py
CHANGED
|
@@ -1,29 +1,210 @@
|
|
|
|
|
|
|
|
| 1 |
import logging
|
|
|
|
| 2 |
|
| 3 |
-
from fastapi import APIRouter, Header, HTTPException
|
| 4 |
|
| 5 |
from app.config import settings
|
| 6 |
-
from app.models.schemas import
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
logger = logging.getLogger(__name__)
|
| 10 |
router = APIRouter()
|
| 11 |
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
if not settings.admin_token or x_admin_token != settings.admin_token:
|
| 19 |
raise HTTPException(
|
| 20 |
status_code=403,
|
| 21 |
-
detail="
|
| 22 |
)
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
try:
|
| 25 |
count, message = ingest_folder(request.folder)
|
| 26 |
return IngestResponse(ingested=count, message=message)
|
| 27 |
except Exception:
|
| 28 |
logger.exception("Ingestion failed")
|
| 29 |
raise HTTPException(status_code=500, detail="Ingestion failed — see server logs.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import hmac
|
| 2 |
+
import re
|
| 3 |
import logging
|
| 4 |
+
from pathlib import Path
|
| 5 |
|
| 6 |
+
from fastapi import APIRouter, Header, HTTPException, UploadFile, File, Form
|
| 7 |
|
| 8 |
from app.config import settings
|
| 9 |
+
from app.models.schemas import (
|
| 10 |
+
IngestRequest,
|
| 11 |
+
IngestResponse,
|
| 12 |
+
DocumentInfo,
|
| 13 |
+
DocumentListResponse,
|
| 14 |
+
UploadResponse,
|
| 15 |
+
CategoryListResponse,
|
| 16 |
+
DocumentContentResponse,
|
| 17 |
+
UpdateContentRequest,
|
| 18 |
+
)
|
| 19 |
+
from app.services.ingestion_service import (
|
| 20 |
+
ingest_folder,
|
| 21 |
+
ingest_single_file,
|
| 22 |
+
delete_document,
|
| 23 |
+
UPLOAD_ALLOWED_EXTENSIONS,
|
| 24 |
+
)
|
| 25 |
+
from app.services.vector_store import get_vector_store
|
| 26 |
|
| 27 |
logger = logging.getLogger(__name__)
|
| 28 |
router = APIRouter()
|
| 29 |
|
| 30 |
|
| 31 |
+
def _require_admin(token: str | None) -> None:
|
| 32 |
+
"""Raise 403 if the token is missing or doesn't match ADMIN_TOKEN."""
|
| 33 |
+
if not settings.admin_token or not hmac.compare_digest(
|
| 34 |
+
(token or "").encode(), settings.admin_token.encode()
|
| 35 |
+
):
|
|
|
|
| 36 |
raise HTTPException(
|
| 37 |
status_code=403,
|
| 38 |
+
detail="Invalid or missing admin token. Set ADMIN_TOKEN and pass X-Admin-Token header.",
|
| 39 |
)
|
| 40 |
|
| 41 |
+
|
| 42 |
+
# ── Existing bulk-ingest endpoint ─────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@router.post("/ingest", response_model=IngestResponse)
|
| 46 |
+
async def ingest(
|
| 47 |
+
request: IngestRequest,
|
| 48 |
+
x_admin_token: str | None = Header(default=None),
|
| 49 |
+
):
|
| 50 |
+
_require_admin(x_admin_token)
|
| 51 |
try:
|
| 52 |
count, message = ingest_folder(request.folder)
|
| 53 |
return IngestResponse(ingested=count, message=message)
|
| 54 |
except Exception:
|
| 55 |
logger.exception("Ingestion failed")
|
| 56 |
raise HTTPException(status_code=500, detail="Ingestion failed — see server logs.")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
# ── Admin: upload a single document ──────────────────────────────────────────
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
@router.post("/admin/upload", response_model=UploadResponse)
|
| 63 |
+
async def upload_document(
|
| 64 |
+
file: UploadFile = File(...),
|
| 65 |
+
category: str = Form(...),
|
| 66 |
+
x_admin_token: str | None = Header(default=None),
|
| 67 |
+
):
|
| 68 |
+
_require_admin(x_admin_token)
|
| 69 |
+
|
| 70 |
+
ext = Path(file.filename or "").suffix.lower()
|
| 71 |
+
if ext not in UPLOAD_ALLOWED_EXTENSIONS:
|
| 72 |
+
allowed = ", ".join(sorted(UPLOAD_ALLOWED_EXTENSIONS))
|
| 73 |
+
raise HTTPException(
|
| 74 |
+
status_code=400,
|
| 75 |
+
detail=f"Unsupported file type '{ext}'. Allowed: {allowed}",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
# Sanitize category: lowercase alphanumeric, hyphens, underscores only
|
| 79 |
+
safe_category = re.sub(r"[^\w\-]", "_", category.strip()).strip("_").lower()
|
| 80 |
+
if not safe_category:
|
| 81 |
+
raise HTTPException(status_code=400, detail="Category name is required")
|
| 82 |
+
|
| 83 |
+
# Sanitize filename: keep word chars, hyphens, dots, spaces → underscores
|
| 84 |
+
original_name = Path(file.filename or "upload").name
|
| 85 |
+
safe_name = re.sub(r"[^\w\-. ]", "", original_name).strip().replace(" ", "_")
|
| 86 |
+
if not safe_name or safe_name.startswith("."):
|
| 87 |
+
raise HTTPException(status_code=400, detail="Invalid filename")
|
| 88 |
+
|
| 89 |
+
base = Path(settings.knowledge_base_path).resolve()
|
| 90 |
+
category_dir = base / safe_category
|
| 91 |
+
category_dir.mkdir(parents=True, exist_ok=True)
|
| 92 |
+
|
| 93 |
+
file_path = category_dir / safe_name
|
| 94 |
+
content = await file.read()
|
| 95 |
+
file_path.write_bytes(content)
|
| 96 |
+
|
| 97 |
+
try:
|
| 98 |
+
count, message = ingest_single_file(file_path, base)
|
| 99 |
+
except Exception:
|
| 100 |
+
file_path.unlink(missing_ok=True)
|
| 101 |
+
logger.exception("Ingestion failed for %s", file_path)
|
| 102 |
+
raise HTTPException(status_code=500, detail="Ingestion failed — see server logs.")
|
| 103 |
+
|
| 104 |
+
return UploadResponse(
|
| 105 |
+
filename=safe_name,
|
| 106 |
+
category=safe_category,
|
| 107 |
+
chunks_ingested=count,
|
| 108 |
+
message=message,
|
| 109 |
+
)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# ── Admin: list indexed documents ─────────────────────────────────────────────
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
@router.get("/admin/documents", response_model=DocumentListResponse)
|
| 116 |
+
async def list_documents(x_admin_token: str | None = Header(default=None)):
|
| 117 |
+
_require_admin(x_admin_token)
|
| 118 |
+
docs = get_vector_store().list_sources()
|
| 119 |
+
return DocumentListResponse(
|
| 120 |
+
documents=[DocumentInfo(**d) for d in docs],
|
| 121 |
+
total=len(docs),
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ── Admin: delete a document ──────────────────────────────────────────────────
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@router.delete("/admin/documents")
|
| 129 |
+
async def remove_document(
|
| 130 |
+
source: str,
|
| 131 |
+
x_admin_token: str | None = Header(default=None),
|
| 132 |
+
):
|
| 133 |
+
_require_admin(x_admin_token)
|
| 134 |
+
ok, message = delete_document(source)
|
| 135 |
+
if not ok:
|
| 136 |
+
raise HTTPException(status_code=400, detail=message)
|
| 137 |
+
return {"message": message}
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ── Admin: get editable file content ─────────────────────────────────────────
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
@router.get("/admin/documents/content", response_model=DocumentContentResponse)
|
| 144 |
+
async def get_document_content(
|
| 145 |
+
source: str,
|
| 146 |
+
x_admin_token: str | None = Header(default=None),
|
| 147 |
+
):
|
| 148 |
+
_require_admin(x_admin_token)
|
| 149 |
+
base = Path(settings.knowledge_base_path).resolve()
|
| 150 |
+
target = (base / source).resolve()
|
| 151 |
+
|
| 152 |
+
if not target.is_relative_to(base):
|
| 153 |
+
raise HTTPException(status_code=400, detail="Invalid source path")
|
| 154 |
+
if not target.exists():
|
| 155 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 156 |
+
if target.suffix.lower() not in {".md", ".txt"}:
|
| 157 |
+
raise HTTPException(status_code=400, detail="Editing is only supported for .md and .txt files")
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
content = target.read_text(encoding="utf-8")
|
| 161 |
+
except UnicodeDecodeError:
|
| 162 |
+
content = target.read_text(encoding="latin-1")
|
| 163 |
+
|
| 164 |
+
return DocumentContentResponse(source=source, content=content)
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
# ── Admin: save edited content and re-ingest ─────────────────────────────────
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
@router.put("/admin/documents/content", response_model=UploadResponse)
|
| 171 |
+
async def update_document_content(
|
| 172 |
+
request: UpdateContentRequest,
|
| 173 |
+
x_admin_token: str | None = Header(default=None),
|
| 174 |
+
):
|
| 175 |
+
_require_admin(x_admin_token)
|
| 176 |
+
base = Path(settings.knowledge_base_path).resolve()
|
| 177 |
+
target = (base / request.source).resolve()
|
| 178 |
+
|
| 179 |
+
if not target.is_relative_to(base):
|
| 180 |
+
raise HTTPException(status_code=400, detail="Invalid source path")
|
| 181 |
+
if not target.exists():
|
| 182 |
+
raise HTTPException(status_code=404, detail="File not found")
|
| 183 |
+
if target.suffix.lower() not in {".md", ".txt"}:
|
| 184 |
+
raise HTTPException(status_code=400, detail="Editing is only supported for .md and .txt files")
|
| 185 |
+
|
| 186 |
+
target.write_text(request.content, encoding="utf-8")
|
| 187 |
+
|
| 188 |
+
try:
|
| 189 |
+
count, message = ingest_single_file(target, base)
|
| 190 |
+
except Exception:
|
| 191 |
+
logger.exception("Re-ingestion failed for %s", target)
|
| 192 |
+
raise HTTPException(status_code=500, detail="Re-ingestion failed — see server logs.")
|
| 193 |
+
|
| 194 |
+
return UploadResponse(
|
| 195 |
+
filename=target.name,
|
| 196 |
+
category=target.parent.name if target.parent != base else "",
|
| 197 |
+
chunks_ingested=count,
|
| 198 |
+
message=message,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
# ── Admin: list available categories ─────────────────────────────────────────
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
@router.get("/admin/categories", response_model=CategoryListResponse)
|
| 206 |
+
async def list_categories(x_admin_token: str | None = Header(default=None)):
|
| 207 |
+
_require_admin(x_admin_token)
|
| 208 |
+
base = Path(settings.knowledge_base_path).resolve()
|
| 209 |
+
categories = sorted(d.name for d in base.iterdir() if d.is_dir())
|
| 210 |
+
return CategoryListResponse(categories=categories)
|
backend/app/models/schemas.py
CHANGED
|
@@ -9,12 +9,16 @@ class ChatRequest(BaseModel):
|
|
| 9 |
# Email-flow controls (set by the draft card's Send/Cancel buttons).
|
| 10 |
email_action: Optional[str] = None # "send" | "cancel"
|
| 11 |
email_payload: Optional[dict] = None # {"subject": ..., "body": ...} (edited draft)
|
|
|
|
|
|
|
| 12 |
|
| 13 |
|
| 14 |
class Source(BaseModel):
|
| 15 |
document: str
|
| 16 |
section: Optional[str] = None
|
| 17 |
score: float
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class TokenUsage(BaseModel):
|
|
@@ -53,6 +57,42 @@ class IngestResponse(BaseModel):
|
|
| 53 |
message: str
|
| 54 |
|
| 55 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
class HealthResponse(BaseModel):
|
| 57 |
status: str
|
| 58 |
llm: bool
|
|
|
|
| 9 |
# Email-flow controls (set by the draft card's Send/Cancel buttons).
|
| 10 |
email_action: Optional[str] = None # "send" | "cancel"
|
| 11 |
email_payload: Optional[dict] = None # {"subject": ..., "body": ...} (edited draft)
|
| 12 |
+
# Analysis-flow control (set by the Negoptim AI toggle).
|
| 13 |
+
analysis_action: Optional[str] = None # "start" | "cancel"
|
| 14 |
|
| 15 |
|
| 16 |
class Source(BaseModel):
|
| 17 |
document: str
|
| 18 |
section: Optional[str] = None
|
| 19 |
score: float
|
| 20 |
+
# Truncated retrieved chunk text, shown in the UI's source detail popup.
|
| 21 |
+
excerpt: Optional[str] = None
|
| 22 |
|
| 23 |
|
| 24 |
class TokenUsage(BaseModel):
|
|
|
|
| 57 |
message: str
|
| 58 |
|
| 59 |
|
| 60 |
+
# ── Admin schemas ─────────────────────────────────────────────────────────────
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class DocumentInfo(BaseModel):
|
| 64 |
+
source: str
|
| 65 |
+
category: str
|
| 66 |
+
title: str
|
| 67 |
+
chunk_count: int
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
class DocumentListResponse(BaseModel):
|
| 71 |
+
documents: List[DocumentInfo]
|
| 72 |
+
total: int
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class UploadResponse(BaseModel):
|
| 76 |
+
filename: str
|
| 77 |
+
category: str
|
| 78 |
+
chunks_ingested: int
|
| 79 |
+
message: str
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class CategoryListResponse(BaseModel):
|
| 83 |
+
categories: List[str]
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class DocumentContentResponse(BaseModel):
|
| 87 |
+
source: str
|
| 88 |
+
content: str
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class UpdateContentRequest(BaseModel):
|
| 92 |
+
source: str
|
| 93 |
+
content: str
|
| 94 |
+
|
| 95 |
+
|
| 96 |
class HealthResponse(BaseModel):
|
| 97 |
status: str
|
| 98 |
llm: bool
|
backend/app/services/ingestion_service.py
CHANGED
|
@@ -10,52 +10,78 @@ from app.services.vector_store import get_vector_store
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
-
SUPPORTED_EXTENSIONS = {".md", ".txt", ".pdf"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
# ── Chunking ──────────────────────────────────────────────────────────────────
|
| 17 |
|
| 18 |
|
| 19 |
def _chunk_markdown(text: str, source_path: str) -> List[Dict[str, Any]]:
|
| 20 |
-
"""Split markdown by
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
sections = re.split(r"(?m)^(#{2,3} .+)$", text)
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
|
|
|
| 27 |
|
| 28 |
-
def flush(
|
| 29 |
content = content.strip()
|
| 30 |
if not content:
|
| 31 |
return
|
| 32 |
-
|
| 33 |
-
|
|
|
|
| 34 |
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
buffer = part + "\n"
|
| 40 |
else:
|
| 41 |
buffer += part
|
|
|
|
| 42 |
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
"
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
)
|
| 58 |
-
|
| 59 |
|
| 60 |
|
| 61 |
def _chunk_text(text: str, source_path: str) -> List[Dict[str, Any]]:
|
|
@@ -68,6 +94,7 @@ def _chunk_text(text: str, source_path: str) -> List[Dict[str, Any]]:
|
|
| 68 |
"text": chunk,
|
| 69 |
"metadata": {
|
| 70 |
"source": source_path,
|
|
|
|
| 71 |
"section": "",
|
| 72 |
"chunk_index": i,
|
| 73 |
"type": "text",
|
|
@@ -92,28 +119,135 @@ def _chunk_pdf(file_path: Path, source_path: str) -> List[Dict[str, Any]]:
|
|
| 92 |
return _chunk_text(full_text, source_path)
|
| 93 |
|
| 94 |
|
| 95 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
"""Further split a section that exceeds chunk_size."""
|
| 97 |
if len(text) <= settings.chunk_size:
|
| 98 |
-
return [
|
| 99 |
-
|
| 100 |
paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()]
|
| 101 |
-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
|
| 104 |
|
| 105 |
def _combine_paragraphs(paragraphs: List[str]) -> List[str]:
|
| 106 |
"""Combine short paragraphs into chunks of ~chunk_size with overlap."""
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
chunks: List[str] = []
|
| 108 |
current = ""
|
| 109 |
-
overlap_buffer = ""
|
| 110 |
|
| 111 |
-
for para in
|
| 112 |
candidate = (current + "\n\n" + para).strip() if current else para
|
| 113 |
if len(candidate) > settings.chunk_size and current:
|
| 114 |
chunks.append(current.strip())
|
| 115 |
-
|
| 116 |
-
current = (
|
| 117 |
else:
|
| 118 |
current = candidate
|
| 119 |
|
|
@@ -123,7 +257,16 @@ def _combine_paragraphs(paragraphs: List[str]) -> List[str]:
|
|
| 123 |
return chunks if chunks else ["\n\n".join(paragraphs)]
|
| 124 |
|
| 125 |
|
| 126 |
-
# ── Ingestion ─────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
def _make_chunk_id(source_path: str, index: int, text: str) -> str:
|
|
@@ -135,26 +278,94 @@ def _make_chunk_id(source_path: str, index: int, text: str) -> str:
|
|
| 135 |
def _process_file(file_path: Path, base_path: Path) -> List[Dict[str, Any]]:
|
| 136 |
relative = str(file_path.relative_to(base_path))
|
| 137 |
ext = file_path.suffix.lower()
|
|
|
|
| 138 |
|
| 139 |
if ext == ".pdf":
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
def ingest_folder(folder: str | None = None) -> Tuple[int, str]:
|
| 153 |
base = Path(settings.knowledge_base_path).resolve()
|
| 154 |
|
| 155 |
if folder:
|
| 156 |
-
target = base / folder
|
| 157 |
-
if not target.exists():
|
| 158 |
return 0, f"Folder not found: {folder}"
|
| 159 |
targets = [target]
|
| 160 |
else:
|
|
@@ -169,22 +380,13 @@ def ingest_folder(folder: str | None = None) -> Tuple[int, str]:
|
|
| 169 |
for file_path in target.rglob("*"):
|
| 170 |
if file_path.suffix.lower() in SUPPORTED_EXTENSIONS and file_path.is_file():
|
| 171 |
logger.info("Processing: %s", file_path)
|
| 172 |
-
|
| 173 |
-
all_chunks.extend(chunks)
|
| 174 |
|
| 175 |
if not all_chunks:
|
| 176 |
return 0, "No supported files found"
|
| 177 |
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
for i in range(0, len(all_chunks), BATCH):
|
| 181 |
-
batch = all_chunks[i : i + BATCH]
|
| 182 |
-
texts = [c["text"] for c in batch]
|
| 183 |
-
embeddings = embedding_svc.embed_passages(texts)
|
| 184 |
-
ids = [_make_chunk_id(c["metadata"]["source"], c["metadata"]["chunk_index"], c["text"]) for c in batch]
|
| 185 |
-
metadatas = [c["metadata"] for c in batch]
|
| 186 |
-
vector_store.upsert(ids=ids, embeddings=embeddings, documents=texts, metadatas=metadatas)
|
| 187 |
-
total += len(batch)
|
| 188 |
-
logger.info("Ingested %d / %d chunks", total, len(all_chunks))
|
| 189 |
|
|
|
|
| 190 |
return total, f"Successfully ingested {total} chunks from {len(all_chunks)} total"
|
|
|
|
| 10 |
|
| 11 |
logger = logging.getLogger(__name__)
|
| 12 |
|
| 13 |
+
SUPPORTED_EXTENSIONS = {".md", ".txt", ".pdf", ".docx"}
|
| 14 |
+
# Subset accepted through the admin upload endpoint (PDF excluded for now)
|
| 15 |
+
UPLOAD_ALLOWED_EXTENSIONS = {".md", ".txt", ".docx"}
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _title_from_path(source_path: str) -> str:
|
| 22 |
+
return Path(source_path).stem.replace("-", " ").replace("_", " ").strip().title()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _extract_category(source_path: str) -> str:
|
| 26 |
+
"""Top-level folder name becomes the document category."""
|
| 27 |
+
parts = Path(source_path).parts
|
| 28 |
+
return parts[0] if len(parts) > 1 else ""
|
| 29 |
|
| 30 |
|
| 31 |
# ── Chunking ──────────────────────────────────────────────────────────────────
|
| 32 |
|
| 33 |
|
| 34 |
def _chunk_markdown(text: str, source_path: str) -> List[Dict[str, Any]]:
|
| 35 |
+
"""Split markdown by heading structure (H1-H3), tracking a breadcrumb
|
| 36 |
+
trail so each chunk knows its place in the document hierarchy."""
|
| 37 |
+
parts = re.split(r"(?m)^(#{1,3} .+)$", text)
|
|
|
|
| 38 |
|
| 39 |
+
title = ""
|
| 40 |
+
h2 = h3 = ""
|
| 41 |
+
chunks: List[Dict[str, Any]] = []
|
| 42 |
|
| 43 |
+
def flush(content: str) -> None:
|
| 44 |
content = content.strip()
|
| 45 |
if not content:
|
| 46 |
return
|
| 47 |
+
section = " › ".join(h for h in (h2, h3) if h)
|
| 48 |
+
for piece in _split_by_size(content):
|
| 49 |
+
chunks.append({"text": piece, "section": section})
|
| 50 |
|
| 51 |
+
buffer = ""
|
| 52 |
+
for part in parts:
|
| 53 |
+
m = re.match(r"^(#{1,3}) (.+)$", part.strip())
|
| 54 |
+
if m:
|
| 55 |
+
flush(buffer)
|
| 56 |
+
level, heading = len(m.group(1)), m.group(2).strip()
|
| 57 |
+
if level == 1:
|
| 58 |
+
title = title or heading
|
| 59 |
+
h2 = h3 = ""
|
| 60 |
+
elif level == 2:
|
| 61 |
+
h2, h3 = heading, ""
|
| 62 |
+
else:
|
| 63 |
+
h3 = heading
|
| 64 |
buffer = part + "\n"
|
| 65 |
else:
|
| 66 |
buffer += part
|
| 67 |
+
flush(buffer)
|
| 68 |
|
| 69 |
+
if not title:
|
| 70 |
+
title = _title_from_path(source_path)
|
| 71 |
+
|
| 72 |
+
return [
|
| 73 |
+
{
|
| 74 |
+
"text": c["text"],
|
| 75 |
+
"metadata": {
|
| 76 |
+
"source": source_path,
|
| 77 |
+
"title": title,
|
| 78 |
+
"section": c["section"],
|
| 79 |
+
"chunk_index": i,
|
| 80 |
+
"type": "markdown",
|
| 81 |
+
},
|
| 82 |
+
}
|
| 83 |
+
for i, c in enumerate(chunks)
|
| 84 |
+
]
|
| 85 |
|
| 86 |
|
| 87 |
def _chunk_text(text: str, source_path: str) -> List[Dict[str, Any]]:
|
|
|
|
| 94 |
"text": chunk,
|
| 95 |
"metadata": {
|
| 96 |
"source": source_path,
|
| 97 |
+
"title": _title_from_path(source_path),
|
| 98 |
"section": "",
|
| 99 |
"chunk_index": i,
|
| 100 |
"type": "text",
|
|
|
|
| 119 |
return _chunk_text(full_text, source_path)
|
| 120 |
|
| 121 |
|
| 122 |
+
def _chunk_docx(file_path: Path, source_path: str) -> List[Dict[str, Any]]:
|
| 123 |
+
"""Extract text from a Word document, treating Heading styles like markdown
|
| 124 |
+
headings so structure is preserved in the same way as .md files."""
|
| 125 |
+
try:
|
| 126 |
+
from docx import Document as DocxDocument
|
| 127 |
+
except ImportError:
|
| 128 |
+
logger.warning("python-docx not installed, skipping .docx: %s", file_path)
|
| 129 |
+
return []
|
| 130 |
+
|
| 131 |
+
doc = DocxDocument(str(file_path))
|
| 132 |
+
|
| 133 |
+
# Parse paragraphs into (level, text): level 0 = body, 1/2/3 = headings
|
| 134 |
+
entries: List[Tuple[int, str]] = []
|
| 135 |
+
for para in doc.paragraphs:
|
| 136 |
+
text = para.text.strip()
|
| 137 |
+
if not text:
|
| 138 |
+
continue
|
| 139 |
+
style = para.style.name if para.style else ""
|
| 140 |
+
if style.startswith("Heading 1"):
|
| 141 |
+
entries.append((1, text))
|
| 142 |
+
elif style.startswith("Heading 2"):
|
| 143 |
+
entries.append((2, text))
|
| 144 |
+
elif style.startswith("Heading 3"):
|
| 145 |
+
entries.append((3, text))
|
| 146 |
+
else:
|
| 147 |
+
entries.append((0, text))
|
| 148 |
+
|
| 149 |
+
title = ""
|
| 150 |
+
h2 = h3 = ""
|
| 151 |
+
raw_chunks: List[Dict[str, Any]] = []
|
| 152 |
+
buffer: List[str] = []
|
| 153 |
+
|
| 154 |
+
def flush_buffer() -> None:
|
| 155 |
+
combined = "\n\n".join(buffer).strip()
|
| 156 |
+
buffer.clear()
|
| 157 |
+
if not combined:
|
| 158 |
+
return
|
| 159 |
+
section = " › ".join(h for h in (h2, h3) if h)
|
| 160 |
+
for piece in _split_by_size(combined):
|
| 161 |
+
raw_chunks.append({"text": piece, "section": section})
|
| 162 |
+
|
| 163 |
+
for level, text in entries:
|
| 164 |
+
if level == 0:
|
| 165 |
+
buffer.append(text)
|
| 166 |
+
elif level == 1:
|
| 167 |
+
flush_buffer()
|
| 168 |
+
if not title:
|
| 169 |
+
title = text
|
| 170 |
+
h2 = h3 = ""
|
| 171 |
+
elif level == 2:
|
| 172 |
+
flush_buffer()
|
| 173 |
+
h2, h3 = text, ""
|
| 174 |
+
else:
|
| 175 |
+
flush_buffer()
|
| 176 |
+
h3 = text
|
| 177 |
+
|
| 178 |
+
flush_buffer()
|
| 179 |
+
|
| 180 |
+
if not title:
|
| 181 |
+
title = _title_from_path(source_path)
|
| 182 |
+
|
| 183 |
+
return [
|
| 184 |
+
{
|
| 185 |
+
"text": c["text"],
|
| 186 |
+
"metadata": {
|
| 187 |
+
"source": source_path,
|
| 188 |
+
"title": title,
|
| 189 |
+
"section": c["section"],
|
| 190 |
+
"chunk_index": i,
|
| 191 |
+
"type": "docx",
|
| 192 |
+
},
|
| 193 |
+
}
|
| 194 |
+
for i, c in enumerate(raw_chunks)
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def _split_by_size(text: str) -> List[str]:
|
| 199 |
"""Further split a section that exceeds chunk_size."""
|
| 200 |
if len(text) <= settings.chunk_size:
|
| 201 |
+
return [text]
|
|
|
|
| 202 |
paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()]
|
| 203 |
+
return _combine_paragraphs(paragraphs)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def _split_oversized(paragraph: str) -> List[str]:
|
| 207 |
+
"""Split a single paragraph that exceeds chunk_size at sentence boundaries."""
|
| 208 |
+
if len(paragraph) <= settings.chunk_size:
|
| 209 |
+
return [paragraph]
|
| 210 |
+
|
| 211 |
+
sentences = re.split(r"(?<=[.!?])\s+", paragraph)
|
| 212 |
+
pieces: List[str] = []
|
| 213 |
+
current = ""
|
| 214 |
+
for sentence in sentences:
|
| 215 |
+
candidate = f"{current} {sentence}".strip() if current else sentence
|
| 216 |
+
if len(candidate) > settings.chunk_size and current:
|
| 217 |
+
pieces.append(current)
|
| 218 |
+
current = sentence
|
| 219 |
+
else:
|
| 220 |
+
current = candidate
|
| 221 |
+
if current:
|
| 222 |
+
pieces.append(current)
|
| 223 |
+
return pieces
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def _overlap_tail(chunk: str) -> str:
|
| 227 |
+
"""Last chunk_overlap chars of a chunk, trimmed to a word boundary."""
|
| 228 |
+
if not settings.chunk_overlap:
|
| 229 |
+
return ""
|
| 230 |
+
tail = chunk[-settings.chunk_overlap:]
|
| 231 |
+
if " " in tail:
|
| 232 |
+
tail = tail.split(" ", 1)[1]
|
| 233 |
+
return tail.strip()
|
| 234 |
|
| 235 |
|
| 236 |
def _combine_paragraphs(paragraphs: List[str]) -> List[str]:
|
| 237 |
"""Combine short paragraphs into chunks of ~chunk_size with overlap."""
|
| 238 |
+
units: List[str] = []
|
| 239 |
+
for para in paragraphs:
|
| 240 |
+
units.extend(_split_oversized(para))
|
| 241 |
+
|
| 242 |
chunks: List[str] = []
|
| 243 |
current = ""
|
|
|
|
| 244 |
|
| 245 |
+
for para in units:
|
| 246 |
candidate = (current + "\n\n" + para).strip() if current else para
|
| 247 |
if len(candidate) > settings.chunk_size and current:
|
| 248 |
chunks.append(current.strip())
|
| 249 |
+
tail = _overlap_tail(current)
|
| 250 |
+
current = (tail + "\n\n" + para).strip() if tail else para
|
| 251 |
else:
|
| 252 |
current = candidate
|
| 253 |
|
|
|
|
| 257 |
return chunks if chunks else ["\n\n".join(paragraphs)]
|
| 258 |
|
| 259 |
|
| 260 |
+
# ── Ingestion helpers ─────────────────────────────────────────────────────────
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _embedding_text(chunk: Dict[str, Any]) -> str:
|
| 264 |
+
"""Text actually embedded: '<title> — <section>' header + chunk body."""
|
| 265 |
+
meta = chunk["metadata"]
|
| 266 |
+
header = meta.get("title", "")
|
| 267 |
+
if meta.get("section"):
|
| 268 |
+
header = f"{header} — {meta['section']}" if header else meta["section"]
|
| 269 |
+
return f"{header}\n{chunk['text']}" if header else chunk["text"]
|
| 270 |
|
| 271 |
|
| 272 |
def _make_chunk_id(source_path: str, index: int, text: str) -> str:
|
|
|
|
| 278 |
def _process_file(file_path: Path, base_path: Path) -> List[Dict[str, Any]]:
|
| 279 |
relative = str(file_path.relative_to(base_path))
|
| 280 |
ext = file_path.suffix.lower()
|
| 281 |
+
category = _extract_category(relative)
|
| 282 |
|
| 283 |
if ext == ".pdf":
|
| 284 |
+
chunks = _chunk_pdf(file_path, relative)
|
| 285 |
+
elif ext == ".docx":
|
| 286 |
+
chunks = _chunk_docx(file_path, relative)
|
| 287 |
+
else:
|
| 288 |
+
try:
|
| 289 |
+
text = file_path.read_text(encoding="utf-8")
|
| 290 |
+
except UnicodeDecodeError:
|
| 291 |
+
text = file_path.read_text(encoding="latin-1")
|
| 292 |
+
if ext == ".md":
|
| 293 |
+
chunks = _chunk_markdown(text, relative)
|
| 294 |
+
else:
|
| 295 |
+
chunks = _chunk_text(text, relative)
|
| 296 |
|
| 297 |
+
for c in chunks:
|
| 298 |
+
c["metadata"]["category"] = category
|
| 299 |
+
|
| 300 |
+
return chunks
|
| 301 |
|
| 302 |
+
|
| 303 |
+
def _ingest_chunks(chunks: List[Dict[str, Any]], vector_store, embedding_svc) -> int:
|
| 304 |
+
"""Embed and upsert chunks in batches. Returns total upserted."""
|
| 305 |
+
BATCH = 64
|
| 306 |
+
total = 0
|
| 307 |
+
for i in range(0, len(chunks), BATCH):
|
| 308 |
+
batch = chunks[i: i + BATCH]
|
| 309 |
+
embeddings = embedding_svc.embed_passages([_embedding_text(c) for c in batch])
|
| 310 |
+
ids = [
|
| 311 |
+
_make_chunk_id(c["metadata"]["source"], c["metadata"]["chunk_index"], c["text"])
|
| 312 |
+
for c in batch
|
| 313 |
+
]
|
| 314 |
+
vector_store.upsert(
|
| 315 |
+
ids=ids,
|
| 316 |
+
embeddings=embeddings,
|
| 317 |
+
documents=[c["text"] for c in batch],
|
| 318 |
+
metadatas=[c["metadata"] for c in batch],
|
| 319 |
+
)
|
| 320 |
+
total += len(batch)
|
| 321 |
+
logger.info("Ingested %d / %d chunks", total, len(chunks))
|
| 322 |
+
return total
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
# ── Public API ────────────────────────────────────────────────────────────────
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def ingest_single_file(file_path: Path, base_path: Path) -> Tuple[int, str]:
|
| 329 |
+
"""Ingest (or re-ingest) a single file. Deletes stale vectors first."""
|
| 330 |
+
if file_path.suffix.lower() not in SUPPORTED_EXTENSIONS:
|
| 331 |
+
return 0, f"Unsupported file type: {file_path.suffix}"
|
| 332 |
+
|
| 333 |
+
embedding_svc = get_embedding_service()
|
| 334 |
+
vector_store = get_vector_store()
|
| 335 |
+
|
| 336 |
+
chunks = _process_file(file_path, base_path)
|
| 337 |
+
if not chunks:
|
| 338 |
+
return 0, f"No content extracted from {file_path.name}"
|
| 339 |
+
|
| 340 |
+
relative = str(file_path.relative_to(base_path))
|
| 341 |
+
vector_store.delete_by_sources([relative])
|
| 342 |
+
total = _ingest_chunks(chunks, vector_store, embedding_svc)
|
| 343 |
+
|
| 344 |
+
return total, f"Ingested {total} chunks from {file_path.name}"
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def delete_document(source: str) -> Tuple[bool, str]:
|
| 348 |
+
"""Remove a document's vectors and its file from the knowledge base."""
|
| 349 |
+
base = Path(settings.knowledge_base_path).resolve()
|
| 350 |
+
target = (base / source).resolve()
|
| 351 |
+
|
| 352 |
+
if not target.is_relative_to(base):
|
| 353 |
+
return False, "Invalid source path"
|
| 354 |
+
|
| 355 |
+
get_vector_store().delete_by_sources([source])
|
| 356 |
+
|
| 357 |
+
if target.exists() and target.is_file():
|
| 358 |
+
target.unlink()
|
| 359 |
+
|
| 360 |
+
return True, f"Deleted {source}"
|
| 361 |
|
| 362 |
|
| 363 |
def ingest_folder(folder: str | None = None) -> Tuple[int, str]:
|
| 364 |
base = Path(settings.knowledge_base_path).resolve()
|
| 365 |
|
| 366 |
if folder:
|
| 367 |
+
target = (base / folder).resolve()
|
| 368 |
+
if not target.is_relative_to(base) or not target.exists():
|
| 369 |
return 0, f"Folder not found: {folder}"
|
| 370 |
targets = [target]
|
| 371 |
else:
|
|
|
|
| 380 |
for file_path in target.rglob("*"):
|
| 381 |
if file_path.suffix.lower() in SUPPORTED_EXTENSIONS and file_path.is_file():
|
| 382 |
logger.info("Processing: %s", file_path)
|
| 383 |
+
all_chunks.extend(_process_file(file_path, base))
|
|
|
|
| 384 |
|
| 385 |
if not all_chunks:
|
| 386 |
return 0, "No supported files found"
|
| 387 |
|
| 388 |
+
processed_sources = sorted({c["metadata"]["source"] for c in all_chunks})
|
| 389 |
+
vector_store.delete_by_sources(processed_sources)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
+
total = _ingest_chunks(all_chunks, vector_store, embedding_svc)
|
| 392 |
return total, f"Successfully ingested {total} chunks from {len(all_chunks)} total"
|
backend/app/services/vector_store.py
CHANGED
|
@@ -34,6 +34,14 @@ class VectorStore:
|
|
| 34 |
metadatas=metadatas,
|
| 35 |
)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
def query(self, embedding: List[float], top_k: int) -> Dict[str, Any]:
|
| 38 |
return self._collection.query(
|
| 39 |
query_embeddings=[embedding],
|
|
@@ -52,6 +60,36 @@ class VectorStore:
|
|
| 52 |
)
|
| 53 |
logger.info("Collection reset")
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def is_healthy(self) -> bool:
|
| 56 |
try:
|
| 57 |
self._collection.count()
|
|
|
|
| 34 |
metadatas=metadatas,
|
| 35 |
)
|
| 36 |
|
| 37 |
+
def delete_by_sources(self, sources: List[str]) -> None:
|
| 38 |
+
"""Remove all chunks belonging to the given source files. Called before
|
| 39 |
+
re-ingesting a file so edited documents don't leave stale vectors
|
| 40 |
+
behind (chunk IDs are content-hashed, so edits change the IDs)."""
|
| 41 |
+
if not sources:
|
| 42 |
+
return
|
| 43 |
+
self._collection.delete(where={"source": {"$in": sources}})
|
| 44 |
+
|
| 45 |
def query(self, embedding: List[float], top_k: int) -> Dict[str, Any]:
|
| 46 |
return self._collection.query(
|
| 47 |
query_embeddings=[embedding],
|
|
|
|
| 60 |
)
|
| 61 |
logger.info("Collection reset")
|
| 62 |
|
| 63 |
+
def list_sources(self) -> List[Dict[str, Any]]:
|
| 64 |
+
"""Return per-source stats aggregated from all chunk metadata.
|
| 65 |
+
Category is read from stored metadata when available, otherwise
|
| 66 |
+
derived from the top-level folder in the source path so that
|
| 67 |
+
documents ingested before the category field was added still
|
| 68 |
+
display correctly."""
|
| 69 |
+
from pathlib import Path as _Path
|
| 70 |
+
|
| 71 |
+
result = self._collection.get(include=["metadatas"])
|
| 72 |
+
metadatas = result.get("metadatas") or []
|
| 73 |
+
|
| 74 |
+
sources: Dict[str, Dict[str, Any]] = {}
|
| 75 |
+
for meta in metadatas:
|
| 76 |
+
source = meta.get("source", "")
|
| 77 |
+
stored_cat = meta.get("category", "")
|
| 78 |
+
if not stored_cat:
|
| 79 |
+
parts = _Path(source).parts
|
| 80 |
+
stored_cat = parts[0] if len(parts) > 1 else ""
|
| 81 |
+
|
| 82 |
+
if source not in sources:
|
| 83 |
+
sources[source] = {
|
| 84 |
+
"source": source,
|
| 85 |
+
"category": stored_cat,
|
| 86 |
+
"title": meta.get("title", ""),
|
| 87 |
+
"chunk_count": 0,
|
| 88 |
+
}
|
| 89 |
+
sources[source]["chunk_count"] += 1
|
| 90 |
+
|
| 91 |
+
return sorted(sources.values(), key=lambda x: x["source"])
|
| 92 |
+
|
| 93 |
def is_healthy(self) -> bool:
|
| 94 |
try:
|
| 95 |
self._collection.count()
|
backend/requirements.txt
CHANGED
|
@@ -7,4 +7,5 @@ sentence-transformers==3.3.1
|
|
| 7 |
httpx==0.28.1
|
| 8 |
python-multipart==0.0.19
|
| 9 |
pypdf==5.1.0
|
|
|
|
| 10 |
python-dotenv==1.0.1
|
|
|
|
| 7 |
httpx==0.28.1
|
| 8 |
python-multipart==0.0.19
|
| 9 |
pypdf==5.1.0
|
| 10 |
+
python-docx==1.1.2
|
| 11 |
python-dotenv==1.0.1
|