文件预览

foreshadowing.py

查看 lobster-novel 技能包中的文件内容。

文件内容

memory/foreshadowing.py

#!/usr/bin/env python3
"""
lobster-novel: Foreshadowing/hook tracker (standalone)
Inspired by novel-writing's foreshadowing tracker.
"""
import json
from pathlib import Path
from dataclasses import dataclass, field, asdict
from datetime import datetime
from typing import List, Optional


@dataclass
class Hook:
    """A single hook or foreshadowing element."""
    id: str
    description: str
    planted_chapter: int
    expected_payoff_chapter: int
    status: str = "active"         # active, hinted, resolved, abandoned
    importance: str = "normal"     # minor, normal, major, critical
    category: str = "plot"         # plot, character, world, mystery
    resolved_chapter: int = 0
    notes: str = ""

    def overdue_by(self, current_chapter: int) -> int:
        if self.status != "active":
            return 0
        return max(0, current_chapter - self.expected_payoff_chapter)


class ForeshadowTracker:
    """Manages hooks/foreshadowing with overdue detection."""
    FILE = "hooks.json"

    def __init__(self, project_dir: Path):
        self.dir = Path(project_dir) / "continuity"
        self.dir.mkdir(parents=True, exist_ok=True)
        self.file = self.dir / self.FILE
        self.hooks: List[Hook] = self._load()

    def _load(self) -> List[Hook]:
        if not self.file.exists():
            return []
        try:
            data = json.loads(self.file.read_text(encoding="utf-8"))
            return [Hook(**h) for h in data]
        except Exception:
            return []

    def save(self):
        self.file.write_text(
            json.dumps([asdict(h) for h in self.hooks],
                       ensure_ascii=False, indent=2),
            encoding="utf-8")

    def plant(self, description: str, chapter: int, payoff_chapter: int,
              category: str = "plot", importance: str = "normal") -> Hook:
        """Register a new hook."""
        hook = Hook(
            id=f"hook-{len(self.hooks) + 1}",
            description=description,
            planted_chapter=chapter,
            expected_payoff_chapter=payoff_chapter,
            category=category,
            importance=importance,
        )
        self.hooks.append(hook)
        self.save()
        return hook

    def resolve(self, hook_id: str, chapter: int):
        """Mark a hook as resolved (paid off)."""
        for h in self.hooks:
            if h.id == hook_id:
                h.status = "resolved"
                h.resolved_chapter = chapter
                break
        self.save()

    def resolve_by_desc(self, description: str, chapter: int):
        """Resolve by description (fuzzy match)."""
        for h in self.hooks:
            if h.status == "active" and description.lower() in h.description.lower():
                h.status = "resolved"
                h.resolved_chapter = chapter
                break
        self.save()

    def hint(self, hook_id: str):
        """Mark a hook as hinted (partial payoff)."""
        for h in self.hooks:
            if h.id == hook_id:
                h.status = "hinted"
                break
        self.save()

    def abandon(self, hook_id: str):
        """Mark a hook as abandoned."""
        for h in self.hooks:
            if h.id == hook_id:
                h.status = "abandoned"
                break
        self.save()

    def get_active(self, current_chapter: int) -> List[Hook]:
        """Get all active hooks, sorted by urgency."""
        active = [h for h in self.hooks if h.status == "active"]
        active.sort(key=lambda h: h.overdue_by(current_chapter), reverse=True)
        return active

    def get_overdue(self, current_chapter: int, threshold: int = 3) -> List[Hook]:
        """Get hooks overdue by threshold+ chapters."""
        return [h for h in self.get_active(current_chapter)
                if h.overdue_by(current_chapter) >= threshold]

    def get_resolved(self) -> List[Hook]:
        return [h for h in self.hooks if h.status == "resolved"]

    def summary(self, current_chapter: int) -> str:
        """Human-readable summary."""
        active = self.get_active(current_chapter)
        overdue = self.get_overdue(current_chapter)
        resolved = self.get_resolved()

        lines = [
            f"Hooks: {len(self.hooks)} total, {len(active)} active, "
            f"{len(overdue)} overdue, {len(resolved)} resolved"
        ]
        if overdue:
            lines.append("\n⚠️ Overdue hooks:")
            for h in overdue:
                lines.append(f"  [{h.importance}] {h.description[:60]} "
                             f"(planted ch{h.planted_chapter}, "
                             f"overdue by {h.overdue_by(current_chapter)} ch)")
        if active:
            lines.append("\nActive hooks:")
            for h in active[:10]:
                lines.append(f"  {h.id}: {h.description[:50]} → due ch{h.expected_payoff_chapter}")
        return "\n".join(lines)