utopia-analyzer 1.0.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.
- utopia/__init__.py +3 -0
- utopia/__main__.py +6 -0
- utopia/archetypes.py +168 -0
- utopia/cli.py +107 -0
- utopia/formatters/__init__.py +1 -0
- utopia/formatters/json_output.py +50 -0
- utopia/formatters/markdown_output.py +96 -0
- utopia/formatters/terminal.py +130 -0
- utopia/github_client.py +517 -0
- utopia/improvement_plan.py +120 -0
- utopia/recruiter.py +221 -0
- utopia/report.py +76 -0
- utopia/roast.py +121 -0
- utopia/scoring.py +377 -0
- utopia_analyzer-1.0.0.dist-info/METADATA +194 -0
- utopia_analyzer-1.0.0.dist-info/RECORD +20 -0
- utopia_analyzer-1.0.0.dist-info/WHEEL +5 -0
- utopia_analyzer-1.0.0.dist-info/entry_points.txt +2 -0
- utopia_analyzer-1.0.0.dist-info/licenses/LICENSE +21 -0
- utopia_analyzer-1.0.0.dist-info/top_level.txt +1 -0
utopia/__init__.py
ADDED
utopia/__main__.py
ADDED
utopia/archetypes.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Developer archetype detection based on languages, topics, and repo signals."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Callable
|
|
8
|
+
|
|
9
|
+
from utopia.github_client import ProfileData, RepoData
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Archetype signal definitions
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
ARCHETYPE_LANGUAGES: dict[str, set[str]] = {
|
|
16
|
+
"backend": {"python", "go", "java", "rust", "ruby", "php", "c#", "c++", "c"},
|
|
17
|
+
"frontend": {"javascript", "typescript", "html", "css"},
|
|
18
|
+
"mobile": {"swift", "kotlin", "dart", "java", "objective-c"},
|
|
19
|
+
"ai_ml": {"python", "r", "julia"},
|
|
20
|
+
"data": {"python", "sql", "scala", "r"},
|
|
21
|
+
"devops": {"dockerfile", "shell", "powershell", "go", "yaml"},
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
ARCHETYPE_TOPICS: dict[str, set[str]] = {
|
|
25
|
+
"backend": {"api", "backend", "database", "server", "rest", "graphql", "microservices"},
|
|
26
|
+
"frontend": {"react", "vue", "angular", "css", "ui", "frontend", "webpack", "nextjs"},
|
|
27
|
+
"mobile": {"android", "ios", "flutter", "react-native", "mobile", "swiftui"},
|
|
28
|
+
"ai_ml": {
|
|
29
|
+
"tensorflow",
|
|
30
|
+
"pytorch",
|
|
31
|
+
"machine-learning",
|
|
32
|
+
"ai",
|
|
33
|
+
"deep-learning",
|
|
34
|
+
"nlp",
|
|
35
|
+
"llm",
|
|
36
|
+
"neural-network",
|
|
37
|
+
},
|
|
38
|
+
"data": {"data", "etl", "pipeline", "spark", "kafka", "hadoop", "airflow", "dbt"},
|
|
39
|
+
"devops": {"docker", "kubernetes", "terraform", "ci-cd", "github-actions", "ansible"},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ARCHETYPE_LABELS = {
|
|
43
|
+
"backend": "Backend Engineer",
|
|
44
|
+
"frontend": "Frontend Engineer",
|
|
45
|
+
"full_stack": "Full Stack Developer",
|
|
46
|
+
"mobile": "Mobile Developer",
|
|
47
|
+
"ai_ml": "AI/ML Developer",
|
|
48
|
+
"data": "Data Engineer",
|
|
49
|
+
"devops": "DevOps Engineer",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# Thresholds used to convert raw signal counts into percentage scores.
|
|
53
|
+
ARCHETYPE_IDEAL_LANGUAGE_BYTES = 1_000_000 # ~1 MB of archetype-aligned code
|
|
54
|
+
ARCHETYPE_IDEAL_TOPIC_MATCHES = 5
|
|
55
|
+
ARCHETYPE_IDEAL_REPO_SIGNALS = 3 # e.g. workflow + dockerfile + topic matches
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# Signal aggregation
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
def _collect_signals(profile: ProfileData) -> dict[str, dict[str, float]]:
|
|
62
|
+
"""Aggregate raw signals per archetype across all repos."""
|
|
63
|
+
signals: dict[str, dict[str, float]] = {
|
|
64
|
+
key: {"language_bytes": 0.0, "topic_matches": 0.0, "repo_signals": 0.0}
|
|
65
|
+
for key in ARCHETYPE_LABELS
|
|
66
|
+
if key != "full_stack"
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
for repo in profile.repos:
|
|
70
|
+
repo_topics = {t.lower() for t in repo.topics}
|
|
71
|
+
repo_main_lang = (repo.language or "").lower()
|
|
72
|
+
|
|
73
|
+
for archetype in signals:
|
|
74
|
+
# Language bytes
|
|
75
|
+
if repo_main_lang in ARCHETYPE_LANGUAGES.get(archetype, set()):
|
|
76
|
+
signals[archetype]["language_bytes"] += 1.0 # count repos, not bytes
|
|
77
|
+
for lang, bytes_count in repo.languages.items():
|
|
78
|
+
if lang.lower() in ARCHETYPE_LANGUAGES.get(archetype, set()):
|
|
79
|
+
signals[archetype]["language_bytes"] += bytes_count
|
|
80
|
+
|
|
81
|
+
# Topic matches
|
|
82
|
+
matching_topics = repo_topics & ARCHETYPE_TOPICS.get(archetype, set())
|
|
83
|
+
signals[archetype]["topic_matches"] += len(matching_topics)
|
|
84
|
+
|
|
85
|
+
# Repo-level file/topic signals
|
|
86
|
+
repo_signal = 0.0
|
|
87
|
+
if archetype == "devops":
|
|
88
|
+
if repo.has_workflows:
|
|
89
|
+
repo_signal += 1.0
|
|
90
|
+
if repo.has_dockerfile:
|
|
91
|
+
repo_signal += 1.0
|
|
92
|
+
if repo_topics & ARCHETYPE_TOPICS["devops"]:
|
|
93
|
+
repo_signal += 1.0
|
|
94
|
+
else:
|
|
95
|
+
if repo_main_lang in ARCHETYPE_LANGUAGES.get(archetype, set()):
|
|
96
|
+
repo_signal += 0.5
|
|
97
|
+
if matching_topics:
|
|
98
|
+
repo_signal += 0.5
|
|
99
|
+
signals[archetype]["repo_signals"] += repo_signal
|
|
100
|
+
|
|
101
|
+
return signals
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _normalize(value: float, ideal: float) -> float:
|
|
105
|
+
if ideal <= 0:
|
|
106
|
+
return 0.0
|
|
107
|
+
return min(value / ideal, 1.0)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _log_normalize(value: float, ideal: float) -> float:
|
|
111
|
+
if value <= 0 or ideal <= 0:
|
|
112
|
+
return 0.0
|
|
113
|
+
return min(math.log10(value) / math.log10(ideal), 1.0)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _archetype_percentage(raw_signals: dict[str, float]) -> float:
|
|
117
|
+
"""Convert raw signals into a 0-100 percentage score."""
|
|
118
|
+
language_part = _log_normalize(
|
|
119
|
+
raw_signals["language_bytes"], ARCHETYPE_IDEAL_LANGUAGE_BYTES
|
|
120
|
+
)
|
|
121
|
+
topic_part = _normalize(raw_signals["topic_matches"], ARCHETYPE_IDEAL_TOPIC_MATCHES)
|
|
122
|
+
repo_part = _normalize(raw_signals["repo_signals"], ARCHETYPE_IDEAL_REPO_SIGNALS)
|
|
123
|
+
|
|
124
|
+
# Weighted blend: languages are the strongest signal, topics second, repo files third.
|
|
125
|
+
blended = language_part * 0.5 + topic_part * 0.3 + repo_part * 0.2
|
|
126
|
+
return round(blended * 100, 1)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
# ---------------------------------------------------------------------------
|
|
130
|
+
# Public API
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
@dataclass
|
|
133
|
+
class ArchetypeResult:
|
|
134
|
+
key: str
|
|
135
|
+
label: str
|
|
136
|
+
percentage: float
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def detect_archetypes(profile: ProfileData) -> list[ArchetypeResult]:
|
|
140
|
+
"""Detect and rank developer archetypes for the given profile."""
|
|
141
|
+
raw = _collect_signals(profile)
|
|
142
|
+
|
|
143
|
+
base_scores: dict[str, float] = {}
|
|
144
|
+
for key, sigs in raw.items():
|
|
145
|
+
base_scores[key] = _archetype_percentage(sigs)
|
|
146
|
+
|
|
147
|
+
# Full stack is derived from backend + frontend balance.
|
|
148
|
+
backend = base_scores.get("backend", 0)
|
|
149
|
+
frontend = base_scores.get("frontend", 0)
|
|
150
|
+
if backend > 0 and frontend > 0:
|
|
151
|
+
base_scores["full_stack"] = round((min(backend, frontend) * 0.6 + max(backend, frontend) * 0.2), 1)
|
|
152
|
+
else:
|
|
153
|
+
base_scores["full_stack"] = 0.0
|
|
154
|
+
|
|
155
|
+
results = [
|
|
156
|
+
ArchetypeResult(key=key, label=ARCHETYPE_LABELS[key], percentage=score)
|
|
157
|
+
for key, score in base_scores.items()
|
|
158
|
+
]
|
|
159
|
+
results.sort(key=lambda r: r.percentage, reverse=True)
|
|
160
|
+
return results
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def top_archetypes(
|
|
164
|
+
profile: ProfileData, n: int = 4, min_percentage: float = 3.0
|
|
165
|
+
) -> list[ArchetypeResult]:
|
|
166
|
+
"""Return the top N archetypes, filtering out negligible matches."""
|
|
167
|
+
all_archetypes = detect_archetypes(profile)
|
|
168
|
+
return [a for a in all_archetypes if a.percentage >= min_percentage][:n]
|
utopia/cli.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""CLI entry point for UTOPIA."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
# Ensure UTF-8 output on Windows terminals so Unicode bar charts render correctly.
|
|
9
|
+
if sys.platform == "win32" and hasattr(sys.stdout, "reconfigure"):
|
|
10
|
+
try:
|
|
11
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
12
|
+
except Exception:
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
from dotenv import load_dotenv
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.progress import Progress, SpinnerColumn, TextColumn
|
|
19
|
+
|
|
20
|
+
from utopia.formatters import json_output, markdown_output, terminal
|
|
21
|
+
from utopia.github_client import (
|
|
22
|
+
GitHubError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
UserNotFoundError,
|
|
25
|
+
fetch_profile,
|
|
26
|
+
)
|
|
27
|
+
from utopia.report import build_report
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
load_dotenv()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.command()
|
|
34
|
+
@click.argument("username")
|
|
35
|
+
@click.option("--token", default=None, help="GitHub Personal Access Token for higher rate limits.")
|
|
36
|
+
@click.option(
|
|
37
|
+
"--output",
|
|
38
|
+
"output_format",
|
|
39
|
+
type=click.Choice(["terminal", "json", "markdown"], case_sensitive=False),
|
|
40
|
+
default="terminal",
|
|
41
|
+
help="Output format (default: terminal).",
|
|
42
|
+
)
|
|
43
|
+
@click.option("--save", "save_path", default=None, help="File path to save the report.")
|
|
44
|
+
@click.option("--roast", is_flag=True, help="Enable Portfolio Roast Mode.")
|
|
45
|
+
@click.option("--no-color", is_flag=True, help="Disable colored terminal output.")
|
|
46
|
+
@click.option("--no-cache", is_flag=True, help="Skip cached data and refresh from GitHub.")
|
|
47
|
+
def main(
|
|
48
|
+
username: str,
|
|
49
|
+
token: str | None,
|
|
50
|
+
output_format: str,
|
|
51
|
+
save_path: str | None,
|
|
52
|
+
roast: bool,
|
|
53
|
+
no_color: bool,
|
|
54
|
+
no_cache: bool,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Analyze a GitHub profile and generate a UTOPIA portfolio report."""
|
|
57
|
+
token = token or os.getenv("GITHUB_TOKEN")
|
|
58
|
+
output_format = output_format.lower()
|
|
59
|
+
|
|
60
|
+
console = Console(no_color=no_color)
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
with Progress(
|
|
64
|
+
SpinnerColumn(),
|
|
65
|
+
TextColumn("[progress.description]{task.description}"),
|
|
66
|
+
console=console,
|
|
67
|
+
transient=True,
|
|
68
|
+
) as progress:
|
|
69
|
+
task = progress.add_task("Fetching profile...", total=None)
|
|
70
|
+
|
|
71
|
+
def callback(message: str, _ratio: float) -> None:
|
|
72
|
+
progress.update(task, description=message)
|
|
73
|
+
|
|
74
|
+
profile = fetch_profile(username, token=token, no_cache=no_cache, progress_callback=callback)
|
|
75
|
+
|
|
76
|
+
report = build_report(profile, roast_mode=roast)
|
|
77
|
+
|
|
78
|
+
if output_format == "json":
|
|
79
|
+
rendered = json_output.render(report)
|
|
80
|
+
elif output_format == "markdown":
|
|
81
|
+
rendered = markdown_output.render(report)
|
|
82
|
+
else:
|
|
83
|
+
rendered = terminal.render(report, no_color=no_color)
|
|
84
|
+
|
|
85
|
+
if save_path:
|
|
86
|
+
with open(save_path, "w", encoding="utf-8") as f:
|
|
87
|
+
f.write(rendered)
|
|
88
|
+
console.print(f"Report saved to [bold]{save_path}[/bold]")
|
|
89
|
+
|
|
90
|
+
print(rendered, end="")
|
|
91
|
+
|
|
92
|
+
except UserNotFoundError:
|
|
93
|
+
console.print(f"[red]Error:[/red] GitHub user '{username}' not found.")
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
except RateLimitError as exc:
|
|
96
|
+
console.print(f"[red]Rate limit:[/red] {exc.message}")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
except GitHubError as exc:
|
|
99
|
+
console.print(f"[red]GitHub API error:[/red] {exc.message}")
|
|
100
|
+
sys.exit(1)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
console.print(f"[red]Unexpected error:[/red] {exc}")
|
|
103
|
+
sys.exit(1)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Output formatters for UTOPIA reports."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""JSON output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from utopia.report import Report
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def render(report: Report) -> str:
|
|
13
|
+
"""Render the report as a structured JSON string."""
|
|
14
|
+
data: dict[str, Any] = {
|
|
15
|
+
"username": report.username,
|
|
16
|
+
"score": {
|
|
17
|
+
"total": report.score.total,
|
|
18
|
+
"max": report.score.max_score,
|
|
19
|
+
"grade": report.score.grade,
|
|
20
|
+
"breakdown": [
|
|
21
|
+
{
|
|
22
|
+
"category": b.category,
|
|
23
|
+
"label": b.label,
|
|
24
|
+
"score": b.score,
|
|
25
|
+
"max_score": b.max_score,
|
|
26
|
+
}
|
|
27
|
+
for b in report.score.breakdown
|
|
28
|
+
],
|
|
29
|
+
},
|
|
30
|
+
"archetypes": [
|
|
31
|
+
{"key": a.key, "label": a.label, "percentage": a.percentage}
|
|
32
|
+
for a in report.archetypes
|
|
33
|
+
],
|
|
34
|
+
"recruiter": {
|
|
35
|
+
"verdict": report.recruiter.verdict,
|
|
36
|
+
"reasons": report.recruiter.reasons,
|
|
37
|
+
"concerns": report.recruiter.concerns,
|
|
38
|
+
},
|
|
39
|
+
"strengths": report.strengths,
|
|
40
|
+
"weaknesses": report.weaknesses,
|
|
41
|
+
"tech_stack": {
|
|
42
|
+
"languages": report.profile.total_languages,
|
|
43
|
+
},
|
|
44
|
+
"improvement_plan": report.improvement_plan.weeks,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if report.roast:
|
|
48
|
+
data["roast"] = report.roast.lines
|
|
49
|
+
|
|
50
|
+
return json.dumps(data, indent=2)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""Markdown output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from utopia.report import Report
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def _bar(value: float, width: int = 20) -> str:
|
|
9
|
+
filled = int(round(max(0.0, min(1.0, value)) * width))
|
|
10
|
+
return "|" * filled + "-" * (width - filled)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def render(report: Report) -> str:
|
|
14
|
+
"""Render the report as a Markdown document."""
|
|
15
|
+
lines: list[str] = []
|
|
16
|
+
lines.append(f"# UTOPIA Portfolio Report: {report.username}")
|
|
17
|
+
lines.append("")
|
|
18
|
+
lines.append(f"**UTOPIA Score:** {report.score.total:.0f}/{report.score.max_score:.0f}")
|
|
19
|
+
lines.append(f"**Portfolio Grade:** {report.score.grade}")
|
|
20
|
+
lines.append("")
|
|
21
|
+
|
|
22
|
+
lines.append("## Score Breakdown")
|
|
23
|
+
lines.append("")
|
|
24
|
+
lines.append("| Category | Score | Percentage |")
|
|
25
|
+
lines.append("| --- | --- | --- |")
|
|
26
|
+
for b in report.score.breakdown:
|
|
27
|
+
ratio = b.score / b.max_score
|
|
28
|
+
lines.append(f"| {b.label} | {b.score:.0f}/{b.max_score:.0f} | {_bar(ratio)} {ratio*100:.0f}% |")
|
|
29
|
+
lines.append("")
|
|
30
|
+
|
|
31
|
+
if report.archetypes:
|
|
32
|
+
lines.append("## Developer Archetype")
|
|
33
|
+
lines.append("")
|
|
34
|
+
for arch in report.archetypes:
|
|
35
|
+
lines.append(f"- **{arch.label}:** {arch.percentage}%")
|
|
36
|
+
lines.append("")
|
|
37
|
+
|
|
38
|
+
lines.append("## Recruiter Verdict")
|
|
39
|
+
lines.append("")
|
|
40
|
+
lines.append(f"**Would I hire this developer?** {report.recruiter.verdict}")
|
|
41
|
+
if report.recruiter.reasons:
|
|
42
|
+
lines.append("")
|
|
43
|
+
lines.append("### Reasons")
|
|
44
|
+
for reason in report.recruiter.reasons:
|
|
45
|
+
lines.append(f"- {reason}")
|
|
46
|
+
if report.recruiter.concerns:
|
|
47
|
+
lines.append("")
|
|
48
|
+
lines.append("### Concerns")
|
|
49
|
+
for concern in report.recruiter.concerns:
|
|
50
|
+
lines.append(f"- {concern}")
|
|
51
|
+
lines.append("")
|
|
52
|
+
|
|
53
|
+
lines.append("## Strengths & Weaknesses")
|
|
54
|
+
lines.append("")
|
|
55
|
+
lines.append("### Strengths")
|
|
56
|
+
for s in report.strengths:
|
|
57
|
+
lines.append(f"- {s}")
|
|
58
|
+
lines.append("")
|
|
59
|
+
lines.append("### Weaknesses")
|
|
60
|
+
for w in report.weaknesses:
|
|
61
|
+
lines.append(f"- {w}")
|
|
62
|
+
lines.append("")
|
|
63
|
+
|
|
64
|
+
if report.profile.total_languages:
|
|
65
|
+
lines.append("## Tech Stack Breakdown")
|
|
66
|
+
lines.append("")
|
|
67
|
+
total = sum(report.profile.total_languages.values())
|
|
68
|
+
sorted_langs = sorted(
|
|
69
|
+
report.profile.total_languages.items(), key=lambda x: x[1], reverse=True
|
|
70
|
+
)
|
|
71
|
+
top = sorted_langs[:4]
|
|
72
|
+
other = sum(b for _, b in sorted_langs[4:])
|
|
73
|
+
rows = [(lang, b) for lang, b in top]
|
|
74
|
+
if other:
|
|
75
|
+
rows.append(("Other", other))
|
|
76
|
+
for lang, bytes_count in rows:
|
|
77
|
+
pct = bytes_count / total if total else 0
|
|
78
|
+
lines.append(f"- **{lang}:** {_bar(pct)} {pct*100:.1f}%")
|
|
79
|
+
lines.append("")
|
|
80
|
+
|
|
81
|
+
if report.roast:
|
|
82
|
+
lines.append("## Portfolio Roast")
|
|
83
|
+
lines.append("")
|
|
84
|
+
for line in report.roast.lines:
|
|
85
|
+
lines.append(f"> {line}")
|
|
86
|
+
lines.append("")
|
|
87
|
+
|
|
88
|
+
lines.append("## 30-Day Upgrade Plan")
|
|
89
|
+
lines.append("")
|
|
90
|
+
for i, week in enumerate(report.improvement_plan.weeks, start=1):
|
|
91
|
+
lines.append(f"### Week {i}")
|
|
92
|
+
for action in week:
|
|
93
|
+
lines.append(f"- {action}")
|
|
94
|
+
lines.append("")
|
|
95
|
+
|
|
96
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""Rich-based terminal output formatter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.panel import Panel
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from utopia.report import Report
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _color_for_ratio(ratio: float, no_color: bool) -> str:
|
|
14
|
+
if no_color:
|
|
15
|
+
return "white"
|
|
16
|
+
if ratio >= 0.7:
|
|
17
|
+
return "green"
|
|
18
|
+
if ratio >= 0.4:
|
|
19
|
+
return "yellow"
|
|
20
|
+
return "red"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _bar(value: float, width: int = 30) -> str:
|
|
24
|
+
filled = int(round(_clamp(value, 0.0, 1.0) * width))
|
|
25
|
+
return "█" * filled + "░" * (width - filled)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _clamp(value: float, lower: float, upper: float) -> float:
|
|
29
|
+
return max(lower, min(upper, value))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def render(report: Report, no_color: bool = False) -> str:
|
|
33
|
+
console = Console(
|
|
34
|
+
no_color=no_color,
|
|
35
|
+
force_terminal=True,
|
|
36
|
+
color_system=None if no_color else "auto",
|
|
37
|
+
highlight=False,
|
|
38
|
+
)
|
|
39
|
+
with console.capture() as capture:
|
|
40
|
+
console.print()
|
|
41
|
+
|
|
42
|
+
# Header panel
|
|
43
|
+
header_text = Text()
|
|
44
|
+
header_text.append(f"UTOPIA Portfolio Report: {report.username}\n", style="bold cyan")
|
|
45
|
+
header_text.append(f"UTOPIA SCORE: {report.score.total:.0f}/{report.score.max_score:.0f}\n", style="bold")
|
|
46
|
+
header_text.append(f"Portfolio Grade: {report.score.grade}", style="bold")
|
|
47
|
+
console.print(Panel(header_text, expand=False))
|
|
48
|
+
|
|
49
|
+
# Score breakdown
|
|
50
|
+
table = Table(title="Score Breakdown", show_header=True, header_style="bold magenta")
|
|
51
|
+
table.add_column("Category", justify="left")
|
|
52
|
+
table.add_column("Score", justify="right")
|
|
53
|
+
table.add_column("Bar", justify="left")
|
|
54
|
+
|
|
55
|
+
for b in report.score.breakdown:
|
|
56
|
+
ratio = b.score / b.max_score
|
|
57
|
+
color = _color_for_ratio(ratio, no_color)
|
|
58
|
+
table.add_row(
|
|
59
|
+
b.label,
|
|
60
|
+
f"{b.score:.0f}/{b.max_score:.0f}",
|
|
61
|
+
Text(f"{_bar(ratio)} {ratio*100:.0f}%", style=color),
|
|
62
|
+
)
|
|
63
|
+
console.print(table)
|
|
64
|
+
|
|
65
|
+
# Archetypes
|
|
66
|
+
if report.archetypes:
|
|
67
|
+
console.print()
|
|
68
|
+
console.print("[bold]You are:[/bold]")
|
|
69
|
+
for arch in report.archetypes:
|
|
70
|
+
bar = _bar(arch.percentage / 100, width=20)
|
|
71
|
+
console.print(f" {arch.label:<22} {bar} {arch.percentage:>5}%")
|
|
72
|
+
|
|
73
|
+
# Recruiter verdict
|
|
74
|
+
console.print()
|
|
75
|
+
verdict_color = {"YES": "green", "MAYBE": "yellow", "NOT YET": "red"}.get(
|
|
76
|
+
report.recruiter.verdict, "white"
|
|
77
|
+
)
|
|
78
|
+
console.print("[bold]Would I hire this developer?[/bold]")
|
|
79
|
+
console.print(f"[{verdict_color} bold]{report.recruiter.verdict}[/{verdict_color} bold]")
|
|
80
|
+
if report.recruiter.reasons:
|
|
81
|
+
console.print("[bold]Reasons:[/bold]")
|
|
82
|
+
for reason in report.recruiter.reasons:
|
|
83
|
+
console.print(f" ✓ {reason}")
|
|
84
|
+
if report.recruiter.concerns:
|
|
85
|
+
console.print("[bold]Concerns:[/bold]")
|
|
86
|
+
for concern in report.recruiter.concerns:
|
|
87
|
+
console.print(f" ✗ {concern}")
|
|
88
|
+
|
|
89
|
+
# Strengths / weaknesses
|
|
90
|
+
console.print()
|
|
91
|
+
console.print("[bold green]Strengths:[/bold green]")
|
|
92
|
+
for s in report.strengths:
|
|
93
|
+
console.print(f" ✓ {s}")
|
|
94
|
+
console.print("[bold red]Weaknesses:[/bold red]")
|
|
95
|
+
for w in report.weaknesses:
|
|
96
|
+
console.print(f" ✗ {w}")
|
|
97
|
+
|
|
98
|
+
# Tech stack
|
|
99
|
+
if report.profile.total_languages:
|
|
100
|
+
console.print()
|
|
101
|
+
console.print("[bold]Tech Stack Breakdown[/bold]")
|
|
102
|
+
total = sum(report.profile.total_languages.values())
|
|
103
|
+
sorted_langs = sorted(
|
|
104
|
+
report.profile.total_languages.items(), key=lambda x: x[1], reverse=True
|
|
105
|
+
)
|
|
106
|
+
top = sorted_langs[:4]
|
|
107
|
+
other = sum(b for _, b in sorted_langs[4:])
|
|
108
|
+
rows = [(lang, b) for lang, b in top]
|
|
109
|
+
if other:
|
|
110
|
+
rows.append(("Other", other))
|
|
111
|
+
for lang, bytes_count in rows:
|
|
112
|
+
pct = bytes_count / total if total else 0
|
|
113
|
+
bar = _bar(pct, width=20)
|
|
114
|
+
console.print(f" {lang:<14} {bar} {pct*100:>5.1f}%")
|
|
115
|
+
|
|
116
|
+
# Roast
|
|
117
|
+
if report.roast:
|
|
118
|
+
console.print()
|
|
119
|
+
console.print(Panel("\n".join(report.roast.lines), title="[bold red]Roast Mode[/bold red]", border_style="red"))
|
|
120
|
+
|
|
121
|
+
# Improvement plan
|
|
122
|
+
console.print()
|
|
123
|
+
console.print("[bold]30-Day Upgrade Plan[/bold]")
|
|
124
|
+
for i, week in enumerate(report.improvement_plan.weeks, start=1):
|
|
125
|
+
console.print(f"[bold]Week {i}:[/bold]")
|
|
126
|
+
for action in week:
|
|
127
|
+
console.print(f" • {action}")
|
|
128
|
+
|
|
129
|
+
console.print()
|
|
130
|
+
return capture.get()
|