File size: 6,889 Bytes
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
71d239c
 
 
 
 
 
 
 
 
 
 
 
7603aa2
71d239c
 
7603aa2
 
71d239c
 
7603aa2
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
 
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
 
 
 
 
 
71d239c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7603aa2
62bb55b
71d239c
62bb55b
71d239c
 
 
62bb55b
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
158
159
160
161
162
163
164
165
166
167
168
169
170
171
"""StoryCode — understand the code you built, as a story. Gradio frontend.

Flow: upload a .zip (or paste a file) -> static analysis builds the factual
ProjectModel -> MiniCPM narrates it (map per file, reduce to a story) -> you read
the Story, the Architecture Map, and what's Safe to Edit. Changing style/difficulty
re-narrates only (fast); it never re-analyses the code.
"""
from __future__ import annotations

import os

import gradio as gr

import config
import db
import diagram
import narrate
from analyzer import analyze_project
from ingest import from_text, from_zip, from_github
from schema import ProjectModel
from ui import theme

db.init_db()

SAMPLE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)),
                          "scripts", "sample_project")
STYLE_CHOICES = [(f"{s.emoji}  {s.label}", s.key) for s in config.STYLES]
DIFF_CHOICES = [(d.label, d.key) for d in config.DIFFICULTIES]


# --- core analysis ----------------------------------------------------------
def _ingest_input(zip_path: str | None, pasted: str | None, github_url: str | None):
    if zip_path:
        return from_zip(zip_path)
    if github_url and github_url.strip():
        return from_github(github_url.strip())
    if pasted and pasted.strip():
        return from_text(pasted)
    raise gr.Error("Upload a .zip, paste a file, or enter a GitHub URL.")


def _render_all(model: ProjectModel, story):
    secret = ""
    if model.secrets_found:
        n = len(model.secrets_found)
        secret = theme.banner(
            f"🔒 We spotted and <strong>hid {n} likely secret(s)</strong> "
            "(API keys / passwords) before analysing. Remove them from your code too.",
            "warn")
    skipped = ""
    if model.note:
        skipped = theme.banner(model.note, "info")
    return (
        theme.story_html(story),
        theme.plain_panel_html(story),
        diagram.render_html(model),
        theme.safe_to_edit_html(model),
        theme.deps_html(model),
        gr.update(value=secret + skipped, visible=bool(secret or skipped)),
    )


def analyze(zip_path, pasted, github_url, style, difficulty, progress=gr.Progress()):
    ingested = _ingest_input(zip_path, pasted, github_url)
    if not ingested.files:
        raise gr.Error("No code files found in there. Supported: Python, JS/TS, HTML, CSS, JSON, YAML.")

    progress(0.05, desc="Reading your project structure…")
    model = analyze_project(ingested)

    def _p(i, total, path):
        progress(0.1 + 0.7 * i / max(1, total), desc=f"Understanding file {i} of {total}…")

    summaries = narrate.summarise_files(model, progress=_p)
    progress(0.9, desc="Writing your story…")
    story = narrate.tell_story(model, summaries, style, difficulty)

    out = _render_all(model, story)
    return (*out, model, summaries, gr.update(selected="story"))


def analyze_sample(style, difficulty, progress=gr.Progress()):
    from ingest import from_folder
    ingested = from_folder(SAMPLE_DIR, name="doc-qa (sample)")
    progress(0.05, desc="Reading the sample project…")
    model = analyze_project(ingested)

    def _p(i, total, path):
        progress(0.1 + 0.7 * i / max(1, total), desc=f"Understanding file {i} of {total}…")

    summaries = narrate.summarise_files(model, progress=_p)
    progress(0.9, desc="Writing your story…")
    story = narrate.tell_story(model, summaries, style, difficulty)
    out = _render_all(model, story)
    return (*out, model, summaries, gr.update(selected="story"))


def restyle(model: ProjectModel, summaries, style, difficulty):
    """Re-narrate with a new style/difficulty — no re-analysis."""
    if model is None:
        return gr.update(), gr.update()
    story = narrate.tell_story(model, summaries, style, difficulty)
    return theme.story_html(story), theme.plain_panel_html(story)


# --- layout -----------------------------------------------------------------
def build() -> gr.Blocks:
    with gr.Blocks(theme=theme.THEME, css=theme.css(), title="StoryCode",
                   head=theme.MERMAID_HEAD) as demo:
        model_state = gr.State(None)
        summ_state = gr.State(None)

        gr.HTML(theme.header_html())

        with gr.Row():
            with gr.Column(scale=2):
                gr.HTML(theme.sources_html())
                zip_in = gr.File(label="Project .zip", file_types=[".zip"], type="filepath")
                with gr.Accordion("…or paste a single file", open=False):
                    paste_in = gr.Code(label="Paste code here", language="python", lines=10)
                with gr.Accordion("…or paste a GitHub URL", open=False):
                    github_in = gr.Textbox(
                        label="GitHub URL",
                        placeholder="https://github.com/owner/repo",
                        lines=1,
                    )
                with gr.Row():
                    go_btn = gr.Button("Tell me the story", variant="primary",
                                       elem_classes="primary", scale=2)
                    sample_btn = gr.Button("Try the sample project", scale=1)
            with gr.Column(scale=1):
                style_in = gr.Radio(STYLE_CHOICES, value=config.DEFAULT_STYLE,
                                    label="Story style")
                diff_in = gr.Radio(DIFF_CHOICES, value=config.DEFAULT_DIFFICULTY,
                                   label="Explain it for…")

        notice = gr.HTML(visible=False)

        with gr.Tabs() as tabs:
            with gr.Tab("📖 The Story", id="story"):
                with gr.Row():
                    story_box = gr.HTML(elem_classes="sc-story-wrap")
                    with gr.Column(scale=1, min_width=240):
                        plain_box = gr.HTML()
            with gr.Tab("🗺️ Architecture Map", id="map"):
                gr.HTML('<div class="sc-legend">Boxes are your files, grouped by job. '
                        "An arrow means “this file uses that one”.</div>")
                map_box = gr.HTML()
            with gr.Tab("🚦 Safe to Edit", id="safe"):
                safe_box = gr.HTML()
            with gr.Tab("📦 Dependencies", id="deps"):
                deps_box = gr.HTML()

        outputs = [story_box, plain_box, map_box, safe_box, deps_box, notice,
                   model_state, summ_state, tabs]

        go_btn.click(analyze, inputs=[zip_in, paste_in, github_in, style_in, diff_in],
                     outputs=outputs)
        sample_btn.click(analyze_sample, inputs=[style_in, diff_in],
                         outputs=outputs)
        # Restyle live without re-analysing.
        for ctrl in (style_in, diff_in):
            ctrl.change(restyle, inputs=[model_state, summ_state, style_in, diff_in],
                        outputs=[story_box, plain_box])

    return demo


if __name__ == "__main__":
    build().launch(show_api=False)