Let the console search, browse, forget and mount
The store had a write path, a mount path and a search path, and the console
still showed a dashboard of counters. Nothing in the UI could ask what someone
knows, page through atoms, look at a wordless picture, forget one memory, or
mount a cartridge — so the mechanisms built in P0 were invisible.
Backend:
- senses.py: one function decides what this machine can perceive. /api/status
and doctor each computed it before, which is two truths. Picture atoms are now
counted by image_ref rather than by modality, so a screenshot crop from a PDF
counts and a picture whose bytes were deleted stops claiming it can be encoded.
- browse.py: GET /api/atoms pages newest-first through v_atoms_current with
modality/holder/source filters; GET /api/atoms/{id}/image serves the stored
bytes so a picture with no OCR text can be looked at.
- Deleted 8 HTTP routes nobody calls (4 benchmark, research modules, dense index,
visual-contradiction-proof, memory-map). Their functions stay: the CLI and the
proof checks use them. The surface is 30 routes, pinned as a set by a test so a
new unused route fails the suite.
Frontend: App.tsx (517 lines) became a 131-line shell plus seven panels that own
their own fetches, and lib/format.ts. types.ts now carries the memory dimensions
the backend has been sending since schema v2.
Two invented fields were rendering lies: AdapterPanel showed gpu_available and
probe_status, which AdapterLoadReport does not have — every machine looked GPU-less
including this one. The panel now shows only what the server says.
Proof: scripts/ui_smoke.sh starts the API on a fresh temp home, serves the built
UI from the same origin, and drives five interactions in a real browser — search
as somebody in a scenario, page and view a wordless picture, forget, mount, read
adapter states with reasons. It prints the mount refusal verbatim rather than
pretending the mount succeeded.
Gates: 263 pytest passed; ruff and mypy clean on the 8 touched backend files
(repo-wide mypy unchanged at 24 errors / 6 files); pyright 0 errors there;
go build/vet/test/gofmt clean; npm build + eslint clean; ui_smoke and
mcp_write_smoke both PASS.
This commit is contained in:
1 parent
a5008e1476
commit
9f91ff7ed7
27 files changed
+2146
-616
No files matched your search
+34
-62
@@ -5,8 +5,9 @@ import shutil
|
||||
from pathlib import Path
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, UploadFile
|
||||
from fastapi import Depends, FastAPI, File, HTTPException, Query, UploadFile
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import FileResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel
|
||||
|
||||
@@ -14,33 +15,18 @@ from . import __version__
|
||||
from .adapters import plan as adapter_plan
|
||||
from .api_cartridges import register as register_cartridge_routes
|
||||
from .api_holders import register as register_holder_routes
|
||||
from .benchmarks import (
|
||||
benchmark_prediction_report,
|
||||
benchmark_registry_report,
|
||||
benchmark_sample_report,
|
||||
benchmark_source_report,
|
||||
)
|
||||
from .browse import browse, picture
|
||||
from .cache_fabric import CacheFabric
|
||||
from .cartridge import CartridgeService
|
||||
from .compiler import ContextCompiler
|
||||
from .config import Settings, get_settings
|
||||
from .db import Database
|
||||
from .embeddings import EmbeddingService
|
||||
from .evaluation_schemas import (
|
||||
BenchmarkPredictionReport,
|
||||
BenchmarkRegistryReport,
|
||||
BenchmarkSampleReport,
|
||||
BenchmarkSourceReport,
|
||||
HashHopProof,
|
||||
ProofCheck,
|
||||
ProofSuiteReport,
|
||||
)
|
||||
from .evaluation_schemas import HashHopProof, ProofSuiteReport
|
||||
from .evidence_graph import EvidenceGraphService
|
||||
from .ingestion import IngestionService
|
||||
from .memory import MemoryService
|
||||
from .profiles import runtime_profile_report
|
||||
from .proofs import HashHopEvaluator, PRDProofSuite
|
||||
from .research_modules import ResearchModuleService
|
||||
from .retrieval import RetrievalService
|
||||
from .runtime import QwenRuntime, RuntimeUnavailableError
|
||||
from .runtime_schemas import AdapterLoadReport
|
||||
@@ -48,11 +34,13 @@ from .schemas import (
|
||||
AdapterManifest,
|
||||
AdapterStatus,
|
||||
AnswerResponse,
|
||||
AtomPage,
|
||||
EvidenceAtom,
|
||||
ForgetReport,
|
||||
ForgetRequest,
|
||||
MemoryOrigin,
|
||||
MemoryStatus,
|
||||
Modality,
|
||||
PathIngestRequest,
|
||||
RememberRequest,
|
||||
RuntimeProfile,
|
||||
@@ -63,6 +51,7 @@ from .schemas import (
|
||||
SystemStatus,
|
||||
UploadIngestRequest,
|
||||
)
|
||||
from .senses import sense_facts
|
||||
from .training import safetensor_weight_stats
|
||||
from .verifier import Verifier, VisualContradictionLens
|
||||
|
||||
@@ -114,6 +103,7 @@ def create_app() -> FastAPI:
|
||||
version=__version__,
|
||||
runtime=runtime,
|
||||
memory=memory,
|
||||
senses=sense_facts(settings, db),
|
||||
data_home=str(settings.home),
|
||||
profiles={
|
||||
"Live Demo": {"context_tokens": settings.live_context_tokens, "purpose": "stable 262K workflow"},
|
||||
@@ -260,21 +250,33 @@ def create_app() -> FastAPI:
|
||||
def adapters(settings: Annotated[Settings, Depends(settings_dep)]) -> list[AdapterStatus]:
|
||||
return list_adapter_statuses(settings)
|
||||
|
||||
@app.get("/api/benchmarks/registry", response_model=BenchmarkRegistryReport)
|
||||
def benchmarks_registry() -> BenchmarkRegistryReport:
|
||||
return benchmark_registry_report()
|
||||
@app.get("/api/atoms", response_model=AtomPage)
|
||||
def atoms(
|
||||
settings: Annotated[Settings, Depends(settings_dep)],
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
cursor: str | None = None,
|
||||
modality: Modality | None = None,
|
||||
source_id: str | None = None,
|
||||
holder: str | None = None,
|
||||
) -> AtomPage:
|
||||
try:
|
||||
return browse(
|
||||
settings,
|
||||
limit=limit,
|
||||
cursor=cursor,
|
||||
modality=modality,
|
||||
source_id=source_id,
|
||||
holder=holder,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
@app.get("/api/benchmarks/sources", response_model=BenchmarkSourceReport)
|
||||
def benchmarks_sources() -> BenchmarkSourceReport:
|
||||
return benchmark_source_report()
|
||||
|
||||
@app.get("/api/benchmarks/samples", response_model=BenchmarkSampleReport)
|
||||
def benchmarks_samples(settings: Annotated[Settings, Depends(settings_dep)]) -> BenchmarkSampleReport:
|
||||
return benchmark_sample_report(settings.data_dir / "benchmark_samples")
|
||||
|
||||
@app.get("/api/benchmarks/predictions", response_model=BenchmarkPredictionReport)
|
||||
def benchmarks_predictions(settings: Annotated[Settings, Depends(settings_dep)]) -> BenchmarkPredictionReport:
|
||||
return benchmark_prediction_report(settings, settings.data_dir / "benchmark_predictions")
|
||||
@app.get("/api/atoms/{atom_id}/image")
|
||||
def atom_image(atom_id: str, settings: Annotated[Settings, Depends(settings_dep)]) -> FileResponse:
|
||||
found = picture(settings, atom_id)
|
||||
if found is None:
|
||||
raise HTTPException(status_code=404, detail=f"{atom_id} has no picture to show")
|
||||
return FileResponse(found.path, media_type=found.mime)
|
||||
|
||||
@app.get("/api/runtime/profiles", response_model=RuntimeProfileReport)
|
||||
def runtime_profiles(
|
||||
@@ -286,22 +288,6 @@ def create_app() -> FastAPI:
|
||||
def runtime_adapters(settings: Annotated[Settings, Depends(settings_dep)]) -> AdapterLoadReport:
|
||||
return adapter_plan(settings, Database(settings))
|
||||
|
||||
@app.get("/api/research/modules")
|
||||
def research_modules(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||
db = Database(settings)
|
||||
compiler = ContextCompiler(settings, db)
|
||||
context = compiler.compile(
|
||||
"Ely-Eye V-NSA DVKV x-modal KV speculative prefetch evidence graph",
|
||||
RuntimeProfile.research_theater,
|
||||
)
|
||||
return ResearchModuleService(settings, db).build_report(context.plan.question, context.hits)
|
||||
|
||||
@app.post("/api/index/dense")
|
||||
def build_dense_index(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||
service = EmbeddingService(settings)
|
||||
index = service.build_index()
|
||||
return {"atoms": len(index.atom_ids), "dimensions": int(index.vectors.shape[1]) if index.vectors.size else 0}
|
||||
|
||||
@app.post("/api/hashhop", response_model=HashHopProof)
|
||||
def hashhop(request: HashHopRequest, settings: Annotated[Settings, Depends(settings_dep)]) -> HashHopProof:
|
||||
evaluator = HashHopEvaluator(settings)
|
||||
@@ -328,20 +314,6 @@ def create_app() -> FastAPI:
|
||||
raise HTTPException(status_code=404, detail="No PRD proof suite has been generated.")
|
||||
return report
|
||||
|
||||
@app.post("/api/visual-contradiction-proof", response_model=ProofCheck)
|
||||
def visual_contradiction_proof(settings: Annotated[Settings, Depends(settings_dep)]) -> ProofCheck:
|
||||
return PRDProofSuite(settings).visual_contradiction_proof()
|
||||
|
||||
@app.get("/api/memory-map")
|
||||
def memory_map(settings: Annotated[Settings, Depends(settings_dep)]):
|
||||
db = Database(settings)
|
||||
atoms = db.list_atoms(limit=200)
|
||||
return {
|
||||
"atoms": [atom.model_dump(mode="json") for atom in atoms],
|
||||
"layers": CacheFabric(settings, db).statuses(),
|
||||
"cartridges": CartridgeService(settings, db).list_manifests(),
|
||||
}
|
||||
|
||||
mounted_ui = get_settings().ui_dir
|
||||
if mounted_ui is not None:
|
||||
if not mounted_ui.is_dir():
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Page through the store the way a person browses a shelf: newest first, filtered by who and what."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .db import Database, row_to_atom
|
||||
from .retrieval import decode_cursor, encode_cursor
|
||||
from .schemas import AtomPage, Modality
|
||||
from .storage import ObjectStore
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Picture:
|
||||
"""The bytes behind an atom and how to label them when serving."""
|
||||
|
||||
path: Path
|
||||
mime: str
|
||||
|
||||
|
||||
def browse(
|
||||
settings: Settings,
|
||||
*,
|
||||
limit: int,
|
||||
cursor: str | None = None,
|
||||
modality: Modality | None = None,
|
||||
source_id: str | None = None,
|
||||
holder: str | None = None,
|
||||
) -> AtomPage:
|
||||
"""One page plus the total, so a list can say what it left out instead of looking endless."""
|
||||
offset = decode_cursor(cursor)
|
||||
wanted = (modality.value if modality else None, source_id, holder)
|
||||
filters = tuple(value for bound in wanted for value in (bound, bound))
|
||||
database = Database(settings)
|
||||
with database.connect() as connection:
|
||||
total = int(
|
||||
connection.execute(
|
||||
"SELECT COUNT(*) FROM v_atoms_current WHERE (? IS NULL OR modality = ?) AND (? IS NULL OR source_id = ?) AND (? IS NULL OR holder_id = ?)",
|
||||
filters,
|
||||
).fetchone()[0]
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM v_atoms_current WHERE (? IS NULL OR modality = ?) AND (? IS NULL OR source_id = ?) AND (? IS NULL OR holder_id = ?) ORDER BY ingested_at DESC, atom_id DESC LIMIT ? OFFSET ?",
|
||||
(*filters, limit + 1, offset),
|
||||
).fetchall()
|
||||
atoms = [row_to_atom(row) for row in rows[:limit]]
|
||||
more = len(rows) > limit or offset + limit < total
|
||||
return AtomPage(
|
||||
atoms=atoms,
|
||||
total=total,
|
||||
next_cursor=encode_cursor(offset + limit) if atoms and more else None,
|
||||
)
|
||||
|
||||
|
||||
def picture(settings: Settings, atom_id: str) -> Picture | None:
|
||||
"""The stored bytes behind one atom, through the same visibility view search uses.
|
||||
|
||||
None covers three cases the caller cannot tell apart and does not need to: the atom is unknown,
|
||||
it was forgotten, or its bytes are gone. All three mean "there is no picture to show".
|
||||
"""
|
||||
database = Database(settings)
|
||||
with database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT image_ref, mime FROM v_atoms_current JOIN sources USING (source_id) WHERE atom_id = ?",
|
||||
(atom_id,),
|
||||
).fetchone()
|
||||
if row is None or not row["image_ref"]:
|
||||
return None
|
||||
path = ObjectStore(settings).resolve(str(row["image_ref"]))
|
||||
if not path.exists():
|
||||
return None
|
||||
mime = str(row["mime"]) if row["mime"] else "image/png"
|
||||
return Picture(path=path, mime=mime)
|
||||
@@ -14,15 +14,13 @@ from . import __version__
|
||||
from .cartridge_store import mounted_ids
|
||||
from .config import Settings
|
||||
from .db import Database
|
||||
from .embeddings import EmbeddingService
|
||||
from .migrations import SCHEMA_VERSION
|
||||
from .schemas import utc_now
|
||||
from .visual_index import VisualIndex
|
||||
from .schemas import SenseStatus, utc_now
|
||||
from .senses import sense_facts
|
||||
|
||||
BACKUP_FILE = "ely_eye.sqlite"
|
||||
MANIFEST_FILE = "backup_manifest.json"
|
||||
_REQUIRED_TABLES = ("atoms", "sources", "cartridges")
|
||||
PICTURE_MODALITIES = ("image", "ui_screenshot")
|
||||
|
||||
|
||||
class BackupManifest(BaseModel):
|
||||
@@ -57,12 +55,6 @@ def _atoms_word(count: int) -> str:
|
||||
return "atom" if count == 1 else "atoms"
|
||||
|
||||
|
||||
def _modality_counts(database: Database) -> dict[str, int]:
|
||||
with database.connect() as connection:
|
||||
rows = connection.execute("SELECT modality, COUNT(*) AS total FROM atoms GROUP BY modality").fetchall()
|
||||
return {str(row["modality"]): int(row["total"]) for row in rows}
|
||||
|
||||
|
||||
def _counts(connection: sqlite3.Connection) -> dict[str, int]:
|
||||
return {
|
||||
"atoms": int(connection.execute("SELECT COUNT(*) FROM atoms").fetchone()[0]),
|
||||
@@ -175,41 +167,41 @@ def _check_cartridges(database: Database, mounted: list[str], problems: list[str
|
||||
problems.append(f"{cartridge_id} is mounted but its files at {root} are gone")
|
||||
|
||||
|
||||
def _check_senses(settings: Settings, database: Database, visual: VisualIndex, warnings: list[str]) -> None:
|
||||
counts = _modality_counts(database)
|
||||
pictures = sum(counts.get(name, 0) for name in PICTURE_MODALITIES)
|
||||
if settings.clip_model_dir is not None and visual.model_dir() is None:
|
||||
warnings.append(f"the CLIP snapshot at {settings.clip_model_dir} is missing, so wordless pictures cannot be found")
|
||||
elif pictures and not visual.available():
|
||||
def _check_senses(facts: SenseStatus, warnings: list[str]) -> None:
|
||||
"""Render the missing senses as sentences; the counting lives in senses.py, not here."""
|
||||
encodable = facts.picture_atoms - facts.picture_bytes_missing
|
||||
if facts.clip_path is not None and facts.visual == "absent":
|
||||
warnings.append(f"the CLIP snapshot at {facts.clip_path} is missing, so wordless pictures cannot be found")
|
||||
elif encodable and facts.visual == "absent":
|
||||
warnings.append(
|
||||
f"the store holds {pictures} picture {_atoms_word(pictures)} but no CLIP snapshot is set "
|
||||
f"the store holds {encodable} picture {_atoms_word(encodable)} but no CLIP snapshot is set "
|
||||
"(ELY_EYE_CLIP_MODEL_DIR), so they are findable only by the text already on them"
|
||||
)
|
||||
elif visual.visual_atoms() and not visual.is_current():
|
||||
elif facts.visual == "stale":
|
||||
warnings.append("the visual index is behind the store; run `ely-eye index-visual`")
|
||||
audio = counts.get("audio", 0)
|
||||
if settings.asr_model_dir is None:
|
||||
if audio:
|
||||
warnings.append(
|
||||
f"the store holds {audio} audio {_atoms_word(audio)} but no speech snapshot is set "
|
||||
"(ELY_EYE_ASR_MODEL_DIR), so new audio will not be transcribed"
|
||||
)
|
||||
elif not settings.asr_model_dir.exists():
|
||||
warnings.append(f"the speech snapshot at {settings.asr_model_dir} is missing, so audio cannot be transcribed")
|
||||
if settings.vlm_model_dir is not None and not settings.vlm_model_dir.exists():
|
||||
warnings.append(f"the captioning model at {settings.vlm_model_dir} is missing, so images stay uncaptioned")
|
||||
|
||||
|
||||
def _check_indexes(settings: Settings, database: Database, warnings: list[str]) -> None:
|
||||
if not settings.dense_embeddings_enabled:
|
||||
return
|
||||
index = EmbeddingService(settings, database).load_index()
|
||||
stored = database.atom_count()
|
||||
if index is None:
|
||||
warnings.append("dense embeddings are on but no index exists; run `ely-eye build-dense-index`")
|
||||
elif len(index.atom_ids) != stored:
|
||||
if facts.picture_bytes_missing:
|
||||
missing = facts.picture_bytes_missing
|
||||
warnings.append(
|
||||
f"the dense index covers {len(index.atom_ids)} atoms while the store holds {stored}; run `ely-eye build-dense-index`"
|
||||
f"{missing} picture {_atoms_word(missing)} with no bytes behind them; re-ingest the source they came from"
|
||||
)
|
||||
if facts.asr_path is not None and facts.speech == "missing":
|
||||
warnings.append(f"the speech snapshot at {facts.asr_path} is missing, so audio cannot be transcribed")
|
||||
elif facts.audio_atoms and facts.speech == "absent":
|
||||
warnings.append(
|
||||
f"the store holds {facts.audio_atoms} audio {_atoms_word(facts.audio_atoms)} but no speech snapshot is set "
|
||||
"(ELY_EYE_ASR_MODEL_DIR), so new audio will not be transcribed"
|
||||
)
|
||||
if facts.vlm_path is not None and facts.captioning == "missing":
|
||||
warnings.append(f"the captioning model at {facts.vlm_path} is missing, so images stay uncaptioned")
|
||||
|
||||
|
||||
def _check_indexes(facts: SenseStatus, warnings: list[str]) -> None:
|
||||
if facts.dense == "missing":
|
||||
warnings.append("dense embeddings are on but no index exists; run `ely-eye build-dense-index`")
|
||||
elif facts.dense == "behind":
|
||||
warnings.append(
|
||||
f"the dense index covers {facts.dense_covered} atoms while the store holds {facts.store_atoms}; "
|
||||
"run `ely-eye build-dense-index`"
|
||||
)
|
||||
|
||||
|
||||
@@ -242,8 +234,9 @@ def doctor(settings: Settings) -> DoctorReport:
|
||||
problems.append(f"the schema settled at version {version} while this build writes {SCHEMA_VERSION}")
|
||||
mounted = mounted_ids(database)
|
||||
_check_cartridges(database, mounted, problems)
|
||||
_check_senses(settings, database, VisualIndex(settings, database), warnings)
|
||||
_check_indexes(settings, database, warnings)
|
||||
facts = sense_facts(settings, database)
|
||||
_check_senses(facts, warnings)
|
||||
_check_indexes(facts, warnings)
|
||||
if settings.ui_dir is not None and not settings.ui_dir.exists():
|
||||
problems.append(f"ui_dir {settings.ui_dir} is set but holds no built interface, so the API refuses to start")
|
||||
return DoctorReport(
|
||||
|
||||
@@ -201,6 +201,14 @@ class SearchResult(BaseModel):
|
||||
hits: list[RetrievalHit]
|
||||
next_cursor: str | None = None
|
||||
|
||||
|
||||
class AtomPage(BaseModel):
|
||||
"""A page of the store plus the total behind it, so a list can admit what it left out."""
|
||||
|
||||
atoms: list[EvidenceAtom]
|
||||
total: int
|
||||
next_cursor: str | None = None
|
||||
|
||||
class ContextPlan(BaseModel):
|
||||
question: str
|
||||
profile: RuntimeProfile
|
||||
@@ -261,11 +269,39 @@ class RuntimeStatus(BaseModel):
|
||||
vram_total_mb: int | None = None
|
||||
vram_used_mb: int | None = None
|
||||
|
||||
SenseState = Literal["absent", "missing", "ready"]
|
||||
VisualState = Literal["absent", "stale", "ready"]
|
||||
DenseState = Literal["off", "missing", "behind", "current"]
|
||||
|
||||
|
||||
class SenseStatus(BaseModel):
|
||||
"""What the machine can perceive right now, stated once for the UI and the doctor to share.
|
||||
|
||||
`absent` means the sense was never given a model, `missing` means it was given one that is gone.
|
||||
A store full of pictures and no CLIP snapshot is a different problem, and needs a different
|
||||
sentence, than a snapshot directory somebody deleted.
|
||||
"""
|
||||
|
||||
dense: DenseState
|
||||
dense_covered: int
|
||||
store_atoms: int
|
||||
picture_atoms: int
|
||||
picture_bytes_missing: int
|
||||
visual: VisualState
|
||||
audio_atoms: int
|
||||
speech: SenseState
|
||||
captioning: SenseState
|
||||
clip_path: str | None = None
|
||||
asr_path: str | None = None
|
||||
vlm_path: str | None = None
|
||||
|
||||
|
||||
class SystemStatus(BaseModel):
|
||||
app: str = "Ely-Eye"
|
||||
version: str
|
||||
runtime: RuntimeStatus
|
||||
memory: MemoryStatus
|
||||
senses: SenseStatus
|
||||
profiles: dict[str, dict[str, Any]]
|
||||
data_home: str
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
"""What this machine can actually perceive, computed once and read by status and doctor alike."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from .config import Settings
|
||||
from .db import Database
|
||||
from .embeddings import EmbeddingService
|
||||
from .schemas import DenseState, SenseState, SenseStatus, VisualState
|
||||
from .visual_index import VisualIndex
|
||||
|
||||
|
||||
def sense_facts(settings: Settings, database: Database, visual: VisualIndex | None = None) -> SenseStatus:
|
||||
"""Report the senses as facts, so the UI and the doctor cannot disagree about what is missing.
|
||||
|
||||
A path set but gone on disk is a different failure from a path never set: the first is a broken
|
||||
install, the second is a machine that was never given that sense.
|
||||
"""
|
||||
index = visual or VisualIndex(settings, database)
|
||||
claimed = _picture_claim(database)
|
||||
on_disk = index.visual_atom_ids()
|
||||
dense, covered, total = _dense_facts(settings, database)
|
||||
return SenseStatus(
|
||||
dense=dense,
|
||||
dense_covered=covered,
|
||||
store_atoms=total,
|
||||
picture_atoms=claimed,
|
||||
picture_bytes_missing=max(0, claimed - len(on_disk)),
|
||||
visual=_visual_state(index, on_disk),
|
||||
audio_atoms=_audio_count(database),
|
||||
speech=_sense_state(settings.asr_model_dir),
|
||||
captioning=_sense_state(settings.vlm_model_dir),
|
||||
clip_path=_stated(settings.clip_model_dir),
|
||||
asr_path=_stated(settings.asr_model_dir),
|
||||
vlm_path=_stated(settings.vlm_model_dir),
|
||||
)
|
||||
|
||||
|
||||
def _dense_facts(settings: Settings, database: Database) -> tuple[DenseState, int, int]:
|
||||
total = database.atom_count()
|
||||
if not settings.dense_embeddings_enabled:
|
||||
return "off", 0, total
|
||||
loaded = EmbeddingService(settings, database).load_index()
|
||||
if loaded is None:
|
||||
return "missing", 0, total
|
||||
covered = len(loaded.atom_ids)
|
||||
return ("current" if covered == total else "behind"), covered, total
|
||||
|
||||
|
||||
def _visual_state(visual: VisualIndex, pictures: set[str]) -> VisualState:
|
||||
"""A store of pictures the index has not encoded yet is stale: they are invisible to search."""
|
||||
if not visual.available():
|
||||
return "absent"
|
||||
if not pictures:
|
||||
return "ready"
|
||||
loaded = visual.load()
|
||||
return "ready" if loaded is not None and set(loaded[0]) == pictures else "stale"
|
||||
|
||||
|
||||
def _picture_claim(database: Database) -> int:
|
||||
"""Atoms that say they hold a picture, whether or not the bytes survived."""
|
||||
with database.connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT COUNT(*) FROM v_atoms_current WHERE image_ref IS NOT NULL AND image_ref != ''"
|
||||
).fetchone()
|
||||
return int(row[0])
|
||||
|
||||
|
||||
def _sense_state(model_dir: Path | None) -> SenseState:
|
||||
"""A sense with no configured snapshot is absent; one pointing at a deleted directory is missing."""
|
||||
if model_dir is None:
|
||||
return "absent"
|
||||
return "ready" if model_dir.exists() else "missing"
|
||||
|
||||
|
||||
def _stated(model_dir: Path | None) -> str | None:
|
||||
return None if model_dir is None else str(model_dir)
|
||||
|
||||
|
||||
def _audio_count(database: Database) -> int:
|
||||
with database.connect() as connection:
|
||||
row = connection.execute("SELECT COUNT(*) AS n FROM v_atoms_current WHERE modality = 'audio'").fetchone()
|
||||
return int(row["n"])
|
||||
@@ -141,12 +141,30 @@ class VisualIndex:
|
||||
_LOADED.clear()
|
||||
return removed
|
||||
|
||||
def is_current(self, atoms: list[EvidenceAtom] | None = None) -> bool:
|
||||
"""True when the index covers exactly the image atoms the store holds right now."""
|
||||
def visual_atom_ids(self) -> set[str]:
|
||||
"""Picture atoms whose bytes are still on disk, asked with one light query."""
|
||||
store = ObjectStore(self.settings)
|
||||
with self.db.connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT atom_id, image_ref FROM v_atoms_current WHERE image_ref IS NOT NULL AND image_ref != ''"
|
||||
).fetchall()
|
||||
kept: set[str] = set()
|
||||
for row in rows:
|
||||
try:
|
||||
path = store.resolve(str(row["image_ref"]))
|
||||
except ValueError:
|
||||
continue
|
||||
if path.exists():
|
||||
kept.add(str(row["atom_id"]))
|
||||
return kept
|
||||
|
||||
def is_current(self, atom_ids: set[str] | None = None) -> bool:
|
||||
"""True when the index covers exactly the picture atoms the store holds right now."""
|
||||
loaded = self.load()
|
||||
if loaded is None:
|
||||
return False
|
||||
return {atom.atom_id for atom in self.visual_atoms(atoms)} == set(loaded[0])
|
||||
wanted = self.visual_atom_ids() if atom_ids is None else atom_ids
|
||||
return wanted == set(loaded[0])
|
||||
|
||||
def search_text(self, query: str, top_k: int, restrict: set[str] | None = None) -> list[tuple[str, float]]:
|
||||
vector = self.encoder().encode_texts([query])[0]
|
||||
|
||||
@@ -19,6 +19,7 @@ from ely_eye.db import Database
|
||||
from ely_eye.memory import MemoryService
|
||||
from ely_eye.migrations import SCHEMA_VERSION
|
||||
from ely_eye.schemas import EvidenceAtom, MemoryOrigin, Modality, SourceRecord, TrustScores, utc_now
|
||||
from ely_eye.storage import ObjectStore
|
||||
|
||||
KEYS = "the keys are on the shelf by the door"
|
||||
PASSPORT = "the passport is in the blue box"
|
||||
@@ -164,7 +165,14 @@ def test_doctor_names_the_senses_a_store_holds_but_cannot_use(tmp_path: Path) ->
|
||||
)
|
||||
database.upsert_atoms(
|
||||
[
|
||||
EvidenceAtom(atom_id="img:1", modality=Modality.image, source_id="img", source="file:///img.png", trust=trust),
|
||||
EvidenceAtom(
|
||||
atom_id="img:1",
|
||||
modality=Modality.image,
|
||||
source_id="img",
|
||||
source="file:///img.png",
|
||||
trust=trust,
|
||||
image_ref=ObjectStore(settings).put_bytes(b"\x89PNG\r\n\x1a\n", ".png", "image"),
|
||||
),
|
||||
EvidenceAtom(atom_id="aud:1", modality=Modality.audio, source_id="aud", source="file:///a.mp3", trust=trust),
|
||||
]
|
||||
)
|
||||
@@ -175,6 +183,30 @@ def test_doctor_names_the_senses_a_store_holds_but_cannot_use(tmp_path: Path) ->
|
||||
assert "1 audio atom but no speech snapshot" in report.warnings[1]
|
||||
|
||||
|
||||
def test_doctor_names_picture_atoms_whose_bytes_are_gone(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path)
|
||||
database = Database(settings)
|
||||
database.init()
|
||||
trust = TrustScores(parser=0.0, ocr=0.0, model=0.0)
|
||||
database.upsert_sources(
|
||||
[SourceRecord(source_id="img", path="file:///img.png", kind="image", mime="image/png", sha256="a", size_bytes=1)]
|
||||
)
|
||||
database.upsert_atoms(
|
||||
[
|
||||
EvidenceAtom(
|
||||
atom_id="img:1",
|
||||
modality=Modality.image,
|
||||
source_id="img",
|
||||
source="file:///img.png",
|
||||
trust=trust,
|
||||
image_ref="object://image/ab/deadbeef.png",
|
||||
)
|
||||
]
|
||||
)
|
||||
report = maintenance.doctor(settings)
|
||||
assert "1 picture atom with no bytes behind them" in report.warnings[0]
|
||||
|
||||
|
||||
def test_doctor_names_a_mount_whose_files_are_gone(tmp_path: Path) -> None:
|
||||
settings = _settings(tmp_path)
|
||||
database = _store(settings)
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
"""The product face: page the store, look at a picture, and say plainly what the machine can perceive."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.routing import APIRoute
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import ely_eye.api as api_module
|
||||
from ely_eye.config import Settings
|
||||
from ely_eye.db import Database
|
||||
from ely_eye.memory import MemoryService
|
||||
from ely_eye.schemas import EvidenceAtom, MemoryOrigin, Modality, SourceRecord, TrustScores
|
||||
from ely_eye.storage import ObjectStore
|
||||
|
||||
PNG = b"\x89PNG\r\n\x1a\n"
|
||||
BASE = datetime(2026, 5, 1, 12, 0, tzinfo=UTC)
|
||||
|
||||
HTTP_SURFACE = {
|
||||
"/api/adapters",
|
||||
"/api/ask",
|
||||
"/api/atoms",
|
||||
"/api/atoms/{atom_id}/image",
|
||||
"/api/cartridges",
|
||||
"/api/cartridges/{cartridge_id}/append",
|
||||
"/api/cartridges/{cartridge_id}/assets",
|
||||
"/api/cartridges/{cartridge_id}/finalize",
|
||||
"/api/cartridges/{cartridge_id}/mount",
|
||||
"/api/cartridges/{cartridge_id}/refresh",
|
||||
"/api/cartridges/{cartridge_id}/switch",
|
||||
"/api/cartridges/{cartridge_id}/unmount",
|
||||
"/api/chat",
|
||||
"/api/hashhop",
|
||||
"/api/holders",
|
||||
"/api/holders/{holder_id}",
|
||||
"/api/holders/{holder_id}/knows",
|
||||
"/api/ingest/path",
|
||||
"/api/ingest/upload",
|
||||
"/api/memory",
|
||||
"/api/memory/forget",
|
||||
"/api/memory/supersede",
|
||||
"/api/mounts",
|
||||
"/api/proof-suite",
|
||||
"/api/proof-suite/latest",
|
||||
"/api/runtime/adapters",
|
||||
"/api/runtime/profiles",
|
||||
"/api/search",
|
||||
"/api/status",
|
||||
"/api/who_knows",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def settings(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Settings:
|
||||
resolved = Settings(home=tmp_path, default_holder="wife")
|
||||
monkeypatch.setattr(api_module, "get_settings", lambda: resolved)
|
||||
Database(resolved).init()
|
||||
return resolved
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(settings: Settings) -> TestClient:
|
||||
return TestClient(api_module.create_app())
|
||||
|
||||
|
||||
def _seed(settings: Settings, pictures: int = 3) -> None:
|
||||
"""Pictures an hour apart, so newest-first has a fact to order by rather than a coincidence."""
|
||||
database = Database(settings)
|
||||
trust = TrustScores(parser=1.0, ocr=0.0, model=0.0)
|
||||
store = ObjectStore(settings)
|
||||
stored = store.put_bytes(PNG, ".png", "image")
|
||||
database.upsert_sources(
|
||||
[
|
||||
SourceRecord(
|
||||
source_id=f"shot{i}",
|
||||
path=f"file:///shot{i}.png",
|
||||
kind="image",
|
||||
mime="image/png",
|
||||
sha256=f"{i}" * 8,
|
||||
size_bytes=len(PNG),
|
||||
)
|
||||
for i in range(pictures)
|
||||
]
|
||||
)
|
||||
database.upsert_atoms(
|
||||
[
|
||||
EvidenceAtom(
|
||||
atom_id=f"shot{i}:frame",
|
||||
modality=Modality.image,
|
||||
source_id=f"shot{i}",
|
||||
source=f"file:///shot{i}.png",
|
||||
trust=trust,
|
||||
image_ref=stored,
|
||||
holder_id="wife" if i % 2 == 0 else "husband",
|
||||
ingested_at=BASE + timedelta(hours=i),
|
||||
)
|
||||
for i in range(pictures)
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_the_store_pages_newest_first_and_says_how_much_is_left(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings)
|
||||
first = client.get("/api/atoms", params={"limit": 2})
|
||||
assert first.status_code == 200
|
||||
page = first.json()
|
||||
assert page["total"] == 3
|
||||
assert [atom["atom_id"] for atom in page["atoms"]] == ["shot2:frame", "shot1:frame"]
|
||||
assert page["next_cursor"]
|
||||
|
||||
second = client.get("/api/atoms", params={"limit": 2, "cursor": page["next_cursor"]})
|
||||
rest = second.json()
|
||||
assert [atom["atom_id"] for atom in rest["atoms"]] == ["shot0:frame"]
|
||||
assert rest["next_cursor"] is None, "the last page must not promise another one"
|
||||
|
||||
|
||||
def test_browse_narrows_by_modality_and_by_holder(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=2)
|
||||
MemoryService(settings, Database(settings)).remember(
|
||||
"the keys are on the shelf", holder_id="wife", origin=MemoryOrigin.self_
|
||||
)
|
||||
|
||||
images = client.get("/api/atoms", params={"modality": "image"}).json()
|
||||
assert images["total"] == 2
|
||||
assert {atom["modality"] for atom in images["atoms"]} == {"image"}
|
||||
|
||||
his = client.get("/api/atoms", params={"holder": "husband"}).json()
|
||||
assert [atom["atom_id"] for atom in his["atoms"]] == ["shot1:frame"]
|
||||
|
||||
|
||||
def test_an_unreadable_cursor_is_refused_with_400(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
response = client.get("/api/atoms", params={"cursor": "not-a-cursor"})
|
||||
assert response.status_code == 400
|
||||
assert "cursor" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_a_picture_atom_serves_the_bytes_behind_it(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
response = client.get("/api/atoms/shot0:frame/image")
|
||||
assert response.status_code == 200
|
||||
assert response.content == PNG
|
||||
assert response.headers["content-type"] == "image/png"
|
||||
|
||||
|
||||
def test_a_memory_with_no_picture_says_so(client: TestClient, settings: Settings) -> None:
|
||||
atom = MemoryService(settings, Database(settings)).remember(
|
||||
"the keys are on the shelf", holder_id="wife", origin=MemoryOrigin.self_
|
||||
)
|
||||
assert client.get(f"/api/atoms/{atom.atom_id}/image").status_code == 404
|
||||
assert client.get("/api/atoms/nope:image").status_code == 404
|
||||
|
||||
|
||||
def test_a_forgotten_picture_stops_being_served(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
MemoryService(settings, Database(settings)).forget(atom_id="shot0:frame")
|
||||
assert client.get("/api/atoms/shot0:frame/image").status_code == 404
|
||||
assert client.get("/api/atoms").json()["total"] == 0
|
||||
|
||||
|
||||
def test_status_says_what_the_machine_can_see_and_what_it_cannot(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=2)
|
||||
senses = client.get("/api/status").json()["senses"]
|
||||
assert senses["dense"] == "off", "dense embeddings are off by default and status must not claim otherwise"
|
||||
assert senses["picture_atoms"] == 2
|
||||
assert senses["picture_bytes_missing"] == 0
|
||||
assert senses["visual"] == "absent", "no CLIP snapshot on this machine means wordless pictures stay hidden"
|
||||
assert senses["speech"] == "absent"
|
||||
|
||||
|
||||
def test_status_names_pictures_whose_bytes_are_gone(client: TestClient, settings: Settings) -> None:
|
||||
_seed(settings, pictures=1)
|
||||
dead = Database(settings).get_atoms(["shot0:frame"])[0].model_copy(
|
||||
update={"atom_id": "shot0:ghost", "image_ref": "object://image/ab/deadbeef.png"}
|
||||
)
|
||||
Database(settings).upsert_atoms([dead])
|
||||
senses = client.get("/api/status").json()["senses"]
|
||||
assert senses["picture_atoms"] == 2
|
||||
assert senses["picture_bytes_missing"] == 1
|
||||
|
||||
|
||||
def test_the_http_surface_is_exactly_what_someone_uses() -> None:
|
||||
"""A route nobody calls is a contract nobody keeps: adding one has to be a deliberate act."""
|
||||
served = {
|
||||
route.path
|
||||
for route in api_module.app.routes
|
||||
if isinstance(route, APIRoute) and route.path.startswith("/api")
|
||||
}
|
||||
assert served == HTTP_SURFACE
|
||||
Reference in new issue
Block a user