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.
- backend/__init__.py +0 -0
- backend/app.py +95 -0
- backend/desktop_server.py +46 -0
- backend/models.py +629 -0
- backend/prompts/__init__.py +0 -0
- backend/prompts/annotate.py +44 -0
- backend/prompts/edit.py +62 -0
- backend/prompts/overview.py +57 -0
- backend/prompts/qa.py +45 -0
- backend/routers/__init__.py +0 -0
- backend/routers/analyze.py +271 -0
- backend/routers/project.py +872 -0
- backend/services/__init__.py +0 -0
- backend/services/action_plan.py +366 -0
- backend/services/activity.py +313 -0
- backend/services/apimap.py +391 -0
- backend/services/async_surface.py +123 -0
- backend/services/cache.py +183 -0
- backend/services/churn.py +244 -0
- backend/services/ci_checks.py +317 -0
- backend/services/codemap_export.py +136 -0
- backend/services/commands.py +276 -0
- backend/services/complexity.py +111 -0
- backend/services/config_files.py +190 -0
- backend/services/contributing.py +308 -0
- backend/services/coverage.py +283 -0
- backend/services/datamodels.py +190 -0
- backend/services/deep_nesting.py +110 -0
- backend/services/dependencies.py +270 -0
- backend/services/docs.py +255 -0
- backend/services/docstrings.py +163 -0
- backend/services/duplicate_code.py +165 -0
- backend/services/entrypoints.py +152 -0
- backend/services/envscan.py +167 -0
- backend/services/error_handling.py +239 -0
- backend/services/filenames.py +261 -0
- backend/services/github_clone.py +129 -0
- backend/services/glossary.py +96 -0
- backend/services/health_score.py +381 -0
- backend/services/importgraph.py +986 -0
- backend/services/integrations.py +263 -0
- backend/services/licenses.py +505 -0
- backend/services/llm.py +117 -0
- backend/services/long_functions.py +101 -0
- backend/services/ownership.py +194 -0
- backend/services/package_purposes.py +112 -0
- backend/services/pr_reading.py +181 -0
- backend/services/release_map.py +402 -0
- backend/services/report_export.py +232 -0
- backend/services/risk.py +88 -0
- backend/services/scanner.py +546 -0
- backend/services/schedules.py +369 -0
- backend/services/security.py +445 -0
- backend/services/settings_map.py +234 -0
- backend/services/symbols.py +605 -0
- backend/services/techdebt.py +124 -0
- backend/services/too_many_params.py +102 -0
- backend/services/typing_coverage.py +184 -0
- codeabc-0.1.0.dist-info/METADATA +283 -0
- codeabc-0.1.0.dist-info/RECORD +62 -0
- codeabc-0.1.0.dist-info/WHEEL +4 -0
- codeabc-0.1.0.dist-info/licenses/LICENSE +21 -0
backend/prompts/edit.py
ADDED
|
@@ -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}
|