feat: upgrade reading-log to JSONL with context/tags/project fields and auto-render

This commit is contained in:
Research Assistant 2026-05-14 17:31:32 +08:00
parent 60c524b012
commit 6ccce87272
3 changed files with 194 additions and 14 deletions

View file

@ -291,6 +291,13 @@ def build_parser() -> argparse.ArgumentParser:
p_rl.add_argument("--excerpt", help="Quoted excerpt")
p_rl.add_argument("--usage", help="How this supports the current writing")
p_rl.add_argument("--note", help="Optional cross-validation note")
p_rl.add_argument("--context", help="Full paragraph containing excerpt")
p_rl.add_argument("--tags", help="Comma-separated tags")
p_rl.add_argument("--project", help="Associated project name")
p_rl.add_argument("--render", action="store_true", help="Render reading-log.md for one or all projects")
p_rl.add_argument("--correct", dest="correct_id", help="ID of prior reading note to correct")
p_rl.add_argument("--correction", help="Correction text")
p_rl.add_argument("--reason", help="Reason for correction (e.g. 'Rechecked figure legend')")
p_rl.add_argument("--since", help="Export notes since date (YYYY-MM-DD)")
p_rl.add_argument("--limit", type=int, default=50, help="Max notes to export")
p_rl.add_argument("--output", help="Write markdown to file")
@ -636,11 +643,11 @@ def _cmd_paths(vault: Path, args: argparse.Namespace) -> int:
if args.json:
# Output only the keys required by D-Path Output contract
output_keys = {"vault", "worker_script", "ld_deep_script"}
output_keys = {"vault", "worker_script", "pf_deep_script"}
filtered = {k: v for k, v in all_paths.items() if k in output_keys}
filtered["vault"] = str(vault.resolve())
filtered["worker_script"] = str(paths["worker_script"].resolve())
filtered["ld_deep_script"] = str(paths["ld_deep_script"].resolve())
filtered["pf_deep_script"] = str(paths["pf_deep_script"].resolve())
print(json.dumps(filtered, ensure_ascii=False, indent=2))
else:
for key, path_str in sorted(all_paths.items()):

View file

@ -1,16 +1,21 @@
from __future__ import annotations
import argparse
import datetime
import json
import re
from pathlib import Path
from paperforge import __version__ as PF_VERSION
from paperforge.config import paperforge_paths
from paperforge.core.errors import ErrorCode
from paperforge.core.result import PFError, PFResult
from paperforge.memory.db import get_connection, get_memory_db_path
from paperforge.memory.events import export_reading_log, write_reading_note
from paperforge.memory.events import export_reading_log, write_correction_note, write_reading_note
from paperforge.memory.permanent import (
append_reading_note,
read_all_reading_notes,
)
_HEADER_RE = re.compile(r"^## ([A-Z0-9]{8}) \u2014 .+ \d{4}$")
_TITLE_RE = re.compile(r"^\*\*Title:\*\* (.+)")
@ -211,6 +216,72 @@ def lookup_paper_events(vault: Path, key: str) -> dict:
conn.close()
def _render_reading_log_md(vault: Path, project: str = "") -> None:
"""Render reading-log.md from JSONL source of truth.
Groups notes by paper_id and writes a formatted markdown file.
If project is specified, writes to <resources>/Projects/<project>/reading-log.md.
Otherwise writes to <paperforge>/logs/rendered/reading-log.md.
"""
paths = paperforge_paths(vault)
notes = read_all_reading_notes(vault)
if not notes:
print("No reading notes to render.")
return
if project:
notes = [n for n in notes if n.get("project") == project]
if not notes:
print(f"No reading notes found{' for project ' + project if project else ''}.")
return
grouped: dict[str, list[dict]] = {}
for n in notes:
pid = n.get("paper_id", "unknown")
grouped.setdefault(pid, []).append(n)
lines: list[str] = []
heading = f"Reading Log \u2014 {project}" if project else "Reading Log \u2014 All Projects"
lines.append(f"# {heading}\n")
lines.append(f"*Generated: {datetime.date.today().isoformat()} | Total entries: {len(notes)}*\n")
for pid, entries in sorted(grouped.items()):
lines.append(f"## {pid}\n")
for entry in sorted(entries, key=lambda e: (e.get("section", ""), e.get("created_at", ""))):
section = entry.get("section", "Untitled")
lines.append(f"### {section}")
lines.append(f"> {entry.get('excerpt', '')}")
if entry.get("context"):
lines.append(">")
lines.append(f"> {entry.get('context')}")
lines.append("")
if entry.get("usage"):
lines.append(f"- **Usage:** {entry.get('usage')}")
if entry.get("note"):
lines.append(f"- **Note:** {entry.get('note')}")
tag_list = entry.get("tags", [])
if tag_list:
lines.append(f"- **Tags:** {', '.join(tag_list)}")
verified = entry.get("verified", False)
lines.append(f"- **Verified:** {'Yes' if verified else 'No'}")
lines.append("")
lines.append("---\n")
if project:
output_dir = paths["resources"] / "Projects" / project
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "reading-log.md"
else:
output_dir = paths["paperforge"] / "logs" / "rendered"
output_dir.mkdir(parents=True, exist_ok=True)
output_path = output_dir / "reading-log.md"
output_path.write_text("\n".join(lines), encoding="utf-8")
print(f"Rendered {len(notes)} entries to {output_path}")
def run(args: argparse.Namespace) -> int:
vault = args.vault_path
@ -265,22 +336,91 @@ def run(args: argparse.Namespace) -> int:
print(f"No entries found for key: {args.lookup}")
return 0
if args.paper_id and args.excerpt:
ok = write_reading_note(
vault, args.paper_id, args.section or "",
args.excerpt, args.usage or "", args.note or "",
if args.render:
_render_reading_log_md(vault, args.project or "")
return 0
if args.correct_id:
if not args.correction:
result = PFResult(
ok=False, command="reading-log", version=PF_VERSION,
data={},
error=PFError(code=ErrorCode.INVALID_INPUT,
message="--correction is required with --correct"),
)
if args.json:
print(result.to_json())
else:
print("Error: --correction is required with --correct")
return 1
all_notes = read_all_reading_notes(vault)
original = next((n for n in all_notes if n.get("id") == args.correct_id), None)
paper_id = original.get("paper_id", "") if original else ""
if not paper_id:
result = PFResult(
ok=False, command="reading-log", version=PF_VERSION,
data={},
error=PFError(code=ErrorCode.NOT_FOUND,
message=f"Original entry {args.correct_id} not found in JSONL"),
)
if args.json:
print(result.to_json())
else:
print(f"Error: Original entry {args.correct_id} not found in reading-log.jsonl")
return 1
ok = write_correction_note(
vault, paper_id, args.correct_id,
args.correction, args.reason or "",
)
result = PFResult(
ok=ok,
command="reading-log",
version=PF_VERSION,
ok=ok, command="reading-log", version=PF_VERSION,
data={"written": ok},
error=PFError(code=ErrorCode.INTERNAL_ERROR, message="Failed to write") if not ok else None,
error=PFError(code=ErrorCode.INTERNAL_ERROR,
message="Failed to write correction") if not ok else None,
)
if args.json:
print(result.to_json())
else:
print("Written." if ok else "Failed.")
print(f"Correction written for {args.correct_id}." if ok else "Failed.")
return 0 if ok else 1
if args.paper_id and args.excerpt:
tags_list = [t.strip() for t in args.tags.split(",") if t.strip()] if args.tags else None
jsonl_result = append_reading_note(
vault, args.paper_id, args.section or "",
args.excerpt, args.usage or "", args.context or "",
args.note or "", args.project or "", tags_list,
)
_db_ok = write_reading_note(
vault, args.paper_id, args.section or "",
args.excerpt, args.usage or "", args.note or "",
args.context or "", args.project or "", tags_list,
)
ok = jsonl_result.get("ok", False)
result = PFResult(
ok=ok,
command="reading-log",
version=PF_VERSION,
data={"written": ok, "id": jsonl_result.get("id"), "path": jsonl_result.get("path")},
error=PFError(code=ErrorCode.INTERNAL_ERROR,
message=jsonl_result.get("error", "Failed to write")) if not ok else None,
)
if args.json:
print(result.to_json())
else:
if ok:
print(f"Written. ID: {jsonl_result.get('id', 'unknown')}")
else:
print(f"Failed: {jsonl_result.get('error', 'unknown')}")
if ok and args.project:
_render_reading_log_md(vault, args.project)
return 0 if ok else 1
notes = export_reading_log(vault, since=args.since or "", limit=args.limit or 50)

View file

@ -7,7 +7,9 @@ from paperforge.memory.db import get_connection, get_memory_db_path
def write_reading_note(vault: Path, paper_id: str, section: str,
excerpt: str, usage: str = "", note: str = "") -> bool:
excerpt: str, usage: str = "", note: str = "",
context: str = "", project: str = "",
tags: list[str] | None = None) -> bool:
"""Record a reading note in paper_events."""
db_path = get_memory_db_path(vault)
if not db_path.exists():
@ -18,6 +20,9 @@ def write_reading_note(vault: Path, paper_id: str, section: str,
"excerpt": excerpt,
"usage": usage,
"note": note,
"context": context,
"project": project,
"tags": tags or [],
}
conn = get_connection(db_path, read_only=False)
try:
@ -76,3 +81,31 @@ def export_reading_log(vault: Path, since: str = "", limit: int = 50) -> list[di
return results
finally:
conn.close()
def write_correction_note(vault: Path, paper_id: str, original_id: str,
correction: str, reason: str = "") -> bool:
"""Record a correction note for a prior reading_note event."""
db_path = get_memory_db_path(vault)
if not db_path.exists():
return False
payload = {
"original_id": original_id,
"correction": correction,
"reason": reason,
}
conn = get_connection(db_path, read_only=False)
try:
conn.execute(
"""INSERT INTO paper_events (paper_id, event_type, payload_json)
VALUES (?, 'correction_note', ?)""",
(paper_id, json.dumps(payload, ensure_ascii=False)),
)
conn.commit()
return True
except Exception:
conn.rollback()
return False
finally:
conn.close()