Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:24:24 +08:00

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.