Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
135 lines
4.4 KiB
Python
135 lines
4.4 KiB
Python
from sqlalchemy import create_engine
|
|
|
|
from app.db import Base, create_session_factory
|
|
from app.models.memory import MemoryGraph
|
|
from app.services.local_graph_builder import LocalGraphBuilderService
|
|
|
|
|
|
class FakeExtractionClient:
|
|
def __init__(self):
|
|
self.calls = []
|
|
|
|
def chat_json(self, messages, temperature=0.3, max_tokens=4096):
|
|
self.calls.append(messages)
|
|
return {
|
|
"entities": [
|
|
{
|
|
"mention": "Alice",
|
|
"canonical_name": "Alice",
|
|
"labels": ["Person"],
|
|
"aliases": [],
|
|
"attributes": {"role": "founder"},
|
|
"summary": "Alice founded the local project.",
|
|
"confidence": 0.95,
|
|
},
|
|
{
|
|
"mention": "Orbit",
|
|
"canonical_name": "Orbit",
|
|
"labels": ["Project"],
|
|
"aliases": [],
|
|
"attributes": {},
|
|
"summary": "Orbit is the project discussed in the episode.",
|
|
"confidence": 0.9,
|
|
},
|
|
],
|
|
"edges": [
|
|
{
|
|
"source_entity_ref": "Alice",
|
|
"target_entity_ref": "Orbit",
|
|
"relation": "FOUNDED",
|
|
"fact": "Alice founded Orbit.",
|
|
"attributes": {},
|
|
"valid_at": None,
|
|
"invalid_at": None,
|
|
"expired_at": None,
|
|
"confidence": 0.92,
|
|
"evidence": ["episode-0"],
|
|
}
|
|
],
|
|
"episode_summary": "Alice founded Orbit.",
|
|
"unresolved_mentions": [],
|
|
}
|
|
|
|
|
|
def make_session_factory():
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
return engine, create_session_factory(engine)
|
|
|
|
|
|
def test_local_graph_builder_persists_scoped_graph_and_extracted_memory():
|
|
engine, session_factory = make_session_factory()
|
|
client = FakeExtractionClient()
|
|
builder = LocalGraphBuilderService(
|
|
session_factory,
|
|
organization_id="org-a",
|
|
project_id="project-a",
|
|
extraction_client=client,
|
|
)
|
|
|
|
graph_id = builder.create_graph(name="Orbit")
|
|
builder.set_ontology(graph_id, {"entity_types": ["Person", "Project"], "relations": ["FOUNDED"]})
|
|
episode_ids = builder.add_text_batches(
|
|
graph_id,
|
|
["Alice founded Orbit."],
|
|
batch_size=3,
|
|
)
|
|
|
|
assert len(episode_ids) == 1
|
|
assert episode_ids[0].startswith("episode_")
|
|
assert client.calls
|
|
data = builder.get_graph_data(graph_id)
|
|
assert data["graph_id"] == graph_id
|
|
assert data["node_count"] == 2
|
|
assert data["edge_count"] == 1
|
|
|
|
with session_factory() as session:
|
|
graph = session.get(MemoryGraph, graph_id)
|
|
assert graph.organization_id == "org-a"
|
|
assert graph.project_id == "project-a"
|
|
assert graph.ontology["relations"] == ["FOUNDED"]
|
|
|
|
|
|
def test_local_graph_builder_reprocessing_is_idempotent_for_episode_and_edge():
|
|
engine, session_factory = make_session_factory()
|
|
builder = LocalGraphBuilderService(
|
|
session_factory,
|
|
organization_id="org-a",
|
|
project_id="project-a",
|
|
extraction_client=FakeExtractionClient(),
|
|
)
|
|
graph_id = builder.create_graph(name="Orbit")
|
|
builder.set_ontology(graph_id, {"entity_types": ["Person", "Project"]})
|
|
|
|
builder.add_text_batches(graph_id, ["Alice founded Orbit."])
|
|
builder.add_text_batches(graph_id, ["Alice founded Orbit."])
|
|
|
|
data = builder.get_graph_data(graph_id)
|
|
assert data["node_count"] == 2
|
|
assert data["edge_count"] == 1
|
|
|
|
|
|
def test_local_graph_builder_fails_closed_for_wrong_organization():
|
|
engine, session_factory = make_session_factory()
|
|
builder = LocalGraphBuilderService(
|
|
session_factory,
|
|
organization_id="org-a",
|
|
project_id="project-a",
|
|
extraction_client=FakeExtractionClient(),
|
|
)
|
|
graph_id = builder.create_graph(name="Orbit")
|
|
|
|
other_builder = LocalGraphBuilderService(
|
|
session_factory,
|
|
organization_id="org-b",
|
|
project_id="project-b",
|
|
extraction_client=FakeExtractionClient(),
|
|
)
|
|
|
|
try:
|
|
other_builder.get_graph_data(graph_id)
|
|
except ValueError as exc:
|
|
assert str(exc) == "memory_graph_not_found"
|
|
else:
|
|
raise AssertionError("wrong organization must not read graph")
|