Every AI-calling feature (content correction, the @ai modifier, quiz grading, SEO metadata generation, the AI Usage Analyzer, and DeepSearch) can now name its own model via admin-editable configuration, defaulting to the gateway's default model when left blank. The gateway a feature talks to stays fixed to the internal endpoint either way, only the model name is a knob, so the best model can be picked per task. Also splits the news service's shared AI grading/formatting config into two independent endpoint/model/key pairs: reformatting no longer requires repointing (and thereby breaking) the free, scoring-only grading model. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FZ5x6KZTxZjbJqsEkxGbxG
232 lines
8.4 KiB
Python
232 lines
8.4 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import json
|
|
import tempfile
|
|
from pathlib import Path
|
|
|
|
from tests.conftest import run_async
|
|
|
|
from devplacepy.services.jobs.deepsearch import crawl as crawl_module
|
|
from devplacepy.services.jobs.deepsearch import worker as worker_module
|
|
from devplacepy.services.jobs.deepsearch.crawl import CrawledPage, CrawlOutcome
|
|
from devplacepy.services.deepsearch.embeddings import local_embed
|
|
from devplacepy.services.deepsearch.store import VectorStore
|
|
|
|
|
|
def _patch_pipeline(monkeypatch, pages):
|
|
async def fake_plan(query, api_key, emit=lambda frame: None, model=None):
|
|
return [query, f"{query} overview"]
|
|
|
|
async def fake_search(queries, emit=lambda frame: None):
|
|
return [{"url": page.url, "title": page.title, "description": ""} for page in pages]
|
|
|
|
async def fake_crawl(
|
|
candidates, max_pages, emit, is_cached, should_stop, query="", depth=1, seen_hashes=None
|
|
):
|
|
outcome = CrawlOutcome(seen_hashes=seen_hashes if seen_hashes is not None else set())
|
|
for page in pages[:max_pages]:
|
|
emit({"type": "page_loaded", "url": page.url, "done": 1, "total": len(pages)})
|
|
outcome.pages.append(page)
|
|
return outcome
|
|
|
|
async def fake_plan_followups(query, covered_titles, api_key, emit=lambda frame: None, model=None):
|
|
return []
|
|
|
|
def fake_embed(texts, api_key):
|
|
return local_embed(texts)
|
|
|
|
async def fake_embed_async(texts, api_key, **kwargs):
|
|
return local_embed(texts)
|
|
|
|
async def fake_orchestrate(question, crawled, api_key, emit, store=None, queries=None, model=None):
|
|
from devplacepy.services.jobs.deepsearch.orchestrate import Orchestration
|
|
|
|
return Orchestration(
|
|
summary="A summary.",
|
|
findings=[{"title": "F", "detail": "D", "confidence": 0.5, "citations": [1]}],
|
|
confidence=0.6,
|
|
source_diversity=0.5,
|
|
score=42,
|
|
)
|
|
|
|
monkeypatch.setattr(worker_module, "plan_queries", fake_plan)
|
|
monkeypatch.setattr(worker_module, "search_queries", fake_search)
|
|
monkeypatch.setattr(worker_module, "crawl", fake_crawl)
|
|
monkeypatch.setattr(worker_module, "plan_followup_queries", fake_plan_followups)
|
|
monkeypatch.setattr(worker_module, "embed_texts", fake_embed_async)
|
|
monkeypatch.setattr(worker_module, "orchestrate", fake_orchestrate)
|
|
|
|
|
|
def test_worker_run_produces_report(monkeypatch):
|
|
pages = [
|
|
CrawledPage(
|
|
url="https://example.com/a",
|
|
title="Page A",
|
|
text="The transistor was invented at Bell Labs. " * 20,
|
|
source="httpx",
|
|
status=200,
|
|
),
|
|
CrawledPage(
|
|
url="https://other.example/b",
|
|
title="Page B",
|
|
text="Semiconductors are made from silicon. " * 20,
|
|
source="playwright",
|
|
status=200,
|
|
),
|
|
]
|
|
_patch_pipeline(monkeypatch, pages)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
output_dir = Path(tmp)
|
|
payload = {
|
|
"query": "history of the transistor",
|
|
"max_pages": 5,
|
|
"depth": 2,
|
|
"api_key": "k",
|
|
"collection": "ds_worker_test_one",
|
|
"cached_hashes": [],
|
|
}
|
|
report = run_async(worker_module._run(payload, output_dir))
|
|
assert report["query"] == "history of the transistor"
|
|
assert report["page_count"] == 2
|
|
assert report["chunk_count"] > 0
|
|
assert report["summary"] == "A summary."
|
|
assert report["findings"]
|
|
assert (output_dir / "report.json").is_file()
|
|
cache = json.loads((output_dir / "url_cache.json").read_text())
|
|
assert len(cache) == 2
|
|
VectorStore("ds_worker_test_one").drop()
|
|
|
|
|
|
def test_index_chunks_emits_batch_progress(monkeypatch):
|
|
async def fake_embed_async(texts, api_key, **kwargs):
|
|
return local_embed(texts)
|
|
|
|
monkeypatch.setattr(worker_module, "embed_texts", fake_embed_async)
|
|
monkeypatch.setattr(worker_module, "EMBED_BATCH", 1)
|
|
|
|
frames = []
|
|
pages = [
|
|
CrawledPage(
|
|
url="https://example.com/a",
|
|
title="Page A",
|
|
text="The transistor was invented at Bell Labs. " * 30,
|
|
source="httpx",
|
|
status=200,
|
|
)
|
|
]
|
|
store = VectorStore("ds_index_chunks_test")
|
|
try:
|
|
chunk_count, backend = run_async(
|
|
worker_module._index_chunks(store, pages, "k", frames.append)
|
|
)
|
|
assert chunk_count > 0
|
|
assert backend in ("gateway", "local")
|
|
batch_frames = [f for f in frames if f.get("type") == "embed_batch"]
|
|
assert batch_frames
|
|
assert all(f["total"] == chunk_count for f in batch_frames)
|
|
assert max(f["done"] for f in batch_frames) == chunk_count
|
|
done_frames = [f for f in frames if f.get("type") == "embed_done"]
|
|
assert done_frames and done_frames[-1]["chunk_count"] == chunk_count
|
|
finally:
|
|
store.drop()
|
|
|
|
|
|
def test_index_chunks_empty_pages_emits_done(monkeypatch):
|
|
frames = []
|
|
store = VectorStore("ds_index_chunks_empty_test")
|
|
try:
|
|
chunk_count, backend = run_async(
|
|
worker_module._index_chunks(store, [], "k", frames.append)
|
|
)
|
|
assert chunk_count == 0
|
|
assert backend == "empty"
|
|
assert any(
|
|
f.get("type") == "embed_done" and f.get("chunk_count") == 0 for f in frames
|
|
)
|
|
finally:
|
|
store.drop()
|
|
|
|
|
|
def test_worker_run_performs_refinement_round_when_budget_remains(monkeypatch):
|
|
initial_page = CrawledPage(
|
|
url="https://example.com/a",
|
|
title="Page A",
|
|
text="The transistor was invented at Bell Labs. " * 20,
|
|
source="httpx",
|
|
status=200,
|
|
)
|
|
refined_page = CrawledPage(
|
|
url="https://other.example/b",
|
|
title="Page B",
|
|
text="Semiconductors are made from silicon. " * 20,
|
|
source="httpx",
|
|
status=200,
|
|
)
|
|
_patch_pipeline(monkeypatch, [initial_page])
|
|
|
|
followup_calls = []
|
|
|
|
async def fake_plan_followups_once(query, covered_titles, api_key, emit=lambda frame: None, model=None):
|
|
if followup_calls:
|
|
return []
|
|
followup_calls.append(covered_titles)
|
|
return ["a more specific angle"]
|
|
|
|
async def fake_search_followup(queries, emit=lambda frame: None):
|
|
return [{"url": refined_page.url, "title": refined_page.title, "description": ""}]
|
|
|
|
async def fake_crawl_refinement(
|
|
candidates, max_pages, emit, is_cached, should_stop, query="", depth=1, seen_hashes=None
|
|
):
|
|
outcome = CrawlOutcome(seen_hashes=seen_hashes if seen_hashes is not None else set())
|
|
outcome.pages.append(refined_page)
|
|
return outcome
|
|
|
|
monkeypatch.setattr(worker_module, "plan_followup_queries", fake_plan_followups_once)
|
|
|
|
real_search_queries = worker_module.search_queries
|
|
real_crawl = worker_module.crawl
|
|
call_count = {"search": 0, "crawl": 0}
|
|
|
|
async def routed_search(queries, emit=lambda frame: None):
|
|
call_count["search"] += 1
|
|
if call_count["search"] == 1:
|
|
return await real_search_queries(queries, emit)
|
|
return await fake_search_followup(queries, emit)
|
|
|
|
async def routed_crawl(*args, **kwargs):
|
|
call_count["crawl"] += 1
|
|
if call_count["crawl"] == 1:
|
|
return await real_crawl(*args, **kwargs)
|
|
return await fake_crawl_refinement(*args, **kwargs)
|
|
|
|
monkeypatch.setattr(worker_module, "search_queries", routed_search)
|
|
monkeypatch.setattr(worker_module, "crawl", routed_crawl)
|
|
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
output_dir = Path(tmp)
|
|
payload = {
|
|
"query": "history of the transistor",
|
|
"max_pages": 5,
|
|
"depth": 2,
|
|
"api_key": "k",
|
|
"collection": "ds_worker_refine_test",
|
|
"cached_hashes": [],
|
|
}
|
|
report = run_async(worker_module._run(payload, output_dir))
|
|
assert report["page_count"] == 2
|
|
assert followup_calls and followup_calls[0] == ["Page A"]
|
|
VectorStore("ds_worker_refine_test").drop()
|
|
|
|
|
|
def test_worker_control_cancel_stops(monkeypatch):
|
|
pages = [
|
|
CrawledPage(url="https://x.example", title="X", text="content " * 40, source="httpx", status=200)
|
|
]
|
|
_patch_pipeline(monkeypatch, pages)
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
output_dir = Path(tmp)
|
|
(output_dir / "control.json").write_text(json.dumps({"state": "cancelled"}))
|
|
should_stop = worker_module._make_stop(output_dir)
|
|
assert run_async(should_stop()) is True
|