storycode / narrate.py
Codex (via opencode)
Codex: prompt tuning, _loads fix, GitHub ingestion, lint cleanup
7603aa2
Raw
History Blame Contribute Delete
4.09 kB
"""Map-reduce narration: per-file summaries, then the whole-project story.
MAP — summarise each file (style-neutral, cached by file path + content hash).
REDUCE— synthesise the project story in the chosen style + difficulty.
Both steps are grounded by `story.py` prompts. If the model endpoint is missing
or errors, we degrade to the deterministic `plain_fallback_story` so the app
still shows something true.
"""
from __future__ import annotations
import hashlib
from typing import Callable
import llm
import story
from schema import FileInfo, FileSummary, ProjectModel, ProjectStory
# Cache MAP summaries across runs/styles within a session: {content_hash: FileSummary}.
_SUMMARY_CACHE: dict[str, FileSummary] = {}
ProgressFn = Callable[[int, int, str], None]
def _hash(f: FileInfo) -> str:
h = hashlib.sha1() # noqa: S324 - cache key, not security
h.update(f.path.encode())
h.update(str([(s.kind, s.name) for s in f.symbols]).encode())
h.update(str(sorted(f.depends_on)).encode())
return h.hexdigest()
def summarise_files(model: ProjectModel, progress: ProgressFn | None = None
) -> dict[str, FileSummary]:
"""MAP step. Returns {path: FileSummary}. Never raises; falls back per file."""
out: dict[str, FileSummary] = {}
total = len(model.files)
use_model = llm.available()
for i, f in enumerate(model.files, 1):
if progress:
progress(i, total, f.path)
key = _hash(f)
if key in _SUMMARY_CACHE:
out[f.path] = _SUMMARY_CACHE[key]
continue
summary = _summarise_one(model, f) if use_model else _fallback_summary(f)
_SUMMARY_CACHE[key] = summary
out[f.path] = summary
return out
def _summarise_one(model: ProjectModel, f: FileInfo) -> FileSummary:
try:
data = llm.chat_json(story.map_prompt(model, f),
schema=_file_schema(), max_tokens=1024)
return FileSummary(path=f.path,
one_liner=(data.get("one_liner") or "").strip()[:80]
or _fallback_summary(f).one_liner,
summary=(data.get("summary") or "").strip()
or _fallback_summary(f).summary)
except Exception: # noqa: BLE001 - any backend hiccup -> safe fallback
return _fallback_summary(f)
def _fallback_summary(f: FileInfo) -> FileSummary:
import config
role = config.ROLE_LABEL.get(f.role, f.role)
names = ", ".join(s.name for s in f.symbols[:4])
extra = f" It defines {names}." if names else ""
return FileSummary(
path=f.path, one_liner=role.lower(),
summary=(f"This is part of the project's {role.lower()}.{extra} "
f"{f.fan_in} other file(s) depend on it."))
def tell_story(model: ProjectModel, summaries: dict[str, FileSummary],
style_key: str, difficulty_key: str) -> ProjectStory:
"""REDUCE step. Falls back to the deterministic story on any failure."""
if not llm.available():
return story.plain_fallback_story(model, summaries)
try:
data = llm.chat_json(
story.reduce_prompt(model, summaries, style_key, difficulty_key),
schema=_story_schema(), max_tokens=2048)
if not data.get("title"):
raise ValueError("empty story")
from schema import StorySection
steps = [StorySection(heading=s.get("heading", ""), body=s.get("body", ""))
for s in data.get("steps", []) if s.get("body")]
return ProjectStory(title=data["title"], overview=data.get("overview", ""),
steps=steps,
plain_overview=data.get("plain_overview", "") or data.get("overview", ""))
except Exception: # noqa: BLE001
return story.plain_fallback_story(model, summaries)
def _file_schema() -> dict:
from schema import file_summary_schema
return file_summary_schema()
def _story_schema() -> dict:
from schema import project_story_schema
return project_story_schema()