diff --git a/src/zotero_kb/api.py b/src/zotero_kb/api.py index 908aa62..b137d84 100644 --- a/src/zotero_kb/api.py +++ b/src/zotero_kb/api.py @@ -149,6 +149,7 @@ def create_app( resolved_client = llm_client or create_card_generation_client( str(llm_payload.get("provider", "deterministic")), str(llm_payload.get("model", "deterministic")), + log_dir=config.workspace_dir / "logs" / "llm_calls", ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc @@ -257,6 +258,7 @@ def create_app( resolved_client = llm_client or create_card_generation_client( str(llm_payload.get("provider", "deterministic")), str(llm_payload.get("model", "deterministic")), + log_dir=config.workspace_dir / "logs" / "llm_calls", ) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/src/zotero_kb/cards.py b/src/zotero_kb/cards.py index 0945c46..c314089 100644 --- a/src/zotero_kb/cards.py +++ b/src/zotero_kb/cards.py @@ -46,6 +46,35 @@ class CardBuilder: 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) + # Matches section headings that signal the end of the main body. + # Handles pdftotext artifacts where letters may be spaced apart. + _REFERENCES_PATTERN = re.compile( + r"^(?:" + r"References?|Bibliography|" + r"REFERENCES?|BIBLIOGRAPHY|" + r"R\s+E\s*F\s*E\s*R\s*E\s*N\s*C\s*E\s*S?|" + r"B\s*I\s*B\s*L\s*I\s*O\s*G\s*R\s*A\s*P\s*H\s*Y|" + r"参考文献|" + r"ACKNOWLEDGMENTS?|Acknowledgments?|" + r"A\s*C\s*K\s*N\s*O\s*W\s*L\s*E\s*D\s*G\s*M\s*E\s*N\s*T\s*S?" + r")\s*$", + re.MULTILINE, + ) + + # Fallback: reference list entries like "[1] A. Author" at the start of a line. + _REFERENCE_ENTRY_PATTERN = re.compile(r"^\[\d+\]\s+[A-Z]", re.MULTILINE) + + @classmethod + def _truncate_before_references(cls, text: str) -> str: + match = cls._REFERENCES_PATTERN.search(text) + if match: + return text[: match.start()].rstrip() + # If no heading found, try to detect the first bibliography entry. + entry_match = cls._REFERENCE_ENTRY_PATTERN.search(text) + if entry_match: + return text[: entry_match.start()].rstrip() + return text + def _build_source_bundle(self, item: ZoteroItemRecord, language: str) -> dict[str, object]: return { "item_key": item.item_key, @@ -59,7 +88,10 @@ class CardBuilder: "collection_paths": item.collection_paths, "notes": item.notes, "attachments": item.attachments, - "attachment_texts": item.attachment_texts, + "attachment_texts": [ + self._truncate_before_references(str(text)) + for text in item.attachment_texts + ], } @staticmethod @@ -104,6 +136,7 @@ class CardBuilder: ("Summary", [str(card_data.get("summary", ""))]), ("Core Claims", [str(value) for value in card_data.get("core_claims", [])]), ("Methods", [str(value) for value in card_data.get("methods", [])]), + ("Limitations", [str(value) for value in card_data.get("limitations", [])]), ("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)])), @@ -204,6 +237,8 @@ class CardBuilder: "attachments": item.attachments, "keywords": [str(value) for value in card_data.get("keywords", [])], "claims": [str(value) for value in card_data.get("core_claims", [])], + "methods": [str(value) for value in card_data.get("methods", [])], + "limitations": [str(value) for value in card_data.get("limitations", [])], "citations": self._normalize_citations( [value for value in card_data.get("citations", []) if isinstance(value, dict)] ), diff --git a/src/zotero_kb/llm.py b/src/zotero_kb/llm.py index 953ae13..055c7e5 100644 --- a/src/zotero_kb/llm.py +++ b/src/zotero_kb/llm.py @@ -4,6 +4,8 @@ import json import os import urllib.request from collections.abc import Mapping +from datetime import datetime, timezone +from pathlib import Path from typing import Any, Callable, Protocol @@ -28,6 +30,7 @@ class DeterministicCardGenerationClient: "summary": summary if language == "en" else f"中文:{summary}", "core_claims": claims if language == "en" else [f"中文:{claim}" for claim in claims], "methods": [], + "limitations": [], "evidence": attachment_texts[:3], "citations": [ { @@ -59,11 +62,31 @@ class DeepSeekCardGenerationClient: model: str = "deepseek-chat", base_url: str = "https://api.deepseek.com/v1", request_fn: RequestFunction | None = None, + log_dir: str | Path | None = None, ) -> None: self.api_key = api_key self.model = model self.base_url = base_url.rstrip("/") self.request_fn = request_fn or self._default_request + self.log_dir = Path(log_dir) if log_dir else None + + def _write_log(self, source_bundle: dict[str, object], payload: dict[str, object], raw_response: dict[str, object], parsed_result: dict[str, object]) -> None: + if self.log_dir is None: + return + self.log_dir.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S_%f") + item_key = str(source_bundle.get("item_key", "unknown")) + log_path = self.log_dir / f"{timestamp}_{item_key}.json" + log_data = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "item_key": item_key, + "model": self.model, + "source_bundle": source_bundle, + "request_payload": payload, + "raw_response": raw_response, + "parsed_result": parsed_result, + } + log_path.write_text(json.dumps(log_data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]: language = str(source_bundle.get("card_language", "en")) @@ -79,30 +102,36 @@ class DeepSeekCardGenerationClient: { "role": "system", "content": ( - "You convert research source bundles into structured evidence cards for academic writing. " + "You are a research analyst extracting structured evidence cards from academic papers. " + "Your goal is to produce cards that help a researcher cite this work critically and precisely — " + "not just summarize what the paper claims, but surface the technical method, its assumptions, " + "and its limitations so the card can be used to construct arguments like 'Method X achieves Y, " + "but relies on assumption Z, which fails when...'. " "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." + "summary, core_claims, methods, limitations, evidence, citations, quotable_passages, writing_hints, keywords." ), }, { "role": "user", "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" + "summary, core_claims, methods, limitations, 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" + "1. summary: 3-5 sentences. Describe WHAT the paper does at a high level, WHO it builds on, and WHY the problem matters. Do not list benchmark numbers.\n" + "2. core_claims: 3-6 bullet-length claims focused on TECHNICAL CONTRIBUTIONS — novel architectures, key design decisions, theoretical guarantees, or qualitative insights. Avoid 'outperforms X by Y%' unless the number itself is the main contribution. Preserve hedging language.\n" + "3. methods: detailed but concise description of the proposed method. Include: (a) the overall architecture or pipeline, (b) the role of each component and how they interact, (c) key design choices or inductive biases, (d) training or inference procedure if non-standard. If the paper modifies an existing method, state exactly what was changed.\n" + "4. limitations: an ARRAY of strings. Each string is one explicit limitation, assumption, failure mode, or scope restriction stated or strongly implied by the authors. Also include any methodological gaps you can infer from the source bundle (e.g., 'evaluated only on synthetic data', 'requires expensive online planning', 'assumes a single robot arm'). If none are found, return [\"None explicitly discussed\"].\n" + "5. evidence: specific empirical or textual support. Prefer qualitative observations, ablation insights, or case studies over raw benchmark tables. Include numbers only when they directly illustrate a method property (e.g., 'removing component X caused success rate to drop from 78% to 31%').\n" + "6. citations: an array of objects with keys claim, quote, paraphrase, quote_source, use_case.\n" + "7. citations.quote must be verbatim text from notes or attachment_texts only.\n" + "8. Return 1-5 citations whenever usable supporting text exists. Prioritize quotes that: (a) describe a method detail, (b) state an assumption or limitation, (c) draw a comparison to prior work.\n" + "9. quote_source must identify where the quote came from, such as attachment_texts or notes.\n" + "10. use_case must be one of direct_quote, paraphrase, or background. Mark as 'direct_quote' when the verbatim text is particularly precise or memorable; 'paraphrase' when the idea is useful but the wording is not; 'background' for situational context.\n" + "11. quotable_passages: 2-5 short verbatim excerpts that would be useful to quote directly in a related-work or methods section. Prioritize passages about technical details, assumptions, or caveats.\n" + "12. writing_hints: 2-4 sentences suggesting HOW to use this paper in an argument — e.g., 'Cite this when arguing that retrieval-based memory scales better than parametric memory, but note it assumes access to a curated experience buffer.'\n" + "13. Use empty arrays only when the source bundle truly contains no usable supporting quote; do not fabricate.\n" + f"14. {language_instruction}\n\n" f"Source bundle:\n{json.dumps(source_bundle, ensure_ascii=False)}" ), }, @@ -115,11 +144,12 @@ class DeepSeekCardGenerationClient: response = self.request_fn(f"{self.base_url}/chat/completions", payload, headers) content = str(response["choices"][0]["message"]["content"]) parsed = json.loads(_strip_json_fence(content)) - return { + result = { "summary": str(parsed.get("summary", "")), - "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", [])], + "core_claims": _to_str_list(parsed.get("core_claims")), + "methods": _to_str_list(parsed.get("methods")), + "limitations": _to_str_list(parsed.get("limitations")), + "evidence": _to_str_list(parsed.get("evidence")), "citations": [ { "claim": str(value.get("claim", "")), @@ -131,10 +161,12 @@ class DeepSeekCardGenerationClient: 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", [])], + "quotable_passages": _to_str_list(parsed.get("quotable_passages")), + "writing_hints": _to_str_list(parsed.get("writing_hints")), + "keywords": _to_str_list(parsed.get("keywords")), } + self._write_log(source_bundle, payload, response, result) + return result @staticmethod def _default_request(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]: @@ -154,6 +186,7 @@ def create_card_generation_client( *, env: Mapping[str, str] | None = None, request_fn: RequestFunction | None = None, + log_dir: str | Path | None = None, ) -> CardGenerationClient: current_env = os.environ if env is None else env normalized = provider.lower() @@ -161,10 +194,18 @@ def create_card_generation_client( api_key = current_env.get("DEEPSEEK_API_KEY") if not api_key: raise ValueError("DEEPSEEK_API_KEY is required when llm provider is deepseek") - return DeepSeekCardGenerationClient(api_key=api_key, model=model, request_fn=request_fn) + return DeepSeekCardGenerationClient(api_key=api_key, model=model, request_fn=request_fn, log_dir=log_dir) return DeterministicCardGenerationClient() +def _to_str_list(value: object) -> list[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] if value.strip() else [] + return [str(v) for v in value if v is not None] + + def _strip_json_fence(value: str) -> str: stripped = value.strip() if stripped.startswith("```"): diff --git a/src/zotero_kb/projects.py b/src/zotero_kb/projects.py index 6ddd117..84b9f78 100644 --- a/src/zotero_kb/projects.py +++ b/src/zotero_kb/projects.py @@ -47,6 +47,8 @@ class ProjectService: "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 [], + "methods": card_data.get("methods", []) if card_data else [], + "limitations": card_data.get("limitations", []) if card_data else [], "citations": card_data.get("citations", []) if card_data else [], "quotable_spans": card_data.get("quotable_spans", []) if card_data else [], } diff --git a/src/zotero_kb/templates/index.html b/src/zotero_kb/templates/index.html index 86507d3..b7097fe 100644 --- a/src/zotero_kb/templates/index.html +++ b/src/zotero_kb/templates/index.html @@ -530,6 +530,19 @@ .batch-generate-item[aria-disabled="true"] { opacity: 0.72; } + .batch-generate-select-all { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.85rem; + align-items: center; + padding: 0.5rem 1.25rem; + border-bottom: 1px solid var(--line); + font-weight: 600; + position: sticky; + top: 0; + background: rgba(255, 253, 248, 0.98); + z-index: 1; + } .window-scroll { min-height: 0; overflow: auto; @@ -737,6 +750,10 @@