doc-code 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.
- doc_code/__init__.py +3 -0
- doc_code/ai.py +206 -0
- doc_code/cli.py +593 -0
- doc_code/config.py +362 -0
- doc_code/editor.py +496 -0
- doc_code/errors.py +21 -0
- doc_code/git.py +74 -0
- doc_code/py.typed +1 -0
- doc_code/scope.py +105 -0
- doc_code/symbols.py +594 -0
- doc_code-0.1.0.dist-info/METADATA +138 -0
- doc_code-0.1.0.dist-info/RECORD +16 -0
- doc_code-0.1.0.dist-info/WHEEL +5 -0
- doc_code-0.1.0.dist-info/entry_points.txt +2 -0
- doc_code-0.1.0.dist-info/licenses/LICENSE +21 -0
- doc_code-0.1.0.dist-info/top_level.txt +1 -0
doc_code/__init__.py
ADDED
doc_code/ai.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Provider calls and strict JSON response validation for documentation descriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from math import ceil
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
from urllib.error import URLError
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
from .config import Settings
|
|
14
|
+
from .errors import AIProviderError, AITimeoutError, DocGubError, InvalidAIResponseError
|
|
15
|
+
from .symbols import Documentation, Symbol
|
|
16
|
+
|
|
17
|
+
_MAX_PROVIDER_RESPONSE_BYTES = 1_000_000
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def estimate_tokens(text: str) -> int:
|
|
21
|
+
"""Estimate tokens conservatively from the UTF-8 byte length."""
|
|
22
|
+
return ceil(len(text.encode("utf-8")) / 3)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def prompt(content: str, symbols: list[Symbol], language: str = "English") -> str:
|
|
26
|
+
"""Build the strict JSON prompt sent to a provider."""
|
|
27
|
+
names = [{"symbol": item.name, "kind": item.kind, "arguments": item.args} for item in symbols]
|
|
28
|
+
return (
|
|
29
|
+
"Return only a JSON object mapping each requested symbol to an object with "
|
|
30
|
+
"`description` (a concise factual description) and `arguments` (an object mapping "
|
|
31
|
+
"every requested argument name to its specific description). Use an empty `arguments` "
|
|
32
|
+
"object when a symbol has no arguments. Do not include Markdown or code fences. Write "
|
|
33
|
+
f"all documentation text in {json.dumps(language, ensure_ascii=False)}. Requested symbols: "
|
|
34
|
+
+ json.dumps(names)
|
|
35
|
+
+ "\n\nSOURCE:\n"
|
|
36
|
+
+ content
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _post(
|
|
41
|
+
url: str, payload: dict[str, Any], headers: dict[str, str], timeout: int
|
|
42
|
+
) -> dict[str, Any]:
|
|
43
|
+
"""Post JSON and convert transport or decoding failures to domain errors."""
|
|
44
|
+
parsed = urlparse(url)
|
|
45
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
46
|
+
raise AIProviderError("The AI provider endpoint must be an absolute HTTP(S) URL.")
|
|
47
|
+
try:
|
|
48
|
+
with urlopen(
|
|
49
|
+
Request(url, data=json.dumps(payload).encode(), headers=headers, method="POST"),
|
|
50
|
+
timeout=timeout,
|
|
51
|
+
) as response: # nosec B310 - user-configured endpoint
|
|
52
|
+
body = response.read(_MAX_PROVIDER_RESPONSE_BYTES + 1)
|
|
53
|
+
if len(body) > _MAX_PROVIDER_RESPONSE_BYTES:
|
|
54
|
+
raise AIProviderError("The AI provider response exceeds the supported size.")
|
|
55
|
+
decoded = json.loads(body)
|
|
56
|
+
if not isinstance(decoded, dict):
|
|
57
|
+
raise TypeError("The AI provider response must be a JSON object.")
|
|
58
|
+
return cast(dict[str, Any], decoded)
|
|
59
|
+
except TimeoutError as exc:
|
|
60
|
+
raise AITimeoutError(f"Unable to contact the AI provider: {exc}") from exc
|
|
61
|
+
except URLError as exc:
|
|
62
|
+
if isinstance(exc.reason, TimeoutError):
|
|
63
|
+
raise AITimeoutError(f"Unable to contact the AI provider: {exc.reason}") from exc
|
|
64
|
+
raise AIProviderError(f"Unable to contact the AI provider: {exc}") from exc
|
|
65
|
+
except (json.JSONDecodeError, KeyError, IndexError, TypeError) as exc:
|
|
66
|
+
raise AIProviderError("The AI provider returned an invalid response.") from exc
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def documentation_for(
|
|
70
|
+
content: str, symbols: list[Symbol], settings: Settings
|
|
71
|
+
) -> dict[str, Documentation]:
|
|
72
|
+
"""Generate validated documentation for the requested symbols."""
|
|
73
|
+
body = prompt(content, symbols, settings.language)
|
|
74
|
+
tokens = estimate_tokens(body)
|
|
75
|
+
if (
|
|
76
|
+
tokens > settings.max_input_tokens
|
|
77
|
+
or tokens + settings.max_output_tokens > settings.context_window_tokens
|
|
78
|
+
):
|
|
79
|
+
raise DocGubError(
|
|
80
|
+
"AI input exceeds configured token limits; narrow the scope or increase limits."
|
|
81
|
+
)
|
|
82
|
+
if settings.provider == "openai":
|
|
83
|
+
key = os.getenv("OPENAI_API_KEY")
|
|
84
|
+
if not key:
|
|
85
|
+
raise DocGubError("OPENAI_API_KEY is not configured.")
|
|
86
|
+
data = _post(
|
|
87
|
+
settings.endpoint or "https://api.openai.com/v1/chat/completions",
|
|
88
|
+
{
|
|
89
|
+
"model": settings.model,
|
|
90
|
+
"messages": [{"role": "user", "content": body}],
|
|
91
|
+
"max_completion_tokens": settings.max_output_tokens,
|
|
92
|
+
"temperature": settings.temperature,
|
|
93
|
+
},
|
|
94
|
+
{"Authorization": f"Bearer {key}", "Content-Type": "application/json"},
|
|
95
|
+
settings.timeout_seconds,
|
|
96
|
+
)
|
|
97
|
+
answer = _provider_answer(data, "openai")
|
|
98
|
+
elif settings.provider == "gemini":
|
|
99
|
+
key = os.getenv("GEMINI_API_KEY")
|
|
100
|
+
if not key:
|
|
101
|
+
raise DocGubError("GEMINI_API_KEY is not configured.")
|
|
102
|
+
endpoint = settings.endpoint or (
|
|
103
|
+
"https://generativelanguage.googleapis.com/v1beta/models/"
|
|
104
|
+
f"{settings.model}:generateContent"
|
|
105
|
+
)
|
|
106
|
+
data = _post(
|
|
107
|
+
endpoint,
|
|
108
|
+
{"contents": [{"parts": [{"text": body}]}]},
|
|
109
|
+
{"Content-Type": "application/json", "x-goog-api-key": key},
|
|
110
|
+
settings.timeout_seconds,
|
|
111
|
+
)
|
|
112
|
+
answer = _provider_answer(data, "gemini")
|
|
113
|
+
else:
|
|
114
|
+
data = _post(
|
|
115
|
+
settings.endpoint or "http://localhost:11434/api/generate",
|
|
116
|
+
{
|
|
117
|
+
"model": settings.model,
|
|
118
|
+
"prompt": body,
|
|
119
|
+
"stream": False,
|
|
120
|
+
"options": {
|
|
121
|
+
"temperature": settings.temperature,
|
|
122
|
+
"num_ctx": settings.context_window_tokens,
|
|
123
|
+
"num_predict": settings.max_output_tokens,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{"Content-Type": "application/json"},
|
|
127
|
+
settings.timeout_seconds,
|
|
128
|
+
)
|
|
129
|
+
answer = _provider_answer(data, "ollama")
|
|
130
|
+
return _documentation_response(answer, symbols)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _provider_answer(data: dict[str, Any], provider: str) -> str:
|
|
134
|
+
"""Extract provider text while keeping malformed envelopes inside the domain boundary."""
|
|
135
|
+
try:
|
|
136
|
+
if provider == "openai":
|
|
137
|
+
answer = data["choices"][0]["message"]["content"]
|
|
138
|
+
elif provider == "gemini":
|
|
139
|
+
answer = data["candidates"][0]["content"]["parts"][0]["text"]
|
|
140
|
+
else:
|
|
141
|
+
answer = data["response"]
|
|
142
|
+
except (KeyError, IndexError, TypeError) as exc:
|
|
143
|
+
raise AIProviderError(f"The {provider} provider returned an invalid response.") from exc
|
|
144
|
+
if not isinstance(answer, str):
|
|
145
|
+
raise AIProviderError(f"The {provider} provider returned an invalid response.")
|
|
146
|
+
return answer
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _documentation_response(answer: str, symbols: list[Symbol]) -> dict[str, Documentation]:
|
|
150
|
+
"""Validate and normalize the structured documentation returned by a provider."""
|
|
151
|
+
if not isinstance(answer, str):
|
|
152
|
+
raise InvalidAIResponseError("The AI returned documentation in an invalid format.")
|
|
153
|
+
try:
|
|
154
|
+
parsed = json.loads(answer)
|
|
155
|
+
except (json.JSONDecodeError, TypeError) as exc:
|
|
156
|
+
raise InvalidAIResponseError("The AI returned invalid documentation JSON.") from exc
|
|
157
|
+
if not isinstance(parsed, dict):
|
|
158
|
+
raise InvalidAIResponseError("The AI response must be a JSON object of documentation.")
|
|
159
|
+
|
|
160
|
+
requested = {symbol.name: symbol for symbol in symbols}
|
|
161
|
+
if set(parsed) != set(requested):
|
|
162
|
+
raise InvalidAIResponseError("The AI response must document every requested symbol.")
|
|
163
|
+
normalized: dict[str, Documentation] = {}
|
|
164
|
+
for name, value in parsed.items():
|
|
165
|
+
if not isinstance(name, str) or name not in requested:
|
|
166
|
+
raise InvalidAIResponseError("The AI response contains an unexpected symbol.")
|
|
167
|
+
normalized[name] = _normalize_documentation(name, value, requested[name])
|
|
168
|
+
return normalized
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _normalize_documentation(name: str, value: Any, symbol: Symbol) -> Documentation:
|
|
172
|
+
"""Validate one symbol's generated description and argument mapping."""
|
|
173
|
+
if isinstance(value, str):
|
|
174
|
+
legacy_description = value.strip()
|
|
175
|
+
if not legacy_description:
|
|
176
|
+
raise InvalidAIResponseError("Each symbol must contain a non-empty description.")
|
|
177
|
+
return Documentation(legacy_description)
|
|
178
|
+
if not isinstance(value, dict):
|
|
179
|
+
raise InvalidAIResponseError("Each symbol must contain documentation details.")
|
|
180
|
+
structured_description = value.get("description")
|
|
181
|
+
arguments = value.get("arguments", {})
|
|
182
|
+
valid_arguments = isinstance(arguments, dict) and all(
|
|
183
|
+
isinstance(argument, str)
|
|
184
|
+
and isinstance(argument_description, str)
|
|
185
|
+
and bool(argument_description.strip())
|
|
186
|
+
for argument, argument_description in arguments.items()
|
|
187
|
+
)
|
|
188
|
+
if (
|
|
189
|
+
not isinstance(structured_description, str)
|
|
190
|
+
or not structured_description.strip()
|
|
191
|
+
or not valid_arguments
|
|
192
|
+
):
|
|
193
|
+
raise InvalidAIResponseError(
|
|
194
|
+
"Each symbol must contain a non-empty description and argument descriptions."
|
|
195
|
+
)
|
|
196
|
+
if set(arguments) != set(symbol.args):
|
|
197
|
+
raise InvalidAIResponseError(
|
|
198
|
+
f"Documentation for `{name}` must describe every requested argument."
|
|
199
|
+
)
|
|
200
|
+
return Documentation(
|
|
201
|
+
structured_description.strip(),
|
|
202
|
+
{
|
|
203
|
+
argument: argument_description.strip()
|
|
204
|
+
for argument, argument_description in arguments.items()
|
|
205
|
+
},
|
|
206
|
+
)
|