codeabc 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. backend/__init__.py +0 -0
  2. backend/app.py +95 -0
  3. backend/desktop_server.py +46 -0
  4. backend/models.py +629 -0
  5. backend/prompts/__init__.py +0 -0
  6. backend/prompts/annotate.py +44 -0
  7. backend/prompts/edit.py +62 -0
  8. backend/prompts/overview.py +57 -0
  9. backend/prompts/qa.py +45 -0
  10. backend/routers/__init__.py +0 -0
  11. backend/routers/analyze.py +271 -0
  12. backend/routers/project.py +872 -0
  13. backend/services/__init__.py +0 -0
  14. backend/services/action_plan.py +366 -0
  15. backend/services/activity.py +313 -0
  16. backend/services/apimap.py +391 -0
  17. backend/services/async_surface.py +123 -0
  18. backend/services/cache.py +183 -0
  19. backend/services/churn.py +244 -0
  20. backend/services/ci_checks.py +317 -0
  21. backend/services/codemap_export.py +136 -0
  22. backend/services/commands.py +276 -0
  23. backend/services/complexity.py +111 -0
  24. backend/services/config_files.py +190 -0
  25. backend/services/contributing.py +308 -0
  26. backend/services/coverage.py +283 -0
  27. backend/services/datamodels.py +190 -0
  28. backend/services/deep_nesting.py +110 -0
  29. backend/services/dependencies.py +270 -0
  30. backend/services/docs.py +255 -0
  31. backend/services/docstrings.py +163 -0
  32. backend/services/duplicate_code.py +165 -0
  33. backend/services/entrypoints.py +152 -0
  34. backend/services/envscan.py +167 -0
  35. backend/services/error_handling.py +239 -0
  36. backend/services/filenames.py +261 -0
  37. backend/services/github_clone.py +129 -0
  38. backend/services/glossary.py +96 -0
  39. backend/services/health_score.py +381 -0
  40. backend/services/importgraph.py +986 -0
  41. backend/services/integrations.py +263 -0
  42. backend/services/licenses.py +505 -0
  43. backend/services/llm.py +117 -0
  44. backend/services/long_functions.py +101 -0
  45. backend/services/ownership.py +194 -0
  46. backend/services/package_purposes.py +112 -0
  47. backend/services/pr_reading.py +181 -0
  48. backend/services/release_map.py +402 -0
  49. backend/services/report_export.py +232 -0
  50. backend/services/risk.py +88 -0
  51. backend/services/scanner.py +546 -0
  52. backend/services/schedules.py +369 -0
  53. backend/services/security.py +445 -0
  54. backend/services/settings_map.py +234 -0
  55. backend/services/symbols.py +605 -0
  56. backend/services/techdebt.py +124 -0
  57. backend/services/too_many_params.py +102 -0
  58. backend/services/typing_coverage.py +184 -0
  59. codeabc-0.1.0.dist-info/METADATA +283 -0
  60. codeabc-0.1.0.dist-info/RECORD +62 -0
  61. codeabc-0.1.0.dist-info/WHEEL +4 -0
  62. codeabc-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,391 @@
