6c9c7fe7f3
CI / integration tests (3.13) (push) Failing after 1s
Commit lint / pull request title (push) Has been skipped
Docs / links (push) Failing after 1s
CI / unit tests (3.13) (push) Failing after 1s
CI / lint (push) Failing after 1s
CI / integration tests (push) Failing after 1s
CI / package build (push) Failing after 1s
Commit lint / commit messages (push) Failing after 1s
CI / unit tests (push) Failing after 1s
284 lines
12 KiB
Python
284 lines
12 KiB
Python
"""Knowledge HTTP e2e — all 9 endpoints via real LLM + full HTTP stack.
|
|
|
|
Drives every knowledge API endpoint end-to-end with a **real LLM**:
|
|
|
|
GET /categories → taxonomy auto-generation
|
|
POST /documents → real LLM extraction → md → cascade
|
|
GET /documents/{doc_id} → document detail with topics
|
|
GET /topics/{topic_id} → topic content
|
|
POST /search (keyword) → BM25 retrieval
|
|
POST /search (vector) → ANN retrieval
|
|
POST /search (hybrid) → RRF fusion
|
|
POST /search (include_content) → content enrichment
|
|
PATCH /documents/{doc_id} → metadata update
|
|
GET /documents → paginated listing
|
|
PUT /documents/{doc_id} → replace (delete + recreate)
|
|
DELETE /documents/{doc_id} → cleanup
|
|
|
|
Uses ``httpx.AsyncClient`` against ``create_app()`` with full lifespan
|
|
(SQLite + LanceDB + Cascade + OME).
|
|
|
|
Marked ``live_llm`` + ``slow`` — requires ``EVEROS_LLM__*`` +
|
|
``EVEROS_EMBEDDING__*`` credentials in ``.env``.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test document — 3 clear sections for predictable topic extraction.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_TEST_DOCUMENT = """\
|
|
# 2028 Los Angeles Olympics Budget Plan
|
|
|
|
## Venue Construction
|
|
The venue construction program covers 12 new facilities and 8 renovated \
|
|
sites across greater Los Angeles. Total venue construction budget is \
|
|
estimated at $5.3 billion, with the Intuit Dome as the centerpiece for \
|
|
basketball events. Temporary overlay structures account for $800 million.
|
|
|
|
## Transportation Infrastructure
|
|
A comprehensive transportation plan connects all venue clusters via \
|
|
dedicated Olympic lanes. The LAX-to-Downtown express shuttle operates \
|
|
24 hours during Games time. Budget allocation for transportation is \
|
|
$1.2 billion including temporary bus fleet leases.
|
|
|
|
## Security Operations
|
|
Multi-agency security coordination involves LAPD, FBI, and DHS. \
|
|
Cybersecurity operations center monitors digital threats 24/7. \
|
|
Drone detection perimeter extends 30 miles around the Olympic Village. \
|
|
Security budget is $2.1 billion.
|
|
"""
|
|
|
|
_TEST_TITLE = "2028 LA Olympics Budget Plan"
|
|
_PREFIX = "/api/v1/knowledge"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures — reuse the shared e2e conftest (async_client + lifespan)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.live_llm
|
|
@pytest.mark.slow
|
|
async def test_knowledge_full_http_lifecycle(
|
|
async_client: httpx.AsyncClient,
|
|
cascade_done_poll,
|
|
) -> None:
|
|
"""Full HTTP lifecycle: create → get → search → list → delete.
|
|
|
|
Validates every HTTP endpoint with real LLM extraction output,
|
|
checking response shapes, status codes, and semantic correctness
|
|
against the known input document.
|
|
"""
|
|
client = async_client
|
|
|
|
# ── 1. GET /categories — taxonomy auto-generated ──────────────
|
|
resp = await client.get(f"{_PREFIX}/categories")
|
|
assert resp.status_code == 200
|
|
cats = resp.json()["data"]["categories"]
|
|
assert len(cats) >= 10, f"Expected >=10 default categories, got {len(cats)}"
|
|
cat_ids = {c["id"] for c in cats}
|
|
assert "Sports" in cat_ids
|
|
assert "Others" in cat_ids
|
|
|
|
# ── 2. POST /documents — create with real LLM extraction ─────
|
|
resp = await client.post(
|
|
f"{_PREFIX}/documents",
|
|
data={"title": _TEST_TITLE, "source_type": "file"},
|
|
files={"file": ("budget.md", _TEST_DOCUMENT.encode(), "text/plain")},
|
|
)
|
|
assert resp.status_code == 201, f"Create failed: {resp.text}"
|
|
create_data = resp.json()["data"]
|
|
doc_id = create_data["doc_id"]
|
|
assert doc_id.startswith("d_")
|
|
assert create_data["topic_count"] >= 2, (
|
|
f"3-section doc should produce >= 2 topics, got {create_data['topic_count']}"
|
|
)
|
|
assert create_data["category_id"], "LLM should assign a category"
|
|
assert create_data["source_name"] == "budget.md"
|
|
|
|
# ── 3. Wait for cascade to index all topics ─────────────────
|
|
# The watcher needs time to detect new files, then the worker
|
|
# processes them. Poll the GET endpoint until topics appear.
|
|
expected_topics = create_data["topic_count"]
|
|
async with asyncio.timeout(60.0):
|
|
while True:
|
|
resp = await client.get(f"{_PREFIX}/documents/{doc_id}")
|
|
if resp.status_code == 200:
|
|
detail = resp.json()["data"]
|
|
if len(detail.get("topics", [])) >= expected_topics:
|
|
break
|
|
await asyncio.sleep(1.0)
|
|
# Extra settle for LanceDB FTS index.
|
|
await asyncio.sleep(1.0)
|
|
|
|
# ── 4. GET /documents/{doc_id} — document detail ─────────────
|
|
assert detail["doc_id"] == doc_id
|
|
assert detail["title"] == _TEST_TITLE
|
|
assert len(detail["summary"]) > 30, "Document summary should be meaningful"
|
|
assert len(detail["topics"]) >= 2
|
|
|
|
# Verify topic names relate to the input document sections.
|
|
topic_names = [t["topic_name"].lower() for t in detail["topics"]]
|
|
found_sections = {
|
|
"venue": any("venue" in n for n in topic_names),
|
|
"transport": any("transport" in n for n in topic_names),
|
|
"security": any("security" in n for n in topic_names),
|
|
}
|
|
assert sum(found_sections.values()) >= 2, (
|
|
f"Expected >= 2 of 3 sections in topic names, got: {topic_names}"
|
|
)
|
|
|
|
# All topics have topic_path and summary.
|
|
for t in detail["topics"]:
|
|
assert t["topic_path"], f"topic_path empty for {t['topic_id']}"
|
|
assert len(t["summary"]) > 10, f"summary too short for {t['topic_id']}"
|
|
|
|
# ── 5. GET /topics/{topic_id} — topic detail with content ────
|
|
first_topic = detail["topics"][0]
|
|
topic_id = first_topic["topic_id"]
|
|
resp = await client.get(f"{_PREFIX}/topics/{topic_id}")
|
|
assert resp.status_code == 200
|
|
topic_detail = resp.json()["data"]
|
|
assert topic_detail["topic_id"] == topic_id
|
|
assert topic_detail["doc_id"] == doc_id
|
|
assert len(topic_detail["content"]) > 20, "Topic should have content body"
|
|
assert topic_detail["category_id"] == create_data["category_id"]
|
|
|
|
# ── 6. POST /search — keyword search ─────────────────────────
|
|
resp = await client.post(
|
|
f"{_PREFIX}/search",
|
|
json={"query": "budget", "method": "keyword", "top_k": 10},
|
|
)
|
|
assert resp.status_code == 200
|
|
search_data = resp.json()["data"]
|
|
assert search_data["total"] >= 1, "Keyword search 'budget' should find hits, got 0"
|
|
assert search_data["took_ms"] > 0
|
|
|
|
hit = search_data["hits"][0]
|
|
assert hit["score"] > 0
|
|
assert hit["topic_id"]
|
|
assert hit["document"]["doc_id"] == doc_id
|
|
assert hit["document"]["title"] == _TEST_TITLE
|
|
|
|
# ── 7. POST /search — include_content=true ───────────────────
|
|
resp = await client.post(
|
|
f"{_PREFIX}/search",
|
|
json={
|
|
"query": "venue construction",
|
|
"method": "keyword",
|
|
"top_k": 5,
|
|
"include_content": True,
|
|
},
|
|
)
|
|
assert resp.status_code == 200
|
|
hits_with_content = resp.json()["data"]["hits"]
|
|
if hits_with_content:
|
|
assert hits_with_content[0]["content"], (
|
|
"include_content=true should populate content field"
|
|
)
|
|
|
|
# ── 8. POST /search — vector search ─────────────────────────
|
|
resp = await client.post(
|
|
f"{_PREFIX}/search",
|
|
json={"query": "Olympic venue stadium", "method": "vector", "top_k": 5},
|
|
)
|
|
assert resp.status_code == 200
|
|
vector_data = resp.json()["data"]
|
|
assert vector_data["total"] >= 1, "Vector search should find hits"
|
|
assert vector_data["hits"][0]["retrieval_method"] == "vector"
|
|
|
|
# ── 9. POST /search — hybrid search ──────────────────────────
|
|
resp = await client.post(
|
|
f"{_PREFIX}/search",
|
|
json={"query": "security operations", "method": "hybrid", "top_k": 5},
|
|
)
|
|
assert resp.status_code == 200
|
|
hybrid_data = resp.json()["data"]
|
|
assert hybrid_data["total"] >= 1, "Hybrid search should find hits"
|
|
|
|
# ── 10. PATCH /documents/{doc_id} — update metadata ──────────
|
|
new_title = "Updated Olympics Budget 2028"
|
|
resp = await client.patch(
|
|
f"{_PREFIX}/documents/{doc_id}",
|
|
json={"title": new_title},
|
|
)
|
|
assert resp.status_code == 200
|
|
patch_data = resp.json()["data"]
|
|
assert patch_data["doc_id"] == doc_id
|
|
assert "title" in patch_data["updated_fields"]
|
|
|
|
# Verify title change persisted via GET.
|
|
resp = await client.get(f"{_PREFIX}/documents/{doc_id}")
|
|
assert resp.json()["data"]["title"] == new_title
|
|
|
|
# ── 11. GET /documents — paginated listing ───────────────────
|
|
resp = await client.get(f"{_PREFIX}/documents")
|
|
assert resp.status_code == 200
|
|
list_data = resp.json()["data"]
|
|
assert list_data["total"] >= 1
|
|
assert any(d["doc_id"] == doc_id for d in list_data["documents"])
|
|
our_doc = next(d for d in list_data["documents"] if d["doc_id"] == doc_id)
|
|
assert our_doc["title"] == new_title
|
|
assert our_doc["topic_count"] >= 2
|
|
|
|
# ── 12. PUT /documents/{doc_id} — replace with new content ───
|
|
replacement_doc = (
|
|
"# Revised 2028 LA Olympics Plan\n\n"
|
|
"## Athlete Village\n"
|
|
"The athlete village will house 15,000 athletes in UCLA campus "
|
|
"facilities. Total village budget is $1.8 billion.\n"
|
|
)
|
|
resp = await client.put(
|
|
f"{_PREFIX}/documents/{doc_id}",
|
|
data={"title": "Revised Olympics Plan"},
|
|
files={"file": ("revised.md", replacement_doc.encode(), "text/plain")},
|
|
)
|
|
assert resp.status_code == 200, f"Replace failed: {resp.text}"
|
|
replace_data = resp.json()["data"]
|
|
assert replace_data["doc_id"] == doc_id, "PUT should preserve doc_id"
|
|
assert replace_data["topic_count"] >= 1
|
|
|
|
# Wait for cascade to fully cycle: old topics deleted + new topics indexed.
|
|
# Poll until GET returns new title AND topic names from the replacement doc.
|
|
async with asyncio.timeout(60.0):
|
|
while True:
|
|
resp = await client.get(f"{_PREFIX}/documents/{doc_id}")
|
|
if resp.status_code == 200:
|
|
d = resp.json()["data"]
|
|
names = [t["topic_name"].lower() for t in d.get("topics", [])]
|
|
has_new = any("village" in n or "athlete" in n for n in names)
|
|
has_old = any("venue" in n or "security" in n for n in names)
|
|
if d["title"] == "Revised Olympics Plan" and has_new and not has_old:
|
|
break
|
|
await asyncio.sleep(1.0)
|
|
|
|
# Verify replaced content.
|
|
resp = await client.get(f"{_PREFIX}/documents/{doc_id}")
|
|
replaced = resp.json()["data"]
|
|
assert replaced["title"] == "Revised Olympics Plan"
|
|
|
|
# ── 13. DELETE /documents/{doc_id} ────────────────────────────
|
|
resp = await client.delete(f"{_PREFIX}/documents/{doc_id}")
|
|
assert resp.status_code == 200
|
|
del_data = resp.json()["data"]
|
|
assert del_data["doc_id"] == doc_id
|
|
assert del_data["deleted_topics"] >= 1
|
|
|
|
# Note: cascade cleanup of SQLite/LanceDB rows is eventually
|
|
# consistent (scanner interval + FK constraint retry). The service
|
|
# DELETE removes md files immediately; the cascade handler catches
|
|
# up on subsequent scan passes. Integration tests verify cascade
|
|
# cleanup; this e2e test verifies the HTTP contract only.
|