diff --git a/.venv/bin/python b/.venv/bin/python new file mode 120000 index 00000000..b8a0adbb --- /dev/null +++ b/.venv/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/bin/python3 b/.venv/bin/python3 new file mode 120000 index 00000000..ae65fdaa --- /dev/null +++ b/.venv/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/.venv/bin/python3.11 b/.venv/bin/python3.11 new file mode 120000 index 00000000..b8a0adbb --- /dev/null +++ b/.venv/bin/python3.11 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/.venv/lib64 b/.venv/lib64 new file mode 120000 index 00000000..7951405f --- /dev/null +++ b/.venv/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/.venv/pyvenv.cfg b/.venv/pyvenv.cfg new file mode 100644 index 00000000..ba18d0b5 --- /dev/null +++ b/.venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.11.2 +executable = /usr/bin/python3.11 +command = /usr/bin/python3 -m venv /workspace/repo/.venv diff --git a/devplacepy/database/__init__.py b/devplacepy/database/__init__.py index 056c9c1e..39dbc596 100644 --- a/devplacepy/database/__init__.py +++ b/devplacepy/database/__init__.py @@ -16,7 +16,7 @@ from .email import EMAIL_ACCOUNT_DEFAULTS, list_email_accounts, get_email_accoun from .notifications import NOTIFICATION_TYPES, NOTIFICATION_CHANNELS, _NOTIFICATION_CHANNEL_COLUMNS, _NOTIFICATION_CHANNEL_DEFAULTS, _NOTIFICATION_TYPE_KEYS, _notification_prefs_cache, _notification_default, get_notification_default, set_notification_default, _notification_overrides, notification_enabled, get_notification_prefs, set_notification_pref, reset_notification_prefs, mark_notifications_read_by_target from .forks import record_fork, get_fork_parent, count_forks, soft_delete_fork_relations, delete_fork_relations from .follows import get_follow_counts, get_follow_list, get_following_among -from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache +from .deepsearch import _ds_now, create_deepsearch_session, update_deepsearch_session, get_deepsearch_session, add_deepsearch_message, get_deepsearch_messages, get_cached_deepsearch_url, upsert_deepsearch_url_cache, list_deepsearch_sessions from .ranking import VOTABLE_TARGETS, STAR_TARGETS, _authors_cache, _ranked_authors, _rank_map, get_top_authors, get_leaderboard, get_user_rank, get_user_stars, clear_user_stars, update_target_stars, soft_delete_engagement, delete_engagement, get_target_owner_uid from .comments import _drop_blocked, _build_comment_items, load_comments, get_recent_comments_by_target_uids, get_recent_comments_by_post_uids, load_comments_by_target_uids from .content import resolve_by_slug, resolve_object_url, get_uids_by_username_match, text_search_clause, get_daily_topic, get_featured_news @@ -182,6 +182,7 @@ __all__ = [ "get_deepsearch_messages", "get_cached_deepsearch_url", "upsert_deepsearch_url_cache", + "list_deepsearch_sessions", "VOTABLE_TARGETS", "STAR_TARGETS", "_authors_cache", diff --git a/devplacepy/database/deepsearch.py b/devplacepy/database/deepsearch.py index bb826fc6..8fd082ed 100644 --- a/devplacepy/database/deepsearch.py +++ b/devplacepy/database/deepsearch.py @@ -84,6 +84,23 @@ def get_deepsearch_messages(session_uid: str, limit: int = 50) -> list[dict]: ) +def list_deepsearch_sessions( + owner_kind: str, owner_id: str, limit: int = 50 +) -> list[dict]: + if "deepsearch_sessions" not in db.tables: + return [] + rows = list( + get_table("deepsearch_sessions").find( + owner_kind=owner_kind, + owner_id=owner_id, + deleted_at=None, + order_by=["created_at desc"], + _limit=limit, + ) + ) + return sorted(rows, key=lambda r: (r.get("created_at", ""), r.get("uid", "")), reverse=True) + + def get_cached_deepsearch_url(url_hash: str) -> dict | None: if "deepsearch_url_cache" not in db.tables: return None diff --git a/devplacepy/routers/tools/deepsearch.py b/devplacepy/routers/tools/deepsearch.py index 1d118d1e..cda73cf0 100644 --- a/devplacepy/routers/tools/deepsearch.py +++ b/devplacepy/routers/tools/deepsearch.py @@ -11,7 +11,12 @@ from devplacepy import database from devplacepy.config import DEEPSEARCH_DIR from devplacepy.models import DeepsearchChatForm, DeepsearchRunForm from devplacepy.responses import respond -from devplacepy.schemas import DeepsearchJobOut, DeepsearchSessionOut +from devplacepy.schemas import ( + DeepsearchJobOut, + DeepsearchListOut, + DeepsearchSessionOut, + DeepsearchSessionSummary, +) from devplacepy.seo import base_seo_context, site_url, web_application_schema, website_schema from devplacepy.services.deepsearch.chat import DeepsearchChat from devplacepy.services.deepsearch.export import to_json, to_markdown, to_pdf @@ -100,10 +105,13 @@ async def deepsearch_page(request: Request): web_application_schema("DeepSearch", description, "/tools/deepsearch", base), ], ) + sessions = [] + if user: + sessions = database.list_deepsearch_sessions("user", user["uid"]) return templates.TemplateResponse( request, "tools/deepsearch.html", - {**seo_ctx, "request": request, "user": user}, + {**seo_ctx, "request": request, "user": user, "history_sessions": sessions}, ) @router.post("/run") @@ -197,6 +205,27 @@ def _enqueue(uid: str, payload: dict, owner_kind: str, owner_id: str, query: str } ) + +@router.get("/list") +async def deepsearch_list(request: Request): + owner_kind, owner_id = owner_for(request) + sessions = database.list_deepsearch_sessions(owner_kind, owner_id) + return JSONResponse( + DeepsearchListOut.model_validate( + {"sessions": [_session_summary(s) for s in sessions]} + ).model_dump(mode="json") + ) + + +def _session_summary(session: dict) -> dict: + return { + "uid": session.get("uid", ""), + "query": session.get("query", ""), + "status": session.get("status", ""), + "created_at": session.get("created_at", ""), + } + + @router.get("/{uid}") async def deepsearch_status(request: Request, uid: str): job = queue.get_job(uid) @@ -322,6 +351,7 @@ async def deepsearch_export_pdf(request: Request, uid: str): raise not_found("Report not ready") return Response(content=to_pdf(report), media_type="application/pdf") + @router.websocket("/{uid}/ws") async def deepsearch_ws(websocket: WebSocket, uid: str): await websocket.accept() diff --git a/devplacepy/schemas/__init__.py b/devplacepy/schemas/__init__.py index 8c8172a5..c065f2ff 100644 --- a/devplacepy/schemas/__init__.py +++ b/devplacepy/schemas/__init__.py @@ -79,7 +79,9 @@ from devplacepy.schemas.containers import ( from devplacepy.schemas.jobs import ( DbQueryJobOut, DeepsearchJobOut, + DeepsearchListOut, DeepsearchSessionOut, + DeepsearchSessionSummary, ForkJobOut, PlanningJobOut, SeoJobOut, diff --git a/devplacepy/schemas/jobs.py b/devplacepy/schemas/jobs.py index a4c7ce75..cdb01334 100644 --- a/devplacepy/schemas/jobs.py +++ b/devplacepy/schemas/jobs.py @@ -142,6 +142,17 @@ class DeepsearchSessionOut(_Out): completed_at: Optional[str] = None +class DeepsearchSessionSummary(_Out): + uid: str = "" + query: str = "" + status: str = "" + created_at: str = "" + + +class DeepsearchListOut(_Out): + sessions: list = [] + + class DbQueryJobOut(_Out): uid: str = "" kind: str = "" diff --git a/devplacepy/services/devii/actions/catalog/tools.py b/devplacepy/services/devii/actions/catalog/tools.py index 4d8396fd..b382a65e 100644 --- a/devplacepy/services/devii/actions/catalog/tools.py +++ b/devplacepy/services/devii/actions/catalog/tools.py @@ -108,6 +108,18 @@ TOOLS_ACTIONS: tuple[Action, ...] = ( params=(path("uid", "DeepSearch job uid returned by deepsearch."),), requires_auth=False, ), + Action( + name="deepsearch_list", + method="GET", + path="/tools/deepsearch/list", + summary="List the user's past DeepSearch sessions", + description=( + "Returns the signed-in user's deep research session history, newest first, each " + "with its uid, query, status and created_at." + ), + params=(), + requires_auth=True, + ), Action( name="isslop", method="POST", diff --git a/devplacepy/templates/tools/deepsearch.html b/devplacepy/templates/tools/deepsearch.html index 3decf2e4..d0c225c8 100644 --- a/devplacepy/templates/tools/deepsearch.html +++ b/devplacepy/templates/tools/deepsearch.html @@ -95,6 +95,22 @@

Research complete.

Open report and chat + + {% if user and history_sessions %} +
+

My Research History

+ +
+ {% endif %} {% endblock %} diff --git a/dpc.log b/dpc.log new file mode 100644 index 00000000..2401d2d4 --- /dev/null +++ b/dpc.log @@ -0,0 +1,8 @@ +2026-07-19T17:32:58 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T17:32:58 DEBUG model=molodetz-pro fps=30 +2026-07-19T17:32:58 INFO read task from file: /workspace/prompts/research-9.txt +2026-07-19T17:32:58 INFO settings merged: model= allow=0 deny=0 ask=0 +2026-07-19T17:38:24 INFO logging initialised at /workspace/repo/dpc.log +2026-07-19T17:38:24 DEBUG model=molodetz-pro fps=30 +2026-07-19T17:38:24 INFO read task from file: /workspace/prompts/execution-1.txt +2026-07-19T17:38:24 INFO settings merged: model= allow=0 deny=0 ask=0 diff --git a/tests/api/tools/deepsearch/list.py b/tests/api/tools/deepsearch/list.py new file mode 100644 index 00000000..4dee9bb5 --- /dev/null +++ b/tests/api/tools/deepsearch/list.py @@ -0,0 +1,129 @@ +# retoor + +import time +import pytest +import requests +from tests.conftest import BASE_URL +from devplacepy.database import ( + create_deepsearch_session, + get_table, + refresh_snapshot, + set_setting, +) +from devplacepy.services.jobs import queue + +JSON = {"Accept": "application/json"} +_counter = [0] + + +@pytest.fixture(scope="module", autouse=True) +def _settings(app_server): + set_setting("rate_limit_per_minute", "1000000") + set_setting("registration_open", "1") + yield + + +def _user(): + _counter[0] += 1 + name = f"dsl{int(time.time() * 1000)}{_counter[0]}" + requests.post( + f"{BASE_URL}/auth/signup", + data={ + "username": name, + "email": f"{name}@t.dev", + "password": "secret123", + "confirm_password": "secret123", + }, + allow_redirects=True, + ) + refresh_snapshot() + user = get_table("users").find_one(username=name) + return user["uid"], user["api_key"] + + +def _seed_session(owner_id, query="test query"): + uid = queue.enqueue( + "deepsearch", + {"query": query, "depth": 1, "max_pages": 5}, + "user", + owner_id, + "DeepSearch", + ) + create_deepsearch_session( + uid, "user", owner_id, query, 1, 5, f"ds_{uid.replace('-', '')}" + ) + refresh_snapshot() + return uid + + +def _clear(): + refresh_snapshot() + jobs = get_table("jobs") + for row in list(jobs.find(kind="deepsearch")): + jobs.delete(uid=row["uid"]) + sessions = get_table("deepsearch_sessions") + for row in list(sessions.find()): + sessions.delete(uid=row["uid"]) + + +def test_list_returns_own_sessions(app_server): + try: + owner_uid, api_key = _user() + uid = _seed_session(owner_uid, query="first research") + headers = {"X-API-KEY": api_key, **JSON} + r = requests.get(f"{BASE_URL}/tools/deepsearch/list", headers=headers) + assert r.status_code == 200, r.text + body = r.json() + assert "sessions" in body + assert len(body["sessions"]) == 1 + assert body["sessions"][0]["uid"] == uid + assert body["sessions"][0]["query"] == "first research" + assert body["sessions"][0]["status"] == "pending" + assert "created_at" in body["sessions"][0] + finally: + _clear() + + +def test_list_multiple_sessions_sorted_newest_first(app_server): + try: + owner_uid, api_key = _user() + uids = [] + for i in range(3): + uids.append(_seed_session(owner_uid, query=f"research {i}")) + headers = {"X-API-KEY": api_key, **JSON} + r = requests.get(f"{BASE_URL}/tools/deepsearch/list", headers=headers) + assert r.status_code == 200, r.text + body = r.json() + assert len(body["sessions"]) == 3 + uids_in_response = [s["uid"] for s in body["sessions"]] + assert sorted(uids_in_response, reverse=True) == uids_in_response + actual_queries = [s["query"] for s in body["sessions"]] + assert len(set(actual_queries)) == 3 + finally: + _clear() + + +def test_list_does_not_include_other_users_sessions(app_server): + try: + owner_uid, api_key = _user() + _seed_session(owner_uid, query="my research") + other_uid, _ = _user() + _seed_session(other_uid, query="other research") + headers = {"X-API-KEY": api_key, **JSON} + r = requests.get(f"{BASE_URL}/tools/deepsearch/list", headers=headers) + assert r.status_code == 200, r.text + body = r.json() + assert len(body["sessions"]) == 1 + assert body["sessions"][0]["query"] == "my research" + finally: + _clear() + + +def test_list_empty_for_no_sessions(app_server): + owner_uid, api_key = _user() + headers = {"X-API-KEY": api_key, **JSON} + r = requests.get(f"{BASE_URL}/tools/deepsearch/list", headers=headers) + assert r.status_code == 200, r.text + body = r.json() + assert "sessions" in body + assert len(body["sessions"]) == 0