# retoor <retoor@molodetz.nl>
from __future__ import annotations
import asyncio
import json
import logging
import shutil
import sys
from pathlib import Path
from devplacepy.config import BASE_DIR, DEEPSEARCH_DIR
from devplacepy.services.deepsearch.store import VectorStore
from devplacepy.services.jobs.base import JobService
from .progress import hub
logger = logging.getLogger(__name__)
WORKER_MODULE = "devplacepy.services.jobs.deepsearch.worker"
STREAM_LIMIT = 16 * 1024 * 1024
class DeepsearchService(JobService):
kind = "deepsearch"
title = "DeepSearch"
description = (
"Runs a multi-agent web research job: it plans queries, crawls and indexes "
"sources into a per-session vector collection, then synthesises a cited report "
"with confidence scoring and source diversity, streaming live "
"progress over a websocket."
)
def __init__(self):
super().__init__(name="deepsearch", interval_seconds=2)
def session_dir(self, uid: str) -> Path:
return DEEPSEARCH_DIR / uid
def collection_name(self, uid: str) -> str:
return f"ds_{uid.replace('-', '')}"
async def process(self, job: dict) -> dict:
from devplacepy import database
from devplacepy.services.audit import record as audit
uid = job["uid"]
payload = dict(job.get("payload", {}))
query = payload.get("query", "")
output_dir = self.session_dir(uid)
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "control.json").write_text(
json.dumps({"state": "running"}), encoding="utf-8"
)
payload["collection"] = self.collection_name(uid)
payload["cached_hashes"] = self._cached_hashes(database)
payload_path = output_dir / "payload.json"
payload_path.write_text(json.dumps(payload), encoding="utf-8")
actor_kind = job.get("owner_kind") or "system"
actor_uid = job.get("owner_id") if job.get("owner_kind") == "user" else None
database.update_deepsearch_session(uid, {"status": "running"})
try:
summary = await self._run_worker(
uid, payload_path, output_dir, actor_kind, job.get("owner_id") or ""
)
except Exception as exc:
hub.publish(uid, {"type": "failed", "message": str(exc)[:300]})
database.update_deepsearch_session(uid, {"status": "failed"})
audit.record_system(
"deepsearch.run.failed",
actor_kind=actor_kind,
actor_uid=actor_uid,
result="failure",
summary=f"DeepSearch for {query} failed",
metadata={"query": query, "error": str(exc)[:200]},
links=[audit.job(uid)],
)
shutil.rmtree(output_dir, ignore_errors=True)
raise
report = self._load_report(output_dir)
self._persist_cache(database, output_dir)
from datetime import datetime, timezone
database.update_deepsearch_session(
uid,
{
"status": "done",
"score": int(summary.get("score") or 0),
"confidence": float(summary.get("confidence") or 0.0),
"source_diversity": float(summary.get("source_diversity") or 0.0),
"page_count": int(summary.get("page_count") or 0),
"chunk_count": int(summary.get("chunk_count") or 0),
"summary": (report.get("summary") or "")[:4000],
"completed_at": datetime.now(timezone.utc).isoformat(),
},
)
hub.publish(
uid,
{
"type": "done",
"score": summary.get("score"),
"confidence": summary.get("confidence"),
"source_diversity": summary.get("source_diversity"),
"page_count": summary.get("page_count"),
"chunk_count": summary.get("chunk_count"),
"session_url": f"/tools/deepsearch/{uid}/session",
},
)
hub.clear(uid)
audit.record_system(
"deepsearch.run.complete",
actor_kind=actor_kind,
actor_uid=actor_uid,
summary=f"DeepSearch for {query} scored {summary.get('score')}",
metadata={
"query": query,
"score": summary.get("score"),
"page_count": summary.get("page_count"),
},
links=[audit.job(uid)],
)
return {
"query": query,
"score": summary.get("score", 0),
"confidence": summary.get("confidence", 0.0),
"source_diversity": summary.get("source_diversity", 0.0),
"page_count": summary.get("page_count", 0),
"chunk_count": summary.get("chunk_count", 0),
"report": report,
"collection": self.collection_name(uid),
"session_url": f"/tools/deepsearch/{uid}/session",
"bytes_in": 0,
"bytes_out": len(json.dumps(report)) if report else 0,
"item_count": summary.get("page_count", 0),
}
def _cached_hashes(self, database) -> list[str]:
if "deepsearch_url_cache" not in database.db.tables:
return []
return [
row.get("url_hash", "")
for row in database.get_table("deepsearch_url_cache").find()
if row.get("url_hash")
]
def _persist_cache(self, database, output_dir: Path) -> None:
path = output_dir / "url_cache.json"
if not path.is_file():
return
try:
entries = json.loads(path.read_text(encoding="utf-8"))
except (ValueError, OSError):
return
for entry in entries:
database.upsert_deepsearch_url_cache(
entry.get("url_hash", ""),
entry.get("url", ""),
entry.get("title", ""),
entry.get("content_hash", ""),
int(entry.get("status") or 0),
int(entry.get("byte_size") or 0),
)
def _ledger_rsearch(self, owner_kind: str, owner_id: str, frame: dict) -> None:
from devplacepy.services.openai_gateway.usage import record_rsearch_call
success = bool(frame.get("success"))
record_rsearch_call(
owner_kind,
owner_id,
frame.get("endpoint") or "/search",
success,
200 if success else 0,
)
async def _run_worker(
self,
uid: str,
payload_path: Path,
output_dir: Path,
owner_kind: str = "system",
owner_id: str = "",
) -> dict:
proc = await asyncio.create_subprocess_exec(
sys.executable,
"-m",
WORKER_MODULE,
str(payload_path),
str(output_dir),
cwd=str(BASE_DIR),
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
limit=STREAM_LIMIT,
)
summary: dict = {}
worker_error = ""
err = ""
stderr_task = asyncio.create_task(proc.stderr.read())
try:
while True:
line = await proc.stdout.readline()
if not line:
break
try:
frame = json.loads(line.decode("utf-8", "replace"))
except (ValueError, TypeError):
continue
hub.publish(uid, frame)
if frame.get("type") == "report_ready":
summary = frame
elif frame.get("type") == "rsearch":
self._ledger_rsearch(owner_kind, owner_id, frame)
elif frame.get("type") == "error":
worker_error = frame.get("message", "worker error")
err = (await stderr_task).decode("utf-8", "replace")
await proc.wait()
finally:
if proc.returncode is None:
try:
proc.kill()
except ProcessLookupError:
pass
await proc.wait()
if not stderr_task.done():
stderr_task.cancel()
if proc.returncode != 0 or not summary:
raise RuntimeError(
worker_error or err[:500] or f"deepsearch worker exited {proc.returncode}"
)
return summary
def _load_report(self, output_dir: Path) -> dict:
report_path = output_dir / "report.json"
if not report_path.is_file():
return {}
try:
return json.loads(report_path.read_text(encoding="utf-8"))
except (ValueError, OSError):
return {}
def cleanup(self, job: dict) -> None:
uid = job["uid"]
hub.clear(uid)
VectorStore(self.collection_name(uid)).drop()
shutil.rmtree(self.session_dir(uid), ignore_errors=True)