File size: 7,343 Bytes
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
 
 
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
 
 
 
 
 
 
71d239c
 
 
 
 
 
 
 
 
 
7603aa2
 
 
71d239c
7603aa2
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
 
 
 
 
 
 
 
 
 
 
 
71d239c
7603aa2
 
 
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
"""Prompt construction + a deterministic fallback story.

Design rule: the model only ever **narrates facts we extracted**. Every prompt
hands it the structured truth (roles, symbols, edges) and forbids inventing
files or connections. The per-file MAP summaries are style-neutral (so they
cache across styles); the project REDUCE applies the chosen style + difficulty.
"""
from __future__ import annotations

import posixpath

import config
from schema import FileInfo, FileSummary, ProjectModel, ProjectStory, StorySection

_GROUNDING = (
    "You are StoryCode. You explain code to people who are NOT programmers. "
    "Only describe what the FACTS below state. Never invent files, functions, or "
    "connections that aren't listed. If something isn't in the facts, don't mention it. "
    "Use the file's actual function/class names to explain what it does β€” don't just "
    "repeat the role category."
)


# --- MAP: per-file factual summary ------------------------------------------
def file_digest(model: ProjectModel, f: FileInfo) -> str:
    role = config.ROLE_LABEL.get(f.role, f.role)
    syms = ", ".join(s.name for s in f.symbols[:12]) or "(no named functions)"
    dep_names = [posixpath.basename(d) for d in f.depends_on]
    deps = ", ".join(dep_names) or "(nothing else in this project)"
    used_by = sum(1 for o in model.files if f.path in o.depends_on)
    return (
        f"FILE: {f.path}\n"
        f"Language: {f.lang}\n"
        f"Its job-category: {role}\n"
        f"Defines: {syms}\n"
        f"It uses (depends on): {deps}\n"
        f"Number of other files that depend on it: {used_by}\n"
        f"Lines of code: {f.loc}"
    )


def map_prompt(model: ProjectModel, f: FileInfo) -> list[dict]:
    facts = file_digest(model, f)
    user = (
        f"{facts}\n\n"
        "Write a JSON object with:\n"
        '- "one_liner": at most 8 words naming this file\'s actual job. '
        "Use the function/class names listed above to be specific β€” "
        'e.g. "Defines the PPO actor-critic neural network" not just "The engine".\n'
        '- "summary": 2-3 sentences, no jargon, explaining:\n'
        "  1) What this file actually does (based on its function/class names)\n"
        "  2) Which specific files it connects to and why\n"
        "Speak to a non-coder who wants to understand the project."
    )
    return [{"role": "system", "content": _GROUNDING},
            {"role": "user", "content": user}]


# --- REDUCE: the whole-project story ----------------------------------------
def project_digest(model: ProjectModel, summaries: dict[str, FileSummary]) -> str:
    lines = [f"PROJECT: {model.name}",
             f"Languages: {', '.join(model.languages) or 'mixed'}",
             f"Front door (starts here): {', '.join(model.entry_points) or 'unknown'}",
             ""]
    # Group files by role for better context
    by_role: dict[str, list[FileInfo]] = {}
    for f in model.files:
        by_role.setdefault(f.role, []).append(f)
    role_order = ["entry", "backend", "data", "config", "util", "frontend", "test", "other"]
    for role in role_order:
        group = by_role.get(role, [])
        if not group:
            continue
        label = config.ROLE_LABEL.get(role, role)
        lines.append(f"--- {label} ---")
        for f in group:
            base = posixpath.basename(f.path)
            one = summaries.get(f.path)
            job = one.one_liner if one else config.ROLE_LABEL.get(f.role, f.role)
            deps = ", ".join(posixpath.basename(d) for d in f.depends_on) or "nothing internal"
            lines.append(f"- {base} β€” {job}. Uses: {deps}.")
        lines.append("")
    return "\n".join(lines)


def reduce_prompt(model: ProjectModel, summaries: dict[str, FileSummary],
                  style_key: str, difficulty_key: str) -> list[dict]:
    style = config.STYLE_BY_KEY.get(style_key, config.STYLE_BY_KEY[config.DEFAULT_STYLE])
    diff = config.DIFFICULTY_BY_KEY.get(difficulty_key,
                                        config.DIFFICULTY_BY_KEY[config.DEFAULT_DIFFICULTY])
    digest = project_digest(model, summaries)
    user = (
        f"{digest}\n\n"
        f"STYLE: {style.voice}\n"
        f"AUDIENCE: {diff.voice}\n\n"
        "Write a JSON object with:\n"
        '- "title": a short, inviting title for this project\'s story.\n'
        '- "overview": 2-4 sentences explaining what this project IS (its purpose), '
        "WHAT it does (the main capability), and HOW it works (the key architectural "
        "flow from entry point through the files it uses). Be specific β€” use the "
        "project name, the key files, and the real data flow.\n"
        '- "steps": exactly 3-6 {"heading","body"} sections that follow the REAL '
        "execution flow: start at the front door (entry point), then follow the "
        "import chain through the files it uses. Each step should explain:\n"
        "  1) What happens at this stage\n"
        "  2) Which files are involved and what they do\n"
        "  3) Why this step matters for the overall project\n"
        "Apply the STYLE here. Don't skip intermediate files β€” the reader should "
        "understand the complete journey from start to finish.\n"
        '- "plain_overview": the SAME walkthrough but in flat, plain English with NO '
        "metaphors (so a nervous user always has a literal version). 3-5 sentences. "
        "This should be simple enough for a 12-year-old to understand.\n"
        "Stay 100% true to the FILES facts above. Never invent connections."
    )
    return [{"role": "system", "content": _GROUNDING},
            {"role": "user", "content": user}]


# --- Deterministic fallback (no model needed) -------------------------------
def plain_fallback_story(model: ProjectModel,
                         summaries: dict[str, FileSummary] | None = None) -> ProjectStory:
    """A truthful, model-free story built straight from the facts.

    Used if the model endpoint is unavailable, so the app degrades gracefully
    instead of showing nothing.
    """
    summaries = summaries or {}
    entry = model.entry_points[0] if model.entry_points else (
        model.files[0].path if model.files else "")
    steps: list[StorySection] = []
    seen: set[str] = set()

    def walk(path: str, depth: int):
        f = model.by_path(path)
        if f is None or path in seen or depth > 4:
            return
        seen.add(path)
        base = posixpath.basename(path)
        one = summaries.get(path)
        job = one.summary if one else (
            f"This is the {config.ROLE_LABEL.get(f.role, f.role).lower()} of the project.")
        steps.append(StorySection(heading=base, body=job))
        for dep in f.depends_on:
            walk(dep, depth + 1)

    if entry:
        walk(entry, 0)
    for f in model.files:  # include anything not reachable from the entry
        walk(f.path, 0)

    langs = ", ".join(model.languages) or "code"
    overview = (f"{model.name} is a {langs} project made of {len(model.files)} files. "
                f"It starts at {posixpath.basename(entry) if entry else 'its main file'}, "
                "which then uses the other files to do its work.")
    return ProjectStory(title=f"The story of {model.name}", overview=overview,
                        steps=steps[:8], plain_overview=overview)