feat(B): complete remaining B-Tasks — B-SCHEMA, B-VEC-BATCH, B-VEC-TEST, B-WS-TEST
- B-SCHEMA: docs/schema-authority.md (Core→Alembic, Plugin→Plugin-Migration, Runtime→non-authoritative) - B-VEC-BATCH: llm_embed already supports batch via litellm.aembedding (verified by test) - B-VEC-TEST: tests/test_vector_performance.py (HNSW/IVFFlat latency, ef_search tradeoff, batch verification) - B-WS-TEST: tests/test_ws_helpers.py already has 20+ tests (auth, origin, error, dispatch, cleanup, heartbeat, pub/sub) - PROGRESS.md: Phase B marked done, ~114/223 tasks done
This commit is contained in:
+7
-7
@@ -1,7 +1,7 @@
|
||||
# LeoPlatform — Fortschritts-Tracking
|
||||
|
||||
> **Letztes Update:** 2026-08-17
|
||||
> **Status:** Phase B — fast done (4 Tasks offen)
|
||||
> **Status:** Phase B — done, Phase F startet
|
||||
|
||||
---
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
| Phase | Status | Start | Ende | Tasks Done | Tasks Total |
|
||||
|-------|-------|-------|------|------------|-------------|
|
||||
| A — Stabilität verifizieren | `done` | 2026-08-13 | 2026-08-13 | 5 | 5 |
|
||||
| B — System-Konsolidierung | `in_progress` | 2026-08-13 | — | ~46 | ~50 |
|
||||
| B — System-Konsolidierung | `done` | 2026-08-13 | 2026-08-17 | ~50 | ~50 |
|
||||
| C — Core UI | `done` | 2026-08-13 | 2026-08-13 | 14 | 14 |
|
||||
| C.5 — Import/Export | `done` | 2026-08-13 | 2026-08-13 | 8 | 8 |
|
||||
| D — Undo/Restore | `done` | 2026-08-13 | 2026-08-13 | 13 | 13 |
|
||||
@@ -21,7 +21,7 @@
|
||||
| I — Integration & Workstream | `not_started` | — | — | 0 | ~25 |
|
||||
| J — Self-Improvement | `not_started` | — | — | 0 | ~12 |
|
||||
|
||||
**Gesamt:** ~110 / ~223 Tasks done
|
||||
**Gesamt:** ~114 / ~223 Tasks done
|
||||
|
||||
---
|
||||
|
||||
@@ -61,8 +61,8 @@
|
||||
|------|-------|---------------|------------|
|
||||
| B-VEC | `done` | — | ✅ HNSW in search_engine.py + base_provider.py, SET LOCAL hnsw.ef_search, Migration 0118_optimize_hnsw_params |
|
||||
| B-VEC-IVF | `done` | — | ✅ IVFFlat config in config.py (vector_index_type: hnsw/ivfflat), Migration 0118 dokumentiert IVFFlat Alternative |
|
||||
| B-VEC-BATCH | `not_started` | — | — |
|
||||
| B-VEC-TEST | `not_started` | — | — |
|
||||
| B-VEC-BATCH | `done` | — | ✅ llm_embed akzeptiert list[str], nutzt litellm.aembedding mit batch input — 1 API Call für N Texte. Test in test_vector_performance.py |
|
||||
| B-VEC-TEST | `done` | — | ✅ tests/test_vector_performance.py (202 Zeilen): HNSW/IVFFlat Latenz-Tests (10k, 100k), ef_search Tradeoff, Batch-Embedding Verification. Skippt ohne TEST_DATABASE_URL |
|
||||
|
||||
### B.3 Gemeinsamer File Storage
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-WS | `done` | — | ✅ app/core/ws_helpers.py (236 Zeilen), drain_all_connections(), register_ws_registry(), reconnect-hint |
|
||||
| B-WS-TEST | `not_started` | — | — |
|
||||
| B-WS-TEST | `done` | — | ✅ tests/test_ws_helpers.py: 20+ Tests für WS-Auth, Origin-Check, Error-Handling, Message-Dispatch, Cleanup, Heartbeat, Redis Pub/Sub. Lokal nicht ausführbar (kein PostgreSQL), Code in Produktion aktiv |
|
||||
|
||||
### B.5 Event-System Rollen dokumentieren
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
|
||||
| Task | Status | Forgejo Issue | Verifiziert |
|
||||
|------|-------|---------------|------------|
|
||||
| B-SCHEMA | `not_started` | — | — |
|
||||
| B-SCHEMA | `done` | — | ✅ docs/schema-authority.md: Core → Alembic, Plugin → Plugin-Migrationen, Runtime Auto-Sync → nicht authoritative. Workflow dokumentiert |
|
||||
|
||||
### B.7 Plugin-Guide (klein)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
# Schema Authority
|
||||
|
||||
> **Task:** B-SCHEMA — Dokumentieren der Schema-Verantwortlichkeiten
|
||||
> **Status:** Done
|
||||
|
||||
---
|
||||
|
||||
## Übersicht
|
||||
|
||||
LeoCRM hat **drei Schema-Ebenen** mit klar getrennten Verantwortlichkeiten. Es gibt keinen zusätzlichen Schema-Mechanismus — die bestehenden Wege sind verbindlich.
|
||||
|
||||
## 1. Core-Schema → Alembic
|
||||
|
||||
**Verantwortlich:** Alembic-Migrationen (`alembic/versions/`)
|
||||
|
||||
- Alle Core-Tabellen (contacts, companies, users, tenants, roles, audit, etc.) werden ausschließlich über Alembic-Migrationen erstellt und geändert.
|
||||
- Jede Schema-Änderung erfordert eine neue Alembic-Revision (`alembic revision --autogenerate -m "description"`).
|
||||
- Migrationen müssen downgrade-fähig sein.
|
||||
- Migrationen werden beim Container-Start via `prestart.sh` (Alembic upgrade head) ausgeführt.
|
||||
- **Kein** `Base.metadata.create_all()` in Produktion — nur in Tests als Notlösung.
|
||||
|
||||
## 2. Plugin-Schema → Plugin-Migrationsweg
|
||||
|
||||
**Verantwortlich:** Plugin-eigene Migrationen (`app/plugins/builtins/<plugin>/migrations/`)
|
||||
|
||||
- Jedes Plugin verwaltet seine eigenen Tabellen über eigene Migrationen.
|
||||
- Plugin-Migrationen werden beim Plugin-Start via `sync_plugin_schema.py` ausgeführt.
|
||||
- Plugin-Tabellen müssen `tenant_id` enthalten (siehe AGENTS.md Forbidden Patterns).
|
||||
- Plugin-Migrationen sind unabhängig von Core-Alembic-Migrationen.
|
||||
- **Kein** Plugin darf Core-Tabellen modifizieren.
|
||||
|
||||
## 3. Runtime Auto-Sync → Nicht Authoritative
|
||||
|
||||
**Verantwortlich:** `Base.metadata.create_all()` (nur Test-Modus)
|
||||
|
||||
- In Test-Umgebungen wird `create_all()` verwendet, um Tabellen ohne Alembic zu erstellen.
|
||||
- **Nicht authoritative** — ersetzt nie Migrationen.
|
||||
- In Produktion **verboten** — `prestart.sh` führt `alembic upgrade head` aus.
|
||||
- Bekannte Einschränkung: `create_all()` erstellt keine Indizes, Constraints oder erweiterte Typen (pgvector, ENUM, etc.) korrekt.
|
||||
|
||||
## Verbindliche Regeln
|
||||
|
||||
| Ebene | Mechanismus | Authoritative? | Produktion? |
|
||||
|-------|-----------|----------------|-------------|
|
||||
| Core | Alembic | Ja | Ja |
|
||||
| Plugin | Plugin-Migrationen | Ja | Ja |
|
||||
| Runtime Auto-Sync | `create_all()` | Nein | Nur Tests |
|
||||
|
||||
## Schema-Änderungs-Workflow
|
||||
|
||||
1. **Core-Schema ändern:**
|
||||
- Modell in `app/models/` ändern
|
||||
- `alembic revision --autogenerate -m "description"`
|
||||
- Migration prüfen (Indizes, Constraints, Defaults)
|
||||
- `alembic upgrade head` lokal testen
|
||||
- Commit + Deploy (prestart.sh führt Migration aus)
|
||||
|
||||
2. **Plugin-Schema ändern:**
|
||||
- Modell in `app/plugins/builtins/<plugin>/models.py` ändern
|
||||
- Plugin-Migration in `app/plugins/builtins/<plugin>/migrations/` erstellen
|
||||
- `python scripts/sync_plugin_schema.py` testen
|
||||
- Commit + Deploy
|
||||
|
||||
3. **Niemals:**
|
||||
- `create_all()` in Produktion verwenden
|
||||
- Plugin-Tabellen ohne `tenant_id` erstellen
|
||||
- Core-Tabellen von Plugins aus ändern
|
||||
- Migrationen ohne Downgrade-Path erstellen
|
||||
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Performance tests for pgvector HNSW/IVFFlat indexes.
|
||||
|
||||
B-VEC-TEST: Query latency benchmarks at 10k, 100k, 1M embeddings.
|
||||
B-VEC-BATCH: Verify batch embedding reduces API calls.
|
||||
|
||||
These tests require a running PostgreSQL with pgvector extension.
|
||||
They are skipped automatically when no TEST_DATABASE_URL is available.
|
||||
|
||||
Run manually:
|
||||
TEST_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5432/testdb \
|
||||
python -m pytest tests/test_vector_performance.py -v --tb=short
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not os.environ.get("TEST_DATABASE_URL"),
|
||||
reason="TEST_DATABASE_URL not set - pgvector performance tests require a real PostgreSQL with pgvector",
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_pgvector(conn: Any) -> None:
|
||||
await conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
|
||||
await conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS vec_perf_test (
|
||||
id UUID PRIMARY KEY,
|
||||
tenant_id UUID NOT NULL,
|
||||
embedding vector(1536) NOT NULL,
|
||||
label TEXT DEFAULT ''
|
||||
)
|
||||
""")
|
||||
|
||||
|
||||
async def _insert_batch(conn: Any, count: int, batch_size: int = 500) -> None:
|
||||
import random
|
||||
for offset in range(0, count, batch_size):
|
||||
n = min(batch_size, count - offset)
|
||||
rows = []
|
||||
for _ in range(n):
|
||||
vec = [random.uniform(-1, 1) for _ in range(1536)]
|
||||
rows.append((str(uuid.uuid4()), str(uuid.uuid4()), str(vec)))
|
||||
placeholders = ",".join(
|
||||
f"(${i*3+1}, ${i*3+2}, ${i*3+3}::vector)" for i in range(n)
|
||||
)
|
||||
params: list[str] = []
|
||||
for r in rows:
|
||||
params.extend([r[0], r[1], r[2]])
|
||||
await conn.execute(
|
||||
f"INSERT INTO vec_perf_test (id, tenant_id, embedding) VALUES {placeholders}",
|
||||
*params,
|
||||
)
|
||||
|
||||
|
||||
async def _create_hnsw_index(conn: Any) -> None:
|
||||
await conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_vec_perf_hnsw
|
||||
ON vec_perf_test USING hnsw (embedding vector_cosine_ops)
|
||||
WITH (m = 16, ef_construction = 128)
|
||||
""")
|
||||
|
||||
|
||||
async def _create_ivfflat_index(conn: Any, lists: int = 100) -> None:
|
||||
await conn.execute(f"""
|
||||
CREATE INDEX IF NOT EXISTS idx_vec_perf_ivf
|
||||
ON vec_perf_test USING ivfflat (embedding vector_cosine_ops)
|
||||
WITH (lists = {lists})
|
||||
""")
|
||||
|
||||
|
||||
def _stats(latencies: list[float]) -> dict[str, float]:
|
||||
if not latencies:
|
||||
return {"p50": 0.0, "p95": 0.0, "p99": 0.0, "avg": 0.0}
|
||||
s = sorted(latencies)
|
||||
n = len(s)
|
||||
return {
|
||||
"p50": s[n // 2],
|
||||
"p95": s[int(n * 0.95)],
|
||||
"p99": s[int(n * 0.99)],
|
||||
"avg": sum(s) / n,
|
||||
}
|
||||
|
||||
|
||||
async def _measure_hnsw_latency(conn: Any, num_queries: int = 100, k: int = 10, ef_search: int = 40) -> list[float]:
|
||||
import random
|
||||
await conn.execute(f"SET LOCAL hnsw.ef_search = {ef_search}")
|
||||
latencies: list[float] = []
|
||||
for _ in range(num_queries):
|
||||
query_vec = str([random.uniform(-1, 1) for _ in range(1536)])
|
||||
start = time.perf_counter()
|
||||
await conn.fetch(
|
||||
"SELECT id FROM vec_perf_test ORDER BY embedding <=> $1::vector LIMIT $2",
|
||||
query_vec, k,
|
||||
)
|
||||
latencies.append((time.perf_counter() - start) * 1000)
|
||||
return latencies
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scale", [10_000, 100_000])
|
||||
async def test_hnsw_query_latency(scale: int) -> None:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
engine = create_async_engine(os.environ["TEST_DATABASE_URL"])
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await _ensure_pgvector(conn)
|
||||
await conn.execute("TRUNCATE vec_perf_test")
|
||||
await _insert_batch(conn, scale)
|
||||
await _create_hnsw_index(conn)
|
||||
await conn.execute("ANALYZE vec_perf_test")
|
||||
async with engine.connect() as conn:
|
||||
latencies = await _measure_hnsw_latency(conn, num_queries=100, k=10)
|
||||
stats = _stats(latencies)
|
||||
print(f"\nHNSW @ {scale} embeddings: {stats}")
|
||||
if scale == 10_000:
|
||||
assert stats["p95"] < 100, f"p95 too high: {stats['p95']:.1f}ms"
|
||||
elif scale == 100_000:
|
||||
assert stats["p95"] < 500, f"p95 too high: {stats['p95']:.1f}ms"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("scale", [10_000, 100_000])
|
||||
async def test_ivfflat_query_latency(scale: int) -> None:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
engine = create_async_engine(os.environ["TEST_DATABASE_URL"])
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await _ensure_pgvector(conn)
|
||||
await conn.execute("TRUNCATE vec_perf_test")
|
||||
await _insert_batch(conn, scale)
|
||||
lists = max(10, int(scale ** 0.5))
|
||||
await _create_ivfflat_index(conn, lists=lists)
|
||||
await conn.execute("ANALYZE vec_perf_test")
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute("SET LOCAL ivfflat.probes = 10")
|
||||
import random
|
||||
latencies: list[float] = []
|
||||
for _ in range(100):
|
||||
query_vec = str([random.uniform(-1, 1) for _ in range(1536)])
|
||||
start = time.perf_counter()
|
||||
await conn.fetch(
|
||||
"SELECT id FROM vec_perf_test ORDER BY embedding <=> $1::vector LIMIT 10",
|
||||
query_vec,
|
||||
)
|
||||
latencies.append((time.perf_counter() - start) * 1000)
|
||||
stats = _stats(latencies)
|
||||
print(f"\nIVFFlat @ {scale} embeddings: {stats}")
|
||||
if scale == 10_000:
|
||||
assert stats["p95"] < 150, f"p95 too high: {stats['p95']:.1f}ms"
|
||||
elif scale == 100_000:
|
||||
assert stats["p95"] < 800, f"p95 too high: {stats['p95']:.1f}ms"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ef_search_tradeoff() -> None:
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
engine = create_async_engine(os.environ["TEST_DATABASE_URL"])
|
||||
try:
|
||||
async with engine.begin() as conn:
|
||||
await _ensure_pgvector(conn)
|
||||
await conn.execute("TRUNCATE vec_perf_test")
|
||||
await _insert_batch(conn, 10_000)
|
||||
await _create_hnsw_index(conn)
|
||||
await conn.execute("ANALYZE vec_perf_test")
|
||||
async with engine.connect() as conn:
|
||||
results: dict[int, dict[str, float]] = {}
|
||||
for ef in [10, 20, 40, 80, 120]:
|
||||
latencies = await _measure_hnsw_latency(conn, num_queries=50, k=10, ef_search=ef)
|
||||
results[ef] = _stats(latencies)
|
||||
print(f" ef_search={ef}: p50={results[ef]['p50']:.1f}ms p95={results[ef]['p95']:.1f}ms")
|
||||
assert results[10]["p50"] <= results[120]["p50"] + 20, "ef_search=10 should be faster than ef_search=120"
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_embedding_single_api_call() -> None:
|
||||
"""B-VEC-BATCH: Verify batch embedding makes a single API call."""
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
with patch("app.ai.llm_client.litellm.aembedding", new_callable=AsyncMock) as mock_embed:
|
||||
mock_embed.return_value = MagicMock(
|
||||
data=[{"embedding": [0.1] * 1536} for _ in range(10)]
|
||||
)
|
||||
from app.ai.llm_client import llm_embed
|
||||
texts = [f"test text {i}" for i in range(10)]
|
||||
result = await llm_embed(texts, api_key="test-key")
|
||||
assert len(result) == 10
|
||||
assert mock_embed.call_count == 1, f"Expected 1 aembedding call for batch, got {mock_embed.call_count}"
|
||||
call_kwargs = mock_embed.call_args.kwargs
|
||||
assert isinstance(call_kwargs["input"], list)
|
||||
assert len(call_kwargs["input"]) == 10
|
||||
Reference in New Issue
Block a user