feat(plugin): render discussion card from .md with MarkdownRenderer

This commit is contained in:
Research Assistant 2026-05-18 21:12:06 +08:00
parent add220d93c
commit 8233cb7aff
3 changed files with 73 additions and 142 deletions

View file

@ -1,4 +1,4 @@
const { Plugin, Notice, ItemView, Modal, Setting, PluginSettingTab, addIcon } = require('obsidian');
const { Plugin, Notice, ItemView, Modal, Setting, PluginSettingTab, MarkdownRenderer, addIcon } = require('obsidian');
const { exec, execFile, spawn, execFileSync } = require('node:child_process');
const fs = require('fs');
const path = require('path');
@ -1956,7 +1956,7 @@ class PaperForgeStatusView extends ItemView {
return para.length > 300 ? para.slice(0, 300) + '...' : para;
}
/* ── Recent Discussion Card: read ai/discussion.json ── */
/* ── Recent Discussion Card: read ai/discussion.md ── */
_renderRecentDiscussionCard(container, entry) {
const card = container.createEl('div', { cls: 'paperforge-discussion-card' });
card.style.display = 'none';
@ -1964,72 +1964,73 @@ class PaperForgeStatusView extends ItemView {
if (!entry.note_path) return;
const lastSlash = entry.note_path.lastIndexOf('/');
const wsDir = lastSlash !== -1 ? entry.note_path.substring(0, lastSlash) : '.';
const discPath = wsDir + '/ai/discussion.json';
const mdPath = wsDir + '/ai/discussion.md';
// Use Obsidian adapter for path correctness (handles unicode reliably)
this.app.vault.adapter.exists(discPath).then((exists) => {
this.app.vault.adapter.exists(mdPath).then((exists) => {
if (!exists) return;
return this.app.vault.adapter.read(discPath);
}).then((raw) => {
return this.app.vault.adapter.read(mdPath);
}).then(async (raw) => {
if (!raw) return;
const data = JSON.parse(raw);
if (!data.sessions || data.sessions.length === 0) return;
const pairs = this._parseDiscussionMD(raw);
if (!pairs || pairs.length === 0) return;
card.style.display = 'block';
const header = card.createEl('div', { cls: 'paperforge-discussion-header' });
header.createEl('span', { cls: 'paperforge-discussion-title', text: '最近讨论' });
const latestSession = data.sessions[data.sessions.length - 1];
const pairs = (latestSession.qa_pairs || []).slice(-3);
for (const qa of pairs) {
const item = card.createEl('div', { cls: 'paperforge-discussion-item' });
const qEl = item.createEl('div', { cls: 'paperforge-discussion-q', text: '提问:' + qa.question });
const qEl = item.createEl('div', { cls: 'paperforge-discussion-q' });
await MarkdownRenderer.render(this.app, '**提问:**' + qa.question, qEl, mdPath, this);
const aEl = item.createEl('div', { cls: 'paperforge-discussion-a' });
const shortAnswer = qa.answer && qa.answer.length > 150
? qa.answer.slice(0, 150) + '...'
: (qa.answer || '');
aEl.createEl('span', { cls: 'paperforge-discussion-a-text', text: '解答:' + shortAnswer });
if (qa.answer && qa.answer.length > 150) {
const expandContainer = aEl.createEl('div', { cls: 'paperforge-expand-container' });
const expandBtn = expandContainer.createEl('button', { cls: 'paperforge-expand-icon', title: '展开/收起' });
// Insert arrow SVG
expandBtn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>';
if (qa.answer && qa.answer.length > 500) {
aEl.style.maxHeight = '200px';
aEl.style.overflow = 'hidden';
const toggle = item.createEl('button', { cls: 'paperforge-expand-btn', text: '展开更多 ▾' });
let expanded = false;
// Make the whole container clickable
expandContainer.addEventListener('click', () => {
const textSpan = aEl.querySelector('.paperforge-discussion-a-text');
if (textSpan) {
textSpan.setText(expanded ? ('解答:' + shortAnswer) : ('解答:' + qa.answer));
}
expandBtn.innerHTML = expanded
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="18 15 12 9 6 15"></polyline></svg>';
toggle.addEventListener('click', () => {
expanded = !expanded;
aEl.style.maxHeight = expanded ? '' : '200px';
toggle.setText(expanded ? '收起 ▴' : '展开更多 ▾');
});
}
await MarkdownRenderer.render(this.app, qa.answer || '', aEl, mdPath, this);
}
// "查看全部" link
const viewAll = card.createEl('a', { cls: 'paperforge-discussion-viewall', text: '查看全部讨论 →' });
viewAll.addEventListener('click', (e) => {
e.preventDefault();
const discMdPath = wsDir + '/ai/discussion.md';
const discFile = this.app.vault.getAbstractFileByPath(discMdPath);
const discFile = this.app.vault.getAbstractFileByPath(mdPath);
if (discFile) {
this.app.workspace.openLinkText(discMdPath, '');
this.app.workspace.openLinkText(mdPath, '');
} else {
new Notice('讨论文件尚未生成');
}
});
}).catch((e) => {
console.error('PaperForge: discussion.json read error', discPath, e.message);
console.error('PaperForge: discussion.md read error', mdPath, e.message);
});
}
_parseDiscussionMD(content) {
const sessions = content.split(/\n## /).slice(1);
if (sessions.length === 0) return null;
const lastSession = sessions[sessions.length - 1];
const pairs = [];
const qaBlocks = lastSession.split(/\*\*问题:\*\*/).slice(1);
for (const block of qaBlocks) {
const answerMatch = block.match(/\*\*解答:\*\*/);
if (!answerMatch) continue;
const question = block.substring(0, answerMatch.index).trim();
const answer = block.substring(answerMatch.index + '**解答:**'.length).trim();
pairs.push({ question, answer });
}
return pairs.slice(-3);
}
/* ── Paper Technical Details (disclosure with workflow toggles) ── */
_renderPaperTechnicalDetails(container, entry) {
const key = this._currentPaperKey;

View file

@ -5,7 +5,7 @@
交互式文献问答。不强制要求 OCR但 OCR 完成后回答更准确。
每次问答记录到 `discussion.json`Dashboard 可见)。
每次问答记录到 `discussion.md`Dashboard 可见)。
---

View file

@ -34,7 +34,6 @@ def _create_minimal_vault(tmp_path: Path, zotero_key: str = "TSTONE001",
domain_dir = records_dir / domain
domain_dir.mkdir(parents=True, exist_ok=True)
# Library record with frontmatter
record_path = domain_dir / f"{zotero_key}.md"
record_path.write_text(
"---\n"
@ -49,7 +48,6 @@ def _create_minimal_vault(tmp_path: Path, zotero_key: str = "TSTONE001",
encoding="utf-8",
)
# Create paperforge.json
pf_json = vault / "paperforge.json"
pf_json.write_text(
json.dumps({
@ -64,7 +62,6 @@ def _create_minimal_vault(tmp_path: Path, zotero_key: str = "TSTONE001",
encoding="utf-8",
)
# Create canonical index (Phase 38: _find_paper_metadata reads from index)
indexes_dir = vault / "99_System" / "PaperForge" / "indexes"
indexes_dir.mkdir(parents=True, exist_ok=True)
canonical_index = {
@ -113,8 +110,8 @@ def _sample_qa_pairs() -> list[dict]:
class TestRecordSession:
"""record_session() behavior tests."""
def test_create_both_files(self, tmp_path: Path) -> None:
"""Test 1: Creates ai/discussion.json and ai/discussion.md with valid data."""
def test_create_md_file(self, tmp_path: Path) -> None:
"""Test: Creates ai/discussion.md with rich unescaped markdown."""
vault = _create_minimal_vault(tmp_path)
result = record_session(
vault_path=vault,
@ -125,84 +122,42 @@ class TestRecordSession:
)
assert result["status"] == "ok"
json_path = Path(result["json_path"])
assert "json_path" not in result
md_path = Path(result["md_path"])
# JSON file exists and has correct structure
assert json_path.exists()
data = json.loads(json_path.read_text(encoding="utf-8"))
assert data["schema_version"] == "1"
assert data["paper_key"] == "TSTONE001"
assert len(data["sessions"]) == 1
session = data["sessions"][0]
assert len(session["session_id"]) == 36 # UUID length
assert session["agent"] == "pf-paper"
assert session["model"] == "gpt-4"
assert "started" in session
assert session["paper_key"] == "TSTONE001"
assert session["paper_title"] == "Biomechanical Comparison"
assert session["domain"] == "骨科"
assert len(session["qa_pairs"]) == 2
assert session["qa_pairs"][0]["source"] == "user_question"
assert session["qa_pairs"][1]["source"] == "agent_analysis"
# MD file exists and has correct format
assert md_path.exists()
md_content = md_path.read_text(encoding="utf-8")
assert "# AI Discussion Record: Biomechanical Comparison" in md_content
assert "## " in md_content # session heading with date
assert "## " in md_content
assert "**问题:**" in md_content
assert "**解答:**" in md_content
assert "---" in md_content # separator
assert "---" in md_content
def test_append_second_session(self, tmp_path: Path) -> None:
"""Test 2: Second call appends, does not overwrite."""
"""Test: Second call appends, does not overwrite."""
vault = _create_minimal_vault(tmp_path)
qa = _sample_qa_pairs()
# First call
r1 = record_session(vault_path=vault, zotero_key="TSTONE001",
agent="pf-paper", model="gpt-4", qa_pairs=qa)
assert r1["status"] == "ok"
# Second call with different Q&A
qa2 = [{"question": "Q2?", "answer": "A2.", "source": "user_question",
"timestamp": "2026-05-06T13:00:00+08:00"}]
r2 = record_session(vault_path=vault, zotero_key="TSTONE001",
agent="pf-paper", model="gpt-4", qa_pairs=qa2)
assert r2["status"] == "ok"
# JSON: sessions length = 2, first session preserved
json_path = Path(r2["json_path"])
data = json.loads(json_path.read_text(encoding="utf-8"))
assert len(data["sessions"]) == 2
assert len(data["sessions"][0]["qa_pairs"]) == 2 # first session preserved
assert data["sessions"][1]["qa_pairs"][0]["question"] == "Q2?"
# MD: two ## session headings
md_content = Path(r2["md_path"]).read_text(encoding="utf-8")
assert md_content.count("## ") >= 2
def test_utc_timestamp(self, tmp_path: Path) -> None:
"""HARDEN-03: started timestamp uses UTC (not CST/UTC+8)."""
vault = _create_minimal_vault(tmp_path)
result = record_session(
vault_path=vault, zotero_key="TSTONE001",
agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs(),
)
assert result["status"] == "ok"
data = json.loads(Path(result["json_path"]).read_text(encoding="utf-8"))
started = data["sessions"][0]["started"]
# UTC offset is either +00:00 or Z
assert started.endswith("+00:00") or started.endswith("Z"), f"Expected UTC, got: {started}"
def test_markdown_escaping(self, tmp_path: Path) -> None:
"""HARDEN-02: Markdown special chars in QA are escaped in discussion.md."""
def test_markdown_preserved_unescaped(self, tmp_path: Path) -> None:
"""Test: **bold** preserved (NOT escaped to backslash-star sequences)."""
vault = _create_minimal_vault(tmp_path)
qa = [
{
"question": "What does *italic* and **bold** mean?",
"answer": "Use # for headings and [link](url) syntax _underscore_ `code`",
"answer": "Use **bold** and `code`",
"source": "user_question",
"timestamp": "2026-05-06T12:00:00+00:00",
},
@ -213,19 +168,16 @@ class TestRecordSession:
)
assert result["status"] == "ok"
md = Path(result["md_path"]).read_text(encoding="utf-8")
# Verify escaped forms exist (not bare markdown)
assert "\\*italic\\*" in md, f"Expected escaped asterisks in: {md}"
assert "\\*\\*bold\\*\\*" in md
assert "\\_" in md
assert "\\`" in md
assert "**bold**" in md
assert "\\*" not in md
def test_markdown_escaping_cjk(self, tmp_path: Path) -> None:
"""HARDEN-02: CJK characters preserved alongside escaped markdown chars."""
def test_markdown_preserved_cjk(self, tmp_path: Path) -> None:
"""Test: CJK characters preserved alongside unescaped markdown."""
vault = _create_minimal_vault(tmp_path, title="中文测试")
qa = [
{
"question": "什么是*p值*和#显著性?",
"answer": "_效应量_为0.5`[95% CI]",
"answer": "**效应量**为0.5`[95% CI]",
"source": "user_question",
"timestamp": "2026-05-06T12:00:00+00:00",
},
@ -236,43 +188,36 @@ class TestRecordSession:
)
assert result["status"] == "ok"
md = Path(result["md_path"]).read_text(encoding="utf-8")
# CJK preserved
assert "什么是" in md
assert "效应量" in md
# Special chars escaped
assert "\\*p值\\*" in md
assert "\\_" in md
assert "\\`" in md
assert "**效应量**" in md
assert "\\*" not in md
def test_file_lock_prevents_concurrent_write(self, tmp_path: Path) -> None:
"""HARDEN-01: File lock (.json.lock) is created during record_session()."""
def test_file_lock_uses_md_lock(self, tmp_path: Path) -> None:
"""HARDEN-01: Lock file is .md.lock not .json.lock."""
vault = _create_minimal_vault(tmp_path)
result = record_session(
vault_path=vault, zotero_key="TSTONE001",
agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs(),
)
assert result["status"] == "ok"
json_path = Path(result["json_path"])
# Lock file should NOT remain after successful write (released + cleaned up)
lock_path = json_path.with_suffix(".json.lock")
assert not lock_path.exists(), "Lock file should be released after write"
md_path = Path(result["md_path"])
lock_path = md_path.with_suffix(".md.lock")
assert not lock_path.exists()
def test_lock_timeout_returns_error(self, tmp_path: Path) -> None:
"""HARDEN-01: When lock cannot be acquired, returns error status."""
vault = _create_minimal_vault(tmp_path)
# First call to create the json file
r1 = record_session(
vault_path=vault, zotero_key="TSTONE001",
agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs(),
)
assert r1["status"] == "ok"
json_path = Path(r1["json_path"])
md_path = Path(r1["md_path"])
# Hold an exclusive lock to simulate concurrent access
lock_path = json_path.with_suffix(".json.lock")
lock_path = md_path.with_suffix(".md.lock")
external_lock = filelock.FileLock(lock_path, timeout=1)
with external_lock:
# This should fail because lock is held
r2 = record_session(
vault_path=vault, zotero_key="TSTONE001",
agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs(),
@ -281,7 +226,7 @@ class TestRecordSession:
assert "Concurrent access" in r2.get("message", "")
def test_missing_vault(self, tmp_path: Path) -> None:
"""Test 3: Non-existent vault returns error status."""
"""Non-existent vault returns error status."""
result = record_session(
vault_path=tmp_path / "nonexistent",
zotero_key="TSTONE001",
@ -293,7 +238,7 @@ class TestRecordSession:
assert "message" in result
def test_unknown_key(self, tmp_path: Path) -> None:
"""Test 4: Unknown zotero_key returns error without creating files."""
"""Unknown zotero_key returns error without creating files."""
vault = _create_minimal_vault(tmp_path)
result = record_session(
vault_path=vault,
@ -304,12 +249,11 @@ class TestRecordSession:
)
assert result["status"] == "error"
# No files created
ai_dir = vault / "03_Resources" / "Literature" / "UNKNOW01 - untitled" / "ai"
assert not ai_dir.exists() or not list(ai_dir.iterdir()) == []
def test_cjk_encoding(self, tmp_path: Path) -> None:
"""Test 5: CJK content round-trips correctly via ensure_ascii=False."""
"""CJK content round-trips correctly."""
vault = _create_minimal_vault(tmp_path, title="中文测试论文")
cjk_qa = [
{
@ -323,35 +267,24 @@ class TestRecordSession:
agent="pf-paper", model="gpt-4", qa_pairs=cjk_qa)
assert result["status"] == "ok"
# JSON read-back
json_path = Path(result["json_path"])
raw = json_path.read_text(encoding="utf-8")
md_path = Path(result["md_path"])
raw = md_path.read_text(encoding="utf-8")
assert "中文测试论文" in raw
assert "问题一" in raw
assert "答案一" in raw
# Parse and verify
data = json.loads(raw)
assert data["sessions"][0]["paper_title"] == "中文测试论文"
assert data["sessions"][0]["qa_pairs"][0]["question"] == "问题一"
def test_atomic_write_no_partial(self, tmp_path: Path) -> None:
"""Test 6: Atomic write via tempfile + os.replace prevents partial writes."""
"""Atomic write via tempfile + os.replace prevents partial writes."""
vault = _create_minimal_vault(tmp_path)
result = record_session(vault_path=vault, zotero_key="TSTONE001",
agent="pf-paper", model="gpt-4", qa_pairs=_sample_qa_pairs())
assert result["status"] == "ok"
json_path = Path(result["json_path"])
md_path = Path(result["md_path"])
# Verify files are valid (not partial)
data = json.loads(json_path.read_text(encoding="utf-8"))
assert data["schema_version"] == "1"
assert md_path.read_text(encoding="utf-8").startswith("# AI Discussion Record")
def test_cli_invocation(self, tmp_path: Path) -> None:
"""Test 7: CLI `python -m paperforge.worker.discussion record` works."""
"""CLI `python -m paperforge.worker.discussion record` works."""
vault = _create_minimal_vault(tmp_path)
import subprocess
import sys
@ -365,7 +298,6 @@ class TestRecordSession:
"--model", "gpt-4",
"--qa-pairs", qa_json,
]
# Use binary pipes to avoid UTF-8 decode issues on Windows (Python 3.14+)
env = {**os.environ, "PYTHONIOENCODING": "utf-8"}
proc = subprocess.run(cmd, capture_output=True, env=env)
proc_stdout = proc.stdout.decode("utf-8", errors="replace")
@ -374,8 +306,6 @@ class TestRecordSession:
output = json.loads(proc_stdout)
assert output["status"] == "ok"
json_path = Path(output["json_path"])
assert json_path.exists()
data = json.loads(json_path.read_text(encoding="utf-8"))
assert len(data["sessions"]) == 1
assert data["sessions"][0]["agent"] == "pf-paper"
assert "md_path" in output
md_path = Path(output["md_path"])
assert md_path.exists()