diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..e2556bb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,130 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Zotero KB is a local FastAPI web service that reads from a Zotero SQLite database and generates structured Markdown knowledge cards for academic writing workflows. It provides project management, collection browsing, batch card generation via LLM (DeepSeek), and citation recommendation APIs. + +## Development Commands + +Package manager is `uv`. All commands run through `uv run`. + +```bash +# Install dependencies +UV_CACHE_DIR=/tmp/uv-cache uv sync --extra dev + +# Run the server +UV_CACHE_DIR=/tmp/uv-cache uv run python main.py +# Serves on http://127.0.0.1:8000 + +# Run all tests +UV_CACHE_DIR=/tmp/uv-cache uv run pytest -q + +# Run a single test file +UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py -q + +# Run a single test +UV_CACHE_DIR=/tmp/uv-cache uv run pytest tests/test_api.py::test_create_project_endpoint -q +``` + +## Environment Variables + +| Variable | Default | Purpose | +|----------|---------|---------| +| `ZOTERO_DATA_DIR` | `/mnt/c/Users/WSX/Zotero` | Path to Zotero data directory (must contain `zotero.sqlite`) | +| `ZOTERO_KB_WORKSPACE` | `workspace` | Workspace directory for projects, indexes, and cards | +| `ZOTERO_BRIDGE_FILE` | `workspace/bridge/selected-items.json` | Bridge file for Zotero-to-project item import | +| `DEEPSEEK_API_KEY` | — | Required when projects use `llm_provider=deepseek` | + +## Architecture + +### Layer Overview + +`api.py` → FastAPI routes → `Workspace` / `ProjectService` / `WritingService` / `ZoteroReader` / `CardBuilder` + +All business logic lives in `src/zotero_kb/`. There is no database migration system; the app creates directories on demand. + +### ZoteroReader (`zotero_reader.py`) + +Reads directly from `zotero.sqlite` via `sqlite3`. Key capabilities: +- `read_items(item_keys)` — full record with metadata, creators, tags, notes, attachment texts +- `get_collection_tree()` / `get_collection_items(collection_key)` — browse collections hierarchically +- `search_items(query)` — title/abstract substring search +- PDF text extraction delegates to `pdftotext` CLI; other attachments read as plain text +- Attachment paths resolve from `storage:` relative to `ZOTERO_DATA_DIR/storage/` + +### Workspace & Project Model (`workspace.py`, `projects.py`) + +A **project** is a JSON file plus two companion files under `workspace/projects//`: +- `project.json` — metadata including `llm.provider`, `llm.model`, `card_language` +- `selected-items.json` — ordered list of Zotero item keys belonging to the project +- `project-index.json` — cached denormalized view rebuilt on every read + +`Workspace` creates/renames/deletes projects. `ProjectService` manages item selection and rebuilds `project-index.json` by reading three shared indexes: +- `workspace/library/index/items.json` — item metadata +- `workspace/library/index/cards.json` — generated card data (supports per-language variants) +- `workspace/library/index/collections.json` — collection metadata + +### Card Generation (`cards.py`, `llm.py`) + +`CardBuilder.build_or_update(item, language)`: +1. Builds a **source bundle** from the item record (metadata + notes + attachment texts) +2. Hashes and caches the bundle to `library/cache/source-bundles/` +3. Calls `llm_client.generate_card(source_bundle)` +4. Renders a Markdown file to `library/collections// [<key>][<lang>].md` +5. Updates `items.json`, `cards.json`, and `collections.json` + +The LLM layer supports two providers: +- `deepseek` — calls DeepSeek Chat API, requires `DEEPSEEK_API_KEY` +- `deterministic` (fallback) — derives card fields from abstract/notes without any API call + +Cards support **language variants**: `cards.json` stores a map `item_key -> {language -> card_data}`. The `project-index.json` selects the variant matching the project's `card_language`. + +### Writing Support (`writing.py`) + +- `recommend_citations(project_id, prompt)` — simple term-overlap scoring across project cards +- `generate_plan(project_id, prompt)` — returns a single-section plan using the top-scored card + +### Bridge Import (`bridge.py`) + +The `ZOTERO_BRIDGE_FILE` (JSON with `selected_keys` array) is read by the `/api/projects/{id}/imports/selected-items` endpoint to import items that were pre-selected in Zotero. This is separate from the UI-driven `/imports/item-keys` endpoint. + +### Frontend + +A single `index.html` (Jinja2 template rendered as static HTML) provides the full UI. It uses vanilla JS to call the REST API. No build step or JS bundler. + +## Testing + +Tests use `fastapi.testclient.TestClient` with a `FakeLlmClient` injected via `create_app(config, llm_client=...)`. The fixture builder (`tests/fixtures/build_zotero_fixture.py`) creates an in-memory Zotero SQLite schema with sample data. Most tests extract route handlers directly from the FastAPI app rather than using HTTP-level client calls. + +## Workspace Directory Layout + +``` +workspace/ + bridge/ + selected-items.json # bridge file from Zotero + library/ + index/ + items.json # item metadata index + cards.json # card data index (language variants) + collections.json # collection metadata index + collections/ + <collection_path>/ + <title> [<key>][<lang>].md # generated Markdown cards + cache/ + source-bundles/ + <key>.<lang>.json # cached source bundles + projects/ + <project_id>/ + project.json # project config + selected-items.json # item keys in this project + project-index.json # denormalized project view +``` + +## Related Subdirectories + +- `zotcard/` — Zotero plugin (separate JS project, not part of the Python service) +- `zotero-rag/` — Standalone RAG search service (separate project) +- `zotero-bridge/` — Zotero plugin bridge component +- `skills/zotero-citation-planner/` — Claude skill for reading project indexes and generating citation plans diff --git a/skills/zotero-citation-planner/SKILL.md b/skills/zotero-citation-planner/SKILL.md deleted file mode 100644 index 560aa66..0000000 --- a/skills/zotero-citation-planner/SKILL.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -name: zotero-citation-planner -description: Read one Zotero KB project and generate a first-pass citation plan from that project only. ---- - -# Zotero Citation Planner - -Use this skill when the user has a writing intent and needs a project-bounded citation plan. - -## Workflow - -1. Read `projects/<project-id>/project.json` -2. Read `projects/<project-id>/project-index.json` -3. Open only the cards referenced by that project -4. Build a paragraph or section plan using those cards only -5. Return structure, citation order, and citation roles - -## Output - -- section heading -- section goal -- recommended citations -- role of each citation -- notes about how to sequence the paragraph - diff --git a/skills/zotero-citation-recommender/SKILL.md b/skills/zotero-citation-recommender/SKILL.md deleted file mode 100644 index 61c1713..0000000 --- a/skills/zotero-citation-recommender/SKILL.md +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: zotero-citation-recommender -description: Read one Zotero KB project and recommend relevant citations from that project only. ---- - -# Zotero Citation Recommender - -Use this skill when you need citations from a specific Zotero KB project. - -## Workflow - -1. Read `projects/<project-id>/project.json` -2. Read `projects/<project-id>/project-index.json` -3. Open only the card files referenced by that project index -4. Recommend citations from those cards only -5. Do not read unrelated projects unless the user asks - -## Output - -- item key -- title -- why it is relevant -- supporting claims -- quotable passages -- suggested rhetorical role - diff --git a/skills/zotero-cite/SKILL.md b/skills/zotero-cite/SKILL.md new file mode 100644 index 0000000..b5918ce --- /dev/null +++ b/skills/zotero-cite/SKILL.md @@ -0,0 +1,53 @@ +--- +name: zotero-cite +description: > + 从指定的 Zotero KB 项目中检索知识卡片,为论文写作推荐最合适的引用文献。 + 只能访问用户明确指定的项目,严禁读取其他项目。 +--- + +# Zotero Citation Skill + +Use this skill when the user needs citations from a specific Zotero KB project while writing. + +## Constraints + +- **ONLY** read the project explicitly specified by the user. +- NEVER guess a project_id. If the user does not provide one, ask for it. +- NEVER read other projects' indexes unless the user explicitly asks. + +## Workflow + +1. **Get project_id** from the user if not provided. +2. **Read** `workspace/projects/{project_id}/project.json` to confirm the project exists and get its name. +3. **Read** `workspace/projects/{project_id}/project-index.json` to get all cards in the project. +4. **Build context** from the cards: + - title, summary, claims, citations, quotable_spans, attachments + - creator names and publication year (from `items` array) +5. **Analyze the user's writing context** (the sentence/paragraph/section they are working on). +6. **Recommend 1–5 most relevant citations** from the project's cards only. +7. **Format the response** in Chinese (matching the card language if it is zh). + +## Output Format + +For each recommended citation, provide: + +``` +### 推荐引用 {n} +- **文献标题**: {title} +- **作者**: {creators} +- **年份**: {year} +- **item_key**: {item_key}(用于插入引用标记) +- **相关性说明**: {why this card matches the writing context} +- **可用论点**: {1–2 key claims from the card} +- **可直接引用的原文**: {quotable_spans if any} +- **建议的修辞角色**: {supporting evidence / counterpoint / background / methodological reference / etc.} +- **引用建议**: {a concrete suggestion for how to weave this citation into the user's text} +``` + +If no relevant card is found, state clearly that the current project does not contain matching literature, and suggest either: +- broadening the writing topic, or +- importing more items into the project. + +## Cardinal Rule + +> The user has curated this project for a reason. Do NOT suggest citations from outside the project index, even if you know the literature personally. diff --git a/src/zotero_kb/api.py b/src/zotero_kb/api.py index a2805b5..908aa62 100644 --- a/src/zotero_kb/api.py +++ b/src/zotero_kb/api.py @@ -21,10 +21,12 @@ class CreateProjectRequest(BaseModel): name: str = Field(min_length=1) llm_provider: str = "openai" llm_model: str = "gpt-5-mini" + card_language: str = "zh" class RenameProjectRequest(BaseModel): name: str = Field(min_length=1) + card_language: str = "zh" class WritingPromptRequest(BaseModel): @@ -65,6 +67,7 @@ def create_app( name=payload.name, llm_provider=payload.llm_provider, llm_model=payload.llm_model, + card_language=payload.card_language, ) return {"id": project.project_id, "name": project.name} @@ -83,7 +86,7 @@ def create_app( @app.patch("/api/projects/{project_id}") def rename_project(project_id: str, payload: RenameProjectRequest) -> dict[str, object]: try: - project = workspace.rename_project(project_id, payload.name) + project = workspace.rename_project(project_id, payload.name, payload.card_language) except FileNotFoundError as exc: raise HTTPException(status_code=404, detail="Project not found") from exc return {"id": project.project_id, "name": project.name} @@ -115,6 +118,13 @@ def create_app( def search_zotero_items(query: str = "", limit: int = 20) -> dict[str, object]: return {"items": reader.search_items(query, limit=limit)} + @app.get("/api/zotero/items/{item_key}/attachments") + def zotero_item_attachments(item_key: str) -> dict[str, object]: + attachments = reader.get_item_attachments(item_key) + if not attachments: + raise HTTPException(status_code=404, detail="Item not found") + return {"item_key": item_key, "attachments": attachments} + @app.get("/api/projects/{project_id}") def get_project(project_id: str) -> dict[str, object]: project_dir = config.workspace_dir / "projects" / project_id @@ -145,9 +155,10 @@ def create_app( builder = CardBuilder(config.workspace_dir, resolved_client) selected_keys = read_selected_keys(config.bridge_file) items = reader.read_items(selected_keys) + card_language = str(project_payload.get("card_language", "en")) imported_keys = [] for item in items: - builder.build_or_update(item) + builder.build_or_update(item, card_language) 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} @@ -252,6 +263,7 @@ def create_app( builder = CardBuilder(config.workspace_dir, resolved_client) items = reader.read_items(item_keys) + card_language = str(project_payload.get("card_language", "en")) items_index_path = config.workspace_dir / "library" / "index" / "items.json" items_index = project_service._read_json(items_index_path) @@ -265,7 +277,7 @@ def create_app( item_key = item.item_key item_data = items_index.get(item_key, {}) try: - builder.build_or_update(item) + builder.build_or_update(item, card_language) generated.append(item_key) card_status = "done" except Exception: diff --git a/src/zotero_kb/cards.py b/src/zotero_kb/cards.py index b7a8e95..0945c46 100644 --- a/src/zotero_kb/cards.py +++ b/src/zotero_kb/cards.py @@ -5,6 +5,7 @@ import json import re from dataclasses import dataclass from pathlib import Path +from typing import Any from .llm import CardGenerationClient from .zotero_reader import ZoteroItemRecord @@ -15,6 +16,7 @@ class CardBuildResult: item_key: str card_path: Path source_hash: str + language: str class CardBuilder: @@ -28,26 +30,27 @@ class CardBuilder: for path in (self.collections_dir, self.index_dir, self.bundle_dir): path.mkdir(parents=True, exist_ok=True) - def build_or_update(self, item: ZoteroItemRecord) -> CardBuildResult: - source_bundle = self._build_source_bundle(item) + def build_or_update(self, item: ZoteroItemRecord, language: str = "en") -> CardBuildResult: + source_bundle = self._build_source_bundle(item, language) source_hash = self._hash_payload(source_bundle) source_bundle["source_hash"] = source_hash - bundle_path = self.bundle_dir / f"{item.item_key}.json" + bundle_path = self.bundle_dir / f"{item.item_key}.{language}.json" self._write_json(bundle_path, source_bundle) card_data = self.llm_client.generate_card(source_bundle) - card_path = self._card_path(item) + card_path = self._card_path(item, language) card_path.parent.mkdir(parents=True, exist_ok=True) card_path.write_text(self._render_card(item, source_hash, card_data), encoding="utf-8") - self._update_indexes(item, card_path, source_hash, card_data) - return CardBuildResult(item_key=item.item_key, card_path=card_path, source_hash=source_hash) + self._update_indexes(item, card_path, source_hash, card_data, language) + return CardBuildResult(item_key=item.item_key, card_path=card_path, source_hash=source_hash, language=language) - def _build_source_bundle(self, item: ZoteroItemRecord) -> dict[str, object]: + def _build_source_bundle(self, item: ZoteroItemRecord, language: str) -> dict[str, object]: return { "item_key": item.item_key, "title": item.title, + "card_language": language, "creators": item.creators, "year": item.year, "item_type": item.item_type, @@ -55,6 +58,7 @@ class CardBuilder: "tags": item.tags, "collection_paths": item.collection_paths, "notes": item.notes, + "attachments": item.attachments, "attachment_texts": item.attachment_texts, } @@ -63,9 +67,9 @@ class CardBuilder: raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") return f"sha256:{hashlib.sha256(raw).hexdigest()}" - def _card_path(self, item: ZoteroItemRecord) -> Path: + def _card_path(self, item: ZoteroItemRecord, language: str) -> Path: collection_path = item.collection_paths[0] if item.collection_paths else ["Unsorted"] - filename = f"{self._sanitize_filename(item.title)} [{item.item_key}].md" + filename = f"{self._sanitize_filename(item.title)} [{item.item_key}][{language}].md" return self.collections_dir.joinpath(*collection_path, filename) @staticmethod @@ -101,6 +105,8 @@ class CardBuilder: ("Core Claims", [str(value) for value in card_data.get("core_claims", [])]), ("Methods", [str(value) for value in card_data.get("methods", [])]), ("Evidence", [str(value) for value in card_data.get("evidence", [])]), + ("Source Files", self._render_attachments(item.attachments)), + ("Citations", self._render_citations([value for value in card_data.get("citations", []) if isinstance(value, dict)])), ("Quotable Passages", [str(value) for value in card_data.get("quotable_passages", [])]), ("Writing Hints", [str(value) for value in card_data.get("writing_hints", [])]), ] @@ -115,12 +121,50 @@ class CardBuilder: lines.append("") return "\n".join(lines).strip() + "\n" + @staticmethod + def _normalize_citations(raw_citations: list[dict[str, Any]]) -> list[dict[str, object]]: + citations: list[dict[str, object]] = [] + for citation in raw_citations: + citations.append( + { + "claim": str(citation.get("claim", "")), + "quote": str(citation.get("quote", "")), + "paraphrase": str(citation.get("paraphrase", "")), + "quote_source": str(citation.get("quote_source", "")), + "use_case": str(citation.get("use_case", "")), + } + ) + return citations + + @classmethod + def _render_attachments(cls, raw_attachments: list[dict[str, Any]]) -> list[str]: + lines: list[str] = [] + for attachment in raw_attachments: + lines.append(f"- filename: {attachment.get('filename', '')}") + lines.append(f" path: {attachment.get('path', '')}") + lines.append(f" content_type: {attachment.get('content_type', '')}") + lines.append(f" is_pdf: {attachment.get('is_pdf', False)}") + return lines + + @classmethod + def _render_citations(cls, raw_citations: list[dict[str, Any]]) -> list[str]: + citations = cls._normalize_citations(raw_citations) + lines: list[str] = [] + for citation in citations: + lines.append(f"- claim: {citation['claim']}") + lines.append(f" quote: {citation['quote']}") + lines.append(f" paraphrase: {citation['paraphrase']}") + lines.append(f" quote_source: {citation['quote_source']}") + lines.append(f" use_case: {citation['use_case']}") + return lines + def _update_indexes( self, item: ZoteroItemRecord, card_path: Path, source_hash: str, card_data: dict[str, object], + language: str, ) -> None: items_index = self._read_json(self.index_dir / "items.json") cards_index = self._read_json(self.index_dir / "cards.json") @@ -137,18 +181,36 @@ class CardBuilder: "collection_paths": item.collection_paths, "attachment_status": "ok" if item.attachment_texts else "missing", "card_path": str(card_path), + "card_paths": { + **{ + str(key): str(value) + for key, value in dict(items_index.get(item.item_key, {}).get("card_paths", {})).items() + }, + language: str(card_path), + }, "source_hash": source_hash, } - cards_index[item.item_key] = { + existing_variants = cards_index.get(item.item_key, {}) + if not isinstance(existing_variants, dict) or any( + key in existing_variants for key in ("summary", "claims", "citations", "attachments") + ): + existing_variants = {"en": existing_variants} if existing_variants else {} + existing_variants[language] = { "item_key": item.item_key, + "language": language, "card_path": str(card_path), "title": item.title, "summary": str(card_data.get("summary", "")), + "attachments": item.attachments, "keywords": [str(value) for value in card_data.get("keywords", [])], "claims": [str(value) for value in card_data.get("core_claims", [])], + "citations": self._normalize_citations( + [value for value in card_data.get("citations", []) if isinstance(value, dict)] + ), "quotable_spans": [str(value) for value in card_data.get("quotable_passages", [])], "writing_hints": [str(value) for value in card_data.get("writing_hints", [])], } + cards_index[item.item_key] = existing_variants for path_parts in item.collection_paths or [["Unsorted"]]: collection_key = "/".join(path_parts) diff --git a/src/zotero_kb/llm.py b/src/zotero_kb/llm.py index de91b04..953ae13 100644 --- a/src/zotero_kb/llm.py +++ b/src/zotero_kb/llm.py @@ -17,6 +17,7 @@ class DeterministicCardGenerationClient: def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: abstract = str(source_bundle.get("abstract", "")).strip() + language = str(source_bundle.get("card_language", "en")) notes = [str(value).strip() for value in source_bundle.get("notes", []) if str(value).strip()] attachment_texts = [ str(value).strip() for value in source_bundle.get("attachment_texts", []) if str(value).strip() @@ -24,13 +25,24 @@ class DeterministicCardGenerationClient: summary = abstract or (notes[0] if notes else "") or (attachment_texts[0][:240] if attachment_texts else "") claims = (notes[:3] or [summary]) if summary else [] return { - "summary": summary, - "core_claims": claims, + "summary": summary if language == "en" else f"中文:{summary}", + "core_claims": claims if language == "en" else [f"中文:{claim}" for claim in claims], "methods": [], "evidence": attachment_texts[:3], + "citations": [ + { + "claim": claims[0] if claims else summary, + "quote": attachment_texts[0][:240], + "paraphrase": summary if language == "en" else f"中文:{summary}", + "quote_source": "attachment_texts", + "use_case": "direct_quote", + } + ] if attachment_texts else [], "quotable_passages": attachment_texts[:2], "writing_hints": [ - "Use this card when the writing intent overlaps with its summary or notes.", + "Use this card when the writing intent overlaps with its summary or notes." + if language == "en" + else "当写作意图与摘要或笔记相关时使用这张卡片。", ], "keywords": [str(value) for value in source_bundle.get("tags", [])], } @@ -54,6 +66,12 @@ class DeepSeekCardGenerationClient: self.request_fn = request_fn or self._default_request def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: + language = str(source_bundle.get("card_language", "en")) + language_instruction = ( + "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in Chinese." + if language == "zh" + else "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in English." + ) payload = { "model": self.model, "response_format": {"type": "json_object"}, @@ -61,14 +79,32 @@ class DeepSeekCardGenerationClient: { "role": "system", "content": ( - "You convert research source bundles into structured JSON cards. " - "Return a JSON object with keys: summary, core_claims, methods, evidence, " - "quotable_passages, writing_hints, keywords." + "You convert research source bundles into structured evidence cards for academic writing. " + "Never hallucinate. Use only information explicitly supported by the source bundle. " + "Preserve uncertainty and hedging. Return valid JSON only with keys: " + "summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords." ), }, { "role": "user", - "content": json.dumps(source_bundle, ensure_ascii=False), + "content": ( + "Given this source bundle, return exactly one JSON object with keys: " + "summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords.\n\n" + "Rules:\n" + "1. summary: 2-4 factual sentences, no hype.\n" + "2. core_claims: concrete findings only; preserve numbers and hedging.\n" + "3. methods: brief method or setup details actually stated in the source.\n" + "4. evidence: empirical or textual support from the source, numbers preferred.\n" + "5. citations: an array of objects with keys claim, quote, paraphrase, quote_source, use_case.\n" + "6. citations.quote must be verbatim text from notes or attachment_texts only.\n" + "7. Return 1-3 citations whenever the source bundle contains usable supporting text in notes or attachment_texts.\n" + "8. quote_source must identify where the quote came from, such as attachment_texts or notes.\n" + "9. use_case must be one of direct_quote, paraphrase, or background.\n" + "10. quotable_passages: short verbatim excerpts from notes or attachment_texts only.\n" + "11. Use empty arrays only when the source bundle truly contains no usable supporting quote; do not fabricate.\n" + f"12. {language_instruction}\n\n" + f"Source bundle:\n{json.dumps(source_bundle, ensure_ascii=False)}" + ), }, ], } @@ -84,6 +120,17 @@ class DeepSeekCardGenerationClient: "core_claims": [str(value) for value in parsed.get("core_claims", [])], "methods": [str(value) for value in parsed.get("methods", [])], "evidence": [str(value) for value in parsed.get("evidence", [])], + "citations": [ + { + "claim": str(value.get("claim", "")), + "quote": str(value.get("quote", "")), + "paraphrase": str(value.get("paraphrase", "")), + "quote_source": str(value.get("quote_source", "")), + "use_case": str(value.get("use_case", "")), + } + for value in parsed.get("citations", []) + if isinstance(value, dict) + ], "quotable_passages": [str(value) for value in parsed.get("quotable_passages", [])], "writing_hints": [str(value) for value in parsed.get("writing_hints", [])], "keywords": [str(value) for value in parsed.get("keywords", [])], diff --git a/src/zotero_kb/projects.py b/src/zotero_kb/projects.py index 4d51899..6ddd117 100644 --- a/src/zotero_kb/projects.py +++ b/src/zotero_kb/projects.py @@ -22,21 +22,21 @@ class ProjectService: return self._rebuild_project_index(project_id, selected_items) def get_project_view(self, project_id: str) -> dict[str, object]: - index_path = self._project_dir(project_id) / "project-index.json" - if not index_path.exists(): - selected_items = self._read_selected_items(project_id) - return self._rebuild_project_index(project_id, selected_items) - return json.loads(index_path.read_text(encoding="utf-8")) + selected_items = self._read_selected_items(project_id) + return self._rebuild_project_index(project_id, selected_items) def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]: cards_index = self._read_json(self.index_dir / "cards.json") items_index = self._read_json(self.index_dir / "items.json") collections_index = self._read_json(self.index_dir / "collections.json") + project_payload = self._read_json(self._project_dir(project_id) / "project.json") + card_language = str(project_payload.get("card_language", "en")) project_items: list[dict[str, object]] = [] project_cards: list[dict[str, object]] = [] for item_key in selected_items: item_data = items_index.get(item_key, {}) - card_data = cards_index.get(item_key) + card_variants = self._normalize_card_variants(cards_index.get(item_key)) + card_data = card_variants.get(card_language) item_payload = { "item_key": item_key, "title": item_data.get("title", "Untitled"), @@ -45,7 +45,9 @@ class ProjectService: "item_type": item_data.get("item_type", "unknown"), "card_status": "done" if card_data else "pending", "summary": card_data.get("summary") if card_data else None, + "attachments": card_data.get("attachments", []) if card_data else [], "claims": card_data.get("claims", []) if card_data else [], + "citations": card_data.get("citations", []) if card_data else [], "quotable_spans": card_data.get("quotable_spans", []) if card_data else [], } project_items.append(item_payload) @@ -58,6 +60,7 @@ class ProjectService: ] payload = { "project_id": project_id, + "card_language": card_language, "selected_items": selected_items, "items": project_items, "cards": project_cards, @@ -75,6 +78,22 @@ class ProjectService: def _project_dir(self, project_id: str) -> Path: return self.projects_dir / project_id + @staticmethod + def _normalize_card_variants(card_entry: object) -> dict[str, dict[str, object]]: + if not isinstance(card_entry, dict): + return {} + if any(key in card_entry for key in ("summary", "claims", "citations", "attachments")): + legacy_card = dict(card_entry) + legacy_card.setdefault("language", "en") + return {"en": legacy_card} + variants: dict[str, dict[str, object]] = {} + for language, payload in card_entry.items(): + if isinstance(payload, dict): + variant = dict(payload) + variant.setdefault("language", str(language)) + variants[str(language)] = variant + return variants + @staticmethod def _read_json(path: Path) -> dict[str, object]: if not path.exists(): diff --git a/src/zotero_kb/templates/index.html b/src/zotero_kb/templates/index.html index e38be62..86507d3 100644 --- a/src/zotero_kb/templates/index.html +++ b/src/zotero_kb/templates/index.html @@ -16,6 +16,9 @@ --accent-strong: #1e4d33; --surface: #fffdf8; --danger: #8b3a2a; + --left-panel-width: 20rem; + --right-panel-width: 26rem; + --panel-resizer-width: 0.5rem; } * { box-sizing: border-box; @@ -33,17 +36,55 @@ } .layout { display: grid; - grid-template-columns: 20rem 1fr 26rem; + grid-template-columns: + minmax(15rem, var(--left-panel-width)) + var(--panel-resizer-width) + minmax(0, 1fr) + var(--panel-resizer-width) + minmax(18rem, var(--right-panel-width)); min-height: 100vh; } .panel { padding: 1.25rem; - border-right: 1px solid var(--line); background: var(--panel); backdrop-filter: blur(10px); overflow: auto; } - .panel:last-child { + .layout-resizer { + width: var(--panel-resizer-width); + min-width: var(--panel-resizer-width); + cursor: col-resize; + background: + linear-gradient( + 90deg, + transparent 0, + transparent calc(50% - 1px), + rgba(86, 96, 79, 0.28) calc(50% - 1px), + rgba(86, 96, 79, 0.28) calc(50% + 1px), + transparent calc(50% + 1px), + transparent 100% + ); + transition: background 140ms ease; + } + .layout-resizer:hover, + body.is-resizing-panels .layout-resizer { + background: + linear-gradient( + 90deg, + transparent 0, + transparent calc(50% - 1px), + rgba(44, 110, 73, 0.7) calc(50% - 1px), + rgba(44, 110, 73, 0.7) calc(50% + 1px), + transparent calc(50% + 1px), + transparent 100% + ); + } + body.is-resizing-panels { + cursor: col-resize; + user-select: none; + } + .panel.left-panel, + .panel.center-panel { border-right: 0; } h1, h2, h3 { @@ -187,6 +228,19 @@ padding-top: 0.85rem; border-top: 1px solid var(--line); } + .project-item-citations { + display: grid; + gap: 0.75rem; + } + .project-item-citation { + padding: 0.85rem 0.95rem; + border: 1px solid var(--line); + border-radius: 14px; + background: rgba(255, 251, 244, 0.7); + } + .project-item-citation p { + margin: 0.35rem 0 0; + } .project-item-card-header { display: flex; align-items: flex-start; @@ -514,8 +568,10 @@ .layout { grid-template-columns: 1fr; } + .layout-resizer { + display: none; + } .panel { - border-right: 0; border-bottom: 1px solid var(--line); } .modal-card { @@ -549,8 +605,8 @@ </style> </head> <body> - <main class="layout"> - <section class="panel"> + <main id="app-layout" class="layout"> + <section class="panel left-panel"> <h1>Zotero KB</h1> <p>创建项目,按 Zotero collection 树批量导入文献,再在项目范围内做引用推荐与引用方案。</p> @@ -559,6 +615,10 @@ <form id="create-project-form" class="stack"> <input id="project-id" name="project_id" placeholder="project-id,例如 thesis-ch2" required /> <input id="project-name" name="name" placeholder="项目名称" required /> + <select id="card-language" name="card_language" aria-label="卡片语言"> + <option value="zh">中文</option> + <option value="en">English</option> + </select> <div class="row"> <input id="llm-provider" name="llm_provider" value="deepseek" /> <input id="llm-model" name="llm_model" value="deepseek-chat" /> @@ -627,7 +687,15 @@ <button id="window-restore-button" type="button" class="secondary" style="width:auto;padding:0.3rem 0.5rem;font-size:0.8rem">□</button> </div> - <section class="panel"> + <div + id="left-panel-resizer" + class="layout-resizer" + role="separator" + aria-label="调整左侧栏宽度" + aria-orientation="vertical" + ></div> + + <section class="panel center-panel"> <div class="row" style="justify-content: space-between"> <div> <h2>项目文献</h2> @@ -674,7 +742,15 @@ </section> </div> - <section class="panel"> + <div + id="right-panel-resizer" + class="layout-resizer" + role="separator" + aria-label="调整右侧栏宽度" + aria-orientation="vertical" + ></div> + + <section class="panel right-panel"> <h2>Writing</h2> <p>两个入口独立:候选引用推荐和初版引用方案。</p> @@ -706,6 +782,22 @@ <script> const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window"; const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal"; + const PANEL_LAYOUT_STORAGE_KEY = "zotero-kb.panel-layout"; + const DESKTOP_LAYOUT_BREAKPOINT = 980; + const MIN_LEFT_PANEL_WIDTH = 240; + const MIN_CENTER_PANEL_WIDTH = 320; + const MIN_RIGHT_PANEL_WIDTH = 288; + + function readPanelLayoutState() { + try { + const raw = window.localStorage.getItem(PANEL_LAYOUT_STORAGE_KEY); + if (!raw) return {}; + const payload = JSON.parse(raw); + return typeof payload === "object" && payload ? payload : {}; + } catch (_error) { + return {}; + } + } function readWindowSessionState() { try { @@ -719,6 +811,7 @@ } const windowSessionState = readWindowSessionState(); + const panelLayoutState = readPanelLayoutState(); function readImportSessionState() { try { @@ -742,6 +835,7 @@ projectItemGenerationState: {}, editingProjectId: null, editingProjectName: "", + editingProjectLanguage: "zh", confirmingDeleteProjectId: null, savingProjectId: null, deletingProjectId: null, @@ -759,6 +853,12 @@ windowDragStartY: 0, windowDragStartLeft: 0, windowDragStartTop: 0, + leftPanelWidth: typeof panelLayoutState.leftWidth === "number" ? panelLayoutState.leftWidth : null, + rightPanelWidth: typeof panelLayoutState.rightWidth === "number" ? panelLayoutState.rightWidth : null, + activePanelResizeHandle: null, + panelResizeStartX: 0, + panelResizeStartLeftWidth: 0, + panelResizeStartRightWidth: 0, selectedCollectionKey: typeof importSessionState.selectedCollectionKey === "string" ? importSessionState.selectedCollectionKey : null, @@ -786,6 +886,9 @@ }; const elements = { + appLayout: document.getElementById("app-layout"), + leftPanelResizer: document.getElementById("left-panel-resizer"), + rightPanelResizer: document.getElementById("right-panel-resizer"), projectForm: document.getElementById("create-project-form"), projectStatus: document.getElementById("project-status"), projectList: document.getElementById("project-list"), @@ -831,6 +934,100 @@ planPanel: document.getElementById("plan-panel"), }; + function isDesktopLayout() { + return window.innerWidth > DESKTOP_LAYOUT_BREAKPOINT; + } + + function getPanelResizerTotalWidth() { + const computed = window.getComputedStyle(document.documentElement).getPropertyValue("--panel-resizer-width"); + const singleResizerWidth = parseFloat(computed) || 8; + return singleResizerWidth * 2; + } + + function clampPanelWidths(leftWidth, rightWidth) { + const available = window.innerWidth - getPanelResizerTotalWidth(); + const leftMax = Math.max(MIN_LEFT_PANEL_WIDTH, available - MIN_CENTER_PANEL_WIDTH - MIN_RIGHT_PANEL_WIDTH); + const clampedLeft = Math.min(Math.max(leftWidth, MIN_LEFT_PANEL_WIDTH), leftMax); + const rightMax = Math.max(MIN_RIGHT_PANEL_WIDTH, available - MIN_CENTER_PANEL_WIDTH - clampedLeft); + const clampedRight = Math.min(Math.max(rightWidth, MIN_RIGHT_PANEL_WIDTH), rightMax); + const adjustedLeftMax = Math.max(MIN_LEFT_PANEL_WIDTH, available - MIN_CENTER_PANEL_WIDTH - clampedRight); + return { + leftWidth: Math.min(clampedLeft, adjustedLeftMax), + rightWidth: clampedRight, + }; + } + + function persistPanelLayoutState() { + window.localStorage.setItem( + PANEL_LAYOUT_STORAGE_KEY, + JSON.stringify({ + leftWidth: state.leftPanelWidth, + rightWidth: state.rightPanelWidth, + }) + ); + } + + function applyPanelLayoutWidths() { + if (!elements.appLayout) return; + if (!isDesktopLayout()) { + elements.appLayout.style.removeProperty("--left-panel-width"); + elements.appLayout.style.removeProperty("--right-panel-width"); + return; + } + const widths = clampPanelWidths(state.leftPanelWidth ?? 320, state.rightPanelWidth ?? 416); + state.leftPanelWidth = widths.leftWidth; + state.rightPanelWidth = widths.rightWidth; + elements.appLayout.style.setProperty("--left-panel-width", `${widths.leftWidth}px`); + elements.appLayout.style.setProperty("--right-panel-width", `${widths.rightWidth}px`); + } + + function syncPanelLayoutToViewport() { + if (!isDesktopLayout()) { + return; + } + applyPanelLayoutWidths(); + persistPanelLayoutState(); + } + + function startPanelResize(handle, event) { + if (!isDesktopLayout()) { + return; + } + event.preventDefault(); + state.activePanelResizeHandle = handle; + state.panelResizeStartX = event.clientX; + state.panelResizeStartLeftWidth = state.leftPanelWidth ?? 320; + state.panelResizeStartRightWidth = state.rightPanelWidth ?? 416; + document.body.classList.add("is-resizing-panels"); + } + + function movePanelResize(event) { + if (!state.activePanelResizeHandle) { + return; + } + event.preventDefault(); + const dx = event.clientX - state.panelResizeStartX; + const nextLeftWidth = state.activePanelResizeHandle === "left" + ? state.panelResizeStartLeftWidth + dx + : state.panelResizeStartLeftWidth; + const nextRightWidth = state.activePanelResizeHandle === "right" + ? state.panelResizeStartRightWidth - dx + : state.panelResizeStartRightWidth; + const widths = clampPanelWidths(nextLeftWidth, nextRightWidth); + state.leftPanelWidth = widths.leftWidth; + state.rightPanelWidth = widths.rightWidth; + applyPanelLayoutWidths(); + } + + function endPanelResize() { + if (!state.activePanelResizeHandle) { + return; + } + state.activePanelResizeHandle = null; + document.body.classList.remove("is-resizing-panels"); + persistPanelLayoutState(); + } + function persistImportSessionState() { window.sessionStorage.setItem( IMPORT_SESSION_STORAGE_KEY, @@ -1083,7 +1280,12 @@ item.innerHTML = ` <form class="project-inline-form"> <input type="text" value="${escapeHtml(state.editingProjectName)}" aria-label="项目名称" /> + <select name="card_language" aria-label="卡片语言"> + <option value="zh" ${state.editingProjectLanguage === "zh" ? "selected" : ""}>中文</option> + <option value="en" ${state.editingProjectLanguage === "en" ? "selected" : ""}>English</option> + </select> <div class="meta">${project.id}</div> + <div class="meta">卡片语言:${project.card_language === "zh" ? "中文" : "English"}</div> <div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div> <div class="project-list-actions"> <button type="submit" ${isSaving ? "disabled" : ""}>保存</button> @@ -1093,10 +1295,14 @@ `; const form = item.querySelector(".project-inline-form"); const input = form.querySelector("input"); + const languageSelect = form.querySelector('select[name="card_language"]'); const cancelButton = form.querySelector('button[type="button"]'); input.addEventListener("input", (event) => { state.editingProjectName = event.target.value; }); + languageSelect.addEventListener("change", (event) => { + state.editingProjectLanguage = event.target.value; + }); form.addEventListener("submit", (event) => { event.preventDefault(); saveProjectRename(project.id).catch(() => null); @@ -1123,6 +1329,7 @@ <button type="button" class="project-list-main"> <strong>${escapeHtml(project.name)}</strong> <div class="meta">${project.id}</div> + <div class="meta">卡片语言:${project.card_language === "zh" ? "中文" : "English"}</div> <div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div> </button> <div class="project-list-actions"> @@ -1148,6 +1355,7 @@ } state.editingProjectId = projectId; state.editingProjectName = project.name || ""; + state.editingProjectLanguage = project.card_language || "zh"; state.confirmingDeleteProjectId = null; renderProjects(); } @@ -1155,6 +1363,7 @@ function cancelProjectRename() { state.editingProjectId = null; state.editingProjectName = ""; + state.editingProjectLanguage = "zh"; state.savingProjectId = null; renderProjects(); } @@ -1170,10 +1379,11 @@ try { await api(`/api/projects/${projectId}`, { method: "PATCH", - body: JSON.stringify({ name: nextName }), + body: JSON.stringify({ name: nextName, card_language: state.editingProjectLanguage }), }); state.editingProjectId = null; state.editingProjectName = ""; + state.editingProjectLanguage = "zh"; state.savingProjectId = null; setStatus(elements.projectStatus, "项目名称已更新。"); await loadProjects(); @@ -1188,6 +1398,7 @@ state.confirmingDeleteProjectId = projectId; state.editingProjectId = null; state.editingProjectName = ""; + state.editingProjectLanguage = "zh"; renderProjects(); } @@ -1245,6 +1456,18 @@ return status === "pending" || status === "failed"; } + function canGenerateProjectItemInBatch(status) { + return canGenerateProjectItem(status); + } + + function canGenerateProjectItemFromCard(status) { + return status === "pending" || status === "failed" || status === "done"; + } + + function getProjectItemGenerateButtonLabel(status) { + return status === "done" ? "重新生成" : "生成"; + } + function updateBatchGenerateControls() { const hasProject = Boolean(state.currentProjectId); const hasItems = state.currentProjectItems.length > 0; @@ -1342,7 +1565,7 @@ function selectAllPendingBatchItems() { state.batchGenerateSelectedKeys = new Set( state.currentProjectItems - .filter((item) => canGenerateProjectItem(getDisplayedProjectItemStatus(item))) + .filter((item) => canGenerateProjectItemInBatch(getDisplayedProjectItemStatus(item))) .map((item) => item.item_key) ); renderBatchGenerateList(); @@ -1422,7 +1645,7 @@ </div> <div class="project-item-actions"> <span class="project-item-status-pill ${status}">${statusLabel}</span> - ${canGenerateProjectItem(status) ? '<button type="button" class="secondary project-item-generate-button">生成</button>' : ''} + ${canGenerateProjectItemFromCard(status) ? `<button type="button" class="secondary project-item-generate-button">${getProjectItemGenerateButtonLabel(status)}</button>` : ''} </div> </div> <p>${status === "done" ? (escapeHtml(item.summary || "暂无摘要")) : (status === "generating" ? "正在生成卡片内容,请稍候。" : (status === "failed" ? "生成失败,可重试。" : "已导入项目,尚未生成卡片。"))}</p> @@ -1446,15 +1669,18 @@ const detail = document.createElement("div"); detail.className = "project-item-detail"; if (status === "done") { + const attachments = renderProjectItemAttachments(item.attachments || []); const claims = (item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>"; - const quotes = (item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>"; + const citations = renderProjectItemCitations(item.citations || []); detail.innerHTML = ` <div class="meta">${item.item_key}</div> <p>${escapeHtml(item.summary || "暂无摘要")}</p> + <h4>Source Files</h4> + ${attachments} <h4>Claims</h4> <ul>${claims}</ul> - <h4>Quotable</h4> - <ul>${quotes}</ul> + <h4>Citations</h4> + ${citations} `; } else { detail.innerHTML = ` @@ -1503,10 +1729,47 @@ `).join(""); } + function renderProjectItemCitations(citations) { + if (!Array.isArray(citations) || !citations.length) { + return '<div class="empty">暂无可直接用于写作的引用证据。</div>'; + } + const items = citations.map((citation) => ` + <div class="project-item-citation"> + <strong>${escapeHtml(citation.claim || "未命名 claim")}</strong> + <div class="meta">${escapeHtml(citation.use_case || "unspecified")} · ${escapeHtml(citation.quote_source || "unknown source")}</div> + <p>${escapeHtml(citation.quote || "暂无原文引文")}</p> + <p class="meta">${escapeHtml(citation.paraphrase || "暂无转述建议")}</p> + </div> + `).join(""); + return `<div class="project-item-citations">${items}</div>`; + } + + function renderProjectItemAttachments(attachments) { + if (!Array.isArray(attachments) || !attachments.length) { + return '<div class="empty">暂无原文附件信息。</div>'; + } + const items = attachments.map((attachment) => ` + <div class="project-item-citation"> + <strong>${escapeHtml(attachment.filename || "unnamed file")}</strong> + <div class="meta">${escapeHtml(attachment.content_type || "unknown type")} · ${attachment.is_pdf ? "PDF" : "非 PDF"}</div> + <p class="meta">${escapeHtml(attachment.path || "")}</p> + </div> + `).join(""); + return `<div class="project-item-citations">${items}</div>`; + } + function renderSelectedCount() { elements.windowSelectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`; } + function getCurrentProjectItemKeySet() { + return new Set((state.currentProjectItems || []).map((item) => item.item_key)); + } + + function isItemInCurrentProject(itemKey) { + return getCurrentProjectItemKeySet().has(itemKey); + } + function resetCollectionItemsLoadingState(clearItems = false) { if (clearItems) { state.visibleCollectionItems = []; @@ -1669,6 +1932,7 @@ elements.windowCollectionItems.innerHTML = ""; for (const item of state.visibleCollectionItems) { + const alreadyInProject = isItemInCurrentProject(item.item_key); const row = document.createElement("label"); row.className = "project-item"; row.style.display = "grid"; @@ -1676,13 +1940,16 @@ row.style.gap = "0.75rem"; row.style.alignItems = "start"; row.innerHTML = ` - <input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} /> + <input type="checkbox" ${alreadyInProject ? "checked disabled" : (state.selectedItemKeys.has(item.item_key) ? "checked" : "")} /> <div> <strong>${item.title}</strong> <div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div> + ${alreadyInProject ? '<div class="meta">已在项目中</div>' : ''} </div> `; - row.querySelector("input").addEventListener("change", () => toggleItemSelection(item.item_key)); + if (!alreadyInProject) { + row.querySelector("input").addEventListener("change", () => toggleItemSelection(item.item_key)); + } elements.windowCollectionItems.appendChild(row); } updateImportActionState(); @@ -1755,6 +2022,9 @@ } function toggleItemSelection(itemKey) { + if (isItemInCurrentProject(itemKey)) { + return; + } if (state.selectedItemKeys.has(itemKey)) { state.selectedItemKeys.delete(itemKey); } else { @@ -1767,6 +2037,9 @@ function selectVisibleItems() { for (const item of state.visibleCollectionItems) { + if (isItemInCurrentProject(item.item_key)) { + continue; + } state.selectedItemKeys.add(item.item_key); } persistImportSessionState(); @@ -1897,6 +2170,7 @@ }); setStatus(elements.projectStatus, "项目已创建。"); elements.projectForm.reset(); + document.getElementById("card-language").value = "zh"; document.getElementById("llm-provider").value = "deepseek"; document.getElementById("llm-model").value = "deepseek-chat"; await loadProjects(); @@ -1932,10 +2206,15 @@ if (e.target === elements.windowRestoreButton) return; restoreWindow(); }); + elements.leftPanelResizer.addEventListener("pointerdown", (event) => startPanelResize("left", event)); + elements.rightPanelResizer.addEventListener("pointerdown", (event) => startPanelResize("right", event)); + document.addEventListener("pointermove", movePanelResize); + document.addEventListener("pointerup", endPanelResize); elements.windowToolbar.addEventListener("pointerdown", startWindowDrag); document.addEventListener("pointermove", moveWindowDrag); document.addEventListener("pointerup", endWindowDrag); window.addEventListener("resize", () => { + syncPanelLayoutToViewport(); if (!state.isImportWindowOpen) { return; } @@ -2007,6 +2286,7 @@ elements.planTab.addEventListener("click", () => activateTab("plan")); closeImportWindow(); + applyPanelLayoutWidths(); updateImportActionState(); loadProjects().catch((error) => { setStatus(elements.projectStatus, error.message, true); diff --git a/src/zotero_kb/workspace.py b/src/zotero_kb/workspace.py index 9bae46f..8a86232 100644 --- a/src/zotero_kb/workspace.py +++ b/src/zotero_kb/workspace.py @@ -14,6 +14,7 @@ class ProjectRecord: project_id: str name: str project_dir: Path + card_language: str class Workspace: @@ -36,6 +37,7 @@ class Workspace: name: str, llm_provider: str, llm_model: str, + card_language: str, ) -> ProjectRecord: self.ensure_layout() project_dir = self.config.workspace_dir / "projects" / project_id @@ -51,6 +53,7 @@ class Workspace: "model": llm_model, "base_url": None, }, + "card_language": card_language, "created_at": datetime.now(timezone.utc).isoformat(), } @@ -58,9 +61,9 @@ class Workspace: self._write_json(project_dir / "selected-items.json", []) self._write_json(project_dir / "project-index.json", {"items": []}) - return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir) + return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir, card_language=card_language) - def rename_project(self, project_id: str, name: str) -> ProjectRecord: + def rename_project(self, project_id: str, name: str, card_language: str | None = None) -> ProjectRecord: project_dir = self.config.workspace_dir / "projects" / project_id project_file = project_dir / "project.json" if not project_file.exists(): @@ -68,8 +71,15 @@ class Workspace: project_payload = json.loads(project_file.read_text(encoding="utf-8")) project_payload["name"] = name + if card_language is not None: + project_payload["card_language"] = card_language self._write_json(project_file, project_payload) - return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir) + return ProjectRecord( + project_id=project_id, + name=name, + project_dir=project_dir, + card_language=str(project_payload.get("card_language", "en")), + ) def delete_project(self, project_id: str) -> None: project_dir = self.config.workspace_dir / "projects" / project_id diff --git a/src/zotero_kb/zotero_reader.py b/src/zotero_kb/zotero_reader.py index 18b03d2..f2c1c6e 100644 --- a/src/zotero_kb/zotero_reader.py +++ b/src/zotero_kb/zotero_reader.py @@ -19,6 +19,7 @@ class ZoteroItemRecord: tags: list[str] collection_paths: list[list[str]] notes: list[str] + attachments: list[dict[str, object]] attachment_texts: list[str] @@ -52,6 +53,7 @@ class ZoteroReader: item_id = int(item_row["itemID"]) item_data = self._read_item_data(conn, item_id, field_lookup) + attachment_records = self._read_attachment_records(conn, item_id) items.append( ZoteroItemRecord( item_key=item_row["key"], @@ -63,13 +65,20 @@ class ZoteroReader: tags=self._read_tags(conn, item_id), collection_paths=self._read_collection_paths(conn, item_id, collection_lookup), notes=self._read_notes(conn, item_id), - attachment_texts=self._read_attachment_texts(conn, item_id), + attachments=[self._public_attachment_metadata(attachment) for attachment in attachment_records], + attachment_texts=[str(attachment["text"]) for attachment in attachment_records if attachment.get("text")], ) ) return items finally: conn.close() + def get_item_attachments(self, item_key: str) -> list[dict[str, object]]: + items = self.read_items([item_key]) + if not items: + return [] + return items[0].attachments + def search_items(self, query: str, limit: int = 20) -> list[dict[str, object]]: conn = sqlite3.connect(self.db_path) conn.row_factory = sqlite3.Row @@ -389,7 +398,7 @@ class ZoteroReader: ) return [self._strip_markup(str(row["note"])) for row in rows] - def _read_attachment_texts(self, conn: sqlite3.Connection, item_id: int) -> list[str]: + def _read_attachment_records(self, conn: sqlite3.Connection, item_id: int) -> list[dict[str, object]]: rows = conn.execute( """ select contentType, path @@ -399,16 +408,42 @@ class ZoteroReader: """, (item_id,), ) - texts: list[str] = [] + attachments: list[dict[str, object]] = [] for row in rows: - text = self._extract_attachment_text(str(row["path"] or ""), str(row["contentType"] or "")) - if text: - texts.append(text) - return texts + raw_path = str(row["path"] or "") + content_type = str(row["contentType"] or "") + attachment_path = self._resolve_attachment_path(raw_path) + if attachment_path is None: + continue + text = self._extract_attachment_text_from_resolved_path(attachment_path, content_type) + attachments.append( + { + "path": str(attachment_path), + "filename": attachment_path.name, + "content_type": content_type, + "is_pdf": content_type == "application/pdf", + "text": text, + } + ) + return attachments + + @staticmethod + def _public_attachment_metadata(attachment: dict[str, object]) -> dict[str, object]: + return { + "path": attachment["path"], + "filename": attachment["filename"], + "content_type": attachment["content_type"], + "is_pdf": attachment["is_pdf"], + } def _extract_attachment_text(self, raw_path: str, content_type: str) -> str: attachment_path = self._resolve_attachment_path(raw_path) - if attachment_path is None or not attachment_path.exists(): + if attachment_path is None: + return "" + return self._extract_attachment_text_from_resolved_path(attachment_path, content_type) + + def _extract_attachment_text_from_resolved_path(self, attachment_path: Path, content_type: str) -> str: + if not attachment_path.exists(): return "" if content_type == "application/pdf": diff --git a/tests/test_api.py b/tests/test_api.py index 01485a2..0e9ec77 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -12,9 +12,10 @@ 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"Summary for {title}", - "core_claims": [f"{title} supports scoped retrieval."], + "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."], @@ -53,6 +54,7 @@ def test_create_project_endpoint(tmp_path: Path) -> None: name="Thesis Chapter 2", llm_provider="openai", llm_model="gpt-5-mini", + card_language="zh", ) ) @@ -71,14 +73,16 @@ def test_rename_project_endpoint_updates_project_name(tmp_path: Path) -> None: name="Old Name", llm_provider="openai", llm_model="gpt-5-mini", + card_language="en", ) ) - response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name")) + 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: @@ -113,6 +117,7 @@ def test_delete_project_post_fallback_endpoint_removes_project_from_list(tmp_pat "name": "Thesis Chapter 2", "llm_provider": "openai", "llm_model": "gpt-5-mini", + "card_language": "zh", }, ) assert create_response.status_code == 201 @@ -135,6 +140,7 @@ def test_import_selected_items_endpoint(tmp_path: Path) -> None: name="Thesis Chapter 2", llm_provider="openai", llm_model="gpt-5-mini", + card_language="zh", ) ) @@ -155,6 +161,7 @@ def test_recommend_citations_endpoint(tmp_path: Path) -> None: name="Thesis Chapter 2", llm_provider="openai", llm_model="gpt-5-mini", + card_language="zh", ) ) import_selected("thesis-ch2") @@ -177,6 +184,7 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None: name="Thesis Chapter 2", llm_provider="deepseek", llm_model="deepseek-chat", + card_language="en", ) ) @@ -185,6 +193,7 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None: ) 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: @@ -216,6 +225,18 @@ def test_collection_items_endpoint_includes_descendants(tmp_path: Path) -> None: 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() @@ -369,6 +390,33 @@ def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None: 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() diff --git a/tests/test_cards.py b/tests/test_cards.py index 778c769..0d404b7 100644 --- a/tests/test_cards.py +++ b/tests/test_cards.py @@ -7,10 +7,11 @@ from zotero_kb.zotero_reader import ZoteroItemRecord class FakeLlmClient: def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: + language = str(source_bundle.get("card_language", "en")) return { - "summary": "Merged metadata and attachment text improve drafting support.", + "summary": "中文卡片摘要。" if language == "zh" else "Merged metadata and attachment text improve drafting support.", "core_claims": [ - "Project-scoped cards reduce irrelevant retrieval.", + "中文卡片 claim。" if language == "zh" else "Project-scoped cards reduce irrelevant retrieval.", ], "methods": [ "Combines notes, metadata, and full text.", @@ -18,6 +19,15 @@ class FakeLlmClient: "evidence": [ "The merged pipeline improved citation precision.", ], + "citations": [ + { + "claim": "Project-scoped cards reduce irrelevant retrieval.", + "quote": "Project-scoped card retrieval improves citation precision during drafting.", + "paraphrase": "Scoped retrieval improved citation precision during drafting.", + "quote_source": "attachment_texts", + "use_case": "direct_quote", + } + ], "quotable_passages": [ "Project-scoped card retrieval improves citation precision during drafting.", ], @@ -39,11 +49,19 @@ def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None: tags=["llm", "writing"], collection_paths=[["Theory", "Drafting"]], notes=["Merged notes matter."], + attachments=[ + { + "path": str(tmp_path / "zotero" / "storage" / "ATTACH01" / "paper.txt"), + "filename": "paper.txt", + "content_type": "text/plain", + "is_pdf": False, + } + ], attachment_texts=["This paper studies card pipelines for research writing."], ) builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient()) - result = builder.build_or_update(item) + result = builder.build_or_update(item, "en") expected_path = ( tmp_path @@ -51,18 +69,30 @@ def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None: / "collections" / "Theory" / "Drafting" - / "Card Pipelines for Research Writing [PAPER0001].md" + / "Card Pipelines for Research Writing [PAPER0001][en].md" ) assert result.card_path == expected_path - assert result.card_path.read_text(encoding="utf-8").startswith("---") + card_text = result.card_path.read_text(encoding="utf-8") + assert card_text.startswith("---") + assert "# Citations" in card_text + assert "quote_source: attachment_texts" in card_text + assert "locator:" not in card_text cards_index = json.loads((tmp_path / "library" / "index" / "cards.json").read_text(encoding="utf-8")) - assert cards_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing" + assert cards_index["PAPER0001"]["en"]["title"] == "Card Pipelines for Research Writing" + assert cards_index["PAPER0001"]["en"]["language"] == "en" + assert cards_index["PAPER0001"]["en"]["citations"][0]["use_case"] == "direct_quote" + assert "locator" not in cards_index["PAPER0001"]["en"]["citations"][0] + assert cards_index["PAPER0001"]["en"]["attachments"][0]["filename"] == "paper.txt" + assert cards_index["PAPER0001"]["en"]["attachments"][0]["is_pdf"] is False items_index = json.loads((tmp_path / "library" / "index" / "items.json").read_text(encoding="utf-8")) assert items_index["PAPER0001"]["card_path"] == str(expected_path) + assert items_index["PAPER0001"]["card_paths"]["en"] == str(expected_path) source_bundle = json.loads( - (tmp_path / "library" / "cache" / "source-bundles" / "PAPER0001.json").read_text(encoding="utf-8") + (tmp_path / "library" / "cache" / "source-bundles" / "PAPER0001.en.json").read_text(encoding="utf-8") ) assert source_bundle["item_key"] == "PAPER0001" + assert source_bundle["card_language"] == "en" + assert source_bundle["attachments"][0]["filename"] == "paper.txt" diff --git a/tests/test_llm.py b/tests/test_llm.py index f2d1240..fc14a85 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -27,6 +27,15 @@ def test_deepseek_client_uses_env_api_key_and_parses_json() -> None: "core_claims": ["Claim A"], "methods": ["Method A"], "evidence": ["Evidence A"], + "citations": [ + { + "claim": "Claim A", + "quote": "Exact supporting quote.", + "paraphrase": "Paraphrased support.", + "quote_source": "attachment_texts", + "use_case": "direct_quote", + } + ], "quotable_passages": ["Quote A"], "writing_hints": ["Hint A"], "keywords": ["kw-a"], @@ -47,6 +56,7 @@ def test_deepseek_client_uses_env_api_key_and_parses_json() -> None: { "title": "Scoped Retrieval for Drafting", "abstract": "Merged metadata improves drafting.", + "card_language": "zh", "notes": ["Project scoping helps."], "attachment_texts": ["Attachment evidence."], "tags": ["retrieval"], @@ -54,8 +64,12 @@ def test_deepseek_client_uses_env_api_key_and_parses_json() -> None: ) assert result["summary"] == "DeepSeek summary" + assert "locator" not in result["citations"][0] assert captured["url"] == "https://api.deepseek.com/v1/chat/completions" assert captured["payload"]["model"] == "deepseek-chat" + assert "Return 1-3 citations whenever the source bundle contains usable supporting text" in captured["payload"]["messages"][1]["content"] + assert "quote_source must identify where the quote came from" in captured["payload"]["messages"][1]["content"] + assert "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in Chinese." in captured["payload"]["messages"][1]["content"] assert captured["headers"]["Authorization"] == "Bearer sk-test" @@ -66,7 +80,7 @@ def test_deepseek_client_strips_markdown_fences() -> None: "choices": [ { "message": { - "content": "```json\n{\"summary\":\"Fence summary\",\"core_claims\":[],\"methods\":[],\"evidence\":[],\"quotable_passages\":[],\"writing_hints\":[],\"keywords\":[]}\n```" + "content": "```json\n{\"summary\":\"Fence summary\",\"core_claims\":[],\"methods\":[],\"evidence\":[],\"citations\":[],\"quotable_passages\":[],\"writing_hints\":[],\"keywords\":[]}\n```" } } ] diff --git a/tests/test_projects.py b/tests/test_projects.py index 83a81e4..5828df0 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -11,12 +11,13 @@ from zotero_kb.zotero_reader import ZoteroItemRecord 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")) if "Scoped" in title: - summary = "Project-scoped retrieval improves drafting." - claims = ["Project scoping improves citation precision."] + summary = "项目范围检索能改善写作。" if language == "zh" else "Project-scoped retrieval improves drafting." + claims = ["项目范围限定能提升引文精度。"] if language == "zh" else ["Project scoping improves citation precision."] else: - summary = "Unrelated retrieval baseline." - claims = ["Baseline retrieval is broad."] + summary = "无关检索基线。" if language == "zh" else "Unrelated retrieval baseline." + claims = ["基线检索范围较宽。"] if language == "zh" else ["Baseline retrieval is broad."] return { "summary": summary, "core_claims": claims, @@ -39,6 +40,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord: tags=["retrieval"], collection_paths=[["Theory", "Drafting"]], notes=[title], + attachments=[], attachment_texts=[title], ) @@ -46,7 +48,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord: def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path: Path) -> None: config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") workspace = Workspace(config) - workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en") builder = CardBuilder(config.workspace_dir, FakeLlmClient()) builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) @@ -65,7 +67,7 @@ def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path: def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None: config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") workspace = Workspace(config) - workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en") builder = CardBuilder(config.workspace_dir, FakeLlmClient()) builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) @@ -79,7 +81,7 @@ def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None: config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") workspace = Workspace(config) - workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh") items_index_path = config.workspace_dir / "library" / "index" / "items.json" items_index_path.parent.mkdir(parents=True, exist_ok=True) @@ -106,3 +108,122 @@ def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_pa assert len(payload["items"]) == 1 assert payload["items"][0]["item_key"] == "PAPER0001" assert payload["items"][0]["card_status"] == "pending" + + +def test_project_view_treats_legacy_cards_as_english_only(tmp_path: Path) -> None: + config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") + workspace = Workspace(config) + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh") + + cards_index_path = config.workspace_dir / "library" / "index" / "cards.json" + cards_index_path.parent.mkdir(parents=True, exist_ok=True) + cards_index_path.write_text( + json.dumps( + { + "PAPER0001": { + "item_key": "PAPER0001", + "title": "Legacy Card", + "summary": "English legacy summary", + "claims": ["Legacy claim"], + "citations": [], + "quotable_spans": [], + "writing_hints": [], + "attachments": [], + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + items_index_path = config.workspace_dir / "library" / "index" / "items.json" + items_index_path.write_text( + json.dumps( + { + "PAPER0001": { + "item_key": "PAPER0001", + "title": "Legacy Card", + "creators": ["Alice Smith"], + "year": "2024", + "item_type": "journalArticle", + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + service = ProjectService(config.workspace_dir) + payload = service.add_items("thesis-ch2", ["PAPER0001"]) + + assert payload["items"][0]["card_status"] == "pending" + workspace.rename_project("thesis-ch2", "Thesis Chapter 2", "en") + english_view = service.get_project_view("thesis-ch2") + assert english_view["items"][0]["card_status"] == "done" + assert english_view["items"][0]["summary"] == "English legacy summary" + + +def test_project_view_reads_only_current_language_card_variant(tmp_path: Path) -> None: + config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") + workspace = Workspace(config) + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh") + + cards_index_path = config.workspace_dir / "library" / "index" / "cards.json" + cards_index_path.parent.mkdir(parents=True, exist_ok=True) + cards_index_path.write_text( + json.dumps( + { + "PAPER0001": { + "zh": { + "item_key": "PAPER0001", + "language": "zh", + "title": "Bilingual Card", + "summary": "中文摘要", + "claims": ["中文 claim"], + "citations": [], + "quotable_spans": [], + "writing_hints": [], + "attachments": [], + }, + "en": { + "item_key": "PAPER0001", + "language": "en", + "title": "Bilingual Card", + "summary": "English summary", + "claims": ["English claim"], + "citations": [], + "quotable_spans": [], + "writing_hints": [], + "attachments": [], + }, + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + items_index_path = config.workspace_dir / "library" / "index" / "items.json" + items_index_path.write_text( + json.dumps( + { + "PAPER0001": { + "item_key": "PAPER0001", + "title": "Bilingual Card", + "creators": ["Alice Smith"], + "year": "2024", + "item_type": "journalArticle", + } + }, + ensure_ascii=False, + ), + encoding="utf-8", + ) + + service = ProjectService(config.workspace_dir) + zh_view = service.add_items("thesis-ch2", ["PAPER0001"]) + assert zh_view["items"][0]["summary"] == "中文摘要" + assert zh_view["cards"][0]["language"] == "zh" + + workspace.rename_project("thesis-ch2", "Thesis Chapter 2", "en") + en_view = service.get_project_view("thesis-ch2") + assert en_view["items"][0]["summary"] == "English summary" + assert en_view["cards"][0]["language"] == "en" diff --git a/tests/test_ui.py b/tests/test_ui.py index 9d1ca32..c6d3f63 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -26,6 +26,7 @@ def test_index_contains_base_page_forms(tmp_path) -> None: assert 'id="create-project-form"' in html assert 'id="recommend-form"' in html assert 'id="plan-form"' in html + assert 'id="card-language"' in html def test_index_contains_import_window_controls(tmp_path) -> None: @@ -129,7 +130,11 @@ def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None: assert "expandedProjectItemKey" in html assert "function renderProjectItems(items)" in html assert "function toggleProjectItemDetail(itemKey)" in html + assert "function renderProjectItemCitations(citations)" in html + assert "function renderProjectItemAttachments(attachments)" in html assert ".project-item-detail" in html + assert "<h4>Citations</h4>" in html + assert "<h4>Source Files</h4>" in html def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None: @@ -170,6 +175,9 @@ def test_index_has_project_rename_delete_controls(tmp_path) -> None: assert ".project-list-actions" in html assert ".project-inline-form" in html assert ".project-delete-confirmation" in html + assert "card_language" in html + assert "中文" in html + assert "English" in html def test_index_has_project_item_generate_actions_and_batch_window(tmp_path) -> None: @@ -186,10 +194,22 @@ def test_index_has_project_item_generate_actions_and_batch_window(tmp_path) -> N assert "function openBatchGenerateWindow()" in html assert "function closeBatchGenerateWindow()" in html assert "function generateProjectItem(itemKey)" in html + assert "function canGenerateProjectItemInBatch(status)" in html + assert "function getProjectItemGenerateButtonLabel(status)" in html assert "function selectAllPendingBatchItems()" in html assert "function runBatchGenerate()" in html assert ".project-item-status-pill" in html assert ".project-item-generate-button" in html + assert "重新生成" in html + + +def test_index_marks_already_imported_collection_items_as_checked_and_disabled(tmp_path) -> None: + html = _get_index_html(tmp_path) + + assert "function isItemInCurrentProject(itemKey)" in html + assert "function getCurrentProjectItemKeySet()" in html + assert "已在项目中" in html + assert '? "checked disabled" :' in html def test_index_inline_script_is_valid_javascript(tmp_path) -> None: diff --git a/tests/test_workspace.py b/tests/test_workspace.py index a5df55b..9eee261 100644 --- a/tests/test_workspace.py +++ b/tests/test_workspace.py @@ -26,12 +26,16 @@ def test_create_project_writes_project_files(tmp_path: Path) -> None: name="Thesis Chapter 2", llm_provider="openai", llm_model="gpt-5-mini", + card_language="zh", ) assert project.project_id == "thesis-ch2" assert (config.workspace_dir / "projects" / "thesis-ch2" / "project.json").is_file() assert (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").is_file() assert (config.workspace_dir / "projects" / "thesis-ch2" / "project-index.json").is_file() + assert '"card_language": "zh"' in ( + config.workspace_dir / "projects" / "thesis-ch2" / "project.json" + ).read_text(encoding="utf-8") def test_rename_project_updates_project_name(tmp_path: Path) -> None: @@ -43,15 +47,19 @@ def test_rename_project_updates_project_name(tmp_path: Path) -> None: name="Old Name", llm_provider="openai", llm_model="gpt-5-mini", + card_language="en", ) - project = workspace.rename_project("thesis-ch2", "New Name") + project = workspace.rename_project("thesis-ch2", "New Name", "zh") assert project.project_id == "thesis-ch2" assert project.name == "New Name" assert '"name": "New Name"' in ( config.workspace_dir / "projects" / "thesis-ch2" / "project.json" ).read_text(encoding="utf-8") + assert '"card_language": "zh"' in ( + config.workspace_dir / "projects" / "thesis-ch2" / "project.json" + ).read_text(encoding="utf-8") def test_delete_project_removes_project_directory_only(tmp_path: Path) -> None: @@ -63,6 +71,7 @@ def test_delete_project_removes_project_directory_only(tmp_path: Path) -> None: name="Thesis Chapter 2", llm_provider="openai", llm_model="gpt-5-mini", + card_language="en", ) preserved_index = config.workspace_dir / "library" / "index" / "items.json" preserved_index.write_text('{"PAPER0001": {"title": "Keep me"}}', encoding="utf-8") diff --git a/tests/test_writing.py b/tests/test_writing.py index 08dc0a2..107fce8 100644 --- a/tests/test_writing.py +++ b/tests/test_writing.py @@ -41,6 +41,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord: tags=["retrieval"], collection_paths=[["Theory", "Drafting"]], notes=[title], + attachments=[], attachment_texts=[title], ) @@ -48,7 +49,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord: def test_recommend_citations_only_reads_project_items(tmp_path: Path) -> None: config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") workspace = Workspace(config) - workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en") builder = CardBuilder(config.workspace_dir, FakeLlmClient()) builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) builder.build_or_update(_build_item("PAPER0002", "Broad Retrieval Baseline")) @@ -65,7 +66,7 @@ def test_recommend_citations_only_reads_project_items(tmp_path: Path) -> None: def test_generate_plan_returns_structured_sections(tmp_path: Path) -> None: config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") workspace = Workspace(config) - workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini") + workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en") builder = CardBuilder(config.workspace_dir, FakeLlmClient()) builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) diff --git a/tests/test_zotero_reader.py b/tests/test_zotero_reader.py index 9c35c64..21549e3 100644 --- a/tests/test_zotero_reader.py +++ b/tests/test_zotero_reader.py @@ -21,6 +21,14 @@ def test_read_selected_items_from_fixture(tmp_path: Path) -> None: assert item.collection_paths == [["Theory", "Drafting"]] assert item.notes == ["Merged notes matter."] assert item.attachment_texts[0].startswith("This paper studies") + assert item.attachments == [ + { + "path": str(fixture_dir / "storage" / "ATTACH01" / "paper.txt"), + "filename": "paper.txt", + "content_type": "text/plain", + "is_pdf": False, + } + ] def test_read_selected_keys_from_bridge_snapshot(tmp_path: Path) -> None: