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:
parent
cf7e2feb19
commit
aaafa78883
130
CLAUDE.md
Normal file
130
CLAUDE.md
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
53
skills/zotero-cite/SKILL.md
Normal file
53
skills/zotero-cite/SKILL.md
Normal 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 1–5 most relevant citations** from the project's cards only.
|
||||
7. **Format the response** in Chinese (matching the card language if it is zh).
|
||||
|
||||
## Output Format
|
||||
|
||||
For each recommended citation, provide:
|
||||
|
||||
```
|
||||
### 推荐引用 {n}
|
||||
- **文献标题**: {title}
|
||||
- **作者**: {creators}
|
||||
- **年份**: {year}
|
||||
- **item_key**: {item_key}(用于插入引用标记)
|
||||
- **相关性说明**: {why this card matches the writing context}
|
||||
- **可用论点**: {1–2 key claims from the card}
|
||||
- **可直接引用的原文**: {quotable_spans if any}
|
||||
- **建议的修辞角色**: {supporting evidence / counterpoint / background / methodological reference / etc.}
|
||||
- **引用建议**: {a concrete suggestion for how to weave this citation into the user's text}
|
||||
```
|
||||
|
||||
If no relevant card is found, state clearly that the current project does not contain matching literature, and suggest either:
|
||||
- broadening the writing topic, or
|
||||
- importing more items into the project.
|
||||
|
||||
## Cardinal Rule
|
||||
|
||||
> The user has curated this project for a reason. Do NOT suggest citations from outside the project index, even if you know the literature personally.
|
||||
@ -21,10 +21,12 @@ class CreateProjectRequest(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
llm_provider: str = "openai"
|
||||
llm_model: str = "gpt-5-mini"
|
||||
card_language: str = "zh"
|
||||
|
||||
|
||||
class RenameProjectRequest(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
card_language: str = "zh"
|
||||
|
||||
|
||||
class WritingPromptRequest(BaseModel):
|
||||
@ -65,6 +67,7 @@ def create_app(
|
||||
name=payload.name,
|
||||
llm_provider=payload.llm_provider,
|
||||
llm_model=payload.llm_model,
|
||||
card_language=payload.card_language,
|
||||
)
|
||||
return {"id": project.project_id, "name": project.name}
|
||||
|
||||
@ -83,7 +86,7 @@ def create_app(
|
||||
@app.patch("/api/projects/{project_id}")
|
||||
def rename_project(project_id: str, payload: RenameProjectRequest) -> dict[str, object]:
|
||||
try:
|
||||
project = workspace.rename_project(project_id, payload.name)
|
||||
project = workspace.rename_project(project_id, payload.name, payload.card_language)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="Project not found") from exc
|
||||
return {"id": project.project_id, "name": project.name}
|
||||
@ -115,6 +118,13 @@ def create_app(
|
||||
def search_zotero_items(query: str = "", limit: int = 20) -> dict[str, object]:
|
||||
return {"items": reader.search_items(query, limit=limit)}
|
||||
|
||||
@app.get("/api/zotero/items/{item_key}/attachments")
|
||||
def zotero_item_attachments(item_key: str) -> dict[str, object]:
|
||||
attachments = reader.get_item_attachments(item_key)
|
||||
if not attachments:
|
||||
raise HTTPException(status_code=404, detail="Item not found")
|
||||
return {"item_key": item_key, "attachments": attachments}
|
||||
|
||||
@app.get("/api/projects/{project_id}")
|
||||
def get_project(project_id: str) -> dict[str, object]:
|
||||
project_dir = config.workspace_dir / "projects" / project_id
|
||||
@ -145,9 +155,10 @@ def create_app(
|
||||
builder = CardBuilder(config.workspace_dir, resolved_client)
|
||||
selected_keys = read_selected_keys(config.bridge_file)
|
||||
items = reader.read_items(selected_keys)
|
||||
card_language = str(project_payload.get("card_language", "en"))
|
||||
imported_keys = []
|
||||
for item in items:
|
||||
builder.build_or_update(item)
|
||||
builder.build_or_update(item, card_language)
|
||||
imported_keys.append(item.item_key)
|
||||
project_view = project_service.add_items(project_id, imported_keys)
|
||||
return {"project_id": project_id, "imported_item_keys": imported_keys, "project_view": project_view}
|
||||
@ -252,6 +263,7 @@ def create_app(
|
||||
|
||||
builder = CardBuilder(config.workspace_dir, resolved_client)
|
||||
items = reader.read_items(item_keys)
|
||||
card_language = str(project_payload.get("card_language", "en"))
|
||||
|
||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
||||
items_index = project_service._read_json(items_index_path)
|
||||
@ -265,7 +277,7 @@ def create_app(
|
||||
item_key = item.item_key
|
||||
item_data = items_index.get(item_key, {})
|
||||
try:
|
||||
builder.build_or_update(item)
|
||||
builder.build_or_update(item, card_language)
|
||||
generated.append(item_key)
|
||||
card_status = "done"
|
||||
except Exception:
|
||||
|
||||
@ -5,6 +5,7 @@ import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .llm import CardGenerationClient
|
||||
from .zotero_reader import ZoteroItemRecord
|
||||
@ -15,6 +16,7 @@ class CardBuildResult:
|
||||
item_key: str
|
||||
card_path: Path
|
||||
source_hash: str
|
||||
language: str
|
||||
|
||||
|
||||
class CardBuilder:
|
||||
@ -28,26 +30,27 @@ class CardBuilder:
|
||||
for path in (self.collections_dir, self.index_dir, self.bundle_dir):
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def build_or_update(self, item: ZoteroItemRecord) -> CardBuildResult:
|
||||
source_bundle = self._build_source_bundle(item)
|
||||
def build_or_update(self, item: ZoteroItemRecord, language: str = "en") -> CardBuildResult:
|
||||
source_bundle = self._build_source_bundle(item, language)
|
||||
source_hash = self._hash_payload(source_bundle)
|
||||
source_bundle["source_hash"] = source_hash
|
||||
|
||||
bundle_path = self.bundle_dir / f"{item.item_key}.json"
|
||||
bundle_path = self.bundle_dir / f"{item.item_key}.{language}.json"
|
||||
self._write_json(bundle_path, source_bundle)
|
||||
|
||||
card_data = self.llm_client.generate_card(source_bundle)
|
||||
card_path = self._card_path(item)
|
||||
card_path = self._card_path(item, language)
|
||||
card_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
card_path.write_text(self._render_card(item, source_hash, card_data), encoding="utf-8")
|
||||
|
||||
self._update_indexes(item, card_path, source_hash, card_data)
|
||||
return CardBuildResult(item_key=item.item_key, card_path=card_path, source_hash=source_hash)
|
||||
self._update_indexes(item, card_path, source_hash, card_data, language)
|
||||
return CardBuildResult(item_key=item.item_key, card_path=card_path, source_hash=source_hash, language=language)
|
||||
|
||||
def _build_source_bundle(self, item: ZoteroItemRecord) -> dict[str, object]:
|
||||
def _build_source_bundle(self, item: ZoteroItemRecord, language: str) -> dict[str, object]:
|
||||
return {
|
||||
"item_key": item.item_key,
|
||||
"title": item.title,
|
||||
"card_language": language,
|
||||
"creators": item.creators,
|
||||
"year": item.year,
|
||||
"item_type": item.item_type,
|
||||
@ -55,6 +58,7 @@ class CardBuilder:
|
||||
"tags": item.tags,
|
||||
"collection_paths": item.collection_paths,
|
||||
"notes": item.notes,
|
||||
"attachments": item.attachments,
|
||||
"attachment_texts": item.attachment_texts,
|
||||
}
|
||||
|
||||
@ -63,9 +67,9 @@ class CardBuilder:
|
||||
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
return f"sha256:{hashlib.sha256(raw).hexdigest()}"
|
||||
|
||||
def _card_path(self, item: ZoteroItemRecord) -> Path:
|
||||
def _card_path(self, item: ZoteroItemRecord, language: str) -> Path:
|
||||
collection_path = item.collection_paths[0] if item.collection_paths else ["Unsorted"]
|
||||
filename = f"{self._sanitize_filename(item.title)} [{item.item_key}].md"
|
||||
filename = f"{self._sanitize_filename(item.title)} [{item.item_key}][{language}].md"
|
||||
return self.collections_dir.joinpath(*collection_path, filename)
|
||||
|
||||
@staticmethod
|
||||
@ -101,6 +105,8 @@ class CardBuilder:
|
||||
("Core Claims", [str(value) for value in card_data.get("core_claims", [])]),
|
||||
("Methods", [str(value) for value in card_data.get("methods", [])]),
|
||||
("Evidence", [str(value) for value in card_data.get("evidence", [])]),
|
||||
("Source Files", self._render_attachments(item.attachments)),
|
||||
("Citations", self._render_citations([value for value in card_data.get("citations", []) if isinstance(value, dict)])),
|
||||
("Quotable Passages", [str(value) for value in card_data.get("quotable_passages", [])]),
|
||||
("Writing Hints", [str(value) for value in card_data.get("writing_hints", [])]),
|
||||
]
|
||||
@ -115,12 +121,50 @@ class CardBuilder:
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip() + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_citations(raw_citations: list[dict[str, Any]]) -> list[dict[str, object]]:
|
||||
citations: list[dict[str, object]] = []
|
||||
for citation in raw_citations:
|
||||
citations.append(
|
||||
{
|
||||
"claim": str(citation.get("claim", "")),
|
||||
"quote": str(citation.get("quote", "")),
|
||||
"paraphrase": str(citation.get("paraphrase", "")),
|
||||
"quote_source": str(citation.get("quote_source", "")),
|
||||
"use_case": str(citation.get("use_case", "")),
|
||||
}
|
||||
)
|
||||
return citations
|
||||
|
||||
@classmethod
|
||||
def _render_attachments(cls, raw_attachments: list[dict[str, Any]]) -> list[str]:
|
||||
lines: list[str] = []
|
||||
for attachment in raw_attachments:
|
||||
lines.append(f"- filename: {attachment.get('filename', '')}")
|
||||
lines.append(f" path: {attachment.get('path', '')}")
|
||||
lines.append(f" content_type: {attachment.get('content_type', '')}")
|
||||
lines.append(f" is_pdf: {attachment.get('is_pdf', False)}")
|
||||
return lines
|
||||
|
||||
@classmethod
|
||||
def _render_citations(cls, raw_citations: list[dict[str, Any]]) -> list[str]:
|
||||
citations = cls._normalize_citations(raw_citations)
|
||||
lines: list[str] = []
|
||||
for citation in citations:
|
||||
lines.append(f"- claim: {citation['claim']}")
|
||||
lines.append(f" quote: {citation['quote']}")
|
||||
lines.append(f" paraphrase: {citation['paraphrase']}")
|
||||
lines.append(f" quote_source: {citation['quote_source']}")
|
||||
lines.append(f" use_case: {citation['use_case']}")
|
||||
return lines
|
||||
|
||||
def _update_indexes(
|
||||
self,
|
||||
item: ZoteroItemRecord,
|
||||
card_path: Path,
|
||||
source_hash: str,
|
||||
card_data: dict[str, object],
|
||||
language: str,
|
||||
) -> None:
|
||||
items_index = self._read_json(self.index_dir / "items.json")
|
||||
cards_index = self._read_json(self.index_dir / "cards.json")
|
||||
@ -137,18 +181,36 @@ class CardBuilder:
|
||||
"collection_paths": item.collection_paths,
|
||||
"attachment_status": "ok" if item.attachment_texts else "missing",
|
||||
"card_path": str(card_path),
|
||||
"card_paths": {
|
||||
**{
|
||||
str(key): str(value)
|
||||
for key, value in dict(items_index.get(item.item_key, {}).get("card_paths", {})).items()
|
||||
},
|
||||
language: str(card_path),
|
||||
},
|
||||
"source_hash": source_hash,
|
||||
}
|
||||
cards_index[item.item_key] = {
|
||||
existing_variants = cards_index.get(item.item_key, {})
|
||||
if not isinstance(existing_variants, dict) or any(
|
||||
key in existing_variants for key in ("summary", "claims", "citations", "attachments")
|
||||
):
|
||||
existing_variants = {"en": existing_variants} if existing_variants else {}
|
||||
existing_variants[language] = {
|
||||
"item_key": item.item_key,
|
||||
"language": language,
|
||||
"card_path": str(card_path),
|
||||
"title": item.title,
|
||||
"summary": str(card_data.get("summary", "")),
|
||||
"attachments": item.attachments,
|
||||
"keywords": [str(value) for value in card_data.get("keywords", [])],
|
||||
"claims": [str(value) for value in card_data.get("core_claims", [])],
|
||||
"citations": self._normalize_citations(
|
||||
[value for value in card_data.get("citations", []) if isinstance(value, dict)]
|
||||
),
|
||||
"quotable_spans": [str(value) for value in card_data.get("quotable_passages", [])],
|
||||
"writing_hints": [str(value) for value in card_data.get("writing_hints", [])],
|
||||
}
|
||||
cards_index[item.item_key] = existing_variants
|
||||
|
||||
for path_parts in item.collection_paths or [["Unsorted"]]:
|
||||
collection_key = "/".join(path_parts)
|
||||
|
||||
@ -17,6 +17,7 @@ class DeterministicCardGenerationClient:
|
||||
|
||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
||||
abstract = str(source_bundle.get("abstract", "")).strip()
|
||||
language = str(source_bundle.get("card_language", "en"))
|
||||
notes = [str(value).strip() for value in source_bundle.get("notes", []) if str(value).strip()]
|
||||
attachment_texts = [
|
||||
str(value).strip() for value in source_bundle.get("attachment_texts", []) if str(value).strip()
|
||||
@ -24,13 +25,24 @@ class DeterministicCardGenerationClient:
|
||||
summary = abstract or (notes[0] if notes else "") or (attachment_texts[0][:240] if attachment_texts else "")
|
||||
claims = (notes[:3] or [summary]) if summary else []
|
||||
return {
|
||||
"summary": summary,
|
||||
"core_claims": claims,
|
||||
"summary": summary if language == "en" else f"中文:{summary}",
|
||||
"core_claims": claims if language == "en" else [f"中文:{claim}" for claim in claims],
|
||||
"methods": [],
|
||||
"evidence": attachment_texts[:3],
|
||||
"citations": [
|
||||
{
|
||||
"claim": claims[0] if claims else summary,
|
||||
"quote": attachment_texts[0][:240],
|
||||
"paraphrase": summary if language == "en" else f"中文:{summary}",
|
||||
"quote_source": "attachment_texts",
|
||||
"use_case": "direct_quote",
|
||||
}
|
||||
] if attachment_texts else [],
|
||||
"quotable_passages": attachment_texts[:2],
|
||||
"writing_hints": [
|
||||
"Use this card when the writing intent overlaps with its summary or notes.",
|
||||
"Use this card when the writing intent overlaps with its summary or notes."
|
||||
if language == "en"
|
||||
else "当写作意图与摘要或笔记相关时使用这张卡片。",
|
||||
],
|
||||
"keywords": [str(value) for value in source_bundle.get("tags", [])],
|
||||
}
|
||||
@ -54,6 +66,12 @@ class DeepSeekCardGenerationClient:
|
||||
self.request_fn = request_fn or self._default_request
|
||||
|
||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
||||
language = str(source_bundle.get("card_language", "en"))
|
||||
language_instruction = (
|
||||
"Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in Chinese."
|
||||
if language == "zh"
|
||||
else "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in English."
|
||||
)
|
||||
payload = {
|
||||
"model": self.model,
|
||||
"response_format": {"type": "json_object"},
|
||||
@ -61,14 +79,32 @@ class DeepSeekCardGenerationClient:
|
||||
{
|
||||
"role": "system",
|
||||
"content": (
|
||||
"You convert research source bundles into structured JSON cards. "
|
||||
"Return a JSON object with keys: summary, core_claims, methods, evidence, "
|
||||
"quotable_passages, writing_hints, keywords."
|
||||
"You convert research source bundles into structured evidence cards for academic writing. "
|
||||
"Never hallucinate. Use only information explicitly supported by the source bundle. "
|
||||
"Preserve uncertainty and hedging. Return valid JSON only with keys: "
|
||||
"summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords."
|
||||
),
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": json.dumps(source_bundle, ensure_ascii=False),
|
||||
"content": (
|
||||
"Given this source bundle, return exactly one JSON object with keys: "
|
||||
"summary, core_claims, methods, evidence, citations, quotable_passages, writing_hints, keywords.\n\n"
|
||||
"Rules:\n"
|
||||
"1. summary: 2-4 factual sentences, no hype.\n"
|
||||
"2. core_claims: concrete findings only; preserve numbers and hedging.\n"
|
||||
"3. methods: brief method or setup details actually stated in the source.\n"
|
||||
"4. evidence: empirical or textual support from the source, numbers preferred.\n"
|
||||
"5. citations: an array of objects with keys claim, quote, paraphrase, quote_source, use_case.\n"
|
||||
"6. citations.quote must be verbatim text from notes or attachment_texts only.\n"
|
||||
"7. Return 1-3 citations whenever the source bundle contains usable supporting text in notes or attachment_texts.\n"
|
||||
"8. quote_source must identify where the quote came from, such as attachment_texts or notes.\n"
|
||||
"9. use_case must be one of direct_quote, paraphrase, or background.\n"
|
||||
"10. quotable_passages: short verbatim excerpts from notes or attachment_texts only.\n"
|
||||
"11. Use empty arrays only when the source bundle truly contains no usable supporting quote; do not fabricate.\n"
|
||||
f"12. {language_instruction}\n\n"
|
||||
f"Source bundle:\n{json.dumps(source_bundle, ensure_ascii=False)}"
|
||||
),
|
||||
},
|
||||
],
|
||||
}
|
||||
@ -84,6 +120,17 @@ class DeepSeekCardGenerationClient:
|
||||
"core_claims": [str(value) for value in parsed.get("core_claims", [])],
|
||||
"methods": [str(value) for value in parsed.get("methods", [])],
|
||||
"evidence": [str(value) for value in parsed.get("evidence", [])],
|
||||
"citations": [
|
||||
{
|
||||
"claim": str(value.get("claim", "")),
|
||||
"quote": str(value.get("quote", "")),
|
||||
"paraphrase": str(value.get("paraphrase", "")),
|
||||
"quote_source": str(value.get("quote_source", "")),
|
||||
"use_case": str(value.get("use_case", "")),
|
||||
}
|
||||
for value in parsed.get("citations", [])
|
||||
if isinstance(value, dict)
|
||||
],
|
||||
"quotable_passages": [str(value) for value in parsed.get("quotable_passages", [])],
|
||||
"writing_hints": [str(value) for value in parsed.get("writing_hints", [])],
|
||||
"keywords": [str(value) for value in parsed.get("keywords", [])],
|
||||
|
||||
@ -22,21 +22,21 @@ class ProjectService:
|
||||
return self._rebuild_project_index(project_id, selected_items)
|
||||
|
||||
def get_project_view(self, project_id: str) -> dict[str, object]:
|
||||
index_path = self._project_dir(project_id) / "project-index.json"
|
||||
if not index_path.exists():
|
||||
selected_items = self._read_selected_items(project_id)
|
||||
return self._rebuild_project_index(project_id, selected_items)
|
||||
return json.loads(index_path.read_text(encoding="utf-8"))
|
||||
selected_items = self._read_selected_items(project_id)
|
||||
return self._rebuild_project_index(project_id, selected_items)
|
||||
|
||||
def _rebuild_project_index(self, project_id: str, selected_items: list[str]) -> dict[str, object]:
|
||||
cards_index = self._read_json(self.index_dir / "cards.json")
|
||||
items_index = self._read_json(self.index_dir / "items.json")
|
||||
collections_index = self._read_json(self.index_dir / "collections.json")
|
||||
project_payload = self._read_json(self._project_dir(project_id) / "project.json")
|
||||
card_language = str(project_payload.get("card_language", "en"))
|
||||
project_items: list[dict[str, object]] = []
|
||||
project_cards: list[dict[str, object]] = []
|
||||
for item_key in selected_items:
|
||||
item_data = items_index.get(item_key, {})
|
||||
card_data = cards_index.get(item_key)
|
||||
card_variants = self._normalize_card_variants(cards_index.get(item_key))
|
||||
card_data = card_variants.get(card_language)
|
||||
item_payload = {
|
||||
"item_key": item_key,
|
||||
"title": item_data.get("title", "Untitled"),
|
||||
@ -45,7 +45,9 @@ class ProjectService:
|
||||
"item_type": item_data.get("item_type", "unknown"),
|
||||
"card_status": "done" if card_data else "pending",
|
||||
"summary": card_data.get("summary") if card_data else None,
|
||||
"attachments": card_data.get("attachments", []) if card_data else [],
|
||||
"claims": card_data.get("claims", []) if card_data else [],
|
||||
"citations": card_data.get("citations", []) if card_data else [],
|
||||
"quotable_spans": card_data.get("quotable_spans", []) if card_data else [],
|
||||
}
|
||||
project_items.append(item_payload)
|
||||
@ -58,6 +60,7 @@ class ProjectService:
|
||||
]
|
||||
payload = {
|
||||
"project_id": project_id,
|
||||
"card_language": card_language,
|
||||
"selected_items": selected_items,
|
||||
"items": project_items,
|
||||
"cards": project_cards,
|
||||
@ -75,6 +78,22 @@ class ProjectService:
|
||||
def _project_dir(self, project_id: str) -> Path:
|
||||
return self.projects_dir / project_id
|
||||
|
||||
@staticmethod
|
||||
def _normalize_card_variants(card_entry: object) -> dict[str, dict[str, object]]:
|
||||
if not isinstance(card_entry, dict):
|
||||
return {}
|
||||
if any(key in card_entry for key in ("summary", "claims", "citations", "attachments")):
|
||||
legacy_card = dict(card_entry)
|
||||
legacy_card.setdefault("language", "en")
|
||||
return {"en": legacy_card}
|
||||
variants: dict[str, dict[str, object]] = {}
|
||||
for language, payload in card_entry.items():
|
||||
if isinstance(payload, dict):
|
||||
variant = dict(payload)
|
||||
variant.setdefault("language", str(language))
|
||||
variants[str(language)] = variant
|
||||
return variants
|
||||
|
||||
@staticmethod
|
||||
def _read_json(path: Path) -> dict[str, object]:
|
||||
if not path.exists():
|
||||
|
||||
@ -16,6 +16,9 @@
|
||||
--accent-strong: #1e4d33;
|
||||
--surface: #fffdf8;
|
||||
--danger: #8b3a2a;
|
||||
--left-panel-width: 20rem;
|
||||
--right-panel-width: 26rem;
|
||||
--panel-resizer-width: 0.5rem;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@ -33,17 +36,55 @@
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: 20rem 1fr 26rem;
|
||||
grid-template-columns:
|
||||
minmax(15rem, var(--left-panel-width))
|
||||
var(--panel-resizer-width)
|
||||
minmax(0, 1fr)
|
||||
var(--panel-resizer-width)
|
||||
minmax(18rem, var(--right-panel-width));
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
padding: 1.25rem;
|
||||
border-right: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
backdrop-filter: blur(10px);
|
||||
overflow: auto;
|
||||
}
|
||||
.panel:last-child {
|
||||
.layout-resizer {
|
||||
width: var(--panel-resizer-width);
|
||||
min-width: var(--panel-resizer-width);
|
||||
cursor: col-resize;
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
transparent calc(50% - 1px),
|
||||
rgba(86, 96, 79, 0.28) calc(50% - 1px),
|
||||
rgba(86, 96, 79, 0.28) calc(50% + 1px),
|
||||
transparent calc(50% + 1px),
|
||||
transparent 100%
|
||||
);
|
||||
transition: background 140ms ease;
|
||||
}
|
||||
.layout-resizer:hover,
|
||||
body.is-resizing-panels .layout-resizer {
|
||||
background:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent 0,
|
||||
transparent calc(50% - 1px),
|
||||
rgba(44, 110, 73, 0.7) calc(50% - 1px),
|
||||
rgba(44, 110, 73, 0.7) calc(50% + 1px),
|
||||
transparent calc(50% + 1px),
|
||||
transparent 100%
|
||||
);
|
||||
}
|
||||
body.is-resizing-panels {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
.panel.left-panel,
|
||||
.panel.center-panel {
|
||||
border-right: 0;
|
||||
}
|
||||
h1, h2, h3 {
|
||||
@ -187,6 +228,19 @@
|
||||
padding-top: 0.85rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.project-item-citations {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.project-item-citation {
|
||||
padding: 0.85rem 0.95rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 251, 244, 0.7);
|
||||
}
|
||||
.project-item-citation p {
|
||||
margin: 0.35rem 0 0;
|
||||
}
|
||||
.project-item-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@ -514,8 +568,10 @@
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.layout-resizer {
|
||||
display: none;
|
||||
}
|
||||
.panel {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.modal-card {
|
||||
@ -549,8 +605,8 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<section class="panel">
|
||||
<main id="app-layout" class="layout">
|
||||
<section class="panel left-panel">
|
||||
<h1>Zotero KB</h1>
|
||||
<p>创建项目,按 Zotero collection 树批量导入文献,再在项目范围内做引用推荐与引用方案。</p>
|
||||
|
||||
@ -559,6 +615,10 @@
|
||||
<form id="create-project-form" class="stack">
|
||||
<input id="project-id" name="project_id" placeholder="project-id,例如 thesis-ch2" required />
|
||||
<input id="project-name" name="name" placeholder="项目名称" required />
|
||||
<select id="card-language" name="card_language" aria-label="卡片语言">
|
||||
<option value="zh">中文</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
<div class="row">
|
||||
<input id="llm-provider" name="llm_provider" value="deepseek" />
|
||||
<input id="llm-model" name="llm_model" value="deepseek-chat" />
|
||||
@ -627,7 +687,15 @@
|
||||
<button id="window-restore-button" type="button" class="secondary" style="width:auto;padding:0.3rem 0.5rem;font-size:0.8rem">□</button>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div
|
||||
id="left-panel-resizer"
|
||||
class="layout-resizer"
|
||||
role="separator"
|
||||
aria-label="调整左侧栏宽度"
|
||||
aria-orientation="vertical"
|
||||
></div>
|
||||
|
||||
<section class="panel center-panel">
|
||||
<div class="row" style="justify-content: space-between">
|
||||
<div>
|
||||
<h2>项目文献</h2>
|
||||
@ -674,7 +742,15 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="panel">
|
||||
<div
|
||||
id="right-panel-resizer"
|
||||
class="layout-resizer"
|
||||
role="separator"
|
||||
aria-label="调整右侧栏宽度"
|
||||
aria-orientation="vertical"
|
||||
></div>
|
||||
|
||||
<section class="panel right-panel">
|
||||
<h2>Writing</h2>
|
||||
<p>两个入口独立:候选引用推荐和初版引用方案。</p>
|
||||
|
||||
@ -706,6 +782,22 @@
|
||||
<script>
|
||||
const WINDOW_SESSION_STORAGE_KEY = "zotero-kb.import-window";
|
||||
const IMPORT_SESSION_STORAGE_KEY = "zotero-kb.import-modal";
|
||||
const PANEL_LAYOUT_STORAGE_KEY = "zotero-kb.panel-layout";
|
||||
const DESKTOP_LAYOUT_BREAKPOINT = 980;
|
||||
const MIN_LEFT_PANEL_WIDTH = 240;
|
||||
const MIN_CENTER_PANEL_WIDTH = 320;
|
||||
const MIN_RIGHT_PANEL_WIDTH = 288;
|
||||
|
||||
function readPanelLayoutState() {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(PANEL_LAYOUT_STORAGE_KEY);
|
||||
if (!raw) return {};
|
||||
const payload = JSON.parse(raw);
|
||||
return typeof payload === "object" && payload ? payload : {};
|
||||
} catch (_error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readWindowSessionState() {
|
||||
try {
|
||||
@ -719,6 +811,7 @@
|
||||
}
|
||||
|
||||
const windowSessionState = readWindowSessionState();
|
||||
const panelLayoutState = readPanelLayoutState();
|
||||
|
||||
function readImportSessionState() {
|
||||
try {
|
||||
@ -742,6 +835,7 @@
|
||||
projectItemGenerationState: {},
|
||||
editingProjectId: null,
|
||||
editingProjectName: "",
|
||||
editingProjectLanguage: "zh",
|
||||
confirmingDeleteProjectId: null,
|
||||
savingProjectId: null,
|
||||
deletingProjectId: null,
|
||||
@ -759,6 +853,12 @@
|
||||
windowDragStartY: 0,
|
||||
windowDragStartLeft: 0,
|
||||
windowDragStartTop: 0,
|
||||
leftPanelWidth: typeof panelLayoutState.leftWidth === "number" ? panelLayoutState.leftWidth : null,
|
||||
rightPanelWidth: typeof panelLayoutState.rightWidth === "number" ? panelLayoutState.rightWidth : null,
|
||||
activePanelResizeHandle: null,
|
||||
panelResizeStartX: 0,
|
||||
panelResizeStartLeftWidth: 0,
|
||||
panelResizeStartRightWidth: 0,
|
||||
selectedCollectionKey: typeof importSessionState.selectedCollectionKey === "string"
|
||||
? importSessionState.selectedCollectionKey
|
||||
: null,
|
||||
@ -786,6 +886,9 @@
|
||||
};
|
||||
|
||||
const elements = {
|
||||
appLayout: document.getElementById("app-layout"),
|
||||
leftPanelResizer: document.getElementById("left-panel-resizer"),
|
||||
rightPanelResizer: document.getElementById("right-panel-resizer"),
|
||||
projectForm: document.getElementById("create-project-form"),
|
||||
projectStatus: document.getElementById("project-status"),
|
||||
projectList: document.getElementById("project-list"),
|
||||
@ -831,6 +934,100 @@
|
||||
planPanel: document.getElementById("plan-panel"),
|
||||
};
|
||||
|
||||
function isDesktopLayout() {
|
||||
return window.innerWidth > DESKTOP_LAYOUT_BREAKPOINT;
|
||||
}
|
||||
|
||||
function getPanelResizerTotalWidth() {
|
||||
const computed = window.getComputedStyle(document.documentElement).getPropertyValue("--panel-resizer-width");
|
||||
const singleResizerWidth = parseFloat(computed) || 8;
|
||||
return singleResizerWidth * 2;
|
||||
}
|
||||
|
||||
function clampPanelWidths(leftWidth, rightWidth) {
|
||||
const available = window.innerWidth - getPanelResizerTotalWidth();
|
||||
const leftMax = Math.max(MIN_LEFT_PANEL_WIDTH, available - MIN_CENTER_PANEL_WIDTH - MIN_RIGHT_PANEL_WIDTH);
|
||||
const clampedLeft = Math.min(Math.max(leftWidth, MIN_LEFT_PANEL_WIDTH), leftMax);
|
||||
const rightMax = Math.max(MIN_RIGHT_PANEL_WIDTH, available - MIN_CENTER_PANEL_WIDTH - clampedLeft);
|
||||
const clampedRight = Math.min(Math.max(rightWidth, MIN_RIGHT_PANEL_WIDTH), rightMax);
|
||||
const adjustedLeftMax = Math.max(MIN_LEFT_PANEL_WIDTH, available - MIN_CENTER_PANEL_WIDTH - clampedRight);
|
||||
return {
|
||||
leftWidth: Math.min(clampedLeft, adjustedLeftMax),
|
||||
rightWidth: clampedRight,
|
||||
};
|
||||
}
|
||||
|
||||
function persistPanelLayoutState() {
|
||||
window.localStorage.setItem(
|
||||
PANEL_LAYOUT_STORAGE_KEY,
|
||||
JSON.stringify({
|
||||
leftWidth: state.leftPanelWidth,
|
||||
rightWidth: state.rightPanelWidth,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function applyPanelLayoutWidths() {
|
||||
if (!elements.appLayout) return;
|
||||
if (!isDesktopLayout()) {
|
||||
elements.appLayout.style.removeProperty("--left-panel-width");
|
||||
elements.appLayout.style.removeProperty("--right-panel-width");
|
||||
return;
|
||||
}
|
||||
const widths = clampPanelWidths(state.leftPanelWidth ?? 320, state.rightPanelWidth ?? 416);
|
||||
state.leftPanelWidth = widths.leftWidth;
|
||||
state.rightPanelWidth = widths.rightWidth;
|
||||
elements.appLayout.style.setProperty("--left-panel-width", `${widths.leftWidth}px`);
|
||||
elements.appLayout.style.setProperty("--right-panel-width", `${widths.rightWidth}px`);
|
||||
}
|
||||
|
||||
function syncPanelLayoutToViewport() {
|
||||
if (!isDesktopLayout()) {
|
||||
return;
|
||||
}
|
||||
applyPanelLayoutWidths();
|
||||
persistPanelLayoutState();
|
||||
}
|
||||
|
||||
function startPanelResize(handle, event) {
|
||||
if (!isDesktopLayout()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
state.activePanelResizeHandle = handle;
|
||||
state.panelResizeStartX = event.clientX;
|
||||
state.panelResizeStartLeftWidth = state.leftPanelWidth ?? 320;
|
||||
state.panelResizeStartRightWidth = state.rightPanelWidth ?? 416;
|
||||
document.body.classList.add("is-resizing-panels");
|
||||
}
|
||||
|
||||
function movePanelResize(event) {
|
||||
if (!state.activePanelResizeHandle) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
const dx = event.clientX - state.panelResizeStartX;
|
||||
const nextLeftWidth = state.activePanelResizeHandle === "left"
|
||||
? state.panelResizeStartLeftWidth + dx
|
||||
: state.panelResizeStartLeftWidth;
|
||||
const nextRightWidth = state.activePanelResizeHandle === "right"
|
||||
? state.panelResizeStartRightWidth - dx
|
||||
: state.panelResizeStartRightWidth;
|
||||
const widths = clampPanelWidths(nextLeftWidth, nextRightWidth);
|
||||
state.leftPanelWidth = widths.leftWidth;
|
||||
state.rightPanelWidth = widths.rightWidth;
|
||||
applyPanelLayoutWidths();
|
||||
}
|
||||
|
||||
function endPanelResize() {
|
||||
if (!state.activePanelResizeHandle) {
|
||||
return;
|
||||
}
|
||||
state.activePanelResizeHandle = null;
|
||||
document.body.classList.remove("is-resizing-panels");
|
||||
persistPanelLayoutState();
|
||||
}
|
||||
|
||||
function persistImportSessionState() {
|
||||
window.sessionStorage.setItem(
|
||||
IMPORT_SESSION_STORAGE_KEY,
|
||||
@ -1083,7 +1280,12 @@
|
||||
item.innerHTML = `
|
||||
<form class="project-inline-form">
|
||||
<input type="text" value="${escapeHtml(state.editingProjectName)}" aria-label="项目名称" />
|
||||
<select name="card_language" aria-label="卡片语言">
|
||||
<option value="zh" ${state.editingProjectLanguage === "zh" ? "selected" : ""}>中文</option>
|
||||
<option value="en" ${state.editingProjectLanguage === "en" ? "selected" : ""}>English</option>
|
||||
</select>
|
||||
<div class="meta">${project.id}</div>
|
||||
<div class="meta">卡片语言:${project.card_language === "zh" ? "中文" : "English"}</div>
|
||||
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
|
||||
<div class="project-list-actions">
|
||||
<button type="submit" ${isSaving ? "disabled" : ""}>保存</button>
|
||||
@ -1093,10 +1295,14 @@
|
||||
`;
|
||||
const form = item.querySelector(".project-inline-form");
|
||||
const input = form.querySelector("input");
|
||||
const languageSelect = form.querySelector('select[name="card_language"]');
|
||||
const cancelButton = form.querySelector('button[type="button"]');
|
||||
input.addEventListener("input", (event) => {
|
||||
state.editingProjectName = event.target.value;
|
||||
});
|
||||
languageSelect.addEventListener("change", (event) => {
|
||||
state.editingProjectLanguage = event.target.value;
|
||||
});
|
||||
form.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
saveProjectRename(project.id).catch(() => null);
|
||||
@ -1123,6 +1329,7 @@
|
||||
<button type="button" class="project-list-main">
|
||||
<strong>${escapeHtml(project.name)}</strong>
|
||||
<div class="meta">${project.id}</div>
|
||||
<div class="meta">卡片语言:${project.card_language === "zh" ? "中文" : "English"}</div>
|
||||
<div class="meta">${project.llm?.provider || "deterministic"} / ${project.llm?.model || "-"}</div>
|
||||
</button>
|
||||
<div class="project-list-actions">
|
||||
@ -1148,6 +1355,7 @@
|
||||
}
|
||||
state.editingProjectId = projectId;
|
||||
state.editingProjectName = project.name || "";
|
||||
state.editingProjectLanguage = project.card_language || "zh";
|
||||
state.confirmingDeleteProjectId = null;
|
||||
renderProjects();
|
||||
}
|
||||
@ -1155,6 +1363,7 @@
|
||||
function cancelProjectRename() {
|
||||
state.editingProjectId = null;
|
||||
state.editingProjectName = "";
|
||||
state.editingProjectLanguage = "zh";
|
||||
state.savingProjectId = null;
|
||||
renderProjects();
|
||||
}
|
||||
@ -1170,10 +1379,11 @@
|
||||
try {
|
||||
await api(`/api/projects/${projectId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({ name: nextName }),
|
||||
body: JSON.stringify({ name: nextName, card_language: state.editingProjectLanguage }),
|
||||
});
|
||||
state.editingProjectId = null;
|
||||
state.editingProjectName = "";
|
||||
state.editingProjectLanguage = "zh";
|
||||
state.savingProjectId = null;
|
||||
setStatus(elements.projectStatus, "项目名称已更新。");
|
||||
await loadProjects();
|
||||
@ -1188,6 +1398,7 @@
|
||||
state.confirmingDeleteProjectId = projectId;
|
||||
state.editingProjectId = null;
|
||||
state.editingProjectName = "";
|
||||
state.editingProjectLanguage = "zh";
|
||||
renderProjects();
|
||||
}
|
||||
|
||||
@ -1245,6 +1456,18 @@
|
||||
return status === "pending" || status === "failed";
|
||||
}
|
||||
|
||||
function canGenerateProjectItemInBatch(status) {
|
||||
return canGenerateProjectItem(status);
|
||||
}
|
||||
|
||||
function canGenerateProjectItemFromCard(status) {
|
||||
return status === "pending" || status === "failed" || status === "done";
|
||||
}
|
||||
|
||||
function getProjectItemGenerateButtonLabel(status) {
|
||||
return status === "done" ? "重新生成" : "生成";
|
||||
}
|
||||
|
||||
function updateBatchGenerateControls() {
|
||||
const hasProject = Boolean(state.currentProjectId);
|
||||
const hasItems = state.currentProjectItems.length > 0;
|
||||
@ -1342,7 +1565,7 @@
|
||||
function selectAllPendingBatchItems() {
|
||||
state.batchGenerateSelectedKeys = new Set(
|
||||
state.currentProjectItems
|
||||
.filter((item) => canGenerateProjectItem(getDisplayedProjectItemStatus(item)))
|
||||
.filter((item) => canGenerateProjectItemInBatch(getDisplayedProjectItemStatus(item)))
|
||||
.map((item) => item.item_key)
|
||||
);
|
||||
renderBatchGenerateList();
|
||||
@ -1422,7 +1645,7 @@
|
||||
</div>
|
||||
<div class="project-item-actions">
|
||||
<span class="project-item-status-pill ${status}">${statusLabel}</span>
|
||||
${canGenerateProjectItem(status) ? '<button type="button" class="secondary project-item-generate-button">生成</button>' : ''}
|
||||
${canGenerateProjectItemFromCard(status) ? `<button type="button" class="secondary project-item-generate-button">${getProjectItemGenerateButtonLabel(status)}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<p>${status === "done" ? (escapeHtml(item.summary || "暂无摘要")) : (status === "generating" ? "正在生成卡片内容,请稍候。" : (status === "failed" ? "生成失败,可重试。" : "已导入项目,尚未生成卡片。"))}</p>
|
||||
@ -1446,15 +1669,18 @@
|
||||
const detail = document.createElement("div");
|
||||
detail.className = "project-item-detail";
|
||||
if (status === "done") {
|
||||
const attachments = renderProjectItemAttachments(item.attachments || []);
|
||||
const claims = (item.claims || []).map((claim) => `<li>${escapeHtml(claim)}</li>`).join("") || "<li>暂无 claims</li>";
|
||||
const quotes = (item.quotable_spans || []).map((quote) => `<li>${escapeHtml(quote)}</li>`).join("") || "<li>暂无可引用片段</li>";
|
||||
const citations = renderProjectItemCitations(item.citations || []);
|
||||
detail.innerHTML = `
|
||||
<div class="meta">${item.item_key}</div>
|
||||
<p>${escapeHtml(item.summary || "暂无摘要")}</p>
|
||||
<h4>Source Files</h4>
|
||||
${attachments}
|
||||
<h4>Claims</h4>
|
||||
<ul>${claims}</ul>
|
||||
<h4>Quotable</h4>
|
||||
<ul>${quotes}</ul>
|
||||
<h4>Citations</h4>
|
||||
${citations}
|
||||
`;
|
||||
} else {
|
||||
detail.innerHTML = `
|
||||
@ -1503,10 +1729,47 @@
|
||||
`).join("");
|
||||
}
|
||||
|
||||
function renderProjectItemCitations(citations) {
|
||||
if (!Array.isArray(citations) || !citations.length) {
|
||||
return '<div class="empty">暂无可直接用于写作的引用证据。</div>';
|
||||
}
|
||||
const items = citations.map((citation) => `
|
||||
<div class="project-item-citation">
|
||||
<strong>${escapeHtml(citation.claim || "未命名 claim")}</strong>
|
||||
<div class="meta">${escapeHtml(citation.use_case || "unspecified")} · ${escapeHtml(citation.quote_source || "unknown source")}</div>
|
||||
<p>${escapeHtml(citation.quote || "暂无原文引文")}</p>
|
||||
<p class="meta">${escapeHtml(citation.paraphrase || "暂无转述建议")}</p>
|
||||
</div>
|
||||
`).join("");
|
||||
return `<div class="project-item-citations">${items}</div>`;
|
||||
}
|
||||
|
||||
function renderProjectItemAttachments(attachments) {
|
||||
if (!Array.isArray(attachments) || !attachments.length) {
|
||||
return '<div class="empty">暂无原文附件信息。</div>';
|
||||
}
|
||||
const items = attachments.map((attachment) => `
|
||||
<div class="project-item-citation">
|
||||
<strong>${escapeHtml(attachment.filename || "unnamed file")}</strong>
|
||||
<div class="meta">${escapeHtml(attachment.content_type || "unknown type")} · ${attachment.is_pdf ? "PDF" : "非 PDF"}</div>
|
||||
<p class="meta">${escapeHtml(attachment.path || "")}</p>
|
||||
</div>
|
||||
`).join("");
|
||||
return `<div class="project-item-citations">${items}</div>`;
|
||||
}
|
||||
|
||||
function renderSelectedCount() {
|
||||
elements.windowSelectedCount.textContent = `已选 ${state.selectedItemKeys.size} 篇`;
|
||||
}
|
||||
|
||||
function getCurrentProjectItemKeySet() {
|
||||
return new Set((state.currentProjectItems || []).map((item) => item.item_key));
|
||||
}
|
||||
|
||||
function isItemInCurrentProject(itemKey) {
|
||||
return getCurrentProjectItemKeySet().has(itemKey);
|
||||
}
|
||||
|
||||
function resetCollectionItemsLoadingState(clearItems = false) {
|
||||
if (clearItems) {
|
||||
state.visibleCollectionItems = [];
|
||||
@ -1669,6 +1932,7 @@
|
||||
|
||||
elements.windowCollectionItems.innerHTML = "";
|
||||
for (const item of state.visibleCollectionItems) {
|
||||
const alreadyInProject = isItemInCurrentProject(item.item_key);
|
||||
const row = document.createElement("label");
|
||||
row.className = "project-item";
|
||||
row.style.display = "grid";
|
||||
@ -1676,13 +1940,16 @@
|
||||
row.style.gap = "0.75rem";
|
||||
row.style.alignItems = "start";
|
||||
row.innerHTML = `
|
||||
<input type="checkbox" ${state.selectedItemKeys.has(item.item_key) ? "checked" : ""} />
|
||||
<input type="checkbox" ${alreadyInProject ? "checked disabled" : (state.selectedItemKeys.has(item.item_key) ? "checked" : "")} />
|
||||
<div>
|
||||
<strong>${item.title}</strong>
|
||||
<div class="meta">${item.item_key} · ${item.year || "-"} · ${item.item_type || "-"}</div>
|
||||
${alreadyInProject ? '<div class="meta">已在项目中</div>' : ''}
|
||||
</div>
|
||||
`;
|
||||
row.querySelector("input").addEventListener("change", () => toggleItemSelection(item.item_key));
|
||||
if (!alreadyInProject) {
|
||||
row.querySelector("input").addEventListener("change", () => toggleItemSelection(item.item_key));
|
||||
}
|
||||
elements.windowCollectionItems.appendChild(row);
|
||||
}
|
||||
updateImportActionState();
|
||||
@ -1755,6 +2022,9 @@
|
||||
}
|
||||
|
||||
function toggleItemSelection(itemKey) {
|
||||
if (isItemInCurrentProject(itemKey)) {
|
||||
return;
|
||||
}
|
||||
if (state.selectedItemKeys.has(itemKey)) {
|
||||
state.selectedItemKeys.delete(itemKey);
|
||||
} else {
|
||||
@ -1767,6 +2037,9 @@
|
||||
|
||||
function selectVisibleItems() {
|
||||
for (const item of state.visibleCollectionItems) {
|
||||
if (isItemInCurrentProject(item.item_key)) {
|
||||
continue;
|
||||
}
|
||||
state.selectedItemKeys.add(item.item_key);
|
||||
}
|
||||
persistImportSessionState();
|
||||
@ -1897,6 +2170,7 @@
|
||||
});
|
||||
setStatus(elements.projectStatus, "项目已创建。");
|
||||
elements.projectForm.reset();
|
||||
document.getElementById("card-language").value = "zh";
|
||||
document.getElementById("llm-provider").value = "deepseek";
|
||||
document.getElementById("llm-model").value = "deepseek-chat";
|
||||
await loadProjects();
|
||||
@ -1932,10 +2206,15 @@
|
||||
if (e.target === elements.windowRestoreButton) return;
|
||||
restoreWindow();
|
||||
});
|
||||
elements.leftPanelResizer.addEventListener("pointerdown", (event) => startPanelResize("left", event));
|
||||
elements.rightPanelResizer.addEventListener("pointerdown", (event) => startPanelResize("right", event));
|
||||
document.addEventListener("pointermove", movePanelResize);
|
||||
document.addEventListener("pointerup", endPanelResize);
|
||||
elements.windowToolbar.addEventListener("pointerdown", startWindowDrag);
|
||||
document.addEventListener("pointermove", moveWindowDrag);
|
||||
document.addEventListener("pointerup", endWindowDrag);
|
||||
window.addEventListener("resize", () => {
|
||||
syncPanelLayoutToViewport();
|
||||
if (!state.isImportWindowOpen) {
|
||||
return;
|
||||
}
|
||||
@ -2007,6 +2286,7 @@
|
||||
elements.planTab.addEventListener("click", () => activateTab("plan"));
|
||||
|
||||
closeImportWindow();
|
||||
applyPanelLayoutWidths();
|
||||
updateImportActionState();
|
||||
loadProjects().catch((error) => {
|
||||
setStatus(elements.projectStatus, error.message, true);
|
||||
|
||||
@ -14,6 +14,7 @@ class ProjectRecord:
|
||||
project_id: str
|
||||
name: str
|
||||
project_dir: Path
|
||||
card_language: str
|
||||
|
||||
|
||||
class Workspace:
|
||||
@ -36,6 +37,7 @@ class Workspace:
|
||||
name: str,
|
||||
llm_provider: str,
|
||||
llm_model: str,
|
||||
card_language: str,
|
||||
) -> ProjectRecord:
|
||||
self.ensure_layout()
|
||||
project_dir = self.config.workspace_dir / "projects" / project_id
|
||||
@ -51,6 +53,7 @@ class Workspace:
|
||||
"model": llm_model,
|
||||
"base_url": None,
|
||||
},
|
||||
"card_language": card_language,
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
@ -58,9 +61,9 @@ class Workspace:
|
||||
self._write_json(project_dir / "selected-items.json", [])
|
||||
self._write_json(project_dir / "project-index.json", {"items": []})
|
||||
|
||||
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir)
|
||||
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir, card_language=card_language)
|
||||
|
||||
def rename_project(self, project_id: str, name: str) -> ProjectRecord:
|
||||
def rename_project(self, project_id: str, name: str, card_language: str | None = None) -> ProjectRecord:
|
||||
project_dir = self.config.workspace_dir / "projects" / project_id
|
||||
project_file = project_dir / "project.json"
|
||||
if not project_file.exists():
|
||||
@ -68,8 +71,15 @@ class Workspace:
|
||||
|
||||
project_payload = json.loads(project_file.read_text(encoding="utf-8"))
|
||||
project_payload["name"] = name
|
||||
if card_language is not None:
|
||||
project_payload["card_language"] = card_language
|
||||
self._write_json(project_file, project_payload)
|
||||
return ProjectRecord(project_id=project_id, name=name, project_dir=project_dir)
|
||||
return ProjectRecord(
|
||||
project_id=project_id,
|
||||
name=name,
|
||||
project_dir=project_dir,
|
||||
card_language=str(project_payload.get("card_language", "en")),
|
||||
)
|
||||
|
||||
def delete_project(self, project_id: str) -> None:
|
||||
project_dir = self.config.workspace_dir / "projects" / project_id
|
||||
|
||||
@ -19,6 +19,7 @@ class ZoteroItemRecord:
|
||||
tags: list[str]
|
||||
collection_paths: list[list[str]]
|
||||
notes: list[str]
|
||||
attachments: list[dict[str, object]]
|
||||
attachment_texts: list[str]
|
||||
|
||||
|
||||
@ -52,6 +53,7 @@ class ZoteroReader:
|
||||
|
||||
item_id = int(item_row["itemID"])
|
||||
item_data = self._read_item_data(conn, item_id, field_lookup)
|
||||
attachment_records = self._read_attachment_records(conn, item_id)
|
||||
items.append(
|
||||
ZoteroItemRecord(
|
||||
item_key=item_row["key"],
|
||||
@ -63,13 +65,20 @@ class ZoteroReader:
|
||||
tags=self._read_tags(conn, item_id),
|
||||
collection_paths=self._read_collection_paths(conn, item_id, collection_lookup),
|
||||
notes=self._read_notes(conn, item_id),
|
||||
attachment_texts=self._read_attachment_texts(conn, item_id),
|
||||
attachments=[self._public_attachment_metadata(attachment) for attachment in attachment_records],
|
||||
attachment_texts=[str(attachment["text"]) for attachment in attachment_records if attachment.get("text")],
|
||||
)
|
||||
)
|
||||
return items
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def get_item_attachments(self, item_key: str) -> list[dict[str, object]]:
|
||||
items = self.read_items([item_key])
|
||||
if not items:
|
||||
return []
|
||||
return items[0].attachments
|
||||
|
||||
def search_items(self, query: str, limit: int = 20) -> list[dict[str, object]]:
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
@ -389,7 +398,7 @@ class ZoteroReader:
|
||||
)
|
||||
return [self._strip_markup(str(row["note"])) for row in rows]
|
||||
|
||||
def _read_attachment_texts(self, conn: sqlite3.Connection, item_id: int) -> list[str]:
|
||||
def _read_attachment_records(self, conn: sqlite3.Connection, item_id: int) -> list[dict[str, object]]:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
select contentType, path
|
||||
@ -399,16 +408,42 @@ class ZoteroReader:
|
||||
""",
|
||||
(item_id,),
|
||||
)
|
||||
texts: list[str] = []
|
||||
attachments: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
text = self._extract_attachment_text(str(row["path"] or ""), str(row["contentType"] or ""))
|
||||
if text:
|
||||
texts.append(text)
|
||||
return texts
|
||||
raw_path = str(row["path"] or "")
|
||||
content_type = str(row["contentType"] or "")
|
||||
attachment_path = self._resolve_attachment_path(raw_path)
|
||||
if attachment_path is None:
|
||||
continue
|
||||
text = self._extract_attachment_text_from_resolved_path(attachment_path, content_type)
|
||||
attachments.append(
|
||||
{
|
||||
"path": str(attachment_path),
|
||||
"filename": attachment_path.name,
|
||||
"content_type": content_type,
|
||||
"is_pdf": content_type == "application/pdf",
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
return attachments
|
||||
|
||||
@staticmethod
|
||||
def _public_attachment_metadata(attachment: dict[str, object]) -> dict[str, object]:
|
||||
return {
|
||||
"path": attachment["path"],
|
||||
"filename": attachment["filename"],
|
||||
"content_type": attachment["content_type"],
|
||||
"is_pdf": attachment["is_pdf"],
|
||||
}
|
||||
|
||||
def _extract_attachment_text(self, raw_path: str, content_type: str) -> str:
|
||||
attachment_path = self._resolve_attachment_path(raw_path)
|
||||
if attachment_path is None or not attachment_path.exists():
|
||||
if attachment_path is None:
|
||||
return ""
|
||||
return self._extract_attachment_text_from_resolved_path(attachment_path, content_type)
|
||||
|
||||
def _extract_attachment_text_from_resolved_path(self, attachment_path: Path, content_type: str) -> str:
|
||||
if not attachment_path.exists():
|
||||
return ""
|
||||
|
||||
if content_type == "application/pdf":
|
||||
|
||||
@ -12,9 +12,10 @@ from zotero_kb.config import AppConfig
|
||||
class FakeLlmClient:
|
||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
||||
title = str(source_bundle["title"])
|
||||
language = str(source_bundle.get("card_language", "en"))
|
||||
return {
|
||||
"summary": f"Summary for {title}",
|
||||
"core_claims": [f"{title} supports scoped retrieval."],
|
||||
"summary": f"中文摘要 {title}" if language == "zh" else f"Summary for {title}",
|
||||
"core_claims": [f"中文论点 {title}"] if language == "zh" else [f"{title} supports scoped retrieval."],
|
||||
"methods": ["Method details."],
|
||||
"evidence": ["Evidence details."],
|
||||
"quotable_passages": [f"{title} supports scoped retrieval."],
|
||||
@ -53,6 +54,7 @@ def test_create_project_endpoint(tmp_path: Path) -> None:
|
||||
name="Thesis Chapter 2",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
card_language="zh",
|
||||
)
|
||||
)
|
||||
|
||||
@ -71,14 +73,16 @@ def test_rename_project_endpoint_updates_project_name(tmp_path: Path) -> None:
|
||||
name="Old Name",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
card_language="en",
|
||||
)
|
||||
)
|
||||
|
||||
response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name"))
|
||||
response = rename_project("thesis-ch2", RenameProjectRequest(name="New Name", card_language="zh"))
|
||||
|
||||
assert response["id"] == "thesis-ch2"
|
||||
assert response["name"] == "New Name"
|
||||
assert list_projects()[0]["name"] == "New Name"
|
||||
assert list_projects()[0]["card_language"] == "zh"
|
||||
|
||||
|
||||
def test_delete_project_endpoint_removes_project_from_list(tmp_path: Path) -> None:
|
||||
@ -113,6 +117,7 @@ def test_delete_project_post_fallback_endpoint_removes_project_from_list(tmp_pat
|
||||
"name": "Thesis Chapter 2",
|
||||
"llm_provider": "openai",
|
||||
"llm_model": "gpt-5-mini",
|
||||
"card_language": "zh",
|
||||
},
|
||||
)
|
||||
assert create_response.status_code == 201
|
||||
@ -135,6 +140,7 @@ def test_import_selected_items_endpoint(tmp_path: Path) -> None:
|
||||
name="Thesis Chapter 2",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
card_language="zh",
|
||||
)
|
||||
)
|
||||
|
||||
@ -155,6 +161,7 @@ def test_recommend_citations_endpoint(tmp_path: Path) -> None:
|
||||
name="Thesis Chapter 2",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
card_language="zh",
|
||||
)
|
||||
)
|
||||
import_selected("thesis-ch2")
|
||||
@ -177,6 +184,7 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None:
|
||||
name="Thesis Chapter 2",
|
||||
llm_provider="deepseek",
|
||||
llm_model="deepseek-chat",
|
||||
card_language="en",
|
||||
)
|
||||
)
|
||||
|
||||
@ -185,6 +193,7 @@ def test_create_project_persists_deepseek_provider(tmp_path: Path) -> None:
|
||||
)
|
||||
assert project_payload["llm"]["provider"] == "deepseek"
|
||||
assert project_payload["llm"]["model"] == "deepseek-chat"
|
||||
assert project_payload["card_language"] == "en"
|
||||
|
||||
|
||||
def test_search_library_items_endpoint(tmp_path: Path) -> None:
|
||||
@ -216,6 +225,18 @@ def test_collection_items_endpoint_includes_descendants(tmp_path: Path) -> None:
|
||||
assert payload["collection_key"] == "COLL0001"
|
||||
|
||||
|
||||
def test_item_attachments_endpoint_returns_attachment_metadata(tmp_path: Path) -> None:
|
||||
app = create_app(make_test_config(tmp_path), llm_client=FakeLlmClient())
|
||||
endpoint = _route(app, "/api/zotero/items/{item_key}/attachments", "GET")
|
||||
|
||||
payload = endpoint("PAPER0001")
|
||||
|
||||
assert payload["item_key"] == "PAPER0001"
|
||||
assert payload["attachments"][0]["filename"] == "paper.txt"
|
||||
assert payload["attachments"][0]["content_type"] == "text/plain"
|
||||
assert payload["attachments"][0]["is_pdf"] is False
|
||||
|
||||
|
||||
def test_import_item_keys_endpoint_without_bridge(tmp_path: Path) -> None:
|
||||
config = make_test_config(tmp_path)
|
||||
config.bridge_file.unlink()
|
||||
@ -369,6 +390,33 @@ def test_cards_generate_creates_cards_for_pending_items(tmp_path: Path) -> None:
|
||||
assert response["items"][0]["card_status"] == "done"
|
||||
|
||||
|
||||
def test_cards_generate_allows_regeneration_for_done_items(tmp_path: Path) -> None:
|
||||
config = make_test_config(tmp_path)
|
||||
config.bridge_file.unlink()
|
||||
app = create_app(config, llm_client=FakeLlmClient())
|
||||
create_project = _route(app, "/api/projects", "POST")
|
||||
import_keys = _route(app, "/api/projects/{project_id}/imports/item-keys", "POST")
|
||||
generate_cards = _route(app, "/api/projects/{project_id}/cards/generate", "POST")
|
||||
|
||||
create_project(
|
||||
CreateProjectRequest(
|
||||
project_id="thesis-ch2",
|
||||
name="Thesis Chapter 2",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
)
|
||||
)
|
||||
|
||||
import_keys("thesis-ch2", {"item_keys": ["PAPER0001"]})
|
||||
first_response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
|
||||
second_response = generate_cards("thesis-ch2", GenerateCardsRequest(item_keys=["PAPER0001"]))
|
||||
|
||||
assert first_response["generated"] == ["PAPER0001"]
|
||||
assert second_response["generated"] == ["PAPER0001"]
|
||||
assert second_response["failed"] == []
|
||||
assert second_response["items"][0]["card_status"] == "done"
|
||||
|
||||
|
||||
def test_import_item_keys_endpoint_returns_pending_item_in_project_view(tmp_path: Path) -> None:
|
||||
config = make_test_config(tmp_path)
|
||||
config.bridge_file.unlink()
|
||||
|
||||
@ -7,10 +7,11 @@ from zotero_kb.zotero_reader import ZoteroItemRecord
|
||||
|
||||
class FakeLlmClient:
|
||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
||||
language = str(source_bundle.get("card_language", "en"))
|
||||
return {
|
||||
"summary": "Merged metadata and attachment text improve drafting support.",
|
||||
"summary": "中文卡片摘要。" if language == "zh" else "Merged metadata and attachment text improve drafting support.",
|
||||
"core_claims": [
|
||||
"Project-scoped cards reduce irrelevant retrieval.",
|
||||
"中文卡片 claim。" if language == "zh" else "Project-scoped cards reduce irrelevant retrieval.",
|
||||
],
|
||||
"methods": [
|
||||
"Combines notes, metadata, and full text.",
|
||||
@ -18,6 +19,15 @@ class FakeLlmClient:
|
||||
"evidence": [
|
||||
"The merged pipeline improved citation precision.",
|
||||
],
|
||||
"citations": [
|
||||
{
|
||||
"claim": "Project-scoped cards reduce irrelevant retrieval.",
|
||||
"quote": "Project-scoped card retrieval improves citation precision during drafting.",
|
||||
"paraphrase": "Scoped retrieval improved citation precision during drafting.",
|
||||
"quote_source": "attachment_texts",
|
||||
"use_case": "direct_quote",
|
||||
}
|
||||
],
|
||||
"quotable_passages": [
|
||||
"Project-scoped card retrieval improves citation precision during drafting.",
|
||||
],
|
||||
@ -39,11 +49,19 @@ def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None:
|
||||
tags=["llm", "writing"],
|
||||
collection_paths=[["Theory", "Drafting"]],
|
||||
notes=["Merged notes matter."],
|
||||
attachments=[
|
||||
{
|
||||
"path": str(tmp_path / "zotero" / "storage" / "ATTACH01" / "paper.txt"),
|
||||
"filename": "paper.txt",
|
||||
"content_type": "text/plain",
|
||||
"is_pdf": False,
|
||||
}
|
||||
],
|
||||
attachment_texts=["This paper studies card pipelines for research writing."],
|
||||
)
|
||||
builder = CardBuilder(workspace_dir=tmp_path, llm_client=FakeLlmClient())
|
||||
|
||||
result = builder.build_or_update(item)
|
||||
result = builder.build_or_update(item, "en")
|
||||
|
||||
expected_path = (
|
||||
tmp_path
|
||||
@ -51,18 +69,30 @@ def test_build_card_writes_markdown_and_indexes(tmp_path: Path) -> None:
|
||||
/ "collections"
|
||||
/ "Theory"
|
||||
/ "Drafting"
|
||||
/ "Card Pipelines for Research Writing [PAPER0001].md"
|
||||
/ "Card Pipelines for Research Writing [PAPER0001][en].md"
|
||||
)
|
||||
assert result.card_path == expected_path
|
||||
assert result.card_path.read_text(encoding="utf-8").startswith("---")
|
||||
card_text = result.card_path.read_text(encoding="utf-8")
|
||||
assert card_text.startswith("---")
|
||||
assert "# Citations" in card_text
|
||||
assert "quote_source: attachment_texts" in card_text
|
||||
assert "locator:" not in card_text
|
||||
|
||||
cards_index = json.loads((tmp_path / "library" / "index" / "cards.json").read_text(encoding="utf-8"))
|
||||
assert cards_index["PAPER0001"]["title"] == "Card Pipelines for Research Writing"
|
||||
assert cards_index["PAPER0001"]["en"]["title"] == "Card Pipelines for Research Writing"
|
||||
assert cards_index["PAPER0001"]["en"]["language"] == "en"
|
||||
assert cards_index["PAPER0001"]["en"]["citations"][0]["use_case"] == "direct_quote"
|
||||
assert "locator" not in cards_index["PAPER0001"]["en"]["citations"][0]
|
||||
assert cards_index["PAPER0001"]["en"]["attachments"][0]["filename"] == "paper.txt"
|
||||
assert cards_index["PAPER0001"]["en"]["attachments"][0]["is_pdf"] is False
|
||||
|
||||
items_index = json.loads((tmp_path / "library" / "index" / "items.json").read_text(encoding="utf-8"))
|
||||
assert items_index["PAPER0001"]["card_path"] == str(expected_path)
|
||||
assert items_index["PAPER0001"]["card_paths"]["en"] == str(expected_path)
|
||||
|
||||
source_bundle = json.loads(
|
||||
(tmp_path / "library" / "cache" / "source-bundles" / "PAPER0001.json").read_text(encoding="utf-8")
|
||||
(tmp_path / "library" / "cache" / "source-bundles" / "PAPER0001.en.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert source_bundle["item_key"] == "PAPER0001"
|
||||
assert source_bundle["card_language"] == "en"
|
||||
assert source_bundle["attachments"][0]["filename"] == "paper.txt"
|
||||
|
||||
@ -27,6 +27,15 @@ def test_deepseek_client_uses_env_api_key_and_parses_json() -> None:
|
||||
"core_claims": ["Claim A"],
|
||||
"methods": ["Method A"],
|
||||
"evidence": ["Evidence A"],
|
||||
"citations": [
|
||||
{
|
||||
"claim": "Claim A",
|
||||
"quote": "Exact supporting quote.",
|
||||
"paraphrase": "Paraphrased support.",
|
||||
"quote_source": "attachment_texts",
|
||||
"use_case": "direct_quote",
|
||||
}
|
||||
],
|
||||
"quotable_passages": ["Quote A"],
|
||||
"writing_hints": ["Hint A"],
|
||||
"keywords": ["kw-a"],
|
||||
@ -47,6 +56,7 @@ def test_deepseek_client_uses_env_api_key_and_parses_json() -> None:
|
||||
{
|
||||
"title": "Scoped Retrieval for Drafting",
|
||||
"abstract": "Merged metadata improves drafting.",
|
||||
"card_language": "zh",
|
||||
"notes": ["Project scoping helps."],
|
||||
"attachment_texts": ["Attachment evidence."],
|
||||
"tags": ["retrieval"],
|
||||
@ -54,8 +64,12 @@ def test_deepseek_client_uses_env_api_key_and_parses_json() -> None:
|
||||
)
|
||||
|
||||
assert result["summary"] == "DeepSeek summary"
|
||||
assert "locator" not in result["citations"][0]
|
||||
assert captured["url"] == "https://api.deepseek.com/v1/chat/completions"
|
||||
assert captured["payload"]["model"] == "deepseek-chat"
|
||||
assert "Return 1-3 citations whenever the source bundle contains usable supporting text" in captured["payload"]["messages"][1]["content"]
|
||||
assert "quote_source must identify where the quote came from" in captured["payload"]["messages"][1]["content"]
|
||||
assert "Write summary, core_claims, methods, evidence, paraphrase fields, and writing_hints in Chinese." in captured["payload"]["messages"][1]["content"]
|
||||
assert captured["headers"]["Authorization"] == "Bearer sk-test"
|
||||
|
||||
|
||||
@ -66,7 +80,7 @@ def test_deepseek_client_strips_markdown_fences() -> None:
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "```json\n{\"summary\":\"Fence summary\",\"core_claims\":[],\"methods\":[],\"evidence\":[],\"quotable_passages\":[],\"writing_hints\":[],\"keywords\":[]}\n```"
|
||||
"content": "```json\n{\"summary\":\"Fence summary\",\"core_claims\":[],\"methods\":[],\"evidence\":[],\"citations\":[],\"quotable_passages\":[],\"writing_hints\":[],\"keywords\":[]}\n```"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@ -11,12 +11,13 @@ from zotero_kb.zotero_reader import ZoteroItemRecord
|
||||
class FakeLlmClient:
|
||||
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
|
||||
title = str(source_bundle["title"])
|
||||
language = str(source_bundle.get("card_language", "en"))
|
||||
if "Scoped" in title:
|
||||
summary = "Project-scoped retrieval improves drafting."
|
||||
claims = ["Project scoping improves citation precision."]
|
||||
summary = "项目范围检索能改善写作。" if language == "zh" else "Project-scoped retrieval improves drafting."
|
||||
claims = ["项目范围限定能提升引文精度。"] if language == "zh" else ["Project scoping improves citation precision."]
|
||||
else:
|
||||
summary = "Unrelated retrieval baseline."
|
||||
claims = ["Baseline retrieval is broad."]
|
||||
summary = "无关检索基线。" if language == "zh" else "Unrelated retrieval baseline."
|
||||
claims = ["基线检索范围较宽。"] if language == "zh" else ["Baseline retrieval is broad."]
|
||||
return {
|
||||
"summary": summary,
|
||||
"core_claims": claims,
|
||||
@ -39,6 +40,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
|
||||
tags=["retrieval"],
|
||||
collection_paths=[["Theory", "Drafting"]],
|
||||
notes=[title],
|
||||
attachments=[],
|
||||
attachment_texts=[title],
|
||||
)
|
||||
|
||||
@ -46,7 +48,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
|
||||
def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
|
||||
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
|
||||
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
|
||||
|
||||
@ -65,7 +67,7 @@ def test_add_item_to_project_updates_selected_items_and_project_index(tmp_path:
|
||||
def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
|
||||
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
|
||||
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
|
||||
|
||||
@ -79,7 +81,7 @@ def test_remove_item_from_project_updates_selected_items(tmp_path: Path) -> None
|
||||
def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh")
|
||||
|
||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
||||
items_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
@ -106,3 +108,122 @@ def test_add_item_to_project_returns_pending_item_when_card_not_generated(tmp_pa
|
||||
assert len(payload["items"]) == 1
|
||||
assert payload["items"][0]["item_key"] == "PAPER0001"
|
||||
assert payload["items"][0]["card_status"] == "pending"
|
||||
|
||||
|
||||
def test_project_view_treats_legacy_cards_as_english_only(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh")
|
||||
|
||||
cards_index_path = config.workspace_dir / "library" / "index" / "cards.json"
|
||||
cards_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cards_index_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"PAPER0001": {
|
||||
"item_key": "PAPER0001",
|
||||
"title": "Legacy Card",
|
||||
"summary": "English legacy summary",
|
||||
"claims": ["Legacy claim"],
|
||||
"citations": [],
|
||||
"quotable_spans": [],
|
||||
"writing_hints": [],
|
||||
"attachments": [],
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
||||
items_index_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"PAPER0001": {
|
||||
"item_key": "PAPER0001",
|
||||
"title": "Legacy Card",
|
||||
"creators": ["Alice Smith"],
|
||||
"year": "2024",
|
||||
"item_type": "journalArticle",
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
service = ProjectService(config.workspace_dir)
|
||||
payload = service.add_items("thesis-ch2", ["PAPER0001"])
|
||||
|
||||
assert payload["items"][0]["card_status"] == "pending"
|
||||
workspace.rename_project("thesis-ch2", "Thesis Chapter 2", "en")
|
||||
english_view = service.get_project_view("thesis-ch2")
|
||||
assert english_view["items"][0]["card_status"] == "done"
|
||||
assert english_view["items"][0]["summary"] == "English legacy summary"
|
||||
|
||||
|
||||
def test_project_view_reads_only_current_language_card_variant(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "zh")
|
||||
|
||||
cards_index_path = config.workspace_dir / "library" / "index" / "cards.json"
|
||||
cards_index_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cards_index_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"PAPER0001": {
|
||||
"zh": {
|
||||
"item_key": "PAPER0001",
|
||||
"language": "zh",
|
||||
"title": "Bilingual Card",
|
||||
"summary": "中文摘要",
|
||||
"claims": ["中文 claim"],
|
||||
"citations": [],
|
||||
"quotable_spans": [],
|
||||
"writing_hints": [],
|
||||
"attachments": [],
|
||||
},
|
||||
"en": {
|
||||
"item_key": "PAPER0001",
|
||||
"language": "en",
|
||||
"title": "Bilingual Card",
|
||||
"summary": "English summary",
|
||||
"claims": ["English claim"],
|
||||
"citations": [],
|
||||
"quotable_spans": [],
|
||||
"writing_hints": [],
|
||||
"attachments": [],
|
||||
},
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
items_index_path = config.workspace_dir / "library" / "index" / "items.json"
|
||||
items_index_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"PAPER0001": {
|
||||
"item_key": "PAPER0001",
|
||||
"title": "Bilingual Card",
|
||||
"creators": ["Alice Smith"],
|
||||
"year": "2024",
|
||||
"item_type": "journalArticle",
|
||||
}
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
service = ProjectService(config.workspace_dir)
|
||||
zh_view = service.add_items("thesis-ch2", ["PAPER0001"])
|
||||
assert zh_view["items"][0]["summary"] == "中文摘要"
|
||||
assert zh_view["cards"][0]["language"] == "zh"
|
||||
|
||||
workspace.rename_project("thesis-ch2", "Thesis Chapter 2", "en")
|
||||
en_view = service.get_project_view("thesis-ch2")
|
||||
assert en_view["items"][0]["summary"] == "English summary"
|
||||
assert en_view["cards"][0]["language"] == "en"
|
||||
|
||||
@ -26,6 +26,7 @@ def test_index_contains_base_page_forms(tmp_path) -> None:
|
||||
assert 'id="create-project-form"' in html
|
||||
assert 'id="recommend-form"' in html
|
||||
assert 'id="plan-form"' in html
|
||||
assert 'id="card-language"' in html
|
||||
|
||||
|
||||
def test_index_contains_import_window_controls(tmp_path) -> None:
|
||||
@ -129,7 +130,11 @@ def test_index_has_project_item_list_and_inline_detail_hooks(tmp_path) -> None:
|
||||
assert "expandedProjectItemKey" in html
|
||||
assert "function renderProjectItems(items)" in html
|
||||
assert "function toggleProjectItemDetail(itemKey)" in html
|
||||
assert "function renderProjectItemCitations(citations)" in html
|
||||
assert "function renderProjectItemAttachments(attachments)" in html
|
||||
assert ".project-item-detail" in html
|
||||
assert "<h4>Citations</h4>" in html
|
||||
assert "<h4>Source Files</h4>" in html
|
||||
|
||||
|
||||
def test_index_collection_rows_use_readable_unselected_text(tmp_path) -> None:
|
||||
@ -170,6 +175,9 @@ def test_index_has_project_rename_delete_controls(tmp_path) -> None:
|
||||
assert ".project-list-actions" in html
|
||||
assert ".project-inline-form" in html
|
||||
assert ".project-delete-confirmation" in html
|
||||
assert "card_language" in html
|
||||
assert "中文" in html
|
||||
assert "English" in html
|
||||
|
||||
|
||||
def test_index_has_project_item_generate_actions_and_batch_window(tmp_path) -> None:
|
||||
@ -186,10 +194,22 @@ def test_index_has_project_item_generate_actions_and_batch_window(tmp_path) -> N
|
||||
assert "function openBatchGenerateWindow()" in html
|
||||
assert "function closeBatchGenerateWindow()" in html
|
||||
assert "function generateProjectItem(itemKey)" in html
|
||||
assert "function canGenerateProjectItemInBatch(status)" in html
|
||||
assert "function getProjectItemGenerateButtonLabel(status)" in html
|
||||
assert "function selectAllPendingBatchItems()" in html
|
||||
assert "function runBatchGenerate()" in html
|
||||
assert ".project-item-status-pill" in html
|
||||
assert ".project-item-generate-button" in html
|
||||
assert "重新生成" in html
|
||||
|
||||
|
||||
def test_index_marks_already_imported_collection_items_as_checked_and_disabled(tmp_path) -> None:
|
||||
html = _get_index_html(tmp_path)
|
||||
|
||||
assert "function isItemInCurrentProject(itemKey)" in html
|
||||
assert "function getCurrentProjectItemKeySet()" in html
|
||||
assert "已在项目中" in html
|
||||
assert '? "checked disabled" :' in html
|
||||
|
||||
|
||||
def test_index_inline_script_is_valid_javascript(tmp_path) -> None:
|
||||
|
||||
@ -26,12 +26,16 @@ def test_create_project_writes_project_files(tmp_path: Path) -> None:
|
||||
name="Thesis Chapter 2",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
card_language="zh",
|
||||
)
|
||||
|
||||
assert project.project_id == "thesis-ch2"
|
||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project.json").is_file()
|
||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "selected-items.json").is_file()
|
||||
assert (config.workspace_dir / "projects" / "thesis-ch2" / "project-index.json").is_file()
|
||||
assert '"card_language": "zh"' in (
|
||||
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_rename_project_updates_project_name(tmp_path: Path) -> None:
|
||||
@ -43,15 +47,19 @@ def test_rename_project_updates_project_name(tmp_path: Path) -> None:
|
||||
name="Old Name",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
card_language="en",
|
||||
)
|
||||
|
||||
project = workspace.rename_project("thesis-ch2", "New Name")
|
||||
project = workspace.rename_project("thesis-ch2", "New Name", "zh")
|
||||
|
||||
assert project.project_id == "thesis-ch2"
|
||||
assert project.name == "New Name"
|
||||
assert '"name": "New Name"' in (
|
||||
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
|
||||
).read_text(encoding="utf-8")
|
||||
assert '"card_language": "zh"' in (
|
||||
config.workspace_dir / "projects" / "thesis-ch2" / "project.json"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_delete_project_removes_project_directory_only(tmp_path: Path) -> None:
|
||||
@ -63,6 +71,7 @@ def test_delete_project_removes_project_directory_only(tmp_path: Path) -> None:
|
||||
name="Thesis Chapter 2",
|
||||
llm_provider="openai",
|
||||
llm_model="gpt-5-mini",
|
||||
card_language="en",
|
||||
)
|
||||
preserved_index = config.workspace_dir / "library" / "index" / "items.json"
|
||||
preserved_index.write_text('{"PAPER0001": {"title": "Keep me"}}', encoding="utf-8")
|
||||
|
||||
@ -41,6 +41,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
|
||||
tags=["retrieval"],
|
||||
collection_paths=[["Theory", "Drafting"]],
|
||||
notes=[title],
|
||||
attachments=[],
|
||||
attachment_texts=[title],
|
||||
)
|
||||
|
||||
@ -48,7 +49,7 @@ def _build_item(item_key: str, title: str) -> ZoteroItemRecord:
|
||||
def test_recommend_citations_only_reads_project_items(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
|
||||
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
|
||||
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
|
||||
builder.build_or_update(_build_item("PAPER0002", "Broad Retrieval Baseline"))
|
||||
@ -65,7 +66,7 @@ def test_recommend_citations_only_reads_project_items(tmp_path: Path) -> None:
|
||||
def test_generate_plan_returns_structured_sections(tmp_path: Path) -> None:
|
||||
config = AppConfig(workspace_dir=tmp_path / "workspace", zotero_data_dir=tmp_path / "zotero")
|
||||
workspace = Workspace(config)
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini")
|
||||
workspace.create_project("thesis-ch2", "Thesis Chapter 2", "openai", "gpt-5-mini", "en")
|
||||
builder = CardBuilder(config.workspace_dir, FakeLlmClient())
|
||||
builder.build_or_update(_build_item("PAPER0001", "Scoped Retrieval for Drafting"))
|
||||
|
||||
|
||||
@ -21,6 +21,14 @@ def test_read_selected_items_from_fixture(tmp_path: Path) -> None:
|
||||
assert item.collection_paths == [["Theory", "Drafting"]]
|
||||
assert item.notes == ["Merged notes matter."]
|
||||
assert item.attachment_texts[0].startswith("This paper studies")
|
||||
assert item.attachments == [
|
||||
{
|
||||
"path": str(fixture_dir / "storage" / "ATTACH01" / "paper.txt"),
|
||||
"filename": "paper.txt",
|
||||
"content_type": "text/plain",
|
||||
"is_pdf": False,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_read_selected_keys_from_bridge_snapshot(tmp_path: Path) -> None:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user