1
+ """API-endpoint map: extract HTTP routes from web-framework source code.
2
+
3
+ A non-programmer reviewing a backend — a founder auditing an outsourced API,
4
+ say — wants a single answer to "what does this service actually expose?". The
5
+ import graph shows what imports what; this answers the complementary question:
6
+ which URL paths exist, what HTTP methods they accept, and what they do.
7
+
8
+ Supports the most common frameworks by scanning for their decorator or function
9
+ patterns:
10
+
11
+ FastAPI / APIRouter @router.get / .post / .put / .delete / .patch / .options
12
+ Flask / Blueprint @app.route / @bp.route (methods= list)
13
+ Django urlpatterns = [path("...", view, name="...")]
14
+ Express (JS) app.get / app.post / router.get / etc.
15
+ Next.js (App Router) src/app/**/route.ts|js — export async function GET|POST|...
16
+
17
+ Each route entry carries method, path, handler name, brief description (from
18
+ the function's docstring if one is present), file, and line number.
19
+
20
+ Limitations:
21
+ * Patterns are regex-based: computed paths (f-strings, concatenation,
22
+ variables) may be missed or mis-parsed.
23
+ * Dynamic prefix mounting (app.include_router with prefix=, app.register_blueprint
24
+ with url_prefix=) is detected and annotated, but full prefix expansion would
25
+ require data-flow analysis that's out of scope.
26
+ * Results are best-effort — treat them as a navigational index, not a
27
+ definitive spec.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import re
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Shared helpers
36
+ # ---------------------------------------------------------------------------
37
+
38
+ _QUOTE_CONTENT = r"""['"]([^'"]+)['"]""" # a quoted string, captured
39
+
40
+
41
+ def _lang(path: str) -> str:
42
+ ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
43
+ if ext in ("py",):
44
+ return "python"
45
+ if ext in ("js", "ts", "jsx", "tsx", "mjs", "cjs"):
46
+ return "js"
47
+ return ext
48
+
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # FastAPI / Flask / Django (Python)
52
+ # ---------------------------------------------------------------------------
53
+
54
+ # @router.<method>("/<path>") or @app.<method>("/<path>")
55
+ _FASTAPI_DECORATOR_RE = re.compile(
56
+ r"""@[\w.]+\.(get|post|put|delete|patch|options|head)\s*\("""
57
+ r"""\s*""" + _QUOTE_CONTENT,
58
+ re.IGNORECASE,
59
+ )
60
+
61
+ # @app.route("/path", methods=["GET", "POST"]) or @bp.route("/path")
62
+ _FLASK_ROUTE_RE = re.compile(
63
+ r"""@[\w.]+\.route\s*\(\s*""" + _QUOTE_CONTENT + r"""[^)]*\)""",
64
+ re.IGNORECASE,
65
+ )
66
+ _FLASK_METHODS_RE = re.compile(r"""methods\s*=\s*\[([^\]]+)\]""", re.IGNORECASE)
67
+
68
+ # def function_name(...): immediately following decorator
69
+ _PY_FUNC_RE = re.compile(r"""^\s*(?:async\s+)?def\s+(\w+)\s*\(""")
70
+ # docstring: the first string literal after the def line
71
+ _DOCSTRING_RE = re.compile(r"""^\s+['"]{3}(.*?)(?:['"]{3}|$)|^\s+['"]([^'"]+)['"]""")
72
+
73
+ # Django: path("...", view_func, name="...")
74
+ _DJANGO_PATH_RE = re.compile(
75
+ r"""(?:path|re_path|url)\s*\(\s*""" + _QUOTE_CONTENT + r"""\s*,\s*(\w[\w.]*)\s*""",
76
+ re.IGNORECASE,
77
+ )
78
+ # include_router / register_blueprint prefix detection
79
+ _PY_PREFIX_RE = re.compile(
80
+ r"""(?:include_router|register_blueprint)\s*\([^)]*prefix\s*=\s*""" + _QUOTE_CONTENT,
81
+ re.IGNORECASE,
82
+ )
83
+
84
+
85
+ def _py_routes(path: str, content: str) -> list[dict]:
86
+ """Extract routes from a Python file (FastAPI, Flask, Django)."""
87
+ routes: list[dict] = []
88
+ lines = content.splitlines()
89
+ n = len(lines)
90
+ prefix_hints: list[str] = []
91
+
92
+ for m in _PY_PREFIX_RE.finditer(content):
93
+ prefix_hints.append(m.group(1))
94
+
95
+ i = 0
96
+ while i < n:
97
+ line = lines[i]
98
+
99
+ # FastAPI-style: @router.get("/path")
100
+ fm = _FASTAPI_DECORATOR_RE.search(line)
101
+ if fm:
102
+ method = fm.group(1).upper()
103
+ route_path = fm.group(2)
104
+ handler = ""
105
+ description = ""
106
+ # look ahead for the def
107
+ for j in range(i + 1, min(i + 5, n)):
108
+ dm = _PY_FUNC_RE.match(lines[j])
109
+ if dm:
110
+ handler = dm.group(1)
111
+ # try to grab docstring
112
+ if j + 1 < n:
113
+ dsm = _DOCSTRING_RE.match(lines[j + 1])
114
+ if dsm:
115
+ description = (dsm.group(1) or dsm.group(2) or "").strip()
116
+ break
117
+ routes.append(
118
+ {
119
+ "method": method,
120
+ "path": route_path,
121
+ "handler": handler,
122
+ "description": description,
123
+ "file": path,
124
+ "line": i + 1,
125
+ }
126
+ )
127
+ i += 1
128
+ continue
129
+
130
+ # Flask-style: @app.route("/path", methods=["GET", "POST"])
131
+ flask_m = _FLASK_ROUTE_RE.search(line)
132
+ if flask_m:
133
+ route_path = flask_m.group(1)
134
+ raw = line[flask_m.start() :]
135
+ mm = _FLASK_METHODS_RE.search(raw)
136
+ if mm:
137
+ methods = [
138
+ m.strip().strip("'\"").upper()
139
+ for m in mm.group(1).split(",")
140
+ if m.strip().strip("'\"")
141
+ ]
142
+ else:
143
+ methods = ["GET"]
144
+ handler = ""
145
+ description = ""
146
+ for j in range(i + 1, min(i + 5, n)):
147
+ dm = _PY_FUNC_RE.match(lines[j])
148
+ if dm:
149
+ handler = dm.group(1)
150
+ if j + 1 < n:
151
+ dsm = _DOCSTRING_RE.match(lines[j + 1])
152
+ if dsm:
153
+ description = (dsm.group(1) or dsm.group(2) or "").strip()
154
+ break
155
+ for method in methods:
156
+ routes.append(
157
+ {
158
+ "method": method,
159
+ "path": route_path,
160
+ "handler": handler,
161
+ "description": description,
162
+ "file": path,
163
+ "line": i + 1,
164
+ }
165
+ )
166
+ i += 1
167
+ continue
168
+
169
+ # Django: path("...", view_func)
170
+ dj_m = _DJANGO_PATH_RE.search(line)
171
+ if dj_m:
172
+ route_path = dj_m.group(1)
173
+ handler = dj_m.group(2)
174
+ routes.append(
175
+ {
176
+ "method": "ANY",
177
+ "path": route_path,
178
+ "handler": handler,
179
+ "description": "",
180
+ "file": path,
181
+ "line": i + 1,
182
+ }
183
+ )
184
+ i += 1
185
+ continue
186
+
187
+ i += 1
188
+
189
+ # Annotate with prefix hints if found.
190
+ if prefix_hints and routes:
191
+ for r in routes:
192
+ if not r.get("description"):
193
+ r["description"] = f"(router mounted with prefix: {prefix_hints[0]})"
194
+
195
+ return routes
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Express / Next.js (JavaScript / TypeScript)
200
+ # ---------------------------------------------------------------------------
201
+
202
+ # app.get("/path", handler) or router.post("/path", ...)
203
+ _EXPRESS_ROUTE_RE = re.compile(
204
+ r"""(?:app|router)\.(get|post|put|delete|patch|options|head|all)\s*\(\s*""" + _QUOTE_CONTENT,
205
+ re.IGNORECASE,
206
+ )
207
+
208
+ # Next.js App Router: export (async) function GET/POST/PUT/DELETE/PATCH
209
+ _NEXTJS_HANDLER_RE = re.compile(
210
+ r"""export\s+(?:async\s+)?function\s+(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s*\(""",
211
+ )
212
+
213
+ # Next.js route file pattern: app/**/route.(ts|js)
214
+ _NEXTJS_ROUTE_FILE_RE = re.compile(r"""(?:^|/)route\.[jt]sx?$""")
215
+
216
+
217
+ def _js_routes(path: str, content: str) -> list[dict]:
218
+ """Extract routes from a JS/TS file (Express, Next.js)."""
219
+ routes: list[dict] = []
220
+ lines = content.splitlines()
221
+
222
+ is_nextjs_route = bool(_NEXTJS_ROUTE_FILE_RE.search(path.replace("\\", "/")))
223
+
224
+ for i, line in enumerate(lines):
225
+ if is_nextjs_route:
226
+ nm = _NEXTJS_HANDLER_RE.search(line)
227
+ if nm:
228
+ method = nm.group(1).upper()
229
+ # Derive the URL path from the file path:
230
+ # src/app/users/[id]/route.ts → /users/[id]
231
+ fp = path.replace("\\", "/")
232
+ # strip leading everything up to "app/"
233
+ app_idx = fp.find("/app/")
234
+ if app_idx >= 0:
235
+ rel = fp[app_idx + 5 :] # after /app/
236
+ # drop trailing /route.ts
237
+ rel = re.sub(r"/route\.[jt]sx?$", "", rel)
238
+ route_path = "/" + rel if rel else "/"
239
+ else:
240
+ route_path = "/" + fp.rsplit("/", 1)[-1].split(".")[0]
241
+ routes.append(
242
+ {
243
+ "method": method,
244
+ "path": route_path,
245
+ "handler": f"export function {method}",
246
+ "description": "",
247
+ "file": path,
248
+ "line": i + 1,
249
+ }
250
+ )
251
+ continue
252
+
253
+ em = _EXPRESS_ROUTE_RE.search(line)
254
+ if em:
255
+ method = em.group(1).upper()
256
+ route_path = em.group(2)
257
+ routes.append(
258
+ {
259
+ "method": method,
260
+ "path": route_path,
261
+ "handler": "",
262
+ "description": "",
263
+ "file": path,
264
+ "line": i + 1,
265
+ }
266
+ )
267
+
268
+ return routes
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Public API
273
+ # ---------------------------------------------------------------------------
274
+
275
+ _METHOD_ORDER = ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD", "ANY", "ALL"]
276
+
277
+
278
+ def scan_api_routes(file_contents: dict[str, str], *, limit: int = 80) -> dict:
279
+ """Scan file contents for HTTP route declarations.
280
+
281
+ Args:
282
+ file_contents: mapping of path to file text.
283
+ limit: cap on total routes returned.
284
+
285
+ Returns::
286
+
287
+ {
288
+ "total": int,
289
+ "routes": [
290
+ {
291
+ "method": str, # GET / POST / … / ANY / ALL
292
+ "path": str, # route path string as declared
293
+ "handler": str, # handler function/class name
294
+ "description": str,
295
+ "file": str,
296
+ "line": int,
297
+ }, …
298
+ ],
299
+ "frameworks": [str, …], # detected framework names
300
+ "notes": [str, …],
301
+ }
302
+ """
303
+ all_routes: list[dict] = []
304
+ frameworks: set[str] = set()
305
+
306
+ for path, content in file_contents.items():
307
+ if not content:
308
+ continue
309
+ lang_id = _lang(path)
310
+ if lang_id == "python":
311
+ routes = _py_routes(path, content)
312
+ if routes:
313
+ # guess framework from imports in file
314
+ if re.search(r"\bfastapi\b", content, re.IGNORECASE):
315
+ frameworks.add("FastAPI")
316
+ elif re.search(r"\bflask\b", content, re.IGNORECASE):
317
+ frameworks.add("Flask")
318
+ elif re.search(r"\bdjango\b", content, re.IGNORECASE):
319
+ frameworks.add("Django")
320
+ else:
321
+ frameworks.add("Python HTTP")
322
+ all_routes.extend(routes)
323
+ elif lang_id == "js":
324
+ routes = _js_routes(path, content)
325
+ if routes:
326
+ if _NEXTJS_ROUTE_FILE_RE.search(path.replace("\\", "/")):
327
+ frameworks.add("Next.js")
328
+ elif re.search(r"\bexpress\b", content, re.IGNORECASE):
329
+ frameworks.add("Express")
330
+ else:
331
+ frameworks.add("Node HTTP")
332
+ all_routes.extend(routes)
333
+
334
+ total = len(all_routes)
335
+
336
+ # Sort: by path, then method order, then file+line.
337
+ _method_idx = {m: i for i, m in enumerate(_METHOD_ORDER)}
338
+ all_routes.sort(
339
+ key=lambda r: (r["path"], _method_idx.get(r["method"], 99), r["file"], r["line"])
340
+ )
341
+
342
+ notes = _build_notes(total, list(frameworks))
343
+
344
+ return {
345
+ "total": total,
346
+ "routes": all_routes[:limit],
347
+ "frameworks": sorted(frameworks),
348
+ "notes": notes,
349
+ }
350
+
351
+
352
+ def _build_notes(total: int, frameworks: list[str]) -> list[str]:
353
+ if total == 0:
354
+ return ["未检测到已知 HTTP 框架的路由声明(FastAPI/Flask/Django/Express/Next.js)。"]
355
+ notes = []
356
+ if frameworks:
357
+ notes.append(f"检测到框架:{', '.join(frameworks)}。")
358
+ notes.append(
359
+ f"共发现 {total} 条路由声明。"
360
+ "动态生成的路径(变量、f-string 拼接)可能未被捕获,建议结合框架文档核查。"
361
+ )
362
+ return notes
363
+
364
+
365
+ def render_apimap_markdown(project_name: str, data: dict | None) -> str:
366
+ """Render the API route map as a Markdown section, or ``""`` if empty."""
367
+ if not data or not data.get("routes"):
368
+ return ""
369
+ total = data.get("total", 0)
370
+ frameworks = data.get("frameworks", [])
371
+ fw_str = f"({', '.join(frameworks)})" if frameworks else ""
372
+ lines = [
373
+ f"# HTTP 接口地图{fw_str}({project_name})",
374
+ "",
375
+ f"> 共发现 {total} 条路由声明。 动态路径可能未完整捕获,仅供导航参考。",
376
+ "",
377
+ ]
378
+ lines.extend(f"- {note}" for note in data.get("notes", []))
379
+
380
+ lines.append("")
381
+ lines.append("## 路由清单")
382
+ lines.append("")
383
+ lines.append("| 方法 | 路径 | 处理函数 | 说明 | 文件 |")
384
+ lines.append("|------|------|----------|------|------|")
385
+ for r in data.get("routes", []):
386
+ handler = r.get("handler") or "—"
387
+ desc = r.get("description") or "—"
388
+ file_link = f"`{r['file']}:{r['line']}`"
389
+ lines.append(f"| `{r['method']}` | `{r['path']}` | `{handler}` | {desc} | {file_link} |")
390
+
391
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,123 @@
1
+ """Async surface: which modules run on asyncio, and how much of each is async.
2
+
3
+ Whether a codebase is async — and which parts — changes how you call into it: an
4
+ ``async def`` has to be awaited or driven by an event loop, and dropping a
5
+ blocking call into an async path is a classic hang. This maps the async surface
6
+ so a newcomer sees at a glance whether they are in an asyncio codebase and where
7
+ the coroutines live, instead of finding out the first time an ``await`` is
8
+ missing.
9
+
10
+ A module is "async" when it defines at least one ``async def`` (counted anywhere
11
+ in the file, including nested helpers, since an inner coroutine still pulls its
12
+ caller onto the event loop). :func:`scan_async_surface` is pure over the file
13
+ contents (standard-library ``ast``); non-Python, test, and files that don't parse
14
+ are skipped.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+
21
+
22
+ def _is_test_path(path: str) -> bool:
23
+ """Async test helpers are noise for a "how is this project built" map, so tests drop out."""
24
+ lower = path.lower()
25
+ base = lower.rsplit("/", 1)[-1]
26
+ return (
27
+ "/tests/" in lower
28
+ or "/test/" in lower
29
+ or base.startswith("test_")
30
+ or base.endswith("_test.py")
31
+ or base == "conftest.py"
32
+ )
33
+
34
+
35
+ def _count_defs(tree: ast.Module) -> tuple[int, int]:
36
+ """Return (async_defs, sync_defs) for a parsed module, counting nested defs too."""
37
+ async_defs = 0
38
+ sync_defs = 0
39
+ for node in ast.walk(tree):
40
+ if isinstance(node, ast.AsyncFunctionDef):
41
+ async_defs += 1
42
+ elif isinstance(node, ast.FunctionDef):
43
+ sync_defs += 1
44
+ return async_defs, sync_defs
45
+
46
+
47
+ def scan_async_surface(file_contents: dict[str, str], *, limit: int = 15) -> dict:
48
+ """Map how much of the project is asynchronous, and where the coroutines live.
49
+
50
+ Args:
51
+ file_contents: mapping of path to file text (as CodeABC already read it).
52
+ limit: how many of the most-async files to return.
53
+
54
+ Returns ``{"async_functions", "total_functions", "async_modules", "files"}``
55
+ where ``async_modules`` is the count of files that define any coroutine and
56
+ ``files`` are the modules with the most ``async def``s (descending), each
57
+ ``{"path", "async_defs", "sync_defs"}``.
58
+ """
59
+ async_functions = 0
60
+ total_functions = 0
61
+ async_modules = 0
62
+ scored: list[dict] = []
63
+
64
+ for path, content in file_contents.items():
65
+ if not content or not path.endswith(".py") or _is_test_path(path):
66
+ continue
67
+ try:
68
+ tree = ast.parse(content)
69
+ except (SyntaxError, ValueError):
70
+ continue
71
+
72
+ async_defs, sync_defs = _count_defs(tree)
73
+ total_functions += async_defs + sync_defs
74
+ if async_defs == 0:
75
+ continue
76
+
77
+ async_functions += async_defs
78
+ async_modules += 1
79
+ scored.append({"path": path, "async_defs": async_defs, "sync_defs": sync_defs})
80
+
81
+ # Most coroutines first, then path for a stable order.
82
+ scored.sort(key=lambda f: (-f["async_defs"], f["path"]))
83
+ return {
84
+ "async_functions": async_functions,
85
+ "total_functions": total_functions,
86
+ "async_modules": async_modules,
87
+ "files": scored[:limit],
88
+ }
89
+
90
+
91
+ def render_async_surface_markdown(project_name: str, data: dict | None) -> str:
92
+ """Render the async-surface map as Markdown, or ``""`` when the project has no coroutines."""
93
+ data = data or {}
94
+ async_functions = data.get("async_functions") or 0
95
+ if not async_functions:
96
+ return ""
97
+
98
+ total = data.get("total_functions") or 0
99
+ modules = data.get("async_modules") or 0
100
+ files = data.get("files") or []
101
+ share = round(async_functions / total * 100) if total else 0
102
+
103
+ lines = [
104
+ f"# {project_name} — 异步面(哪些模块跑在 asyncio 上)",
105
+ "",
106
+ "> 一个项目是不是异步的、哪些部分是异步的,直接决定你怎么调用它:"
107
+ "`async def` 必须被 `await` 或事件循环驱动,往异步路径里塞一个阻塞调用是经典的卡死原因。"
108
+ "这里标出协程都在哪,方便新人一眼看出这是不是个 asyncio 代码库、异步逻辑集中在哪。"
109
+ "(只要文件里有一个 `async def` 就算异步模块,嵌套的协程也计入。)",
110
+ "",
111
+ f"**{async_functions} 个 async 函数**分布在 **{modules} 个模块**里"
112
+ f"(占全部函数的约 {share}%)。",
113
+ "",
114
+ ]
115
+ if files:
116
+ lines.append("异步逻辑最集中的文件:")
117
+ lines.append("")
118
+ for f in files:
119
+ lines.append(
120
+ f"- `{f['path']}` — {f['async_defs']} 个 async 函数"
121
+ f"(另有 {f['sync_defs']} 个同步函数)"
122
+ )
123
+ return "\n".join(lines).rstrip() + "\n"
@@ -0,0 +1,183 @@
1
+ """Simple SQLite cache for LLM results, keyed by content hash."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import time
8
+ from pathlib import Path
9
+
10
+ import aiosqlite
11
+
12
+ _DB_PATH = Path.home() / ".codeabc" / "cache.db"
13
+ _db: aiosqlite.Connection | None = None
14
+
15
+ # cache entries expire after 7 days
16
+ _TTL_SECONDS = 7 * 24 * 3600
17
+
18
+
19
+ async def init_db():
20
+ global _db
21
+ _DB_PATH.parent.mkdir(parents=True, exist_ok=True)
22
+ _db = await aiosqlite.connect(str(_DB_PATH))
23
+ await _db.execute("""
24
+ CREATE TABLE IF NOT EXISTS cache (
25
+ key TEXT PRIMARY KEY,
26
+ value TEXT NOT NULL,
27
+ created_at REAL NOT NULL
28
+ )
29
+ """)
30
+ # projects table: persist project metadata + file contents across restarts
31
+ await _db.execute("""
32
+ CREATE TABLE IF NOT EXISTS projects (
33
+ id TEXT PRIMARY KEY,
34
+ data TEXT NOT NULL,
35
+ created_at REAL NOT NULL
36
+ )
37
+ """)
38
+ # rate limiting: daily LLM call count per IP
39
+ await _db.execute("""
40
+ CREATE TABLE IF NOT EXISTS rate_limits (
41
+ ip TEXT NOT NULL,
42
+ date TEXT NOT NULL,
43
+ count INTEGER NOT NULL DEFAULT 0,
44
+ PRIMARY KEY (ip, date)
45
+ )
46
+ """)
47
+ await _db.commit()
48
+
49
+
50
+ def content_hash(content: str) -> str:
51
+ return hashlib.sha256(content.encode()).hexdigest()[:24]
52
+
53
+
54
+ async def get(key: str) -> dict | list | None:
55
+ if _db is None:
56
+ return None
57
+ async with _db.execute("SELECT value, created_at FROM cache WHERE key = ?", (key,)) as cursor:
58
+ row = await cursor.fetchone()
59
+ if row is None:
60
+ return None
61
+ value, created_at = row
62
+ if time.time() - created_at > _TTL_SECONDS:
63
+ await _db.execute("DELETE FROM cache WHERE key = ?", (key,))
64
+ await _db.commit()
65
+ return None
66
+ return json.loads(value)
67
+
68
+
69
+ async def put(key: str, value: dict | list):
70
+ if _db is None:
71
+ return
72
+ data = json.dumps(value, ensure_ascii=False)
73
+ await _db.execute(
74
+ "INSERT OR REPLACE INTO cache (key, value, created_at) VALUES (?, ?, ?)",
75
+ (key, data, time.time()),
76
+ )
77
+ await _db.commit()
78
+
79
+
80
+ async def save_project(project_id: str, data: dict):
81
+ """Persist project data (metadata + file contents) to SQLite."""
82
+ if _db is None:
83
+ return
84
+ payload = json.dumps(data, ensure_ascii=False)
85
+ await _db.execute(
86
+ "INSERT OR REPLACE INTO projects (id, data, created_at) VALUES (?, ?, ?)",
87
+ (project_id, payload, time.time()),
88
+ )
89
+ await _db.commit()
90
+
91
+
92
+ _DAILY_LIMIT = 20
93
+
94
+
95
+ async def check_rate_limit(ip: str) -> tuple[bool, int]:
96
+ """Check if IP is within daily free limit.
97
+
98
+ Returns (allowed, remaining).
99
+ """
100
+ if _db is None:
101
+ return True, _DAILY_LIMIT
102
+
103
+ today = time.strftime("%Y-%m-%d")
104
+ async with _db.execute(
105
+ "SELECT count FROM rate_limits WHERE ip = ? AND date = ?", (ip, today)
106
+ ) as cursor:
107
+ row = await cursor.fetchone()
108
+
109
+ current = row[0] if row else 0
110
+ remaining = max(0, _DAILY_LIMIT - current)
111
+ return current < _DAILY_LIMIT, remaining
112
+
113
+
114
+ async def increment_rate_limit(ip: str):
115
+ """Bump the daily usage counter for an IP."""
116
+ if _db is None:
117
+ return
118
+ today = time.strftime("%Y-%m-%d")
119
+ await _db.execute(
120
+ """INSERT INTO rate_limits (ip, date, count) VALUES (?, ?, 1)
121
+ ON CONFLICT (ip, date) DO UPDATE SET count = count + 1""",
122
+ (ip, today),
123
+ )
124
+ await _db.commit()
125
+
126
+
127
+ async def load_project(project_id: str) -> dict | None:
128
+ """Load project data from SQLite. Returns None if not found or expired."""
129
+ if _db is None:
130
+ return None
131
+ async with _db.execute(
132
+ "SELECT data, created_at FROM projects WHERE id = ?", (project_id,)
133
+ ) as cursor:
134
+ row = await cursor.fetchone()
135
+ if row is None:
136
+ return None
137
+ data, created_at = row
138
+ if time.time() - created_at > _TTL_SECONDS:
139
+ await _db.execute("DELETE FROM projects WHERE id = ?", (project_id,))
140
+ await _db.commit()
141
+ return None
142
+ return json.loads(data)
143
+
144
+
145
+ async def list_projects(limit: int = 50) -> list[dict]:
146
+ """List stored projects, newest first, for the on-disk library view.
147
+
148
+ Returns lightweight entries (id, name, total_files, created_at) so a user
149
+ can reopen a past analysis without re-scanning. The heavy file contents stay
150
+ in the row and are only read back when a project is actually opened via
151
+ load_project(). Expired rows are skipped (and purged lazily, like get()).
152
+ """
153
+ if _db is None:
154
+ return []
155
+ async with _db.execute(
156
+ "SELECT id, data, created_at FROM projects ORDER BY created_at DESC"
157
+ ) as cursor:
158
+ rows = await cursor.fetchall()
159
+ now = time.time()
160
+ expired: list[str] = []
161
+ projects: list[dict] = []
162
+ for project_id, data, created_at in rows:
163
+ if now - created_at > _TTL_SECONDS:
164
+ expired.append(project_id)
165
+ continue
166
+ if len(projects) >= limit:
167
+ continue
168
+ try:
169
+ parsed = json.loads(data)
170
+ except (json.JSONDecodeError, ValueError):
171
+ continue
172
+ projects.append(
173
+ {
174
+ "id": project_id,
175
+ "name": parsed.get("name") or project_id,
176
+ "total_files": len(parsed.get("files") or []),
177
+ "created_at": created_at,
178
+ }
179
+ )
180
+ if expired:
181
+ await _db.executemany("DELETE FROM projects WHERE id = ?", [(pid,) for pid in expired])
182
+ await _db.commit()
183
+ return projects