Spaces:
Sleeping
Sleeping
Create app.py
Browse files
app.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import re
|
| 3 |
+
import json
|
| 4 |
+
from dataclasses import dataclass, asdict
|
| 5 |
+
from typing import Any, Dict, List, Optional, Tuple
|
| 6 |
+
|
| 7 |
+
import gradio as gr
|
| 8 |
+
|
| 9 |
+
# =======================
|
| 10 |
+
# .env + проверка OLLAMA_API_KEY (как в примере)
|
| 11 |
+
# =======================
|
| 12 |
+
try:
|
| 13 |
+
from dotenv import load_dotenv
|
| 14 |
+
load_dotenv()
|
| 15 |
+
except Exception:
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
OLLAMA_API_KEY_ENV = "OLLAMA_API_KEY"
|
| 19 |
+
OLLAMA_API_KEY = os.environ.get(OLLAMA_API_KEY_ENV)
|
| 20 |
+
|
| 21 |
+
if not OLLAMA_API_KEY:
|
| 22 |
+
raise RuntimeError(
|
| 23 |
+
f"Переменная окружения {OLLAMA_API_KEY_ENV} не задана.\n"
|
| 24 |
+
f"В Hugging Face Spaces её нужно добавить в Settings → Variables/Secrets."
|
| 25 |
+
)
|
| 26 |
+
|
| 27 |
+
# =======================
|
| 28 |
+
# Ollama Cloud -> smolagents.Model adapter (упрощённо, но совместимо по идее с примером)
|
| 29 |
+
# =======================
|
| 30 |
+
from ollama import Client
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class OllamaCloudModel:
|
| 34 |
+
"""
|
| 35 |
+
Мини-адаптер Ollama Cloud для генерации текста (не поток).
|
| 36 |
+
"""
|
| 37 |
+
|
| 38 |
+
def __init__(
|
| 39 |
+
self,
|
| 40 |
+
model_id: str = "gpt-oss:120b",
|
| 41 |
+
host: str = "https://ollama.com",
|
| 42 |
+
api_key_env: str = "OLLAMA_API_KEY",
|
| 43 |
+
):
|
| 44 |
+
api_key = os.environ.get(api_key_env)
|
| 45 |
+
if not api_key:
|
| 46 |
+
raise ValueError(f"Не найден {api_key_env} в окружении")
|
| 47 |
+
self.model_id = model_id
|
| 48 |
+
self.client = Client(host=host, headers={"Authorization": f"Bearer {api_key}"})
|
| 49 |
+
|
| 50 |
+
def _to_text(self, content: Any) -> str:
|
| 51 |
+
if content is None:
|
| 52 |
+
return ""
|
| 53 |
+
if isinstance(content, str):
|
| 54 |
+
return content
|
| 55 |
+
if isinstance(content, list):
|
| 56 |
+
parts = []
|
| 57 |
+
for p in content:
|
| 58 |
+
if isinstance(p, dict):
|
| 59 |
+
parts.append(p.get("text", ""))
|
| 60 |
+
else:
|
| 61 |
+
parts.append(str(p))
|
| 62 |
+
return "".join(parts)
|
| 63 |
+
return str(content)
|
| 64 |
+
|
| 65 |
+
def generate(self, messages: List[Dict[str, str]], stop_sequences: Optional[List[str]] = None) -> str:
|
| 66 |
+
resp = self.client.chat(model=self.model_id, messages=messages, stream=False)
|
| 67 |
+
text = self._to_text((resp.get("message") or {}).get("content", ""))
|
| 68 |
+
|
| 69 |
+
if stop_sequences:
|
| 70 |
+
for s in stop_sequences:
|
| 71 |
+
if not s:
|
| 72 |
+
continue
|
| 73 |
+
idx = text.find(s)
|
| 74 |
+
if idx != -1:
|
| 75 |
+
text = text[:idx]
|
| 76 |
+
break
|
| 77 |
+
|
| 78 |
+
return text
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# =======================
|
| 82 |
+
# Модели данных
|
| 83 |
+
# =======================
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class NormalizedRequirement:
|
| 87 |
+
req_id: str
|
| 88 |
+
source_text: str
|
| 89 |
+
normalized_text: str
|
| 90 |
+
classification: str # functional / non-functional / constraint / assumption / other
|
| 91 |
+
tags: List[str]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@dataclass
|
| 95 |
+
class QualityMetrics:
|
| 96 |
+
invert_score: float # 0..1
|
| 97 |
+
smart_score: float # 0..1
|
| 98 |
+
gqm_score: float # 0..1
|
| 99 |
+
verifiability: float # 0..1
|
| 100 |
+
specificity: float # 0..1
|
| 101 |
+
ambiguity: float # 0..1 (1 = очень неоднозначно)
|
| 102 |
+
overall: float # 0..1
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
@dataclass
|
| 106 |
+
class Defect:
|
| 107 |
+
code: str
|
| 108 |
+
severity: str # low/medium/high
|
| 109 |
+
message: str
|
| 110 |
+
spans: List[str]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
@dataclass
|
| 114 |
+
class QualityReport:
|
| 115 |
+
req_id: str
|
| 116 |
+
metrics: QualityMetrics
|
| 117 |
+
defects: List[Defect]
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@dataclass
|
| 121 |
+
class RefactorResult:
|
| 122 |
+
req_id: str
|
| 123 |
+
improved_text: str
|
| 124 |
+
fixed_defects: List[str]
|
| 125 |
+
explanation: str
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
# =======================
|
| 129 |
+
# AnalysisAgent: сегментация + нормализация + базовая классификация
|
| 130 |
+
# =======================
|
| 131 |
+
|
| 132 |
+
class AnalysisAgent:
|
| 133 |
+
REQ_LINE_RE = re.compile(r"^\s*(?:REQ[-_ ]?\d+|R\d+|\d+[.)]|-|\*)\s+(.*)\s*$", re.IGNORECASE)
|
| 134 |
+
|
| 135 |
+
def segment(self, srs: str) -> List[str]:
|
| 136 |
+
srs = (srs or "").strip()
|
| 137 |
+
if not srs:
|
| 138 |
+
return []
|
| 139 |
+
|
| 140 |
+
# 1) если требования уже построчно/списком — берём строки
|
| 141 |
+
lines = [ln.strip() for ln in srs.splitlines() if ln.strip()]
|
| 142 |
+
|
| 143 |
+
# 2) склеиваем многострочные пункты: если строка не похожа на начало нового пункта — считаем продолжением
|
| 144 |
+
chunks: List[str] = []
|
| 145 |
+
buf: List[str] = []
|
| 146 |
+
for ln in lines:
|
| 147 |
+
is_new = bool(self.REQ_LINE_RE.match(ln)) or ln.lower().startswith(("shall", "must", "should", "system", "приложение", "система"))
|
| 148 |
+
if is_new and buf:
|
| 149 |
+
chunks.append(" ".join(buf).strip())
|
| 150 |
+
buf = []
|
| 151 |
+
buf.append(ln)
|
| 152 |
+
if buf:
|
| 153 |
+
chunks.append(" ".join(buf).strip())
|
| 154 |
+
|
| 155 |
+
# fallback: если получилось слишком мало — режем по точке с запятой/точкам с большими буквами
|
| 156 |
+
if len(chunks) <= 1 and len(srs) > 300:
|
| 157 |
+
parts = re.split(r"(?<=[.;])\s+(?=[A-ZА-ЯЁ])", srs)
|
| 158 |
+
parts = [p.strip() for p in parts if p.strip()]
|
| 159 |
+
if len(parts) > len(chunks):
|
| 160 |
+
chunks = parts
|
| 161 |
+
|
| 162 |
+
return chunks
|
| 163 |
+
|
| 164 |
+
def classify(self, text: str) -> Tuple[str, List[str]]:
|
| 165 |
+
t = text.lower()
|
| 166 |
+
tags: List[str] = []
|
| 167 |
+
|
| 168 |
+
# очень грубая эвристика
|
| 169 |
+
if any(k in t for k in ["ms", "сек", "seconds", "performance", "latency", "rps", "нагруз", "время отклика", "доступност", "sla"]):
|
| 170 |
+
tags.append("performance")
|
| 171 |
+
if any(k in t for k in ["security", "шифр", "oauth", "jwt", "роль", "доступ", "авторизац", "аутентификац", "pII".lower()]):
|
| 172 |
+
tags.append("security")
|
| 173 |
+
if any(k in t for k in ["log", "лог", "audit", "трасс", "наблюдаем", "метрик", "monitor", "алерт"]):
|
| 174 |
+
tags.append("observability")
|
| 175 |
+
if any(k in t for k in ["ui", "ux", "интерфейс", "экран", "форма", "кнопк"]):
|
| 176 |
+
tags.append("ui")
|
| 177 |
+
|
| 178 |
+
# classification
|
| 179 |
+
if any(k in t for k in ["должен", "must", "shall", "обязан"]):
|
| 180 |
+
# функциональные чаще про действия/кейсы
|
| 181 |
+
if any(k in t for k in ["созда", "удаля", "обнов", "получ", "отправ", "импорт", "экспорт", "рассчит", "показ", "поиск", "фильтр"]):
|
| 182 |
+
return "functional", tags
|
| 183 |
+
# нефункциональные — про качество/ограничения
|
| 184 |
+
if any(k in t for k in ["не менее", "не более", "ms", "сек", "доступност", "безопасн", "масштаб", "производит", "надежн", "совместим"]):
|
| 185 |
+
return "non-functional", tags
|
| 186 |
+
return "other", tags
|
| 187 |
+
|
| 188 |
+
if any(k in t for k in ["предполагается", "assume", "assumption"]):
|
| 189 |
+
return "assumption", tags
|
| 190 |
+
|
| 191 |
+
if any(k in t for k in ["огранич", "constraint", "только", "запрещ", "не допускается"]):
|
| 192 |
+
return "constraint", tags
|
| 193 |
+
|
| 194 |
+
return "other", tags
|
| 195 |
+
|
| 196 |
+
def normalize(self, text: str) -> str:
|
| 197 |
+
# убираем маркеры типа "1) " "REQ-1 " "-" "*"
|
| 198 |
+
m = self.REQ_LINE_RE.match(text)
|
| 199 |
+
if m:
|
| 200 |
+
text = m.group(1).strip()
|
| 201 |
+
|
| 202 |
+
text = re.sub(r"\s+", " ", text).strip()
|
| 203 |
+
# нормализация "Система должна" в единую форму, но без фанатизма
|
| 204 |
+
text = re.sub(r"^(система|приложение)\s+", "Система ", text, flags=re.IGNORECASE)
|
| 205 |
+
return text
|
| 206 |
+
|
| 207 |
+
def run(self, srs: str) -> List[NormalizedRequirement]:
|
| 208 |
+
segments = self.segment(srs)
|
| 209 |
+
out: List[NormalizedRequirement] = []
|
| 210 |
+
for i, seg in enumerate(segments, start=1):
|
| 211 |
+
norm = self.normalize(seg)
|
| 212 |
+
cls, tags = self.classify(norm)
|
| 213 |
+
out.append(
|
| 214 |
+
NormalizedRequirement(
|
| 215 |
+
req_id=f"REQ-{i:03d}",
|
| 216 |
+
source_text=seg,
|
| 217 |
+
normalized_text=norm,
|
| 218 |
+
classification=cls,
|
| 219 |
+
tags=tags,
|
| 220 |
+
)
|
| 221 |
+
)
|
| 222 |
+
return out
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
# =======================
|
| 226 |
+
# QualityAgent: rule-based + (упрощённые) INVERT / SMART / GQM
|
| 227 |
+
# =======================
|
| 228 |
+
|
| 229 |
+
class QualityAgent:
|
| 230 |
+
AMBIGUOUS_WORDS = [
|
| 231 |
+
"быстро", "удобно", "понятно", "желательно", "примерно", "как можно", "оптимально",
|
| 232 |
+
"в разумные сроки", "и т.п.", "и т.д.", "достаточно", "при необходимости",
|
| 233 |
+
"some", "several", "as needed", "etc", "quickly", "user-friendly",
|
| 234 |
+
]
|
| 235 |
+
WEAK_MODALS = ["может", "could", "may", "желательно", "should"]
|
| 236 |
+
STRONG_MODALS = ["должен", "must", "shall", "обязан"]
|
| 237 |
+
UNVERIFIABLE = ["удобно", "интуитивно", "красиво", "понятно", "friendly", "intuitive"]
|
| 238 |
+
|
| 239 |
+
NUMBER_RE = re.compile(r"(\d+(?:[.,]\d+)?)\s*(ms|сек|s|seconds|%|rps|rpm|gb|mb|kb|мин|min|hours|час|дней|day|days)\b", re.IGNORECASE)
|
| 240 |
+
|
| 241 |
+
def _find_spans(self, text: str, needles: List[str]) -> List[str]:
|
| 242 |
+
found: List[str] = []
|
| 243 |
+
t = text.lower()
|
| 244 |
+
for w in needles:
|
| 245 |
+
if w.lower() in t:
|
| 246 |
+
found.append(w)
|
| 247 |
+
return found
|
| 248 |
+
|
| 249 |
+
def _defect(self, code: str, severity: str, message: str, spans: List[str]) -> Defect:
|
| 250 |
+
return Defect(code=code, severity=severity, message=message, spans=spans)
|
| 251 |
+
|
| 252 |
+
def evaluate(self, req: NormalizedRequirement) -> QualityReport:
|
| 253 |
+
text = req.normalized_text.strip()
|
| 254 |
+
t = text.lower()
|
| 255 |
+
|
| 256 |
+
defects: List[Defect] = []
|
| 257 |
+
|
| 258 |
+
amb = self._find_spans(text, self.AMBIGUOUS_WORDS)
|
| 259 |
+
if amb:
|
| 260 |
+
defects.append(self._defect(
|
| 261 |
+
code="AMBIGUOUS_WORDING",
|
| 262 |
+
severity="high" if len(amb) >= 2 else "medium",
|
| 263 |
+
message="Найдены неоднозначные/расплывчатые слова. Замените на измеримые критерии.",
|
| 264 |
+
spans=amb,
|
| 265 |
+
))
|
| 266 |
+
|
| 267 |
+
weak = self._find_spans(text, self.WEAK_MODALS)
|
| 268 |
+
if weak:
|
| 269 |
+
defects.append(self._defect(
|
| 270 |
+
code="WEAK_MODAL",
|
| 271 |
+
severity="medium",
|
| 272 |
+
message="Используются слабые модальные слова (может/should). Требование может стать необязательным.",
|
| 273 |
+
spans=weak,
|
| 274 |
+
))
|
| 275 |
+
|
| 276 |
+
has_strong = any(m in t for m in self.STRONG_MODALS)
|
| 277 |
+
if not has_strong and req.classification in ("functional", "non-functional", "constraint"):
|
| 278 |
+
defects.append(self._defect(
|
| 279 |
+
code="MISSING_NORMATIVE_MODAL",
|
| 280 |
+
severity="medium",
|
| 281 |
+
message="Нет явной нормативности (должен/must/shall). Уточните обязательность.",
|
| 282 |
+
spans=[],
|
| 283 |
+
))
|
| 284 |
+
|
| 285 |
+
# Проверяем измеримость (числа+единицы) для NFR/качества
|
| 286 |
+
has_numbers = bool(self.NUMBER_RE.search(text))
|
| 287 |
+
if req.classification == "non-functional" and not has_numbers:
|
| 288 |
+
defects.append(self._defect(
|
| 289 |
+
code="MISSING_MEASURABLE_CRITERIA",
|
| 290 |
+
severity="high",
|
| 291 |
+
message="Для нефункционального требования не задан измеримый критерий (число + единица).",
|
| 292 |
+
spans=[],
|
| 293 |
+
))
|
| 294 |
+
|
| 295 |
+
unver = self._find_spans(text, self.UNVERIFIABLE)
|
| 296 |
+
if unver:
|
| 297 |
+
defects.append(self._defect(
|
| 298 |
+
code="POOR_VERIFIABILITY",
|
| 299 |
+
severity="high",
|
| 300 |
+
message="Формулировка плохо проверяема (субъективные критерии).",
|
| 301 |
+
spans=unver,
|
| 302 |
+
))
|
| 303 |
+
|
| 304 |
+
# Простейшая проверка на “одна мысль — одно требование”
|
| 305 |
+
if len(re.split(r"\b(и|and|или|or)\b", t)) > 7:
|
| 306 |
+
defects.append(self._defect(
|
| 307 |
+
code="MULTIPLE_REQUIREMENTS_IN_ONE",
|
| 308 |
+
severity="medium",
|
| 309 |
+
message="Похоже, в одном предложении смешано несколько требований. Разбейте на отдельные пункты.",
|
| 310 |
+
spans=[],
|
| 311 |
+
))
|
| 312 |
+
|
| 313 |
+
# --- Метрики (упрощённые) ---
|
| 314 |
+
# ambiguity: 0..1 (1 = много неоднозначностей)
|
| 315 |
+
ambiguity = min(1.0, 0.2 * len(amb) + (0.15 if weak else 0.0))
|
| 316 |
+
|
| 317 |
+
# specificity: штрафуем за отсутствие чисел в NFR и за расплывчатость
|
| 318 |
+
specificity = 1.0
|
| 319 |
+
if req.classification == "non-functional" and not has_numbers:
|
| 320 |
+
specificity -= 0.5
|
| 321 |
+
specificity -= 0.15 * len(amb)
|
| 322 |
+
specificity = max(0.0, min(1.0, specificity))
|
| 323 |
+
|
| 324 |
+
# verifiability: штраф за субъективность и отсутствие критериев
|
| 325 |
+
verifiability = 1.0
|
| 326 |
+
if unver:
|
| 327 |
+
verifiability -= 0.6
|
| 328 |
+
if req.classification == "non-functional" and not has_numbers:
|
| 329 |
+
verifiability -= 0.4
|
| 330 |
+
verifiability = max(0.0, min(1.0, verifiability))
|
| 331 |
+
|
| 332 |
+
# SMART (S,M,A,R,T) — приближение через специфичность + измеримость + проверяемость
|
| 333 |
+
smart_score = max(0.0, min(1.0, (specificity * 0.45 + verifiability * 0.55)))
|
| 334 |
+
|
| 335 |
+
# INVERT — приближение: отсутствие неоднозначности + “однозначная проверка”
|
| 336 |
+
invert_score = max(0.0, min(1.0, (1.0 - ambiguity) * 0.55 + verifiability * 0.45))
|
| 337 |
+
|
| 338 |
+
# GQM — если есть (Goal/Question/Metric) хотя бы частично: наличие метрик/критериев и “что проверять”
|
| 339 |
+
gqm_score = 0.3
|
| 340 |
+
if has_numbers:
|
| 341 |
+
gqm_score += 0.4
|
| 342 |
+
if has_strong:
|
| 343 |
+
gqm_score += 0.2
|
| 344 |
+
if not amb:
|
| 345 |
+
gqm_score += 0.1
|
| 346 |
+
gqm_score = max(0.0, min(1.0, gqm_score))
|
| 347 |
+
|
| 348 |
+
overall = max(0.0, min(1.0, (invert_score + smart_score + gqm_score) / 3.0))
|
| 349 |
+
|
| 350 |
+
metrics = QualityMetrics(
|
| 351 |
+
invert_score=round(invert_score, 3),
|
| 352 |
+
smart_score=round(smart_score, 3),
|
| 353 |
+
gqm_score=round(gqm_score, 3),
|
| 354 |
+
verifiability=round(verifiability, 3),
|
| 355 |
+
specificity=round(specificity, 3),
|
| 356 |
+
ambiguity=round(ambiguity, 3),
|
| 357 |
+
overall=round(overall, 3),
|
| 358 |
+
)
|
| 359 |
+
|
| 360 |
+
return QualityReport(req_id=req.req_id, metrics=metrics, defects=defects)
|
| 361 |
+
|
| 362 |
+
def run(self, req: NormalizedRequirement) -> QualityReport:
|
| 363 |
+
return self.evaluate(req)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
# =======================
|
| 367 |
+
# RefactorAgent: LLM-рефакторинг требования по дефектам + целевым критериям
|
| 368 |
+
# =======================
|
| 369 |
+
|
| 370 |
+
class RefactorAgent:
|
| 371 |
+
def __init__(self, model: OllamaCloudModel):
|
| 372 |
+
self.model = model
|
| 373 |
+
|
| 374 |
+
def run(self, req: NormalizedRequirement, report: QualityReport) -> RefactorResult:
|
| 375 |
+
defects = report.defects or []
|
| 376 |
+
|
| 377 |
+
defects_md = "\n".join(
|
| 378 |
+
[f"- [{d.severity}] {d.code}: {d.message}" + (f" (спаны: {', '.join(d.spans)})" if d.spans else "")
|
| 379 |
+
for d in defects]
|
| 380 |
+
) or "- (дефектов не найдено)"
|
| 381 |
+
|
| 382 |
+
target_criteria = """
|
| 383 |
+
Целевые критерии улучшения:
|
| 384 |
+
- SMART: конкретно, измеримо, достижимо, релевантно, ограничено по времени/условиям где уместно
|
| 385 |
+
- INVERT (упрощённо): однозначность, проверяемость, атомарность, отсутствие расплывчатых слов
|
| 386 |
+
- GQM: чтобы было понятно, как измерять/проверять (метрика/критерий приемки)
|
| 387 |
+
"""
|
| 388 |
+
|
| 389 |
+
prompt = f"""
|
| 390 |
+
Ты — эксперт по требованиям (SRS). Улучши формулировку требования.
|
| 391 |
+
|
| 392 |
+
Исходное требование (req_id={req.req_id}):
|
| 393 |
+
{req.normalized_text}
|
| 394 |
+
|
| 395 |
+
Классификация: {req.classification}
|
| 396 |
+
Теги: {", ".join(req.tags) if req.tags else "-"}
|
| 397 |
+
|
| 398 |
+
Выявленные дефекты:
|
| 399 |
+
{defects_md}
|
| 400 |
+
|
| 401 |
+
{target_criteria}
|
| 402 |
+
|
| 403 |
+
Задача:
|
| 404 |
+
1) Верни улучшенную формулировку (одной строкой или несколькими, но без воды).
|
| 405 |
+
2) Затем коротко перечисли, какие дефекты исправлены.
|
| 406 |
+
3) Если требование нефункциональное и нет метрик — добавь измеримый критерий (число + единица),
|
| 407 |
+
но НЕ выдумывай домен: используй нейтральные критерии (например, время отклика, точность, SLA) и пометь как "пример".
|
| 408 |
+
4) Не добавляй лишних требований, только уточняй существующее.
|
| 409 |
+
|
| 410 |
+
Формат ответа (СТРОГО):
|
| 411 |
+
IMPROVED:
|
| 412 |
+
<текст>
|
| 413 |
+
|
| 414 |
+
FIXED_DEFECTS:
|
| 415 |
+
- <код1>
|
| 416 |
+
- <код2>
|
| 417 |
+
|
| 418 |
+
EXPLANATION:
|
| 419 |
+
<1-3 предложения>
|
| 420 |
+
""".strip()
|
| 421 |
+
|
| 422 |
+
out = self.model.generate([{"role": "user", "content": prompt}])
|
| 423 |
+
|
| 424 |
+
improved, fixed, expl = self._parse_refactor(out, fallback=req.normalized_text, defects=defects)
|
| 425 |
+
return RefactorResult(
|
| 426 |
+
req_id=req.req_id,
|
| 427 |
+
improved_text=improved,
|
| 428 |
+
fixed_defects=fixed,
|
| 429 |
+
explanation=expl,
|
| 430 |
+
)
|
| 431 |
+
|
| 432 |
+
def _parse_refactor(self, text: str, fallback: str, defects: List[Defect]) -> Tuple[str, List[str], str]:
|
| 433 |
+
improved = fallback
|
| 434 |
+
fixed_codes = [d.code for d in defects]
|
| 435 |
+
explanation = "Уточнена формулировка с учётом найденных дефектов."
|
| 436 |
+
|
| 437 |
+
# грубый парсер по секциям
|
| 438 |
+
m1 = re.search(r"IMPROVED:\s*(.+?)\n\s*FIXED_DEFECTS:", text, flags=re.S | re.I)
|
| 439 |
+
if m1:
|
| 440 |
+
improved = m1.group(1).strip()
|
| 441 |
+
|
| 442 |
+
m2 = re.search(r"FIXED_DEFECTS:\s*(.+?)\n\s*EXPLANATION:", text, flags=re.S | re.I)
|
| 443 |
+
if m2:
|
| 444 |
+
block = m2.group(1).strip()
|
| 445 |
+
codes = []
|
| 446 |
+
for ln in block.splitlines():
|
| 447 |
+
ln = ln.strip()
|
| 448 |
+
if ln.startswith("-"):
|
| 449 |
+
codes.append(ln[1:].strip())
|
| 450 |
+
if codes:
|
| 451 |
+
fixed_codes = codes
|
| 452 |
+
|
| 453 |
+
m3 = re.search(r"EXPLANATION:\s*(.+)\s*$", text, flags=re.S | re.I)
|
| 454 |
+
if m3:
|
| 455 |
+
explanation = m3.group(1).strip()
|
| 456 |
+
|
| 457 |
+
return improved, fixed_codes, explanation
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
# =======================
|
| 461 |
+
# PlantUML: простая валидация/минимальные правки + (опционально) LLM-подсказки
|
| 462 |
+
# =======================
|
| 463 |
+
|
| 464 |
+
class PlantUMLAgent:
|
| 465 |
+
START = "@startuml"
|
| 466 |
+
END = "@enduml"
|
| 467 |
+
|
| 468 |
+
def extract_blocks(self, text: str) -> List[str]:
|
| 469 |
+
if not text:
|
| 470 |
+
return []
|
| 471 |
+
blocks = []
|
| 472 |
+
pattern = re.compile(r"@startuml[\s\S]*?@enduml", re.IGNORECASE)
|
| 473 |
+
for m in pattern.finditer(text):
|
| 474 |
+
blocks.append(m.group(0).strip())
|
| 475 |
+
return blocks
|
| 476 |
+
|
| 477 |
+
def basic_fix(self, block: str) -> Tuple[str, List[str]]:
|
| 478 |
+
changes: List[str] = []
|
| 479 |
+
b = block.strip()
|
| 480 |
+
|
| 481 |
+
if self.START not in b.lower():
|
| 482 |
+
b = f"@startuml\n{b}"
|
| 483 |
+
changes.append("Добавлен @startuml в начале блока.")
|
| 484 |
+
if self.END not in b.lower():
|
| 485 |
+
b = f"{b}\n@enduml"
|
| 486 |
+
changes.append("Добавлен @enduml в конце блока.")
|
| 487 |
+
|
| 488 |
+
# н��рмализуем переносы и табы
|
| 489 |
+
b2 = b.replace("\t", " ")
|
| 490 |
+
if b2 != b:
|
| 491 |
+
b = b2
|
| 492 |
+
changes.append("Заменены табы на пробелы.")
|
| 493 |
+
|
| 494 |
+
# типовые опечатки
|
| 495 |
+
b2 = re.sub(r"@end\s*uml", "@enduml", b, flags=re.IGNORECASE)
|
| 496 |
+
if b2 != b:
|
| 497 |
+
b = b2
|
| 498 |
+
changes.append("Исправлено '@end uml' → '@enduml'.")
|
| 499 |
+
|
| 500 |
+
return b.strip(), changes
|
| 501 |
+
|
| 502 |
+
def run(self, plantuml_text: str) -> Tuple[str, List[str]]:
|
| 503 |
+
blocks = self.extract_blocks(plantuml_text)
|
| 504 |
+
if not blocks and (plantuml_text or "").strip():
|
| 505 |
+
# если пользователь вставил диаграмму без start/end — считаем это одним блоком
|
| 506 |
+
blocks = [(plantuml_text or "").strip()]
|
| 507 |
+
|
| 508 |
+
fixed_blocks: List[str] = []
|
| 509 |
+
changes: List[str] = []
|
| 510 |
+
|
| 511 |
+
for i, bl in enumerate(blocks, start=1):
|
| 512 |
+
fixed, ch = self.basic_fix(bl)
|
| 513 |
+
fixed_blocks.append(fixed)
|
| 514 |
+
for c in ch:
|
| 515 |
+
changes.append(f"Диаграмма #{i}: {c}")
|
| 516 |
+
|
| 517 |
+
return ("\n\n".join(fixed_blocks)).strip(), changes
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
# =======================
|
| 521 |
+
# Orchestrator: связывает агентов и собирает единый ответ
|
| 522 |
+
# =======================
|
| 523 |
+
|
| 524 |
+
class Orchestrator:
|
| 525 |
+
def __init__(self, model_id: str):
|
| 526 |
+
self.model = OllamaCloudModel(model_id=model_id.strip() or "gpt-oss:120b")
|
| 527 |
+
self.analysis = AnalysisAgent()
|
| 528 |
+
self.quality = QualityAgent()
|
| 529 |
+
self.refactor = RefactorAgent(self.model)
|
| 530 |
+
self.plantuml = PlantUMLAgent()
|
| 531 |
+
|
| 532 |
+
def run(self, srs: str, plantuml_optional: str) -> Dict[str, Any]:
|
| 533 |
+
changes: List[str] = []
|
| 534 |
+
tips: List[str] = []
|
| 535 |
+
|
| 536 |
+
# 1) PlantUML (опционально)
|
| 537 |
+
fixed_diagrams = ""
|
| 538 |
+
diagram_changes: List[str] = []
|
| 539 |
+
if (plantuml_optional or "").strip():
|
| 540 |
+
fixed_diagrams, diagram_changes = self.plantuml.run(plantuml_optional)
|
| 541 |
+
changes.extend(diagram_changes)
|
| 542 |
+
|
| 543 |
+
# 2) Analysis
|
| 544 |
+
normalized = self.analysis.run(srs)
|
| 545 |
+
if not normalized:
|
| 546 |
+
return {
|
| 547 |
+
"final_md": "❗️Не нашёл требований в тексте SRS. Вставь хотя бы 1-2 пункта требований.",
|
| 548 |
+
"normalized_json": "[]",
|
| 549 |
+
"quality_json": "[]",
|
| 550 |
+
"refactor_json": "[]",
|
| 551 |
+
}
|
| 552 |
+
|
| 553 |
+
# 3) Quality + Refactor
|
| 554 |
+
quality_reports: List[QualityReport] = []
|
| 555 |
+
refactors: List[RefactorResult] = []
|
| 556 |
+
|
| 557 |
+
for req in normalized:
|
| 558 |
+
qr = self.quality.run(req)
|
| 559 |
+
quality_reports.append(qr)
|
| 560 |
+
|
| 561 |
+
# если дефектов нет — можно не трогать; но обычно полезно унифицировать стиль
|
| 562 |
+
rr = self.refactor.run(req, qr) if (qr.defects or []) else RefactorResult(
|
| 563 |
+
req_id=req.req_id,
|
| 564 |
+
improved_text=req.normalized_text,
|
| 565 |
+
fixed_defects=[],
|
| 566 |
+
explanation="Дефектов не выявлено, формулировка оставлена без изменений.",
|
| 567 |
+
)
|
| 568 |
+
refactors.append(rr)
|
| 569 |
+
|
| 570 |
+
# изменения по требованию
|
| 571 |
+
if rr.improved_text.strip() != req.normalized_text.strip():
|
| 572 |
+
changes.append(f"{req.req_id}: улучшена формулировка требования.")
|
| 573 |
+
if qr.defects:
|
| 574 |
+
tips.append(f"{req.req_id}: проверь критерии приемки и измеримость (особенно если это NFR).")
|
| 575 |
+
|
| 576 |
+
# 4) Сборка пользовательского ответа
|
| 577 |
+
final_md = self._render_final(normalized, quality_reports, refactors, fixed_diagrams, changes, tips)
|
| 578 |
+
|
| 579 |
+
return {
|
| 580 |
+
"final_md": final_md,
|
| 581 |
+
"normalized_json": json.dumps([asdict(x) for x in normalized], ensure_ascii=False, indent=2),
|
| 582 |
+
"quality_json": json.dumps([self._asdict_quality(x) for x in quality_reports], ensure_ascii=False, indent=2),
|
| 583 |
+
"refactor_json": json.dumps([asdict(x) for x in refactors], ensure_ascii=False, indent=2),
|
| 584 |
+
}
|
| 585 |
+
|
| 586 |
+
def _asdict_quality(self, qr: QualityReport) -> Dict[str, Any]:
|
| 587 |
+
return {
|
| 588 |
+
"req_id": qr.req_id,
|
| 589 |
+
"metrics": asdict(qr.metrics),
|
| 590 |
+
"defects": [asdict(d) for d in (qr.defects or [])],
|
| 591 |
+
}
|
| 592 |
+
|
| 593 |
+
def _render_final(
|
| 594 |
+
self,
|
| 595 |
+
normalized: List[NormalizedRequirement],
|
| 596 |
+
quality: List[QualityReport],
|
| 597 |
+
refactors: List[RefactorResult],
|
| 598 |
+
fixed_diagrams: str,
|
| 599 |
+
changes: List[str],
|
| 600 |
+
tips: List[str],
|
| 601 |
+
) -> str:
|
| 602 |
+
q_by_id = {q.req_id: q for q in quality}
|
| 603 |
+
r_by_id = {r.req_id: r for r in refactors}
|
| 604 |
+
|
| 605 |
+
md = []
|
| 606 |
+
md.append("# ✅ Исправленные требования и рекомендации\n")
|
| 607 |
+
|
| 608 |
+
md.append("## 1) Итоговые требования (улучшенные формулировки)\n")
|
| 609 |
+
for req in normalized:
|
| 610 |
+
rr = r_by_id[req.req_id]
|
| 611 |
+
qr = q_by_id[req.req_id]
|
| 612 |
+
md.append(f"### {req.req_id} — {req.classification}\n")
|
| 613 |
+
md.append(f"**Было:** {req.normalized_text}\n")
|
| 614 |
+
md.append(f"**Стало:** {rr.improved_text}\n")
|
| 615 |
+
md.append("**Метрики качества (0..1):**")
|
| 616 |
+
md.append(
|
| 617 |
+
f"- overall: {qr.metrics.overall} | SMART: {qr.metrics.smart_score} | INVERT: {qr.metrics.invert_score} | GQM: {qr.metrics.gqm_score}"
|
| 618 |
+
)
|
| 619 |
+
if qr.defects:
|
| 620 |
+
md.append("**Дефекты:**")
|
| 621 |
+
for d in qr.defects:
|
| 622 |
+
spans = f" (спаны: {', '.join(d.spans)})" if d.spans else ""
|
| 623 |
+
md.append(f"- [{d.severity}] {d.code}: {d.message}{spans}")
|
| 624 |
+
md.append("**Что исправлено:**")
|
| 625 |
+
if rr.fixed_defects:
|
| 626 |
+
for c in rr.fixed_defects:
|
| 627 |
+
md.append(f"- {c}")
|
| 628 |
+
else:
|
| 629 |
+
md.append("- (ничего)")
|
| 630 |
+
md.append(f"**Пояснение:** {rr.explanation}\n")
|
| 631 |
+
|
| 632 |
+
if fixed_diagrams.strip():
|
| 633 |
+
md.append("## 2) Диаграммы (PlantUML) с исправлениями\n")
|
| 634 |
+
md.append("```plantuml")
|
| 635 |
+
md.append(fixed_diagrams.strip())
|
| 636 |
+
md.append("```\n")
|
| 637 |
+
|
| 638 |
+
md.append("## 3) Список изменений\n")
|
| 639 |
+
if changes:
|
| 640 |
+
for c in changes:
|
| 641 |
+
md.append(f"- {c}")
|
| 642 |
+
else:
|
| 643 |
+
md.append("- (изменений не зафиксировано)")
|
| 644 |
+
|
| 645 |
+
md.append("\n## 4) Советы\n")
|
| 646 |
+
uniq_tips = []
|
| 647 |
+
for t in tips:
|
| 648 |
+
if t not in uniq_tips:
|
| 649 |
+
uniq_tips.append(t)
|
| 650 |
+
|
| 651 |
+
if uniq_tips:
|
| 652 |
+
for t in uniq_tips[:20]:
|
| 653 |
+
md.append(f"- {t}")
|
| 654 |
+
else:
|
| 655 |
+
md.append("- Требования выглядят неплохо. Если это финальная версия, добавь тестируемые критерии приемки там, где это важно.")
|
| 656 |
+
|
| 657 |
+
md.append("\n---\n")
|
| 658 |
+
md.append("_Примечание: метрики INVERT/SMART/GQM здесь реализованы в упрощённом виде (практическая эвристика), "
|
| 659 |
+
"а улучшение текста делает LLM-агент по списку дефектов._")
|
| 660 |
+
|
| 661 |
+
return "\n".join(md)
|
| 662 |
+
|
| 663 |
+
|
| 664 |
+
# =======================
|
| 665 |
+
# Gradio UI
|
| 666 |
+
# =======================
|
| 667 |
+
|
| 668 |
+
def gradio_run(
|
| 669 |
+
srs_text: str,
|
| 670 |
+
plantuml_text: str,
|
| 671 |
+
model_id: str,
|
| 672 |
+
):
|
| 673 |
+
orch = Orchestrator(model_id=model_id)
|
| 674 |
+
res = orch.run(srs_text or "", plantuml_text or "")
|
| 675 |
+
return res["final_md"], res["normalized_json"], res["quality_json"], res["refactor_json"]
|
| 676 |
+
|
| 677 |
+
|
| 678 |
+
with gr.Blocks() as demo:
|
| 679 |
+
gr.Markdown(
|
| 680 |
+
"""
|
| 681 |
+
# 🧩 Multi-Agent SRS Refiner (Orchestrator + Analysis + Quality + Refactor)
|
| 682 |
+
Вставь SRS (требования) и (опционально) PlantUML — получишь:
|
| 683 |
+
- улучшенные формулировки требований
|
| 684 |
+
- метрики качества и дефекты
|
| 685 |
+
- (опционально) исправленные PlantUML блоки
|
| 686 |
+
- список изменений и советы
|
| 687 |
+
"""
|
| 688 |
+
)
|
| 689 |
+
|
| 690 |
+
with gr.Row():
|
| 691 |
+
with gr.Column(scale=2):
|
| 692 |
+
srs_input = gr.Textbox(
|
| 693 |
+
label="SRS / Требования (текст)",
|
| 694 |
+
placeholder="Вставь требования списком (по строкам) или цельным текстом.",
|
| 695 |
+
lines=14,
|
| 696 |
+
)
|
| 697 |
+
plantuml_input = gr.Textbox(
|
| 698 |
+
label="PlantUML (опционально)",
|
| 699 |
+
placeholder="@startuml ... @enduml (можно несколько блоков)",
|
| 700 |
+
lines=10,
|
| 701 |
+
)
|
| 702 |
+
|
| 703 |
+
with gr.Column(scale=1):
|
| 704 |
+
model_id_input = gr.Textbox(
|
| 705 |
+
label="Ollama Cloud модель",
|
| 706 |
+
value="gpt-oss:120b",
|
| 707 |
+
placeholder="Например: gpt-oss:120b",
|
| 708 |
+
)
|
| 709 |
+
run_btn = gr.Button("Запустить оркестратора")
|
| 710 |
+
|
| 711 |
+
out_md = gr.Markdown(label="Результат")
|
| 712 |
+
with gr.Accordion("Debug: Нормализованные требования (JSON)", open=False):
|
| 713 |
+
out_norm = gr.Code(language="json")
|
| 714 |
+
with gr.Accordion("Debug: Оценка качества (JSON)", open=False):
|
| 715 |
+
out_quality = gr.Code(language="json")
|
| 716 |
+
with gr.Accordion("Debug: Рефакторинг (JSON)", open=False):
|
| 717 |
+
out_ref = gr.Code(language="json")
|
| 718 |
+
|
| 719 |
+
run_btn.click(
|
| 720 |
+
fn=gradio_run,
|
| 721 |
+
inputs=[srs_input, plantuml_input, model_id_input],
|
| 722 |
+
outputs=[out_md, out_norm, out_quality, out_ref],
|
| 723 |
+
)
|
| 724 |
+
|
| 725 |
+
if __name__ == "__main__":
|
| 726 |
+
demo.launch()
|