This commit is contained in:
2025-11-14 14:47:19 +00:00
parent 2a85845835
commit 46b2319e2d
199 changed files with 607543 additions and 11147 deletions
+3
View File
@@ -0,0 +1,3 @@
from . import cabinets, files
+75
View File
@@ -0,0 +1,75 @@
import os
from fastapi import APIRouter, Depends, HTTPException
from typing import Any, Dict
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
router = APIRouter()
auth = SupabaseBearer()
@router.get("/cabinets")
def list_cabinets(payload: Dict[str, Any] = Depends(auth)):
user_id = payload.get('sub') or payload.get('user_id')
if not user_id:
raise HTTPException(status_code=401, detail="Invalid token payload")
client = SupabaseServiceRoleClient()
# Owned + shared via membership
owned = client.supabase.table('file_cabinets').select('*').eq('user_id', user_id).execute().data
shared = client.supabase.table('cabinet_memberships').select('cabinet_id').eq('profile_id', user_id).execute().data
shared_ids = [m['cabinet_id'] for m in (shared or [])]
shared_rows = client.supabase.table('file_cabinets').select('*').in_('id', shared_ids).execute().data if shared_ids else []
return {"owned": owned or [], "shared": shared_rows or []}
@router.post("/cabinets")
def create_cabinet(body: Dict[str, Any], payload: Dict[str, Any] = Depends(auth)):
user_id = payload.get('sub') or payload.get('user_id')
name = (body or {}).get('name')
if not user_id or not name:
raise HTTPException(status_code=400, detail="name is required")
client = SupabaseServiceRoleClient()
res = client.supabase.table('file_cabinets').insert({
'user_id': user_id,
'name': name
}).execute()
return res.data
@router.patch("/cabinets/{cabinet_id}")
def rename_cabinet(cabinet_id: str, body: Dict[str, Any], payload: Dict[str, Any] = Depends(auth)):
name = (body or {}).get('name')
if not name:
raise HTTPException(status_code=400, detail="name is required")
client = SupabaseServiceRoleClient()
res = client.supabase.table('file_cabinets').update({'name': name}).eq('id', cabinet_id).execute()
return res.data
@router.delete("/cabinets/{cabinet_id}")
def delete_cabinet(cabinet_id: str, payload: Dict[str, Any] = Depends(auth)):
client = SupabaseServiceRoleClient()
res = client.supabase.table('file_cabinets').delete().eq('id', cabinet_id).execute()
return res.data
@router.post("/cabinets/{cabinet_id}/members")
def add_member(cabinet_id: str, body: Dict[str, Any], payload: Dict[str, Any] = Depends(auth)):
target_profile_id = (body or {}).get('profile_id')
role = (body or {}).get('role', 'viewer')
if not target_profile_id:
raise HTTPException(status_code=400, detail="profile_id required")
client = SupabaseServiceRoleClient()
# Insert membership (RLS will ensure only owner can do it)
res = client.supabase.table('cabinet_memberships').upsert({
'cabinet_id': cabinet_id,
'profile_id': target_profile_id,
'role': role
}).execute()
return res.data
@router.delete("/cabinets/{cabinet_id}/members/{profile_id}")
def remove_member(cabinet_id: str, profile_id: str, payload: Dict[str, Any] = Depends(auth)):
client = SupabaseServiceRoleClient()
res = client.supabase.table('cabinet_memberships').delete().match({
'cabinet_id': cabinet_id,
'profile_id': profile_id
}).execute()
return res.data
File diff suppressed because it is too large Load Diff
+256
View File
@@ -0,0 +1,256 @@
"""
Simplified Files Router
======================
Simplified version of the files router with auto-processing removed.
Keeps only essential functionality for file management and manual processing triggers.
This replaces the complex auto-processing system with simple file storage.
"""
import os
import uuid
import logging
from typing import Dict, List, Optional, Any
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form, BackgroundTasks
from fastapi.responses import JSONResponse
from modules.auth.supabase_bearer import SupabaseBearer
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
from modules.database.supabase.utils.storage import StorageAdmin
from modules.logger_tool import initialise_logger
router = APIRouter()
auth = SupabaseBearer()
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
def _choose_bucket(scope: str, user_id: str, school_id: Optional[str]) -> str:
"""Choose appropriate bucket based on scope - matches old system logic."""
scope = (scope or 'teacher').lower()
if scope == 'school' and school_id:
return f"cc.institutes.{school_id}.private"
# teacher / student fall back to users bucket for now
return 'cc.users'
@router.post("/files/upload")
async def upload_file(
cabinet_id: str = Form(...),
path: str = Form(...),
scope: str = Form(...),
file: UploadFile = File(...),
payload: Dict[str, Any] = Depends(auth)
):
"""
SIMPLIFIED file upload - no automatic processing.
Just stores the file and creates a database record.
This is the legacy endpoint maintained for backward compatibility.
"""
try:
user_id = payload.get('sub') or payload.get('user_id')
if not user_id:
raise HTTPException(status_code=401, detail="User ID required")
# Read file content
file_bytes = await file.read()
file_size = len(file_bytes)
mime_type = file.content_type or 'application/octet-stream'
filename = file.filename or path
logger.info(f"📤 Simplified upload: {filename} ({file_size} bytes) for user {user_id}")
# Initialize services
client = SupabaseServiceRoleClient()
storage = StorageAdmin()
# Generate file ID and storage path
file_id = str(uuid.uuid4())
# Use same bucket logic as old system for consistency
bucket = _choose_bucket('teacher', user_id, None)
storage_path = f"{cabinet_id}/{file_id}/{filename}"
# Store file in Supabase storage
try:
storage.upload_file(bucket, storage_path, file_bytes, mime_type, upsert=True)
except Exception as e:
logger.error(f"Storage upload failed for {file_id}: {e}")
raise HTTPException(status_code=500, detail=f"Storage upload failed: {str(e)}")
# Create database record
try:
insert_res = client.supabase.table('files').insert({
'id': file_id,
'name': filename,
'cabinet_id': cabinet_id,
'bucket': bucket,
'path': storage_path,
'mime_type': mime_type,
'uploaded_by': user_id,
'size_bytes': file_size,
'source': 'classroomcopilot-web',
'is_directory': False,
'processing_status': 'uploaded', # No auto-processing
'relative_path': filename
}).execute()
if not insert_res.data:
# Clean up storage on DB failure
try:
storage.delete_file(bucket, storage_path)
except:
pass
raise HTTPException(status_code=500, detail="Failed to create file record")
file_record = insert_res.data[0]
except Exception as e:
logger.error(f"Database insert failed for {file_id}: {e}")
# Clean up storage
try:
storage.delete_file(bucket, storage_path)
except:
pass
raise HTTPException(status_code=500, detail=f"Database error: {str(e)}")
logger.info(f"✅ Simplified upload completed: {file_id}")
return {
'status': 'success',
'message': 'File uploaded successfully (no auto-processing)',
'file': file_record,
'auto_processing_disabled': True,
'next_steps': 'Use manual processing endpoints if needed'
}
except HTTPException:
raise
except Exception as e:
logger.error(f"Upload error: {e}")
raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}")
@router.get("/files")
def list_files(cabinet_id: str, payload: Dict[str, Any] = Depends(auth)):
"""List files in a cabinet."""
client = SupabaseServiceRoleClient()
res = client.supabase.table('files').select('*').eq('cabinet_id', cabinet_id).execute()
return res.data
@router.get("/files/{file_id}")
def get_file(file_id: str, payload: Dict[str, Any] = Depends(auth)):
"""Get file details."""
client = SupabaseServiceRoleClient()
res = client.supabase.table('files').select('*').eq('id', file_id).single().execute()
if not res.data:
raise HTTPException(status_code=404, detail="File not found")
return res.data
@router.delete("/files/{file_id}")
def delete_file(file_id: str, payload: Dict[str, Any] = Depends(auth)):
"""Delete a file."""
client = SupabaseServiceRoleClient()
storage = StorageAdmin()
# Get file info first
res = client.supabase.table('files').select('*').eq('id', file_id).single().execute()
if not res.data:
raise HTTPException(status_code=404, detail="File not found")
file_data = res.data
# Delete from storage
try:
storage.delete_file(file_data['bucket'], file_data['path'])
except Exception as e:
logger.warning(f"Failed to delete file from storage: {e}")
# Delete from database
delete_res = client.supabase.table('files').delete().eq('id', file_id).execute()
logger.info(f"🗑️ Deleted file: {file_id}")
return {
'status': 'success',
'message': 'File deleted successfully'
}
@router.post("/files/{file_id}/process-manual")
async def trigger_manual_processing(
file_id: str,
processing_type: str = Form('basic'), # basic, advanced, custom
payload: Dict[str, Any] = Depends(auth)
):
"""
Trigger manual processing for a file.
This is where users can manually start processing when they want it.
"""
# TODO: Implement manual processing triggers
# This would call the archived processing logic when the user explicitly requests it
logger.info(f"🔧 Manual processing requested for file {file_id} (type: {processing_type})")
return {
'status': 'accepted',
'message': f'Manual processing queued for file {file_id}',
'processing_type': processing_type,
'note': 'Manual processing not yet implemented - will use archived auto-processing logic'
}
@router.get("/files/{file_id}/status")
def get_processing_status(file_id: str, payload: Dict[str, Any] = Depends(auth)):
"""Get processing status for a file."""
client = SupabaseServiceRoleClient()
res = client.supabase.table('files').select('processing_status, error_message, extra').eq('id', file_id).single().execute()
if not res.data:
raise HTTPException(status_code=404, detail="File not found")
return {
'file_id': file_id,
'status': res.data.get('processing_status', 'unknown'),
'error': res.data.get('error_message'),
'details': res.data.get('extra', {})
}
# Keep existing artefacts endpoints for backward compatibility
@router.get("/files/{file_id}/artefacts")
def list_file_artefacts(file_id: str, payload: Dict[str, Any] = Depends(auth)):
"""List artefacts for a file."""
client = SupabaseServiceRoleClient()
res = client.supabase.table('document_artefacts').select('*').eq('file_id', file_id).execute()
return res.data or []
@router.get("/files/{file_id}/viewer-artefacts")
def list_viewer_artefacts(file_id: str, payload: Dict[str, Any] = Depends(auth)):
"""List artefacts organized for the viewer."""
client = SupabaseServiceRoleClient()
# Get all artefacts
res = client.supabase.table('document_artefacts').select('*').eq('file_id', file_id).execute()
artefacts = res.data or []
# Simple organization - no complex bundle logic
organized = {
'document_analysis': [],
'processing_bundles': [],
'raw_data': []
}
for artefact in artefacts:
artefact_type = artefact.get('type', '')
if 'analysis' in artefact_type.lower():
organized['document_analysis'].append(artefact)
elif any(bundle_type in artefact_type for bundle_type in ['docling', 'bundle']):
organized['processing_bundles'].append(artefact)
else:
organized['raw_data'].append(artefact)
return organized
+588
View File
@@ -0,0 +1,588 @@
# api/routers/database/files/split_map.py
"""
Automatic split_map.json generator for uploaded documents.
This module creates chapter/section boundaries for documents using existing artefacts
(Tika JSON, Docling frontmatter OCR) and optional PDF outline extraction.
Strategy (waterfall, stop at confidence ≥ 0.7):
1. PDF Outline/Bookmarks (best): confidence ≈ 0.95
2. Headings from Docling JSON: confidence ≈ 0.8
3. TOC from Tika text: confidence ≈ 0.7-0.8
4. Fixed windows: confidence ≈ 0.2
Hard constraints:
- For any fallback Docling "no-OCR" call: limit page_range to [1, min(30, page_count)]
- Never process more than 30 pages in one Docling request
- Use existing artefacts whenever possible
"""
import re
import json
import uuid
import datetime
import os
import requests
from typing import List, Dict, Any, Optional, Tuple
from modules.database.supabase.utils.client import SupabaseServiceRoleClient
from modules.database.supabase.utils.storage import StorageAdmin
from modules.logger_tool import initialise_logger
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
# ---------- Utilities
def _now_iso():
"""Return current UTC timestamp in ISO format."""
return datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z"
def _load_artefact_json(storage: StorageAdmin, bucket: str, rel_path: str) -> Optional[Dict[str, Any]]:
"""Load JSON artefact from storage."""
try:
raw = storage.download_file(bucket, rel_path)
return json.loads(raw.decode("utf-8"))
except Exception as e:
logger.debug(f"Failed to load artefact {rel_path}: {e}")
return None
def _page_count_from_tika(tika_json: Dict[str, Any]) -> Optional[int]:
"""Extract page count from Tika JSON metadata."""
for k in ("xmpTPg:NPages", "Page-Count", "pdf:PageCount", "pdf:pagecount"):
v = tika_json.get(k) or tika_json.get(k.lower())
try:
if v is not None:
return int(v)
except Exception:
pass
return None
# ---------- A) Outline via PyMuPDF (optional but recommended)
def _try_outline(pdf_bytes: bytes) -> Optional[List[Tuple[str, int]]]:
"""
Extract PDF outline/bookmarks using PyMuPDF.
Returns [(title, start_page)] for level-1 bookmarks only.
"""
try:
import fitz # PyMuPDF
doc = fitz.open(stream=pdf_bytes, filetype="pdf")
toc = doc.get_toc(simple=True) # list of [level, title, page]
doc.close()
# Keep level-1 only, ensure valid pages
out = []
for level, title, page in toc:
if level == 1 and page >= 1:
clean_title = title.strip()
if clean_title and len(clean_title) > 1:
out.append((clean_title, page))
return out if len(out) >= 2 else None # Need at least 2 chapters
except ImportError:
logger.debug("PyMuPDF not available, skipping outline extraction")
return None
except Exception as e:
logger.debug(f"Outline extraction failed: {e}")
return None
# ---------- B) Headings from Docling JSON
def _try_headings(docling_json: Dict[str, Any]) -> Optional[List[Tuple[str, int, int]]]:
"""
Extract headings from Docling JSON.
Returns [(title, start_page, level)] — we only return starts; end pages are computed later.
"""
if not docling_json:
return None
# Handle different Docling JSON structures
blocks = (docling_json.get("blocks") or
docling_json.get("elements") or
docling_json.get("body", {}).get("blocks") or [])
candidates: List[Tuple[str, int, int]] = []
for b in blocks:
# Check if this is a heading block
role = (b.get("role") or b.get("type") or "").lower()
if not ("heading" in role or role in ("h1", "h2", "title", "section-header")):
continue
# Extract text content
text = (b.get("text") or b.get("content") or "").strip()
if not text or len(text) < 3:
continue
# Extract page number with robust handling of 0-based pageIndex
p = None
if b.get("pageIndex") is not None:
try:
p = int(b.get("pageIndex")) + 1
except Exception:
p = None
if p is None:
for key in ("page", "page_no", "page_number"):
if b.get(key) is not None:
try:
p = int(b.get(key))
except Exception:
p = None
break
if p is None or p < 1:
continue
# Determine heading level
level = 1 # default
if "1" in role or "h1" in role:
level = 1
elif "2" in role or "h2" in role:
level = 2
# Chapter regex boosts to level 1
if re.match(r"^\s*(chapter|ch\.?|section|part)\s+\d+", text, re.I):
level = 1
candidates.append((text, p, level))
if not candidates:
return None
# Prefer level 1; if none, promote level 2 to level 1
l1 = [(t, p, l) for (t, p, l) in candidates if l == 1]
if not l1:
l1 = [(t, p, 1) for (t, p, _) in candidates]
# Sort by page and keep strictly increasing pages only
l1_sorted = []
seen = set()
for (t, p, l) in sorted(l1, key=lambda x: x[1]):
if p not in seen and p >= 1:
l1_sorted.append((t, p, l))
seen.add(p)
return l1_sorted if len(l1_sorted) >= 2 else None
def _try_headings_fallback(file_id: str, cabinet_id: str, bucket: str,
processing_bytes: bytes, processing_mime: str,
page_count: int) -> Optional[List[Tuple[str, int, int]]]:
"""
Make a limited Docling no-OCR call (max 30 pages) to extract headings.
This is used only when existing artefacts don't have sufficient heading data.
"""
try:
docling_url = os.getenv('DOCLING_URL') or os.getenv('NEOFS_DOCLING_URL')
if not docling_url:
logger.debug("No Docling URL configured for headings fallback")
return None
# Strictly limit to first 30 pages
max_pages = min(30, page_count)
logger.info(f"Headings fallback: limited Docling call for file_id={file_id}, pages=1-{max_pages}")
# Build Docling request (no-OCR, limited pages)
docling_api_key = os.getenv('DOCLING_API_KEY')
headers = {'Accept': 'application/json'}
if docling_api_key:
headers['X-Api-Key'] = docling_api_key
form_data = [
('target_type', 'inbody'),
('to_formats', 'json'),
('do_ocr', 'false'),
('force_ocr', 'false'),
('image_export_mode', 'embedded'),
('pdf_backend', 'dlparse_v4'),
('table_mode', 'fast'),
('page_range', '1'),
('page_range', str(max_pages))
]
files = [('files', ('file', processing_bytes, processing_mime))]
# Make the request with timeout
timeout = int(os.getenv('DOCLING_HEADINGS_TIMEOUT', '1800')) # 30 minutes default
resp = requests.post(
f"{docling_url.rstrip('/')}/v1/convert/file",
files=files,
data=form_data,
headers=headers,
timeout=timeout
)
resp.raise_for_status()
docling_json = resp.json()
logger.debug(f"Headings fallback: received Docling response for file_id={file_id}")
return _try_headings(docling_json)
except Exception as e:
logger.error(f"Headings fallback failed for file_id={file_id}: {e}")
return None
# ---------- C) TOC from Tika text (dot leaders & page num)
TOC_LINE = re.compile(r"^\s*(.+?)\s?(\.{2,}|\s{3,})\s*(\d{1,4})\s*$")
def _try_toc_text(tika_text: str) -> Optional[List[Tuple[str, int]]]:
"""
Parse TOC from Tika text using dot leaders and page numbers.
Returns [(title, start_page)] if successful.
"""
if not tika_text:
return None
# Heuristic: only scan first ~1500 lines (roughly first 15 pages)
head = "\n".join(tika_text.splitlines()[:1500])
pairs = []
for line in head.splitlines():
m = TOC_LINE.match(line)
if not m:
continue
title = m.group(1).strip()
try:
page = int(m.group(3))
except Exception:
continue
# Reject obvious junk
if len(title) < 3 or page < 1 or page > 9999:
continue
# Skip common false positives
if any(skip in title.lower() for skip in ['copyright', 'isbn', 'published', 'printed']):
continue
pairs.append((title, page))
# Require at least 5 entries and monotonic pages
if len(pairs) >= 5:
pages = [p for _, p in pairs]
if pages == sorted(pages):
logger.debug(f"TOC extraction found {len(pairs)} entries")
return pairs
return None
# ---------- Build entries with ends, apply smoothing
def _entries_from_starts(starts: List[Tuple[str, int, int]], page_count: int, source: str = "headings") -> List[Dict[str, Any]]:
"""
Build entries from start points with computed end pages.
starts: [(title, page, level)]
"""
entries = []
base_confidence = 0.8 if source == "headings" else 0.75
for i, (title, start, level) in enumerate(starts):
end = (starts[i + 1][1] - 1) if i + 1 < len(starts) else page_count
entries.append({
"id": f"sec{i + 1:02d}",
"title": title,
"level": level,
"start_page": int(start),
"end_page": int(end),
"source": source,
"confidence": base_confidence
})
# Merge tiny sections (< 3 pages) into previous
merged = []
for e in entries:
section_size = e["end_page"] - e["start_page"] + 1
if merged and section_size < 3:
# Merge into previous section
merged[-1]["end_page"] = e["end_page"]
merged[-1]["title"] += " / " + e["title"]
merged[-1]["confidence"] *= 0.95 # Slight confidence penalty for merging
else:
merged.append(e)
return merged
def _entries_from_pairs(pairs: List[Tuple[str, int]], page_count: int, source: str = "outline") -> List[Dict[str, Any]]:
"""
Build entries from (title, start_page) pairs.
"""
entries = []
base_confidence = 0.95 if source == "outline" else (0.8 if source == "toc" else 0.75)
for i, (title, start) in enumerate(pairs):
end = (pairs[i + 1][1] - 1) if i + 1 < len(pairs) else page_count
entries.append({
"id": f"sec{i + 1:02d}",
"title": title,
"level": 1,
"start_page": int(start),
"end_page": int(end),
"source": source,
"confidence": base_confidence
})
# Apply same merging logic for tiny sections
merged = []
for e in entries:
section_size = e["end_page"] - e["start_page"] + 1
if merged and section_size < 3:
merged[-1]["end_page"] = e["end_page"]
merged[-1]["title"] += " / " + e["title"]
merged[-1]["confidence"] *= 0.95
else:
merged.append(e)
return merged
# ---------- Post-processing normalization
def _normalize_entries(entries: List[Dict[str, Any]], page_count: int) -> List[Dict[str, Any]]:
"""Normalize entries to ensure:
- coverage from page 1
- 1 <= start_page <= end_page <= page_count
- strictly increasing, non-overlapping ranges
- fill initial gap with a synthetic front matter section if needed
"""
if not entries:
return entries
# Sanitize and sort by start_page
safe: List[Dict[str, Any]] = []
for e in entries:
try:
s = int(e.get("start_page", 1))
t = int(e.get("end_page", s))
except Exception:
continue
s = max(1, min(s, page_count))
t = max(1, min(t, page_count))
if t < s:
t = s
ne = dict(e)
ne["start_page"], ne["end_page"] = s, t
safe.append(ne)
safe.sort(key=lambda x: (x["start_page"], x.get("level", 1)))
# De-overlap by adjusting starts; ensure monotonic ranges
normalized: List[Dict[str, Any]] = []
for e in safe:
if not normalized:
normalized.append(e)
continue
prev = normalized[-1]
if e["start_page"] <= prev["end_page"]:
e["start_page"] = prev["end_page"] + 1
if e["start_page"] > page_count:
continue
if e["end_page"] < e["start_page"]:
e["end_page"] = e["start_page"]
e["end_page"] = min(e["end_page"], page_count)
normalized.append(e)
# Insert synthetic front matter if first start > 1
if normalized and normalized[0]["start_page"] > 1:
front = {
"id": "sec00",
"title": "Front matter",
"level": 1,
"start_page": 1,
"end_page": normalized[0]["start_page"] - 1,
"source": "synthetic",
"confidence": 0.6,
}
normalized.insert(0, front)
# Ensure last section ends at page_count
if normalized and normalized[-1]["end_page"] < page_count:
normalized[-1]["end_page"] = page_count
# Renumber ids sequentially
out: List[Dict[str, Any]] = []
for idx, e in enumerate(normalized, start=1):
ne = dict(e)
ne["id"] = f"sec{idx:02d}"
out.append(ne)
return out
# ---------- Main entry point
def create_split_map_for_file(file_id: str) -> Dict[str, Any]:
"""
Create split_map.json for a file using waterfall strategy:
1. PDF outline (best)
2. Docling headings (from existing or limited fallback)
3. Tika TOC parsing
4. Fixed windows (fallback)
"""
logger.info(f"Creating split_map for file_id={file_id}")
client = SupabaseServiceRoleClient()
storage = StorageAdmin()
# 1) Lookup file row & bucket
fr = client.supabase.table('files').select('id,bucket,cabinet_id,name,path,mime_type').eq('id', file_id).single().execute()
file_row = fr.data or {}
bucket = file_row.get('bucket')
cabinet_id = file_row.get('cabinet_id')
# 2) Find artefacts
arts = client.supabase.table('document_artefacts') \
.select('*').eq('file_id', file_id).order('created_at', desc=True).execute().data or []
def find_art(t):
for a in arts:
if a.get('type') == t:
return a
return None
a_pdf = find_art('document_pdf') # if converted to PDF
a_tika = find_art('tika_json')
a_noocr = find_art('docling_noocr_json')
a_fm = find_art('docling_frontmatter_json')
# 3) Load JSON/text
tika_json = _load_artefact_json(storage, bucket, a_tika['rel_path']) if a_tika else None
docling_noocr = _load_artefact_json(storage, bucket, a_noocr['rel_path']) if a_noocr else None
docling_fm = _load_artefact_json(storage, bucket, a_fm['rel_path']) if a_fm else None
# Get page count
page_count = _page_count_from_tika(tika_json or {}) or 100 # reasonable default
# Get PDF bytes for outline extraction
pdf_bytes = None
processing_bytes = None
processing_mime = None
if a_pdf:
# Use converted PDF
pdf_bytes = storage.download_file(bucket, a_pdf['rel_path'])
processing_bytes = pdf_bytes
processing_mime = 'application/pdf'
else:
# Check if original file is PDF
if file_row.get('mime_type') == 'application/pdf':
pdf_bytes = storage.download_file(bucket, file_row['path'])
processing_bytes = pdf_bytes
processing_mime = 'application/pdf'
# 4) Try methods in waterfall order
method = "fixed"
confidence = 0.2
entries: List[Dict[str, Any]] = []
# A) PDF Outline/Bookmarks (best)
if pdf_bytes and not entries:
logger.debug(f"Trying outline extraction for file_id={file_id}")
pairs = _try_outline(pdf_bytes)
if pairs:
entries = _entries_from_pairs(pairs, page_count, source="outline")
method, confidence = "outline", 0.95
logger.info(f"Split map: outline method found {len(entries)} sections")
# B) Headings from existing Docling JSON
if not entries:
logger.debug(f"Trying headings from existing Docling JSON for file_id={file_id}")
# Try no-OCR first, then frontmatter
for docling_json, source_name in [(docling_noocr, "noocr"), (docling_fm, "frontmatter")]:
if docling_json:
starts = _try_headings(docling_json)
if starts:
entries = _entries_from_starts(starts, page_count, source="headings")
method, confidence = "headings", 0.8
logger.info(f"Split map: headings method ({source_name}) found {len(entries)} sections")
break
# B2) Headings fallback with limited Docling call (if we have processing bytes)
if not entries and processing_bytes and processing_mime:
logger.debug(f"Trying headings fallback with limited Docling call for file_id={file_id}")
starts = _try_headings_fallback(file_id, cabinet_id, bucket, processing_bytes, processing_mime, page_count)
if starts:
entries = _entries_from_starts(starts, page_count, source="headings")
method, confidence = "headings", 0.75 # Slightly lower confidence for fallback
logger.info(f"Split map: headings fallback found {len(entries)} sections")
# C) TOC from Tika text
if not entries and tika_json:
logger.debug(f"Trying TOC extraction from Tika text for file_id={file_id}")
# Try common Tika text keys
text = tika_json.get("X-TIKA:content") or tika_json.get("content") or ""
pairs = _try_toc_text(text)
if pairs:
entries = _entries_from_pairs(pairs, page_count, source="toc")
method, confidence = "toc", 0.75
logger.info(f"Split map: TOC method found {len(entries)} sections")
# D) Fixed windows (fallback)
if not entries:
logger.info(f"Using fixed window fallback for file_id={file_id}")
step = max(10, min(20, page_count // 10)) # Adaptive step size
pairs = []
for i in range(1, page_count + 1, step):
end_page = min(i + step - 1, page_count)
title = f"Pages {i}-{end_page}" if i != end_page else f"Page {i}"
pairs.append((title, i))
entries = _entries_from_pairs(pairs, page_count, source="fixed")
method, confidence = "fixed", 0.2
logger.info(f"Split map: fixed method created {len(entries)} sections")
# 5) Normalize entries and build split_map.json
entries = _normalize_entries(entries, page_count)
split_map = {
"version": 1,
"file_id": file_id,
"source_pdf_artefact_id": a_pdf['id'] if a_pdf else None,
"sources": {
"docling_noocr_json": a_noocr['id'] if a_noocr else None,
"docling_frontmatter_json": a_fm['id'] if a_fm else None,
"tika_json": a_tika['id'] if a_tika else None
},
"method": method,
"confidence": confidence,
"page_count": page_count,
"entries": entries,
"created_at": _now_iso(),
"notes": f"auto-generated using {method} method; user can edit in Split Marker UI"
}
# 6) Store as artefact
artefact_id = str(uuid.uuid4())
rel_path = f"{cabinet_id}/{file_id}/{artefact_id}/split_map.json"
storage.upload_file(
bucket,
rel_path,
json.dumps(split_map, ensure_ascii=False, indent=2).encode("utf-8"),
"application/json",
upsert=True
)
# Enhanced metadata for UI display
enhanced_extra = {
"method": method,
"confidence": confidence,
"entries_count": len(entries),
"display_name": "Document Structure Map",
"bundle_label": "Split Map",
"section_title": "Document Structure Map",
"page_count": page_count,
"bundle_type": "split_map_json",
"processing_mode": "document_analysis",
"pipeline": "structure_analysis",
"is_structure_map": True,
"ui_category": "document_analysis",
"ui_order": 2,
"description": f"Document section boundaries identified using {method} method with {confidence:.1%} confidence ({len(entries)} sections)",
"viewer_type": "json"
}
client.supabase.table('document_artefacts').insert({
"id": artefact_id,
"file_id": file_id,
"type": "split_map_json",
"rel_path": rel_path,
"extra": enhanced_extra,
"status": "completed"
}).execute()
logger.info(f"Split map stored: file_id={file_id}, method={method}, confidence={confidence:.2f}, entries={len(entries)}")
return split_map
+6 -7
View File
@@ -31,20 +31,19 @@ async def upload_curriculum(file: UploadFile = File(...), db_name: str = Form(..
async def upload_school_curriculum(
file: UploadFile = File(...),
db_name: str = Form(...),
school_uuid: str = Form(...),
school_uuid_string: str = Form(...),
school_name: str = Form(...),
school_website: str = Form(...),
school_path: str = Form(...)
school_node_storage_path: str = Form(...)
):
if file.content_type != 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
return {"status": "Error", "message": "Invalid file format"}
logging.info(f"Uploading curriculum for school {school_name} in {db_name}")
dataframes = xl.create_dataframes_from_fastapiuploadfile(file)
school_node = SchoolNode(
unique_id=f'School_{school_uuid}',
school_uuid=school_uuid,
school_name=school_name,
school_website=school_website,
path=school_path
uuid_string=school_uuid_string,
name=school_name,
website=school_website,
node_storage_path=school_node_storage_path
)
return init_school_curriculum.create_curriculum(db_name, dataframes, school_node)
+50 -46
View File
@@ -23,9 +23,9 @@ def initialise_schools_from_config():
"""Initialize a school with the configuration provided from env variables
"""
default_config = {
"school_uuid": "kevlarai",
"school_name": "KevlarAI School",
"school_website": "https://kevlarai.com",
"uuid_string": "kevlarai-dev",
"name": "KevlarAI School",
"website": "https://kevlarai.com",
"timetable_file": "kevlarai_data/kevlarai_timetable.xlsx",
"curriculum_file": "kevlarai_data/kevlarai_curriculum.xlsx"
}
@@ -33,10 +33,10 @@ def initialise_schools_from_config():
# school_config_str = os.getenv("SCHOOL_CONFIG") # TODO: Implement this
school_config = default_config
db_name = f"cc.institutes.{school_config['school_uuid']}"
db_name = f"cc.institutes.{school_config['uuid_string']}"
curriculum_db_name = f"{db_name}.curriculum"
logger.info(f"Creating database for {school_config['school_name']} using db_name: {db_name}")
logger.info(f"Creating database for {school_config['name']} using db_name: {db_name}")
driver = driver_tools.get_driver()
if driver is None:
logger.error("Failed to connect to Neo4j")
@@ -54,7 +54,7 @@ def initialise_schools_from_config():
# Add filesystem path debugging
base_path = os.getenv("NODE_FILESYSTEM_PATH")
schools_path = os.path.join(base_path, "schools")
school_path = os.path.join(schools_path, f"cc.institutes.{school_config['school_uuid']}")
school_path = os.path.join(schools_path, f"cc.institutes.{school_config['uuid_string']}")
logger.debug("Filesystem paths:", {
"base_path": base_path,
@@ -70,29 +70,34 @@ def initialise_schools_from_config():
})
# Create database entry for school without timetable or curriculum
logger.info(f"Creating school entry for {school_config['school_name']} in database {db_name} without timetable or curriculum")
logger.info(f"Creating school entry for {school_config['name']} in database {db_name} without timetable or curriculum")
school_uuid_string=school_config["uuid_strig"]
school_name=school_config["name"]
school_website=school_config["website"]
result = init_school.create_school(
db_name=db_name,
school_uuid=school_config["school_uuid"],
school_name=school_config["school_name"],
school_website=school_config["school_website"]
school_type="development",
uuid_string=school_uuid_string,
name=school_name,
website=school_website
)
logger.success(f"{school_config['school_name']} school entry created successfully")
logger.success(f"{school_config['name']} school entry created successfully")
# Create school node from result
school_node = result['school_node']
refreshed_school_node = SchoolNode(
unique_id=school_node.unique_id,
school_uuid=school_node.school_uuid,
school_name=school_node.school_name,
school_website=school_node.school_website,
path=school_node.path
school_type="development",
uuid_string=school_node.uuid_string,
name=school_node.name,
website=school_node.website
)
# Create timetable entries for school from Excel file
timetable_file = os.path.join(os.getenv("BACKEND_INIT_PATH"), school_config["timetable_file"])
logger.info(f"Creating timetable entries for {school_config['school_name']} using timetable file: {timetable_file}.")
logger.info(f"Creating timetable entries for {school_config['name']} using timetable file: {timetable_file}.")
school_timetable_dataframes = xl.create_dataframes(timetable_file)
@@ -107,7 +112,7 @@ def initialise_schools_from_config():
curriculum_file = os.path.join(os.getenv("BACKEND_INIT_PATH"), school_config["curriculum_file"])
school_curriculum_dataframes = xl.create_dataframes(curriculum_file)
logger.info(f"Creating curriculum entries for {school_config['school_name']} using curriculum file: {curriculum_file}.")
logger.info(f"Creating curriculum entries for {school_config['name']} using curriculum file: {curriculum_file}.")
init_school_curriculum.create_curriculum(
dataframes=school_curriculum_dataframes,
db_name=db_name,
@@ -123,18 +128,18 @@ async def create_user(
user_type: str = Form(...),
user_name: str = Form(...),
user_email: str = Form(...),
school_uuid: str = Form(None),
school_uuid_string: str = Form(None),
school_name: str = Form(None),
school_website: str = Form(None),
school_path: str = Form(None),
school_node_storage_path: str = Form(None),
worker_data: str = Form(None)
):
logger.info(f"Creating user with user_id: {user_id}, user_type: {user_type}, user_name: {user_name}, user_email: {user_email}")
if school_uuid:
logger.info(f"School UUID provided: {school_uuid}")
if school_uuid_string:
logger.info(f"School UUID string provided: {school_uuid_string}")
else:
logger.info(f"No school UUID provided")
logger.info(f"No school UUID string provided")
if school_name:
logger.info(f"School name provided: {school_name}")
@@ -146,8 +151,8 @@ async def create_user(
else:
logger.info(f"No school website provided")
if school_path:
logger.info(f"School path provided: {school_path}")
if school_node_storage_path:
logger.info(f"School path provided: {school_node_storage_path}")
else:
logger.info(f"No school path provided")
@@ -169,13 +174,12 @@ async def create_user(
# Create school node if school data provided
school_node = None
if all([school_uuid, school_name, school_website, school_path]):
if all([school_uuid_string, school_name, school_website, school_node_storage_path]):
school_node = SchoolNode(
unique_id=f'School_{school_uuid}',
school_uuid=school_uuid,
school_name=school_name,
school_website=school_website,
path=school_path
uuid_string=school_uuid_string,
name=school_name,
website=school_website,
node_storage_path=school_node_storage_path
)
# Create user with single database reference
@@ -219,23 +223,23 @@ async def create_schools():
@router.post("/create-department")
async def create_department(
db_name: str = Form(...),
unique_id: str = Form(...),
uuid_string: str = Form(...),
department_name: str = Form(...),
department_code: str = Form(...),
path: str = Form(...)
department_node_storage_path: str = Form(...)
):
if db_name is None or unique_id is None or department_name is None or department_code is None or path is None:
logging.error(f"Invalid department data: {db_name}, {unique_id}, {department_name}, {department_code}, {path}")
if db_name is None or uuid_string is None or department_name is None or department_code is None or department_node_storage_path is None:
logging.error(f"Invalid department data: {db_name}, {uuid_string}, {department_name}, {department_code}, {department_node_storage_path}")
raise HTTPException(status_code=400, detail="Invalid department data")
department = DepartmentNode(
unique_id=unique_id,
uuid_string=uuid_string,
department_name=department_name,
department_code=department_code,
path=path
node_storage_path=department_node_storage_path
)
logger.info(f"Creating department {department_name} with unique_id {unique_id}")
logger.info(f"Creating department {department_name} with uuid_string {uuid_string}")
try:
result = init_school.create_department(db_name, department)
return JSONResponse(content={"status": "success", "data": result})
@@ -246,20 +250,20 @@ async def create_department(
@router.post("/create-class")
async def create_class(
db_name: str = Form(...),
unique_id: str = Form(...),
uuid_string: str = Form(...),
subject_class_code: str = Form(...),
year_group: str = Form(...),
subject: str = Form(...),
subject_code: str = Form(...),
path: str = Form(...)
subject_node_storage_path: str = Form(...)
):
subject_class_node = SubjectClassNode(
unique_id=unique_id,
uuid_string=uuid_string,
subject_class_code=subject_class_code,
year_group=year_group,
subject=subject,
subject_code=subject_code,
path=path
node_storage_path=subject_node_storage_path
)
# Implementation for creating a class
pass
@@ -267,14 +271,14 @@ async def create_class(
@router.post("/create-room")
async def create_room(
db_name: str = Form(...),
room_unique_id: str = Form(...),
room_uuid_string: str = Form(...),
room_code: str = Form(...),
path: str = Form(...)
room_node_storage_path: str = Form(...)
):
room = RoomNode(
room_unique_id=room_unique_id,
room_uuid_string=room_uuid_string,
room_code=room_code,
path=path
node_storage_path=room_node_storage_path
)
# Implementation for creating a room
pass
+10 -12
View File
@@ -15,7 +15,7 @@ logging = logger.get_logger(
from fastapi import APIRouter, File, UploadFile, Form, HTTPException, BackgroundTasks
import pandas as pd
import modules.database.tools.neo4j_driver_tools as driver
from modules.database.tools.neo4j_session_tools import get_node_by_unique_id
from modules.database.tools.neo4j_session_tools import get_node_by_uuid_string
import modules.database.init.init_school_timetable as init_school_timetable
import modules.database.init.init_worker_timetable as init_worker_timetable
from modules.database.schemas.nodes.schools.schools import SchoolNode
@@ -28,18 +28,16 @@ router = APIRouter()
async def upload_school_timetable(
file: UploadFile = File(...),
db_name: str = Form(...),
unique_id: str = Form(...),
school_uuid: str = Form(...),
school_uuid_string: str = Form(...),
school_name: str = Form(...),
school_website: str = Form(...),
path: str = Form(...)
school_node_storage_path: str = Form(...)
):
school_node = SchoolNode(
unique_id=unique_id,
school_uuid=school_uuid,
school_name=school_name,
school_website=school_website,
path=path
uuid_string=school_uuid_string,
name=school_name,
website=school_website,
node_storage_path=school_node_storage_path
)
if file.content_type != 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
return {"status": "Error", "message": "Invalid file format"}
@@ -91,13 +89,13 @@ async def process_worker_timetable(file_content, worker_node_data):
timetable_df = pd.read_excel(BytesIO(file_content))
# Get the school version of the worker node
logging.info(f"Getting school worker node for {worker_node_data['unique_id']} from {worker_node_data['worker_db_name']}")
logging.info(f"Getting school worker node for {worker_node_data['uuid_string']} from {worker_node_data['worker_db_name']}")
with neo_driver.session(database=worker_node_data['worker_db_name']) as neo_session:
school_worker_node = get_node_by_unique_id(session=neo_session, unique_id=worker_node_data['unique_id'])
school_worker_node = get_node_by_uuid_string(session=neo_session, uuid_string=worker_node_data['uuid_string'])
if school_worker_node is None:
error_msg = f"School worker node not found for unique_id: {worker_node_data['unique_id']}"
error_msg = f"School worker node not found for uuid_string: {worker_node_data['uuid_string']}"
logging.error(error_msg)
raise Exception(error_msg)
+14 -16
View File
@@ -15,7 +15,7 @@ logging = logger.get_logger(
from fastapi import APIRouter, File, UploadFile, Form, HTTPException, BackgroundTasks
import pandas as pd
import modules.database.tools.neo4j_driver_tools as driver
from modules.database.tools.neo4j_session_tools import get_node_by_unique_id
from modules.database.tools.neo4j_session_tools import get_node_by_uuid_string
import modules.database.init.init_school_timetable as init_school_timetable
import modules.database.init.init_worker_timetable as init_worker_timetable
from modules.database.schemas.nodes.users import UserNode
@@ -31,18 +31,16 @@ router = APIRouter()
async def upload_school_timetable(
file: UploadFile = File(...),
db_name: str = Form(...),
unique_id: str = Form(...),
school_uuid: str = Form(...),
school_uuid_string: str = Form(...),
school_name: str = Form(...),
school_website: str = Form(...),
path: str = Form(...)
school_node_storage_path: str = Form(...)
):
school_node = SchoolNode(
unique_id=unique_id,
school_uuid=school_uuid,
school_name=school_name,
school_website=school_website,
path=path
uuid_string=school_uuid_string,
name=school_name,
website=school_website,
node_storage_path=school_node_storage_path
)
if file.content_type != 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet':
return {"status": "Error", "message": "Invalid file format"}
@@ -101,13 +99,13 @@ async def process_worker_timetable(file_content, user_node_data, worker_node_dat
timetable_df = pd.read_excel(BytesIO(file_content))
# Get the school version of the worker node
logging.info(f"Getting school worker node for {worker_node_data['unique_id']} from {worker_node_data['worker_db_name']}")
logging.info(f"Getting school worker node for {worker_node_data['uuid_string']} from {worker_node_data['worker_db_name']}")
with neo_driver.session(database=worker_node_data['worker_db_name']) as neo_session:
school_worker_node = get_node_by_unique_id(session=neo_session, unique_id=worker_node_data['unique_id'])
school_worker_node = get_node_by_uuid_string(session=neo_session, uuid_string=worker_node_data['uuid_string'])
if school_worker_node is None:
error_msg = f"School worker node not found for unique_id: {worker_node_data['unique_id']}"
error_msg = f"School worker node not found for uuid_string: {worker_node_data['uuid_string']}"
logging.error(error_msg)
raise Exception(error_msg)
@@ -127,23 +125,23 @@ async def process_worker_timetable(file_content, user_node_data, worker_node_dat
# Create TeacherNode from worker_node_data
user_worker_node = TeacherNode(
unique_id=worker_node_data['unique_id'],
uuid_string=worker_node_data['uuid_string'],
teacher_code=worker_node_data['teacher_code'],
teacher_name_formal=worker_node_data['teacher_name_formal'],
teacher_email=worker_node_data['teacher_email'],
path=worker_node_data['path'],
node_storage_path=worker_node_data['node_storage_path'],
worker_db_name=worker_node_data['worker_db_name'],
user_db_name=worker_node_data['user_db_name']
)
# Create user node
user_node = UserNode(
unique_id=user_node_data['unique_id'],
uuid_string=user_node_data['uuid_string'],
user_id=user_node_data['user_id'],
user_type=user_node_data['user_type'],
user_name=user_node_data['user_name'],
user_email=user_node_data['user_email'],
path=user_node_data['path'],
node_storage_path=user_node_data['node_storage_path'],
worker_node_data=user_node_data['worker_node_data']
)
@@ -27,29 +27,29 @@ async def get_calendar_structure(db_name: str) -> Dict[str, Any]:
// Collect all nodes with dates converted to strings
RETURN {
years: collect(DISTINCT {
id: y.unique_id,
path: y.path,
id: y.uuid_string,
path: y.node_storage_path,
date: toString(y.date),
__primarylabel__: 'CalendarYear'
}),
months: collect(DISTINCT {
id: m.unique_id,
path: m.path,
id: m.uuid_string,
path: m.node_storage_path,
date: toString(m.date),
__primarylabel__: 'CalendarMonth'
}),
weeks: collect(DISTINCT {
id: w.unique_id,
path: w.path,
id: w.uuid_string,
path: w.node_storage_path,
date: toString(w.date),
__primarylabel__: 'CalendarWeek'
}),
days: collect(DISTINCT {
id: d.unique_id,
path: d.path,
id: d.uuid_string,
path: d.node_storage_path,
date: toString(d.date),
week_id: w.unique_id,
month_id: m.unique_id,
week_id: w.uuid_string,
month_id: m.uuid_string,
__primarylabel__: 'CalendarDay'
})
} as structure
@@ -98,11 +98,11 @@ async def get_calendar_days(db_name: str, start_date: str, end_date: str) -> Dic
OPTIONAL MATCH (w:CalendarWeek)-[:WEEK_INCLUDES_DAY]->(d)
OPTIONAL MATCH (m:CalendarMonth)-[:MONTH_INCLUDES_DAY]->(d)
RETURN {
id: d.unique_id,
path: d.path,
id: d.uuid_string,
path: d.node_storage_path,
date: d.date,
week_id: w.unique_id,
month_id: m.unique_id,
week_id: w.uuid_string,
month_id: m.uuid_string,
__primarylabel__: 'CalendarDay'
} as day
ORDER BY d.date
@@ -132,10 +132,10 @@ async def get_calendar_weeks(db_name: str, start_date: str, end_date: str) -> Di
WHERE date(w.date) >= date($start_date) AND date(w.date) <= date($end_date)
WITH w, collect(d) as days
RETURN {
id: w.unique_id,
path: w.path,
id: w.uuid_string,
path: w.node_storage_path,
date: w.date,
day_ids: [day in days | day.unique_id],
day_ids: [day in days | day.uuid_string],
__primarylabel__: 'CalendarWeek'
} as week
ORDER BY w.date
@@ -165,10 +165,10 @@ async def get_calendar_months(db_name: str, start_date: str, end_date: str) -> D
WHERE date(m.date) >= date($start_date) AND date(m.date) <= date($end_date)
WITH m, collect(d) as days
RETURN {
id: m.unique_id,
path: m.path,
id: m.uuid_string,
path: m.node_storage_path,
date: m.date,
day_ids: [day in days | day.unique_id],
day_ids: [day in days | day.uuid_string],
__primarylabel__: 'CalendarMonth'
} as month
ORDER BY m.date
@@ -197,10 +197,10 @@ async def get_calendar_years(db_name: str) -> Dict[str, Any]:
MATCH (y:CalendarYear)-[:YEAR_INCLUDES_MONTH]->(m:CalendarMonth)
WITH y, collect(m) as months
RETURN {
id: y.unique_id,
path: y.path,
id: y.uuid_string,
path: y.node_storage_path,
date: y.date,
month_ids: [month in months | month.unique_id],
month_ids: [month in months | month.uuid_string],
__primarylabel__: 'CalendarYear'
} as year
ORDER BY y.date
+37 -3
View File
@@ -47,7 +47,7 @@ def get_default_node_week(db_name: str) -> Dict[str, Any]:
return {
"status": "success",
"node": {
"id": node["unique_id"],
"id": node["uuid_string"],
"path": node["path"],
"type": "CalendarWeek",
"label": node.get("title", "Calendar Week"),
@@ -81,7 +81,7 @@ def get_default_node_month(db_name: str) -> Dict[str, Any]:
return {
"status": "success",
"node": {
"id": node["unique_id"],
"id": node["uuid_string"],
"path": node["path"],
"type": "CalendarMonth",
"label": node.get("title", "Calendar Month"),
@@ -89,6 +89,39 @@ def get_default_node_month(db_name: str) -> Dict[str, Any]:
}
}
@router.get("/debug-list-nodes")
async def debug_list_nodes(db_name: str) -> Dict[str, Any]:
"""Debug endpoint to list all nodes in a database."""
try:
with driver_tools.get_session(database=db_name) as session:
query = """
MATCH (n)
RETURN labels(n) as labels, n.uuid_string as uuid, n.user_name as name, n.cc_username as username
LIMIT 20
"""
result = session.run(query)
nodes = []
for record in result:
nodes.append({
"labels": list(record["labels"]),
"uuid": record["uuid"],
"name": record["name"],
"username": record["username"]
})
return {
"status": "success",
"db_name": db_name,
"node_count": len(nodes),
"nodes": nodes
}
except Exception as e:
return {
"status": "error",
"db_name": db_name,
"error": str(e)
}
@router.get("/get-default-node/{context}")
async def get_default_node(context: str, db_name: str, base_context: str | None = None) -> Dict[str, Any]:
"""Get the default node for a given context."""
@@ -244,8 +277,9 @@ async def get_default_node(context: str, db_name: str, base_context: str | None
return {
"status": "success",
"node": {
"id": node["unique_id"],
"id": node["uuid_string"],
"path": node["path"],
"node_storage_path": node.get("node_storage_path", node["path"]),
"type": list(node.labels)[0],
"label": node.get("title", ""),
"data": converted_data
+6 -6
View File
@@ -51,10 +51,10 @@ router = APIRouter()
@router.get("/get_teacher_timetable_events")
async def get_teacher_timetable_events(
unique_id: str,
uuid_string: str,
worker_db_name: str
):
logging.info(f"Getting timetable events for teacher {unique_id} from database {worker_db_name}")
logging.info(f"Getting timetable events for teacher {uuid_string} from database {worker_db_name}")
neo_driver = driver.get_driver(db_name=worker_db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -62,9 +62,9 @@ async def get_teacher_timetable_events(
try:
with neo_driver.session(database=worker_db_name) as neo_session:
query = """
MATCH (t:Teacher {unique_id: $unique_id})-[:TEACHER_HAS_TIMETABLE]->(tt:TeacherTimetable)
MATCH (t:Teacher {uuid_string: $uuid_string})-[:TEACHER_HAS_TIMETABLE]->(tt:TeacherTimetable)
-[:TIMETABLE_HAS_CLASS]->(sc:SubjectClass)-[:CLASS_HAS_LESSON]->(tl:TimetableLesson)
RETURN tl.unique_id as id,
RETURN tl.uuid_string as id,
tl.period_code as period_code,
COALESCE(sc.subject_class_code, 'Untitled Class') as subject_class,
tl.date as date,
@@ -72,7 +72,7 @@ async def get_teacher_timetable_events(
tl.end_time as end_time,
tl.path as path
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
events = []
for record in result:
@@ -92,7 +92,7 @@ async def get_teacher_timetable_events(
"path": record['path']
}
})
logging.info(f"Found {len(events)} events for teacher {unique_id}")
logging.info(f"Found {len(events)} events for teacher {uuid_string}")
return {"status": "success", "events": events}
except Exception as e:
logging.error(f"Error fetching events: {str(e)}")
+49 -45
View File
@@ -25,18 +25,18 @@ from fastapi import APIRouter, HTTPException, Query
router = APIRouter()
@router.get("/get-node")
async def get_node(unique_id: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting node for {unique_id} from database {db_name}")
async def get_node(uuid_string: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting node for {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n {unique_id: $unique_id})
MATCH (n {uuid_string: $uuid_string})
RETURN n
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
@@ -47,11 +47,23 @@ async def get_node(unique_id: str = Query(...), db_name: str = Query(...)):
try:
# Convert node based on its type
node_type = node_labels[0] if node_labels else "Unknown"
if node_type in globals():
node_class = globals()[f"{node_type}Node"]
logging.debug(f"Attempting to convert node of type: {node_type}")
logging.debug(f"Available node classes: {[name for name in globals() if name.endswith('Node')]}")
logging.debug(f"UserNode in globals: {'UserNode' in globals()}")
logging.debug(f"UserNode class: {UserNode}")
logging.debug(f"UserNode class name: {UserNode.__name__}")
# Try to find the node class
node_class_name = f"{node_type}Node"
if node_class_name in globals():
node_class = globals()[node_class_name]
logging.debug(f"Found node class: {node_class}")
node_object = node_class(**node_data)
node_dict = node_object.to_dict()
logging.debug(f"Successfully converted node to dict: {node_dict}")
else:
logging.warning(f"No node class found for type: {node_type} (looking for {node_class_name}), using raw data")
logging.debug(f"Available classes: {[name for name in globals() if 'Node' in name]}")
node_dict = node_data
return {
@@ -101,8 +113,8 @@ async def get_user_node(user_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-connected-nodes")
async def get_connected_nodes(unique_id: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting connected nodes for {unique_id} from database {db_name}")
async def get_connected_nodes(uuid_string: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting connected nodes for {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -110,11 +122,11 @@ async def get_connected_nodes(unique_id: str = Query(...), db_name: str = Query(
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n {unique_id: $unique_id})
MATCH (n {uuid_string: $uuid_string})
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
main_node = record['n']
@@ -171,15 +183,15 @@ async def get_connected_nodes(unique_id: str = Query(...), db_name: str = Query(
driver.close_driver(neo_driver)
@router.get("/get-user-connected-nodes")
async def get_user_connected_nodes(unique_id: str = Query(...)):
logging.info(f"Getting user adjacent nodes for node {unique_id}")
async def get_user_connected_nodes(uuid_string: str = Query(...)):
logging.info(f"Getting user adjacent nodes for node {uuid_string}")
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai") # TODO: This function needs to be able to take a db_name as a parameter
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
raise HTTPException(status_code=500, detail="Failed to connect to the database")
try:
with neo_driver.session(database=db_name) as neo_session:
user_node_and_connected_nodes = session.get_node_by_unique_id_and_adjacent_nodes(neo_session, unique_id)
user_node_and_connected_nodes = session.get_node_by_uuid_string_and_adjacent_nodes(neo_session, uuid_string)
user_node = user_node_and_connected_nodes['node']
connected_nodes = user_node_and_connected_nodes['connected_nodes']
try:
@@ -251,15 +263,15 @@ async def get_user_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-worker-connected-nodes")
async def get_worker_connected_nodes(unique_id: str = Query(...)):
logging.info(f"Getting worker adjacent nodes for node {unique_id}")
async def get_worker_connected_nodes(uuid_string: str = Query(...)):
logging.info(f"Getting worker adjacent nodes for node {uuid_string}")
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai") # TODO: This function needs to be able to take a db_name as a parameter
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
raise HTTPException(status_code=500, detail="Failed to connect to the database")
try:
with neo_driver.session(database=db_name) as neo_session:
node_and_connected_nodes = session.get_node_by_unique_id_and_adjacent_nodes(neo_session, unique_id)
node_and_connected_nodes = session.get_node_by_uuid_string_and_adjacent_nodes(neo_session, uuid_string)
worker_node = node_and_connected_nodes['node']
connected_nodes = node_and_connected_nodes['connected_nodes']
try:
@@ -319,9 +331,9 @@ async def get_worker_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-calendar-connected-nodes")
async def get_calendar_connected_nodes(unique_id: str = Query(...)):
async def get_calendar_connected_nodes(uuid_string: str = Query(...)):
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai")
logging.info(f"Getting connected nodes for calendar {unique_id} from database {db_name}")
logging.info(f"Getting connected nodes for calendar {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -330,11 +342,11 @@ async def get_calendar_connected_nodes(unique_id: str = Query(...)):
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n)
WHERE n.unique_id = $unique_id AND (n:Calendar OR n:CalendarYear OR n:CalendarMonth OR n:CalendarWeek OR n:CalendarDay OR n:CalendarTimeChunk)
WHERE n.uuid_string = $uuid_string AND (n:Calendar OR n:CalendarYear OR n:CalendarMonth OR n:CalendarWeek OR n:CalendarDay OR n:CalendarTimeChunk)
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
calendar_node = record['n']
@@ -369,9 +381,9 @@ async def get_calendar_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-teacher-timetable-connected-nodes")
async def get_teacher_timetable_connected_nodes(unique_id: str = Query(...)):
async def get_teacher_timetable_connected_nodes(uuid_string: str = Query(...)):
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai")
logging.info(f"Getting connected nodes for teacher timetable {unique_id} from database {db_name}")
logging.info(f"Getting connected nodes for teacher timetable {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -379,11 +391,11 @@ async def get_teacher_timetable_connected_nodes(unique_id: str = Query(...)):
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n:TeacherTimetable {unique_id: $unique_id})
MATCH (n:TeacherTimetable {uuid_string: $uuid_string})
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
teacher_timetable_node = record['n']
@@ -422,9 +434,9 @@ async def get_teacher_timetable_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-school-timetable-connected-nodes")
async def get_school_timetable_connected_nodes(unique_id: str = Query(...)):
async def get_school_timetable_connected_nodes(uuid_string: str = Query(...)):
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai")
logging.info(f"Getting connected nodes for school timetable {unique_id} from database {db_name}")
logging.info(f"Getting connected nodes for school timetable {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -432,11 +444,11 @@ async def get_school_timetable_connected_nodes(unique_id: str = Query(...)):
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n:SchoolTimetable {unique_id: $unique_id})
MATCH (n:SchoolTimetable {uuid_string: $uuid_string})
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
school_timetable_node = record['n']
@@ -483,9 +495,9 @@ async def get_school_timetable_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-curriculum-connected-nodes")
async def get_curriculum_connected_nodes(unique_id: str = Query(...)):
async def get_curriculum_connected_nodes(uuid_string: str = Query(...)):
db_name = os.getenv("NEO4J_DB_NAME", "cc.institutes.kevlarai")
logging.info(f"Getting connected nodes for curriculum {unique_id} from database {db_name}")
logging.info(f"Getting connected nodes for curriculum {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -494,11 +506,11 @@ async def get_curriculum_connected_nodes(unique_id: str = Query(...)):
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n)
WHERE n.unique_id = $unique_id AND (n:PastoralStructure OR n:YearGroup OR n:CurriculumStructure OR n:KeyStage OR n:KeyStageSyllabus OR n:YearGroupSyllabus OR n:Subject OR n:Topic OR n:TopicLesson OR n:LearningStatement OR n:ScienceLab)
WHERE n.uuid_string = $uuid_string AND (n:PastoralStructure OR n:YearGroup OR n:CurriculumStructure OR n:KeyStage OR n:KeyStageSyllabus OR n:YearGroupSyllabus OR n:Subject OR n:Topic OR n:TopicLesson OR n:LearningStatement OR n:ScienceLab)
OPTIONAL MATCH (n)-[]-(connected)
RETURN n, collect(connected) as connected_nodes
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
curriculum_node = record['n']
@@ -533,26 +545,18 @@ async def get_curriculum_connected_nodes(unique_id: str = Query(...)):
driver.close_driver(neo_driver)
@router.get("/get-school-node")
async def get_school_node(school_uuid: str = Query(...)):
logging.info(f"Getting school node for school {school_uuid}...")
db_name = f"cc.institutes.{school_uuid}"
async def get_school_node(school_uuid_string: str = Query(...)):
logging.info(f"Getting school node for school {school_uuid_string}...")
db_name = f"cc.institutes.{school_uuid_string}"
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
try:
with neo_driver.session(database=db_name) as neo_session:
nodes = session.find_nodes_by_label_and_properties(neo_session, "School", {"school_uuid": school_uuid})
nodes = session.find_nodes_by_label_and_properties(neo_session, "School", {"uuid_string": school_uuid_string})
if nodes:
school_node = nodes[0]
data = SchoolNode(
unique_id=school_node["unique_id"],
school_uuid=school_node["school_uuid"],
school_name=school_node["school_name"],
school_website=school_node["school_website"],
path=school_node["path"]
)
school_node_data = data.to_dict()
school_node_data = SchoolNode(**nodes[0]).to_dict()
return {"status": "success", "school_node": school_node_data, "school_node_raw": nodes}
else:
return {"status": "not_found", "message": "School node not found"}
@@ -89,8 +89,8 @@ async def get_all_nodes_and_edges():
@router.get("/get-connected-nodes-and-edges")
async def get_connected_nodes_and_edges(unique_id: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting connected nodes and edges for {unique_id} from database {db_name}")
async def get_connected_nodes_and_edges(uuid_string: str = Query(...), db_name: str = Query(...)):
logging.info(f"Getting connected nodes and edges for {uuid_string} from database {db_name}")
neo_driver = driver.get_driver(db_name=db_name)
if neo_driver is None:
return {"status": "error", "message": "Failed to connect to the database"}
@@ -98,11 +98,11 @@ async def get_connected_nodes_and_edges(unique_id: str = Query(...), db_name: st
try:
with neo_driver.session(database=db_name) as neo_session:
query = """
MATCH (n {unique_id: $unique_id})
MATCH (n {uuid_string: $uuid_string})
OPTIONAL MATCH (n)-[r]-(connected)
RETURN n, collect(connected) as connected_nodes, collect(r) as relationships
"""
result = neo_session.run(query, unique_id=unique_id)
result = neo_session.run(query, uuid_string=uuid_string)
record = result.single()
if record:
main_node = record['n']
+218 -51
View File
@@ -34,20 +34,24 @@ async def read_tldraw_user_node_file(user_node: UserNode):
logging.debug(f"Filesystem root path: {fs.root_path}")
# Handle path based on environment
if os.getenv("DEV_MODE") == "true":
# In dev mode, use the full system path from the node
if not user_node.path:
raise HTTPException(status_code=400, detail="Node path not found")
logging.debug(f"Using DEV_MODE path: {user_node.path}")
base_path = os.path.normpath(user_node.path)
# Use the path directly as provided - it represents the structure from root
if not user_node.node_storage_path:
raise HTTPException(status_code=400, detail="Node path not found")
# The path might already contain parts of the filesystem structure
# We need to construct the full path carefully
if user_node.node_storage_path.startswith("users/"):
# If path starts with users/, remove it since filesystem already has users/ structure
base_path = user_node.node_storage_path[6:] # Remove "users/" prefix
logging.debug(f"Removed 'users/' prefix, base_path is now: {base_path}")
else:
# In prod mode, construct path using formatted email
logging.warning(f"Using db_name as base path not ready in prod: {db_name}")
base_path = formatted_email
base_path = user_node.node_storage_path
logging.debug(f"No 'users/' prefix found, using path as-is: {base_path}")
base_path = os.path.normpath(base_path)
logging.debug(f"Using base path: {base_path}")
# Construct final path including tldraw file
logging.debug(f"Base path: {base_path}")
file_path = os.path.join(base_path, "tldraw_file.json")
logging.debug(f"File path: {file_path}")
file_location = os.path.normpath(os.path.join(fs.root_path, file_path))
@@ -68,8 +72,30 @@ async def read_tldraw_user_node_file(user_node: UserNode):
logging.error(f"Error reading file: {e}")
raise HTTPException(status_code=500, detail="Error reading file")
else:
logging.debug(f"File does not exist: {file_location}")
raise HTTPException(status_code=404, detail="File not found")
# Check if directory exists
directory_location = os.path.dirname(file_location)
if os.path.exists(directory_location):
logging.debug(f"Directory exists but file doesn't, creating default tldraw file at: {file_location}")
try:
# Create default tldraw content
default_tldraw_content = create_default_tldraw_content()
# Ensure directory exists (should already exist, but just in case)
os.makedirs(directory_location, exist_ok=True)
# Write the default file
with open(file_location, "w") as file:
json.dump(default_tldraw_content, file, indent=4)
logging.info(f"Default tldraw file created at: {file_location}")
return default_tldraw_content
except Exception as e:
logging.error(f"Error creating default tldraw file: {e}")
raise HTTPException(status_code=500, detail="Error creating default tldraw file")
else:
logging.debug(f"Neither directory nor file exists: {directory_location}")
raise HTTPException(status_code=404, detail="Directory not found")
@router.post("/set_tldraw_user_node_file")
async def set_tldraw_user_node_file(user_node: UserNode, data: Dict):
@@ -81,15 +107,22 @@ async def set_tldraw_user_node_file(user_node: UserNode, data: Dict):
fs = ClassroomCopilotFilesystem(db_name=db_name, init_run_type="user")
# Handle path based on environment
if os.getenv("ENVIRONMENT") == "dev":
# In dev mode, use the full system path from the node
if not user_node.path:
raise HTTPException(status_code=400, detail="Node path not found")
base_path = os.path.normpath(user_node.path)
# Use the path directly as provided - it represents the structure from root
if not user_node.node_storage_path:
raise HTTPException(status_code=400, detail="Node path not found")
# The path might already contain parts of the filesystem structure
# We need to construct the full path carefully
if user_node.node_storage_path.startswith("users/"):
# If path starts with users/, remove it since filesystem already has users/ structure
base_path = user_node.node_storage_path[6:] # Remove "users/" prefix
logging.debug(f"Removed 'users/' prefix, base_path is now: {base_path}")
else:
# In prod mode, construct path using formatted email
base_path = formatted_email
base_path = user_node.node_storage_path
logging.debug(f"No 'users/' prefix found, using path as-is: {base_path}")
base_path = os.path.normpath(base_path)
logging.debug(f"Using base path: {base_path}")
# Construct final path including tldraw file
file_path = os.path.join(base_path, "tldraw_file.json")
@@ -99,11 +132,15 @@ async def set_tldraw_user_node_file(user_node: UserNode, data: Dict):
try:
# Ensure directory exists
os.makedirs(os.path.dirname(file_location), exist_ok=True)
directory_location = os.path.dirname(file_location)
os.makedirs(directory_location, exist_ok=True)
logging.debug(f"Ensured directory exists: {directory_location}")
# Write the file
with open(file_location, "w") as file:
json.dump(data, file)
json.dump(data, file, indent=4)
logging.info(f"tldraw file successfully written to: {file_location}")
return {"status": "success"}
except Exception as e:
logging.error(f"Error writing file: {e}")
@@ -112,29 +149,38 @@ async def set_tldraw_user_node_file(user_node: UserNode, data: Dict):
@router.get("/get_tldraw_node_file")
async def read_tldraw_node_file(path: str, db_name: str):
logging.debug(f"Reading tldraw file for path: {path}")
logging.debug(f"Database name: {db_name}")
fs = ClassroomCopilotFilesystem(db_name=db_name, init_run_type="user")
logging.debug(f"Filesystem root path: {fs.root_path}")
# Handle path based on environment
if os.getenv("DEV_MODE") == "true":
# In dev mode, use the full system path from the node
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
logging.debug(f"Using DEV_MODEpath: {path}")
base_path = os.path.normpath(path)
# Use the path directly as provided - it represents the structure from root
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
# The path might already contain parts of the filesystem structure
# We need to construct the full path carefully
if path.startswith("users/"):
# If path starts with users/, remove it since filesystem already has users/ structure
base_path = path[6:] # Remove "users/" prefix
logging.debug(f"Removed 'users/' prefix, base_path is now: {base_path}")
else:
# In prod mode, construct path
logging.warning(f"Using db_name as base path not ready in prod: {db_name}")
base_path = db_name
base_path = path
logging.debug(f"No 'users/' prefix found, using path as-is: {base_path}")
base_path = os.path.normpath(base_path)
logging.debug(f"Using base path: {base_path}")
# Construct final path including tldraw file
logging.debug(f"Base path: {base_path}")
file_path = os.path.join(base_path, "tldraw_file.json")
logging.debug(f"File path: {file_path}")
file_location = os.path.normpath(os.path.join(fs.root_path, file_path))
logging.debug(f"File location: {file_location}")
logging.debug(f"Final file location: {file_location}")
# Debug: Check what directories exist
logging.debug(f"Checking if root path exists: {fs.root_path} - {os.path.exists(fs.root_path)}")
logging.debug(f"Checking if base path exists: {os.path.join(fs.root_path, base_path)} - {os.path.exists(os.path.join(fs.root_path, base_path))}")
logging.debug(f"Attempting to read file at: {file_location}")
@@ -151,8 +197,44 @@ async def read_tldraw_node_file(path: str, db_name: str):
logging.error(f"Error reading file: {e}")
raise HTTPException(status_code=500, detail="Error reading file")
else:
logging.debug(f"File does not exist: {file_location}")
raise HTTPException(status_code=404, detail="File not found")
# Check if directory exists
directory_location = os.path.dirname(file_location)
logging.debug(f"Checking if directory exists: {directory_location} - {os.path.exists(directory_location)}")
if os.path.exists(directory_location):
logging.debug(f"Directory exists but file doesn't, creating default tldraw file at: {file_location}")
try:
# Create default tldraw content
default_tldraw_content = create_default_tldraw_content()
# Ensure directory exists (should already exist, but just in case)
os.makedirs(directory_location, exist_ok=True)
# Write the default file
with open(file_location, "w") as file:
json.dump(default_tldraw_content, file, indent=4)
logging.info(f"Default tldraw file created at: {file_location}")
return default_tldraw_content
except Exception as e:
logging.error(f"Error creating default tldraw file: {e}")
raise HTTPException(status_code=500, detail="Error creating default tldraw file")
else:
logging.debug(f"Neither directory nor file exists: {directory_location}")
# List contents of parent directories to help debug
parent_dir = os.path.dirname(directory_location)
if os.path.exists(parent_dir):
logging.debug(f"Parent directory exists: {parent_dir}")
try:
contents = os.listdir(parent_dir)
logging.debug(f"Parent directory contents: {contents}")
except Exception as e:
logging.debug(f"Could not list parent directory contents: {e}")
else:
logging.debug(f"Parent directory does not exist: {parent_dir}")
raise HTTPException(status_code=404, detail="Directory not found")
@router.post("/set_tldraw_node_file")
async def set_tldraw_node_file(path: str, db_name: str, data: Dict):
@@ -162,22 +244,25 @@ async def set_tldraw_node_file(path: str, db_name: str, data: Dict):
logging.debug(f"Filesystem root path: {fs.root_path}")
# Handle path based on environment
if os.getenv("DEV_MODE") == "true":
# In dev mode, use the full system path from the node
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
logging.debug(f"Using DEV_MODEpath: {path}")
base_path = os.path.normpath(path)
# Use the path directly as provided - it represents the structure from root
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
# The path might already contain parts of the filesystem structure
# We need to construct the full path carefully
if path.startswith("users/"):
# If path starts with users/, remove it since filesystem already has users/ structure
base_path = path[6:] # Remove "users/" prefix
logging.debug(f"Removed 'users/' prefix, base_path is now: {base_path}")
else:
# In prod mode, construct path
logging.warning(f"Using db_name as base path not ready in prod: {db_name}")
base_path = db_name
base_path = path
logging.debug(f"No 'users/' prefix found, using path as-is: {base_path}")
base_path = os.path.normpath(base_path)
logging.debug(f"Using base path: {base_path}")
# Construct final path including tldraw file
logging.debug(f"Base path: {base_path}")
file_path = os.path.join(base_path, "tldraw_file.json")
logging.debug(f"File path: {file_path}")
file_location = os.path.normpath(os.path.join(fs.root_path, file_path))
logging.debug(f"File location: {file_location}")
@@ -185,12 +270,94 @@ async def set_tldraw_node_file(path: str, db_name: str, data: Dict):
try:
# Ensure directory exists
os.makedirs(os.path.dirname(file_location), exist_ok=True)
directory_location = os.path.dirname(file_location)
os.makedirs(directory_location, exist_ok=True)
logging.debug(f"Ensured directory exists: {directory_location}")
# Write the file
with open(file_location, "w") as file:
json.dump(data, file)
json.dump(data, file, indent=4)
logging.info(f"tldraw file successfully written to: {file_location}")
return {"status": "success"}
except Exception as e:
logging.error(f"Error writing file: {e}")
raise HTTPException(status_code=500, detail="Error writing file")
def create_default_tldraw_content():
"""Create default tldraw content structure."""
return {
"document": {
"store": {
"document:document": {
"gridSize": 10,
"name": "",
"meta": {},
"id": "document:document",
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page:page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
}
},
"schema": {
"schemaVersion": 2,
"sequences": {
"com.tldraw.store": 4,
"com.tldraw.asset": 1,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 25,
"com.tldraw.instance_page_state": 5,
"com.tldraw.page": 1,
"com.tldraw.instance_presence": 5,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 4,
"com.tldraw.asset.bookmark": 2,
"com.tldraw.asset.image": 5,
"com.tldraw.asset.video": 5,
"com.tldraw.shape.arrow": 5,
"com.tldraw.shape.bookmark": 2,
"com.tldraw.shape.draw": 2,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.geo": 9,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.highlight": 1,
"com.tldraw.shape.image": 4,
"com.tldraw.shape.line": 5,
"com.tldraw.shape.note": 8,
"com.tldraw.shape.text": 2,
"com.tldraw.shape.video": 2,
"com.tldraw.binding.arrow": 0
}
},
"recordVersions": {
"asset": {"version": 1, "subTypeKey": "type", "subTypeVersions": {}},
"camera": {"version": 1},
"document": {"version": 2},
"instance": {"version": 21},
"instance_page_state": {"version": 5},
"page": {"version": 1},
"shape": {"version": 3, "subTypeKey": "type", "subTypeVersions": {}},
"instance_presence": {"version": 5},
"pointer": {"version": 1}
},
"rootShapeIds": [],
"bindings": [],
"assets": []
},
"session": {
"version": 0,
"currentPageId": "page:page",
"pageStates": [{
"pageId": "page:page",
"camera": {"x": 0, "y": 0, "z": 1},
"selectedShapeIds": []
}]
}
}
@@ -0,0 +1,265 @@
"""
TLDraw Supabase Storage Router
=============================
Handles TLDraw snapshot operations using Supabase Storage instead of local filesystem.
This replaces the old filesystem-based tldraw_filesystem.py router.
"""
from dotenv import load_dotenv, find_dotenv
load_dotenv(find_dotenv())
import os
import json
import logging
from fastapi import APIRouter, HTTPException, Query
from typing import Dict, Any
from modules.database.supabase.utils.storage import StorageAdmin
from modules.logger_tool import initialise_logger
router = APIRouter()
logger = initialise_logger(__name__, os.getenv("LOG_LEVEL"), os.getenv("LOG_PATH"), 'default', True)
def create_default_tldraw_content():
"""Create default tldraw content structure."""
return {
"document": {
"store": {
"document:document": {
"gridSize": 10,
"name": "",
"meta": {},
"id": "document:document",
"typeName": "document"
},
"page:page": {
"meta": {},
"id": "page:page",
"name": "Page 1",
"index": "a1",
"typeName": "page"
}
},
"schema": {
"schemaVersion": 2,
"sequences": {
"com.tldraw.store": 4,
"com.tldraw.asset": 1,
"com.tldraw.camera": 1,
"com.tldraw.document": 2,
"com.tldraw.instance": 25,
"com.tldraw.instance_page_state": 5,
"com.tldraw.page": 1,
"com.tldraw.instance_presence": 5,
"com.tldraw.pointer": 1,
"com.tldraw.shape": 4,
"com.tldraw.asset.bookmark": 2,
"com.tldraw.asset.image": 5,
"com.tldraw.asset.video": 5,
"com.tldraw.shape.arrow": 5,
"com.tldraw.shape.bookmark": 2,
"com.tldraw.shape.draw": 2,
"com.tldraw.shape.embed": 4,
"com.tldraw.shape.frame": 0,
"com.tldraw.shape.geo": 9,
"com.tldraw.shape.group": 0,
"com.tldraw.shape.highlight": 1,
"com.tldraw.shape.image": 4,
"com.tldraw.shape.line": 5,
"com.tldraw.shape.note": 8,
"com.tldraw.shape.text": 2,
"com.tldraw.shape.video": 2,
"com.tldraw.binding.arrow": 0
}
},
"recordVersions": {
"asset": {"version": 1, "subTypeKey": "type", "subTypeVersions": {}},
"camera": {"version": 1},
"document": {"version": 2},
"instance": {"version": 21},
"instance_page_state": {"version": 5},
"page": {"version": 1},
"shape": {"version": 3, "subTypeKey": "type", "subTypeVersions": {}},
"instance_presence": {"version": 5},
"pointer": {"version": 1}
},
"rootShapeIds": [],
"bindings": [],
"assets": []
},
"session": {
"version": 0,
"currentPageId": "page:page",
"pageStates": [{
"pageId": "page:page",
"camera": {"x": 0, "y": 0, "z": 1},
"selectedShapeIds": []
}]
}
}
@router.get("/get_tldraw_node_file")
async def read_tldraw_node_file_from_supabase(
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
db_name: str = Query(..., description="Database name for context")
):
"""
Load TLDraw snapshot from Supabase Storage.
Args:
path: Supabase Storage path in format 'bucket/nodetype/node_id'
db_name: Database name for context (used for logging)
Returns:
TLDraw snapshot data
"""
logger.debug(f"Reading tldraw file from Supabase Storage for path: {path}")
logger.debug(f"Database name: {db_name}")
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
try:
# Initialize Supabase Storage
storage = StorageAdmin()
# Parse the path to extract bucket and file path
# Expected format: "cc.public.snapshots/User/user_id" or "cc.public.snapshots/Teacher/teacher_id"
path_parts = path.split('/')
if len(path_parts) < 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket = path_parts[0] # e.g., "cc.public.snapshots"
node_type = path_parts[1] # e.g., "User", "Teacher"
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
# Construct the file path in Supabase Storage
# Format: nodetype/node_id/tldraw_file.json
file_path = f"{node_type}/{node_id}/tldraw_file.json"
logger.debug(f"Bucket: {bucket}")
logger.debug(f"File path: {file_path}")
try:
# Try to download the file from Supabase Storage
file_data = storage.download_file(bucket, file_path)
# Parse JSON data
try:
snapshot_data = json.loads(file_data.decode('utf-8'))
logger.info(f"Successfully loaded tldraw snapshot from Supabase Storage: {file_path}")
# Ensure the snapshot has the correct structure for TLDraw
if isinstance(snapshot_data, dict) and 'document' in snapshot_data and 'session' in snapshot_data:
# Check if it has the new format (schemaVersion in document.schema)
if 'document' in snapshot_data and isinstance(snapshot_data['document'], dict) and 'schema' in snapshot_data['document']:
return snapshot_data
# Check if it has the old format (schemaVersion at root level)
elif 'schemaVersion' in snapshot_data:
return snapshot_data
else:
# Use default structure if schema is missing
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing schemaVersion. Using default structure.")
return create_default_tldraw_content()
else:
# Use default structure if basic structure is missing
logger.warning(f"Snapshot data from {file_path_in_bucket} is missing top-level TLDraw keys. Using default structure.")
return create_default_tldraw_content()
except json.JSONDecodeError as e:
logger.error(f"Failed to parse JSON from Supabase Storage file: {e}")
raise HTTPException(status_code=500, detail="Invalid JSON in file")
except Exception as e:
# File doesn't exist, create default content
logger.info(f"File not found in Supabase Storage, creating default tldraw content: {file_path}")
# Create default tldraw content
default_content = create_default_tldraw_content()
try:
# Upload default content to Supabase Storage
json_data = json.dumps(default_content, indent=2).encode('utf-8')
storage.upload_file(bucket, file_path, json_data, 'application/json', upsert=True)
logger.info(f"Default tldraw file created in Supabase Storage: {file_path}")
return default_content
except Exception as upload_error:
logger.error(f"Error creating default tldraw file in Supabase Storage: {upload_error}")
raise HTTPException(status_code=500, detail="Error creating default tldraw file")
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Unexpected error loading tldraw file from Supabase Storage: {e}")
raise HTTPException(status_code=500, detail=f"Error loading file: {str(e)}")
@router.post("/set_tldraw_node_file")
async def set_tldraw_node_file_in_supabase(
path: str = Query(..., description="Supabase Storage path (e.g., 'cc.public.snapshots/User/user_id')"),
db_name: str = Query(..., description="Database name for context"),
data: Dict[str, Any] = None
):
"""
Save TLDraw snapshot to Supabase Storage.
Args:
path: Supabase Storage path in format 'bucket/nodetype/node_id'
db_name: Database name for context (used for logging)
data: TLDraw snapshot data to save
Returns:
Success status
"""
logger.debug(f"Saving tldraw file to Supabase Storage for path: {path}")
logger.debug(f"Database name: {db_name}")
if not path:
raise HTTPException(status_code=400, detail="Path not provided")
if not data:
raise HTTPException(status_code=400, detail="Data not provided")
try:
# Initialize Supabase Storage
storage = StorageAdmin()
# Parse the path to extract bucket and file path
path_parts = path.split('/')
if len(path_parts) < 3:
raise HTTPException(status_code=400, detail="Invalid path format. Expected: bucket/nodetype/node_id")
bucket = path_parts[0] # e.g., "cc.public.snapshots"
node_type = path_parts[1] # e.g., "User", "Teacher"
node_id = path_parts[2] # e.g., "cbc309e5-4029-4c34-aab7-0aa33c563cd0"
# Construct the file path in Supabase Storage
file_path = f"{node_type}/{node_id}/tldraw_file.json"
logger.debug(f"Bucket: {bucket}")
logger.debug(f"File path: {file_path}")
# Convert data to JSON
try:
json_data = json.dumps(data, indent=2).encode('utf-8')
except (TypeError, ValueError) as e:
logger.error(f"Failed to serialize data to JSON: {e}")
raise HTTPException(status_code=400, detail="Invalid data format")
# Upload to Supabase Storage
try:
storage.upload_file(bucket, file_path, json_data, 'application/json', upsert=True)
logger.info(f"Successfully saved tldraw snapshot to Supabase Storage: {file_path}")
return {"status": "success", "message": "File saved successfully"}
except Exception as upload_error:
logger.error(f"Error uploading file to Supabase Storage: {upload_error}")
raise HTTPException(status_code=500, detail="Error saving file")
except HTTPException:
# Re-raise HTTP exceptions
raise
except Exception as e:
logger.error(f"Unexpected error saving tldraw file to Supabase Storage: {e}")
raise HTTPException(status_code=500, detail=f"Error saving file: {str(e)}")
@@ -29,34 +29,34 @@ async def get_worker_structure(db_name: str) -> Dict[str, Any]:
// Collect all nodes
RETURN {
timetables: collect(DISTINCT {
id: tt.unique_id,
path: tt.path,
id: tt.uuid_string,
path: tt.node_storage_path,
title: tt.title,
type: tt.__primarylabel__,
startTime: toString(tt.start_date),
endTime: toString(tt.end_date)
}),
classes: collect(DISTINCT {
id: c.unique_id,
path: c.path,
id: c.uuid_string,
path: c.node_storage_path,
title: c.title,
type: c.__primarylabel__
}),
lessons: collect(DISTINCT {
id: l.unique_id,
path: l.path,
id: l.uuid_string,
path: l.node_storage_path,
title: l.title,
type: l.__primarylabel__
}),
journals: collect(DISTINCT {
id: j.unique_id,
path: j.path,
id: j.uuid_string,
path: j.node_storage_path,
title: j.title,
type: j.__primarylabel__
}),
planners: collect(DISTINCT {
id: p.unique_id,
path: p.path,
id: p.uuid_string,
path: p.node_storage_path,
title: p.title,
type: p.__primarylabel__
})
@@ -106,8 +106,8 @@ async def get_timetables(db_name: str, start_date: str, end_date: str) -> Dict[s
MATCH (tt:UserTeacherTimetable)
WHERE date(tt.start_date) >= date($start_date) AND date(tt.end_date) <= date($end_date)
RETURN {
id: tt.unique_id,
path: tt.path,
id: tt.uuid_string,
path: tt.node_storage_path,
title: tt.title,
type: tt.__primarylabel__,
startTime: toString(tt.start_date),
@@ -138,8 +138,8 @@ async def get_journals(db_name: str) -> Dict[str, Any]:
query = """
MATCH (j:Journal)
RETURN {
id: j.unique_id,
path: j.path,
id: j.uuid_string,
path: j.node_storage_path,
title: j.title,
type: j.__primarylabel__
} as journal
@@ -168,8 +168,8 @@ async def get_planners(db_name: str) -> Dict[str, Any]:
query = """
MATCH (p:Planner)
RETURN {
id: p.unique_id,
path: p.path,
id: p.uuid_string,
path: p.node_storage_path,
title: p.title,
type: p.__primarylabel__
} as planner