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 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 IngestRequest, IngestResponse
7
- from app.services.ingestion_service import ingest_folder
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
 
9
  logger = logging.getLogger(__name__)
10
  router = APIRouter()
11
 
12
 
13
- @router.post("/ingest", response_model=IngestResponse)
14
- async def ingest(request: IngestRequest, x_admin_token: str | None = Header(default=None)):
15
- # Ingestion is CPU-heavy and mutates the index — never expose it publicly.
16
- # In production the index is baked into the Docker image at build time, so
17
- # this endpoint stays disabled unless ADMIN_TOKEN is explicitly configured.
18
- if not settings.admin_token or x_admin_token != settings.admin_token:
19
  raise HTTPException(
20
  status_code=403,
21
- detail="Ingestion over HTTP is disabled. Set ADMIN_TOKEN and pass the X-Admin-Token header, or run scripts/ingest.py locally.",
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 H2/H3 headings, further split oversized sections."""
21
- chunks: List[Dict[str, Any]] = []
22
- # Split on lines that start with ## or ###
23
- sections = re.split(r"(?m)^(#{2,3} .+)$", text)
24
 
25
- current_heading = ""
26
- buffer = ""
 
27
 
28
- def flush(heading: str, content: str) -> None:
29
  content = content.strip()
30
  if not content:
31
  return
32
- sub_chunks = _split_by_size(content, heading)
33
- chunks.extend(sub_chunks)
 
34
 
35
- for part in sections:
36
- if re.match(r"^#{2,3} .+$", part.strip()):
37
- flush(current_heading, buffer)
38
- current_heading = part.strip().lstrip("#").strip()
 
 
 
 
 
 
 
 
 
39
  buffer = part + "\n"
40
  else:
41
  buffer += part
 
42
 
43
- flush(current_heading, buffer)
44
-
45
- result = []
46
- for i, chunk in enumerate(chunks):
47
- result.append(
48
- {
49
- "text": chunk["text"],
50
- "metadata": {
51
- "source": source_path,
52
- "section": chunk.get("heading", ""),
53
- "chunk_index": i,
54
- "type": "markdown",
55
- },
56
- }
57
- )
58
- return result
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 _split_by_size(text: str, heading: str) -> List[Dict[str, Any]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
96
  """Further split a section that exceeds chunk_size."""
97
  if len(text) <= settings.chunk_size:
98
- return [{"text": text, "heading": heading}]
99
-
100
  paragraphs = [p.strip() for p in re.split(r"\n{2,}", text) if p.strip()]
101
- combined = _combine_paragraphs(paragraphs)
102
- return [{"text": c, "heading": heading} for c in combined]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 paragraphs:
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
- overlap_buffer = current[-settings.chunk_overlap:] if settings.chunk_overlap else ""
116
- current = (overlap_buffer + "\n\n" + para).strip()
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
- return _chunk_pdf(file_path, relative)
 
 
 
 
 
 
 
 
 
 
 
141
 
142
- try:
143
- text = file_path.read_text(encoding="utf-8")
144
- except UnicodeDecodeError:
145
- text = file_path.read_text(encoding="latin-1")
146
 
147
- if ext == ".md":
148
- return _chunk_markdown(text, relative)
149
- return _chunk_text(text, relative)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- chunks = _process_file(file_path, base)
173
- all_chunks.extend(chunks)
174
 
175
  if not all_chunks:
176
  return 0, "No supported files found"
177
 
178
- BATCH = 64
179
- total = 0
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