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,62 @@
1
+ """Prompt + helper for natural-language code editing.
2
+
3
+ CodeABC never writes to the user's files; this produces a *suggested* edited
4
+ version of a snippet that the reader can review and copy. The prompt asks the
5
+ model to apply a plain-language instruction (e.g. "把茅台换成比亚迪") and
6
+ return only the modified code, which `extract_code_block` then unwraps.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ _MAX_CODE_CHARS = 8000
12
+
13
+
14
+ def build_edit_prompt(
15
+ instruction: str,
16
+ code: str,
17
+ *,
18
+ file_path: str = "",
19
+ language: str = "",
20
+ ) -> str:
21
+ """Build the LLM prompt for applying a natural-language edit to *code*."""
22
+ code = code.strip()
23
+ if len(code) > _MAX_CODE_CHARS:
24
+ code = code[:_MAX_CODE_CHARS] + "\n…(代码较长,已截断)"
25
+
26
+ where = f"(来自文件 `{file_path}`)" if file_path else ""
27
+ fence = language or ""
28
+
29
+ return f"""你是一个细心的编程助手。下面是一段{language}代码{where}:
30
+
31
+ ```{fence}
32
+ {code}
33
+ ```
34
+
35
+ 用户想这样修改它:
36
+ {instruction.strip()}
37
+
38
+ 请按用户的要求修改代码,遵守:
39
+ 1. 只做用户要求的改动,其余部分**原样保留**,不要顺手重构、改风格或加注释。
40
+ 2. 保持原有缩进、命名习惯和语言不变。
41
+ 3. 如果要求不清楚或在这段代码里无法完成,就原样返回代码不要乱改。
42
+
43
+ 只输出修改后的**完整代码**,用一个 ```{fence} 代码块``` 包裹,前后不要写任何解释或多余文字。"""
44
+
45
+
46
+ def extract_code_block(text: str) -> str:
47
+ """Unwrap a fenced code block from LLM output; fall back to stripped text.
48
+
49
+ Tolerates an optional language tag after the opening fence and any stray
50
+ prose the model adds outside the fence.
51
+ """
52
+ text = text.strip()
53
+ start = text.find("```")
54
+ if start == -1:
55
+ return text
56
+ # skip the opening fence line (which may carry a language tag)
57
+ newline = text.find("\n", start)
58
+ if newline == -1:
59
+ return text
60
+ end = text.find("```", newline)
61
+ inner = text[newline + 1 : end] if end != -1 else text[newline + 1 :]
62
+ return inner.strip("\n")
@@ -0,0 +1,57 @@
1
+ """Prompt for generating project overview ("项目说明书")."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def build_overview_prompt(files: list[dict]) -> str:
7
+ """Build the LLM prompt for project overview generation.
8
+
9
+ Args:
10
+ files: list of {"path": str, "size": int, "language": str, "preview": str}
11
+ """
12
+ # build file context
13
+ file_sections = []
14
+ for f in files:
15
+ preview = f.get("preview", "").strip()
16
+ if not preview:
17
+ file_sections.append(f"### {f['path']} ({f['language']}, {f['size']} bytes)\n(empty)")
18
+ continue
19
+ # truncate very long previews
20
+ if len(preview) > 3000:
21
+ preview = preview[:3000] + "\n... (truncated)"
22
+ file_sections.append(
23
+ f"### {f['path']} ({f['language']}, {f['size']} bytes)\n```\n{preview}\n```"
24
+ )
25
+
26
+ files_context = "\n\n".join(file_sections)
27
+
28
+ return f"""你是一个耐心的编程导师,正在帮一个完全不懂代码的人理解一个项目。
29
+ 请用中文、大白话来解释,不要使用任何编程专业术语。
30
+ 如果必须提到技术概念,请用日常生活的类比来解释(比如把函数比作"一个做特定事情的小助手")。
31
+
32
+ 下面是这个项目的文件列表和部分内容:
33
+
34
+ {files_context}
35
+
36
+ 请根据以上信息,生成一份"项目说明书"。严格输出以下 JSON 格式(不要输出其他内容):
37
+
38
+ {{
39
+ "summary": "一句话说明这个项目做什么(20字以内)",
40
+ "description": "用2-3句大白话详细解释这个项目的用途,让完全不懂代码的人也能理解",
41
+ "files": [
42
+ {{"path": "文件路径", "role": "大白话描述作用", "importance": "high/medium/low"}}
43
+ ],
44
+ "how_to_run": [
45
+ "步骤1:...",
46
+ "步骤2:..."
47
+ ],
48
+ "quick_tips": [
49
+ "如果你只想做XX,去YY文件改ZZ就行"
50
+ ]
51
+ }}
52
+
53
+ 注意:
54
+ - files 数组要包含所有重要文件,按重要性排序
55
+ - importance 为 high 的是核心文件(入口、主要逻辑),medium 是辅助文件,low 是配置/资源
56
+ - how_to_run 要写得足够具体,让不懂代码的人也能照着做
57
+ - quick_tips 给出1-3个最实用的提示"""
backend/prompts/qa.py ADDED
@@ -0,0 +1,45 @@
1
+ """Prompt for answering a reader's question about a piece of code."""
2
+
3
+ from __future__ import annotations
4
+
5
+ _MAX_CODE_CHARS = 8000
6
+
7
+
8
+ def build_qa_prompt(
9
+ question: str,
10
+ code: str,
11
+ *,
12
+ file_path: str = "",
13
+ language: str = "",
14
+ ) -> str:
15
+ """Build the LLM prompt for Q&A about a code selection.
16
+
17
+ Args:
18
+ question: the reader's question, in their own words.
19
+ code: the selected code (or whole file) the question is about.
20
+ file_path: optional path, to ground the answer.
21
+ language: optional language name, for the code fence.
22
+ """
23
+ code = code.strip()
24
+ if len(code) > _MAX_CODE_CHARS:
25
+ code = code[:_MAX_CODE_CHARS] + "\n…(代码较长,已截断)"
26
+
27
+ where = f"(来自文件 `{file_path}`)" if file_path else ""
28
+ fence = language or ""
29
+ code_block = f"```{fence}\n{code}\n```\n\n" if code else ""
30
+
31
+ return f"""你是一个耐心的编程导师,正在帮一个不太懂编程的人读懂一段代码。
32
+
33
+ 下面是用户选中的代码{where}:
34
+
35
+ {code_block}用户的问题是:
36
+ {question.strip()}
37
+
38
+ 请用中文、大白话回答:
39
+ 1. 直接回答问题,先给结论再展开,别绕圈子。
40
+ 2. 尽量少用编程术语;必须用时,顺手用一句日常类比解释清楚。
41
+ 3. 只依据给出的代码和通用编程常识回答;代码里看不出来的,就如实说"这段代码里看不出来",不要编造。
42
+ 4. 如果问题和这段代码无关,礼貌说明并尽量给出有用的方向。
43
+ 5. 回答简洁,控制在几段以内,必要时可用短列表。
44
+
45
+ 直接输出答案正文,不要重复问题、不要加多余的客套。"""
File without changes
@@ -0,0 +1,271 @@
1
+ """Analysis routes: generate project overview and file annotations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+
8
+ from fastapi import APIRouter, HTTPException, Request
9
+ from fastapi.responses import StreamingResponse
10
+
11
+ from backend.models import EditRequest, QARequest
12
+ from backend.prompts.annotate import build_annotation_prompt
13
+ from backend.prompts.edit import build_edit_prompt, extract_code_block
14
+ from backend.prompts.overview import build_overview_prompt
15
+ from backend.prompts.qa import build_qa_prompt
16
+ from backend.routers.project import get_project_data
17
+ from backend.services import cache
18
+ from backend.services.llm import call_llm, is_error_text, stream_llm
19
+
20
+ logger = logging.getLogger(__name__)
21
+ router = APIRouter(tags=["analyze"])
22
+
23
+
24
+ def _extract_json(text: str) -> dict | list | None:
25
+ """Try to parse JSON from LLM output, handling markdown code fences."""
26
+ text = text.strip()
27
+ # strip ```json ... ``` wrapper
28
+ if text.startswith("```"):
29
+ first_nl = text.find("\n")
30
+ last_fence = text.rfind("```")
31
+ if first_nl != -1 and last_fence > first_nl:
32
+ text = text[first_nl + 1 : last_fence].strip()
33
+ try:
34
+ return json.loads(text)
35
+ except json.JSONDecodeError:
36
+ pass
37
+ # fallback: find first [ or { and extract
38
+ for opener, closer in [("[", "]"), ("{", "}")]:
39
+ start = text.find(opener)
40
+ end = text.rfind(closer)
41
+ if start != -1 and end > start:
42
+ try:
43
+ return json.loads(text[start : end + 1])
44
+ except json.JSONDecodeError:
45
+ continue
46
+ return None
47
+
48
+
49
+ def _coerce_annotation_list(parsed: dict | list | None) -> list:
50
+ """Normalize the model's parsed JSON into a list of annotations.
51
+
52
+ The prompt asks for a bare JSON array, but models sometimes wrap it in an
53
+ object (``{"annotations": [...]}``, ``{"批注": [...]}``, ...). Salvage the
54
+ array in that case instead of silently dropping every annotation and showing
55
+ the reader a file with no explanations.
56
+ """
57
+ if isinstance(parsed, list):
58
+ return parsed
59
+ if isinstance(parsed, dict):
60
+ for value in parsed.values():
61
+ if isinstance(value, list):
62
+ return value
63
+ return []
64
+
65
+
66
+ def _client_ip(request: Request) -> str:
67
+ """Get the real client IP, respecting X-Forwarded-For behind a proxy."""
68
+ forwarded = request.headers.get("x-forwarded-for")
69
+ if forwarded:
70
+ return forwarded.split(",")[0].strip()
71
+ return request.client.host if request.client else "unknown"
72
+
73
+
74
+ async def _enforce_rate_limit(request: Request):
75
+ """Raise 429 if the free-tier user has exceeded the daily limit.
76
+
77
+ BYOK users (with x-api-key header) are never rate-limited.
78
+ """
79
+ if request.headers.get("x-api-key"):
80
+ return # user brought their own key, no limit
81
+
82
+ ip = _client_ip(request)
83
+ allowed, remaining = await cache.check_rate_limit(ip)
84
+ if not allowed:
85
+ raise HTTPException(
86
+ 429,
87
+ "今日免费额度已用完(每天 20 次)。点击右上角设置,填入自己的 API Key 即可无限使用。",
88
+ )
89
+ # bump count now (before the actual LLM call)
90
+ await cache.increment_rate_limit(ip)
91
+
92
+
93
+ @router.get("/project/{project_id}/overview")
94
+ async def get_overview(project_id: str, request: Request):
95
+ """Generate or return cached project overview. Streams SSE."""
96
+ proj = await get_project_data(project_id)
97
+ if not proj:
98
+ raise HTTPException(404, "Project not found")
99
+
100
+ # build a cache key from file paths + sizes
101
+ files_sig = "|".join(f"{f['path']}:{f['size']}" for f in proj["files"])
102
+ cache_key = f"overview:{cache.content_hash(files_sig)}"
103
+
104
+ # check cache first (cached results are free, no rate limit)
105
+ cached = await cache.get(cache_key)
106
+ if cached:
107
+
108
+ async def cached_stream():
109
+ yield f"data: {json.dumps({'result': cached}, ensure_ascii=False)}\n\n"
110
+ yield "data: [DONE]\n\n"
111
+
112
+ return StreamingResponse(cached_stream(), media_type="text/event-stream")
113
+
114
+ # not cached -> requires LLM call -> check rate limit
115
+ await _enforce_rate_limit(request)
116
+
117
+ prompt = build_overview_prompt(proj["files"])
118
+ api_key = request.headers.get("x-api-key")
119
+
120
+ async def generate():
121
+ full_response = ""
122
+ errored = False
123
+ async for chunk in stream_llm(prompt, api_key=api_key):
124
+ full_response += chunk
125
+ # Don't stream a raw "[LLM Error: ...]" into the reader's view; once
126
+ # we see the sentinel, hold it back and report it as an error below.
127
+ if is_error_text(full_response):
128
+ errored = True
129
+ continue
130
+ yield f"data: {json.dumps({'chunk': chunk}, ensure_ascii=False)}\n\n"
131
+
132
+ if errored:
133
+ yield f"data: {json.dumps({'error': full_response}, ensure_ascii=False)}\n\n"
134
+ yield "data: [DONE]\n\n"
135
+ return
136
+
137
+ # try to parse and cache the full response
138
+ parsed = _extract_json(full_response)
139
+ if parsed and isinstance(parsed, dict):
140
+ await cache.put(cache_key, parsed)
141
+ yield f"data: {json.dumps({'result': parsed}, ensure_ascii=False)}\n\n"
142
+ else:
143
+ yield f"data: {json.dumps({'raw': full_response}, ensure_ascii=False)}\n\n"
144
+
145
+ yield "data: [DONE]\n\n"
146
+
147
+ return StreamingResponse(generate(), media_type="text/event-stream")
148
+
149
+
150
+ @router.get("/project/{project_id}/file/{file_path:path}/annotations")
151
+ async def get_annotations(project_id: str, file_path: str, request: Request):
152
+ """Generate or return cached annotations for a file."""
153
+ proj = await get_project_data(project_id)
154
+ if not proj:
155
+ raise HTTPException(404, "Project not found")
156
+
157
+ content = proj.get("file_contents", {}).get(file_path)
158
+ if content is None:
159
+ raise HTTPException(404, f"File not found: {file_path}")
160
+
161
+ # detect language
162
+ lang = "python"
163
+ for f in proj["files"]:
164
+ if f["path"] == file_path:
165
+ lang = f["language"]
166
+ break
167
+
168
+ # cache by file content (cached results are free)
169
+ cache_key = f"annotate:{cache.content_hash(content)}"
170
+ cached = await cache.get(cache_key)
171
+ if cached:
172
+ return {"path": file_path, "language": lang, "annotations": cached}
173
+
174
+ # not cached -> LLM call -> rate limit
175
+ await _enforce_rate_limit(request)
176
+
177
+ prompt = build_annotation_prompt(content, lang)
178
+ api_key = request.headers.get("x-api-key")
179
+
180
+ result = await call_llm(prompt, api_key=api_key)
181
+
182
+ parsed = _extract_json(result)
183
+ annotations = _coerce_annotation_list(parsed)
184
+
185
+ if annotations:
186
+ await cache.put(cache_key, annotations)
187
+
188
+ return {"path": file_path, "language": lang, "annotations": annotations}
189
+
190
+
191
+ @router.post("/project/{project_id}/qa")
192
+ async def ask_question(project_id: str, req: QARequest, request: Request):
193
+ """Answer a free-form question about a selected piece of code.
194
+
195
+ Powers "select code and ask questions". Caches by question+code so a
196
+ repeated question is free and doesn't count against the rate limit.
197
+ """
198
+ proj = await get_project_data(project_id)
199
+ if not proj:
200
+ raise HTTPException(404, "Project not found")
201
+
202
+ question = req.question.strip()
203
+ if not question:
204
+ raise HTTPException(400, "Question must not be empty")
205
+
206
+ combined = question + "\x00" + req.code
207
+ cache_key = f"qa:{cache.content_hash(combined)}"
208
+ cached = await cache.get(cache_key)
209
+ if isinstance(cached, dict) and cached.get("answer"):
210
+ return {"answer": cached["answer"]}
211
+
212
+ await _enforce_rate_limit(request)
213
+
214
+ prompt = build_qa_prompt(
215
+ question,
216
+ req.code,
217
+ file_path=req.file_path,
218
+ language=req.language,
219
+ )
220
+ api_key = request.headers.get("x-api-key")
221
+ answer = (await call_llm(prompt, api_key=api_key)).strip()
222
+ if is_error_text(answer):
223
+ raise HTTPException(502, answer)
224
+
225
+ if answer:
226
+ await cache.put(cache_key, {"answer": answer})
227
+
228
+ return {"answer": answer}
229
+
230
+
231
+ @router.post("/project/{project_id}/edit")
232
+ async def edit_code(project_id: str, req: EditRequest, request: Request):
233
+ """Apply a natural-language instruction to a code snippet.
234
+
235
+ Returns a *suggested* edited version (CodeABC never writes to disk).
236
+ Cached by instruction+code so repeats are free.
237
+ """
238
+ proj = await get_project_data(project_id)
239
+ if not proj:
240
+ raise HTTPException(404, "Project not found")
241
+
242
+ instruction = req.instruction.strip()
243
+ if not instruction:
244
+ raise HTTPException(400, "Instruction must not be empty")
245
+ if not req.code.strip():
246
+ raise HTTPException(400, "Code must not be empty")
247
+
248
+ combined = instruction + "\x00" + req.code
249
+ cache_key = f"edit:{cache.content_hash(combined)}"
250
+ cached = await cache.get(cache_key)
251
+ if isinstance(cached, dict) and cached.get("edited_code"):
252
+ return {"edited_code": cached["edited_code"]}
253
+
254
+ await _enforce_rate_limit(request)
255
+
256
+ prompt = build_edit_prompt(
257
+ instruction,
258
+ req.code,
259
+ file_path=req.file_path,
260
+ language=req.language,
261
+ )
262
+ api_key = request.headers.get("x-api-key")
263
+ raw = await call_llm(prompt, api_key=api_key)
264
+ if is_error_text(raw):
265
+ raise HTTPException(502, raw)
266
+ edited = extract_code_block(raw)
267
+
268
+ if edited:
269
+ await cache.put(cache_key, {"edited_code": edited})
270
+
271
+ return {"edited_code": edited}