From 63de030789c1eb0d23a77718c6830151cd9d1acb Mon Sep 17 00:00:00 2001 From: Saberlve Date: Wed, 15 Apr 2026 18:44:43 +0800 Subject: [PATCH] feat: add GET /api/projects/{id}/import-state endpoint Returns pending/done status for each item in a project by checking selected-items.json against cards.json. Co-Authored-By: Claude Opus 4.6 --- src/zotero_kb/api.py | 216 +++++++++++++++++++++++++++++++++++++++++ tests/test_api.py | 222 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 438 insertions(+) create mode 100644 src/zotero_kb/api.py create mode 100644 tests/test_api.py diff --git a/src/zotero_kb/api.py b/src/zotero_kb/api.py new file mode 100644 index 0000000..30e346c --- /dev/null +++ b/src/zotero_kb/api.py @@ -0,0 +1,216 @@ +from __future__ import annotations + +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.responses import HTMLResponse +from pydantic import BaseModel, Field + +from .bridge import read_selected_keys +from .cards import CardBuilder +from .config import AppConfig +from .llm import CardGenerationClient, create_card_generation_client +from .projects import ProjectService +from .workspace import Workspace +from .writing import WritingService +from .zotero_reader import ZoteroReader + + +class CreateProjectRequest(BaseModel): + project_id: str = Field(min_length=1) + name: str = Field(min_length=1) + llm_provider: str = "openai" + llm_model: str = "gpt-5-mini" + + +class WritingPromptRequest(BaseModel): + prompt: str = Field(min_length=1) + + +class WritingPlanRequest(BaseModel): + prompt: str = Field(min_length=1) + + +class ImportItemKeysRequest(BaseModel): + item_keys: list[str] = Field(min_length=1) + + +def create_app( + config: AppConfig, + *, + llm_client: CardGenerationClient | None = None, +) -> FastAPI: + app = FastAPI(title="Zotero KB") + workspace = Workspace(config) + project_service = ProjectService(config.workspace_dir) + writing_service = WritingService(config.workspace_dir) + reader = ZoteroReader(config.zotero_data_dir) + + @app.get("/", response_class=HTMLResponse) + def index() -> str: + return (Path(__file__).parent / "templates" / "index.html").read_text(encoding="utf-8") + + @app.post("/api/projects", status_code=201) + def create_project(payload: CreateProjectRequest) -> dict[str, object]: + project = workspace.create_project( + project_id=payload.project_id, + name=payload.name, + llm_provider=payload.llm_provider, + llm_model=payload.llm_model, + ) + return {"id": project.project_id, "name": project.name} + + @app.get("/api/projects") + def list_projects() -> list[dict[str, object]]: + projects_dir = config.workspace_dir / "projects" + if not projects_dir.exists(): + return [] + result = [] + for project_dir in sorted(path for path in projects_dir.iterdir() if path.is_dir()): + project_file = project_dir / "project.json" + if project_file.exists(): + result.append(_read_json(project_file)) + return result + + @app.get("/api/zotero/collections/tree") + def zotero_collection_tree() -> dict[str, object]: + return {"collections": reader.get_collection_tree()} + + @app.get("/api/zotero/collections/{collection_key}/items") + def zotero_collection_items(collection_key: str, include_descendants: bool = True) -> dict[str, object]: + return { + "collection_key": collection_key, + "items": reader.get_collection_items(collection_key, include_descendants=include_descendants), + } + + @app.get("/api/zotero/items") + def search_zotero_items(query: str = "", limit: int = 20) -> dict[str, object]: + return {"items": reader.search_items(query, limit=limit)} + + @app.get("/api/projects/{project_id}") + def get_project(project_id: str) -> dict[str, object]: + project_dir = config.workspace_dir / "projects" / project_id + project_file = project_dir / "project.json" + if not project_file.exists(): + raise HTTPException(status_code=404, detail="Project not found") + project = _read_json(project_file) + project["view"] = project_service.get_project_view(project_id) + return project + + @app.post("/api/projects/{project_id}/imports/selected-items") + def import_selected_items(project_id: str) -> dict[str, object]: + project_file = config.workspace_dir / "projects" / project_id / "project.json" + if not project_file.exists(): + raise HTTPException(status_code=404, detail="Project not found") + if config.bridge_file is None or not config.bridge_file.exists(): + raise HTTPException(status_code=400, detail="Bridge snapshot file not found") + + project_payload = _read_json(project_file) + llm_payload = project_payload.get("llm", {}) + try: + resolved_client = llm_client or create_card_generation_client( + str(llm_payload.get("provider", "deterministic")), + str(llm_payload.get("model", "deterministic")), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + builder = CardBuilder(config.workspace_dir, resolved_client) + selected_keys = read_selected_keys(config.bridge_file) + items = reader.read_items(selected_keys) + imported_keys = [] + for item in items: + builder.build_or_update(item) + imported_keys.append(item.item_key) + project_view = project_service.add_items(project_id, imported_keys) + return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view} + + @app.post("/api/projects/{project_id}/imports/item-keys") + def import_item_keys(project_id: str, payload: ImportItemKeysRequest | dict[str, object]) -> dict[str, object]: + project_file = config.workspace_dir / "projects" / project_id / "project.json" + if not project_file.exists(): + raise HTTPException(status_code=404, detail="Project not found") + + if isinstance(payload, dict): + item_keys = [str(value) for value in payload.get("item_keys", [])] + else: + item_keys = [str(value) for value in payload.item_keys] + + project_payload = _read_json(project_file) + llm_payload = project_payload.get("llm", {}) + try: + resolved_client = llm_client or create_card_generation_client( + str(llm_payload.get("provider", "deterministic")), + str(llm_payload.get("model", "deterministic")), + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + builder = CardBuilder(config.workspace_dir, resolved_client) + items = reader.read_items(item_keys) + imported_keys = [] + for item in items: + builder.build_or_update(item) + imported_keys.append(item.item_key) + project_view = project_service.add_items(project_id, imported_keys) + return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view} + + @app.get("/api/projects/{project_id}/cards") + def list_project_cards(project_id: str) -> dict[str, object]: + return project_service.get_project_view(project_id) + + @app.get("/api/projects/{project_id}/import-state") + def get_import_state(project_id: str) -> dict[str, object]: + project_dir = config.workspace_dir / "projects" / project_id + project_file = project_dir / "project.json" + if not project_file.exists(): + raise HTTPException(status_code=404, detail="Project not found") + + selected_items = project_service._read_selected_items(project_id) + if not selected_items: + return {"project_id": project_id, "items": [], "pending_count": 0, "done_count": 0} + + items_index = project_service._read_json(config.workspace_dir / "library" / "index" / "items.json") + cards_index = project_service._read_json(config.workspace_dir / "library" / "index" / "cards.json") + + items: list[dict[str, object]] = [] + pending_count = 0 + done_count = 0 + for item_key in selected_items: + item_data = items_index.get(item_key, {}) + card_data = cards_index.get(item_key) + card_status = "done" if card_data else "pending" + if card_status == "done": + done_count += 1 + else: + pending_count += 1 + items.append( + { + "item_key": item_key, + "title": item_data.get("title", "Untitled"), + "creators": item_data.get("creators", []), + "year": item_data.get("year"), + "item_type": item_data.get("item_type", "unknown"), + "card_status": card_status, + } + ) + return {"project_id": project_id, "items": items, "pending_count": pending_count, "done_count": done_count} + + @app.delete("/api/projects/{project_id}/items/{item_key}") + def remove_project_item(project_id: str, item_key: str) -> dict[str, object]: + return project_service.remove_item(project_id, item_key) + + @app.post("/api/projects/{project_id}/writing/recommend-citations") + def recommend_citations(project_id: str, payload: WritingPromptRequest) -> dict[str, object]: + return writing_service.recommend_citations(project_id, payload.prompt) + + @app.post("/api/projects/{project_id}/writing/generate-plan") + def generate_plan(project_id: str, payload: WritingPlanRequest) -> dict[str, object]: + return writing_service.generate_plan(project_id, payload.prompt) + + return app + + +def _read_json(path: Path) -> dict[str, object]: + import json + + return json.loads(path.read_text(encoding="utf-8")) diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..bd0c644 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,222 @@ +import json +from pathlib import Path + +from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir +from zotero_kb.api import CreateProjectRequest, WritingPromptRequest, create_app +from zotero_kb.config import AppConfig + + +class FakeLlmClient: + def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: + title = str(source_bundle["title"]) + return { + "summary": f"Summary for {title}", + "core_claims": [f"{title} supports scoped retrieval."], + "methods": ["Method details."], + "evidence": ["Evidence details."], + "quotable_passages": [f"{title} supports scoped retrieval."], + "writing_hints": ["Use as support."], + "keywords": ["retrieval", "writing"], + } + + +def make_test_config(tmp_path: Path) -> AppConfig: + zotero_dir = tmp_path / "zotero" + zotero_dir.mkdir() + build_fixture_zotero_dir(zotero_dir) + bridge_file = tmp_path / "bridge.json" + bridge_file.write_text('{"selected_keys": ["PAPER0001"]}', encoding="utf-8") + return AppConfig( + workspace_dir=tmp_path / "workspace", + zotero_data_dir=zotero_dir, + bridge_file=bridge_file, + ) + + +def _route(app, path: str, method: str): + for route in app.routes: + if getattr(route, "path", None) == path and method in getattr(route, "methods", set()): + return route.endpoint + raise AssertionError(f"Route {method} {path} not found") + + +def test_create_project_endpoint(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + endpoint = _route(app, "/api/projects", "POST") + + response = endpoint( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + assert response["id"] == "thesis-ch2" + + +def test_import_selected_items_endpoint(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_selected = _route(app, "/api/projects/{project_id}/imports/selected-items", "POST") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + response = import_selected("thesis-ch2") + + assert response["imported_item_keys"] == ["PAPER0001"] + + +def test_recommend_citations_endpoint(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_selected = _route(app, "/api/projects/{project_id}/imports/selected-items", "POST") + recommend = _route(app, "/api/projects/{project_id}/writing/recommend-citations", "POST") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + import_selected("thesis-ch2") + + response = recommend( + "thesis-ch2", + WritingPromptRequest(prompt="support scoped retrieval during drafting"), + ) + + assert response["results"][0]["item_key"] == "PAPER0001" + + +def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + endpoint = _route(app, "/api/projects", "POST") + + endpoint( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="deepseek", + llm_model="deepseek-chat", + ) + ) + + project_payload = json.loads( + (tmp_path / "workspace" / "projects" / "thesis-ch2" / "project.json").read_text(encoding="utf-8") + ) + assert project_payload["llm"]["provider"] == "deepseek" + assert project_payload["llm"]["model"] == "deepseek-chat" + + +def test_search_library_items_endpoint(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + search_items = _route(app, "/api/zotero/items", "GET") + + payload = search_items("research", 10) + + assert payload["items"][0]["item_key"] == "PAPER0001" + + +def test_collection_tree_endpoint(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + endpoint = _route(app, "/api/zotero/collections/tree", "GET") + + payload = endpoint() + + assert payload["collections"][0]["collection_key"] == "COLL0001" + assert payload["collections"][0]["children"][0]["collection_key"] == "COLL0002" + + +def test_collection_items_endpoint_includes_descendants(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + endpoint = _route(app, "/api/zotero/collections/{collection_key}/items", "GET") + + payload = endpoint("COLL0001", True) + + assert payload["items"][0]["item_key"] == "PAPER0001" + assert payload["collection_key"] == "COLL0001" + + +def test_import_item_keys_endpoint_without_bridge(tmp_path: Path) -> None: + config = make_test_config(tmp_path) + config.bridge_file.unlink() + app = create_app(config, llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="deepseek", + llm_model="deepseek-chat", + ) + ) + + payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]}) + + assert payload["imported_item_keys"] == ["PAPER0001"] + + +def test_import_state_endpoint_shows_pending_when_no_card(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_state = _route(app, "/api/projects/{project_id}/import-state", "GET") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + # Manually write selected-items.json to simulate items added but no card generated + project_dir = tmp_path / "workspace" / "projects" / "thesis-ch2" + (project_dir / "selected-items.json").write_text('["PAPER0001"]', encoding="utf-8") + + payload = import_state("thesis-ch2") + + assert payload["project_id"] == "thesis-ch2" + assert len(payload["items"]) == 1 + assert payload["items"][0]["item_key"] == "PAPER0001" + assert payload["items"][0]["card_status"] == "pending" + assert payload["pending_count"] == 1 + assert payload["done_count"] == 0 + + +def test_import_state_endpoint_shows_done_when_card_exists(tmp_path: Path) -> None: + app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient()) + create_project = _route(app, "/api/projects", "POST") + import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST") + import_state = _route(app, "/api/projects/{project_id}/import-state", "GET") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]}) + + payload = import_state("thesis-ch2") + + assert payload["project_id"] == "thesis-ch2" + assert len(payload["items"]) == 1 + assert payload["items"][0]["item_key"] == "PAPER0001" + assert payload["items"][0]["card_status"] == "done" + assert payload["pending_count"] == 0 + assert payload["done_count"] == 1