git2report 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,123 @@
1
+ """Evaluation helpers: make a sheet to fill in by hand, then score it.
2
+
3
+ git2report-eval sheet --repo ./proj --since 45d --n 25 --out eval.csv
4
+ (fill in the empty columns in Excel: manual summary + 1-5 scores)
5
+ git2report-eval score eval.csv
6
+
7
+ Per sampled commit the sheet holds: the TOOL sentence (our pipeline) and a BASELINE sentence
8
+ (what you get by pasting the raw commit to an AI with no pipeline; offline mode: the raw commit message)."""
9
+ from __future__ import annotations
10
+
11
+ import csv
12
+ from pathlib import Path
13
+ from typing import Optional
14
+
15
+ import typer
16
+
17
+ from git2report.chunker import chunk_module
18
+ from git2report.clustering import cluster
19
+ from git2report.config import Config
20
+ from git2report.extractor import extract
21
+ from git2report.filtering import filter_commits
22
+ from git2report.models import GitReportError
23
+ from git2report.summarizer import SummaryRequest, get_summarizer
24
+ from git2report.timeutil import parse_since
25
+
26
+ app = typer.Typer(add_completion=False, help="Build and score the manual-vs-tool evaluation sheet.")
27
+
28
+ COLUMNS = ["hash", "message", "module", "tool_sentence", "tool_mode", "baseline_sentence",
29
+ "manual_summary", "tool_accuracy_1to5", "tool_readability_1to5", "tool_unsupported_claims",
30
+ "baseline_accuracy_1to5", "baseline_readability_1to5", "baseline_unsupported_claims", "notes"]
31
+
32
+
33
+ def pick_evenly(items: list, n: int) -> list:
34
+ """Deterministic, evenly spread sample (so the sheet can be rebuilt identically)."""
35
+ if n >= len(items):
36
+ return list(items)
37
+ step = len(items) / n
38
+ return [items[int(i * step)] for i in range(n)]
39
+
40
+
41
+ def baseline_sentence(commit, summarizer) -> str:
42
+ if hasattr(summarizer, "ask"):
43
+ diff = "\n".join(f.diff for f in commit.files)[:1500]
44
+ prompt = f"Explain this git commit to a non-technical person.\nMessage: {commit.subject}\nChanges:\n{diff}"
45
+ try:
46
+ return " ".join(summarizer.ask(prompt).split())
47
+ except Exception as exc:
48
+ return f"[AI unavailable: {type(exc).__name__}] {commit.subject}"
49
+ return commit.subject # offline baseline = the raw commit message
50
+
51
+
52
+ @app.command()
53
+ def sheet(
54
+ repo: Path = typer.Option(Path("."), "--repo", "-r"),
55
+ since: str = typer.Option("45d", "--since", "-s"),
56
+ n: int = typer.Option(25, "--n", help="How many commits to sample."),
57
+ audience: str = typer.Option("guide", "--audience", "-a"),
58
+ llm: str = typer.Option("offline", "--llm", help="offline or ollama"),
59
+ model: str = typer.Option("llama3:8b", "--model"),
60
+ out: Path = typer.Option(Path("eval.csv"), "--out", "-o"),
61
+ ):
62
+ cfg = Config()
63
+ try:
64
+ commits = extract(repo, parse_since(since))
65
+ kept, _ = filter_commits(commits, cfg)
66
+ if not kept:
67
+ raise GitReportError("No usable commits in that window; try a longer --since.")
68
+ cluster(kept, cfg)
69
+ except GitReportError as exc:
70
+ typer.secho(f"Error: {exc}", fg=typer.colors.RED, err=True)
71
+ raise typer.Exit(1)
72
+ summarizer = get_summarizer(llm, model)
73
+ rows = []
74
+ for c in pick_evenly(kept, n):
75
+ res = summarizer.summarize(SummaryRequest(c.module, [c], chunk_module([c], cfg).text, audience))
76
+ rows.append({"hash": c.hash, "message": c.subject, "module": c.module, "tool_sentence": res.text,
77
+ "tool_mode": res.mode, "baseline_sentence": baseline_sentence(c, summarizer)})
78
+ with open(out, "w", newline="", encoding="utf-8-sig") as f: # utf-8-sig so Excel opens it correctly
79
+ w = csv.DictWriter(f, fieldnames=COLUMNS)
80
+ w.writeheader()
81
+ for r in rows:
82
+ w.writerow({k: r.get(k, "") for k in COLUMNS})
83
+ typer.echo(f"Wrote {out} with {len(rows)} commits. Fill in the empty columns, then run: git2report-eval score {out}")
84
+
85
+
86
+ def _nums(rows: list[dict], col: str) -> list[float]:
87
+ out = []
88
+ for r in rows:
89
+ try:
90
+ out.append(float((r.get(col) or "").strip()))
91
+ except ValueError:
92
+ continue
93
+ return out
94
+
95
+
96
+ def _avg(xs: list[float]) -> str:
97
+ return f"{sum(xs) / len(xs):.2f}" if xs else "n/a"
98
+
99
+
100
+ def summarize_scores(rows: list[dict]) -> str:
101
+ lines = ["| Measure | Tool (our pipeline) | Baseline |", "|---|---|---|"]
102
+ for label, t, b in (("Accuracy (1-5, higher is better)", "tool_accuracy_1to5", "baseline_accuracy_1to5"),
103
+ ("Readability (1-5, higher is better)", "tool_readability_1to5", "baseline_readability_1to5")):
104
+ tv, bv = _nums(rows, t), _nums(rows, b)
105
+ lines.append(f"| {label} | {_avg(tv)} (n={len(tv)}) | {_avg(bv)} (n={len(bv)}) |")
106
+ tu, bu = _nums(rows, "tool_unsupported_claims"), _nums(rows, "baseline_unsupported_claims")
107
+ lines.append(f"| Unsupported claims (total, lower is better) | {int(sum(tu))} over {len(tu)} rows | {int(sum(bu))} over {len(bu)} rows |")
108
+ return "\n".join(lines)
109
+
110
+
111
+ @app.command()
112
+ def score(sheet_path: Path = typer.Argument(..., help="The filled-in CSV"),
113
+ out: Optional[Path] = typer.Option(None, "--out", "-o", help="Also save the table as Markdown")):
114
+ with open(sheet_path, newline="", encoding="utf-8-sig") as f:
115
+ rows = list(csv.DictReader(f))
116
+ table = summarize_scores(rows)
117
+ typer.echo(f"{len(rows)} rows read\n\n{table}")
118
+ if out:
119
+ out.write_text(table + "\n", encoding="utf-8")
120
+
121
+
122
+ if __name__ == "__main__":
123
+ app()
@@ -0,0 +1,37 @@
1
+ """STEP 1: read commits from a Git repository into plain records."""
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ from pydriller import Repository
8
+
9
+ from git2report.models import CommitRecord, FileChange, GitReportError
10
+
11
+
12
+ def extract(repo_path: Path, since: datetime) -> list[CommitRecord]:
13
+ repo_path = Path(repo_path)
14
+ if not (repo_path / ".git").exists():
15
+ raise GitReportError(f"'{repo_path}' is not a Git repository (no .git folder found).")
16
+ records: list[CommitRecord] = []
17
+ try:
18
+ for c in Repository(str(repo_path), since=since).traverse_commits():
19
+ records.append(CommitRecord(
20
+ hash=c.hash[:7],
21
+ message=c.msg.strip(),
22
+ date=c.committer_date.date().isoformat(),
23
+ author=c.author.name,
24
+ is_merge=c.merge,
25
+ files=[
26
+ FileChange(
27
+ path=f.new_path or f.old_path or "",
28
+ added=f.added_lines,
29
+ deleted=f.deleted_lines,
30
+ diff=f.diff or "",
31
+ )
32
+ for f in c.modified_files
33
+ ],
34
+ ))
35
+ except Exception as exc: # PyDriller/GitPython errors
36
+ raise GitReportError(f"Could not read the repository: {exc}") from exc
37
+ return records
@@ -0,0 +1,34 @@
1
+ """STEP 2: drop commits a reader does not care about (merges, typos, unfinished work)."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass
6
+
7
+ from git2report.config import Config
8
+ from git2report.models import CommitRecord
9
+
10
+
11
+ @dataclass
12
+ class Decision:
13
+ hash: str
14
+ subject: str
15
+ kept: bool
16
+ reason: str
17
+
18
+
19
+ def decide(commit: CommitRecord, cfg: Config) -> Decision:
20
+ subject = commit.subject
21
+ if commit.is_merge or subject.lower().startswith("merge "):
22
+ return Decision(commit.hash, subject, False, "merge commit")
23
+ words = "|".join(re.escape(w) for w in cfg.noise_words)
24
+ if re.search(rf"\b({words})\b", subject.lower()):
25
+ if commit.lines >= cfg.keep_if_lines:
26
+ return Decision(commit.hash, subject, True, "noise word, but big change: kept")
27
+ return Decision(commit.hash, subject, False, "noise word and small change")
28
+ return Decision(commit.hash, subject, True, "ok")
29
+
30
+
31
+ def filter_commits(commits: list[CommitRecord], cfg: Config) -> tuple[list[CommitRecord], list[Decision]]:
32
+ decisions = [decide(c, cfg) for c in commits]
33
+ kept = [c for c, d in zip(commits, decisions) if d.kept]
34
+ return kept, decisions
git2report/metrics.py ADDED
@@ -0,0 +1,111 @@
1
+ """STEP 4: numbers, counted by code. The AI never produces or touches these."""
2
+ from __future__ import annotations
3
+
4
+ from git2report.config import Config
5
+ from git2report.models import CommitRecord
6
+ from git2report.rules import commit_type, is_junk, is_test
7
+
8
+
9
+ def compute_metrics(commits: list[CommitRecord], groups: dict[str, list[CommitRecord]], cfg: Config,
10
+ total_commits_seen: int | None = None) -> dict:
11
+ from pathlib import Path
12
+
13
+ files = [f for c in commits for f in c.files if not is_junk(f.path, cfg.junk)]
14
+ test_lines = sum(f.added + f.deleted for f in files if is_test(f.path))
15
+ code_lines = sum(f.added + f.deleted for f in files if not is_test(f.path))
16
+ lines_added = sum(f.added for f in files)
17
+ lines_deleted = sum(f.deleted for f in files)
18
+
19
+ by_type: dict[str, int] = {}
20
+ for c in commits:
21
+ key = commit_type(c.subject) or "other"
22
+ by_type[key] = by_type.get(key, 0) + 1
23
+ top = max(groups, key=lambda g: (len(groups[g]), g)) if groups else ""
24
+
25
+ author_stats: dict[str, dict] = {}
26
+ for c in commits:
27
+ a = c.author or "Unknown"
28
+ if a not in author_stats:
29
+ author_stats[a] = {"name": a, "commits": 0, "added": 0, "deleted": 0, "modules": {}}
30
+ author_stats[a]["commits"] += 1
31
+ for f in c.files:
32
+ if not is_junk(f.path, cfg.junk):
33
+ author_stats[a]["added"] += f.added
34
+ author_stats[a]["deleted"] += f.deleted
35
+ if c.module:
36
+ author_stats[a]["modules"][c.module] = author_stats[a]["modules"].get(c.module, 0) + 1
37
+
38
+ contributors = []
39
+ for a, s in author_stats.items():
40
+ top_mod = max(s["modules"], key=s["modules"].get) if s["modules"] else top
41
+ contributors.append({
42
+ "name": a,
43
+ "commits": s["commits"],
44
+ "added": s["added"],
45
+ "deleted": s["deleted"],
46
+ "lines_changed": s["added"] + s["deleted"],
47
+ "top_module": top_mod,
48
+ })
49
+ contributors.sort(key=lambda x: (x["commits"], x["lines_changed"]), reverse=True)
50
+
51
+ total_lines = max(test_lines + code_lines, 1)
52
+ mod_lines: dict[str, int] = {}
53
+ for mod, cs in groups.items():
54
+ mod_files = [f for c in cs for f in c.files if not is_junk(f.path, cfg.junk)]
55
+ mod_lines[mod] = sum(f.added + f.deleted for f in mod_files)
56
+
57
+ module_distribution = {
58
+ mod: round((lines / total_lines) * 100, 1)
59
+ for mod, lines in sorted(mod_lines.items(), key=lambda kv: kv[1], reverse=True)
60
+ }
61
+
62
+ ext_stats: dict[str, dict] = {}
63
+ TECH_MAP = {
64
+ ".jsx": "React (JSX)", ".tsx": "React (TSX)", ".js": "JavaScript", ".ts": "TypeScript",
65
+ ".py": "Python", ".html": "HTML", ".css": "CSS", ".scss": "SCSS", ".json": "JSON / Config",
66
+ ".md": "Documentation", ".sql": "SQL / Database", ".yml": "YAML / CI", ".yaml": "YAML / CI",
67
+ ".go": "Go", ".rs": "Rust", ".java": "Java", ".sh": "Shell Script"
68
+ }
69
+ for f in files:
70
+ ext = Path(f.path).suffix.lower() or "other"
71
+ name = TECH_MAP.get(ext, ext.upper() if ext.startswith(".") else "Other")
72
+ if name not in ext_stats:
73
+ ext_stats[name] = {"tech": name, "count": 0, "lines": 0}
74
+ ext_stats[name]["count"] += 1
75
+ ext_stats[name]["lines"] += (f.added + f.deleted)
76
+
77
+ tech_stack = []
78
+ for t, s in sorted(ext_stats.items(), key=lambda x: x[1]["lines"], reverse=True)[:5]:
79
+ pct = round((s["lines"] / total_lines) * 100, 1)
80
+ if pct > 0:
81
+ tech_stack.append({"tech": t, "percentage": pct, "lines": s["lines"]})
82
+
83
+ # Codebase Hotspots: files touched by most commits
84
+ file_freq: dict[str, dict] = {}
85
+ for c in commits:
86
+ for f in c.files:
87
+ if not is_junk(f.path, cfg.junk) and f.path:
88
+ if f.path not in file_freq:
89
+ file_freq[f.path] = {"path": f.path, "commits": 0, "lines": 0}
90
+ file_freq[f.path]["commits"] += 1
91
+ file_freq[f.path]["lines"] += (f.added + f.deleted)
92
+ hotspots = sorted(file_freq.values(), key=lambda x: (x["commits"], x["lines"]), reverse=True)[:5]
93
+
94
+ return {
95
+ "commits": len(commits),
96
+ "commits_seen_before_filter": total_commits_seen if total_commits_seen is not None else len(commits),
97
+ "authors": len({c.author for c in commits}),
98
+ "lines_changed": test_lines + code_lines, # junk files excluded
99
+ "lines_changed_incl_junk": sum(c.lines for c in commits),
100
+ "lines_added": lines_added,
101
+ "lines_deleted": lines_deleted,
102
+ "net_lines": lines_added - lines_deleted,
103
+ "top_module": top,
104
+ "modules": len(groups),
105
+ "test_to_code_ratio": round(test_lines / max(code_lines, 1), 2),
106
+ "commits_by_type": dict(sorted(by_type.items())),
107
+ "contributors": contributors,
108
+ "module_distribution": module_distribution,
109
+ "tech_stack": tech_stack,
110
+ "hotspots": hotspots,
111
+ }
git2report/models.py ADDED
@@ -0,0 +1,45 @@
1
+ """Plain data containers shared by every pipeline step."""
2
+ from __future__ import annotations
3
+
4
+ from dataclasses import dataclass, field
5
+
6
+
7
+ class GitReportError(Exception):
8
+ """A problem the user can fix (bad path, no commits, ...). Shown without a traceback."""
9
+
10
+
11
+ @dataclass
12
+ class FileChange:
13
+ path: str
14
+ added: int
15
+ deleted: int
16
+ diff: str = ""
17
+
18
+
19
+ @dataclass
20
+ class CommitRecord:
21
+ hash: str
22
+ message: str
23
+ date: str
24
+ author: str
25
+ is_merge: bool
26
+ files: list[FileChange] = field(default_factory=list)
27
+ module: str = ""
28
+ module_reason: str = ""
29
+
30
+ @property
31
+ def subject(self) -> str:
32
+ text = self.message.strip()
33
+ return text.splitlines()[0] if text else ""
34
+
35
+ @property
36
+ def lines(self) -> int:
37
+ return sum(f.added + f.deleted for f in self.files)
38
+
39
+
40
+ @dataclass
41
+ class BranchInfo:
42
+ name: str
43
+ last_date: str
44
+ ahead: int # commits on the branch that are not yet in the current branch
45
+ subject: str
git2report/pipeline.py ADDED
@@ -0,0 +1,188 @@
1
+ """Glue: run the 7 steps in order and (optionally) save every step's result as a file."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ from dataclasses import asdict, dataclass, field
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+ from typing import Callable
9
+
10
+ from git2report.branches import open_branches
11
+ from git2report.chunker import chunk_module
12
+ from git2report.clustering import cluster
13
+ from git2report.config import Config
14
+ from git2report.extractor import extract
15
+ from git2report.filtering import filter_commits
16
+ from git2report.metrics import compute_metrics
17
+ from git2report.models import GitReportError
18
+ from git2report.report import CategorizedCommit, ReportData, Section
19
+ from git2report.rules import commit_type, is_junk
20
+ from git2report.summarizer import SummaryRequest
21
+
22
+
23
+ def get_remote_url(repo: Path) -> str | None:
24
+ import subprocess
25
+ try:
26
+ r = subprocess.run(["git", "config", "--get", "remote.origin.url"], cwd=repo, capture_output=True, text=True)
27
+ url = r.stdout.strip()
28
+ if not url:
29
+ return None
30
+ if url.startswith("git@github.com:"):
31
+ url = "https://github.com/" + url[len("git@github.com:"):]
32
+ elif url.startswith("git@gitlab.com:"):
33
+ url = "https://gitlab.com/" + url[len("git@gitlab.com:"):]
34
+ if url.endswith(".git"):
35
+ url = url[:-4]
36
+ return url if url.startswith("http") else None
37
+ except Exception:
38
+ return None
39
+
40
+
41
+ @dataclass
42
+ class Options:
43
+ repo: Path
44
+ since: datetime
45
+ audience: str = "guide"
46
+ artifacts: Path | None = None # if set, every step's output is saved here
47
+ planned: list[str] = field(default_factory=list) # goals written by the developer (never invented)
48
+ include_branches: bool = True
49
+
50
+
51
+ def glance_text(by_type: dict[str, int]) -> str:
52
+ """'3 features, 2 fixes, 1 improvement': counted from commit types, never written by the AI."""
53
+ feats, fixes = by_type.get("feat", 0), by_type.get("fix", 0)
54
+ imps = by_type.get("perf", 0) + by_type.get("refactor", 0)
55
+ rest = sum(by_type.values()) - feats - fixes - imps
56
+ parts = []
57
+ for n, one, many in ((feats, "feature", "features"), (fixes, "fix", "fixes"),
58
+ (imps, "improvement", "improvements"), (rest, "other change", "other changes")):
59
+ if n:
60
+ parts.append(f"{n} {one if n == 1 else many}")
61
+ return ", ".join(parts) or "no categorised changes"
62
+
63
+
64
+ def run_pipeline(opts: Options, summarizer, cfg: Config | None = None,
65
+ log: Callable[[str], None] = lambda _m: None) -> ReportData:
66
+ cfg = cfg or Config()
67
+
68
+ log("Step 1/7 extracting commits")
69
+ commits = extract(opts.repo, opts.since)
70
+ if not commits:
71
+ raise GitReportError("No commits found in that time window. Try a longer --since (e.g. 14d).")
72
+ log(f" {len(commits)} commits found")
73
+
74
+ log("Step 2/7 filtering noise")
75
+ kept, decisions = filter_commits(commits, cfg)
76
+ if not kept:
77
+ raise GitReportError("Every commit in the window was filtered out as noise (merges, typos, wip).")
78
+ log(f" {len(commits)} -> {len(kept)} commits")
79
+
80
+ log("Step 3/7 clustering by module")
81
+ groups = cluster(kept, cfg)
82
+ log(f" {len(groups)} modules: {', '.join(groups)}")
83
+
84
+ log("Step 4/7 counting metrics")
85
+ metrics = compute_metrics(kept, groups, cfg, total_commits_seen=len(commits))
86
+
87
+ sections: list[Section] = []
88
+ chunks, prompts, summaries = {}, {}, {}
89
+ for i, (module, cs) in enumerate(groups.items(), 1):
90
+ log(f"Step 5-6 {module} ({i}/{len(groups)}): chunking + summarising")
91
+ ch = chunk_module(cs, cfg)
92
+ req = SummaryRequest(module, cs, ch.text, opts.audience)
93
+ result = summarizer.summarize(req)
94
+ hashes = [c.hash for c in cs]
95
+ cat_commits = [
96
+ CategorizedCommit(
97
+ hash=c.hash,
98
+ subject=c.subject,
99
+ author=c.author,
100
+ date=c.date,
101
+ type=commit_type(c.subject) or "other",
102
+ added=sum(f.added for f in c.files if not is_junk(f.path, cfg.junk)),
103
+ deleted=sum(f.deleted for f in c.files if not is_junk(f.path, cfg.junk)),
104
+ )
105
+ for c in cs
106
+ ]
107
+ sections.append(Section(module, result.text, hashes, result.mode, result.note, commits=cat_commits))
108
+ chunks[module], prompts[module] = ch, req.prompt
109
+ summaries[module] = {"summary": result.text, "mode": result.mode, "flagged": result.flagged,
110
+ "note": result.note, "commit_hashes": hashes}
111
+
112
+ log("Step 7/7 building the report")
113
+ modes = {s.mode for s in sections}
114
+ n_off = sum(1 for s in sections if s.mode == "offline")
115
+ base = getattr(summarizer, "label", "unknown")
116
+ if modes == {"ai"} or not hasattr(summarizer, "model"):
117
+ mode_label = base
118
+ elif modes == {"offline"} and hasattr(summarizer, "model"):
119
+ mode_label = f"{base}; the AI step failed for every module, so offline wording was used"
120
+ else:
121
+ mode_label = f"{base}; offline wording used for {n_off} module(s)"
122
+ in_progress = open_branches(opts.repo, opts.since) if opts.include_branches else []
123
+ remote_url = get_remote_url(opts.repo)
124
+ exec_summary = make_executive_summary(Path(opts.repo).resolve().name, metrics, sections, summarizer)
125
+ data = ReportData(
126
+ project=Path(opts.repo).resolve().name,
127
+ start=opts.since.date().isoformat(), end=datetime.now().date().isoformat(),
128
+ audience=opts.audience, metrics=metrics, sections=sections, mode_label=mode_label,
129
+ glance_text=glance_text(metrics["commits_by_type"]), in_progress=in_progress, planned=opts.planned,
130
+ remote_url=remote_url, executive_summary=exec_summary,
131
+ )
132
+ if opts.artifacts:
133
+ _save_artifacts(opts.artifacts, commits, decisions, groups, metrics, chunks, prompts, summaries)
134
+ return data
135
+
136
+
137
+ def make_executive_summary(project: str, metrics: dict, sections: list[Section], summarizer) -> str:
138
+ if hasattr(summarizer, "ask") and getattr(summarizer, "mode", "") == "ai":
139
+ from git2report.cache import compute_cache_key, default_cache
140
+ all_hashes = [h for s in sections for h in s.hashes]
141
+ cache_key = compute_cache_key("exec_summary", project, all_hashes, getattr(summarizer, "model", "ai"), str(metrics.get("lines_changed", 0)))
142
+ cached = default_cache.get(cache_key)
143
+ if cached:
144
+ return cached["summary"]
145
+
146
+ mod_summaries = "\n".join(f"- {s.module}: {s.text}" for s in sections[:5])
147
+ prompt = (
148
+ f"You are a technology leader summarizing software progress for project '{project}'.\n"
149
+ f"Key metrics: {metrics['commits']} commits, {metrics['lines_changed']} lines changed, primary focus on {metrics['top_module']}.\n"
150
+ f"Module deliverables:\n{mod_summaries}\n\n"
151
+ "Task: Write 2 clear, inspiring, plain-language sentences summarizing the major business progress and impact for non-technical leadership.\n"
152
+ "Rules: Do not write file names or numbers. Use plain, positive business English.\n"
153
+ 'Return ONLY JSON: {"summary": "..."}'
154
+ )
155
+ try:
156
+ raw = summarizer.ask(prompt)
157
+ from git2report.summarizer import parse_model_json
158
+ text = parse_model_json(raw)
159
+ if text:
160
+ default_cache.set(cache_key, text, "ai")
161
+ return text
162
+ except Exception:
163
+ pass
164
+ commits = metrics["commits"]
165
+ modules = metrics["modules"]
166
+ top = metrics["top_module"]
167
+ added, deleted = metrics.get("lines_added", 0), metrics.get("lines_deleted", 0)
168
+ return (
169
+ f"During this delivery cycle, engineering completed {commits} verified commits across {modules} module(s), "
170
+ f"expanding the codebase with +{added} lines and pruning -{deleted} lines. "
171
+ f"Development concentrated primarily on {top}, advancing key capabilities while reinforcing overall system stability."
172
+ )
173
+
174
+
175
+ def _save_artifacts(root: Path, commits, decisions, groups, metrics, chunks, prompts, summaries) -> None:
176
+ root = Path(root)
177
+ (root / "chunks").mkdir(parents=True, exist_ok=True)
178
+ (root / "prompts").mkdir(parents=True, exist_ok=True)
179
+ dump = lambda name, obj: (root / name).write_text(json.dumps(obj, indent=2), encoding="utf-8")
180
+ dump("commits.json", [asdict(c) for c in commits])
181
+ dump("filter_decisions.json", [asdict(d) for d in decisions])
182
+ dump("clusters.json", {m: [c.hash for c in cs] for m, cs in groups.items()})
183
+ dump("metrics.json", metrics)
184
+ dump("summaries.json", summaries)
185
+ for m, ch in chunks.items():
186
+ safe = "".join(ch_ if ch_.isalnum() or ch_ in "-_" else "_" for ch_ in m)
187
+ (root / "chunks" / f"{safe}.txt").write_text(ch.text, encoding="utf-8")
188
+ (root / "prompts" / f"{safe}.txt").write_text(prompts[m], encoding="utf-8")