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)
|
item_keys: list[str] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class GenerateCardsRequest(BaseModel):
|
||||||
|
item_keys: list[str] = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
def create_app(
|
def create_app(
|
||||||
config: AppConfig,
|
config: AppConfig,
|
||||||
*,
|
*,
|
||||||
@ -201,6 +205,70 @@ def create_app(
|
|||||||
)
|
)
|
||||||
return {"project_id": project_id, "items": items, "pending_count": pending_count, "done_count": done_count}
|
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}")
|
@app.delete("/api/projects/{project_id}/items/{item_key}")
|
||||||
def remove_project_item(project_id: str, item_key: str) -> dict[str, object]:
|
def remove_project_item(project_id: str, item_key: str) -> dict[str, object]:
|
||||||
return project_service.remove_item(project_id, item_key)
|
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 tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
|
||||||
from zotero_kb.api import CreateProjectRequest, WritingPromptRequest, create_app
|
from zotero_kb.api import CreateProjectRequest, WritingPromptRequest, create_app
|
||||||
|
from zotero_kb.api import GenerateCardsRequest
|
||||||
from zotero_kb.config import AppConfig
|
from zotero_kb.config import AppConfig
|
||||||
|
|
||||||
|
|
||||||
@ -261,3 +262,41 @@ def test_import_item_keys_does_not_generate_cards(tmp_path: Path) -> None:
|
|||||||
else:
|
else:
|
||||||
# File not existing means no cards were generated, which is expected
|
# File not existing means no cards were generated, which is expected
|
||||||
pass
|
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