- Introduced `card_language` attribute in ProjectRecord and Workspace classes to handle multiple languages for project cards. - Updated project creation and renaming methods to accept and store the card language. - Enhanced ZoteroReader to read and return attachment metadata, including language-specific summaries and claims. - Modified LLM client to generate card content based on the specified language, supporting both English and Chinese. - Updated tests to cover new functionality, ensuring correct handling of multilingual card generation and retrieval. - Adjusted UI tests to verify the presence of language selection options and proper rendering of multilingual content.
441 lines
16 KiB
Python
441 lines
16 KiB
Python
import json
|
|
from pathlib import Path
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from tests.fixtures.build_zotero_fixture import build_fixture_zotero_dir
|
|
from zotero_kb.api import CreateProjectRequest, RenameProjectRequest, WritingPromptRequest, create_app
|
|
from zotero_kb.api import GenerateCardsRequest
|
|
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"])
|
|
language = str(source_bundle.get("card_language", "en"))
|
|
return {
|
|
"summary": f"中文摘要 {title}" if language == "zh" else f"Summary for {title}",
|
|
"core_claims": [f"中文论点 {title}"] if language == "zh" else [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",
|
|
card_language="zh",
|
|
)
|
|
)
|
|
|
|
assert response["id"] == "thesis-ch2"
|
|
|
|
|
|
def test_rename_project_endpoint_updates_project_name(tmp_path: Path) -> None:
|
|
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
|
create_project = _route(app, "/api/projects", "POST")
|
|
rename_project = _route(app, "/api/projects/{project_id}", "PATCH")
|
|
list_projects = _route(app, "/api/projects", "GET")
|
|
|
|
create_project(
|
|
CreateProjectRequest(
|
|
project_id="thesis-ch2",
|
|
name="Old Name",
|
|
llm_provider="openai",
|
|
llm_model="gpt-5-mini",
|
|
card_language="en",
|
|
)
|
|
)
|
|
|
|
response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name", card_language="zh"))
|
|
|
|
assert response["id"] == "thesis-ch2"
|
|
assert response["name"] == "New Name"
|
|
assert list_projects()[0]["name"] == "New Name"
|
|
assert list_projects()[0]["card_language"] == "zh"
|
|
|
|
|
|
def test_delete_project_endpoint_removes_project_from_list(tmp_path: Path) -> None:
|
|
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
|
create_project = _route(app, "/api/projects", "POST")
|
|
delete_project = _route(app, "/api/projects/{project_id}", "DELETE")
|
|
list_projects = _route(app, "/api/projects", "GET")
|
|
|
|
create_project(
|
|
CreateProjectRequest(
|
|
project_id="thesis-ch2",
|
|
name="Thesis Chapter 2",
|
|
llm_provider="openai",
|
|
llm_model="gpt-5-mini",
|
|
)
|
|
)
|
|
|
|
response = delete_project("thesis-ch2")
|
|
|
|
assert response["deleted"] == "thesis-ch2"
|
|
assert list_projects() == []
|
|
|
|
|
|
def test_delete_project_post_fallback_endpoint_removes_project_from_list(tmp_path: Path) -> None:
|
|
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
|
client = TestClient(app)
|
|
|
|
create_response = client.post(
|
|
"/api/projects",
|
|
json={
|
|
"project_id": "thesis-ch2",
|
|
"name": "Thesis Chapter 2",
|
|
"llm_provider": "openai",
|
|
"llm_model": "gpt-5-mini",
|
|
"card_language": "zh",
|
|
},
|
|
)
|
|
assert create_response.status_code == 201
|
|
|
|
delete_response = client.post("/api/projects/thesis-ch2/delete")
|
|
|
|
assert delete_response.status_code == 200
|
|
assert delete_response.json() == {"deleted": "thesis-ch2"}
|
|
assert client.get("/api/projects").json() == []
|
|
|
|
|
|
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",
|
|
card_language="zh",
|
|
)
|
|
)
|
|
|
|
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",
|
|
card_language="zh",
|
|
)
|
|
)
|
|
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",
|
|
card_language="en",
|
|
)
|
|
)
|
|
|
|
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"
|
|
assert project_payload["card_language"] == "en"
|
|
|
|
|
|
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_item_attachments_endpoint_returns_attachment_metadata(tmp_path: Path) -> None:
|
|
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
|
endpoint = _route(app, "/api/zotero/items/{item_key}/attachments", "GET")
|
|
|
|
payload = endpoint("PAPER0001")
|
|
|
|
assert payload["item_key"] == "PAPER0001"
|
|
assert payload["attachments"][0]["filename"] == "paper.txt"
|
|
assert payload["attachments"][0]["content_type"] == "text/plain"
|
|
assert payload["attachments"][0]["is_pdf"] is False
|
|
|
|
|
|
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_pending_after_import(tmp_path: Path) -> None:
|
|
"""After import_item_keys, card_status should be pending (cards generated separately)."""
|
|
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"] == "pending"
|
|
assert payload["pending_count"] == 1
|
|
assert payload["done_count"] == 0
|
|
|
|
|
|
def test_import_item_keys_does_not_generate_cards(tmp_path: Path) -> None:
|
|
"""import_item_keys should only register items, not generate cards."""
|
|
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="openai",
|
|
llm_model="gpt-5-mini",
|
|
)
|
|
)
|
|
|
|
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
|
|
|
# Verify item was added to selected-items.json
|
|
selected_items_path = tmp_path / "workspace" / "projects" / "thesis-ch2" / "selected-items.json"
|
|
selected_items = json.loads(selected_items_path.read_text(encoding="utf-8"))
|
|
assert "PAPER0001" in selected_items
|
|
|
|
# Verify item metadata was written to items.json
|
|
items_index_path = tmp_path / "workspace" / "library" / "index" / "items.json"
|
|
items_index = json.loads(items_index_path.read_text(encoding="utf-8"))
|
|
assert "PAPER0001" in items_index
|
|
assert items_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing"
|
|
|
|
# Verify NO card was generated (cards.json should not have PAPER0001)
|
|
cards_index_path = tmp_path / "workspace" / "library" / "index" / "cards.json"
|
|
if cards_index_path.exists():
|
|
cards_index = json.loads(cards_index_path.read_text(encoding="utf-8"))
|
|
assert "PAPER0001" not in cards_index
|
|
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"
|
|
|
|
|
|
def test_cards_generate_allows_regeneration_for_done_items(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")
|
|
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_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
|
first_response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
|
|
second_response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
|
|
|
|
assert first_response["generated"] == ["PAPER0001"]
|
|
assert second_response["generated"] == ["PAPER0001"]
|
|
assert second_response["failed"] == []
|
|
assert second_response["items"][0]["card_status"] == "done"
|
|
|
|
|
|
def test_import_item_keys_endpoint_returns_pending_item_in_project_view(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="openai",
|
|
llm_model="gpt-5-mini",
|
|
)
|
|
)
|
|
|
|
payload = import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
|
|
|
assert payload["project_view"]["selected_items"] == ["PAPER0001"]
|
|
assert payload["project_view"]["items"][0]["item_key"] == "PAPER0001"
|
|
assert payload["project_view"]["items"][0]["card_status"] == "pending"
|