e768098d0e
tools_continuous_delivery / Private PyPI non-main branch release (push) Has been skipped
tools_continuous_delivery / Private PyPI main branch release (push) Failing after 2m42s
Publish Promptflow Doc / Build (push) Has been cancelled
Publish Promptflow Doc / Deploy (push) Has been cancelled
Flake8 Lint / flake8 (push) Has been cancelled
Spell check CI / Spell_Check (push) Has been cancelled
222 lines
7.6 KiB
Python
222 lines
7.6 KiB
Python
import ast
|
|
import asyncio
|
|
import logging
|
|
import os
|
|
from typing import List
|
|
|
|
import tiktoken
|
|
from dotenv import load_dotenv
|
|
from typing_extensions import Never
|
|
|
|
from agent_framework import Agent, Executor, WorkflowBuilder, WorkflowContext, handler
|
|
from agent_framework.openai import OpenAIChatClient, OpenAIChatOptions
|
|
|
|
from divider import Divider
|
|
from file import File
|
|
from prompt import PromptLimitException, docstring_prompt
|
|
|
|
load_dotenv()
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers (extracted from original generate_docstring_tool.py / azure_open_ai.py)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def get_imports(content: str) -> List[str]:
|
|
tree = ast.parse(content)
|
|
import_statements = []
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
for n in node.names:
|
|
import_statements.append(f"import {n.name}")
|
|
elif isinstance(node, ast.ImportFrom):
|
|
module_name = node.module
|
|
for n in node.names:
|
|
import_statements.append(f"from {module_name} import {n.name}")
|
|
return import_statements
|
|
|
|
|
|
_TOKEN_BUDGET = {
|
|
"gpt-4-32k": 31000,
|
|
"gpt-4": 7000,
|
|
"gpt-3.5-turbo-16k": 15000,
|
|
}
|
|
_DEFAULT_BUDGET = 4000
|
|
|
|
|
|
def _count_tokens(text: str, model: str) -> int:
|
|
try:
|
|
encoding = tiktoken.encoding_for_model(model)
|
|
except KeyError:
|
|
encoding = tiktoken.encoding_for_model("gpt-3.5-turbo")
|
|
return len(encoding.encode(text))
|
|
|
|
|
|
def _get_message_tokens(messages: List[dict], model: str) -> int:
|
|
num_tokens = 0
|
|
for message in messages:
|
|
num_tokens += 5
|
|
for key, value in message.items():
|
|
if value:
|
|
num_tokens += _count_tokens(value, model)
|
|
if key == "name":
|
|
num_tokens += 5
|
|
num_tokens += 5 # assistant priming
|
|
return num_tokens
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Executors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
SYSTEM_PROMPT = "You are a Python engineer."
|
|
|
|
|
|
class LoadCodeExecutor(Executor):
|
|
@handler
|
|
async def load(self, source: str, ctx: WorkflowContext[str]) -> None:
|
|
file = File(source)
|
|
await ctx.send_message(file.content)
|
|
|
|
|
|
class DivideCodeExecutor(Executor):
|
|
@handler
|
|
async def divide(self, file_content: str, ctx: WorkflowContext[list]) -> None:
|
|
divided = Divider.divide_file(file_content)
|
|
await ctx.send_message(divided)
|
|
|
|
|
|
class GenerateDocstringExecutor(Executor):
|
|
"""Generates docstrings for each code part using the LLM, then combines.
|
|
|
|
Collapses the original ``generate_docstring`` + ``combine_code`` nodes.
|
|
"""
|
|
|
|
def __init__(self, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self._agent = None # lazily initialised on first use
|
|
|
|
def _ensure_agent(self) -> None:
|
|
if self._agent is not None:
|
|
return
|
|
self._model = os.environ.get("AZURE_OPENAI_DEPLOYMENT", "gpt-35-turbo")
|
|
self._max_tokens = int(os.environ.get("MAX_TOKENS", "1200"))
|
|
total = _TOKEN_BUDGET.get(self._model, _DEFAULT_BUDGET)
|
|
self._tokens_limit = total - self._max_tokens
|
|
|
|
client = OpenAIChatClient(
|
|
azure_endpoint=os.environ["AZURE_OPENAI_ENDPOINT"],
|
|
model=self._model,
|
|
api_key=os.environ["AZURE_OPENAI_API_KEY"],
|
|
)
|
|
self._agent = Agent(
|
|
client=client,
|
|
name="DocstringAgent",
|
|
instructions=SYSTEM_PROMPT,
|
|
)
|
|
|
|
# -- token helpers -------------------------------------------------------
|
|
|
|
def _validate_tokens(self, prompt_text: str) -> None:
|
|
self._ensure_agent()
|
|
messages = [
|
|
{"role": "system", "content": SYSTEM_PROMPT},
|
|
{"role": "user", "content": prompt_text},
|
|
]
|
|
total = _get_message_tokens(messages, self._model)
|
|
if total > self._tokens_limit:
|
|
raise PromptLimitException(
|
|
f"token count {total} exceeds limit {self._tokens_limit}"
|
|
)
|
|
|
|
# -- LLM call ------------------------------------------------------------
|
|
|
|
async def _generate_one(self, prompt_text: str) -> str:
|
|
self._ensure_agent()
|
|
response = await self._agent.run(
|
|
prompt_text,
|
|
options=OpenAIChatOptions(temperature=0.1, max_tokens=self._max_tokens),
|
|
)
|
|
return response.text
|
|
|
|
# -- handler -------------------------------------------------------------
|
|
|
|
@handler
|
|
async def generate(self, divided: list, ctx: WorkflowContext[Never, str]) -> None:
|
|
self._ensure_agent()
|
|
divided = list(reversed(divided))
|
|
all_divided: List[str] = []
|
|
|
|
# If too many imports result in tokens exceeding the limit, please set an empty string.
|
|
modules = '' # '\n'.join(get_imports(divided[-1]))
|
|
modules_tokens = _count_tokens(modules, self._model)
|
|
if modules_tokens > 300:
|
|
logging.warning(f'Too many imports, the number of tokens is {modules_tokens}')
|
|
if modules_tokens > 500:
|
|
logging.warning(
|
|
f'Too many imports, the number of tokens is {modules_tokens}, will set an empty string.'
|
|
)
|
|
modules = ''
|
|
|
|
# Divide the code into two parts if the global class/function is too long.
|
|
while len(divided):
|
|
item = divided.pop()
|
|
try:
|
|
self._validate_tokens(docstring_prompt(code=item, module=modules))
|
|
except PromptLimitException as e:
|
|
logging.warning(e.message + ', will divide the code into two parts.')
|
|
divided_tmp = Divider.divide_half(item)
|
|
if len(divided_tmp) > 1:
|
|
divided.extend(list(reversed(divided_tmp)))
|
|
continue
|
|
except Exception as e:
|
|
logging.warning(e)
|
|
all_divided.append(item)
|
|
|
|
# Generate docstrings concurrently
|
|
tasks = []
|
|
last_code = ''
|
|
for item in all_divided:
|
|
if Divider.has_class_or_func(item):
|
|
tasks.append(
|
|
self._generate_one(docstring_prompt(last_code=last_code, code=item, module=modules))
|
|
)
|
|
else:
|
|
# No class/function — nothing to generate.
|
|
tasks.append(asyncio.sleep(0))
|
|
last_code = item
|
|
|
|
res_doc = await asyncio.gather(*tasks)
|
|
|
|
new_code: List[str] = []
|
|
for i in range(len(all_divided)):
|
|
if isinstance(res_doc[i], str):
|
|
new_code.append(Divider.merge_doc2code(res_doc[i], all_divided[i]))
|
|
else:
|
|
new_code.append(all_divided[i])
|
|
|
|
# Combine (collapsed from the combine_code prompt node)
|
|
result = ''.join(new_code)
|
|
await ctx.yield_output(result)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Workflow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def create_workflow():
|
|
"""Factory that builds a fresh workflow instance (safe for concurrent use)."""
|
|
_load = LoadCodeExecutor(id="load_code")
|
|
_divide = DivideCodeExecutor(id="divide_code")
|
|
_generate = GenerateDocstringExecutor(id="generate_docstring")
|
|
|
|
return (
|
|
WorkflowBuilder(name="GenDocstringWorkflow", start_executor=_load)
|
|
.add_edge(_load, _divide)
|
|
.add_edge(_divide, _generate)
|
|
.build()
|
|
)
|
|
|
|
|
|
workflow = create_workflow()
|