feat: add POST /api/projects/{id}/cards/generate endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9ba1668a66
commit
4350f9b3a1
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user