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:
parent
cc1960e9a9
commit
63de030789
216
src/zotero_kb/api.py
Normal file
216
src/zotero_kb/api.py
Normal 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
222
tests/test_api.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user