feat: Add multilingual support for project cards and attachments

- 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.
This commit is contained in:
Saberlve 2026-04-22 10:21:03 +08:00
parent cf7e2feb19
commit aaafa78883
19 changed files with 973 additions and 125 deletions

130
CLAUDE.md Normal file
View File

@ -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:<key>` 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_id>/`:
- `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/<collection_path>/<title> [<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

View File

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

View File

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

View File

@ -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 15 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}
- **可用论点**: {12 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.

View File

@ -21,10 +21,12 @@ class CreateProjectRequest(BaseModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
llm_provider: str = "openai" llm_provider: str = "openai"
llm_model: str = "gpt-5-mini" llm_model: str = "gpt-5-mini"
card_language: str = "zh"
class RenameProjectRequest(BaseModel): class RenameProjectRequest(BaseModel):
name: str = Field(min_length=1) name: str = Field(min_length=1)
card_language: str = "zh"
class WritingPromptRequest(BaseModel): class WritingPromptRequest(BaseModel):
@ -65,6 +67,7 @@ def create_app(
name=payload.name, name=payload.name,
llm_provider=payload.llm_provider, llm_provider=payload.llm_provider,
llm_model=payload.llm_model, llm_model=payload.llm_model,
card_language=payload.card_language,
) )
return {"id": project.project_id, "name": project.name} return {"id": project.project_id, "name": project.name}
@ -83,7 +86,7 @@ def create_app(
@app.patch("/api/projects/{project_id}") @app.patch("/api/projects/{project_id}")
def rename_project(project_id: str, payload: RenameProjectRequest) -> dict[str, object]: def rename_project(project_id: str, payload: RenameProjectRequest) -> dict[str, object]:
try: try:
project = workspace.rename_project(project_id, payload.name) project = workspace.rename_project(project_id, payload.name, payload.card_language)
except FileNotFoundError as exc: except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail="Project not found") from exc raise HTTPException(status_code=404, detail="Project not found") from exc
return {"id": project.project_id, "name": project.name} 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]: def search_zotero_items(query: str = "", limit: int = 20) -> dict[str, object]:
return {"items": reader.search_items(query, limit=limit)} 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}") @app.get("/api/projects/{project_id}")
def get_project(project_id: str) -> dict[str, object]: def get_project(project_id: str) -> dict[str, object]:
project_dir = config.workspace_dir / "projects" / project_id project_dir = config.workspace_dir / "projects" / project_id
@ -145,9 +155,10 @@ def create_app(
builder = CardBuilder(config.workspace_dir, resolved_client) builder = CardBuilder(config.workspace_dir, resolved_client)
selected_keys = read_selected_keys(config.bridge_file) selected_keys = read_selected_keys(config.bridge_file)
items = reader.read_items(selected_keys) items = reader.read_items(selected_keys)
card_language = str(project_payload.get("card_language", "en"))
imported_keys = [] imported_keys = []
for item in items: for item in items:
builder.build_or_update(item) builder.build_or_update(item, card_language)
imported_keys.append(item.item_key) imported_keys.append(item.item_key)
project_view = project_service.add_items(project_id, imported_keys) project_view = project_service.add_items(project_id, imported_keys)
return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view} 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) builder = CardBuilder(config.workspace_dir, resolved_client)
items = reader.read_items(item_keys) 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_path = config.workspace_dir / "library" / "index" / "items.json"
items_index = project_service._read_json(items_index_path) items_index = project_service._read_json(items_index_path)
@ -265,7 +277,7 @@ def create_app(
item_key = item.item_key item_key = item.item_key
item_data = items_index.get(item_key, {}) item_data = items_index.get(item_key, {})
try: try:
builder.build_or_update(item) builder.build_or_update(item, card_language)
generated.append(item_key) generated.append(item_key)
card_status = "done" card_status = "done"
except Exception: except Exception:

View File

@ -5,6 +5,7 @@ import json
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any
from .llm import CardGenerationClient from .llm import CardGenerationClient
from .zotero_reader import ZoteroItemRecord from .zotero_reader import ZoteroItemRecord
@ -15,6 +16,7 @@ class CardBuildResult:
item_key: str item_key: str
card_path: Path card_path: Path
source_hash: str source_hash: str
language: str
class CardBuilder: class CardBuilder:
@ -28,26 +30,27 @@ class CardBuilder:
for path in (self.collections_dir, self.index_dir, self.bundle_dir): for path in (self.collections_dir, self.index_dir, self.bundle_dir):
path.mkdir(parents=True, exist_ok=True) path.mkdir(parents=True, exist_ok=True)
def build_or_update(self, item: ZoteroItemRecord) -> CardBuildResult: def build_or_update(self, item: ZoteroItemRecord, language: str = "en") -> CardBuildResult:
source_bundle = self._build_source_bundle(item) source_bundle = self._build_source_bundle(item, language)
source_hash = self._hash_payload(source_bundle) source_hash = self._hash_payload(source_bundle)
source_bundle["source_hash"] = source_hash 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) self._write_json(bundle_path, source_bundle)
card_data = self.llm_client.generate_card(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.parent.mkdir(parents=True, exist_ok=True)
card_path.write_text(self._render_card(item, source_hash, card_data), encoding="utf-8") 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) 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) 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 { return {
"item_key": item.item_key, "item_key": item.item_key,
"title": item.title, "title": item.title,
"card_language": language,
"creators": item.creators, "creators": item.creators,
"year": item.year, "year": item.year,
"item_type": item.item_type, "item_type": item.item_type,
@ -55,6 +58,7 @@ class CardBuilder:
"tags": item.tags, "tags": item.tags,
"collection_paths": item.collection_paths, "collection_paths": item.collection_paths,
"notes": item.notes, "notes": item.notes,
"attachments": item.attachments,
"attachment_texts": item.attachment_texts, "attachment_texts": item.attachment_texts,
} }
@ -63,9 +67,9 @@ class CardBuilder:
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8") raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
return f"sha256:{hashlib.sha256(raw).hexdigest()}" 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"] 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) return self.collections_dir.joinpath(*collection_path, filename)
@staticmethod @staticmethod
@ -101,6 +105,8 @@ class CardBuilder:
("Core Claims", [str(value) for value in card_data.get("core_claims", [])]), ("Core Claims", [str(value) for value in card_data.get("core_claims", [])]),
("Methods", [str(value) for value in card_data.get("methods", [])]), ("Methods", [str(value) for value in card_data.get("methods", [])]),
("Evidence", [str(value) for value in card_data.get("evidence", [])]), ("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", [])]), ("Quotable Passages", [str(value) for value in card_data.get("quotable_passages", [])]),
("Writing Hints", [str(value) for value in card_data.get("writing_hints", [])]), ("Writing Hints", [str(value) for value in card_data.get("writing_hints", [])]),
] ]
@ -115,12 +121,50 @@ class CardBuilder:
lines.append("") lines.append("")
return "\n".join(lines).strip() + "\n" 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( def _update_indexes(
self, self,
item: ZoteroItemRecord, item: ZoteroItemRecord,
card_path: Path, card_path: Path,
source_hash: str, source_hash: str,
card_data: dict[str, object], card_data: dict[str, object],
language: str,
) -> None: ) -> None:
items_index = self._read_json(self.index_dir / "items.json") items_index = self._read_json(self.index_dir / "items.json")
cards_index = self._read_json(self.index_dir / "cards.json") cards_index = self._read_json(self.index_dir / "cards.json")
@ -137,18 +181,36 @@ class CardBuilder:
"collection_paths": item.collection_paths, "collection_paths": item.collection_paths,
"attachment_status": "ok" if item.attachment_texts else "missing", "attachment_status": "ok" if item.attachment_texts else "missing",
"card_path": str(card_path), "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, "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, "item_key": item.item_key,
"language": language,
"card_path": str(card_path), "card_path": str(card_path),
"title": item.title, "title": item.title,
"summary": str(card_data.get("summary", "")), "summary": str(card_data.get("summary", "")),
"attachments": item.attachments,
"keywords": [str(value) for value in card_data.get("keywords", [])], "keywords": [str(value) for value in card_data.get("keywords", [])],
"claims": [str(value) for value in card_data.get("core_claims", [])], "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", [])], "quotable_spans": [str(value) for value in card_data.get("quotable_passages", [])],
"writing_hints": [str(value) for value in card_data.get("writing_hints", [])], "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"]]: for path_parts in item.collection_paths or [["Unsorted"]]:
collection_key = "/".join(path_parts) collection_key = "/".join(path_parts)

View File

@ -17,6 +17,7 @@ class DeterministicCardGenerationClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
abstract = str(source_bundle.get("abstract", "")).strip() 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()] notes = [str(value).strip() for value in source_bundle.get("notes", []) if str(value).strip()]
attachment_texts = [ attachment_texts = [
str(value).strip() for value in source_bundle.get("attachment_texts", []) if str(value).strip() 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 "") 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 [] claims = (notes[:3] or [summary]) if summary else []
return { return {
"summary": summary, "summary": summary if language == "en" else f"中文:{summary}",
"core_claims": claims, "core_claims": claims if language == "en" else [f"中文:{claim}" for claim in claims],
"methods": [], "methods": [],
"evidence": attachment_texts[:3], "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], "quotable_passages": attachment_texts[:2],
"writing_hints": [ "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", [])], "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 self.request_fn = request_fn or self._default_request
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: 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 = { payload = {
"model": self.model, "model": self.model,
"response_format": {"type": "json_object"}, "response_format": {"type": "json_object"},
@ -61,14 +79,32 @@ class DeepSeekCardGenerationClient:
{ {
"role": "system", "role": "system",
"content": ( "content": (
"You convert research source bundles into structured JSON cards. " "You convert research source bundles into structured evidence cards for academic writing. "
"Return a JSON object with keys: summary, core_claims, methods, evidence, " "Never hallucinate. Use only information explicitly supported by the source bundle. "
"quotable_passages, writing_hints, keywords." "Preserve uncertainty and hedging. Return valid JSON only with keys: "
"summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords."
), ),
}, },
{ {
"role": "user", "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", [])], "core_claims": [str(value) for value in parsed.get("core_claims", [])],
"methods": [str(value) for value in parsed.get("methods", [])], "methods": [str(value) for value in parsed.get("methods", [])],
"evidence": [str(value) for value in parsed.get("evidence", [])], "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", [])], "quotable_passages": [str(value) for value in parsed.get("quotable_passages", [])],
"writing_hints": [str(value) for value in parsed.get("writing_hints", [])], "writing_hints": [str(value) for value in parsed.get("writing_hints", [])],
"keywords": [str(value) for value in parsed.get("keywords", [])], "keywords": [str(value) for value in parsed.get("keywords", [])],

View File

@ -22,21 +22,21 @@ class ProjectService:
return self._rebuild_project_index(project_id, selected_items) return self._rebuild_project_index(project_id, selected_items)
def get_project_view(self, project_id: str) -> dict[str, object]: def get_project_view(self, project_id: str) -> dict[str, object]:
index_path = self._project_dir(project_id) / "project-index.json" selected_items = self._read_selected_items(project_id)
if not index_path.exists(): return self._rebuild_project_index(project_id, selected_items)
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"))
def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]: 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") cards_index = self._read_json(self.index_dir / "cards.json")
items_index = self._read_json(self.index_dir / "items.json") items_index = self._read_json(self.index_dir / "items.json")
collections_index = self._read_json(self.index_dir / "collections.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_items: list[dict[str, object]] = []
project_cards: list[dict[str, object]] = [] project_cards: list[dict[str, object]] = []
for item_key in selected_items: for item_key in selected_items:
item_data = items_index.get(item_key, {}) 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_payload = {
"item_key": item_key, "item_key": item_key,
"title": item_data.get("title", "Untitled"), "title": item_data.get("title", "Untitled"),
@ -45,7 +45,9 @@ class ProjectService:
"item_type": item_data.get("item_type", "unknown"), "item_type": item_data.get("item_type", "unknown"),
"card_status": "done" if card_data else "pending", "card_status": "done" if card_data else "pending",
"summary": card_data.get("summary") if card_data else None, "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 [], "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 [], "quotable_spans": card_data.get("quotable_spans", []) if card_data else [],
} }
project_items.append(item_payload) project_items.append(item_payload)
@ -58,6 +60,7 @@ class ProjectService:
] ]
payload = { payload = {
"project_id": project_id, "project_id": project_id,
"card_language": card_language,
"selected_items": selected_items, "selected_items": selected_items,
"items": project_items, "items": project_items,
"cards": project_cards, "cards": project_cards,
@ -75,6 +78,22 @@ class ProjectService:
def _project_dir(self, project_id: str) -> Path: def _project_dir(self, project_id: str) -> Path:
return self.projects_dir / project_id 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 @staticmethod
def _read_json(path: Path) -> dict[str, object]: def _read_json(path: Path) -> dict[str, object]:
if not path.exists(): if not path.exists():

View File

@ -16,6 +16,9 @@
--accent-strong: #1e4d33; --accent-strong: #1e4d33;
--surface: #fffdf8; --surface: #fffdf8;
--danger: #8b3a2a; --danger: #8b3a2a;
--left-panel-width: 20rem;
--right-panel-width: 26rem;
--panel-resizer-width: 0.5rem;
} }
* { * {
box-sizing: border-box; box-sizing: border-box;
@ -33,17 +36,55 @@
} }
.layout { .layout {
display: grid; 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; min-height: 100vh;
} }
.panel { .panel {
padding: 1.25rem; padding: 1.25rem;
border-right: 1px solid var(--line);
background: var(--panel); background: var(--panel);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
overflow: auto; 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; border-right: 0;
} }
h1, h2, h3 { h1, h2, h3 {
@ -187,6 +228,19 @@
padding-top: 0.85rem; padding-top: 0.85rem;
border-top: 1px solid var(--line); 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 { .project-item-card-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@ -514,8 +568,10 @@
.layout { .layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.layout-resizer {
display: none;
}
.panel { .panel {
border-right: 0;
border-bottom: 1px solid var(--line); border-bottom: 1px solid var(--line);
} }
.modal-card { .modal-card {
@ -549,8 +605,8 @@
</style> </style>
</head> </head>
<body> <body>
<main class="layout"> <main id="app-layout" class="layout">
<section class="panel"> <section class="panel left-panel">
<h1>Zotero KB</h1> <h1>Zotero KB</h1>
<p>创建项目,按 Zotero collection 树批量导入文献,再在项目范围内做引用推荐与引用方案。</p> <p>创建项目,按 Zotero collection 树批量导入文献,再在项目范围内做引用推荐与引用方案。</p>
@ -559,6 +615,10 @@
<form id="create-project-form" class="stack"> <form id="create-project-form" class="stack">
<input id="project-id" name="project_id" placeholder="project-id例如 thesis-ch2" required /> <input id="project-id" name="project_id" placeholder="project-id例如 thesis-ch2" required />
<input id="project-name" name="name" placeholder="项目名称" 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"> <div class="row">
<input id="llm-provider" name="llm_provider" value="deepseek" /> <input id="llm-provider" name="llm_provider" value="deepseek" />
<input id="llm-model" name="llm_model" value="deepseek-chat" /> <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> <button id="window-restore-button" type="button" class="secondary" style="width:auto;padding:0.3rem 0.5rem;font-size:0.8rem"></button>
</div> </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 class="row" style="justify-content: space-between">
<div> <div>
<h2>项目文献</h2> <h2>项目文献</h2>
@ -674,7 +742,15 @@
</section> </section>
</div> </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> <h2>Writing</h2>
<p>两个入口独立:候选引用推荐和初版引用方案。</p> <p>两个入口独立:候选引用推荐和初版引用方案。</p>
@ -706,6 +782,22 @@
<script> <script>
const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window"; const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window";
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal"; 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() { function readWindowSessionState() {
try { try {
@ -719,6 +811,7 @@
} }
const windowSessionState = readWindowSessionState(); const windowSessionState = readWindowSessionState();
const panelLayoutState = readPanelLayoutState();
function readImportSessionState() { function readImportSessionState() {
try { try {
@ -742,6 +835,7 @@
projectItemGenerationState: {}, projectItemGenerationState: {},
editingProjectId: null, editingProjectId: null,
editingProjectName: "", editingProjectName: "",
editingProjectLanguage: "zh",
confirmingDeleteProjectId: null, confirmingDeleteProjectId: null,
savingProjectId: null, savingProjectId: null,
deletingProjectId: null, deletingProjectId: null,
@ -759,6 +853,12 @@
windowDragStartY: 0, windowDragStartY: 0,
windowDragStartLeft: 0, windowDragStartLeft: 0,
windowDragStartTop: 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" selectedCollectionKey: typeof importSessionState.selectedCollectionKey === "string"
? importSessionState.selectedCollectionKey ? importSessionState.selectedCollectionKey
: null, : null,
@ -786,6 +886,9 @@
}; };
const elements = { 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"), projectForm: document.getElementById("create-project-form"),
projectStatus: document.getElementById("project-status"), projectStatus: document.getElementById("project-status"),
projectList: document.getElementById("project-list"), projectList: document.getElementById("project-list"),
@ -831,6 +934,100 @@
planPanel: document.getElementById("plan-panel"), 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() { function persistImportSessionState() {
window.sessionStorage.setItem( window.sessionStorage.setItem(
IMPORT_SESSION_STORAGE_KEY, IMPORT_SESSION_STORAGE_KEY,
@ -1083,7 +1280,12 @@
item.innerHTML = ` item.innerHTML = `
<form class="project-inline-form"> <form class="project-inline-form">
<input type="text" value="${escapeHtml(state.editingProjectName)}" aria-label="项目名称" /> <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.id}</div>
<div class="meta">卡片语言:${project.card_language === "zh" ? "中文" : "English"}</div>
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div> <div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
<div class="project-list-actions"> <div class="project-list-actions">
<button type="submit" ${isSaving ? "disabled" : ""}>保存</button> <button type="submit" ${isSaving ? "disabled" : ""}>保存</button>
@ -1093,10 +1295,14 @@
`; `;
const form = item.querySelector(".project-inline-form"); const form = item.querySelector(".project-inline-form");
const input = form.querySelector("input"); const input = form.querySelector("input");
const languageSelect = form.querySelector('select[name="card_language"]');
const cancelButton = form.querySelector('button[type="button"]'); const cancelButton = form.querySelector('button[type="button"]');
input.addEventListener("input", (event) => { input.addEventListener("input", (event) => {
state.editingProjectName = event.target.value; state.editingProjectName = event.target.value;
}); });
languageSelect.addEventListener("change", (event) => {
state.editingProjectLanguage = event.target.value;
});
form.addEventListener("submit", (event) => { form.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
saveProjectRename(project.id).catch(() => null); saveProjectRename(project.id).catch(() => null);
@ -1123,6 +1329,7 @@
<button type="button" class="project-list-main"> <button type="button" class="project-list-main">
<strong>${escapeHtml(project.name)}</strong> <strong>${escapeHtml(project.name)}</strong>
<div class="meta">${project.id}</div> <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="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
</button> </button>
<div class="project-list-actions"> <div class="project-list-actions">
@ -1148,6 +1355,7 @@
} }
state.editingProjectId = projectId; state.editingProjectId = projectId;
state.editingProjectName = project.name || ""; state.editingProjectName = project.name || "";
state.editingProjectLanguage = project.card_language || "zh";
state.confirmingDeleteProjectId = null; state.confirmingDeleteProjectId = null;
renderProjects(); renderProjects();
} }
@ -1155,6 +1363,7 @@
function cancelProjectRename() { function cancelProjectRename() {
state.editingProjectId = null; state.editingProjectId = null;
state.editingProjectName = ""; state.editingProjectName = "";
state.editingProjectLanguage = "zh";
state.savingProjectId = null; state.savingProjectId = null;
renderProjects(); renderProjects();
} }
@ -1170,10 +1379,11 @@
try { try {
await api(`/api/projects/${projectId}`, { await api(`/api/projects/${projectId}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ name: nextName }), body: JSON.stringify({ name: nextName, card_language: state.editingProjectLanguage }),
}); });
state.editingProjectId = null; state.editingProjectId = null;
state.editingProjectName = ""; state.editingProjectName = "";
state.editingProjectLanguage = "zh";
state.savingProjectId = null; state.savingProjectId = null;
setStatus(elements.projectStatus, "项目名称已更新。"); setStatus(elements.projectStatus, "项目名称已更新。");
await loadProjects(); await loadProjects();
@ -1188,6 +1398,7 @@
state.confirmingDeleteProjectId = projectId; state.confirmingDeleteProjectId = projectId;
state.editingProjectId = null; state.editingProjectId = null;
state.editingProjectName = ""; state.editingProjectName = "";
state.editingProjectLanguage = "zh";
renderProjects(); renderProjects();
} }
@ -1245,6 +1456,18 @@
return status === "pending" || status === "failed"; 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() { function updateBatchGenerateControls() {
const hasProject = Boolean(state.currentProjectId); const hasProject = Boolean(state.currentProjectId);
const hasItems = state.currentProjectItems.length > 0; const hasItems = state.currentProjectItems.length > 0;
@ -1342,7 +1565,7 @@
function selectAllPendingBatchItems() { function selectAllPendingBatchItems() {
state.batchGenerateSelectedKeys = new Set( state.batchGenerateSelectedKeys = new Set(
state.currentProjectItems state.currentProjectItems
.filter((item) => canGenerateProjectItem(getDisplayedProjectItemStatus(item))) .filter((item) => canGenerateProjectItemInBatch(getDisplayedProjectItemStatus(item)))
.map((item) => item.item_key) .map((item) => item.item_key)
); );
renderBatchGenerateList(); renderBatchGenerateList();
@ -1422,7 +1645,7 @@
</div> </div>
<div class="project-item-actions"> <div class="project-item-actions">
<span class="project-item-status-pill ${status}">${statusLabel}</span> <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>
</div> </div>
<p>${status === "done" ? (escapeHtml(item.summary || "暂无摘要")) : (status === "generating" ? "正在生成卡片内容,请稍候。" : (status === "failed" ? "生成失败,可重试。" : "已导入项目,尚未生成卡片。"))}</p> <p>${status === "done" ? (escapeHtml(item.summary || "暂无摘要")) : (status === "generating" ? "正在生成卡片内容,请稍候。" : (status === "failed" ? "生成失败,可重试。" : "已导入项目,尚未生成卡片。"))}</p>
@ -1446,15 +1669,18 @@
const detail = document.createElement("div"); const detail = document.createElement("div");
detail.className = "project-item-detail"; detail.className = "project-item-detail";
if (status === "done") { if (status === "done") {
const attachments = renderProjectItemAttachments(item.attachments || []);
const claims = (item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>"; 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 = ` detail.innerHTML = `
<div class="meta">${item.item_key}</div> <div class="meta">${item.item_key}</div>
<p>${escapeHtml(item.summary || "暂无摘要")}</p> <p>${escapeHtml(item.summary || "暂无摘要")}</p>
<h4>Source Files</h4>
${attachments}
<h4>Claims</h4> <h4>Claims</h4>
<ul>${claims}</ul> <ul>${claims}</ul>
<h4>Quotable</h4> <h4>Citations</h4>
<ul>${quotes}</ul> ${citations}
`; `;
} else { } else {
detail.innerHTML = ` detail.innerHTML = `
@ -1503,10 +1729,47 @@
`).join(""); `).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() { function renderSelectedCount() {
elements.windowSelectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`; 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) { function resetCollectionItemsLoadingState(clearItems = false) {
if (clearItems) { if (clearItems) {
state.visibleCollectionItems = []; state.visibleCollectionItems = [];
@ -1669,6 +1932,7 @@
elements.windowCollectionItems.innerHTML = ""; elements.windowCollectionItems.innerHTML = "";
for (const item of state.visibleCollectionItems) { for (const item of state.visibleCollectionItems) {
const alreadyInProject = isItemInCurrentProject(item.item_key);
const row = document.createElement("label"); const row = document.createElement("label");
row.className = "project-item"; row.className = "project-item";
row.style.display = "grid"; row.style.display = "grid";
@ -1676,13 +1940,16 @@
row.style.gap = "0.75rem"; row.style.gap = "0.75rem";
row.style.alignItems = "start"; row.style.alignItems = "start";
row.innerHTML = ` 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> <div>
<strong>${item.title}</strong> <strong>${item.title}</strong>
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div> <div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
${alreadyInProject ? '<div class="meta">已在项目中</div>' : ''}
</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); elements.windowCollectionItems.appendChild(row);
} }
updateImportActionState(); updateImportActionState();
@ -1755,6 +2022,9 @@
} }
function toggleItemSelection(itemKey) { function toggleItemSelection(itemKey) {
if (isItemInCurrentProject(itemKey)) {
return;
}
if (state.selectedItemKeys.has(itemKey)) { if (state.selectedItemKeys.has(itemKey)) {
state.selectedItemKeys.delete(itemKey); state.selectedItemKeys.delete(itemKey);
} else { } else {
@ -1767,6 +2037,9 @@
function selectVisibleItems() { function selectVisibleItems() {
for (const item of state.visibleCollectionItems) { for (const item of state.visibleCollectionItems) {
if (isItemInCurrentProject(item.item_key)) {
continue;
}
state.selectedItemKeys.add(item.item_key); state.selectedItemKeys.add(item.item_key);
} }
persistImportSessionState(); persistImportSessionState();
@ -1897,6 +2170,7 @@
}); });
setStatus(elements.projectStatus, "项目已创建。"); setStatus(elements.projectStatus, "项目已创建。");
elements.projectForm.reset(); elements.projectForm.reset();
document.getElementById("card-language").value = "zh";
document.getElementById("llm-provider").value = "deepseek"; document.getElementById("llm-provider").value = "deepseek";
document.getElementById("llm-model").value = "deepseek-chat"; document.getElementById("llm-model").value = "deepseek-chat";
await loadProjects(); await loadProjects();
@ -1932,10 +2206,15 @@
if (e.target === elements.windowRestoreButton) return; if (e.target === elements.windowRestoreButton) return;
restoreWindow(); 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); elements.windowToolbar.addEventListener("pointerdown", startWindowDrag);
document.addEventListener("pointermove", moveWindowDrag); document.addEventListener("pointermove", moveWindowDrag);
document.addEventListener("pointerup", endWindowDrag); document.addEventListener("pointerup", endWindowDrag);
window.addEventListener("resize", () => { window.addEventListener("resize", () => {
syncPanelLayoutToViewport();
if (!state.isImportWindowOpen) { if (!state.isImportWindowOpen) {
return; return;
} }
@ -2007,6 +2286,7 @@
elements.planTab.addEventListener("click", () => activateTab("plan")); elements.planTab.addEventListener("click", () => activateTab("plan"));
closeImportWindow(); closeImportWindow();
applyPanelLayoutWidths();
updateImportActionState(); updateImportActionState();
loadProjects().catch((error) => { loadProjects().catch((error) => {
setStatus(elements.projectStatus, error.message, true); setStatus(elements.projectStatus, error.message, true);

View File

@ -14,6 +14,7 @@ class ProjectRecord:
project_id: str project_id: str
name: str name: str
project_dir: Path project_dir: Path
card_language: str
class Workspace: class Workspace:
@ -36,6 +37,7 @@ class Workspace:
name: str, name: str,
llm_provider: str, llm_provider: str,
llm_model: str, llm_model: str,
card_language: str,
) -> ProjectRecord: ) -> ProjectRecord:
self.ensure_layout() self.ensure_layout()
project_dir = self.config.workspace_dir / "projects" / project_id project_dir = self.config.workspace_dir / "projects" / project_id
@ -51,6 +53,7 @@ class Workspace:
"model": llm_model, "model": llm_model,
"base_url": None, "base_url": None,
}, },
"card_language": card_language,
"created_at": datetime.now(timezone.utc).isoformat(), "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 / "selected-items.json", [])
self._write_json(project_dir / "project-index.json", {"items": []}) 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_dir = self.config.workspace_dir / "projects" / project_id
project_file = project_dir / "project.json" project_file = project_dir / "project.json"
if not project_file.exists(): if not project_file.exists():
@ -68,8 +71,15 @@ class Workspace:
project_payload = json.loads(project_file.read_text(encoding="utf-8")) project_payload = json.loads(project_file.read_text(encoding="utf-8"))
project_payload["name"] = name project_payload["name"] = name
if card_language is not None:
project_payload["card_language"] = card_language
self._write_json(project_file, project_payload) 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: def delete_project(self, project_id: str) -> None:
project_dir = self.config.workspace_dir / "projects" / project_id project_dir = self.config.workspace_dir / "projects" / project_id

View File

@ -19,6 +19,7 @@ class ZoteroItemRecord:
tags: list[str] tags: list[str]
collection_paths: list[list[str]] collection_paths: list[list[str]]
notes: list[str] notes: list[str]
attachments: list[dict[str, object]]
attachment_texts: list[str] attachment_texts: list[str]
@ -52,6 +53,7 @@ class ZoteroReader:
item_id = int(item_row["itemID"]) item_id = int(item_row["itemID"])
item_data = self._read_item_data(conn, item_id, field_lookup) item_data = self._read_item_data(conn, item_id, field_lookup)
attachment_records = self._read_attachment_records(conn, item_id)
items.append( items.append(
ZoteroItemRecord( ZoteroItemRecord(
item_key=item_row["key"], item_key=item_row["key"],
@ -63,13 +65,20 @@ class ZoteroReader:
tags=self._read_tags(conn, item_id), tags=self._read_tags(conn, item_id),
collection_paths=self._read_collection_paths(conn, item_id, collection_lookup), collection_paths=self._read_collection_paths(conn, item_id, collection_lookup),
notes=self._read_notes(conn, item_id), 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 return items
finally: finally:
conn.close() 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]]: def search_items(self, query: str, limit: int = 20) -> list[dict[str, object]]:
conn = sqlite3.connect(self.db_path) conn = sqlite3.connect(self.db_path)
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
@ -389,7 +398,7 @@ class ZoteroReader:
) )
return [self._strip_markup(str(row["note"])) for row in rows] 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( rows = conn.execute(
""" """
select contentType, path select contentType, path
@ -399,16 +408,42 @@ class ZoteroReader:
""", """,
(item_id,), (item_id,),
) )
texts: list[str] = [] attachments: list[dict[str, object]] = []
for row in rows: for row in rows:
text = self._extract_attachment_text(str(row["path"] or ""), str(row["contentType"] or "")) raw_path = str(row["path"] or "")
if text: content_type = str(row["contentType"] or "")
texts.append(text) attachment_path = self._resolve_attachment_path(raw_path)
return texts 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: def _extract_attachment_text(self, raw_path: str, content_type: str) -> str:
attachment_path = self._resolve_attachment_path(raw_path) 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 "" return ""
if content_type == "application/pdf": if content_type == "application/pdf":

View File

@ -12,9 +12,10 @@ from zotero_kb.config import AppConfig
class FakeLlmClient: class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
title = str(source_bundle["title"]) title = str(source_bundle["title"])
language = str(source_bundle.get("card_language", "en"))
return { return {
"summary": f"Summary for {title}", "summary": f"中文摘要 {title}" if language == "zh" else f"Summary for {title}",
"core_claims": [f"{title} supports scoped retrieval."], "core_claims": [f"中文论点 {title}"] if language == "zh" else [f"{title} supports scoped retrieval."],
"methods": ["Method details."], "methods": ["Method details."],
"evidence": ["Evidence details."], "evidence": ["Evidence details."],
"quotable_passages": [f"{title} supports scoped retrieval."], "quotable_passages": [f"{title} supports scoped retrieval."],
@ -53,6 +54,7 @@ def test_create_project_endpoint(tmp_path: Path) -> None:
name="Thesis Chapter 2", name="Thesis Chapter 2",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", 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", name="Old Name",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", 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["id"] == "thesis-ch2"
assert response["name"] == "New Name" assert response["name"] == "New Name"
assert list_projects()[0]["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: 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", "name": "Thesis Chapter 2",
"llm_provider": "openai", "llm_provider": "openai",
"llm_model": "gpt-5-mini", "llm_model": "gpt-5-mini",
"card_language": "zh",
}, },
) )
assert create_response.status_code == 201 assert create_response.status_code == 201
@ -135,6 +140,7 @@ def test_import_selected_items_endpoint(tmp_path: Path) -> None:
name="Thesis Chapter 2", name="Thesis Chapter 2",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", 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", name="Thesis Chapter 2",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", llm_model="gpt-5-mini",
card_language="zh",
) )
) )
import_selected("thesis-ch2") import_selected("thesis-ch2")
@ -177,6 +184,7 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None:
name="Thesis Chapter 2", name="Thesis Chapter 2",
llm_provider="deepseek", llm_provider="deepseek",
llm_model="deepseek-chat", 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"]["provider"] == "deepseek"
assert project_payload["llm"]["model"] == "deepseek-chat" assert project_payload["llm"]["model"] == "deepseek-chat"
assert project_payload["card_language"] == "en"
def test_search_library_items_endpoint(tmp_path: Path) -> None: 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" 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: def test_import_item_keys_endpoint_without_bridge(tmp_path: Path) -> None:
config = make_test_config(tmp_path) config = make_test_config(tmp_path)
config.bridge_file.unlink() 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" 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: def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None:
config = make_test_config(tmp_path) config = make_test_config(tmp_path)
config.bridge_file.unlink() config.bridge_file.unlink()

View File

@ -7,10 +7,11 @@ from zotero_kb.zotero_reader import ZoteroItemRecord
class FakeLlmClient: class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
language = str(source_bundle.get("card_language", "en"))
return { 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": [ "core_claims": [
"Project-scoped cards reduce irrelevant retrieval.", "中文卡片 claim。" if language == "zh" else "Project-scoped cards reduce irrelevant retrieval.",
], ],
"methods": [ "methods": [
"Combines notes, metadata, and full text.", "Combines notes, metadata, and full text.",
@ -18,6 +19,15 @@ class FakeLlmClient:
"evidence": [ "evidence": [
"The merged pipeline improved citation precision.", "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": [ "quotable_passages": [
"Project-scoped card retrieval improves citation precision during drafting.", "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"], tags=["llm", "writing"],
collection_paths=[["Theory", "Drafting"]], collection_paths=[["Theory", "Drafting"]],
notes=["Merged notes matter."], 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."], attachment_texts=["This paper studies card pipelines for research writing."],
) )
builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient()) builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient())
result = builder.build_or_update(item) result = builder.build_or_update(item, "en")
expected_path = ( expected_path = (
tmp_path tmp_path
@ -51,18 +69,30 @@ def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None:
/ "collections" / "collections"
/ "Theory" / "Theory"
/ "Drafting" / "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 == 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")) 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")) 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_path"] == str(expected_path)
assert items_index["PAPER0001"]["card_paths"]["en"] == str(expected_path)
source_bundle = json.loads( 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["item_key"] == "PAPER0001"
assert source_bundle["card_language"] == "en"
assert source_bundle["attachments"][0]["filename"] == "paper.txt"

View File

@ -27,6 +27,15 @@ def test_deepseek_client_uses_env_api_key_and_parses_json() -> None:
"core_claims": ["Claim A"], "core_claims": ["Claim A"],
"methods": ["Method A"], "methods": ["Method A"],
"evidence": ["Evidence 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"], "quotable_passages": ["Quote A"],
"writing_hints": ["Hint A"], "writing_hints": ["Hint A"],
"keywords": ["kw-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", "title": "Scoped Retrieval for Drafting",
"abstract": "Merged metadata improves drafting.", "abstract": "Merged metadata improves drafting.",
"card_language": "zh",
"notes": ["Project scoping helps."], "notes": ["Project scoping helps."],
"attachment_texts": ["Attachment evidence."], "attachment_texts": ["Attachment evidence."],
"tags": ["retrieval"], "tags": ["retrieval"],
@ -54,8 +64,12 @@ def test_deepseek_client_uses_env_api_key_and_parses_json() -> None:
) )
assert result["summary"] == "DeepSeek summary" assert result["summary"] == "DeepSeek summary"
assert "locator" not in result["citations"][0]
assert captured["url"] == "https://api.deepseek.com/v1/chat/completions" assert captured["url"] == "https://api.deepseek.com/v1/chat/completions"
assert captured["payload"]["model"] == "deepseek-chat" 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" assert captured["headers"]["Authorization"] == "Bearer sk-test"
@ -66,7 +80,7 @@ def test_deepseek_client_strips_markdown_fences() -> None:
"choices": [ "choices": [
{ {
"message": { "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```"
} }
} }
] ]

View File

@ -11,12 +11,13 @@ from zotero_kb.zotero_reader import ZoteroItemRecord
class FakeLlmClient: class FakeLlmClient:
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
title = str(source_bundle["title"]) title = str(source_bundle["title"])
language = str(source_bundle.get("card_language", "en"))
if "Scoped" in title: if "Scoped" in title:
summary = "Project-scoped retrieval improves drafting." summary = "项目范围检索能改善写作。" if language == "zh" else "Project-scoped retrieval improves drafting."
claims = ["Project scoping improves citation precision."] claims = ["项目范围限定能提升引文精度。"] if language == "zh" else ["Project scoping improves citation precision."]
else: else:
summary = "Unrelated retrieval baseline." summary = "无关检索基线。" if language == "zh" else "Unrelated retrieval baseline."
claims = ["Baseline retrieval is broad."] claims = ["基线检索范围较宽。"] if language == "zh" else ["Baseline retrieval is broad."]
return { return {
"summary": summary, "summary": summary,
"core_claims": claims, "core_claims": claims,
@ -39,6 +40,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
tags=["retrieval"], tags=["retrieval"],
collection_paths=[["Theory", "Drafting"]], collection_paths=[["Theory", "Drafting"]],
notes=[title], notes=[title],
attachments=[],
attachment_texts=[title], 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: 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") config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config) 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 = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) 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: 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") config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config) 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 = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) 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: 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") config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config) 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 = config.workspace_dir / "library" / "index" / "items.json"
items_index_path.parent.mkdir(parents=True, exist_ok=True) 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 len(payload["items"]) == 1
assert payload["items"][0]["item_key"] == "PAPER0001" assert payload["items"][0]["item_key"] == "PAPER0001"
assert payload["items"][0]["card_status"] == "pending" 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"

