From 4350f9b3a13f736c5169386937179290490b6057 Mon Sep 17 00:00:00 2001 From: Saberlve Date: Wed, 15 Apr 2026 20:08:14 +0800 Subject: [PATCH] feat: add POST /api/projects/{id}/cards/generate endpoint Co-Authored-By: Claude Opus 4.6 --- src/zotero_kb/api.py | 68 ++++++++++++++++++++++++++++++++++++++++++++ tests/test_api.py | 39 +++++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/src/zotero_kb/api.py b/src/zotero_kb/api.py index c95bdc2..3999e1c 100644 --- a/src/zotero_kb/api.py +++ b/src/zotero_kb/api.py @@ -35,6 +35,10 @@ class ImportItemKeysRequest(BaseModel): item_keys: list[str] = Field(min_length=1) +class GenerateCardsRequest(BaseModel): + item_keys: list[str] = Field(min_length=1) + + def create_app( config: AppConfig, *, @@ -201,6 +205,70 @@ def create_app( ) return {"project_id": project_id, "items": items, "pending_count": pending_count, "done_count": done_count} + @app.post("/api/projects/{project_id}/cards/generate") + def generate_cards(project_id: str, payload: GenerateCardsRequest | 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) + + items_index_path = config.workspace_dir / "library" / "index" / "items.json" + items_index = project_service._read_json(items_index_path) + cards_index = project_service._read_json(config.workspace_dir / "library" / "index" / "cards.json") + + generated: list[str] = [] + failed: list[str] = [] + response_items: list[dict[str, object]] = [] + + for item in items: + item_key = item.item_key + item_data = items_index.get(item_key, {}) + try: + builder.build_or_update(item) + generated.append(item_key) + card_status = "done" + except Exception: + failed.append(item_key) + card_status = "failed" + + response_items.append( + { + "item_key": item_key, + "title": item_data.get("title", item.title if hasattr(item, 'title') else "Untitled"), + "creators": item_data.get("creators", item.creators if hasattr(item, 'creators') else []), + "year": item_data.get("year", item.year if hasattr(item, 'year') else None), + "item_type": item_data.get("item_type", item.item_type if hasattr(item, 'item_type') else "unknown"), + "card_status": card_status, + } + ) + + if generated: + project_service.add_items(project_id, generated) + + return { + "project_id": project_id, + "generated": generated, + "failed": failed, + "items": response_items, + } + @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) diff --git a/tests/test_api.py b/tests/test_api.py index 7acede6..a0b2c8e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -3,6 +3,7 @@ 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.api import GenerateCardsRequest from zotero_kb.config import AppConfig @@ -261,3 +262,41 @@ def test_import_item_keys_does_not_generate_cards(tmp_path: Path) -> None: else: # File not existing means no cards were generated, which is expected pass + + +def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None: + """cards/generate endpoint should generate cards for items that have pending status.""" + 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") + import_state = _route(app, "/api/projects/{project_id}/import-state", "GET") + generate_cards = _route(app, "/api/projects/{project_id}/cards/generate", "POST") + + create_project( + CreateProjectRequest( + project_id="thesis-ch2", + name="Thesis Chapter 2", + llm_provider="openai", + llm_model="gpt-5-mini", + ) + ) + + # Import item keys first (writes to items.json but doesn't generate cards) + import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]}) + + # Verify card_status is pending before generation + state_before = import_state("thesis-ch2") + assert state_before["items"][0]["card_status"] == "pending" + + # Call generate endpoint + response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"])) + + # Verify response structure + assert response["project_id"] == "thesis-ch2" + assert response["generated"] == ["PAPER0001"] + assert response["failed"] == [] + assert len(response["items"]) == 1 + assert response["items"][0]["item_key"] == "PAPER0001" + assert response["items"][0]["card_status"] == "done"