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 <noreply@anthropic.com>
This commit is contained in:
Saberlve 2026-04-15 18:44:43 +08:00
parent cc1960e9a9
commit 63de030789
2 changed files with 438 additions and 0 deletions

216
src/zotero_kb/api.py Normal file
View File

@ -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"))

222
tests/test_api.py Normal file
View File

@ -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