Mohammed AL Sarraj commited on
Commit
950dcd2
·
0 Parent(s):

initial deploy

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +4 -0
  2. .env.example +5 -0
  3. Dockerfile +7 -0
  4. app/__init__.py +42 -0
  5. app/__pycache__/__init__.cpython-314.pyc +0 -0
  6. app/core/__init__.py +0 -0
  7. app/core/__pycache__/__init__.cpython-314.pyc +0 -0
  8. app/core/__pycache__/ai.cpython-314.pyc +0 -0
  9. app/core/ai.py +219 -0
  10. app/core/file_reader.py +99 -0
  11. app/home/__init__.py +0 -0
  12. app/home/__pycache__/__init__.cpython-314.pyc +0 -0
  13. app/home/__pycache__/routes.cpython-314.pyc +0 -0
  14. app/home/routes.py +8 -0
  15. app/home/templates/home/index.html +152 -0
  16. app/templates/base.html +121 -0
  17. app/tools/__init__.py +0 -0
  18. app/tools/__pycache__/__init__.cpython-314.pyc +0 -0
  19. app/tools/changelog_ai/__init__.py +0 -0
  20. app/tools/changelog_ai/__pycache__/__init__.cpython-314.pyc +0 -0
  21. app/tools/changelog_ai/__pycache__/changelog.cpython-314.pyc +0 -0
  22. app/tools/changelog_ai/__pycache__/routes.cpython-314.pyc +0 -0
  23. app/tools/changelog_ai/changelog.py +94 -0
  24. app/tools/changelog_ai/routes.py +32 -0
  25. app/tools/changelog_ai/templates/changelog_ai/index.html +726 -0
  26. app/tools/doc_forge/__init__.py +0 -0
  27. app/tools/doc_forge/__pycache__/__init__.cpython-314.pyc +0 -0
  28. app/tools/doc_forge/__pycache__/db.cpython-314.pyc +0 -0
  29. app/tools/doc_forge/__pycache__/doc_generator.cpython-314.pyc +0 -0
  30. app/tools/doc_forge/__pycache__/github_fetcher.cpython-314.pyc +0 -0
  31. app/tools/doc_forge/__pycache__/routes.cpython-314.pyc +0 -0
  32. app/tools/doc_forge/db.py +88 -0
  33. app/tools/doc_forge/doc_generator.py +127 -0
  34. app/tools/doc_forge/github_fetcher.py +116 -0
  35. app/tools/doc_forge/routes.py +121 -0
  36. app/tools/doc_forge/templates/doc_forge/docs.html +417 -0
  37. app/tools/doc_forge/templates/doc_forge/index.html +430 -0
  38. app/tools/git_narrator/__init__.py +0 -0
  39. app/tools/git_narrator/__pycache__/__init__.cpython-314.pyc +0 -0
  40. app/tools/git_narrator/__pycache__/narrator.cpython-314.pyc +0 -0
  41. app/tools/git_narrator/__pycache__/routes.cpython-314.pyc +0 -0
  42. app/tools/git_narrator/narrator.py +112 -0
  43. app/tools/git_narrator/routes.py +22 -0
  44. app/tools/git_narrator/templates/git_narrator/index.html +509 -0
  45. app/tools/schema_detective/__init__.py +0 -0
  46. app/tools/schema_detective/__pycache__/__init__.cpython-314.pyc +0 -0
  47. app/tools/schema_detective/__pycache__/detective.cpython-314.pyc +0 -0
  48. app/tools/schema_detective/__pycache__/routes.cpython-314.pyc +0 -0
  49. app/tools/schema_detective/detective.py +252 -0
  50. app/tools/schema_detective/routes.py +40 -0
.dockerignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ __pycache__
2
+ *.pyc
3
+ .env
4
+ *.db
.env.example ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ GROQ_API_KEY=
2
+ CEREBRAS_API_KEY=
3
+ OPENROUTER_API_KEY=
4
+ MISTRAL_API_KEY=
5
+ SECRET_KEY=change-me
Dockerfile ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+ WORKDIR /app
3
+ COPY requirements.txt .
4
+ RUN pip install --no-cache-dir -r requirements.txt
5
+ COPY . .
6
+ EXPOSE 7860
7
+ CMD ["gunicorn", "wsgi:app", "--bind", "0.0.0.0:7860", "--workers", "2", "--timeout", "120", "--access-logfile", "-"]
app/__init__.py ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DevKit — Flask app factory."""
2
+ import os
3
+ from flask import Flask
4
+ from flask_wtf.csrf import CSRFProtect
5
+
6
+ _csrf = CSRFProtect()
7
+
8
+
9
+ def create_app():
10
+ app = Flask(__name__, template_folder="templates")
11
+ app.config["SECRET_KEY"] = os.environ.get("SECRET_KEY", "dev-devkit-2026")
12
+ app.config["MAX_CONTENT_LENGTH"] = 30 * 1024 * 1024
13
+ _csrf.init_app(app)
14
+
15
+ from app.home.routes import bp as home_bp
16
+ app.register_blueprint(home_bp)
17
+
18
+ from app.tools.schema_detective.routes import bp as schema_detective_bp
19
+ app.register_blueprint(schema_detective_bp, url_prefix='/schema-detective')
20
+
21
+ from app.tools.test_forge.routes import bp as test_forge_bp
22
+ app.register_blueprint(test_forge_bp, url_prefix='/test-forge')
23
+
24
+ from app.tools.sql_whisperer.routes import bp as sql_whisperer_bp
25
+ app.register_blueprint(sql_whisperer_bp, url_prefix='/sql-whisperer')
26
+
27
+ from app.tools.doc_forge.routes import bp as doc_forge_bp
28
+ app.register_blueprint(doc_forge_bp, url_prefix='/doc-forge')
29
+
30
+ from app.tools.changelog_ai.routes import bp as changelog_ai_bp
31
+ app.register_blueprint(changelog_ai_bp, url_prefix='/changelog-ai')
32
+
33
+ from app.tools.git_narrator.routes import bp as git_narrator_bp
34
+ app.register_blueprint(git_narrator_bp, url_prefix='/git-narrator')
35
+
36
+ from flask import jsonify
37
+ @app.errorhandler(Exception)
38
+ def _handle_exc(e):
39
+ code = getattr(e, "code", 500)
40
+ return jsonify({"error": str(e)}), code
41
+
42
+ return app
app/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (2.23 kB). View file
 
app/core/__init__.py ADDED
File without changes
app/core/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (162 Bytes). View file
 
app/core/__pycache__/ai.cpython-314.pyc ADDED
Binary file (11.2 kB). View file
 
app/core/ai.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multi-provider AI engine. Runtime chain: Groq -> Cerebras -> OpenRouter -> Mistral -> Ollama."""
2
+ import json, logging, os, re, requests
3
+
4
+ logger = logging.getLogger(__name__)
5
+ _OLLAMA_BASE = "http://localhost:11434"
6
+
7
+ _PROVIDER_URLS = {
8
+ "groq": "https://api.groq.com/openai/v1/chat/completions",
9
+ "cerebras": "https://api.cerebras.ai/v1/chat/completions",
10
+ "openrouter": "https://openrouter.ai/api/v1/chat/completions",
11
+ "mistral": "https://api.mistral.ai/v1/chat/completions",
12
+ "openai": "https://api.openai.com/v1/chat/completions",
13
+ }
14
+ _FREE_MODELS = {
15
+ "groq": "llama-3.1-8b-instant",
16
+ "cerebras": "llama3.1-8b",
17
+ "openrouter": "google/gemma-3-12b-it:free",
18
+ "mistral": "mistral-small-latest",
19
+ }
20
+ _PREMIUM_MODELS = {
21
+ "groq": "llama-3.3-70b-versatile",
22
+ "cerebras": "qwen-3-235b-a22b-instruct-2507",
23
+ "openrouter": "google/gemma-3-27b-it:free",
24
+ "mistral": "mistral-medium-latest",
25
+ "openai": "gpt-4o-mini",
26
+ }
27
+ _CHAIN_CFG = [
28
+ {"name": "groq", "key_env": "GROQ_API_KEY", "timeout": 30, "extra": {}},
29
+ {"name": "cerebras", "key_env": "CEREBRAS_API_KEY", "timeout": 30, "extra": {}},
30
+ {"name": "openrouter", "key_env": "OPENROUTER_API_KEY", "timeout": 45,
31
+ "extra": {"HTTP-Referer": "https://github.com/Moealsarraj", "X-Title": "AI Tools"}},
32
+ {"name": "mistral", "key_env": "MISTRAL_API_KEY", "timeout": 40, "extra": {}},
33
+ ]
34
+
35
+ # Build the runtime provider list — all providers with valid keys
36
+ _PROVIDERS = []
37
+ for _p in _CHAIN_CFG:
38
+ _k = os.environ.get(_p["key_env"], "")
39
+ if _k:
40
+ _PROVIDERS.append({
41
+ "name": _p["name"],
42
+ "url": _PROVIDER_URLS[_p["name"]],
43
+ "model": _FREE_MODELS[_p["name"]],
44
+ "key": _k,
45
+ "timeout": _p["timeout"],
46
+ "extra": _p["extra"],
47
+ })
48
+
49
+ # Ollama fallback
50
+ _OLLAMA_PROVIDER = None
51
+ try:
52
+ _r = requests.get(f"{_OLLAMA_BASE}/api/tags", timeout=3)
53
+ if _r.status_code == 200:
54
+ _installed = [m["name"] for m in _r.json().get("models", [])]
55
+ if _installed:
56
+ _OLLAMA_PROVIDER = {"name": "ollama", "model": _installed[0]}
57
+ except Exception:
58
+ pass
59
+
60
+ _AI_AVAILABLE = bool(_PROVIDERS or _OLLAMA_PROVIDER)
61
+
62
+ _RE_THINK = re.compile(r"<think>.*?</think>", re.DOTALL)
63
+ _RE_OPEN = re.compile(r"^```[a-z]*\n?", re.MULTILINE)
64
+ _RE_CLOSE = re.compile(r"\n?```$", re.MULTILINE)
65
+
66
+ def _clean(raw: str) -> str:
67
+ raw = _RE_THINK.sub("", raw).strip()
68
+ raw = _RE_OPEN.sub("", raw)
69
+ return _RE_CLOSE.sub("", raw).strip()
70
+
71
+ def _post_openai(url, key, model, messages, max_tokens, extra_headers, timeout=60):
72
+ headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
73
+ headers.update(extra_headers)
74
+ r = requests.post(url, headers=headers,
75
+ json={"model": model, "messages": messages, "max_tokens": max_tokens},
76
+ timeout=timeout)
77
+ r.raise_for_status()
78
+ return _clean(r.json()["choices"][0]["message"]["content"])
79
+
80
+ def call_ai(messages: list, system: str = "", max_tokens: int = 2048,
81
+ api_key_row: dict | None = None) -> str:
82
+ if system:
83
+ messages = [{"role": "system", "content": system}] + messages
84
+ # Custom API key path (used by e.g. Wasit/Amin integrations)
85
+ if api_key_row:
86
+ provider = api_key_row.get("provider", "openai")
87
+ key = api_key_row["key"]
88
+ url = api_key_row.get("url") or _PROVIDER_URLS.get(provider, "")
89
+ model = api_key_row.get("model") or _PREMIUM_MODELS.get(provider, "gpt-4o-mini")
90
+ if not url:
91
+ raise ValueError(f"No endpoint known for provider {provider!r}")
92
+ if provider == "claude":
93
+ r = requests.post("https://api.anthropic.com/v1/messages",
94
+ headers={"x-api-key": key, "anthropic-version": "2023-06-01",
95
+ "content-type": "application/json"},
96
+ json={"model": "claude-sonnet-4-6", "max_tokens": max_tokens, "messages": messages},
97
+ timeout=60)
98
+ r.raise_for_status()
99
+ return _clean(r.json()["content"][0]["text"])
100
+ return _post_openai(url, key, model, messages, max_tokens, {})
101
+ if not _AI_AVAILABLE:
102
+ raise RuntimeError("No AI provider. Set GROQ_API_KEY or similar in .env")
103
+ # Ollama-only path
104
+ if not _PROVIDERS and _OLLAMA_PROVIDER:
105
+ r = requests.post(f"{_OLLAMA_BASE}/api/chat",
106
+ json={"model": _OLLAMA_PROVIDER["model"], "messages": messages, "stream": False},
107
+ timeout=120)
108
+ r.raise_for_status()
109
+ return _clean(r.json()["message"]["content"])
110
+ # Runtime chain: try each provider, fall back on 429 or transient errors
111
+ last_exc = None
112
+ for prov in _PROVIDERS:
113
+ try:
114
+ return _post_openai(
115
+ prov["url"], prov["key"], prov["model"],
116
+ messages, max_tokens, prov["extra"], prov["timeout"]
117
+ )
118
+ except requests.exceptions.HTTPError as e:
119
+ status = e.response.status_code if e.response is not None else 0
120
+ if status in (429, 503, 502):
121
+ logger.debug("Provider %s returned %s, trying next", prov["name"], status)
122
+ last_exc = e
123
+ continue
124
+ raise
125
+ except (requests.exceptions.ConnectionError,
126
+ requests.exceptions.Timeout) as e:
127
+ last_exc = e
128
+ continue
129
+ # Try Ollama as last resort
130
+ if _OLLAMA_PROVIDER:
131
+ r = requests.post(f"{_OLLAMA_BASE}/api/chat",
132
+ json={"model": _OLLAMA_PROVIDER["model"], "messages": messages, "stream": False},
133
+ timeout=120)
134
+ r.raise_for_status()
135
+ return _clean(r.json()["message"]["content"])
136
+ raise last_exc or RuntimeError("All AI providers failed or rate-limited")
137
+
138
+ def _repair_json(text: str) -> str:
139
+ """Escape literal control characters inside JSON string values."""
140
+ result = []
141
+ in_str = False
142
+ esc = False
143
+ for c in text:
144
+ if esc:
145
+ result.append(c)
146
+ esc = False
147
+ continue
148
+ if c == '\\' and in_str:
149
+ result.append(c)
150
+ esc = True
151
+ continue
152
+ if c == '"':
153
+ in_str = not in_str
154
+ result.append(c)
155
+ continue
156
+ if in_str and c == '\n':
157
+ result.append('\\n')
158
+ continue
159
+ if in_str and c == '\r':
160
+ result.append('\\r')
161
+ continue
162
+ if in_str and c == '\t':
163
+ result.append('\\t')
164
+ continue
165
+ result.append(c)
166
+ return ''.join(result)
167
+
168
+ def _extract_json(raw: str):
169
+ """Try progressively harder to extract valid JSON from raw text."""
170
+ raw = raw.strip()
171
+ # Direct parse
172
+ try:
173
+ return json.loads(raw)
174
+ except json.JSONDecodeError:
175
+ pass
176
+ # Repair literal newlines inside strings then retry
177
+ repaired = _repair_json(raw)
178
+ try:
179
+ return json.loads(repaired)
180
+ except json.JSONDecodeError:
181
+ pass
182
+ # Find first { or [ then walk to find matching closer
183
+ for source in (repaired, raw):
184
+ for start_ch, end_ch in [('{', '}'), ('[', ']')]:
185
+ idx = source.find(start_ch)
186
+ if idx == -1:
187
+ continue
188
+ depth = 0
189
+ in_str = False
190
+ esc = False
191
+ for i in range(idx, len(source)):
192
+ c = source[i]
193
+ if esc:
194
+ esc = False
195
+ continue
196
+ if c == '\\' and in_str:
197
+ esc = True
198
+ continue
199
+ if c == '"':
200
+ in_str = not in_str
201
+ continue
202
+ if in_str:
203
+ continue
204
+ if c == start_ch:
205
+ depth += 1
206
+ elif c == end_ch:
207
+ depth -= 1
208
+ if depth == 0:
209
+ candidate = source[idx:i+1]
210
+ try:
211
+ return json.loads(candidate)
212
+ except json.JSONDecodeError:
213
+ break
214
+ raise ValueError(f"AI returned non-JSON: {raw[:200]}")
215
+
216
+ def call_ai_json(messages: list, system: str = "", max_tokens: int = 2048,
217
+ api_key_row: dict | None = None) -> dict | list:
218
+ raw = call_ai(messages, system=system, max_tokens=max_tokens, api_key_row=api_key_row)
219
+ return _extract_json(raw)
app/core/file_reader.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File text extractor — supports .docx, .pdf, .txt.
2
+
3
+ Reusable: copy this file to any Flask project's app/core/ directory.
4
+ Dependencies: pypdf>=4.0 (for PDF support — add to requirements.txt)
5
+ DOCX and TXT use Python built-ins only (no extra packages needed).
6
+ """
7
+ import io
8
+ import zipfile
9
+ import xml.etree.ElementTree as ET
10
+ from pathlib import Path
11
+
12
+ ALLOWED_EXTENSIONS = {".pdf", ".docx", ".txt"}
13
+ MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
14
+
15
+ _WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"
16
+
17
+
18
+ def extract_text(file_storage) -> str:
19
+ """Extract plain text from a Werkzeug FileStorage object.
20
+
21
+ Supports .pdf, .docx, .txt files up to 10 MB.
22
+ Returns extracted text as a string.
23
+ Raises ValueError for unsupported types, oversized files, or parse errors.
24
+ """
25
+ filename = file_storage.filename or ""
26
+ ext = Path(filename).suffix.lower()
27
+
28
+ if ext not in ALLOWED_EXTENSIONS:
29
+ raise ValueError(
30
+ f"Unsupported file type '{ext or '(none)'}'. Allowed: PDF, DOCX, TXT"
31
+ )
32
+
33
+ data = file_storage.read()
34
+ if len(data) > MAX_FILE_SIZE:
35
+ raise ValueError("File too large (max 10 MB)")
36
+ if not data:
37
+ raise ValueError("File is empty")
38
+
39
+ if ext == ".txt":
40
+ return data.decode("utf-8", errors="replace").strip()
41
+ if ext == ".docx":
42
+ return _read_docx(io.BytesIO(data))
43
+ if ext == ".pdf":
44
+ return _read_pdf(io.BytesIO(data))
45
+
46
+ raise ValueError(f"Unhandled extension: {ext}")
47
+
48
+
49
+ def _read_docx(stream: io.BytesIO) -> str:
50
+ """Extract text from a .docx file using built-in zipfile + xml.etree (no deps)."""
51
+ try:
52
+ with zipfile.ZipFile(stream) as z:
53
+ with z.open("word/document.xml") as f:
54
+ tree = ET.parse(f)
55
+ except (zipfile.BadZipFile, KeyError) as exc:
56
+ raise ValueError(f"Could not read Word document: {exc}")
57
+
58
+ root = tree.getroot()
59
+ paragraphs = []
60
+ for para in root.iter(f"{{{_WORD_NS}}}p"):
61
+ # Collect all text runs, preserving spaces
62
+ parts = []
63
+ for node in para.iter():
64
+ if node.tag == f"{{{_WORD_NS}}}t" and node.text:
65
+ parts.append(node.text)
66
+ elif node.tag == f"{{{_WORD_NS}}}br":
67
+ parts.append("\n")
68
+ text = "".join(parts).strip()
69
+ if text:
70
+ paragraphs.append(text)
71
+
72
+ text = "\n\n".join(paragraphs)
73
+ if not text.strip():
74
+ raise ValueError("No readable text found in the Word document")
75
+ return text
76
+
77
+
78
+ def _read_pdf(stream: io.BytesIO) -> str:
79
+ """Extract text from a PDF using pypdf."""
80
+ try:
81
+ from pypdf import PdfReader
82
+ except ImportError:
83
+ raise ValueError("pypdf not installed — run: pip install pypdf")
84
+
85
+ try:
86
+ reader = PdfReader(stream)
87
+ except Exception as exc:
88
+ raise ValueError(f"Could not read PDF: {exc}")
89
+
90
+ pages = []
91
+ for page in reader.pages:
92
+ text = page.extract_text() or ""
93
+ if text.strip():
94
+ pages.append(text.strip())
95
+
96
+ text = "\n\n".join(pages)
97
+ if not text.strip():
98
+ raise ValueError("No readable text found in the PDF (may be image-based)")
99
+ return text
app/home/__init__.py ADDED
File without changes
app/home/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (162 Bytes). View file
 
app/home/__pycache__/routes.cpython-314.pyc ADDED
Binary file (565 Bytes). View file
 
app/home/routes.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """DevKit landing page."""
2
+ from flask import Blueprint, render_template
3
+
4
+ bp = Blueprint("home", __name__, template_folder="templates")
5
+
6
+ @bp.route("/")
7
+ def index():
8
+ return render_template("home/index.html")
app/home/templates/home/index.html ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}DevKit — Developer Toolkit{% endblock %}
3
+
4
+ {% block content %}
5
+ <div class="flex flex-col h-screen overflow-hidden">
6
+
7
+ <!-- Nav: same h-14 pattern as all tools -->
8
+ <nav class="flex items-center justify-between w-full px-6 py-2 bg-slate-50 h-14 z-50 border-b border-slate-200 shrink-0">
9
+ <div class="flex items-center gap-3">
10
+ <span class="w-8 h-8 rounded-lg bg-primary flex items-center justify-center">
11
+ <span class="material-symbols-outlined text-on-primary text-lg" style="font-variation-settings:'FILL' 1;">terminal</span>
12
+ </span>
13
+ <span class="text-lg font-semibold text-on-surface">DevKit</span>
14
+ </div>
15
+ <span class="text-xs font-semibold text-on-surface-variant bg-surface-container px-3 py-1.5 rounded-full">6 tools</span>
16
+ </nav>
17
+
18
+ <div class="flex flex-1 overflow-hidden">
19
+
20
+ <!-- Sidebar: same w-64 bg-slate-100 pattern as test-forge / agent-builder -->
21
+ <aside class="flex flex-col h-full w-64 p-4 gap-4 bg-slate-100 border-r border-slate-200 shrink-0 overflow-y-auto">
22
+ <div>
23
+ <p class="text-xs font-bold text-on-surface-variant uppercase tracking-widest mb-3">Categories</p>
24
+ <div class="space-y-1">
25
+ <div class="flex items-center gap-3 px-3 py-2 rounded-lg bg-white shadow-sm text-primary cursor-default">
26
+ <span class="material-symbols-outlined text-base">storage</span>
27
+ <span class="text-sm font-medium">Database</span>
28
+ </div>
29
+ <div class="flex items-center gap-3 px-3 py-2 rounded-lg text-on-surface-variant hover:bg-slate-200/60 transition-colors cursor-default">
30
+ <span class="material-symbols-outlined text-base">science</span>
31
+ <span class="text-sm font-medium">Code Quality</span>
32
+ </div>
33
+ <div class="flex items-center gap-3 px-3 py-2 rounded-lg text-on-surface-variant hover:bg-slate-200/60 transition-colors cursor-default">
34
+ <span class="material-symbols-outlined text-base">article</span>
35
+ <span class="text-sm font-medium">Documentation</span>
36
+ </div>
37
+ <div class="flex items-center gap-3 px-3 py-2 rounded-lg text-on-surface-variant hover:bg-slate-200/60 transition-colors cursor-default">
38
+ <span class="material-symbols-outlined text-base">history</span>
39
+ <span class="text-sm font-medium">Version History</span>
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </aside>
44
+
45
+ <!-- Main workspace -->
46
+ <main class="flex-1 flex flex-col overflow-hidden bg-surface">
47
+
48
+ <!-- Action bar: matches the h-14 workspace bars inside tools -->
49
+ <div class="h-14 flex items-center justify-between px-6 bg-surface-container-low border-b border-slate-200 shrink-0">
50
+ <span class="text-xs font-bold text-on-surface-variant uppercase tracking-widest">Workspace</span>
51
+ <span class="text-xs text-outline">No signup required · Free</span>
52
+ </div>
53
+
54
+ <div class="flex-1 overflow-y-auto p-8">
55
+
56
+ <!-- Hero -->
57
+ <div class="mb-8">
58
+ <span class="text-[11px] font-semibold text-primary uppercase tracking-widest block mb-1">DevKit</span>
59
+ <h1 class="text-2xl font-bold text-on-surface tracking-tight">Developer Toolkit</h1>
60
+ <p class="text-sm text-on-surface-variant mt-2 max-w-xl leading-relaxed">Six tools for the day-to-day developer workflow — database auditing, SQL generation, test suites, documentation, and version history.</p>
61
+ </div>
62
+
63
+ <!-- Tool cards -->
64
+ <div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
65
+ <a href="/schema-detective/" class="group block bg-surface-container-lowest rounded-xl overflow-hidden border border-outline-variant/10 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all">
66
+ <div class="h-11 flex items-center gap-3 px-4 bg-surface-container-low border-b border-slate-200/70 shrink-0">
67
+ <span class="w-6 h-6 rounded-md bg-primary flex items-center justify-center shrink-0">
68
+ <span class="material-symbols-outlined text-on-primary" style="font-size:14px;line-height:1;font-variation-settings:'FILL' 1;">table_view</span>
69
+ </span>
70
+ <span class="text-sm font-semibold text-on-surface">Schema Detective</span>
71
+ <span class="ml-auto flex items-center gap-0.5 text-xs font-medium text-outline group-hover:text-primary transition-colors">Open<span class="material-symbols-outlined text-sm leading-none ml-0.5">arrow_forward</span></span>
72
+ </div>
73
+ <div class="p-4">
74
+ <p class="text-sm text-on-surface-variant leading-relaxed">Audit database schemas for vulnerabilities, missing indexes, and design flaws.</p>
75
+ </div>
76
+ </a>
77
+ <a href="/test-forge/" class="group block bg-surface-container-lowest rounded-xl overflow-hidden border border-outline-variant/10 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all">
78
+ <div class="h-11 flex items-center gap-3 px-4 bg-surface-container-low border-b border-slate-200/70 shrink-0">
79
+ <span class="w-6 h-6 rounded-md bg-primary flex items-center justify-center shrink-0">
80
+ <span class="material-symbols-outlined text-on-primary" style="font-size:14px;line-height:1;font-variation-settings:'FILL' 1;">science</span>
81
+ </span>
82
+ <span class="text-sm font-semibold text-on-surface">Test Forge</span>
83
+ <span class="ml-auto flex items-center gap-0.5 text-xs font-medium text-outline group-hover:text-primary transition-colors">Open<span class="material-symbols-outlined text-sm leading-none ml-0.5">arrow_forward</span></span>
84
+ </div>
85
+ <div class="p-4">
86
+ <p class="text-sm text-on-surface-variant leading-relaxed">Generate complete, runnable test suites across 8 frameworks from any source code.</p>
87
+ </div>
88
+ </a>
89
+ <a href="/sql-whisperer/" class="group block bg-surface-container-lowest rounded-xl overflow-hidden border border-outline-variant/10 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all">
90
+ <div class="h-11 flex items-center gap-3 px-4 bg-surface-container-low border-b border-slate-200/70 shrink-0">
91
+ <span class="w-6 h-6 rounded-md bg-primary flex items-center justify-center shrink-0">
92
+ <span class="material-symbols-outlined text-on-primary" style="font-size:14px;line-height:1;font-variation-settings:'FILL' 1;">data_object</span>
93
+ </span>
94
+ <span class="text-sm font-semibold text-on-surface">SQL Whisperer</span>
95
+ <span class="ml-auto flex items-center gap-0.5 text-xs font-medium text-outline group-hover:text-primary transition-colors">Open<span class="material-symbols-outlined text-sm leading-none ml-0.5">arrow_forward</span></span>
96
+ </div>
97
+ <div class="p-4">
98
+ <p class="text-sm text-on-surface-variant leading-relaxed">Convert plain-English questions to SQL for PostgreSQL, MySQL, SQLite, and more.</p>
99
+ </div>
100
+ </a>
101
+ <a href="/doc-forge/" class="group block bg-surface-container-lowest rounded-xl overflow-hidden border border-outline-variant/10 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all">
102
+ <div class="h-11 flex items-center gap-3 px-4 bg-surface-container-low border-b border-slate-200/70 shrink-0">
103
+ <span class="w-6 h-6 rounded-md bg-primary flex items-center justify-center shrink-0">
104
+ <span class="material-symbols-outlined text-on-primary" style="font-size:14px;line-height:1;font-variation-settings:'FILL' 1;">article</span>
105
+ </span>
106
+ <span class="text-sm font-semibold text-on-surface">Doc Forge</span>
107
+ <span class="ml-auto flex items-center gap-0.5 text-xs font-medium text-outline group-hover:text-primary transition-colors">Open<span class="material-symbols-outlined text-sm leading-none ml-0.5">arrow_forward</span></span>
108
+ </div>
109
+ <div class="p-4">
110
+ <p class="text-sm text-on-surface-variant leading-relaxed">Generate README, architecture overviews, and API docs from any GitHub repo.</p>
111
+ </div>
112
+ </a>
113
+ <a href="/changelog-ai/" class="group block bg-surface-container-lowest rounded-xl overflow-hidden border border-outline-variant/10 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all">
114
+ <div class="h-11 flex items-center gap-3 px-4 bg-surface-container-low border-b border-slate-200/70 shrink-0">
115
+ <span class="w-6 h-6 rounded-md bg-primary flex items-center justify-center shrink-0">
116
+ <span class="material-symbols-outlined text-on-primary" style="font-size:14px;line-height:1;font-variation-settings:'FILL' 1;">update</span>
117
+ </span>
118
+ <span class="text-sm font-semibold text-on-surface">Changelog AI</span>
119
+ <span class="ml-auto flex items-center gap-0.5 text-xs font-medium text-outline group-hover:text-primary transition-colors">Open<span class="material-symbols-outlined text-sm leading-none ml-0.5">arrow_forward</span></span>
120
+ </div>
121
+ <div class="p-4">
122
+ <p class="text-sm text-on-surface-variant leading-relaxed">Turn commit history into changelogs for developers, users, or executives.</p>
123
+ </div>
124
+ </a>
125
+ <a href="/git-narrator/" class="group block bg-surface-container-lowest rounded-xl overflow-hidden border border-outline-variant/10 shadow-sm hover:shadow-md hover:-translate-y-0.5 transition-all">
126
+ <div class="h-11 flex items-center gap-3 px-4 bg-surface-container-low border-b border-slate-200/70 shrink-0">
127
+ <span class="w-6 h-6 rounded-md bg-primary flex items-center justify-center shrink-0">
128
+ <span class="material-symbols-outlined text-on-primary" style="font-size:14px;line-height:1;font-variation-settings:'FILL' 1;">history</span>
129
+ </span>
130
+ <span class="text-sm font-semibold text-on-surface">Git Narrator</span>
131
+ <span class="ml-auto flex items-center gap-0.5 text-xs font-medium text-outline group-hover:text-primary transition-colors">Open<span class="material-symbols-outlined text-sm leading-none ml-0.5">arrow_forward</span></span>
132
+ </div>
133
+ <div class="p-4">
134
+ <p class="text-sm text-on-surface-variant leading-relaxed">Transform raw git logs into readable project narratives and milestone timelines.</p>
135
+ </div>
136
+ </a>
137
+ </div>
138
+
139
+ </div>
140
+ </main>
141
+ </div>
142
+
143
+ <!-- Footer: same h-8 status bar as tools -->
144
+ <footer class="h-8 bg-surface-container-highest flex items-center justify-between px-6 text-[11px] font-medium text-on-surface-variant border-t border-slate-200 shrink-0">
145
+ <div class="flex items-center gap-4">
146
+ <div class="flex items-center gap-1.5"><div class="w-1.5 h-1.5 rounded-full bg-green-500"></div><span>DevKit</span></div>
147
+ </div>
148
+ <span>6 Tools · Free</span>
149
+ </footer>
150
+
151
+ </div>
152
+ {% endblock %}
app/templates/base.html ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html class="light" lang="en">
3
+ <head>
4
+ <meta charset="utf-8"/>
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
6
+ <title>{% block title %}Competitive Intel{% endblock %}</title>
7
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet"/>
9
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet"/>
10
+ <script id="tailwind-config">
11
+ tailwind.config = {
12
+ darkMode: "class",
13
+ theme: {
14
+ extend: {
15
+ colors: {
16
+ "error-container": "#fe8983",
17
+ "on-primary-fixed-variant": "#005bb0",
18
+ "on-secondary-fixed-variant": "#505d68",
19
+ "surface-bright": "#f8f9fa",
20
+ "surface": "#f8f9fa",
21
+ "surface-tint": "#005db5",
22
+ "primary": "#005db5",
23
+ "on-surface-variant": "#586064",
24
+ "primary-fixed-dim": "#bfd5ff",
25
+ "inverse-surface": "#0c0f10",
26
+ "on-tertiary": "#fbf7ff",
27
+ "background": "#f8f9fa",
28
+ "secondary": "#54616b",
29
+ "on-secondary-fixed": "#34414b",
30
+ "on-background": "#2b3437",
31
+ "surface-container": "#eaeff1",
32
+ "tertiary-dim": "#51516c",
33
+ "on-primary": "#f6f7ff",
34
+ "secondary-container": "#d7e4f1",
35
+ "tertiary": "#5d5c78",
36
+ "primary-dim": "#0052a0",
37
+ "on-error": "#fff7f6",
38
+ "surface-container-lowest": "#ffffff",
39
+ "outline-variant": "#abb3b7",
40
+ "on-secondary": "#f5f9ff",
41
+ "inverse-primary": "#5f9efb",
42
+ "tertiary-fixed-dim": "#cbc9e9",
43
+ "outline": "#737c7f",
44
+ "on-secondary-container": "#46535e",
45
+ "primary-container": "#d6e3ff",
46
+ "error-dim": "#4e0309",
47
+ "error": "#9f403d",
48
+ "secondary-fixed": "#d7e4f1",
49
+ "primary-fixed": "#d6e3ff",
50
+ "inverse-on-surface": "#9b9d9e",
51
+ "on-tertiary-container": "#4a4a65",
52
+ "tertiary-fixed": "#d9d7f8",
53
+ "on-tertiary-fixed-variant": "#54546f",
54
+ "on-surface": "#2b3437",
55
+ "on-primary-fixed": "#003f7d",
56
+ "on-primary-container": "#00519e",
57
+ "on-tertiary-fixed": "#383751",
58
+ "surface-container-low": "#f1f4f6",
59
+ "surface-dim": "#d1dce0",
60
+ "secondary-fixed-dim": "#c9d6e3",
61
+ "on-error-container": "#752121",
62
+ "surface-container-high": "#e3e9ec",
63
+ "surface-variant": "#dbe4e7",
64
+ "tertiary-container": "#d9d7f8",
65
+ "surface-container-highest": "#dbe4e7",
66
+ "secondary-dim": "#48555f"
67
+ },
68
+ borderRadius: {
69
+ "DEFAULT": "0.25rem",
70
+ "lg": "0.5rem",
71
+ "xl": "0.75rem",
72
+ "full": "9999px"
73
+ },
74
+ fontFamily: {
75
+ "headline": ["Inter", "sans-serif"],
76
+ "body": ["Inter", "sans-serif"],
77
+ "label": ["Inter", "sans-serif"]
78
+ }
79
+ }
80
+ }
81
+ }
82
+ </script>
83
+ <style>
84
+ body { font-family: 'Inter', sans-serif; }
85
+ .material-symbols-outlined {
86
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
87
+ vertical-align: middle;
88
+ }
89
+ .glass-effect { background: rgba(248,249,250,0.8); backdrop-filter: blur(12px); }
90
+ .ghost-border { outline: 1px solid rgba(171,179,183,0.15); }
91
+ ::-webkit-scrollbar { width: 6px; }
92
+ ::-webkit-scrollbar-track { background: transparent; }
93
+ ::-webkit-scrollbar-thumb { background: #abb3b7; border-radius: 10px; }
94
+
95
+ /* Code editor (test-forge, schema-detective) */
96
+ .code-editor { font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace; }
97
+ .editor-bg { background: #1e1e2e; color: #cdd6f4; }
98
+ /* Arabic text (arabic-bench) */
99
+ .arabic-text { font-family: 'Segoe UI', Tahoma, Arial, sans-serif; }
100
+ /* Scrollbar utilities */
101
+ .no-scrollbar::-webkit-scrollbar { display: none; }
102
+ .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
103
+ .custom-scrollbar::-webkit-scrollbar { width: 6px; }
104
+ .custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
105
+ .custom-scrollbar::-webkit-scrollbar-thumb { background: #abb3b7; border-radius: 10px; }
106
+ /* Prompt-bench iOS overrides — scoped so they only affect prompt-bench internals */
107
+ .toolbar-blur { background: rgba(242,242,247,0.85); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); }
108
+ .btn-primary { display:inline-flex;align-items:center;gap:6px;padding:6px 14px;background:#007AFF;color:#fff;border-radius:8px;font-size:13px;font-weight:600;border:none;cursor:pointer; }
109
+ .btn-secondary{ display:inline-flex;align-items:center;gap:4px;padding:5px 10px;background:rgba(0,0,0,0.06);color:#3C3C43;border-radius:7px;font-size:12px;font-weight:500;border:none;cursor:pointer; }
110
+ .section-label{ font-size:11px;font-weight:600;color:#636366;text-transform:uppercase;letter-spacing:.06em; }
111
+ .sys-label { color: #1C1C1E; }
112
+ .text-sys-label { color: #1C1C1E; }
113
+ </style>
114
+ {% block extra_head %}{% endblock %}
115
+ <meta name="csrf-token" content="{{ csrf_token() }}"/>
116
+ </head>
117
+ <body class="bg-surface text-on-surface selection:bg-primary-container selection:text-on-primary-container">
118
+ {% block content %}{% endblock %}
119
+ {% block extra_scripts %}{% endblock %}
120
+ </body>
121
+ </html>
app/tools/__init__.py ADDED
File without changes
app/tools/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (163 Bytes). View file
 
app/tools/changelog_ai/__init__.py ADDED
File without changes
app/tools/changelog_ai/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (176 Bytes). View file
 
app/tools/changelog_ai/__pycache__/changelog.cpython-314.pyc ADDED
Binary file (4.37 kB). View file
 
app/tools/changelog_ai/__pycache__/routes.cpython-314.pyc ADDED
Binary file (2.13 kB). View file
 
app/tools/changelog_ai/changelog.py ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Changelog.ai — transforms raw git commits into polished release notes."""
2
+ from app.core.ai import call_ai_json
3
+
4
+ _LANG_NAMES = {
5
+ "en": "English", "ar": "Arabic", "fr": "French", "es": "Spanish",
6
+ "de": "German", "zh": "Chinese (Simplified)", "ja": "Japanese",
7
+ "pt": "Portuguese", "ru": "Russian", "tr": "Turkish",
8
+ "ko": "Korean", "nl": "Dutch", "it": "Italian", "pl": "Polish",
9
+ }
10
+
11
+ _SYSTEM = """You are an expert technical writer who specializes in software release communications.
12
+ You transform raw git commit history into clear, well-structured changelogs.
13
+ You adapt your tone precisely to the target audience without over-explaining.
14
+ Return ONLY valid JSON — no markdown fences, no preamble."""
15
+
16
+ _AUDIENCE_NOTES = {
17
+ "Developer": (
18
+ "Write for software engineers. Use precise technical language. "
19
+ "Reference module names, APIs, and implementation details. "
20
+ "Include all commit types: feat, fix, perf, refactor. "
21
+ "Descriptions should be concise and technically accurate. "
22
+ "Skip chore/docs commits unless they have real developer impact."
23
+ ),
24
+ "User": (
25
+ "Write for end users with no technical background. "
26
+ "Translate technical changes into plain English benefits. "
27
+ "Focus on what users can NOW DO or what problems are SOLVED. "
28
+ "Only include feat and fix sections — no internal refactors, no chores. "
29
+ "Use active voice, 'You can now...', 'We fixed...' language."
30
+ ),
31
+ "Executive": (
32
+ "Write for C-suite and business stakeholders. "
33
+ "Focus exclusively on business impact, risk reduction, and strategic value. "
34
+ "Be brief: one punchy sentence per item maximum. "
35
+ "Skip low-level fixes unless they had business/availability impact. "
36
+ "Frame everything in terms of outcome, not implementation."
37
+ ),
38
+ }
39
+
40
+ _PROMPT_TMPL = """Transform the following git commits into a polished changelog.
41
+
42
+ TARGET AUDIENCE: {audience}
43
+ AUDIENCE INSTRUCTIONS: {audience_notes}
44
+
45
+ GIT COMMITS / RAW CHANGES:
46
+ ---
47
+ {commits}
48
+ ---
49
+
50
+ Return a JSON object with EXACTLY these keys:
51
+ {{
52
+ "sections": [
53
+ {{
54
+ "type": "<exactly one of: features | fixes | improvements | breaking>",
55
+ "items": [
56
+ {{
57
+ "title": "<short, clear title — max 8 words>",
58
+ "description": "<1-2 sentence description adapted to the target audience>"
59
+ }}
60
+ ]
61
+ }}
62
+ ]
63
+ }}
64
+
65
+ Rules:
66
+ - sections: only include types that have actual items; omit empty sections
67
+ - type ordering: breaking first, then features, then improvements, then fixes
68
+ - breaking: only if a commit explicitly breaks backward compatibility
69
+ - improvements: performance, refactors, dependency bumps with user impact
70
+ - Filter chore/doc-only commits unless they carry meaningful impact for this audience
71
+ - Each item title must be sentence-case and not end with a period
72
+ - Minimum 1 item per section; group related commits into one item when appropriate"""
73
+
74
+
75
+ def generate_changelog(commits: str, audience: str, language: str = "en") -> dict:
76
+ """Generate a polished changelog from raw git commits."""
77
+ audience_notes = _AUDIENCE_NOTES.get(audience, _AUDIENCE_NOTES["Developer"])
78
+ lang_name = _LANG_NAMES.get(language, "English")
79
+ lang_instruction = (
80
+ f"\n\nIMPORTANT: Write ALL text values in the JSON response in {lang_name}. "
81
+ "This includes all item titles and descriptions. "
82
+ "The JSON keys (type, sections, items, title, description) must remain in English."
83
+ ) if language != "en" else ""
84
+ prompt = _PROMPT_TMPL.format(
85
+ audience=audience,
86
+ audience_notes=audience_notes,
87
+ commits=commits[:6000],
88
+ ) + lang_instruction
89
+ result = call_ai_json(
90
+ [{"role": "user", "content": prompt}],
91
+ system=_SYSTEM,
92
+ max_tokens=2048,
93
+ )
94
+ return result or {}
app/tools/changelog_ai/routes.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Changelog.ai routes."""
2
+ from flask import Blueprint, render_template, request, jsonify
3
+ from .changelog import generate_changelog
4
+
5
+ bp = Blueprint("changelog_ai", __name__, template_folder="templates")
6
+
7
+ SUPPORTED_AUDIENCES = {"Developer", "User", "Executive"}
8
+
9
+
10
+ @bp.route("/")
11
+ def index():
12
+ return render_template("changelog_ai/index.html")
13
+
14
+
15
+ @bp.route("/api/generate", methods=["POST"])
16
+ def api_generate():
17
+ body = request.get_json(silent=True) or {}
18
+ commits = (body.get("commits") or "").strip()
19
+ audience = (body.get("audience") or "Developer").strip()
20
+ language = (body.get("language") or "en").strip()
21
+
22
+ if not commits:
23
+ return jsonify({"error": "Paste some commits or change notes first"}), 400
24
+ if len(commits) < 20:
25
+ return jsonify({"error": "Too short — paste at least a few commit messages"}), 400
26
+ if audience not in SUPPORTED_AUDIENCES:
27
+ return jsonify({"error": f"Unsupported audience: {audience}"}), 400
28
+
29
+ result = generate_changelog(commits, audience, language=language)
30
+ if not result:
31
+ return jsonify({"error": "AI failed to generate changelog — please try again"}), 502
32
+ return jsonify(result)
app/tools/changelog_ai/templates/changelog_ai/index.html ADDED
@@ -0,0 +1,726 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Changelog.ai — AI Release Notes Generator{% endblock %}
3
+
4
+ {% block content %}
5
+ <!-- Top Nav -->
6
+ <header class="flex justify-between items-center px-6 w-full sticky top-0 z-50 bg-white/80 backdrop-blur-md h-16 border-b border-slate-200/60">
7
+ <div class="flex items-center gap-6">
8
+ <span class="text-lg font-semibold text-on-surface tracking-tight">Changelog.ai</span>
9
+ <!-- Audience mode selector in nav -->
10
+ <div class="hidden md:flex gap-1 h-full items-end pb-0">
11
+ <button data-nav-audience="Developer"
12
+ class="nav-audience-btn px-4 pb-4 pt-4 text-sm font-medium text-on-surface-variant hover:text-on-surface transition-colors border-b-2 border-transparent">Developer</button>
13
+ <button data-nav-audience="User"
14
+ class="nav-audience-btn px-4 pb-4 pt-4 text-sm font-semibold text-blue-700 border-b-2 border-blue-700">User</button>
15
+ <button data-nav-audience="Executive"
16
+ class="nav-audience-btn px-4 pb-4 pt-4 text-sm font-medium text-on-surface-variant hover:text-on-surface transition-colors border-b-2 border-transparent">Executive</button>
17
+ </div>
18
+ </div>
19
+ <div class="flex items-center gap-3">
20
+ <button id="btn-download-pdf"
21
+ class="hidden bg-white border border-slate-200 hover:border-slate-300 text-on-surface px-4 py-2 rounded-lg text-sm font-medium transition-all shadow-sm flex items-center gap-2">
22
+ <span class="material-symbols-outlined text-sm" style="font-size:16px">picture_as_pdf</span>
23
+ Download PDF
24
+ </button>
25
+ <button id="btn-copy-md"
26
+ class="bg-primary hover:bg-primary-dim text-on-primary px-4 py-2 rounded-lg text-sm font-medium transition-all shadow-sm flex items-center gap-2">
27
+ <span id="copy-icon" class="material-symbols-outlined text-sm" style="font-size:16px">download</span>
28
+ <span id="copy-label">Copy Markdown</span>
29
+ </button>
30
+ </div>
31
+ </header>
32
+
33
+ <div class="flex h-[calc(100vh-4rem)] overflow-hidden">
34
+
35
+ <!-- Sidebar -->
36
+ <aside class="flex flex-col gap-2 p-4 h-full w-56 border-r border-slate-200/60 bg-slate-50 shrink-0">
37
+ <div class="flex items-center gap-3 px-2 mb-4">
38
+ <div class="w-8 h-8 rounded bg-primary flex items-center justify-center text-on-primary font-bold text-xs shadow-inner">CL</div>
39
+ <div>
40
+ <span class="text-sm font-semibold text-on-surface">Changelog.ai</span>
41
+ <span class="text-[10px] text-on-surface-variant uppercase tracking-wider font-bold block">Release Notes</span>
42
+ </div>
43
+ </div>
44
+ <nav class="flex-1 space-y-1">
45
+ <a class="flex items-center gap-3 px-3 py-2 bg-white text-blue-700 shadow-sm rounded-lg font-medium text-sm" href="#">
46
+ <span class="material-symbols-outlined" style="font-size:18px">history</span> Generator
47
+ </a>
48
+ </nav>
49
+ </aside>
50
+
51
+ <!-- Main Workspace -->
52
+ <main class="flex-1 flex overflow-hidden bg-surface">
53
+
54
+ <!-- Left Panel: Raw Input -->
55
+ <section class="w-2/5 p-5 border-r border-slate-200/60 flex flex-col gap-3 bg-white/50 shrink-0">
56
+ <div>
57
+ <span class="text-[10px] font-semibold text-on-surface-variant uppercase tracking-widest block mb-1">Source Input</span>
58
+ <h2 class="text-xl font-semibold text-on-surface">Raw Git History</h2>
59
+ </div>
60
+
61
+ <!-- Version + Date row -->
62
+ <div class="grid grid-cols-2 gap-3">
63
+ <div>
64
+ <label class="text-[10px] font-semibold text-on-surface-variant uppercase tracking-widest block mb-1">Version</label>
65
+ <input id="version-input" type="text" placeholder="v1.0.0"
66
+ class="w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-on-surface focus:ring-2 focus:ring-primary/20 outline-none">
67
+ </div>
68
+ <div>
69
+ <label class="text-[10px] font-semibold text-on-surface-variant uppercase tracking-widest block mb-1">Date</label>
70
+ <input id="date-input" type="date"
71
+ class="w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-on-surface focus:ring-2 focus:ring-primary/20 outline-none">
72
+ </div>
73
+ </div>
74
+
75
+ <!-- Language selector -->
76
+ <div>
77
+ <label class="text-[10px] font-semibold text-on-surface-variant uppercase tracking-widest block mb-1">Output Language</label>
78
+ <select id="lang-select"
79
+ class="w-full bg-white border border-slate-200 rounded-lg px-3 py-2 text-sm text-on-surface focus:ring-2 focus:ring-primary/20 outline-none">
80
+ <option value="en">English</option>
81
+ <option value="ar">Arabic</option>
82
+ <option value="fr">French</option>
83
+ <option value="es">Spanish</option>
84
+ <option value="de">German</option>
85
+ <option value="zh">Chinese (Simplified)</option>
86
+ <option value="ja">Japanese</option>
87
+ <option value="pt">Portuguese</option>
88
+ <option value="ru">Russian</option>
89
+ <option value="tr">Turkish</option>
90
+ <option value="ko">Korean</option>
91
+ <option value="nl">Dutch</option>
92
+ <option value="it">Italian</option>
93
+ <option value="pl">Polish</option>
94
+ </select>
95
+ </div>
96
+
97
+ <!-- Commits textarea -->
98
+ <div class="flex-1 relative">
99
+ <textarea id="commits-input"
100
+ class="w-full h-full bg-surface-container-lowest border-none ghost-border rounded-xl p-4 font-mono text-sm text-on-surface-variant focus:ring-2 focus:ring-primary/10 resize-none shadow-sm"
101
+ spellcheck="false"
102
+ placeholder="feat(api): add auth endpoints (f2a3b4)
103
+ fix(worker): resolve memory leak in background worker (9a8b7c)
104
+ perf(db): optimize query latency (d4e5f6)
105
+ feat(ui): implement new dashboard layouts (1a2b3c)
106
+ feat(auth): sso integration with Okta (5e6f7g)
107
+ chore(deps): bump tailwindcss from 3.0 to 3.4
108
+ docs: update readme with deployment steps
109
+ refactor: clean up legacy charting utility"></textarea>
110
+ </div>
111
+
112
+ <div class="flex items-center gap-2">
113
+ <button id="btn-generate"
114
+ class="flex-1 bg-primary hover:bg-primary-dim text-on-primary py-3 rounded-xl font-semibold flex items-center justify-center gap-3 shadow-lg shadow-primary/20 transition-all active:scale-[0.98]">
115
+ <span class="material-symbols-outlined">auto_awesome</span>
116
+ Generate Polished Changelog
117
+ </button>
118
+ <button id="btn-try-demo"
119
+ class="shrink-0 bg-white border border-slate-200 hover:border-primary hover:text-primary text-on-surface-variant px-4 py-3 rounded-xl text-sm font-medium transition-all flex items-center gap-2 shadow-sm"
120
+ title="Load a sample to try the generator">
121
+ <span class="material-symbols-outlined" style="font-size:16px">play_circle</span>
122
+ Try a demo
123
+ </button>
124
+ </div>
125
+ </section>
126
+
127
+ <!-- Right Panel: Preview -->
128
+ <section class="flex-1 p-6 overflow-y-auto bg-surface-container-low flex flex-col gap-5">
129
+
130
+ <!-- Audience Toggle Pills -->
131
+ <div class="flex items-center justify-center shrink-0">
132
+ <div class="bg-surface-container-high p-1 rounded-xl flex gap-1">
133
+ <button data-pill-audience="Developer"
134
+ class="pill-btn px-4 py-2 text-sm font-medium rounded-lg text-on-surface-variant hover:text-on-surface transition-all">Developer</button>
135
+ <button data-pill-audience="User"
136
+ class="pill-btn px-6 py-2 text-sm font-semibold rounded-lg bg-white text-primary shadow-sm">User</button>
137
+ <button data-pill-audience="Executive"
138
+ class="pill-btn px-4 py-2 text-sm font-medium rounded-lg text-on-surface-variant hover:text-on-surface transition-all">Executive</button>
139
+ </div>
140
+ </div>
141
+
142
+ <!-- States -->
143
+ <div id="empty-state" class="flex-1 flex flex-col items-center justify-center text-center text-on-surface-variant">
144
+ <span class="material-symbols-outlined text-5xl text-outline mb-4">auto_awesome</span>
145
+ <p class="text-sm font-medium">Paste your commits and click Generate</p>
146
+ <p class="text-xs text-outline mt-1">Set version, audience, and language, then generate</p>
147
+ </div>
148
+
149
+ <div id="loading-state" class="hidden flex-1 flex flex-col items-center justify-center gap-4">
150
+ <div class="w-10 h-10 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
151
+ <p class="text-sm text-on-surface-variant">Generating changelog…</p>
152
+ </div>
153
+
154
+ <div id="error-state" class="hidden flex-1 flex flex-col items-center justify-center text-center">
155
+ <span class="material-symbols-outlined text-error text-4xl mb-3">error</span>
156
+ <p id="error-msg" class="text-sm text-error font-medium px-8"></p>
157
+ </div>
158
+
159
+ <!-- GitHub-style Preview Card -->
160
+ <div id="results-state" class="hidden max-w-3xl mx-auto w-full flex flex-col gap-4">
161
+ <div class="bg-white rounded-2xl shadow-sm border border-slate-200/50 p-8">
162
+ <!-- Release Header -->
163
+ <header class="mb-8 border-b border-slate-100 pb-6">
164
+ <div class="flex items-start justify-between mb-2">
165
+ <div>
166
+ <h1 id="release-title" class="text-3xl font-bold text-on-surface tracking-tight">Latest Release</h1>
167
+ <p id="release-meta" class="text-sm text-on-surface-variant mt-1"></p>
168
+ </div>
169
+ <span id="audience-badge" class="bg-secondary-container text-on-secondary-container text-xs font-bold px-3 py-1 rounded-full mt-1">User</span>
170
+ </div>
171
+ </header>
172
+ <!-- Sections rendered by JS -->
173
+ <div id="changelog-sections" class="space-y-8"></div>
174
+ </div>
175
+
176
+ <!-- Bento stats -->
177
+ <div class="grid grid-cols-3 gap-3">
178
+ <div class="col-span-2 bg-white/60 p-5 rounded-2xl ghost-border flex items-center gap-4">
179
+ <div class="p-3 bg-primary-container rounded-xl">
180
+ <span class="material-symbols-outlined text-primary" style="font-size:20px">checklist</span>
181
+ </div>
182
+ <div>
183
+ <p class="text-xs font-bold text-on-surface-variant uppercase tracking-wider">Changes</p>
184
+ <p id="stat-changes" class="text-lg font-bold text-on-surface">—</p>
185
+ </div>
186
+ </div>
187
+ <div class="bg-white/60 p-5 rounded-2xl ghost-border flex flex-col justify-center">
188
+ <p class="text-xs font-bold text-on-surface-variant uppercase tracking-wider mb-1">Audience</p>
189
+ <p id="stat-audience" class="text-xl font-bold text-on-surface">—</p>
190
+ </div>
191
+ </div>
192
+ </div>
193
+
194
+ </section>
195
+ </main>
196
+ </div>
197
+ {% endblock %}
198
+
199
+ {% block extra_scripts %}
200
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/html2pdf.js/0.10.1/html2pdf.bundle.min.js"></script>
201
+ <script>
202
+ (function () {
203
+ const CSRF = document.querySelector('meta[name="csrf-token"]').content;
204
+
205
+ let selectedAudience = 'User';
206
+ let lastResult = null;
207
+
208
+ const commitsInput = document.getElementById('commits-input');
209
+ const versionInput = document.getElementById('version-input');
210
+ const dateInput = document.getElementById('date-input');
211
+ const langSelect = document.getElementById('lang-select');
212
+ const btnGenerate = document.getElementById('btn-generate');
213
+ const btnCopyMd = document.getElementById('btn-copy-md');
214
+ const btnDownloadPdf = document.getElementById('btn-download-pdf');
215
+ const copyIcon = document.getElementById('copy-icon');
216
+ const copyLabel = document.getElementById('copy-label');
217
+
218
+ const emptyState = document.getElementById('empty-state');
219
+ const loadingState = document.getElementById('loading-state');
220
+ const errorState = document.getElementById('error-state');
221
+ const resultsState = document.getElementById('results-state');
222
+ const errorMsg = document.getElementById('error-msg');
223
+ const changelogSections = document.getElementById('changelog-sections');
224
+ const audienceBadge = document.getElementById('audience-badge');
225
+ const releaseTitle = document.getElementById('release-title');
226
+ const releaseMeta = document.getElementById('release-meta');
227
+ const statChanges = document.getElementById('stat-changes');
228
+ const statAudience = document.getElementById('stat-audience');
229
+
230
+ // Set default date to today
231
+ dateInput.valueAsDate = new Date();
232
+
233
+ const SECTION_META = {
234
+ features: { emoji: '\u2728', label: 'New Features', bg: 'bg-blue-50', dot: 'bg-blue-500', pdfColor: [59, 130, 246] },
235
+ fixes: { emoji: '\uD83D\uDC1B', label: 'Bug Fixes', bg: 'bg-red-50', dot: 'bg-red-400', pdfColor: [239, 68, 68] },
236
+ improvements: { emoji: '\uD83D\uDE80', label: 'Improvements',bg: 'bg-emerald-50', dot: 'bg-emerald-500', pdfColor: [16, 185, 129] },
237
+ breaking: { emoji: '\u26A0\uFE0F', label: 'Breaking Changes', bg: 'bg-amber-50', dot: 'bg-amber-500', pdfColor: [245, 158, 11] },
238
+ };
239
+
240
+ const SECTION_LABELS = {
241
+ en: { features: 'New Features', fixes: 'Bug Fixes', improvements: 'Improvements', breaking: 'Breaking Changes' },
242
+ ar: { features: '\u0645\u064a\u0632\u0627\u062a \u062c\u062f\u064a\u062f\u0629', fixes: '\u0625\u0635\u0644\u0627\u062d \u0627\u0644\u0623\u062e\u0637\u0627\u0621', improvements: '\u062a\u062d\u0633\u064a\u0646\u0627\u062a', breaking: '\u062a\u063a\u064a\u064a\u0631\u0627\u062a \u062c\u0630\u0631\u064a\u0629' },
243
+ fr: { features: 'Nouvelles fonctionnalit\u00e9s', fixes: 'Correctifs', improvements: 'Am\u00e9liorations', breaking: 'Changements majeurs' },
244
+ es: { features: 'Nuevas funciones', fixes: 'Correcciones', improvements: 'Mejoras', breaking: 'Cambios importantes' },
245
+ de: { features: 'Neue Funktionen', fixes: 'Fehlerbehebungen', improvements: 'Verbesserungen', breaking: 'Grundlegende \u00c4nderungen' },
246
+ zh: { features: '\u65b0\u529f\u80fd', fixes: '\u9519\u8bef\u4fee\u590d', improvements: '\u6539\u8fdb', breaking: '\u91cd\u5927\u53d8\u66f4' },
247
+ ja: { features: '\u65b0\u6a5f\u80fd', fixes: '\u30d0\u30b0\u4fee\u6b63', improvements: '\u6539\u5584', breaking: '\u7834\u58ca\u7684\u5909\u66f4' },
248
+ pt: { features: 'Novos recursos', fixes: 'Corre\u00e7\u00f5es', improvements: 'Melhorias', breaking: 'Mudan\u00e7as importantes' },
249
+ ru: { features: '\u041d\u043e\u0432\u044b\u0435 \u0444\u0443\u043d\u043a\u0446\u0438\u0438', fixes: '\u0418\u0441\u043f\u0440\u0430\u0432\u043b\u0435\u043d\u0438\u044f \u043e\u0448\u0438\u0431\u043e\u043a', improvements: '\u0423\u043b\u0443\u0447\u0448\u0435\u043d\u0438\u044f', breaking: '\u041a\u0440\u0438\u0442\u0438\u0447\u0435\u0441\u043a\u0438\u0435 \u0438\u0437\u043c\u0435\u043d\u0435\u043d\u0438\u044f' },
250
+ tr: { features: 'Yeni \u00f6zellikler', fixes: 'Hata d\u00fczeltmeleri', improvements: '\u0130yile\u015ftirmeler', breaking: 'B\u00fcy\u00fck de\u011fi\u015fiklikler' },
251
+ ko: { features: '\uc0c8\ub85c\uc6b4 \uae30\ub2a5', fixes: '\ubc84\uadf8 \uc218\uc815', improvements: '\uac1c\uc120 \uc0ac\ud56d', breaking: '\uc8fc\uc694 \ubcc0\uacbd \uc0ac\ud56d' },
252
+ nl: { features: 'Nieuwe functies', fixes: 'Bugfixes', improvements: 'Verbeteringen', breaking: 'Grote wijzigingen' },
253
+ it: { features: 'Nuove funzionalit\u00e0', fixes: 'Correzioni di bug', improvements: 'Miglioramenti', breaking: 'Modifiche importanti' },
254
+ pl: { features: 'Nowe funkcje', fixes: 'Poprawki b\u0142\u0119d\u00f3w', improvements: 'Ulepszenia', breaking: 'Prze\u0142omowe zmiany' },
255
+ };
256
+
257
+ function getSectionLabel(type) {
258
+ const lang = langSelect.value;
259
+ const labels = SECTION_LABELS[lang] || SECTION_LABELS['en'];
260
+ return labels[type] || SECTION_META[type]?.label || type;
261
+ }
262
+
263
+ const RTL_LANGS = new Set(['ar']);
264
+ function isRtl() { return RTL_LANGS.has(langSelect.value); }
265
+
266
+ // html2pdf screenshots the DOM, so all languages/scripts render correctly.
267
+
268
+ // ── Audience sync ─────────────────────────────────────────────────────────
269
+ function setAudience(aud) {
270
+ selectedAudience = aud;
271
+ document.querySelectorAll('.nav-audience-btn').forEach(b => {
272
+ const on = b.dataset.navAudience === aud;
273
+ b.className = on
274
+ ? 'nav-audience-btn px-4 pb-4 pt-4 text-sm font-semibold text-blue-700 border-b-2 border-blue-700'
275
+ : 'nav-audience-btn px-4 pb-4 pt-4 text-sm font-medium text-on-surface-variant hover:text-on-surface transition-colors border-b-2 border-transparent';
276
+ });
277
+ document.querySelectorAll('.pill-btn').forEach(b => {
278
+ const on = b.dataset.pillAudience === aud;
279
+ b.className = on
280
+ ? 'pill-btn px-6 py-2 text-sm font-semibold rounded-lg bg-white text-primary shadow-sm'
281
+ : 'pill-btn px-4 py-2 text-sm font-medium rounded-lg text-on-surface-variant hover:text-on-surface transition-all';
282
+ });
283
+ }
284
+
285
+ document.querySelectorAll('.nav-audience-btn').forEach(b =>
286
+ b.addEventListener('click', () => setAudience(b.dataset.navAudience)));
287
+ document.querySelectorAll('.pill-btn').forEach(b =>
288
+ b.addEventListener('click', () => setAudience(b.dataset.pillAudience)));
289
+
290
+ setAudience('User');
291
+
292
+ // ── Generate ─────────────────────────────────────────────────────────────
293
+ btnGenerate.addEventListener('click', generate);
294
+
295
+ async function generate() {
296
+ const commits = commitsInput.value.trim();
297
+ if (!commits) { showError('Paste some commit messages first.'); return; }
298
+
299
+ showState('loading');
300
+ btnDownloadPdf.classList.add('hidden');
301
+
302
+ try {
303
+ const res = await fetch('/api/generate', {
304
+ method: 'POST',
305
+ headers: { 'Content-Type': 'application/json', 'X-CSRFToken': CSRF },
306
+ body: JSON.stringify({
307
+ commits,
308
+ audience: selectedAudience,
309
+ language: langSelect.value,
310
+ })
311
+ });
312
+ const data = await res.json();
313
+ if (!res.ok) { showError(data.error || 'Generation failed — try again.'); return; }
314
+ lastResult = data;
315
+ renderChangelog(data);
316
+ showState('results');
317
+ btnDownloadPdf.classList.remove('hidden');
318
+ } catch (_) {
319
+ showError('Network error — is the server running?');
320
+ }
321
+ }
322
+
323
+ // ── Render changelog ──────────────────────────────────────────────────────
324
+ function renderChangelog(data) {
325
+ changelogSections.textContent = '';
326
+ const sections = data.sections || [];
327
+ let totalItems = 0;
328
+
329
+ // Apply RTL direction to the whole preview
330
+ resultsState.setAttribute('dir', isRtl() ? 'rtl' : 'ltr');
331
+
332
+ // Release header
333
+ const ver = versionInput.value.trim();
334
+ const date = dateInput.value;
335
+ releaseTitle.textContent = ver ? ver : 'Latest Release';
336
+ const parts = [];
337
+ if (date) {
338
+ const d = new Date(date + 'T00:00:00');
339
+ parts.push(d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }));
340
+ }
341
+ releaseMeta.textContent = parts.join(' · ');
342
+
343
+ sections.forEach(section => {
344
+ const meta = SECTION_META[section.type] || SECTION_META.improvements;
345
+ const items = section.items || [];
346
+ if (!items.length) return;
347
+ totalItems += items.length;
348
+
349
+ const sectionEl = document.createElement('section');
350
+
351
+ const headerRow = document.createElement('div');
352
+ headerRow.className = 'flex items-center gap-3 mb-4';
353
+
354
+ const emojiBox = document.createElement('span');
355
+ emojiBox.className = 'w-9 h-9 rounded-xl ' + meta.bg + ' flex items-center justify-center text-lg';
356
+ emojiBox.textContent = meta.emoji;
357
+
358
+ const title = document.createElement('h3');
359
+ title.className = 'text-base font-bold text-on-surface';
360
+ title.textContent = getSectionLabel(section.type);
361
+
362
+ const countBadge = document.createElement('span');
363
+ countBadge.className = 'ml-auto text-xs font-semibold text-on-surface-variant bg-slate-100 px-2 py-0.5 rounded-full';
364
+ countBadge.textContent = items.length + ' item' + (items.length !== 1 ? 's' : '');
365
+
366
+ headerRow.appendChild(emojiBox);
367
+ headerRow.appendChild(title);
368
+ headerRow.appendChild(countBadge);
369
+ sectionEl.appendChild(headerRow);
370
+
371
+ const box = document.createElement('div');
372
+ box.className = 'bg-slate-50/50 rounded-xl p-5 ghost-border';
373
+ const ul = document.createElement('ul');
374
+ ul.className = 'space-y-4';
375
+
376
+ items.forEach(item => {
377
+ const li = document.createElement('li');
378
+ li.className = 'flex items-start gap-3';
379
+
380
+ const dot = document.createElement('div');
381
+ dot.className = 'w-1.5 h-1.5 rounded-full mt-2 shrink-0 ' + meta.dot;
382
+
383
+ const textBlock = document.createElement('div');
384
+ const itemTitle = document.createElement('p');
385
+ itemTitle.className = 'font-semibold text-sm text-on-surface';
386
+ itemTitle.textContent = item.title || '';
387
+
388
+ const itemDesc = document.createElement('p');
389
+ itemDesc.className = 'text-xs text-on-surface-variant mt-0.5 leading-relaxed';
390
+ itemDesc.textContent = item.description || '';
391
+
392
+ textBlock.appendChild(itemTitle);
393
+ if (item.description) textBlock.appendChild(itemDesc);
394
+
395
+ li.appendChild(dot);
396
+ li.appendChild(textBlock);
397
+ ul.appendChild(li);
398
+ });
399
+
400
+ box.appendChild(ul);
401
+ sectionEl.appendChild(box);
402
+ changelogSections.appendChild(sectionEl);
403
+ });
404
+
405
+ audienceBadge.textContent = selectedAudience;
406
+ statChanges.textContent = totalItems + ' item' + (totalItems !== 1 ? 's' : '');
407
+ statAudience.textContent = selectedAudience;
408
+ }
409
+
410
+ // ── Copy Markdown ─────────────────────────────────────────────────────────
411
+ btnCopyMd.addEventListener('click', () => {
412
+ if (!lastResult) return;
413
+ const ver = versionInput.value.trim();
414
+ const date = dateInput.value;
415
+ const lines = [];
416
+ if (ver) lines.push('# ' + ver, '');
417
+ if (date) lines.push('_Released ' + date + '_', '');
418
+ lines.push('**Audience: ' + selectedAudience + '**', '');
419
+ (lastResult.sections || []).forEach(section => {
420
+ const meta = SECTION_META[section.type] || SECTION_META.improvements;
421
+ lines.push('## ' + meta.emoji + ' ' + getSectionLabel(section.type));
422
+ lines.push('');
423
+ (section.items || []).forEach(item => {
424
+ lines.push('- **' + (item.title || '') + '** — ' + (item.description || ''));
425
+ });
426
+ lines.push('');
427
+ });
428
+ navigator.clipboard.writeText(lines.join('\n')).then(() => {
429
+ copyIcon.textContent = 'check';
430
+ copyLabel.textContent = 'Copied!';
431
+ setTimeout(() => {
432
+ copyIcon.textContent = 'download';
433
+ copyLabel.textContent = 'Copy Markdown';
434
+ }, 2000);
435
+ });
436
+ });
437
+
438
+ // ── PDF Export ────────────────────────────────────────────────────────────
439
+ btnDownloadPdf.addEventListener('click', downloadPDF);
440
+
441
+ function downloadPDF() {
442
+ if (!lastResult) return;
443
+ btnDownloadPdf.disabled = true;
444
+ btnDownloadPdf.textContent = 'Generating…';
445
+ const el = document.getElementById('results-state');
446
+ const ver = versionInput.value.trim();
447
+ const fname = (ver ? ver.replace(/\s+/g, '-') : 'changelog') + '-release-notes.pdf';
448
+ html2pdf().set({
449
+ margin: 10,
450
+ filename: fname,
451
+ image: { type: 'jpeg', quality: 0.98 },
452
+ html2canvas: { scale: 2, useCORS: true, logging: false, backgroundColor: '#f8fafc' },
453
+ jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
454
+ pagebreak: { mode: ['avoid-all', 'css'] },
455
+ }).from(el).save().then(() => {
456
+ btnDownloadPdf.disabled = false;
457
+ btnDownloadPdf.textContent = 'Download PDF';
458
+ });
459
+ }
460
+ /* legacy jsPDF body
461
+ function downloadPDF_legacy() {
462
+ const { jsPDF } = window.jspdf;
463
+ const doc = new jsPDF('p', 'mm', 'a4');
464
+ const PW = 210, PH = 297, M = 14;
465
+ const CW = PW - M * 2;
466
+
467
+ const NAVY = [30, 41, 82];
468
+ const BLUE = [59, 130, 246];
469
+ const SLATE = [100, 116, 139];
470
+ const LIGHT = [241, 245, 249];
471
+ const WHITE = [255, 255, 255];
472
+ const DARK = [15, 23, 42];
473
+
474
+ const ver = versionInput.value.trim();
475
+ const date = dateInput.value;
476
+ let totalPages = 1;
477
+ let y = 0;
478
+
479
+ function addPage() {
480
+ doc.addPage();
481
+ totalPages++;
482
+ y = 20;
483
+ drawPageFooter();
484
+ }
485
+
486
+ function checkY(needed) {
487
+ if (y + needed > PH - 20) addPage();
488
+ }
489
+
490
+ function drawPageFooter() {
491
+ doc.setFillColor(...LIGHT);
492
+ doc.rect(0, PH - 12, PW, 12, 'F');
493
+ doc.setFontSize(7);
494
+ doc.setTextColor(...SLATE);
495
+ doc.text('Generated by Changelog.ai', M, PH - 5);
496
+ doc.text('Page ' + doc.getCurrentPageInfo().pageNumber, PW - M, PH - 5, { align: 'right' });
497
+ }
498
+
499
+ // ── Header ──────────────────────────────────────────────────────────────
500
+ doc.setFillColor(...NAVY);
501
+ doc.rect(0, 0, PW, 30, 'F');
502
+ doc.setFillColor(...BLUE);
503
+ doc.rect(0, 30, PW, 2.5, 'F');
504
+
505
+ doc.setTextColor(...WHITE);
506
+ doc.setFontSize(18);
507
+ doc.setFont('helvetica', 'bold');
508
+ doc.text('RELEASE NOTES', M, 19);
509
+
510
+ if (ver) {
511
+ doc.setFontSize(12);
512
+ doc.setFont('helvetica', 'normal');
513
+ doc.text(ver, PW - M, 19, { align: 'right' });
514
+ }
515
+
516
+ y = 40;
517
+
518
+ // ── Meta row ────────────────────────────────────────────────────────────
519
+ doc.setFontSize(9);
520
+ doc.setFont('helvetica', 'normal');
521
+ doc.setTextColor(...SLATE);
522
+ if (date) {
523
+ const d = new Date(date + 'T00:00:00');
524
+ const dateStr = d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
525
+ doc.text(dateStr, M, y);
526
+ }
527
+ doc.setFillColor(...BLUE);
528
+ doc.roundedRect(PW - M - 32, y - 4.5, 32, 6.5, 1.5, 1.5, 'F');
529
+ doc.setTextColor(...WHITE);
530
+ doc.setFontSize(7.5);
531
+ doc.setFont('helvetica', 'bold');
532
+ doc.text(selectedAudience.toUpperCase(), PW - M - 16, y - 0.2, { align: 'center' });
533
+
534
+ y += 10;
535
+
536
+ // ── Sections ─────────────────────────────────────────────────────────────
537
+ const rtl = isRtl();
538
+ const textX = rtl ? M + CW - 4 : M + 4;
539
+ const textAlign = rtl ? 'right' : 'left';
540
+ const itemX = rtl ? M + CW - 8 : M + 8;
541
+ const itemW = CW - 14;
542
+ const dotX = rtl ? M + CW - 3 : M + 3;
543
+ const countX = rtl ? M + 4 : M + CW - 4;
544
+ const countAlign = rtl ? 'left' : 'right';
545
+ const divX1 = rtl ? M : M + 6;
546
+ const divX2 = rtl ? M + CW - 6 : M + CW;
547
+
548
+ const sections = lastResult.sections || [];
549
+ sections.forEach(section => {
550
+ const meta = SECTION_META[section.type] || SECTION_META.improvements;
551
+ const items = section.items || [];
552
+ if (!items.length) return;
553
+
554
+ checkY(18);
555
+
556
+ // Section header band
557
+ const clr = meta.pdfColor || BLUE;
558
+ doc.setFillColor(clr[0] + Math.round((255 - clr[0]) * 0.88),
559
+ clr[1] + Math.round((255 - clr[1]) * 0.88),
560
+ clr[2] + Math.round((255 - clr[2]) * 0.88));
561
+ doc.roundedRect(M, y, CW, 9, 1.5, 1.5, 'F');
562
+
563
+ doc.setFontSize(9.5);
564
+ doc.setFont('helvetica', 'bold');
565
+ doc.setTextColor(...DARK);
566
+ doc.text(meta.emoji + ' ' + getSectionLabel(section.type), textX, y + 6.2, { align: textAlign });
567
+
568
+ const countStr = items.length + ' item' + (items.length !== 1 ? 's' : '');
569
+ doc.setFontSize(7.5);
570
+ doc.setFont('helvetica', 'normal');
571
+ doc.setTextColor(...SLATE);
572
+ doc.text(countStr, countX, y + 6.2, { align: countAlign });
573
+
574
+ y += 12;
575
+
576
+ items.forEach(item => {
577
+ const titleLines = doc.splitTextToSize(item.title || '', itemW);
578
+ const descLines = item.description
579
+ ? doc.splitTextToSize(item.description, itemW)
580
+ : [];
581
+ const blockH = 4 + titleLines.length * 5 + (descLines.length > 0 ? 2 + descLines.length * 4.2 : 0) + 4;
582
+ checkY(blockH + 2);
583
+
584
+ // Dot
585
+ doc.setFillColor(...clr);
586
+ doc.circle(dotX, y + 2.8, 1, 'F');
587
+
588
+ // Title
589
+ doc.setFontSize(9);
590
+ doc.setFont('helvetica', 'bold');
591
+ doc.setTextColor(...DARK);
592
+ doc.text(titleLines, itemX, y + 4.5, { align: textAlign });
593
+ y += titleLines.length * 5;
594
+
595
+ // Description
596
+ if (descLines.length) {
597
+ doc.setFontSize(8);
598
+ doc.setFont('helvetica', 'normal');
599
+ doc.setTextColor(...SLATE);
600
+ doc.text(descLines, itemX, y + 3.5, { align: textAlign });
601
+ y += descLines.length * 4.2 + 2;
602
+ }
603
+
604
+ y += 4;
605
+
606
+ // Divider
607
+ doc.setDrawColor(226, 232, 240);
608
+ doc.setLineWidth(0.3);
609
+ doc.line(divX1, y, divX2, y);
610
+ y += 3;
611
+ });
612
+
613
+ y += 4;
614
+ });
615
+
616
+ // ── Stats footer area ────────────────────────────────────────────────────
617
+ checkY(24);
618
+ y += 2;
619
+ doc.setFillColor(...LIGHT);
620
+ doc.roundedRect(M, y, CW, 18, 2, 2, 'F');
621
+
622
+ let totalItems = 0;
623
+ sections.forEach(s => { totalItems += (s.items || []).length; });
624
+
625
+ doc.setFontSize(8);
626
+ doc.setFont('helvetica', 'bold');
627
+ doc.setTextColor(...SLATE);
628
+ doc.text('TOTAL CHANGES', M + 8, y + 7);
629
+ doc.setFontSize(13);
630
+ doc.setTextColor(...DARK);
631
+ doc.text(String(totalItems), M + 8, y + 14);
632
+
633
+ doc.setFontSize(8);
634
+ doc.setFont('helvetica', 'bold');
635
+ doc.setTextColor(...SLATE);
636
+ doc.text('AUDIENCE', PW / 2, y + 7, { align: 'center' });
637
+ doc.setFontSize(10);
638
+ doc.setTextColor(...DARK);
639
+ doc.setFont('helvetica', 'normal');
640
+ doc.text(selectedAudience, PW / 2, y + 14, { align: 'center' });
641
+
642
+ if (ver) {
643
+ doc.setFontSize(8);
644
+ doc.setFont('helvetica', 'bold');
645
+ doc.setTextColor(...SLATE);
646
+ doc.text('VERSION', M + CW - 8, y + 7, { align: 'right' });
647
+ doc.setFontSize(10);
648
+ doc.setFont('helvetica', 'normal');
649
+ doc.setTextColor(...DARK);
650
+ doc.text(ver, M + CW - 8, y + 14, { align: 'right' });
651
+ }
652
+
653
+ // Footer on first page
654
+ drawPageFooter();
655
+
656
+ const fname2 = (ver ? ver.replace(/\s+/g, '-') : 'changelog') + '-release-notes.pdf';
657
+ doc.save(fname2);
658
+ }*/
659
+
660
+ // ── State helpers ─────────────────────────────────────────────────────────
661
+ function showState(state) {
662
+ [emptyState, loadingState, errorState, resultsState].forEach(el => el.classList.add('hidden'));
663
+ const map = { empty: emptyState, loading: loadingState, error: errorState, results: resultsState };
664
+ if (map[state]) map[state].classList.remove('hidden');
665
+ }
666
+
667
+ function showError(msg) {
668
+ errorMsg.textContent = msg;
669
+ showState('error');
670
+ }
671
+
672
+ // ── Demo loader ───────────────────────────────────────────────────────────
673
+ const _DEMOS = [
674
+ {
675
+ version: 'v2.4.0',
676
+ commits: `- feat: add AI-powered auto-complete to search bar
677
+ - feat: support bulk export to CSV and Excel
678
+ - feat: dark mode toggle in user settings
679
+ - fix: dashboard charts not loading on Safari
680
+ - fix: email notifications sent twice on password reset
681
+ - fix: file uploads failing for files over 50MB
682
+ - perf: reduced page load time by 40% via lazy loading
683
+ - security: patched XSS vulnerability in comment fields`,
684
+ },
685
+ {
686
+ version: 'v3.1.2',
687
+ commits: `- feat: offline mode — browse and save content without internet
688
+ - feat: biometric login (Face ID / fingerprint)
689
+ - feat: share to WhatsApp and Instagram Stories
690
+ - fix: app crashing on Android 12 when opening camera
691
+ - fix: notifications not appearing on iOS 17
692
+ - fix: search results showing duplicates
693
+ - improvement: 60% faster image loading
694
+ - improvement: reduced battery drain by 25%`,
695
+ },
696
+ {
697
+ version: 'v1.8.0',
698
+ commits: `- feat: Claude 3 and GPT-4o model support
699
+ - feat: streaming responses with real-time token display
700
+ - feat: team workspaces with shared prompt libraries
701
+ - feat: version history for all prompts
702
+ - fix: API timeout errors on long responses
703
+ - fix: syntax highlighting broken for Python 3.12
704
+ - fix: keyboard shortcuts not working on Windows
705
+ - breaking: removed deprecated v1 API endpoints
706
+ - docs: updated quickstart guide and API reference`,
707
+ },
708
+ ];
709
+
710
+ let _demoIndex = 0;
711
+
712
+ function loadDemo() {
713
+ const demo = _DEMOS[_demoIndex % _DEMOS.length];
714
+ _demoIndex++;
715
+ commitsInput.value = demo.commits;
716
+ versionInput.value = demo.version;
717
+ // Trigger generate
718
+ generate();
719
+ }
720
+
721
+ window.loadDemo = loadDemo;
722
+
723
+ document.getElementById('btn-try-demo').addEventListener('click', loadDemo);
724
+ })();
725
+ </script>
726
+ {% endblock %}
app/tools/doc_forge/__init__.py ADDED
File without changes
app/tools/doc_forge/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (173 Bytes). View file
 
app/tools/doc_forge/__pycache__/db.cpython-314.pyc ADDED
Binary file (5.29 kB). View file
 
app/tools/doc_forge/__pycache__/doc_generator.cpython-314.pyc ADDED
Binary file (7.55 kB). View file
 
app/tools/doc_forge/__pycache__/github_fetcher.cpython-314.pyc ADDED
Binary file (7.84 kB). View file
 
app/tools/doc_forge/__pycache__/routes.cpython-314.pyc ADDED
Binary file (6.31 kB). View file
 
app/tools/doc_forge/db.py ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLite cache for generated docs."""
2
+ import json
3
+ import sqlite3
4
+ import os
5
+
6
+ _SCHEMA = """
7
+ PRAGMA journal_mode=WAL;
8
+ PRAGMA foreign_keys=ON;
9
+
10
+ CREATE TABLE IF NOT EXISTS repos (
11
+ id INTEGER PRIMARY KEY,
12
+ owner TEXT NOT NULL,
13
+ repo TEXT NOT NULL,
14
+ info TEXT NOT NULL, -- JSON
15
+ tree TEXT NOT NULL, -- JSON array
16
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
17
+ UNIQUE(owner, repo)
18
+ );
19
+
20
+ CREATE TABLE IF NOT EXISTS docs (
21
+ id INTEGER PRIMARY KEY,
22
+ repo_id INTEGER NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
23
+ doc_type TEXT NOT NULL, -- readme | architecture | api
24
+ content TEXT NOT NULL, -- JSON
25
+ generated_at TEXT NOT NULL DEFAULT (datetime('now')),
26
+ UNIQUE(repo_id, doc_type)
27
+ );
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_docs_repo ON docs(repo_id);
30
+ """
31
+
32
+ def get_db(path: str) -> sqlite3.Connection:
33
+ con = sqlite3.connect(path)
34
+ con.row_factory = sqlite3.Row
35
+ con.executescript(_SCHEMA)
36
+ con.commit()
37
+ return con
38
+
39
+
40
+ def upsert_repo(db, owner: str, repo: str, info: dict, tree: list) -> int:
41
+ db.execute("""
42
+ INSERT INTO repos (owner, repo, info, tree)
43
+ VALUES (?, ?, ?, ?)
44
+ ON CONFLICT(owner, repo) DO UPDATE SET
45
+ info=excluded.info, tree=excluded.tree, created_at=datetime('now')
46
+ """, (owner, repo, json.dumps(info), json.dumps(tree)))
47
+ db.commit()
48
+ row = db.execute("SELECT id FROM repos WHERE owner=? AND repo=?",
49
+ (owner, repo)).fetchone()
50
+ return row["id"]
51
+
52
+
53
+ def upsert_doc(db, repo_id: int, doc_type: str, content: dict):
54
+ db.execute("""
55
+ INSERT INTO docs (repo_id, doc_type, content)
56
+ VALUES (?, ?, ?)
57
+ ON CONFLICT(repo_id, doc_type) DO UPDATE SET
58
+ content=excluded.content, generated_at=datetime('now')
59
+ """, (repo_id, doc_type, json.dumps(content)))
60
+ db.commit()
61
+
62
+
63
+ def get_docs(db, owner: str, repo: str) -> dict | None:
64
+ row = db.execute("SELECT id FROM repos WHERE owner=? AND repo=?",
65
+ (owner, repo)).fetchone()
66
+ if not row:
67
+ return None
68
+ docs = db.execute("SELECT doc_type, content FROM docs WHERE repo_id=?",
69
+ (row["id"],)).fetchall()
70
+ if not docs:
71
+ return None
72
+ result = {}
73
+ for d in docs:
74
+ result[d["doc_type"]] = json.loads(d["content"])
75
+ return result
76
+
77
+
78
+ def list_recent(db, limit: int = 10) -> list:
79
+ rows = db.execute("""
80
+ SELECT r.owner, r.repo, r.info, r.created_at,
81
+ COUNT(d.id) as doc_count
82
+ FROM repos r LEFT JOIN docs d ON d.repo_id = r.id
83
+ GROUP BY r.id ORDER BY r.created_at DESC LIMIT ?
84
+ """, (limit,)).fetchall()
85
+ return [{"owner": r["owner"], "repo": r["repo"],
86
+ "info": json.loads(r["info"]),
87
+ "created_at": r["created_at"], "doc_count": r["doc_count"]}
88
+ for r in rows]
app/tools/doc_forge/doc_generator.py ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI-powered documentation generator for DocForge."""
2
+ import json
3
+ from app.core.ai import call_ai, call_ai_json
4
+
5
+ _README_SYSTEM = """You are a senior technical writer and developer advocate.
6
+ Generate a comprehensive, beautiful GitHub README.md for the given repository.
7
+ Output ONLY the raw Markdown content — no JSON, no preamble, no code fences.
8
+ README must include: title with badges, description, features list, installation,
9
+ usage with examples, configuration, contributing guide, and license section.
10
+ Use real emoji sparingly. Make it genuinely useful, not generic."""
11
+
12
+ _README_META_SYSTEM = """You are a technical analyst. Given a repository description, return ONLY valid JSON.
13
+ Return a JSON object with EXACTLY these keys:
14
+ {"summary": "2-3 sentence plain English summary", "tech_stack": ["tech1", "tech2"], "key_features": ["feature 1", "feature 2"], "complexity": "beginner|intermediate|advanced"}
15
+ No markdown fences, no preamble."""
16
+
17
+ _ARCH_SYSTEM = """You are a software architect. Analyze the repository and write a clear architecture document.
18
+ Output ONLY raw Markdown — no JSON, no preamble, no code fences around the whole document.
19
+ Structure your response with these sections:
20
+ ## Architecture Overview
21
+ (2-3 paragraphs explaining the overall design)
22
+ ## Key Components
23
+ (bullet list: component name — file path — what it does)
24
+ ## Data Flow
25
+ (numbered steps describing how data moves through the system)
26
+ ## Mermaid Diagram
27
+ (a ```mermaid code block with a graph LR or flowchart diagram)"""
28
+
29
+ _ARCH_META_SYSTEM = """You are a technical analyst. Return ONLY valid JSON — no markdown, no preamble.
30
+ {"components": [{"name": "X", "role": "Y", "file": "path/to/file"}], "mermaid": "graph LR\\n A --> B"}"""
31
+
32
+ _API_SYSTEM = """You are a technical writer. Extract and document all API endpoints,
33
+ functions, and classes from the code files provided.
34
+ Output ONLY raw Markdown — no JSON, no preamble.
35
+ Structure with these sections:
36
+ ## API Endpoints
37
+ (table: Method | Path | Description | Returns)
38
+ ## Functions
39
+ (### FunctionName signature, then description and params as a bullet list)
40
+ ## Classes
41
+ (### ClassName, then description and method list)"""
42
+
43
+
44
+ def _build_context(repo_info: dict, tree: list[str], files: dict[str, str]) -> str:
45
+ ctx = f"Repository: {repo_info['full_name']}\n"
46
+ ctx += f"Description: {repo_info.get('description', 'No description')}\n"
47
+ ctx += f"Primary language: {repo_info.get('language', 'Unknown')}\n"
48
+ ctx += f"Stars: {repo_info.get('stars', 0)} Forks: {repo_info.get('forks', 0)}\n"
49
+ if repo_info.get("topics"):
50
+ ctx += f"Topics: {', '.join(repo_info['topics'])}\n"
51
+ ctx += f"\nFile tree ({len(tree)} files, showing first 30):\n"
52
+ ctx += "\n".join(f" {p}" for p in tree[:30])
53
+ ctx += "\n\nKey file contents:\n"
54
+ for path, content in list(files.items())[:5]:
55
+ ctx += f"\n--- {path} ---\n{content[:1500]}\n"
56
+ # Hard cap: Groq llama-3.1-8b has ~8k token context; keep prompt under ~12k chars
57
+ return ctx[:12000]
58
+
59
+
60
+ def generate_readme(repo_info: dict, tree: list[str],
61
+ files: dict[str, str], api_key_row=None) -> dict:
62
+ ctx = _build_context(repo_info, tree, files)
63
+ # Generate README as plain text (more reliable than embedding in JSON)
64
+ readme_text = call_ai(
65
+ [{"role": "user", "content": f"Generate a README.md for this repository:\n\n{ctx}"}],
66
+ system=_README_SYSTEM,
67
+ max_tokens=2048,
68
+ api_key_row=api_key_row,
69
+ )
70
+ # Generate metadata as simple JSON
71
+ try:
72
+ meta = call_ai_json(
73
+ [{"role": "user", "content": f"Analyze this repository and return metadata JSON:\n{ctx[:3000]}"}],
74
+ system=_README_META_SYSTEM,
75
+ max_tokens=512,
76
+ api_key_row=api_key_row,
77
+ )
78
+ if not isinstance(meta, dict):
79
+ meta = {}
80
+ except Exception:
81
+ meta = {}
82
+ return {
83
+ "readme": readme_text,
84
+ "summary": meta.get("summary", ""),
85
+ "tech_stack": meta.get("tech_stack", []),
86
+ "key_features": meta.get("key_features", []),
87
+ "complexity": meta.get("complexity", "intermediate"),
88
+ }
89
+
90
+
91
+ def generate_architecture(repo_info: dict, tree: list[str],
92
+ files: dict[str, str], api_key_row=None) -> dict:
93
+ ctx = _build_context(repo_info, tree, files)
94
+ overview_md = call_ai(
95
+ [{"role": "user", "content": f"Write an architecture document for this repository:\n\n{ctx}"}],
96
+ system=_ARCH_SYSTEM,
97
+ max_tokens=2048,
98
+ api_key_row=api_key_row,
99
+ )
100
+ try:
101
+ meta = call_ai_json(
102
+ [{"role": "user", "content": f"List the key components and a Mermaid diagram for this repo:\n{ctx[:3000]}"}],
103
+ system=_ARCH_META_SYSTEM,
104
+ max_tokens=1024,
105
+ api_key_row=api_key_row,
106
+ )
107
+ if not isinstance(meta, dict):
108
+ meta = {}
109
+ except Exception:
110
+ meta = {}
111
+ return {
112
+ "overview": overview_md,
113
+ "components": meta.get("components", []),
114
+ "mermaid": meta.get("mermaid", ""),
115
+ }
116
+
117
+
118
+ def generate_api_docs(repo_info: dict, tree: list[str],
119
+ files: dict[str, str], api_key_row=None) -> dict:
120
+ ctx = _build_context(repo_info, tree, files)
121
+ api_md = call_ai(
122
+ [{"role": "user", "content": f"Document the API, functions, and classes from this codebase:\n\n{ctx}"}],
123
+ system=_API_SYSTEM,
124
+ max_tokens=2048,
125
+ api_key_row=api_key_row,
126
+ )
127
+ return {"content": api_md}
app/tools/doc_forge/github_fetcher.py ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Fetches repository structure and key file contents from GitHub API."""
2
+ import os
3
+ import re
4
+ import requests
5
+
6
+ _GITHUB_API = "https://api.github.com"
7
+ _SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv",
8
+ "dist", "build", ".next", ".nuxt", "coverage", "htmlcov"}
9
+ _CODE_EXTS = {".py", ".js", ".ts", ".jsx", ".tsx", ".go", ".rs", ".java",
10
+ ".rb", ".php", ".cs", ".cpp", ".c", ".h", ".swift", ".kt"}
11
+ _DOC_EXTS = {".md", ".rst", ".txt", ".yaml", ".yml", ".toml", ".json"}
12
+ _PRIORITY = ["README.md", "readme.md", "README.rst", "main.py", "app.py",
13
+ "index.js", "index.ts", "main.go", "src/main.rs", "setup.py",
14
+ "pyproject.toml", "package.json", "go.mod", "Cargo.toml"]
15
+
16
+
17
+ def _headers():
18
+ token = os.environ.get("GITHUB_TOKEN", "")
19
+ h = {"Accept": "application/vnd.github.v3+json"}
20
+ if token:
21
+ h["Authorization"] = f"Bearer {token}"
22
+ return h
23
+
24
+
25
+ def parse_repo_url(url: str) -> tuple[str, str]:
26
+ """Return (owner, repo) from a GitHub URL or owner/repo string."""
27
+ url = url.strip().rstrip("/")
28
+ # Match github.com/owner/repo — ignore /tree/, /blob/, /issues/ etc.
29
+ m = re.search(r"github\.com/([^/]+)/([^/?#\s]+)", url)
30
+ if m:
31
+ repo = m.group(2)
32
+ if repo.endswith(".git"):
33
+ repo = repo[:-4]
34
+ return m.group(1), repo
35
+ # Plain "owner/repo" shorthand
36
+ parts = url.split("/")
37
+ if len(parts) == 2 and parts[0] and parts[1]:
38
+ return parts[0], parts[1]
39
+ raise ValueError(f"Cannot parse GitHub URL: {url!r}")
40
+
41
+
42
+ def get_repo_info(owner: str, repo: str) -> dict:
43
+ r = requests.get(f"{_GITHUB_API}/repos/{owner}/{repo}",
44
+ headers=_headers(), timeout=15)
45
+ r.raise_for_status()
46
+ d = r.json()
47
+ return {
48
+ "full_name": d.get("full_name", ""),
49
+ "description": d.get("description", ""),
50
+ "language": d.get("language", ""),
51
+ "stars": d.get("stargazers_count", 0),
52
+ "forks": d.get("forks_count", 0),
53
+ "topics": d.get("topics", []),
54
+ "default_branch": d.get("default_branch", "main"),
55
+ "url": d.get("html_url", ""),
56
+ }
57
+
58
+
59
+ def get_file_tree(owner: str, repo: str, branch: str = "main",
60
+ max_files: int = 150) -> list[str]:
61
+ """Return flat list of file paths, priority files first."""
62
+ r = requests.get(
63
+ f"{_GITHUB_API}/repos/{owner}/{repo}/git/trees/{branch}?recursive=1",
64
+ headers=_headers(), timeout=20)
65
+ if r.status_code == 404:
66
+ # Try main vs master
67
+ alt = "master" if branch == "main" else "main"
68
+ r = requests.get(
69
+ f"{_GITHUB_API}/repos/{owner}/{repo}/git/trees/{alt}?recursive=1",
70
+ headers=_headers(), timeout=20)
71
+ r.raise_for_status()
72
+ blobs = [item["path"] for item in r.json().get("tree", [])
73
+ if item["type"] == "blob"
74
+ and not any(seg in _SKIP_DIRS for seg in item["path"].split("/"))]
75
+
76
+ # Sort: priority first, then code, then docs, then rest
77
+ def rank(p):
78
+ name = p.split("/")[-1]
79
+ if p in _PRIORITY or name in _PRIORITY:
80
+ return 0
81
+ ext = os.path.splitext(p)[1].lower()
82
+ if ext in _CODE_EXTS:
83
+ return 1
84
+ if ext in _DOC_EXTS:
85
+ return 2
86
+ return 3
87
+
88
+ return sorted(blobs, key=rank)[:max_files]
89
+
90
+
91
+ def fetch_file(owner: str, repo: str, path: str, branch: str = "main") -> str:
92
+ """Fetch raw content of a single file (max 50KB)."""
93
+ r = requests.get(
94
+ f"https://raw.githubusercontent.com/{owner}/{repo}/{branch}/{path}",
95
+ headers=_headers(), timeout=15)
96
+ if r.status_code != 200:
97
+ return ""
98
+ text = r.text
99
+ return text[:50_000] # cap at 50KB per file
100
+
101
+
102
+ def fetch_key_files(owner: str, repo: str, tree: list[str],
103
+ branch: str = "main", max_chars: int = 60_000) -> dict[str, str]:
104
+ """Fetch the most important files up to max_chars total."""
105
+ results: dict[str, str] = {}
106
+ total = 0
107
+ # Always try priority files first
108
+ ordered = [p for p in _PRIORITY if p in tree] + [p for p in tree if p not in _PRIORITY]
109
+ for path in ordered:
110
+ if total >= max_chars:
111
+ break
112
+ content = fetch_file(owner, repo, path, branch)
113
+ if content:
114
+ results[path] = content
115
+ total += len(content)
116
+ return results
app/tools/doc_forge/routes.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DocForge routes — generate full documentation for any GitHub repo."""
2
+ import json
3
+ import os
4
+ from flask import (Blueprint, render_template, request, jsonify,
5
+ current_app, Response)
6
+ from .github_fetcher import (parse_repo_url, get_repo_info,
7
+ get_file_tree, fetch_key_files)
8
+ from .doc_generator import (generate_readme, generate_architecture,
9
+ generate_api_docs)
10
+ from .db import get_db, upsert_repo, upsert_doc, get_docs, list_recent
11
+
12
+ bp = Blueprint("doc_forge", __name__, template_folder="templates")
13
+
14
+
15
+ def _db():
16
+ db_path = os.path.join(os.path.dirname(current_app.root_path), "docforge.db")
17
+ return get_db(db_path)
18
+
19
+
20
+ # ── Pages ──────────────────────────────────────────────────────────────────────
21
+
22
+ @bp.route("/")
23
+ def index():
24
+ db = _db()
25
+ recent = list_recent(db)
26
+ return render_template("doc_forge/index.html", recent=recent)
27
+
28
+
29
+ @bp.route("/docs/<owner>/<repo>")
30
+ def docs_view(owner, repo):
31
+ db = _db()
32
+ data = get_docs(db, owner, repo)
33
+ if not data:
34
+ return render_template("doc_forge/index.html", recent=list_recent(db),
35
+ error=f"No docs found for {owner}/{repo}. Generate them first.")
36
+ return render_template("doc_forge/docs.html", owner=owner, repo=repo, data=data)
37
+
38
+
39
+ # ── API ────────────────────────────────────────────────────────────────────────
40
+
41
+ @bp.route("/api/analyze", methods=["POST"])
42
+ def analyze():
43
+ """Step 1: fetch repo metadata + file tree. Returns info without generating docs."""
44
+ body = request.get_json(silent=True) or {}
45
+ url = (body.get("url") or "").strip()
46
+ if not url:
47
+ return jsonify({"error": "repo_url is required"}), 400
48
+ try:
49
+ owner, repo = parse_repo_url(url)
50
+ info = get_repo_info(owner, repo)
51
+ tree = get_file_tree(owner, repo, info["default_branch"])
52
+ return jsonify({
53
+ "owner": owner, "repo": repo,
54
+ "info": info, "file_count": len(tree),
55
+ "tree_preview": tree[:20],
56
+ })
57
+ except Exception as exc:
58
+ return jsonify({"error": str(exc)}), 400
59
+
60
+
61
+ @bp.route("/api/generate", methods=["POST"])
62
+ def generate():
63
+ """Step 2: generate all docs for a repo."""
64
+ body = request.get_json(silent=True) or {}
65
+ url = (body.get("url") or "").strip()
66
+ types = body.get("types", ["readme", "architecture", "api"])
67
+ if not url:
68
+ return jsonify({"error": "url is required"}), 400
69
+ try:
70
+ owner, repo = parse_repo_url(url)
71
+ info = get_repo_info(owner, repo)
72
+ tree = get_file_tree(owner, repo, info["default_branch"])
73
+ files = fetch_key_files(owner, repo, tree, info["default_branch"])
74
+ db = _db()
75
+ repo_id = upsert_repo(db, owner, repo, info, tree)
76
+ results = {}
77
+
78
+ if "readme" in types:
79
+ readme = generate_readme(info, tree, files)
80
+ upsert_doc(db, repo_id, "readme", readme)
81
+ results["readme"] = readme
82
+
83
+ if "architecture" in types:
84
+ arch = generate_architecture(info, tree, files)
85
+ upsert_doc(db, repo_id, "architecture", arch)
86
+ results["architecture"] = arch
87
+
88
+ if "api" in types:
89
+ api_docs = generate_api_docs(info, tree, files)
90
+ upsert_doc(db, repo_id, "api", api_docs)
91
+ results["api"] = api_docs
92
+
93
+ return jsonify({"owner": owner, "repo": repo, "info": info, "docs": results})
94
+ except Exception as exc:
95
+ return jsonify({"error": str(exc)}), 500
96
+
97
+
98
+ @bp.route("/api/download/<owner>/<repo>/<doc_type>")
99
+ def download(owner, repo, doc_type):
100
+ """Download a generated doc as a file."""
101
+ db = _db()
102
+ data = get_docs(db, owner, repo)
103
+ if not data or doc_type not in data:
104
+ return jsonify({"error": "not found"}), 404
105
+ content = data[doc_type]
106
+ if doc_type == "readme":
107
+ text = content.get("readme", "")
108
+ filename = "README.md"
109
+ mime = "text/markdown"
110
+ else:
111
+ text = json.dumps(content, indent=2, ensure_ascii=False)
112
+ filename = f"{doc_type}.json"
113
+ mime = "application/json"
114
+ return Response(text, mimetype=mime,
115
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'})
116
+
117
+
118
+ @bp.route("/api/recent")
119
+ def recent():
120
+ db = _db()
121
+ return jsonify(list_recent(db))
app/tools/doc_forge/templates/doc_forge/docs.html ADDED
@@ -0,0 +1,417 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}{{ owner }}/{{ repo }} — DocForge{% endblock %}
4
+
5
+ {% block extra_head %}
6
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
7
+ <script src="https://cdn.jsdelivr.net/npm/dompurify@3/dist/purify.min.js"></script>
8
+ <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"></script>
9
+ <style>
10
+ body { padding-top: 0; }
11
+ .sidebar-link { display: flex; align-items: center; gap: 0.75rem; color: #424753; padding: 0.75rem 1rem; font-size: 0.875rem; transition: all 0.15s; border-radius: 0.5rem; }
12
+ .sidebar-link:hover { color: #171c22; background: rgba(0,0,0,0.04); }
13
+ .sidebar-link.active{ color: #0051ae; border-left: 3px solid #0051ae; padding-left: calc(1rem - 3px); background: rgba(0,81,174,0.08); border-radius: 0.5rem; }
14
+ .tab-btn { padding: 0.625rem 1.25rem; font-size: 0.875rem; font-weight: 600; color: #424753; border-bottom: 2px solid transparent; transition: all 0.15s; cursor: pointer; background: none; border-top: none; border-left: none; border-right: none; }
15
+ .tab-btn.active { color: #0051ae; border-bottom-color: #0051ae; }
16
+ .tab-btn:hover:not(.active) { color: #171c22; }
17
+ .tab-panel { display: none; }
18
+ .tab-panel.active { display: block; }
19
+ .prose-doc h1, .prose-doc h2, .prose-doc h3 { font-weight: 700; color: #171c22; margin: 1.5rem 0 0.75rem; }
20
+ .prose-doc h1 { font-size: 1.75rem; }
21
+ .prose-doc h2 { font-size: 1.375rem; }
22
+ .prose-doc h3 { font-size: 1.125rem; }
23
+ .prose-doc p { color: #424753; line-height: 1.75; margin-bottom: 1rem; }
24
+ .prose-doc ul { list-style: disc; padding-left: 1.5rem; margin-bottom: 1rem; color: #424753; }
25
+ .prose-doc ol { list-style: decimal; padding-left: 1.5rem; margin-bottom: 1rem; color: #424753; }
26
+ .prose-doc li { margin-bottom: 0.25rem; line-height: 1.75; }
27
+ .prose-doc code { background: #eaeef6; padding: 0.125rem 0.375rem; border-radius: 0.25rem; font-family: 'JetBrains Mono', monospace; font-size: 0.8125rem; color: #0051ae; }
28
+ .prose-doc pre { background: #2c3137; color: #e2e8f0; padding: 1.25rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; font-family: 'JetBrains Mono', monospace; font-size: 0.8125rem; line-height: 1.6; }
29
+ .prose-doc pre code { background: none; color: inherit; padding: 0; }
30
+ .prose-doc blockquote { border-left: 3px solid #0051ae; padding-left: 1rem; color: #424753; font-style: italic; margin: 1rem 0; }
31
+ .prose-doc a { color: #0051ae; text-decoration: underline; text-underline-offset: 2px; }
32
+ .prose-doc table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
33
+ .prose-doc th { background: #e4e8f0; padding: 0.75rem 1rem; text-align: left; font-size: 0.875rem; font-weight: 700; color: #171c22; }
34
+ .prose-doc td { padding: 0.75rem 1rem; font-size: 0.875rem; color: #424753; border-bottom: 1px solid #f0f4fc; }
35
+ .mermaid { background: #f7f9ff; padding: 1.5rem; border-radius: 0.5rem; text-align: center; }
36
+ </style>
37
+ {% endblock %}
38
+
39
+ {% block content %}
40
+ <div class="flex h-screen overflow-hidden pt-12">
41
+
42
+ {# ── Sidebar ───────────────────────────────────────────────────────────────── #}
43
+ <aside class="w-64 bg-surface-container-low border-r border-outline-variant/40 flex-shrink-0 flex flex-col overflow-y-auto">
44
+ <div class="px-5 py-5 border-b border-outline-variant/40">
45
+ <div class="flex items-center gap-3">
46
+ <div class="w-10 h-10 bg-primary rounded-lg flex items-center justify-center text-white font-bold text-lg">
47
+ {{ repo[0].upper() }}
48
+ </div>
49
+ <div class="min-w-0">
50
+ <h3 class="text-on-surface text-sm font-semibold leading-tight truncate">{{ repo }}</h3>
51
+ <p class="text-on-surface-variant text-xs font-mono truncate">{{ owner }}</p>
52
+ </div>
53
+ </div>
54
+ </div>
55
+
56
+ <nav class="flex-1 px-2 py-4 space-y-0.5">
57
+ <a href="/" class="sidebar-link">
58
+ <span class="material-symbols-outlined text-[20px]">home</span>
59
+ <span>Home</span>
60
+ </a>
61
+ {% if data.readme %}
62
+ <a href="#" onclick="switchTab('readme'); return false;" class="sidebar-link active" id="nav-readme">
63
+ <span class="material-symbols-outlined text-[20px]">description</span>
64
+ <span>README</span>
65
+ </a>
66
+ {% endif %}
67
+ {% if data.architecture %}
68
+ <a href="#" onclick="switchTab('architecture'); return false;" class="sidebar-link" id="nav-architecture">
69
+ <span class="material-symbols-outlined text-[20px]">account_tree</span>
70
+ <span>Architecture</span>
71
+ </a>
72
+ {% endif %}
73
+ {% if data.api %}
74
+ <a href="#" onclick="switchTab('api'); return false;" class="sidebar-link" id="nav-api">
75
+ <span class="material-symbols-outlined text-[20px]">api</span>
76
+ <span>API Docs</span>
77
+ </a>
78
+ {% endif %}
79
+ </nav>
80
+
81
+ <div class="p-4 border-t border-outline-variant/40 space-y-1">
82
+ <a href="https://github.com/{{ owner }}/{{ repo }}" target="_blank"
83
+ class="flex items-center gap-2 text-on-surface-variant hover:text-primary text-xs py-2 px-2 transition-colors rounded-lg">
84
+ <span class="material-symbols-outlined text-base">open_in_new</span>
85
+ View on GitHub
86
+ </a>
87
+ <a href="/" class="flex items-center gap-2 text-on-surface-variant hover:text-primary text-xs py-2 px-2 transition-colors rounded-lg">
88
+ <span class="material-symbols-outlined text-base">add</span>
89
+ New Repository
90
+ </a>
91
+ </div>
92
+ </aside>
93
+
94
+ {# ── Main Content ──────────────────────────────────────────────────────────── #}
95
+ <main class="flex-1 overflow-y-auto bg-background">
96
+ <div class="max-w-4xl mx-auto p-8">
97
+
98
+ {# Breadcrumb #}
99
+ <nav class="flex items-center gap-2 text-xs font-mono text-on-surface-variant mb-4">
100
+ <a href="/" class="hover:text-primary transition-colors">docforge</a>
101
+ <span class="material-symbols-outlined text-[12px]">chevron_right</span>
102
+ <span>{{ owner }}</span>
103
+ <span class="material-symbols-outlined text-[12px]">chevron_right</span>
104
+ <span class="text-primary font-bold">{{ repo }}</span>
105
+ </nav>
106
+
107
+ {# Page header #}
108
+ <header class="flex justify-between items-start mb-8 gap-4 flex-wrap">
109
+ <div>
110
+ <h1 class="text-3xl font-extrabold text-on-surface tracking-tight">{{ owner }}/{{ repo }}</h1>
111
+ {% if data.readme and data.readme.summary %}
112
+ <p class="text-on-surface-variant mt-2 max-w-2xl">{{ data.readme.summary }}</p>
113
+ {% endif %}
114
+ <div class="flex flex-wrap gap-2 mt-3">
115
+ {% if data.readme and data.readme.tech_stack %}
116
+ {% for tech in data.readme.tech_stack[:6] %}
117
+ <span class="px-2 py-0.5 rounded bg-secondary-container text-on-secondary-container text-[11px] font-mono">{{ tech }}</span>
118
+ {% endfor %}
119
+ {% endif %}
120
+ {% if data.readme and data.readme.complexity %}
121
+ <span class="px-2 py-0.5 rounded bg-tertiary-fixed text-on-tertiary-fixed text-[11px] font-mono">{{ data.readme.complexity }}</span>
122
+ {% endif %}
123
+ </div>
124
+ </div>
125
+ <div class="flex gap-2 flex-shrink-0">
126
+ <button onclick="copyCurrentTab()"
127
+ class="bg-surface-container-high text-on-surface px-4 py-2 rounded-lg text-sm font-medium flex items-center gap-2 hover:bg-surface-container-highest transition-colors cursor-pointer">
128
+ <span id="copy-icon" class="material-symbols-outlined text-lg">content_copy</span>
129
+ Copy
130
+ </button>
131
+ <button onclick="downloadCurrentTab()"
132
+ class="bg-gradient-to-r from-primary to-primary-container text-on-primary px-5 py-2 rounded-lg text-sm font-semibold flex items-center gap-2 shadow-lg shadow-primary/20 hover:-translate-y-0.5 transition-all cursor-pointer">
133
+ <span class="material-symbols-outlined text-lg">download</span>
134
+ Download
135
+ </button>
136
+ </div>
137
+ </header>
138
+
139
+ {# Tabs #}
140
+ <div class="flex gap-0 border-b border-outline-variant/30 mb-8">
141
+ {% if data.readme %}
142
+ <button class="tab-btn active" id="tab-readme" onclick="switchTab('readme')">README</button>
143
+ {% endif %}
144
+ {% if data.architecture %}
145
+ <button class="tab-btn" id="tab-architecture" onclick="switchTab('architecture')">Architecture</button>
146
+ {% endif %}
147
+ {% if data.api %}
148
+ <button class="tab-btn" id="tab-api" onclick="switchTab('api')">API Docs</button>
149
+ {% endif %}
150
+ </div>
151
+
152
+ {# ── README Panel ──────────────────────────────────────────────────────── #}
153
+ {% if data.readme %}
154
+ <div class="tab-panel active" id="panel-readme">
155
+ <div class="bg-surface-container-lowest rounded-xl p-8">
156
+ <div class="prose-doc" id="readme-content"></div>
157
+ </div>
158
+ {% if data.readme.key_features %}
159
+ <div class="mt-6 grid grid-cols-1 sm:grid-cols-2 gap-4">
160
+ {% for feat in data.readme.key_features[:4] %}
161
+ <div class="bg-surface-container-lowest p-5 rounded-xl flex items-start gap-3">
162
+ <span class="material-symbols-outlined text-primary text-xl mt-0.5 flex-shrink-0">check_circle</span>
163
+ <p class="text-sm text-on-surface-variant">{{ feat | e }}</p>
164
+ </div>
165
+ {% endfor %}
166
+ </div>
167
+ {% endif %}
168
+ </div>
169
+ {% endif %}
170
+
171
+ {# ── Architecture Panel ────────────────────────────────────────────────── #}
172
+ {% if data.architecture %}
173
+ <div class="tab-panel" id="panel-architecture">
174
+ {% if data.architecture.overview %}
175
+ <div class="bg-surface-container-lowest rounded-xl p-8 mb-6">
176
+ <h2 class="text-xl font-bold text-on-surface mb-4 flex items-center gap-3">
177
+ <span class="w-1.5 h-5 bg-primary rounded-full flex-shrink-0"></span>
178
+ Overview
179
+ </h2>
180
+ <div id="arch-overview" class="prose-doc"></div>
181
+ <script>document.getElementById('arch-overview').innerHTML = DOMPurify.sanitize(marked.parse({{ data.architecture.overview | tojson }}));</script>
182
+ </div>
183
+ {% endif %}
184
+
185
+ {% if data.architecture.mermaid %}
186
+ <div class="bg-surface-container-lowest rounded-xl p-8 mb-6">
187
+ <h2 class="text-xl font-bold text-on-surface mb-6 flex items-center gap-3">
188
+ <span class="w-1.5 h-5 bg-primary-container rounded-full flex-shrink-0"></span>
189
+ System Diagram
190
+ </h2>
191
+ <div class="mermaid">{{ data.architecture.mermaid | e }}</div>
192
+ </div>
193
+ {% endif %}
194
+
195
+ {% if data.architecture.components %}
196
+ <div class="bg-surface-container-lowest rounded-xl p-8 mb-6">
197
+ <h2 class="text-xl font-bold text-on-surface mb-6 flex items-center gap-3">
198
+ <span class="w-1.5 h-5 bg-tertiary rounded-full flex-shrink-0"></span>
199
+ Components
200
+ </h2>
201
+ <div class="space-y-4">
202
+ {% for comp in data.architecture.components %}
203
+ <div class="bg-surface-container-low p-5 rounded-xl">
204
+ <h3 class="font-bold text-on-surface text-sm mb-1 font-mono">
205
+ {{ (comp.name if comp is mapping else comp) | e }}
206
+ </h3>
207
+ {% if comp is mapping and comp.description %}
208
+ <p class="text-sm text-on-surface-variant">{{ comp.description | e }}</p>
209
+ {% endif %}
210
+ {% if comp is mapping and comp.responsibilities %}
211
+ <ul class="mt-2 space-y-1">
212
+ {% for r in comp.responsibilities %}
213
+ <li class="text-xs text-on-surface-variant flex items-start gap-2">
214
+ <span class="material-symbols-outlined text-outline text-sm mt-0.5 flex-shrink-0">arrow_right</span>
215
+ {{ r | e }}
216
+ </li>
217
+ {% endfor %}
218
+ </ul>
219
+ {% endif %}
220
+ </div>
221
+ {% endfor %}
222
+ </div>
223
+ </div>
224
+ {% endif %}
225
+
226
+ {% if data.architecture.data_flow %}
227
+ <div class="bg-surface-container-lowest rounded-xl p-8">
228
+ <h2 class="text-xl font-bold text-on-surface mb-4 flex items-center gap-3">
229
+ <span class="w-1.5 h-5 bg-secondary rounded-full flex-shrink-0"></span>
230
+ Data Flow
231
+ </h2>
232
+ <p class="text-on-surface-variant leading-relaxed">{{ data.architecture.data_flow | e }}</p>
233
+ </div>
234
+ {% endif %}
235
+ </div>
236
+ {% endif %}
237
+
238
+ {# ── API Panel ────────────────────────────────────────────────────────── #}
239
+ {% if data.api %}
240
+ <div class="tab-panel" id="panel-api">
241
+
242
+ {% if data.api.endpoints %}
243
+ <div class="bg-surface-container-lowest rounded-xl p-8 mb-6">
244
+ <h2 class="text-xl font-bold text-on-surface mb-6 flex items-center gap-3">
245
+ <span class="w-1.5 h-5 bg-primary rounded-full flex-shrink-0"></span>
246
+ Endpoints
247
+ </h2>
248
+ <div class="overflow-hidden rounded-xl bg-surface-container-low">
249
+ <table class="w-full text-left">
250
+ <thead class="bg-surface-container-high">
251
+ <tr>
252
+ <th class="px-5 py-3 text-xs font-bold text-on-surface uppercase tracking-wide">Method</th>
253
+ <th class="px-5 py-3 text-xs font-bold text-on-surface uppercase tracking-wide">Path</th>
254
+ <th class="px-5 py-3 text-xs font-bold text-on-surface uppercase tracking-wide">Description</th>
255
+ </tr>
256
+ </thead>
257
+ <tbody>
258
+ {% for ep in data.api.endpoints %}
259
+ {% set method = ep.method if ep is mapping else 'GET' %}
260
+ <tr class="hover:bg-surface-container-highest transition-colors">
261
+ <td class="px-5 py-3">
262
+ <span class="font-mono text-xs font-bold
263
+ {% if method == 'GET' %}text-green-600
264
+ {% elif method == 'POST' %}text-blue-600
265
+ {% elif method in ('PUT','PATCH') %}text-purple-600
266
+ {% elif method == 'DELETE' %}text-red-600
267
+ {% else %}text-on-surface{% endif %}">
268
+ {{ method | e }}
269
+ </span>
270
+ </td>
271
+ <td class="px-5 py-3 font-mono text-xs text-on-surface">
272
+ {{ (ep.path if ep is mapping else ep) | e }}
273
+ </td>
274
+ <td class="px-5 py-3 text-sm text-on-surface-variant">
275
+ {{ (ep.description if ep is mapping else '') | e }}
276
+ </td>
277
+ </tr>
278
+ {% endfor %}
279
+ </tbody>
280
+ </table>
281
+ </div>
282
+ </div>
283
+ {% endif %}
284
+
285
+ {% if data.api.functions %}
286
+ <div class="bg-surface-container-lowest rounded-xl p-8 mb-6">
287
+ <h2 class="text-xl font-bold text-on-surface mb-6 flex items-center gap-3">
288
+ <span class="w-1.5 h-5 bg-primary-container rounded-full flex-shrink-0"></span>
289
+ Functions
290
+ </h2>
291
+ <div class="space-y-4">
292
+ {% for fn in data.api.functions %}
293
+ <div class="bg-surface-container-low p-5 rounded-xl">
294
+ <p class="font-mono text-sm font-bold text-on-surface mb-1">
295
+ {{ (fn.name if fn is mapping else fn) | e }}{% if fn is mapping and fn.signature %}({{ fn.signature | e }}){% endif %}
296
+ </p>
297
+ {% if fn is mapping and fn.description %}
298
+ <p class="text-sm text-on-surface-variant">{{ fn.description | e }}</p>
299
+ {% endif %}
300
+ {% if fn is mapping and fn.returns %}
301
+ <p class="text-xs text-outline mt-2 font-mono">→ {{ fn.returns | e }}</p>
302
+ {% endif %}
303
+ </div>
304
+ {% endfor %}
305
+ </div>
306
+ </div>
307
+ {% endif %}
308
+
309
+ {% if data.api.classes %}
310
+ <div class="bg-surface-container-lowest rounded-xl p-8">
311
+ <h2 class="text-xl font-bold text-on-surface mb-6 flex items-center gap-3">
312
+ <span class="w-1.5 h-5 bg-tertiary rounded-full flex-shrink-0"></span>
313
+ Classes
314
+ </h2>
315
+ <div class="space-y-4">
316
+ {% for cls in data.api.classes %}
317
+ <div class="bg-surface-container-low p-5 rounded-xl">
318
+ <p class="font-mono text-sm font-bold text-on-surface mb-1">
319
+ class {{ (cls.name if cls is mapping else cls) | e }}
320
+ </p>
321
+ {% if cls is mapping and cls.description %}
322
+ <p class="text-sm text-on-surface-variant">{{ cls.description | e }}</p>
323
+ {% endif %}
324
+ </div>
325
+ {% endfor %}
326
+ </div>
327
+ </div>
328
+ {% endif %}
329
+
330
+ {% if not data.api.endpoints and not data.api.functions and not data.api.classes %}
331
+ {% if data.api.content %}
332
+ <div class="bg-surface-container-lowest rounded-xl p-8">
333
+ <div id="api-md-content" class="prose-doc"></div>
334
+ </div>
335
+ <script>
336
+ document.getElementById('api-md-content').innerHTML =
337
+ DOMPurify.sanitize(marked.parse({{ data.api.content | tojson }}));
338
+ </script>
339
+ {% else %}
340
+ <div class="bg-surface-container-lowest rounded-xl p-8 text-center">
341
+ <span class="material-symbols-outlined text-outline text-4xl">api</span>
342
+ <p class="text-on-surface-variant mt-4">No API elements detected in this repository.</p>
343
+ </div>
344
+ {% endif %}
345
+ {% endif %}
346
+ </div>
347
+ {% endif %}
348
+
349
+ <div class="mt-12 pt-8 flex justify-between items-center text-sm text-on-surface-variant">
350
+ <a href="/" class="flex items-center gap-2 hover:text-primary transition-colors">
351
+ <span class="material-symbols-outlined text-base">arrow_back</span>
352
+ Back to DocForge
353
+ </a>
354
+ <a href="https://github.com/{{ owner }}/{{ repo }}" target="_blank"
355
+ class="flex items-center gap-2 hover:text-primary transition-colors">
356
+ View on GitHub
357
+ <span class="material-symbols-outlined text-base">open_in_new</span>
358
+ </a>
359
+ </div>
360
+ </div>
361
+ </main>
362
+ </div>
363
+ {% endblock %}
364
+
365
+ {% block extra_scripts %}
366
+ <script>
367
+ mermaid.initialize({ startOnLoad: true, theme: 'neutral', securityLevel: 'loose' });
368
+
369
+ let _currentTab = '{% if data.readme %}readme{% elif data.architecture %}architecture{% else %}api{% endif %}';
370
+
371
+ // Render README via marked + DOMPurify
372
+ (function renderReadme() {
373
+ const el = document.getElementById('readme-content');
374
+ if (!el) return;
375
+ const raw = {{ (data.readme.readme if data.readme else '') | tojson }};
376
+ const html = DOMPurify.sanitize(marked.parse(raw));
377
+ el.setHTML ? el.setHTML(html) : (el.textContent = '', el.insertAdjacentHTML('afterbegin', DOMPurify.sanitize(html)));
378
+ })();
379
+
380
+ function switchTab(name) {
381
+ document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
382
+ const panel = document.getElementById('panel-' + name);
383
+ if (panel) panel.classList.add('active');
384
+
385
+ document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
386
+ const btn = document.getElementById('tab-' + name);
387
+ if (btn) btn.classList.add('active');
388
+
389
+ document.querySelectorAll('.sidebar-link').forEach(l => l.classList.remove('active'));
390
+ const nav = document.getElementById('nav-' + name);
391
+ if (nav) nav.classList.add('active');
392
+
393
+ _currentTab = name;
394
+ if (name === 'architecture') mermaid.run({ querySelector: '.mermaid' });
395
+ }
396
+
397
+ function copyCurrentTab() {
398
+ let text = '';
399
+ if (_currentTab === 'readme') {
400
+ text = {{ (data.readme.readme if data.readme else '') | tojson }};
401
+ } else if (_currentTab === 'architecture') {
402
+ text = JSON.stringify({{ (data.architecture or {}) | tojson }}, null, 2);
403
+ } else if (_currentTab === 'api') {
404
+ text = JSON.stringify({{ (data.api or {}) | tojson }}, null, 2);
405
+ }
406
+ navigator.clipboard.writeText(text).then(() => {
407
+ const icon = document.getElementById('copy-icon');
408
+ icon.textContent = 'check';
409
+ setTimeout(() => { icon.textContent = 'content_copy'; }, 1800);
410
+ });
411
+ }
412
+
413
+ function downloadCurrentTab() {
414
+ window.location.href = '/api/download/{{ owner }}/{{ repo }}/' + _currentTab;
415
+ }
416
+ </script>
417
+ {% endblock %}
app/tools/doc_forge/templates/doc_forge/index.html ADDED
@@ -0,0 +1,430 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+
3
+ {% block title %}DocForge — AI Documentation Engine{% endblock %}
4
+
5
+ {% block extra_head %}
6
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
7
+ {% endblock %}
8
+
9
+ {% block content %}
10
+ <main class="pt-16 pb-20 overflow-x-hidden">
11
+
12
+ {# ── Error Banner ──────────────────────────────────────────────────────────── #}
13
+ {% if error %}
14
+ <div class="max-w-7xl mx-auto px-6 pt-6">
15
+ <div class="flex items-center gap-3 p-4 bg-error-container rounded-xl text-on-error-container text-sm font-medium">
16
+ <span class="material-symbols-outlined text-error">error</span>
17
+ {{ error }}
18
+ </div>
19
+ </div>
20
+ {% endif %}
21
+
22
+ {# ── Hero ──────────────────────────────────────────────────────────────────── #}
23
+ <section class="max-w-7xl mx-auto px-6 py-16 lg:py-24 flex flex-col items-center text-center">
24
+ <div class="mb-4 inline-flex items-center gap-2 px-3 py-1 rounded-full bg-secondary-container text-on-secondary-container text-xs font-semibold tracking-wide">
25
+ <span class="material-symbols-outlined text-base">auto_awesome</span>
26
+ AI-POWERED DOCUMENTATION ENGINE
27
+ </div>
28
+ <h1 class="text-5xl lg:text-7xl font-extrabold tracking-tighter text-on-surface mb-6 max-w-4xl leading-[1.1]">
29
+ Document any GitHub repo
30
+ <span class="bg-gradient-to-r from-primary to-primary-container bg-clip-text text-transparent">instantly.</span>
31
+ </h1>
32
+ <p class="text-lg text-on-surface-variant max-w-2xl mb-12 font-medium">
33
+ Transform complex codebases into human-readable READMEs, architecture maps, and API references in seconds. Just paste a URL.
34
+ </p>
35
+
36
+ {# ── URL Input ─────────────────────────────────────────────────────────────── #}
37
+ <div id="url-cluster" class="w-full max-w-2xl p-2 bg-surface-container-lowest rounded-xl shadow-2xl shadow-primary/5 flex flex-col sm:flex-row gap-2 transition-all focus-within:ring-4 focus-within:ring-primary/10">
38
+ <div class="flex-1 flex items-center px-4 gap-3">
39
+ <span class="material-symbols-outlined text-outline">link</span>
40
+ <input id="repo-url"
41
+ class="w-full bg-transparent border-none focus:ring-0 font-mono text-sm placeholder:text-outline/50 py-3 outline-none"
42
+ placeholder="https://github.com/username/repository"
43
+ type="text"
44
+ autocomplete="off"/>
45
+ </div>
46
+ <button id="analyze-btn"
47
+ onclick="analyzeRepo()"
48
+ class="bg-gradient-to-b from-primary to-primary-container text-on-primary px-8 py-3 rounded-lg font-bold text-sm tracking-tight hover:brightness-110 active:scale-[0.98] transition-all shadow-lg shadow-primary/20 flex items-center gap-2 justify-center">
49
+ <span id="analyze-icon" class="material-symbols-outlined text-base">search</span>
50
+ <span id="analyze-label">Analyze</span>
51
+ </button>
52
+ </div>
53
+ <div class="mt-6 flex flex-wrap justify-center items-center gap-6 text-xs font-mono text-outline uppercase tracking-widest">
54
+ <div class="flex items-center gap-2"><span class="material-symbols-outlined text-sm">check_circle</span> No Login Required</div>
55
+ <div class="flex items-center gap-2"><span class="material-symbols-outlined text-sm">check_circle</span> Instant Results</div>
56
+ <div class="flex items-center gap-2"><span class="material-symbols-outlined text-sm">check_circle</span> Free &amp; Open Source</div>
57
+ </div>
58
+
59
+ {# ── Repo Preview Card (hidden until analyze) ──────────────────────────────── #}
60
+ <div id="repo-card" class="hidden w-full max-w-2xl mt-8 bg-surface-container-lowest rounded-xl p-6 text-left shadow-sm">
61
+ <div class="flex items-start justify-between gap-4 mb-6">
62
+ <div>
63
+ <a id="rc-link" href="#" target="_blank" class="text-xl font-bold text-on-surface hover:text-primary transition-colors font-mono"><span id="rc-name"></span></a>
64
+ <p id="rc-desc" class="text-sm text-on-surface-variant mt-1 leading-relaxed"></p>
65
+ </div>
66
+ <div id="rc-lang-badge" class="px-2 py-1 rounded bg-secondary-container text-on-secondary-container text-xs font-mono whitespace-nowrap"></div>
67
+ </div>
68
+ <div class="flex items-center gap-6 text-sm text-on-surface-variant mb-6">
69
+ <span class="flex items-center gap-1.5"><span class="material-symbols-outlined text-base">star</span> <span id="rc-stars"></span></span>
70
+ <span class="flex items-center gap-1.5"><span class="material-symbols-outlined text-base">fork_right</span> <span id="rc-forks"></span></span>
71
+ <span class="flex items-center gap-1.5"><span class="material-symbols-outlined text-base">folder</span> <span id="rc-files"></span> files</span>
72
+ </div>
73
+
74
+ {# Doc type checkboxes #}
75
+ <p class="text-xs font-bold uppercase tracking-widest text-on-surface-variant mb-3">Generate</p>
76
+ <div class="flex gap-3 mb-6 flex-wrap">
77
+ <label class="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container cursor-pointer hover:bg-surface-container-high transition-colors text-sm font-medium">
78
+ <input type="checkbox" id="chk-readme" checked class="accent-primary rounded"> README
79
+ </label>
80
+ <label class="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container cursor-pointer hover:bg-surface-container-high transition-colors text-sm font-medium">
81
+ <input type="checkbox" id="chk-architecture" checked class="accent-primary rounded"> Architecture
82
+ </label>
83
+ <label class="flex items-center gap-2 px-4 py-2 rounded-lg bg-surface-container cursor-pointer hover:bg-surface-container-high transition-colors text-sm font-medium">
84
+ <input type="checkbox" id="chk-api" checked class="accent-primary rounded"> API Docs
85
+ </label>
86
+ </div>
87
+
88
+ <button id="generate-btn"
89
+ onclick="generateDocs()"
90
+ class="w-full bg-gradient-to-b from-primary to-primary-container text-on-primary py-3 rounded-lg font-bold text-sm tracking-tight hover:brightness-110 active:scale-[0.98] transition-all shadow-lg shadow-primary/20 flex items-center justify-center gap-2">
91
+ <span class="material-symbols-outlined text-base">auto_awesome</span>
92
+ Generate Documentation
93
+ </button>
94
+ </div>
95
+
96
+ {# ── Progress indicator (hidden until generating) ─────────────────────────── #}
97
+ <div id="progress-card" class="hidden w-full max-w-2xl mt-8 bg-surface-container-lowest rounded-xl p-8 text-center">
98
+ <div class="flex flex-col items-center gap-4">
99
+ <div class="w-12 h-12 rounded-xl bg-primary-fixed flex items-center justify-center animate-pulse">
100
+ <span class="material-symbols-outlined text-primary text-2xl">psychology</span>
101
+ </div>
102
+ <p class="font-bold text-on-surface">Analyzing codebase…</p>
103
+ <p id="progress-msg" class="text-sm text-on-surface-variant font-mono">Fetching repository structure</p>
104
+ <div class="w-full bg-surface-container-high rounded-full h-1.5 overflow-hidden">
105
+ <div id="progress-bar" class="h-full bg-gradient-to-r from-primary to-primary-container rounded-full transition-all duration-500" style="width: 0%"></div>
106
+ </div>
107
+ </div>
108
+ </div>
109
+ </section>
110
+
111
+ {# ── Bento: Features ──────────────────────────────────────────────────────── #}
112
+ <section class="max-w-7xl mx-auto px-6 py-12">
113
+ <div class="grid grid-cols-1 md:grid-cols-3 grid-rows-2 gap-4 md:h-[560px]">
114
+
115
+ {# Main card #}
116
+ <div class="md:col-span-2 md:row-span-2 bg-surface-container-lowest p-10 rounded-xl relative overflow-hidden group">
117
+ <div class="relative z-10 h-full flex flex-col">
118
+ <div class="w-12 h-12 bg-primary-fixed rounded-lg flex items-center justify-center mb-6">
119
+ <span class="material-symbols-outlined text-primary">psychology</span>
120
+ </div>
121
+ <h3 class="text-3xl font-bold tracking-tight mb-4 text-on-surface">Understand any codebase in seconds.</h3>
122
+ <p class="text-on-surface-variant max-w-sm mb-8 font-medium">
123
+ DocForge parses file trees, function relationships, and logic flows to explain
124
+ <span class="italic text-on-surface">why</span> code works — not just what it does.
125
+ </p>
126
+ <div class="mt-auto pt-8 flex items-center justify-between">
127
+ <div class="flex items-center gap-2 text-xs font-mono text-outline">
128
+ <span class="material-symbols-outlined text-sm">token</span>
129
+ Multi-provider AI backbone
130
+ </div>
131
+ </div>
132
+ </div>
133
+ <div class="absolute right-0 bottom-0 w-2/3 h-2/3 opacity-30 group-hover:opacity-50 transition-opacity">
134
+ <div class="bg-gradient-to-br from-primary/20 to-transparent w-full h-full rounded-tl-[100px]"></div>
135
+ </div>
136
+ <div class="absolute top-10 right-10 flex flex-col gap-2 scale-75 opacity-40 group-hover:opacity-90 transition-all group-hover:scale-100 origin-top-right">
137
+ <div class="bg-surface-container-highest px-3 py-1.5 rounded-lg text-[10px] font-mono">PARSING TREE... [DONE]</div>
138
+ <div class="bg-surface-container-highest px-3 py-1.5 rounded-lg text-[10px] font-mono">MAPPING DEPS... [DONE]</div>
139
+ <div class="bg-surface-container-highest px-3 py-1.5 rounded-lg text-[10px] font-mono">GENERATING DOCS... [OK]</div>
140
+ </div>
141
+ </div>
142
+
143
+ {# Architecture card #}
144
+ <div class="bg-surface-container-low p-8 rounded-xl flex flex-col justify-between">
145
+ <div>
146
+ <div class="w-10 h-10 bg-tertiary-fixed rounded-lg flex items-center justify-center mb-4">
147
+ <span class="material-symbols-outlined text-tertiary">account_tree</span>
148
+ </div>
149
+ <h4 class="text-xl font-bold tracking-tight mb-2">Visual Architecture Maps.</h4>
150
+ <p class="text-sm text-on-surface-variant leading-relaxed">
151
+ Auto-generated Mermaid diagrams show how data flows through the system.
152
+ </p>
153
+ </div>
154
+ <div class="mt-6 p-3 bg-surface-container-lowest rounded-lg flex items-center gap-3">
155
+ <span class="material-symbols-outlined text-primary text-sm">schema</span>
156
+ <span class="text-[10px] font-mono uppercase tracking-widest text-outline">Mermaid · Flowchart · ER</span>
157
+ </div>
158
+ </div>
159
+
160
+ {# Export card #}
161
+ <div class="bg-inverse-surface p-8 rounded-xl flex flex-col justify-between">
162
+ <div>
163
+ <div class="w-10 h-10 bg-slate-700 rounded-lg flex items-center justify-center mb-4">
164
+ <span class="material-symbols-outlined text-white">data_object</span>
165
+ </div>
166
+ <h4 class="text-xl font-bold tracking-tight text-white mb-2">Export Ready.</h4>
167
+ <p class="text-sm text-slate-400 leading-relaxed">
168
+ Download README.md, architecture.json, or api.json directly to your project.
169
+ </p>
170
+ </div>
171
+ <div class="mt-4 flex gap-2 items-center">
172
+ <div class="flex-1 h-1 bg-slate-700 rounded-full overflow-hidden">
173
+ <div class="w-[85%] h-full bg-primary rounded-full"></div>
174
+ </div>
175
+ <span class="text-[10px] font-mono text-slate-500">MD · JSON</span>
176
+ </div>
177
+ </div>
178
+ </div>
179
+ </section>
180
+
181
+ {# ── Feature Spotlight ────────────────────────────────────────────────────── #}
182
+ <section class="max-w-7xl mx-auto px-6 py-20">
183
+ <div class="flex flex-col lg:flex-row items-center gap-16">
184
+ <div class="w-full lg:w-1/2">
185
+ <h2 class="text-4xl font-extrabold tracking-tighter mb-6 text-on-surface">The complete technical ledger for any project.</h2>
186
+ <div class="space-y-8">
187
+ <div class="flex gap-4">
188
+ <div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
189
+ <span class="material-symbols-outlined text-sm text-primary">description</span>
190
+ </div>
191
+ <div>
192
+ <h5 class="font-bold text-on-surface">AI-Written README</h5>
193
+ <p class="text-sm text-on-surface-variant">A complete, professional README with setup instructions, features, and badges — generated from your code.</p>
194
+ </div>
195
+ </div>
196
+ <div class="flex gap-4">
197
+ <div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
198
+ <span class="material-symbols-outlined text-sm text-primary">account_tree</span>
199
+ </div>
200
+ <div>
201
+ <h5 class="font-bold text-on-surface">Architecture Blueprint</h5>
202
+ <p class="text-sm text-on-surface-variant">Component maps, data flow diagrams, and a Mermaid chart you can drop straight into documentation.</p>
203
+ </div>
204
+ </div>
205
+ <div class="flex gap-4">
206
+ <div class="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
207
+ <span class="material-symbols-outlined text-sm text-primary">api</span>
208
+ </div>
209
+ <div>
210
+ <h5 class="font-bold text-on-surface">API Reference</h5>
211
+ <p class="text-sm text-on-surface-variant">Auto-detected endpoints, functions, and classes with parameters and return types documented.</p>
212
+ </div>
213
+ </div>
214
+ </div>
215
+ </div>
216
+
217
+ {# Mock doc preview #}
218
+ <div class="w-full lg:w-1/2 bg-surface-container-low rounded-xl p-4 relative">
219
+ <div class="bg-surface-container-lowest rounded-lg overflow-hidden shadow-2xl">
220
+ <div class="bg-inverse-surface px-4 py-2 flex items-center justify-between">
221
+ <div class="flex gap-1.5">
222
+ <div class="w-2.5 h-2.5 rounded-full bg-red-500/50"></div>
223
+ <div class="w-2.5 h-2.5 rounded-full bg-yellow-500/50"></div>
224
+ <div class="w-2.5 h-2.5 rounded-full bg-green-500/50"></div>
225
+ </div>
226
+ <span class="text-[10px] text-slate-500 font-mono">docforge.ai/docs/owner/repo</span>
227
+ </div>
228
+ <div class="p-6">
229
+ <div class="flex items-center gap-3 mb-6">
230
+ <div class="w-10 h-10 rounded-full bg-primary flex items-center justify-center">
231
+ <span class="material-symbols-outlined text-white text-xl">auto_stories</span>
232
+ </div>
233
+ <div>
234
+ <h6 class="text-xs font-mono text-outline uppercase tracking-tighter">Documentation Preview</h6>
235
+ <p class="font-bold text-on-surface">README.md</p>
236
+ </div>
237
+ </div>
238
+ <div class="space-y-3">
239
+ <div class="h-2 w-[40%] bg-surface-container rounded-full"></div>
240
+ <div class="h-2 w-[90%] bg-surface-container rounded-full"></div>
241
+ <div class="h-2 w-[70%] bg-surface-container rounded-full"></div>
242
+ <div class="pt-4 flex flex-wrap gap-2">
243
+ <div class="px-2 py-1 rounded bg-secondary-container text-on-secondary-container text-[10px] font-mono">Python</div>
244
+ <div class="px-2 py-1 rounded bg-tertiary-fixed text-on-tertiary-fixed text-[10px] font-mono">Flask</div>
245
+ <div class="px-2 py-1 rounded bg-primary-fixed text-on-primary-fixed text-[10px] font-mono">MIT</div>
246
+ </div>
247
+ </div>
248
+ </div>
249
+ </div>
250
+ <div class="absolute -z-10 -right-4 -bottom-4 w-full h-full bg-primary/5 rounded-xl blur-3xl"></div>
251
+ </div>
252
+ </div>
253
+ </section>
254
+
255
+ {# ── Recent Repos ─────────────────────────────────────────────────────────── #}
256
+ {% if recent %}
257
+ <section class="max-w-7xl mx-auto px-6 py-12">
258
+ <h2 class="text-2xl font-bold tracking-tight text-on-surface mb-8">Recent Repositories</h2>
259
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
260
+ {% for r in recent %}
261
+ <a href="/docs/{{ r.owner }}/{{ r.repo }}"
262
+ class="group block bg-surface-container-lowest p-6 rounded-xl hover:bg-surface-container transition-colors">
263
+ <div class="flex items-start justify-between mb-3">
264
+ <div>
265
+ <p class="font-mono text-xs text-outline mb-1">{{ r.owner }}</p>
266
+ <h3 class="font-bold text-on-surface group-hover:text-primary transition-colors">{{ r.repo }}</h3>
267
+ </div>
268
+ {% if r.info.language %}
269
+ <span class="px-2 py-0.5 rounded bg-secondary-container text-on-secondary-container text-[10px] font-mono">{{ r.info.language }}</span>
270
+ {% endif %}
271
+ </div>
272
+ {% if r.info.description %}
273
+ <p class="text-sm text-on-surface-variant line-clamp-2 mb-4">{{ r.info.description }}</p>
274
+ {% endif %}
275
+ <div class="flex items-center gap-4 text-xs text-outline">
276
+ <span class="flex items-center gap-1"><span class="material-symbols-outlined text-sm">star</span> {{ r.info.stars }}</span>
277
+ <span class="flex items-center gap-1"><span class="material-symbols-outlined text-sm">description</span> {{ r.doc_count }} docs</span>
278
+ </div>
279
+ </a>
280
+ {% endfor %}
281
+ </div>
282
+ </section>
283
+ {% endif %}
284
+
285
+ {# ── CTA ──────────────────────────────────────────────────────────────────── #}
286
+ <section class="max-w-5xl mx-auto px-6 py-20">
287
+ <div class="bg-surface-container-lowest rounded-xl p-12 lg:p-20 text-center relative overflow-hidden">
288
+ <div class="absolute top-0 left-0 w-full h-1 bg-gradient-to-r from-primary via-primary-container to-primary"></div>
289
+ <h2 class="text-4xl font-extrabold tracking-tight mb-4 text-on-surface">Ready to document your code?</h2>
290
+ <p class="text-on-surface-variant mb-10 max-w-xl mx-auto">Paste any public GitHub URL above and get a full documentation suite in under a minute.</p>
291
+ <button onclick="document.getElementById('repo-url').focus(); window.scrollTo({top: 0, behavior: 'smooth'})"
292
+ class="bg-gradient-to-b from-primary to-primary-container text-on-primary px-10 py-4 rounded-lg font-bold text-base shadow-xl shadow-primary/20 hover:scale-105 transition-transform">
293
+ Get Started Free
294
+ </button>
295
+ </div>
296
+ </section>
297
+
298
+ </main>
299
+
300
+ {# ── Footer ───────────────────────────────────────────────────────────────── #}
301
+ <footer class="bg-surface-container-lowest py-12">
302
+ <div class="max-w-7xl mx-auto px-6 flex flex-col md:flex-row justify-between items-center gap-8">
303
+ <div class="flex flex-col items-center md:items-start">
304
+ <span class="text-lg font-bold tracking-tight text-on-surface mb-1">DocForge</span>
305
+ <p class="text-xs text-outline font-medium">AI-powered documentation for every GitHub repository.</p>
306
+ </div>
307
+ <div class="flex gap-8">
308
+ <a href="https://github.com/Moealsarraj/doc-forge" target="_blank" class="text-sm font-medium text-on-surface-variant hover:text-primary transition-colors">GitHub</a>
309
+ </div>
310
+ </div>
311
+ </footer>
312
+ {% endblock %}
313
+
314
+ {% block extra_scripts %}
315
+ <script>
316
+ // CSRF helper
317
+ const _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
318
+ function _post(url, body) {
319
+ return fetch(url, {
320
+ method: 'POST',
321
+ headers: {'Content-Type': 'application/json', 'X-CSRFToken': _csrf},
322
+ body: JSON.stringify(body)
323
+ });
324
+ }
325
+
326
+ // State
327
+ let _currentOwner = null;
328
+ let _currentRepo = null;
329
+
330
+ function setProgress(pct, msg) {
331
+ document.getElementById('progress-bar').style.width = pct + '%';
332
+ document.getElementById('progress-msg').textContent = msg;
333
+ }
334
+
335
+ async function analyzeRepo() {
336
+ const url = document.getElementById('repo-url').value.trim();
337
+ if (!url) return;
338
+
339
+ // Show progress
340
+ document.getElementById('repo-card').classList.add('hidden');
341
+ document.getElementById('progress-card').classList.remove('hidden');
342
+ const btn = document.getElementById('analyze-btn');
343
+ btn.disabled = true;
344
+ document.getElementById('analyze-label').textContent = 'Analyzing…';
345
+ document.getElementById('analyze-icon').textContent = 'hourglass_top';
346
+ setProgress(10, 'Connecting to GitHub API…');
347
+
348
+ try {
349
+ setProgress(30, 'Fetching repository metadata…');
350
+ const res = await _post('/api/analyze', {url});
351
+ const data = await res.json();
352
+ if (!res.ok) throw new Error(data.error || 'Analysis failed');
353
+
354
+ setProgress(100, 'Done!');
355
+ _currentOwner = data.owner;
356
+ _currentRepo = data.repo;
357
+
358
+ // Populate card
359
+ const info = data.info;
360
+ document.getElementById('rc-name').textContent = info.full_name;
361
+ document.getElementById('rc-link').href = info.url;
362
+ document.getElementById('rc-desc').textContent = info.description || 'No description provided.';
363
+ document.getElementById('rc-lang-badge').textContent = info.language || 'Unknown';
364
+ document.getElementById('rc-stars').textContent = (info.stars || 0).toLocaleString() + ' stars';
365
+ document.getElementById('rc-forks').textContent = (info.forks || 0).toLocaleString() + ' forks';
366
+ document.getElementById('rc-files').textContent = data.file_count;
367
+
368
+ setTimeout(() => {
369
+ document.getElementById('progress-card').classList.add('hidden');
370
+ document.getElementById('repo-card').classList.remove('hidden');
371
+ }, 400);
372
+ } catch (err) {
373
+ document.getElementById('progress-card').classList.add('hidden');
374
+ alert('Error: ' + err.message);
375
+ } finally {
376
+ btn.disabled = false;
377
+ document.getElementById('analyze-label').textContent = 'Analyze';
378
+ document.getElementById('analyze-icon').textContent = 'search';
379
+ }
380
+ }
381
+
382
+ async function generateDocs() {
383
+ if (!_currentOwner) return;
384
+ const url = document.getElementById('repo-url').value.trim();
385
+ const types = [];
386
+ if (document.getElementById('chk-readme').checked) types.push('readme');
387
+ if (document.getElementById('chk-architecture').checked) types.push('architecture');
388
+ if (document.getElementById('chk-api').checked) types.push('api');
389
+ if (!types.length) { alert('Select at least one doc type.'); return; }
390
+
391
+ document.getElementById('repo-card').classList.add('hidden');
392
+ document.getElementById('progress-card').classList.remove('hidden');
393
+ setProgress(5, 'Starting AI generation…');
394
+
395
+ const steps = [
396
+ [20, 'Fetching key source files…'],
397
+ [45, 'Generating README…'],
398
+ [65, 'Mapping architecture…'],
399
+ [80, 'Documenting API…'],
400
+ [92, 'Saving to database…']
401
+ ];
402
+ let si = 0;
403
+ const ticker = setInterval(() => {
404
+ if (si < steps.length) { setProgress(...steps[si++]); }
405
+ }, 1800);
406
+
407
+ try {
408
+ const res = await _post('/api/generate', {url, types});
409
+ const data = await res.json();
410
+ clearInterval(ticker);
411
+ if (!res.ok) throw new Error(data.error || 'Generation failed');
412
+
413
+ setProgress(100, 'Complete! Redirecting…');
414
+ setTimeout(() => {
415
+ window.location.href = `/docs/${data.owner}/${data.repo}`;
416
+ }, 600);
417
+ } catch (err) {
418
+ clearInterval(ticker);
419
+ document.getElementById('progress-card').classList.add('hidden');
420
+ document.getElementById('repo-card').classList.remove('hidden');
421
+ alert('Error: ' + err.message);
422
+ }
423
+ }
424
+
425
+ // Allow Enter key in input
426
+ document.getElementById('repo-url').addEventListener('keydown', e => {
427
+ if (e.key === 'Enter') analyzeRepo();
428
+ });
429
+ </script>
430
+ {% endblock %}
app/tools/git_narrator/__init__.py ADDED
File without changes
app/tools/git_narrator/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (176 Bytes). View file
 
app/tools/git_narrator/__pycache__/narrator.cpython-314.pyc ADDED
Binary file (5.85 kB). View file
 
app/tools/git_narrator/__pycache__/routes.cpython-314.pyc ADDED
Binary file (1.51 kB). View file
 
app/tools/git_narrator/narrator.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Git history narrator — turns raw commits into editorial prose."""
2
+ import os
3
+ import re
4
+ import requests
5
+ from app.core.ai import call_ai_json
6
+
7
+ _GITHUB_API = "https://api.github.com"
8
+ _SYSTEM = """You are a senior engineering writer and technical storyteller.
9
+ You transform dry git commit logs into compelling, editorial-quality engineering narratives.
10
+ Write like a thoughtful tech lead preparing a sprint retrospective for the whole company.
11
+ Be specific about what was built, avoid jargon where plain language works, and surface the human story behind the code.
12
+ Return ONLY valid JSON — no markdown fences."""
13
+
14
+ _PROMPT_TMPL = """Narrate the following git history into an editorial engineering report.
15
+
16
+ GIT HISTORY:
17
+ ---
18
+ {log}
19
+ ---
20
+
21
+ Return a JSON object with EXACTLY these keys:
22
+ {{
23
+ "period_label": "<inferred time period, e.g. 'Sprint 42' or 'Week of Jan 15, 2025'>",
24
+ "highlights": [
25
+ {{
26
+ "title": "<punchy title for the biggest story>",
27
+ "narrative": "<2-3 paragraph editorial story — what changed, why it matters, what comes next>",
28
+ "key_commit": "<most important commit message verbatim>",
29
+ "impact": "<one sentence business impact>"
30
+ }}
31
+ ],
32
+ "tech_debt": [
33
+ {{
34
+ "icon": "<material symbol name, e.g. cleaning_services|speed|bug_report|tune>",
35
+ "title": "<short title>",
36
+ "description": "<2-3 sentences on what was fixed/refactored and the measurable benefit>"
37
+ }}
38
+ ],
39
+ "milestones": [
40
+ {{
41
+ "status": "<shipped|in_progress|planned>",
42
+ "title": "<milestone name>",
43
+ "narrative": "<editorial paragraph on this milestone>",
44
+ "contributors": ["<name or handle>"]
45
+ }}
46
+ ],
47
+ "commits": [
48
+ {{
49
+ "hash": "<7-char hash or empty string>",
50
+ "message": "<commit message>",
51
+ "author": "<author name or handle>",
52
+ "time": "<relative time e.g. '2 hours ago'>",
53
+ "type": "<feat|fix|docs|refactor|chore|test|perf>"
54
+ }}
55
+ ],
56
+ "summary_stats": {{
57
+ "total_commits": <integer>,
58
+ "contributors": <integer>,
59
+ "features": <integer>,
60
+ "fixes": <integer>
61
+ }}
62
+ }}
63
+
64
+ Aim for 2-3 highlights, 2-3 tech_debt items, 2-3 milestones.
65
+ If the log is sparse, extrapolate intelligently from what's there.
66
+ commits should list ALL commits from the log (max 20)."""
67
+
68
+
69
+ def _fetch_github_commits(owner: str, repo: str, limit: int = 30) -> str:
70
+ """Fetch recent commits from GitHub and format as git log text."""
71
+ token = os.environ.get("GITHUB_TOKEN", "")
72
+ headers = {"Accept": "application/vnd.github.v3+json"}
73
+ if token:
74
+ headers["Authorization"] = f"Bearer {token}"
75
+ r = requests.get(f"{_GITHUB_API}/repos/{owner}/{repo}/commits?per_page={limit}",
76
+ headers=headers, timeout=20)
77
+ r.raise_for_status()
78
+ lines = []
79
+ for c in r.json():
80
+ sha = c["sha"][:7]
81
+ msg = c["commit"]["message"].split("\n")[0]
82
+ name = c["commit"]["author"]["name"]
83
+ date = c["commit"]["author"]["date"]
84
+ lines.append(f"commit {sha}\nAuthor: {name}\nDate: {date}\n\n {msg}\n")
85
+ return "\n".join(lines)
86
+
87
+
88
+ def _parse_github_url(text: str):
89
+ """Return (owner, repo) if text looks like a GitHub URL, else None."""
90
+ m = re.search(r"github\.com/([^/\s]+)/([^/\s]+?)(?:\.git)?(?:\s|$)", text)
91
+ if m:
92
+ return m.group(1), m.group(2)
93
+ parts = text.strip().split("/")
94
+ if len(parts) == 2 and " " not in text:
95
+ return parts[0], parts[1]
96
+ return None
97
+
98
+
99
+ def narrate(raw_input: str) -> dict:
100
+ """Narrate a git history. raw_input can be a GitHub URL or raw git log text."""
101
+ parsed = _parse_github_url(raw_input.strip())
102
+ if parsed:
103
+ try:
104
+ owner, repo = parsed
105
+ log = _fetch_github_commits(owner, repo)
106
+ except Exception as e:
107
+ log = raw_input # fallback to treating as raw log
108
+ else:
109
+ log = raw_input
110
+
111
+ prompt = _PROMPT_TMPL.format(log=log[:8000])
112
+ return call_ai_json([{"role": "user", "content": prompt}], system=_SYSTEM) or {}
app/tools/git_narrator/routes.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Git Narrator routes."""
2
+ from flask import Blueprint, render_template, request, jsonify
3
+ from .narrator import narrate
4
+
5
+ bp = Blueprint("git_narrator", __name__, template_folder="templates")
6
+
7
+
8
+ @bp.route("/")
9
+ def index():
10
+ return render_template("git_narrator/index.html")
11
+
12
+
13
+ @bp.route("/api/narrate", methods=["POST"])
14
+ def api_narrate():
15
+ body = request.get_json(silent=True) or {}
16
+ raw = (body.get("content") or "").strip()
17
+ if not raw:
18
+ return jsonify({"error": "content is required (GitHub URL or git log text)"}), 400
19
+ if len(raw) < 10:
20
+ return jsonify({"error": "Input too short — paste a URL or git log"}), 400
21
+ result = narrate(raw)
22
+ return jsonify(result)
app/tools/git_narrator/templates/git_narrator/index.html ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "base.html" %}
2
+ {% block title %}Git Narrator — Engineering Stories{% endblock %}
3
+
4
+ {% block content %}
5
+
6
+ {# ── Toolbar ─────────────────────────────────────────────────────────────────── #}
7
+ <header class="toolbar-blur fixed top-0 w-full z-50 flex items-center justify-between px-5 h-12 flex-shrink-0"
8
+ style="border-bottom:1px solid rgba(240,246,252,0.08);">
9
+ <div class="flex items-center gap-3">
10
+ <div class="flex items-center gap-2">
11
+ <span class="material-symbols-outlined text-[16px]" style="color:#58A6FF;">auto_stories</span>
12
+ <span class="font-semibold text-gd-text text-sm" style="letter-spacing:-0.02em;">Git Narrator</span>
13
+ </div>
14
+ <span style="color:rgba(240,246,252,0.12);">|</span>
15
+ <span class="section-label" style="color:#484F58;">Engineering Stories</span>
16
+ </div>
17
+ <div class="flex items-center gap-2">
18
+ <span id="period-chip" class="hidden pill" style="background:rgba(88,166,255,0.12); color:#58A6FF;"></span>
19
+ <button id="copy-btn" class="hidden btn-ghost text-xs">
20
+ <span id="copy-icon" class="material-symbols-outlined text-[13px]">content_copy</span>
21
+ Copy
22
+ </button>
23
+ <button id="narrate-btn" onclick="doNarrate()" class="btn-primary">
24
+ <span class="material-symbols-outlined text-[14px]">auto_awesome</span>
25
+ <span id="narrate-label">Narrate History</span>
26
+ </button>
27
+ </div>
28
+ </header>
29
+
30
+ {# ── Body ─────────────────────────────────────────────────────────────────────── #}
31
+ <div class="flex flex-1 pt-12 overflow-hidden">
32
+
33
+ {# ── Left: Terminal input zone ───────────────────────────────────────────────── #}
34
+ <aside class="w-[320px] flex-shrink-0 flex flex-col overflow-y-auto"
35
+ style="background:#0D1117; border-right:1px solid rgba(240,246,252,0.07);">
36
+
37
+ {# Panel label #}
38
+ <div class="flex items-center gap-2 px-4 py-3"
39
+ style="border-bottom:1px solid rgba(240,246,252,0.06);">
40
+ <span class="w-1.5 h-1.5 rounded-full" style="background:#3FB950;"></span>
41
+ <span class="section-label">Source</span>
42
+ </div>
43
+
44
+ {# Prompt hint #}
45
+ <div class="px-4 pt-3 pb-1.5 flex items-center gap-2">
46
+ <span class="text-[11px] font-mono" style="color:#3FB950;">$</span>
47
+ <span class="text-[10px] font-mono" style="color:#484F58;">paste url or git log below</span>
48
+ </div>
49
+
50
+ {# Input area #}
51
+ <div class="px-3 pb-3">
52
+ <div class="rounded-lg overflow-hidden"
53
+ style="background:#161B22; border:1px solid rgba(240,246,252,0.08);
54
+ box-shadow:inset 0 2px 8px rgba(0,0,0,0.3);">
55
+ <textarea id="log-input" class="input-terminal" style="height:200px;"
56
+ placeholder="https://github.com/org/repo&#10;&#10;# or raw git log:&#10;a3f1b2c feat: add OAuth2 login&#10;d4e5f6a fix: resolve race condition&#10;7b8c9d0 docs: update API reference&#10;…"></textarea>
57
+ </div>
58
+ </div>
59
+
60
+ {# Run button #}
61
+ <div class="px-3 pb-4">
62
+ <button onclick="doNarrate()"
63
+ class="w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-mono text-[11px] font-semibold transition-all"
64
+ style="background:#238636; color:#fff; border:1px solid rgba(63,185,80,0.3); box-shadow:0 1px 4px rgba(0,0,0,0.4);">
65
+ <span class="material-symbols-outlined text-[14px]">auto_awesome</span>
66
+ narrate_history()
67
+ </button>
68
+ </div>
69
+
70
+ {# Divider #}
71
+ <div style="height:1px; background:rgba(240,246,252,0.06);"></div>
72
+
73
+ {# Stats #}
74
+ <div id="stats-area" class="hidden flex flex-col gap-2.5 px-3 pt-3">
75
+ <div class="flex items-center gap-1.5 px-1">
76
+ <span class="text-[11px] font-mono" style="color:#3FB950;">$</span>
77
+ <span class="section-label">git shortlog -sn</span>
78
+ </div>
79
+ <div class="grid grid-cols-2 gap-1.5">
80
+ <div class="rounded-lg p-3 text-center" style="background:#161B22; border:1px solid rgba(240,246,252,0.06);">
81
+ <p id="stat-commits" class="text-lg font-bold font-mono" style="color:#58A6FF;">—</p>
82
+ <p class="section-label mt-0.5">Commits</p>
83
+ </div>
84
+ <div class="rounded-lg p-3 text-center" style="background:#161B22; border:1px solid rgba(240,246,252,0.06);">
85
+ <p id="stat-contributors" class="text-lg font-bold font-mono" style="color:#3FB950;">—</p>
86
+ <p class="section-label mt-0.5">Authors</p>
87
+ </div>
88
+ <div class="rounded-lg p-3 text-center" style="background:#161B22; border:1px solid rgba(240,246,252,0.06);">
89
+ <p id="stat-features" class="text-lg font-bold font-mono" style="color:#D2A8FF;">—</p>
90
+ <p class="section-label mt-0.5">Features</p>
91
+ </div>
92
+ <div class="rounded-lg p-3 text-center" style="background:#161B22; border:1px solid rgba(240,246,252,0.06);">
93
+ <p id="stat-fixes" class="text-lg font-bold font-mono" style="color:#E3B341;">—</p>
94
+ <p class="section-label mt-0.5">Fixes</p>
95
+ </div>
96
+ </div>
97
+ </div>
98
+
99
+ {# Commits log #}
100
+ <div id="commits-area" class="hidden flex flex-col gap-1 px-3 pt-3 pb-4">
101
+ <div class="flex items-center gap-1.5 px-1 mb-1">
102
+ <span class="text-[11px] font-mono" style="color:#3FB950;">$</span>
103
+ <span class="section-label">git log --oneline</span>
104
+ </div>
105
+ <div id="commits-list" class="flex flex-col"></div>
106
+ </div>
107
+
108
+ </aside>
109
+
110
+ {# ── Right: Narrative output ─────────────────────────────────────────────────── #}
111
+ <main class="flex-1 overflow-y-auto" style="background:#161B22;">
112
+
113
+ {# Empty state #}
114
+ <div id="empty-state" class="h-full flex flex-col items-center justify-center text-center select-none px-8">
115
+ <div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-5"
116
+ style="background:#1C2128; border:1px solid rgba(240,246,252,0.08);">
117
+ <span class="material-symbols-outlined text-2xl" style="color:#484F58;">auto_stories</span>
118
+ </div>
119
+ <p class="text-sm font-semibold text-gd-text mb-2" style="letter-spacing:-0.02em;">The Narrative</p>
120
+ <p class="text-xs text-gd-text-2 max-w-xs leading-relaxed">
121
+ Paste a GitHub URL or git log on the left. The AI writes your commit history as an editorial engineering report.
122
+ </p>
123
+ <div class="mt-5 flex flex-wrap justify-center gap-1.5">
124
+ {% for tag in ['Highlights', 'Tech Debt', 'Milestones', 'Contributors', 'Impact'] %}
125
+ <span class="pill" style="background:#1C2128; color:#484F58; border:1px solid rgba(240,246,252,0.06); font-size:9px;">{{ tag }}</span>
126
+ {% endfor %}
127
+ </div>
128
+ </div>
129
+
130
+ {# Loading state #}
131
+ <div id="loading-state" class="hidden h-full flex flex-col items-center justify-center text-center px-8">
132
+ <div class="w-14 h-14 rounded-2xl flex items-center justify-center mb-5"
133
+ style="background:rgba(88,166,255,0.08); border:1px solid rgba(88,166,255,0.15);">
134
+ <span class="material-symbols-outlined text-2xl" style="color:#58A6FF; animation:pulse 1.4s ease-in-out infinite;">psychology</span>
135
+ </div>
136
+ <p class="text-sm font-semibold text-gd-text mb-1.5" style="letter-spacing:-0.02em;">Writing the narrative…</p>
137
+ <p id="loading-msg" class="text-xs font-mono text-gd-text-3">Parsing commit history</p>
138
+ <div class="w-48 h-0.5 rounded-full overflow-hidden mt-5"
139
+ style="background:rgba(240,246,252,0.06);">
140
+ <div id="loading-bar" class="h-full rounded-full transition-all duration-700"
141
+ style="width:5%; background:#58A6FF;"></div>
142
+ </div>
143
+ </div>
144
+
145
+ {# Narrative output #}
146
+ <div id="narrative-output" class="hidden max-w-2xl mx-auto px-8 py-8 flex flex-col gap-10 pb-12">
147
+
148
+ {# Report header #}
149
+ <div class="flex items-end justify-between">
150
+ <div>
151
+ <p class="section-label mb-2">Engineering Report</p>
152
+ <h1 class="text-2xl font-bold text-gd-text" style="letter-spacing:-0.03em;">The Narrative</h1>
153
+ </div>
154
+ <span id="period-label-inline" class="text-xs font-mono text-gd-text-3 pb-1"></span>
155
+ </div>
156
+
157
+ <div style="height:1px; background:rgba(240,246,252,0.07);"></div>
158
+
159
+ <div id="highlights-section"></div>
160
+ <div id="tech-debt-section"></div>
161
+ <div id="milestones-section"></div>
162
+
163
+ </div>
164
+ </main>
165
+ </div>
166
+
167
+ {# ── Footer ─────────────────────────────────────────────────────────────────── #}
168
+ <footer class="toolbar-blur fixed bottom-0 w-full h-9 flex items-center justify-between px-5 flex-shrink-0"
169
+ style="border-top:1px solid rgba(240,246,252,0.07);">
170
+ <span class="section-label">Git Narrator v1.0</span>
171
+ <div class="flex items-center gap-1.5">
172
+ <span id="footer-dot" class="w-1.5 h-1.5 rounded-full" style="background:#3FB950;"></span>
173
+ <span id="footer-status" class="section-label" style="color:#3FB950;">Ready</span>
174
+ </div>
175
+ </footer>
176
+
177
+ <style>
178
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.4} }
179
+ </style>
180
+
181
+ {% endblock %}
182
+
183
+ {% block extra_scripts %}
184
+ <script>
185
+ const _csrf = document.querySelector('meta[name="csrf-token"]')?.content || '';
186
+ let _narrativeText = '';
187
+
188
+ function setStatus(msg, color='#3FB950') {
189
+ document.getElementById('footer-dot').style.background = color;
190
+ document.getElementById('footer-status').style.color = color;
191
+ document.getElementById('footer-status').textContent = msg;
192
+ }
193
+
194
+ async function doNarrate() {
195
+ const raw = document.getElementById('log-input').value.trim();
196
+ if (!raw) { document.getElementById('log-input').focus(); return; }
197
+
198
+ document.getElementById('empty-state').classList.add('hidden');
199
+ document.getElementById('narrative-output').classList.add('hidden');
200
+ document.getElementById('loading-state').classList.remove('hidden');
201
+ document.getElementById('stats-area').classList.add('hidden');
202
+ document.getElementById('commits-area').classList.add('hidden');
203
+ document.getElementById('copy-btn').classList.add('hidden');
204
+ document.getElementById('period-chip').classList.add('hidden');
205
+
206
+ const btn = document.getElementById('narrate-btn');
207
+ btn.disabled = true;
208
+ document.getElementById('narrate-label').textContent = 'Writing…';
209
+ setStatus('Processing…', '#E3B341');
210
+
211
+ let pct = 5;
212
+ const msgs = ['Parsing commit history…','Identifying key themes…','Writing highlights…','Building the narrative…'];
213
+ let mi = 0;
214
+ const ticker = setInterval(() => {
215
+ pct = Math.min(pct + 12, 88);
216
+ document.getElementById('loading-bar').style.width = pct + '%';
217
+ document.getElementById('loading-msg').textContent = msgs[mi++ % msgs.length];
218
+ }, 1400);
219
+
220
+ try {
221
+ const res = await fetch('/api/narrate', {
222
+ method: 'POST',
223
+ headers: {'Content-Type':'application/json','X-CSRFToken':_csrf},
224
+ body: JSON.stringify({content: raw})
225
+ });
226
+ const data = await res.json();
227
+ clearInterval(ticker);
228
+ document.getElementById('loading-bar').style.width = '100%';
229
+ if (!res.ok) throw new Error(data.error || 'Narration failed');
230
+ renderNarrative(data);
231
+ setStatus('Done');
232
+ } catch(err) {
233
+ clearInterval(ticker);
234
+ document.getElementById('loading-state').classList.add('hidden');
235
+ document.getElementById('empty-state').classList.remove('hidden');
236
+ setStatus('Error', '#F85149');
237
+ alert('Error: ' + err.message);
238
+ } finally {
239
+ btn.disabled = false;
240
+ document.getElementById('narrate-label').textContent = 'Narrate History';
241
+ }
242
+ }
243
+
244
+ function renderNarrative(data) {
245
+ document.getElementById('loading-state').classList.add('hidden');
246
+
247
+ const periodText = data.period_label || '';
248
+ document.getElementById('period-chip').textContent = periodText;
249
+ document.getElementById('period-chip').classList.toggle('hidden', !periodText);
250
+ document.getElementById('period-label-inline').textContent = periodText;
251
+
252
+ // Stats
253
+ const ss = data.summary_stats || {};
254
+ document.getElementById('stat-commits').textContent = ss.total_commits || '—';
255
+ document.getElementById('stat-contributors').textContent = ss.contributors || '—';
256
+ document.getElementById('stat-features').textContent = ss.features || '—';
257
+ document.getElementById('stat-fixes').textContent = ss.fixes || '—';
258
+ document.getElementById('stats-area').classList.remove('hidden');
259
+
260
+ // Commits
261
+ const cl = document.getElementById('commits-list');
262
+ while (cl.firstChild) cl.removeChild(cl.firstChild);
263
+ (data.commits || []).slice(0, 8).forEach(c => {
264
+ const row = document.createElement('div');
265
+ row.className = 'flex items-start gap-2 py-1.5 font-mono';
266
+ row.style.cssText = 'border-bottom:1px solid rgba(240,246,252,0.04);';
267
+
268
+ const hash = document.createElement('span');
269
+ hash.className = 'text-[10px] flex-shrink-0 mt-0.5';
270
+ hash.style.color = '#58A6FF';
271
+ hash.textContent = (c.hash || '???????').slice(0, 7);
272
+
273
+ const info = document.createElement('div');
274
+ info.className = 'min-w-0';
275
+ const msg = document.createElement('p');
276
+ msg.className = 'text-[11px] truncate';
277
+ msg.style.color = '#C9D1D9';
278
+ msg.textContent = c.message || '';
279
+ const meta = document.createElement('p');
280
+ meta.className = 'text-[9px] mt-0.5';
281
+ meta.style.color = '#484F58';
282
+ meta.textContent = [c.time, c.author ? '@'+c.author : ''].filter(Boolean).join(' · ');
283
+ info.append(msg, meta);
284
+ row.append(hash, info);
285
+ cl.appendChild(row);
286
+ });
287
+ document.getElementById('commits-area').classList.remove('hidden');
288
+
289
+ // ── Highlights ─────────────────────────────────────────────────────────────
290
+ const hs = document.getElementById('highlights-section');
291
+ while (hs.firstChild) hs.removeChild(hs.firstChild);
292
+ if ((data.highlights || []).length) {
293
+ hs.appendChild(makeSectionHeader("Week's Highlights", 'auto_awesome', '#58A6FF'));
294
+ const grid = document.createElement('div');
295
+ grid.className = 'flex flex-col gap-3 mt-4';
296
+
297
+ (data.highlights || []).forEach(h => {
298
+ const card = document.createElement('div');
299
+ card.className = 'card p-5';
300
+ card.style.borderLeftColor = '#58A6FF';
301
+ card.style.borderLeftWidth = '3px';
302
+
303
+ const title = document.createElement('h3');
304
+ title.className = 'text-sm font-semibold text-gd-text mb-2';
305
+ title.style.letterSpacing = '-0.01em';
306
+ title.textContent = h.title || '';
307
+
308
+ const narrative = document.createElement('p');
309
+ narrative.className = 'text-xs leading-relaxed text-gd-text-2';
310
+ narrative.textContent = h.narrative || '';
311
+
312
+ card.append(title, narrative);
313
+
314
+ if (h.impact) {
315
+ const impact = document.createElement('div');
316
+ impact.className = 'flex items-center gap-1.5 mt-3';
317
+ const icon = document.createElement('span');
318
+ icon.className = 'material-symbols-outlined text-[13px]';
319
+ icon.style.color = '#3FB950';
320
+ icon.textContent = 'trending_up';
321
+ const txt = document.createElement('span');
322
+ txt.className = 'text-[11px] font-medium';
323
+ txt.style.color = '#3FB950';
324
+ txt.textContent = h.impact;
325
+ impact.append(icon, txt);
326
+ card.appendChild(impact);
327
+ }
328
+
329
+ if (h.key_commit) {
330
+ const pre = document.createElement('pre');
331
+ pre.className = 'font-mono text-[10px] leading-relaxed whitespace-pre-wrap mt-3 p-3 rounded-lg';
332
+ pre.style.cssText = 'background:#0D1117; color:#484F58; border:1px solid rgba(240,246,252,0.06);';
333
+ pre.textContent = h.key_commit;
334
+ card.appendChild(pre);
335
+ }
336
+
337
+ grid.appendChild(card);
338
+ });
339
+ hs.appendChild(grid);
340
+ }
341
+
342
+ // ── Tech Debt ──────────────────────────────────────────────────────────────
343
+ const td = document.getElementById('tech-debt-section');
344
+ while (td.firstChild) td.removeChild(td.firstChild);
345
+ if ((data.tech_debt || []).length) {
346
+ td.appendChild(makeSectionHeader('Technical Debt Addressed', 'build', '#E3B341'));
347
+ const grid = document.createElement('div');
348
+ grid.className = 'grid grid-cols-2 gap-3 mt-4';
349
+
350
+ (data.tech_debt || []).forEach(item => {
351
+ const card = document.createElement('div');
352
+ card.className = 'card p-4';
353
+
354
+ const top = document.createElement('div');
355
+ top.className = 'flex items-center gap-2 mb-2';
356
+ const icon = document.createElement('span');
357
+ icon.className = 'material-symbols-outlined text-[15px]';
358
+ icon.style.color = '#E3B341';
359
+ icon.textContent = item.icon || 'build';
360
+ const h4 = document.createElement('p');
361
+ h4.className = 'text-xs font-semibold text-gd-text';
362
+ h4.textContent = item.title || '';
363
+ top.append(icon, h4);
364
+
365
+ const desc = document.createElement('p');
366
+ desc.className = 'text-xs leading-relaxed text-gd-text-2';
367
+ desc.textContent = item.description || '';
368
+
369
+ card.append(top, desc);
370
+ grid.appendChild(card);
371
+ });
372
+ td.appendChild(grid);
373
+ }
374
+
375
+ // ── Milestones ─────────────────────────────────────────────────────────────
376
+ const ms = document.getElementById('milestones-section');
377
+ while (ms.firstChild) ms.removeChild(ms.firstChild);
378
+ if ((data.milestones || []).length) {
379
+ ms.appendChild(makeSectionHeader('Feature Milestones', 'flag', '#58A6FF'));
380
+
381
+ const timeline = document.createElement('div');
382
+ timeline.className = 'relative pl-8 flex flex-col gap-6 mt-4';
383
+ const line = document.createElement('div');
384
+ line.className = 'absolute left-[9px] top-2 bottom-2 w-px';
385
+ line.style.background = 'rgba(240,246,252,0.08)';
386
+ timeline.appendChild(line);
387
+
388
+ (data.milestones || []).forEach(m => {
389
+ const row = document.createElement('div');
390
+ row.className = 'relative';
391
+
392
+ const dot = document.createElement('div');
393
+ dot.className = 'absolute -left-[28px] top-1.5 w-4 h-4 rounded-full flex items-center justify-center flex-shrink-0';
394
+ if (m.status === 'shipped') {
395
+ dot.style.cssText = 'background:#238636; border:1px solid rgba(63,185,80,0.4);';
396
+ const chk = document.createElement('span');
397
+ chk.className = 'material-symbols-outlined';
398
+ chk.style.cssText = 'font-size:10px; color:#fff; font-variation-settings:"FILL" 1,"wght" 600,"GRAD" 0,"opsz" 20;';
399
+ chk.textContent = 'check';
400
+ dot.appendChild(chk);
401
+ } else {
402
+ dot.style.cssText = 'background:#1C2128; border:2px solid #58A6FF;';
403
+ const pulse = document.createElement('div');
404
+ pulse.className = 'w-1.5 h-1.5 rounded-full';
405
+ pulse.style.cssText = 'background:#58A6FF; animation:pulse 1.4s ease-in-out infinite;';
406
+ dot.appendChild(pulse);
407
+ }
408
+
409
+ const card = document.createElement('div');
410
+ card.className = 'card p-5';
411
+
412
+ const badge = document.createElement('span');
413
+ badge.className = 'pill mb-3 inline-flex';
414
+ badge.style.cssText = m.status === 'shipped'
415
+ ? 'background:rgba(63,185,80,0.10); color:#3FB950;'
416
+ : 'background:rgba(88,166,255,0.10); color:#58A6FF;';
417
+ badge.textContent = (m.status || 'planned').toUpperCase();
418
+
419
+ const h4 = document.createElement('h4');
420
+ h4.className = 'text-sm font-semibold text-gd-text mb-2';
421
+ h4.style.letterSpacing = '-0.01em';
422
+ h4.textContent = m.title || '';
423
+
424
+ const p = document.createElement('p');
425
+ p.className = 'text-xs leading-relaxed text-gd-text-2';
426
+ p.textContent = m.narrative || '';
427
+
428
+ card.append(badge, h4, p);
429
+
430
+ if (m.contributors && m.contributors.length) {
431
+ const contrib = document.createElement('div');
432
+ contrib.className = 'flex items-center gap-2 mt-3 pt-3';
433
+ contrib.style.cssText = 'border-top:1px solid rgba(240,246,252,0.07);';
434
+ const avatars = document.createElement('div');
435
+ avatars.className = 'flex -space-x-1';
436
+ m.contributors.slice(0, 4).forEach(name => {
437
+ const av = document.createElement('div');
438
+ av.className = 'w-5 h-5 rounded-full flex items-center justify-center text-[8px] font-bold font-mono';
439
+ av.style.cssText = 'background:#238636; color:#fff; border:2px solid #1C2128;';
440
+ av.textContent = (name[0] || '?').toUpperCase();
441
+ avatars.appendChild(av);
442
+ });
443
+ const names = document.createElement('span');
444
+ names.className = 'text-[10px] text-gd-text-3';
445
+ names.textContent = m.contributors.join(', ');
446
+ contrib.append(avatars, names);
447
+ card.appendChild(contrib);
448
+ }
449
+
450
+ row.append(dot, card);
451
+ timeline.appendChild(row);
452
+ });
453
+ ms.appendChild(timeline);
454
+ }
455
+
456
+ _narrativeText = buildCopyText(data);
457
+ const copyBtn = document.getElementById('copy-btn');
458
+ copyBtn.classList.remove('hidden');
459
+ copyBtn.onclick = () => {
460
+ navigator.clipboard.writeText(_narrativeText).then(() => {
461
+ const icon = document.getElementById('copy-icon');
462
+ icon.textContent = 'check';
463
+ setTimeout(() => { icon.textContent = 'content_copy'; }, 2000);
464
+ });
465
+ };
466
+
467
+ document.getElementById('narrative-output').classList.remove('hidden');
468
+ document.getElementById('narrative-output').scrollIntoView({ behavior:'smooth' });
469
+ }
470
+
471
+ function makeSectionHeader(title, iconName, color) {
472
+ const wrap = document.createElement('div');
473
+ wrap.className = 'section-header';
474
+ const icon = document.createElement('span');
475
+ icon.className = 'material-symbols-outlined text-[15px]';
476
+ icon.style.color = color;
477
+ icon.textContent = iconName;
478
+ const h = document.createElement('h2');
479
+ h.className = 'text-sm font-semibold text-gd-text';
480
+ h.style.letterSpacing = '-0.01em';
481
+ h.textContent = title;
482
+ const line = document.createElement('div');
483
+ line.className = 'section-header-line';
484
+ wrap.append(icon, h, line);
485
+ return wrap;
486
+ }
487
+
488
+ function buildCopyText(data) {
489
+ const lines = ['# ' + (data.period_label || 'Engineering Narrative') + '\n'];
490
+ (data.highlights || []).forEach(h => {
491
+ lines.push('## ' + h.title + '\n' + h.narrative + '\n');
492
+ if (h.impact) lines.push('Impact: ' + h.impact + '\n');
493
+ });
494
+ if ((data.tech_debt || []).length) {
495
+ lines.push('## Technical Debt\n');
496
+ data.tech_debt.forEach(t => lines.push('### ' + t.title + '\n' + t.description + '\n'));
497
+ }
498
+ if ((data.milestones || []).length) {
499
+ lines.push('## Milestones\n');
500
+ data.milestones.forEach(m => lines.push('[' + (m.status||'').toUpperCase() + '] ' + m.title + '\n' + m.narrative + '\n'));
501
+ }
502
+ return lines.join('\n');
503
+ }
504
+
505
+ document.addEventListener('keydown', e => {
506
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') doNarrate();
507
+ });
508
+ </script>
509
+ {% endblock %}
app/tools/schema_detective/__init__.py ADDED
File without changes
app/tools/schema_detective/__pycache__/__init__.cpython-314.pyc ADDED
Binary file (180 Bytes). View file
 
app/tools/schema_detective/__pycache__/detective.cpython-314.pyc ADDED
Binary file (12.8 kB). View file
 
app/tools/schema_detective/__pycache__/routes.cpython-314.pyc ADDED
Binary file (2.69 kB). View file
 
app/tools/schema_detective/detective.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema Detective — audits database schemas for design issues."""
2
+ import re
3
+ from app.core.ai import call_ai_json, call_ai
4
+
5
+ _SYSTEM = """You are a senior database architect and security engineer with deep expertise in SQL,
6
+ normalization, indexing strategies, database security, and production reliability.
7
+ You audit schemas with a critical eye — catching design flaws, security vulnerabilities,
8
+ and data integrity risks that cause real production incidents.
9
+ Return ONLY valid JSON — no markdown fences, no preamble."""
10
+
11
+
12
+ _PROMPT_TMPL = """Audit the following database schema (and any embedded SQL) for ALL design issues, security vulnerabilities, and integrity risks.
13
+ {pre_context}
14
+ SCHEMA / SQL:
15
+ ---
16
+ {schema}
17
+ ---
18
+
19
+ Return a JSON object with EXACTLY these keys:
20
+ {{
21
+ "health_score": <integer 0-100 — overall schema health; 100 = perfect, 0 = completely broken>,
22
+ "summary": "<1-2 sentence plain-language verdict — what's the biggest risk and overall state>",
23
+ "findings": [
24
+ {{
25
+ "severity": "<exactly one of: critical | warning | advice>",
26
+ "title": "<short issue title, max 6 words>",
27
+ "description": "<1-2 sentence explanation of the problem and why it matters in production>",
28
+ "location": "<exact table name, column name, procedure name, or SQL clause where the issue appears>",
29
+ "suggestion": "<concrete fix — include the actual corrected SQL snippet when applicable>"
30
+ }}
31
+ ]
32
+ }}
33
+
34
+ Severity definitions:
35
+ - critical: causes data loss, security breach, or catastrophic data corruption. Examples:
36
+ * SQL injection via dynamic query string concatenation with user input
37
+ * DELETE or UPDATE statements with no WHERE clause (wipes entire table)
38
+ * Passwords or secrets stored in plaintext (no hashing)
39
+ * Missing PRIMARY KEY on a business entity table
40
+ * Circular or missing FOREIGN KEY constraints breaking referential integrity
41
+ * NULL allowed on columns that are business-critical (email, user_id, amount)
42
+ - warning: degrades performance, risks integrity under load, or creates operational risk. Examples:
43
+ * Unindexed foreign key columns (causes full table scans on joins)
44
+ * TEXT type where VARCHAR(n) is appropriate (no length constraint)
45
+ * Missing NOT NULL on required business fields
46
+ * Missing updated_at / created_at audit columns
47
+ * Redundant or overlapping indexes (e.g. index on (a), (a,b), (a,b,c) — first is redundant)
48
+ * Missing transactions around multi-step operations
49
+ - advice: maintainability and future-proofing. Examples:
50
+ * Vague column or table names (t1, col1, data, misc, info, temp — not what they represent)
51
+ * Single-letter or numeric table aliases in stored procedures
52
+ * Missing soft-delete pattern (no is_deleted / deleted_at column)
53
+ * No CHECK constraints on enum-like columns
54
+
55
+ FK direction rule: the child table (many side) holds the FK column that references the parent (one side).
56
+ Correct: products.category_id REFERENCES categories(id) — products is the child, categories is the parent.
57
+ Wrong: reversed direction or FK defined on the parent side.
58
+
59
+ Naming convention rule: the real problem is meaningless names (t1, t2, col1, data, misc).
60
+ NEVER suggest appending _table, _tbl, _record — that is an anti-pattern. Suggest descriptive real names instead.
61
+
62
+ Rules:
63
+ - Prioritize: list critical findings first, then warnings, then advice
64
+ - The CONFIRMED CRITICAL ISSUES above MUST appear in findings as severity=critical — expand on each with real-world impact
65
+ - Be specific: reference exact table/column names from the schema — do not generalise
66
+ - suggestion must be actionable: if it is a SQL change, show the corrected SQL
67
+ - health_score: start at 100, deduct 20-25 per critical, 5-10 per warning, 1-3 per advice; floor at 0
68
+ - Minimum 3 findings if any issues exist; maximum 15
69
+ - Do NOT fabricate issues that are not present in the schema"""
70
+
71
+
72
+ def _pre_scan(sql: str) -> list[dict]:
73
+ """Deterministic regex scan for the most dangerous SQL patterns.
74
+ Returns findings that are guaranteed regardless of what the AI notices."""
75
+ findings = []
76
+
77
+ # 1. SQL Injection — dynamic exec of a variable built by string concatenation
78
+ # Covers: EXEC(@sql), EXECUTE(@sql), sp_executesql @sql
79
+ exec_pattern = re.compile(
80
+ r'(?i)(?:EXEC(?:UTE)?\s*\(\s*@\w+|sp_executesql\s+@\w+)', re.IGNORECASE
81
+ )
82
+ # Also catch: SET @var = '...' + @user_input (dynamic SQL building)
83
+ concat_pattern = re.compile(
84
+ r"(?i)SET\s+@\w+\s*=\s*(?:'[^']*'\s*\+|@\w+\s*\+)", re.IGNORECASE
85
+ )
86
+ lines = sql.splitlines()
87
+ injection_lines = []
88
+ for i, line in enumerate(lines, 1):
89
+ if exec_pattern.search(line) or concat_pattern.search(line):
90
+ injection_lines.append(f"line {i}: {line.strip()[:80]}")
91
+ if injection_lines:
92
+ findings.append({
93
+ "severity": "critical",
94
+ "title": "SQL Injection Vulnerability",
95
+ "description": (
96
+ "Dynamic SQL is constructed by concatenating user-supplied variables into a "
97
+ "query string and then executed with EXEC/EXECUTE. An attacker can inject "
98
+ "arbitrary SQL, bypassing all access controls and exfiltrating or destroying data."
99
+ ),
100
+ "location": "; ".join(injection_lines[:3]),
101
+ "suggestion": (
102
+ "Replace string concatenation with parameterized queries. "
103
+ "Use sp_executesql with typed parameters:\n"
104
+ " EXEC sp_executesql N'SELECT * FROM users WHERE id = @id', "
105
+ "N'@id INT', @id = @user_input;"
106
+ ),
107
+ })
108
+
109
+ # 2. DELETE without WHERE — bare DELETE FROM <table> ;
110
+ for m in re.finditer(r'(?im)^\s*DELETE\s+FROM\s+(\w+)\s*;', sql):
111
+ table = m.group(1)
112
+ findings.append({
113
+ "severity": "critical",
114
+ "title": "DELETE Without WHERE Clause",
115
+ "description": (
116
+ f"DELETE FROM {table} has no WHERE clause — this permanently deletes "
117
+ f"every row in {table} on execution. One accidental or malicious call "
118
+ "destroys the entire table's data with no automatic rollback."
119
+ ),
120
+ "location": f"DELETE FROM {table}",
121
+ "suggestion": (
122
+ f"Always scope deletes: DELETE FROM {table} WHERE <condition>;\n"
123
+ f"If a full wipe is intentional, use TRUNCATE TABLE {table} explicitly "
124
+ "to make the intent obvious."
125
+ ),
126
+ })
127
+
128
+ # 3. UPDATE without WHERE — UPDATE <table> SET ... ; (no WHERE before semicolon)
129
+ for m in re.finditer(
130
+ r'(?is)UPDATE\s+(\w+)\s+SET\s+(?:(?!WHERE).)*?;', sql
131
+ ):
132
+ table = m.group(1)
133
+ stmt_preview = m.group(0).strip()[:80]
134
+ findings.append({
135
+ "severity": "critical",
136
+ "title": "UPDATE Without WHERE Clause",
137
+ "description": (
138
+ f"UPDATE {table} SET ... has no WHERE clause — this overwrites every "
139
+ f"row in {table}. Any typo or accidental execution corrupts the entire table."
140
+ ),
141
+ "location": stmt_preview,
142
+ "suggestion": (
143
+ f"Add a WHERE clause: UPDATE {table} SET ... WHERE <condition>;"
144
+ ),
145
+ })
146
+
147
+ # 4. Plaintext passwords in INSERT statements
148
+ # Look for INSERT ... (... password ...) VALUES (... 'literal' ...)
149
+ for m in re.finditer(
150
+ r"(?is)INSERT\s+INTO\s+\w+[^;]*?(?:password|passwd|pwd)[^;]*?VALUES[^;]*?'([^']{1,100})'[^;]*?;",
151
+ sql,
152
+ ):
153
+ findings.append({
154
+ "severity": "critical",
155
+ "title": "Plaintext Password Stored",
156
+ "description": (
157
+ "Passwords are inserted as plaintext string literals. Any database breach "
158
+ "or log exposure immediately reveals all user credentials with zero effort."
159
+ ),
160
+ "location": m.group(0).strip()[:80],
161
+ "suggestion": (
162
+ "Hash passwords before storage using bcrypt, argon2id, or scrypt. "
163
+ "Never store or log plaintext passwords:\n"
164
+ " password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt())\n"
165
+ " INSERT INTO users (password) VALUES (:password_hash)"
166
+ ),
167
+ })
168
+
169
+ return findings
170
+
171
+
172
+ def analyze_schema(schema: str) -> dict:
173
+ """Audit a database schema and return structured findings."""
174
+ pre_findings = _pre_scan(schema)
175
+
176
+ # Inject pre-detected issues into the prompt so the AI elaborates on them
177
+ if pre_findings:
178
+ titles = "\n".join(f" - {f['title']} (at: {f['location'][:60]})" for f in pre_findings)
179
+ pre_context = (
180
+ f"\n\nCONFIRMED CRITICAL ISSUES (detected by static analysis — "
181
+ f"you MUST include ALL of these in findings as severity=critical "
182
+ f"and explain their full real-world impact):\n{titles}\n"
183
+ )
184
+ else:
185
+ pre_context = ""
186
+
187
+ prompt = _PROMPT_TMPL.format(schema=schema[:8000], pre_context=pre_context)
188
+ result = call_ai_json(
189
+ [{"role": "user", "content": prompt}],
190
+ system=_SYSTEM,
191
+ max_tokens=3072,
192
+ )
193
+
194
+ if not result or not isinstance(result, dict):
195
+ # AI failed — return pre-findings only
196
+ return {
197
+ "health_score": max(0, 100 - 22 * len(pre_findings)),
198
+ "summary": "Static analysis detected critical security issues. AI elaboration unavailable.",
199
+ "findings": pre_findings,
200
+ }
201
+
202
+ if pre_findings:
203
+ # Deduplicate: remove any AI findings whose title overlaps with pre-detected ones
204
+ pre_titles = {f["title"].lower() for f in pre_findings}
205
+ ai_only = [
206
+ f for f in result.get("findings", [])
207
+ if f.get("title", "").lower() not in pre_titles
208
+ ]
209
+ # Pre-findings lead the list (guaranteed, most critical)
210
+ result["findings"] = pre_findings + ai_only
211
+ # Re-anchor health_score downward for guaranteed criticals
212
+ result["health_score"] = max(0, result.get("health_score", 100) - 22 * len(pre_findings))
213
+
214
+ return result
215
+
216
+
217
+ _FIX_SYSTEM = (
218
+ "You are a senior database architect. Given a SQL schema with known issues, "
219
+ "produce a complete corrected version that fixes all listed problems. "
220
+ "Return ONLY valid SQL — no markdown fences, no preamble, no trailing commentary."
221
+ )
222
+
223
+ _FIX_PROMPT = """Fix the following SQL schema by addressing every listed issue.
224
+
225
+ ORIGINAL SCHEMA:
226
+ ---
227
+ {schema}
228
+ ---
229
+
230
+ ISSUES TO FIX:
231
+ {issues}
232
+
233
+ Rules:
234
+ - Return the COMPLETE corrected schema — all original tables must be present
235
+ - Fix every issue listed: add missing PKs, hash passwords, add WHERE clauses, remove SQL injection, add indexes, etc.
236
+ - Add brief inline comments only where a fix is non-obvious (e.g. -- hashed via bcrypt)
237
+ - Do NOT add features that weren't in the original schema
238
+ - Output valid SQL only"""
239
+
240
+
241
+ def fix_schema(schema: str, findings: list) -> str:
242
+ """Return a corrected SQL schema that addresses all audit findings."""
243
+ issues = "\n".join(
244
+ f"- [{f.get('severity','').upper()}] {f.get('title','')}: {f.get('suggestion','')[:300]}"
245
+ for f in findings
246
+ if f.get("suggestion")
247
+ )
248
+ if not issues:
249
+ issues = "General cleanup — apply best practices for normalization, naming, and security."
250
+
251
+ prompt = _FIX_PROMPT.format(schema=schema[:6000], issues=issues)
252
+ return call_ai([{"role": "user", "content": prompt}], system=_FIX_SYSTEM, max_tokens=3000).strip()
app/tools/schema_detective/routes.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Schema Detective routes."""
2
+ from flask import Blueprint, render_template, request, jsonify
3
+ from .detective import analyze_schema, fix_schema
4
+
5
+ bp = Blueprint("schema_detective", __name__, template_folder="templates")
6
+
7
+
8
+ @bp.route("/")
9
+ def index():
10
+ return render_template("schema_detective/index.html")
11
+
12
+
13
+ @bp.route("/api/analyze", methods=["POST"])
14
+ def api_analyze():
15
+ body = request.get_json(silent=True) or {}
16
+ schema = (body.get("schema") or "").strip()
17
+
18
+ if not schema:
19
+ return jsonify({"error": "Schema is required — paste your SQL or table definitions"}), 400
20
+ if len(schema) < 30:
21
+ return jsonify({"error": "Schema too short — paste at least one CREATE TABLE statement"}), 400
22
+
23
+ result = analyze_schema(schema)
24
+ if not result:
25
+ return jsonify({"error": "AI failed to analyze schema — please try again"}), 502
26
+ return jsonify(result)
27
+
28
+
29
+ @bp.route("/api/fix", methods=["POST"])
30
+ def api_fix():
31
+ body = request.get_json(silent=True) or {}
32
+ schema = (body.get("schema") or "").strip()
33
+ findings = body.get("findings") or []
34
+ if not schema:
35
+ return jsonify({"error": "Schema is required"}), 400
36
+ try:
37
+ fixed = fix_schema(schema, findings)
38
+ return jsonify({"fixed_schema": fixed})
39
+ except Exception as e:
40
+ return jsonify({"error": f"Fix generation failed: {str(e)}"}), 502