105 lines
3.2 KiB
Python
105 lines
3.2 KiB
Python
import json
|
||
import re
|
||
from typing import Any
|
||
|
||
from app.llm.client import LLMClient
|
||
|
||
|
||
def strip_markdown_json(text: str) -> str:
|
||
text = text.strip()
|
||
fenced = re.search(r"```(?:json)?\s*(.*?)\s*```", text, re.DOTALL | re.IGNORECASE)
|
||
if fenced:
|
||
return fenced.group(1).strip()
|
||
return text
|
||
|
||
|
||
def slugify_branch(title: str, max_len: int = 40) -> str:
|
||
text = title.lower()
|
||
text = re.sub(r"[^a-z0-9а-яё]+", "-", text, flags=re.IGNORECASE)
|
||
text = re.sub(r"-+", "-", text).strip("-")
|
||
return text[:max_len] or "task"
|
||
|
||
|
||
async def structure_work_item(
|
||
raw_text: str,
|
||
projects: list[dict[str, Any]],
|
||
) -> dict[str, Any]:
|
||
project_lines = "\n".join(
|
||
f"- {p['slug']}: {p['name']} (id={p['taiga_id']})" for p in projects
|
||
)
|
||
system_prompt = f"""
|
||
Ты технический ассистент. Преобразуй сырое описание фичи или бага в строгий JSON.
|
||
Отвечай только JSON, без markdown.
|
||
|
||
Доступные проекты Taiga:
|
||
{project_lines}
|
||
|
||
Схема:
|
||
{{
|
||
"project_slug": "slug проекта из списка",
|
||
"title": "короткое название",
|
||
"description": "понятное описание",
|
||
"issue_type": "feature|bug",
|
||
"priority": "low|normal|high",
|
||
"tags": ["tag1"],
|
||
"acceptance_criteria": ["критерий 1"],
|
||
"children": [
|
||
{{"title": "подзадача", "description": "описание", "type": "Task"}}
|
||
],
|
||
"questions": ["уточняющий вопрос если данных мало"]
|
||
}}
|
||
|
||
Правила:
|
||
- Пиши на русском.
|
||
- project_slug выбери из списка; если неясно — первый подходящий или спроси в questions.
|
||
- acceptance_criteria проверяемые.
|
||
- children — технические подзадачи.
|
||
""".strip()
|
||
|
||
llm = LLMClient()
|
||
result = await llm.complete(
|
||
[
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": raw_text},
|
||
]
|
||
)
|
||
content = strip_markdown_json(result.get("content") or "")
|
||
return json.loads(content)
|
||
|
||
|
||
def format_story_description(task: dict[str, Any], raw_text: str) -> str:
|
||
lines = [task.get("description") or raw_text]
|
||
|
||
acceptance = task.get("acceptance_criteria") or []
|
||
if acceptance:
|
||
lines.append("")
|
||
lines.append("## Acceptance criteria")
|
||
for item in acceptance:
|
||
lines.append(f"- {item}")
|
||
|
||
questions = task.get("questions") or []
|
||
if questions:
|
||
lines.append("")
|
||
lines.append("## Вопросы")
|
||
for item in questions:
|
||
lines.append(f"- {item}")
|
||
|
||
lines.append("")
|
||
lines.append("## Исходное описание")
|
||
lines.append(raw_text)
|
||
return "\n".join(lines).strip()
|
||
|
||
|
||
def format_gitea_body(
|
||
task: dict[str, Any],
|
||
raw_text: str,
|
||
taiga_ref: int,
|
||
taiga_url: str,
|
||
branch: str,
|
||
) -> str:
|
||
body = format_story_description(task, raw_text)
|
||
body += f"\n\n---\n**Taiga:** #{taiga_ref} — {taiga_url}\n"
|
||
body += f"**Ветка:** `{branch}`\n"
|
||
body += "\nЗакрытие: `Closes gitea #N, taiga #REF` в коммите"
|
||
return body
|