View File

@ -26,6 +26,7 @@ def test_index_contains_base_page_forms(tmp_path) -> None:
assert 'id="create-project-form"' in html assert 'id="create-project-form"' in html
assert 'id="recommend-form"' in html assert 'id="recommend-form"' in html
assert 'id="plan-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: 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 "expandedProjectItemKey" in html
assert "function renderProjectItems(items)" in html assert "function renderProjectItems(items)" in html
assert "function toggleProjectItemDetail(itemKey)" 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 ".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: 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-list-actions" in html
assert ".project-inline-form" in html assert ".project-inline-form" in html
assert ".project-delete-confirmation" 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: 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 openBatchGenerateWindow()" in html
assert "function closeBatchGenerateWindow()" in html assert "function closeBatchGenerateWindow()" in html
assert "function generateProjectItem(itemKey)" 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 selectAllPendingBatchItems()" in html
assert "function runBatchGenerate()" in html assert "function runBatchGenerate()" in html
assert ".project-item-status-pill" in html assert ".project-item-status-pill" in html
assert ".project-item-generate-button" 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: def test_index_inline_script_is_valid_javascript(tmp_path) -> None:

View File

@ -26,12 +26,16 @@ def test_create_project_writes_project_files(tmp_path: Path) -> None:
name="Thesis Chapter 2", name="Thesis Chapter 2",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", llm_model="gpt-5-mini",
card_language="zh",
) )
assert project.project_id == "thesis-ch2" assert project.project_id == "thesis-ch2"
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project.json").is_file() 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" / "selected-items.json").is_file()
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project-index.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: 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", name="Old Name",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", 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.project_id == "thesis-ch2"
assert project.name == "New Name" assert project.name == "New Name"
assert '"name": "New Name"' in ( assert '"name": "New Name"' in (
config.workspace_dir / "projects" / "thesis-ch2" / "project.json" config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
).read_text(encoding="utf-8") ).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: 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", name="Thesis Chapter 2",
llm_provider="openai", llm_provider="openai",
llm_model="gpt-5-mini", llm_model="gpt-5-mini",
card_language="en",
) )
preserved_index = config.workspace_dir / "library" / "index" / "items.json" preserved_index = config.workspace_dir / "library" / "index" / "items.json"
preserved_index.write_text('{"PAPER0001": {"title": "Keep me"}}', encoding="utf-8") preserved_index.write_text('{"PAPER0001": {"title": "Keep me"}}', encoding="utf-8")

View File

@ -41,6 +41,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
tags=["retrieval"], tags=["retrieval"],
collection_paths=[["Theory", "Drafting"]], collection_paths=[["Theory", "Drafting"]],
notes=[title], notes=[title],
attachments=[],
attachment_texts=[title], 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: 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") config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config) 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 = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
builder.build_or_update(_build_item("PAPER0002", "Broad Retrieval Baseline")) 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: def test_generate_plan_returns_structured_sections(tmp_path: Path) -> None:
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero") config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
workspace = Workspace(config) 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 = CardBuilder(config.workspace_dir, FakeLlmClient())
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting")) builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))

View File

@ -21,6 +21,14 @@ def test_read_selected_items_from_fixture(tmp_path: Path) -> None:
assert item.collection_paths == [["Theory", "Drafting"]] assert item.collection_paths == [["Theory", "Drafting"]]
assert item.notes == ["Merged notes matter."] assert item.notes == ["Merged notes matter."]
assert item.attachment_texts[0].startswith("This paper studies") 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: def test_read_selected_keys_from_bridge_snapshot(tmp_path: Path) -> None: