Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:39:52 +08:00

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()