zotero-kb/src/zotero_kb/llm.py
Saberlve aaafa78883 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.
2026-04-22 10:21:03 +08:00

175 lines
8.3 KiB
Python

from __future__ import annotations
import json
import os
import urllib.request
from collections.abc import Mapping
from typing import Any, Callable, Protocol
class CardGenerationClient(Protocol):
def generate_card(self, source_bundle: dict[str, object]) -> dict[str, object]:
"""Return structured card sections for a normalized source bundle."""
class DeterministicCardGenerationClient:
"""Fallback card generator used when no remote model client is configured."""
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()
]
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 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."
if language == "en"
else "当写作意图与摘要或笔记相关时使用这张卡片。",
],
"keywords": [str(value) for value in source_bundle.get("tags", [])],
}
RequestFunction = Callable[[str, dict[str, object], dict[str, str]], dict[str, object]]
class DeepSeekCardGenerationClient:
def __init__(
self,
api_key: str,
*,
model: str = "deepseek-chat",
base_url: str = "https://api.deepseek.com/v1",
request_fn: RequestFunction | 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
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"},
"messages": [
{
"role": "system",
"content": (
"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": (
"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)}"
),
},
],
}
headers = {
"Authorization": f"Bearer {self.api_key}",
"Content-Type": "application/json",
}
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 {
"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", [])],
"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", [])],
}
@staticmethod
def _default_request(url: str, payload: dict[str, object], headers: dict[str, str]) -> dict[str, object]:
request = urllib.request.Request(
url=url,
data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
headers=headers,
method="POST",
)
with urllib.request.urlopen(request, timeout=120) as response:
return json.loads(response.read().decode("utf-8"))
def create_card_generation_client(
provider: str,
model: str,
*,
env: Mapping[str, str] | None = None,
request_fn: RequestFunction | None = None,
) -> CardGenerationClient:
current_env = os.environ if env is None else env
normalized = provider.lower()
if normalized == "deepseek":
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 DeterministicCardGenerationClient()
def _strip_json_fence(value: str) -> str:
stripped = value.strip()
if stripped.startswith("```"):
lines = stripped.splitlines()
if len(lines) >= 3 and lines[0].startswith("```") and lines[-1].strip() == "```":
return "\n".join(lines[1:-1]).strip()
return stripped