Add project registry refresh foundation
This commit is contained in:
@@ -33,6 +33,10 @@ from atocore.memory.service import (
|
||||
update_memory,
|
||||
)
|
||||
from atocore.observability.logger import get_logger
|
||||
from atocore.projects.registry import (
|
||||
list_registered_projects,
|
||||
refresh_registered_project,
|
||||
)
|
||||
from atocore.retrieval.retriever import retrieve
|
||||
from atocore.retrieval.vector_store import get_vector_store
|
||||
|
||||
@@ -55,6 +59,14 @@ class IngestSourcesResponse(BaseModel):
|
||||
results: list[dict]
|
||||
|
||||
|
||||
class ProjectRefreshResponse(BaseModel):
|
||||
project: str
|
||||
aliases: list[str]
|
||||
description: str
|
||||
purge_deleted: bool
|
||||
roots: list[dict]
|
||||
|
||||
|
||||
class QueryRequest(BaseModel):
|
||||
prompt: str
|
||||
top_k: int = 10
|
||||
@@ -148,6 +160,28 @@ def api_ingest_sources() -> IngestSourcesResponse:
|
||||
return IngestSourcesResponse(results=results)
|
||||
|
||||
|
||||
@router.get("/projects")
|
||||
def api_projects() -> dict:
|
||||
"""Return registered projects and their resolved ingest roots."""
|
||||
return {
|
||||
"projects": list_registered_projects(),
|
||||
"registry_path": str(_config.settings.resolved_project_registry_path),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/projects/{project_name}/refresh", response_model=ProjectRefreshResponse)
|
||||
def api_refresh_project(project_name: str, purge_deleted: bool = False) -> ProjectRefreshResponse:
|
||||
"""Refresh one registered project from its configured ingest roots."""
|
||||
try:
|
||||
result = refresh_registered_project(project_name, purge_deleted=purge_deleted)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
except Exception as e:
|
||||
log.error("project_refresh_failed", project=project_name, error=str(e))
|
||||
raise HTTPException(status_code=500, detail=f"Project refresh failed: {e}")
|
||||
return ProjectRefreshResponse(**result)
|
||||
|
||||
|
||||
@router.post("/query", response_model=QueryResponse)
|
||||
def api_query(req: QueryRequest) -> QueryResponse:
|
||||
"""Retrieve relevant chunks for a prompt."""
|
||||
|
||||
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
|
||||
log_dir: Path = Path("./logs")
|
||||
backup_dir: Path = Path("./backups")
|
||||
run_dir: Path = Path("./run")
|
||||
project_registry_path: Path = Path("./config/project-registry.json")
|
||||
host: str = "127.0.0.1"
|
||||
port: int = 8100
|
||||
|
||||
@@ -91,6 +92,10 @@ class Settings(BaseSettings):
|
||||
return self._resolve_path(self.resolved_data_dir.parent / "run")
|
||||
return self._resolve_path(self.run_dir)
|
||||
|
||||
@property
|
||||
def resolved_project_registry_path(self) -> Path:
|
||||
return self._resolve_path(self.project_registry_path)
|
||||
|
||||
@property
|
||||
def machine_dirs(self) -> list[Path]:
|
||||
return [
|
||||
|
||||
1
src/atocore/projects/__init__.py
Normal file
1
src/atocore/projects/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Project registry and source refresh helpers."""
|
||||
152
src/atocore/projects/registry.py
Normal file
152
src/atocore/projects/registry.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""Registered project source metadata and refresh helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict, dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import atocore.config as _config
|
||||
from atocore.ingestion.pipeline import ingest_folder
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProjectSourceRef:
|
||||
source: str
|
||||
subpath: str
|
||||
label: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RegisteredProject:
|
||||
project_id: str
|
||||
aliases: tuple[str, ...]
|
||||
description: str
|
||||
ingest_roots: tuple[ProjectSourceRef, ...]
|
||||
|
||||
|
||||
def load_project_registry() -> list[RegisteredProject]:
|
||||
"""Load project registry entries from JSON config."""
|
||||
registry_path = _config.settings.resolved_project_registry_path
|
||||
if not registry_path.exists():
|
||||
return []
|
||||
|
||||
payload = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
entries = payload.get("projects", [])
|
||||
projects: list[RegisteredProject] = []
|
||||
|
||||
for entry in entries:
|
||||
project_id = str(entry["id"]).strip()
|
||||
aliases = tuple(
|
||||
alias.strip()
|
||||
for alias in entry.get("aliases", [])
|
||||
if isinstance(alias, str) and alias.strip()
|
||||
)
|
||||
description = str(entry.get("description", "")).strip()
|
||||
ingest_roots = tuple(
|
||||
ProjectSourceRef(
|
||||
source=str(root["source"]).strip(),
|
||||
subpath=str(root["subpath"]).strip(),
|
||||
label=str(root.get("label", "")).strip(),
|
||||
)
|
||||
for root in entry.get("ingest_roots", [])
|
||||
if str(root.get("source", "")).strip()
|
||||
and str(root.get("subpath", "")).strip()
|
||||
)
|
||||
projects.append(
|
||||
RegisteredProject(
|
||||
project_id=project_id,
|
||||
aliases=aliases,
|
||||
description=description,
|
||||
ingest_roots=ingest_roots,
|
||||
)
|
||||
)
|
||||
|
||||
return projects
|
||||
|
||||
|
||||
def list_registered_projects() -> list[dict]:
|
||||
"""Return registry entries with resolved source readiness."""
|
||||
return [_project_to_dict(project) for project in load_project_registry()]
|
||||
|
||||
|
||||
def get_registered_project(project_name: str) -> RegisteredProject | None:
|
||||
"""Resolve a registry entry by id or alias."""
|
||||
needle = project_name.strip().lower()
|
||||
if not needle:
|
||||
return None
|
||||
|
||||
for project in load_project_registry():
|
||||
candidates = {project.project_id.lower(), *(alias.lower() for alias in project.aliases)}
|
||||
if needle in candidates:
|
||||
return project
|
||||
return None
|
||||
|
||||
|
||||
def refresh_registered_project(project_name: str, purge_deleted: bool = False) -> dict:
|
||||
"""Ingest all configured source roots for a registered project."""
|
||||
project = get_registered_project(project_name)
|
||||
if project is None:
|
||||
raise ValueError(f"Unknown project: {project_name}")
|
||||
|
||||
roots = []
|
||||
for source_ref in project.ingest_roots:
|
||||
resolved = _resolve_ingest_root(source_ref)
|
||||
root_result = {
|
||||
"source": source_ref.source,
|
||||
"subpath": source_ref.subpath,
|
||||
"label": source_ref.label,
|
||||
"path": str(resolved),
|
||||
}
|
||||
if not resolved.exists():
|
||||
roots.append({**root_result, "status": "missing"})
|
||||
continue
|
||||
if not resolved.is_dir():
|
||||
roots.append({**root_result, "status": "not_directory"})
|
||||
continue
|
||||
|
||||
roots.append(
|
||||
{
|
||||
**root_result,
|
||||
"status": "ingested",
|
||||
"results": ingest_folder(resolved, purge_deleted=purge_deleted),
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
"project": project.project_id,
|
||||
"aliases": list(project.aliases),
|
||||
"description": project.description,
|
||||
"purge_deleted": purge_deleted,
|
||||
"roots": roots,
|
||||
}
|
||||
|
||||
|
||||
def _project_to_dict(project: RegisteredProject) -> dict:
|
||||
return {
|
||||
"id": project.project_id,
|
||||
"aliases": list(project.aliases),
|
||||
"description": project.description,
|
||||
"ingest_roots": [
|
||||
{
|
||||
**asdict(source_ref),
|
||||
"path": str(_resolve_ingest_root(source_ref)),
|
||||
"exists": _resolve_ingest_root(source_ref).exists(),
|
||||
"is_dir": _resolve_ingest_root(source_ref).is_dir(),
|
||||
}
|
||||
for source_ref in project.ingest_roots
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _resolve_ingest_root(source_ref: ProjectSourceRef) -> Path:
|
||||
base_map = {
|
||||
"vault": _config.settings.resolved_vault_source_dir,
|
||||
"drive": _config.settings.resolved_drive_source_dir,
|
||||
}
|
||||
try:
|
||||
base_dir = base_map[source_ref.source]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"Unsupported source root: {source_ref.source}") from exc
|
||||
|
||||
return (base_dir / source_ref.subpath).resolve(strict=False)
|
||||
Reference in New Issue
Block a user