AlsuGibadullina commited on
Commit
8d57631
·
verified ·
1 Parent(s): 926b50e

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +726 -0
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()